diff --git a/resources/calib/temperature_tower/belt_temp_provino_unit.stl b/resources/calib/temperature_tower/belt_temp_provino_unit.stl new file mode 100644 index 0000000000..556cf313d2 Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_provino_unit.stl differ diff --git a/resources/calib/temperature_tower/belt_temp_tower_230_190.stl b/resources/calib/temperature_tower/belt_temp_tower_230_190.stl new file mode 100644 index 0000000000..0496fd1522 Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_tower_230_190.stl differ diff --git a/resources/calib/temperature_tower/belt_temp_tower_240_210.stl b/resources/calib/temperature_tower/belt_temp_tower_240_210.stl new file mode 100644 index 0000000000..31801c1744 Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_tower_240_210.stl differ diff --git a/resources/calib/temperature_tower/belt_temp_tower_250_230.stl b/resources/calib/temperature_tower/belt_temp_tower_250_230.stl new file mode 100644 index 0000000000..b03f057884 Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_tower_250_230.stl differ diff --git a/resources/calib/temperature_tower/belt_temp_tower_270_230.stl b/resources/calib/temperature_tower/belt_temp_tower_270_230.stl new file mode 100644 index 0000000000..7a0bfbd1da Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_tower_270_230.stl differ diff --git a/resources/calib/temperature_tower/belt_temp_tower_280_240.stl b/resources/calib/temperature_tower/belt_temp_tower_280_240.stl new file mode 100644 index 0000000000..1276d5ce04 Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_tower_280_240.stl differ diff --git a/resources/calib/temperature_tower/belt_temp_tower_320_280.stl b/resources/calib/temperature_tower/belt_temp_tower_320_280.stl new file mode 100644 index 0000000000..957e038067 Binary files /dev/null and b/resources/calib/temperature_tower/belt_temp_tower_320_280.stl differ diff --git a/resources/calib/temperature_tower/gen_belt_temp_tower.py b/resources/calib/temperature_tower/gen_belt_temp_tower.py new file mode 100644 index 0000000000..9182647acb --- /dev/null +++ b/resources/calib/temperature_tower/gen_belt_temp_tower.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Belt temperature-tower asset generator (discrete-provini design). + +A vertical temperature tower cannot be sliced on a belt printer, so lay a row of +DISCRETE provini (one per temperature) along the belt (designed Y) with a fixed +surface gap. Each provino is the chevron+arc unit (belt_temp_provino_unit.stl, +keel-first); its temperature is ENGRAVED upright into the 50 mm face — a raised +number would be an unsupported overhang on the belt. The C++ calib_temp belt branch +(Plater.cpp) injects one M104 per zone 70 layers INTO provino i: + print_z[i] = i * PITCH * cos(theta) + 70 * layer_height (theta = 45) +inside the body, not in the empty inter-provino gap (which has no sliced layers for +the event to attach to). PITCH below is the shared geometry contract with that code — +keep them in sync. + +Generates one STL per filament temp range used by Temp_Calibration_Dlg. +""" +import numpy as np, trimesh, os +from matplotlib.textpath import TextPath +from matplotlib.font_manager import FontProperties +from shapely.geometry import Polygon as ShPoly +from shapely.ops import unary_union + +HERE = os.path.dirname(os.path.abspath(__file__)) +UNIT = os.path.join(HERE, 'belt_temp_provino_unit.stl') # single provino, keel-first +SURF_GAP = 25.0 # surface-to-surface gap between provini (mm) — user spec +TEXT_H = 9.0 +TEXT_DEPTH = 0.8 # engraving depth (numbers are CUT into the face, not raised: + # a raised number is an unsupported Y-overhang on the belt) +TEXT_OVERSHOOT = 0.6 # extra height poking out of the face for a clean boolean cut + +# Temperature ranges (start, end) per filament family, 5 C step. File name encodes them. +RANGES = [(230,190),(270,230),(250,230),(280,240),(240,210),(320,280)] + +unit = trimesh.load(UNIT) +dY = unit.bounds[1,1] - unit.bounds[0,1] +PITCH = dY + SURF_GAP # designed-Y pitch == C++ contract constant +print(f"unit dY={dY:.2f} PITCH={PITCH:.3f} (C++ contract: print_z[i]=i*{PITCH:.3f}*cos45)") + +# 50 mm face normal (0,-1,1)/sqrt2 ; UPRIGHT basis u=+X det(+1) (verified non-mirrored) +n = np.array([0,-1,1.])/np.sqrt(2) +u = np.array([1,0,0.]); v = np.array([0,1,1.])/np.sqrt(2) +R = np.column_stack([u,v,n]) +fn = unit.face_normals; fc = unit.triangles_center; fa = unit.area_faces +sel = (fn@n) > 0.9 +face_c = (fc[sel]*fa[sel,None]).sum(0)/fa[sel].sum() + +def text_mesh(s): + tp = TextPath((0,0), s, size=TEXT_H, prop=FontProperties(family='DejaVu Sans')) + rings = [ShPoly(p) for p in tp.to_polygons() if len(p)>=3] + rings.sort(key=lambda r:r.area, reverse=True) + used=[False]*len(rings); parts=[] + for i,o in enumerate(rings): + if used[i]: continue + holes=[] + for j in range(i+1,len(rings)): + if not used[j] and o.contains(rings[j]): holes.append(rings[j].exterior.coords); used[j]=True + parts.append(ShPoly(o.exterior.coords,holes)); used[i]=True + poly = unary_union(parts) + geoms = list(poly.geoms) if poly.geom_type=='MultiPolygon' else [poly] + m = trimesh.util.concatenate([trimesh.creation.extrude_polygon(g,height=TEXT_DEPTH+TEXT_OVERSHOOT) for g in geoms]) + c = m.bounds.mean(axis=0); m.apply_translation([-c[0],-c[1],0]); return m + +for t_start, t_end in RANGES: + temps = list(range(t_start, t_end-1, -5)) + parts=[] + for i,T in enumerate(temps): + c = unit.copy(); c.apply_translation([0, i*PITCH, 0]) + t = text_mesh(str(T)); M=np.eye(4); M[:3,:3]=R; t.apply_transform(M) + # place the text spanning from TEXT_DEPTH inside the face to TEXT_OVERSHOOT outside, + # then CUT it out of the provino (engrave) — no raised material, no Y-overhang. + t.apply_translation(face_c - n*TEXT_DEPTH + np.array([0,i*PITCH,0])) + c = trimesh.boolean.difference([c, t], engine='manifold') + parts.append(c) + asset = trimesh.util.concatenate(parts) + out = os.path.join(HERE, f"belt_temp_tower_{t_start}_{t_end}.stl") + asset.export(out) + dims = np.round(asset.bounds[1]-asset.bounds[0],1) + wt = all(p.is_watertight for p in parts) + print(f" {t_start}->{t_end}: {len(temps)} zones bbox={dims} watertight={wt} -> {os.path.basename(out)}") diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 6bc6a30f0f..58d0ccb237 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -13297,14 +13297,96 @@ void Plater::calib_temp(const Calib_Params& params) { if (params.mode != CalibMode::Calib_Temp_Tower) return; - // ORCA-Belt: a counter-rotated tower would cantilever every block off a - // support wedge — print the temperature blocks as separate objects in - // native belt orientation instead. + // Belt printers build along the conveyor, not vertically — a tall tower cannot + // be sliced. Instead lay a row of DISCRETE provini (one per temperature) along + // the belt and change temperature in discrete steps via custom per-layer G-code + // (M104), injected in the empty gap just before each provino so the nozzle is + // settled by the time that provino prints. We deliberately do NOT call + // set_calib_params here: its per-layer interpolation (interpolate_value_across_ + // layers) would overwrite these discrete M104 events. { - double belt_angle_rad = 0.; - Vec3d belt_axis = Vec3d::UnitX(); - if (belt_calib_rotation_params(belt_angle_rad, belt_axis)) { - _calib_temp_belt_sectioned(params, std::abs(belt_angle_rad)); + auto belt_printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config; + if (belt_printer_config->has("belt_printer") && belt_printer_config->opt_bool("belt_printer")) { + // Belt temperature-tower model selector (Calib_Params::test_model): + // 0 = "Standard" -> Joe's counter-rotated sectioned tower. + // 1 = "Overhang" -> engraved inverted-L provini that stress overhang + // print-quality at each discrete temperature (below). + if (params.test_model < 1) { + double belt_angle_rad = 0.; Vec3d belt_axis = Vec3d::UnitX(); + belt_calib_rotation_params(belt_angle_rad, belt_axis); + _calib_temp_belt_sectioned(params, std::abs(belt_angle_rad)); + return; + } + constexpr int TEMP_STEP = 5; // matches Temp_Calibration_Dlg + // Shared geometry contract with the offline asset generator + // (resources/calib/temperature_tower/gen_belt_temp_tower.py): provini are + // replicated along the belt (designed Y) at this pitch. The slicing plane + // is oblique (belt_slice_rotation_angle), so the per-zone advance in the + // layer print_z space the custom-gcode matcher uses is PITCH*cos(theta). + constexpr double PITCH_Y = 74.718; // designed-Y pitch == gen PITCH + const double angle = belt_printer_config->has("belt_slice_rotation_angle") + ? belt_printer_config->opt_float("belt_slice_rotation_angle") : 45.0; + const double zone_topz = PITCH_Y * std::cos(angle * M_PI / 180.0); + // The custom-gcode matcher attaches each event to a real sliced layer. The + // empty inter-provino gap has NO layers, so an event placed there is silently + // dropped. Fire it 70 layers ABOVE provino i's start instead — inside the + // provino body, past the gap and the overlap with provino i-1's tail, so the + // temperature change attaches and applies cleanly. (layer print_z steps by the + // process layer height.) + auto belt_print_config = &wxGetApp().preset_bundle->prints.get_edited_preset().config; + const double layer_h = belt_print_config->has("layer_height") + ? belt_print_config->opt_float("layer_height") : 0.2; + const double into_provino = 70.0 * layer_h; + + const int t_start = (int) lround(params.start); + const int t_end = (int) lround(params.end); + std::vector temps; + const int tstep = (t_start >= t_end) ? -TEMP_STEP : TEMP_STEP; + for (int t = t_start; (tstep < 0) ? (t >= t_end) : (t <= t_end); t += tstep) + temps.push_back(t); + if (temps.empty()) temps.push_back(t_start); + + const std::string calib_dir = Slic3r::resources_dir() + "/calib/temperature_tower/"; + std::string asset = calib_dir + "belt_temp_tower_" + std::to_string(t_start) + "_" + std::to_string(t_end) + ".stl"; + if (!boost::filesystem::exists(asset)) { + BOOST_LOG_TRIVIAL(warning) << "[belt_temp] no embossed provini for " << t_start << "->" << t_end + << ", falling back to 230_190 (embossed numbers will not match)"; + asset = calib_dir + "belt_temp_tower_230_190.stl"; + } + add_model(false, asset); + + // Place keel-first asset at the belt entry (designed Y = 0) so Z_gcode + // starts at 0, centered laterally on the bed, resting on the conveyor. + ModelObject* obj = model().objects[0]; + obj->ensure_on_bed(); + BoundingBoxf3 obb = obj->bounding_box_exact(); + auto bed_shape = belt_printer_config->option("printable_area")->values; + BoundingBoxf bed_ext = get_extents(bed_shape); + obj->translate_instances(Vec3d(bed_ext.center().x() - obb.center().x(), -obb.min.y(), 0.0)); + + auto belt_filament_config = &wxGetApp().preset_bundle->filaments.get_edited_preset().config; + belt_filament_config->set_key_value("nozzle_temperature_initial_layer", new ConfigOptionInts(1, temps.front())); + belt_filament_config->set_key_value("nozzle_temperature", new ConfigOptionInts(1, temps.front())); + obj->config.set_key_value("brim_type", new ConfigOptionEnum(btNoBrim)); + obj->config.set_key_value("seam_slope_type", new ConfigOptionEnum(SeamScarfType::None)); + obj->config.set_key_value("overhang_reverse", new ConfigOptionBool(false)); + belt_printer_config->set_key_value("resonance_avoidance", new ConfigOptionBool{false}); + + const int plate_idx = get_partplate_list().get_curr_plate_index(); + model().curr_plate_index = plate_idx; + CustomGCode::Info &cg_info = model().plates_custom_gcodes[plate_idx]; + cg_info.mode = CustomGCode::Mode::SingleExtruder; + cg_info.gcodes.clear(); + for (size_t i = 1; i < temps.size(); ++i) { + const double pz = double(i) * zone_topz + into_provino; // 70 layers into provino i + cg_info.gcodes.push_back(CustomGCode::Item{ + pz, CustomGCode::Custom, 1, "", + "M104 S" + std::to_string(temps[i]) + " ; belt temp zone " + std::to_string(temps[i]) }); + } + + changed_objects({ 0 }); + wxGetApp().get_tab(Preset::TYPE_FILAMENT)->update_dirty(); + wxGetApp().get_tab(Preset::TYPE_FILAMENT)->reload_config(); return; } } diff --git a/src/slic3r/GUI/calib_dlg.cpp b/src/slic3r/GUI/calib_dlg.cpp index 870b078aa1..89bb4de3aa 100644 --- a/src/slic3r/GUI/calib_dlg.cpp +++ b/src/slic3r/GUI/calib_dlg.cpp @@ -395,6 +395,17 @@ Temp_Calibration_Dlg::Temp_Calibration_Dlg(wxWindow* parent, wxWindowID id, Plat method_box->Add(m_rbFilamentType, 0, wxALL | wxEXPAND, FromDIP(4)); v_sizer->Add(method_box, 0, wxTOP | wxRIGHT | wxLEFT | wxEXPAND, FromDIP(10)); + // Belt temperature-tower model: Standard (sectioned tower) vs Overhang (engraved + // inverted-L provini that stress overhang quality per temperature). Only affects + // belt printers; the upright tower ignores it, so the picker is only shown on + // belts. The dialog is cached across printer switches, so visibility is toggled + // per-show in on_show() rather than gated here at construction time. + auto labeled_box_model = new LabeledStaticBox(this, _L("Test model")); + m_model_box = new wxStaticBoxSizer(labeled_box_model, wxHORIZONTAL); + m_rbModel = new RadioGroup(this, { _L("Standard"), _L("Overhang") }, wxVERTICAL); + m_model_box->Add(m_rbModel, 0, wxALL | wxEXPAND, FromDIP(4)); + v_sizer->Add(m_model_box, 0, wxTOP | wxRIGHT | wxLEFT | wxEXPAND, FromDIP(10)); + // Settings wxString start_temp_str = _L("Start temp: "); wxString end_temp_str = _L("End temp: "); @@ -455,6 +466,11 @@ Temp_Calibration_Dlg::Temp_Calibration_Dlg(wxWindow* parent, wxWindowID id, Plat m_rbFilamentType->Connect(wxEVT_COMMAND_RADIOBOX_SELECTED, wxCommandEventHandler(Temp_Calibration_Dlg::on_filament_type_changed), NULL, this); + // Refresh the belt-only model picker on every show — the dialog is cached and + // reused across printer switches. + this->Connect(wxEVT_SHOW, wxShowEventHandler(Temp_Calibration_Dlg::on_show)); + m_model_box->ShowItems(is_belt_printer_selected()); + wxGetApp().UpdateDlgDarkUI(this); Layout(); @@ -511,11 +527,27 @@ void Temp_Calibration_Dlg::on_start(wxCommandEvent& event) { m_params.start = start; m_params.end = end; m_params.mode = CalibMode::Calib_Temp_Tower; + // Picker only exists on belt printers; default non-belt to the Standard model. + m_params.test_model = m_rbModel ? m_rbModel->GetSelection() : 0; m_plater->calib_temp(m_params); EndModal(wxID_OK); } +void Temp_Calibration_Dlg::on_show(wxShowEvent& event) { + // ORCA-Belt: the dialog is cached across printer switches, so refresh the + // belt-only "Test model" picker on every show. The Overhang model only + // applies to belt printers; hide it (and resize the dialog) otherwise. + const bool belt = is_belt_printer_selected(); + if (m_model_box->AreAnyItemsShown() != belt) { + m_model_box->ShowItems(belt); + Layout(); + Fit(); + GetSizer()->SetSizeHints(this); + } + event.Skip(); +} + void Temp_Calibration_Dlg::on_filament_type_changed(wxCommandEvent& event) { int selection = event.GetSelection(); unsigned long start = 0, end = 0; diff --git a/src/slic3r/GUI/calib_dlg.hpp b/src/slic3r/GUI/calib_dlg.hpp index 5ff0fbf3e3..2ddf056dc6 100644 --- a/src/slic3r/GUI/calib_dlg.hpp +++ b/src/slic3r/GUI/calib_dlg.hpp @@ -59,9 +59,12 @@ protected: virtual void on_start(wxCommandEvent& event); virtual void on_filament_type_changed(wxCommandEvent& event); + void on_show(wxShowEvent& event); Calib_Params m_params; RadioGroup* m_rbFilamentType; + RadioGroup* m_rbModel = nullptr; + wxStaticBoxSizer* m_model_box = nullptr; TextInput* m_tiStart; TextInput* m_tiEnd; TextInput* m_tiStep;