feat(belt/calib): add Overhang temperature-tower model (selectable) (#48)

Belt printers can't slice a tall vertical temperature tower. This adds a
belt-specific temperature-tower model — a row of discrete, individually
engraved provini laid along the belt, each printed at one temperature via
custom per-layer M104. Each provino is an inverted-L overhang that stresses
print quality, so the operator reads the best temperature off overhang
quality rather than a continuous ramp.

It is offered as a "Test model" choice in the temperature calibration dialog
(mirroring the Cornering test's selector), so users keep Joe's counter-rotated
sectioned tower as "Standard" and can pick this one as "Overhang":
- Calib_Params::test_model (existing field) carries the choice.
- Temp_Calibration_Dlg gets a Standard/Overhang radio.
- Plater::calib_temp belt branch: test_model 0 -> _calib_temp_belt_sectioned
  (unchanged Standard path), 1 -> the discrete-provini Overhang path.

Assets: belt_temp_provino_unit.stl + belt_temp_tower_<start>_<end>.stl (6
ranges) + gen_belt_temp_tower.py (manifold engraving). Based on
belt/generic-calibrations. The Overhang path is HW-validated on the IdeaFormer
IR3 V2 (discrete M104 + engraved numbers); not re-validated since the rebase.
This commit is contained in:
Tommaso Bianchi
2026-06-12 10:17:23 +02:00
committed by harrierpigeon
parent 0da24cd38b
commit 85fd613cf7
11 changed files with 179 additions and 7 deletions

View File

@@ -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)}")

View File

@@ -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<int> 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<ConfigOptionPoints>("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<BrimType>(btNoBrim));
obj->config.set_key_value("seam_slope_type", new ConfigOptionEnum<SeamScarfType>(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;
}
}

View File

@@ -395,6 +395,15 @@ 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.
auto labeled_box_model = new LabeledStaticBox(this, _L("Test model"));
auto model_box = new wxStaticBoxSizer(labeled_box_model, wxHORIZONTAL);
m_rbModel = new RadioGroup(this, { _L("Standard"), _L("Overhang") }, wxVERTICAL);
model_box->Add(m_rbModel, 0, wxALL | wxEXPAND, FromDIP(4));
v_sizer->Add(model_box, 0, wxTOP | wxRIGHT | wxLEFT | wxEXPAND, FromDIP(10));
// Settings
wxString start_temp_str = _L("Start temp: ");
wxString end_temp_str = _L("End temp: ");
@@ -511,6 +520,7 @@ void Temp_Calibration_Dlg::on_start(wxCommandEvent& event) {
m_params.start = start;
m_params.end = end;
m_params.mode = CalibMode::Calib_Temp_Tower;
m_params.test_model = m_rbModel->GetSelection();
m_plater->calib_temp(m_params);
EndModal(wxID_OK);

View File

@@ -62,6 +62,7 @@ protected:
Calib_Params m_params;
RadioGroup* m_rbFilamentType;
RadioGroup* m_rbModel;
TextInput* m_tiStart;
TextInput* m_tiEnd;
TextInput* m_tiStep;