mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-16 05:27:50 +00:00
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:
committed by
harrierpigeon
parent
0da24cd38b
commit
85fd613cf7
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)}")
|
||||||
@@ -13297,14 +13297,96 @@ void Plater::calib_temp(const Calib_Params& params) {
|
|||||||
if (params.mode != CalibMode::Calib_Temp_Tower)
|
if (params.mode != CalibMode::Calib_Temp_Tower)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// ORCA-Belt: a counter-rotated tower would cantilever every block off a
|
// Belt printers build along the conveyor, not vertically — a tall tower cannot
|
||||||
// support wedge — print the temperature blocks as separate objects in
|
// be sliced. Instead lay a row of DISCRETE provini (one per temperature) along
|
||||||
// native belt orientation instead.
|
// 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.;
|
auto belt_printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config;
|
||||||
Vec3d belt_axis = Vec3d::UnitX();
|
if (belt_printer_config->has("belt_printer") && belt_printer_config->opt_bool("belt_printer")) {
|
||||||
if (belt_calib_rotation_params(belt_angle_rad, belt_axis)) {
|
// Belt temperature-tower model selector (Calib_Params::test_model):
|
||||||
_calib_temp_belt_sectioned(params, std::abs(belt_angle_rad));
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
method_box->Add(m_rbFilamentType, 0, wxALL | wxEXPAND, FromDIP(4));
|
||||||
v_sizer->Add(method_box, 0, wxTOP | wxRIGHT | wxLEFT | wxEXPAND, FromDIP(10));
|
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
|
// Settings
|
||||||
wxString start_temp_str = _L("Start temp: ");
|
wxString start_temp_str = _L("Start temp: ");
|
||||||
wxString end_temp_str = _L("End 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.start = start;
|
||||||
m_params.end = end;
|
m_params.end = end;
|
||||||
m_params.mode = CalibMode::Calib_Temp_Tower;
|
m_params.mode = CalibMode::Calib_Temp_Tower;
|
||||||
|
m_params.test_model = m_rbModel->GetSelection();
|
||||||
m_plater->calib_temp(m_params);
|
m_plater->calib_temp(m_params);
|
||||||
EndModal(wxID_OK);
|
EndModal(wxID_OK);
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ protected:
|
|||||||
Calib_Params m_params;
|
Calib_Params m_params;
|
||||||
|
|
||||||
RadioGroup* m_rbFilamentType;
|
RadioGroup* m_rbFilamentType;
|
||||||
|
RadioGroup* m_rbModel;
|
||||||
TextInput* m_tiStart;
|
TextInput* m_tiStart;
|
||||||
TextInput* m_tiEnd;
|
TextInput* m_tiEnd;
|
||||||
TextInput* m_tiStep;
|
TextInput* m_tiStep;
|
||||||
|
|||||||
Reference in New Issue
Block a user