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

@@ -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;