mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-10 17:21:10 +00:00
Have the preview consume the shared zone layout instead of rebuilding it
Closes review comment 17. GCodeViewer had its own copy of the flip_x/flip_y corner mapping, the active column/row sets, the physical-to-zone index mapping and the zone pitch -- the same derivation as the plate's, with nothing keeping the two in step. It now calls compute_imex_zone_layout() and consumes head_zone_centers. The mirror is expressed as a reflection about the midpoint of the two zone centres rather than about a zone-relative strip width, which is algebraically identical for equal-sized zones and needs no pitch, and the toolhead-box face is chosen by comparing zone centres instead of physical columns. The July report of a math error in the visualizer for non-primary heads was this drift: the sec_box_offset_y else-branch hardcoded -imex_box_wy, which happened to equal the primary's offset on the rear-* layouts and pointed the wrong way on the front-* ones. Structurally unreachable now. Verifying the two sides matched turned up two config defaults that disagreed, both fixed in their own commits: imex_nozzle_clearance_x/y (the viewer's 30.0f matched PrintConfig, the zone code's 0.0 did not, and the strip loops are gated on it) and imex_tools_per_gantry (the library's 2 matched, both GUI paths used 1). Consuming the shared function meant resolving it per frame, and the sequential-view marker flag is sticky, so one drag of the slider made every subsequent frame parse five strings and allocate a dozen containers from inputs that never change. The resolve now sits behind a cache key mirroring PartPlate::build_imex_cache_key(), plus the two inputs only the preview reads -- the bed extents and the tool layout. An idle frame compares scalars and allocates nothing. The toolhead-box mesh, which was being re-uploaded to the GPU every frame for the same reason, is rebuilt only when the clearances change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
838c2f2df8
commit
1e608fe24c
+320
-275
@@ -11,6 +11,7 @@
|
||||
#include "libslic3r/LocalesUtils.hpp"
|
||||
#include "libslic3r/PresetBundle.hpp"
|
||||
#include "libslic3r/IMEXHelpers.hpp"
|
||||
#include "libslic3r/IMEXZones.hpp"
|
||||
//BBS: add convex hull logic for toolpath check
|
||||
#include "libslic3r/Geometry/ConvexHull.hpp"
|
||||
|
||||
@@ -1606,6 +1607,228 @@ void GCodeViewer::reset()
|
||||
m_contained_in_bed = true;
|
||||
}
|
||||
|
||||
// IDEX/IQEX: resolve where every secondary carriage marker sits, relative to the primary,
|
||||
// for one (printer preset, active mode, plate bed) triple. This parses the mode string
|
||||
// several times over and rebuilds the zone grid, and every one of its inputs is preset or
|
||||
// plate state, so render() runs it only when GCodeViewer::ImexMarkerKey changes and replays
|
||||
// the returned plan on all the frames in between.
|
||||
//
|
||||
// `mode` is already resolved (the plate's mode beats the process preset). An empty
|
||||
// `carriages` list means "no secondary markers for this configuration" — IMEX off in this
|
||||
// mode, firmware-managed zones, or a mode whose roster has no zone-owning secondary.
|
||||
GCodeViewer::ImexMarkerPlan GCodeViewer::resolve_imex_marker_plan(const DynamicPrintConfig& printer_cfg,
|
||||
const std::string& mode,
|
||||
const BoundingBoxf& bed_extents)
|
||||
{
|
||||
ImexMarkerPlan plan;
|
||||
|
||||
// Firmware-managed-zones centers the slice at bed origin, so prim_pos
|
||||
// and the preview toolpaths sit in a shifted frame relative to the
|
||||
// plate-local bed bounds the zone math uses. Computing secondaries here
|
||||
// would place them off-bed. The firmware physically fans the centered
|
||||
// toolpath out into the zones, so the centered preview with no
|
||||
// secondaries is the honest representation.
|
||||
auto* fw_opt = printer_cfg.opt<ConfigOptionBool>("imex_firmware_managed_zones");
|
||||
if (fw_opt && fw_opt->value)
|
||||
return plan;
|
||||
if (mode.empty() || mode == kImexPrimaryMode)
|
||||
return plan;
|
||||
|
||||
auto* tpg_opt = printer_cfg.opt<ConfigOptionInt>("imex_tools_per_gantry");
|
||||
auto* wx_opt = printer_cfg.opt<ConfigOptionFloat>("imex_nozzle_clearance_x");
|
||||
auto* wy_opt = printer_cfg.opt<ConfigOptionFloat>("imex_nozzle_clearance_y");
|
||||
// Only the gantry grouping and the mirror axis are decided here; the zone
|
||||
// grid itself comes from compute_imex_zone_layout() below. The fallback is 2,
|
||||
// matching PrintConfig.cpp:6643, compute_imex_zone_layout() and
|
||||
// PartPlate::calc_imex_ghosts() -- all four must agree, or a missing key
|
||||
// groups tools against a grid divided a different way.
|
||||
const int tools_per_gantry = tpg_opt ? std::max(1, tpg_opt->value) : 2;
|
||||
plan.box_wx = wx_opt ? (float)wx_opt->value : 30.0f;
|
||||
plan.box_wy = wy_opt ? (float)wy_opt->value : 30.0f;
|
||||
|
||||
// Parse "idx:P/C/M" via shared helpers — matches PartPlate::calc_imex_zones.
|
||||
// The role travels as an ImexRole all the way to the marker placement
|
||||
// below; there is no int encoding in between, so a role added to the enum
|
||||
// cannot silently fall into the "not Mirror, therefore Copy" branch.
|
||||
int pri_tool = -1;
|
||||
std::vector<int> sec_tool_ids;
|
||||
std::map<int, ImexRole> sec_tool_roles; // tool_id -> Copy or Mirror
|
||||
std::set<int> sec_aggregated; // representatives standing in for a whole gantry
|
||||
{
|
||||
// Same lookup rule as PartPlate's zones and ghosts, and as the slicer:
|
||||
// an unresolved mode, and a mode the tools array is too short to cover,
|
||||
// both come back empty and leave the marker roster empty.
|
||||
const std::string entry = find_imex_mode(printer_cfg, mode).active_tools;
|
||||
pri_tool = imex_primary_tool_for_mode(entry);
|
||||
// Aggregate per gantry via the same source of truth as PartPlate's
|
||||
// zone/ghost aggregation: in Span modes only one tool prints per
|
||||
// zone at a time, so a non-primary gantry collapses to one
|
||||
// representative marker. Non-Span gantries keep per-tool markers.
|
||||
const ImexGantryGrouping grouping =
|
||||
group_imex_active_tools_by_gantry(entry, tools_per_gantry);
|
||||
auto add_tool = [&](int phys_idx, ImexRole role, bool aggregated) {
|
||||
if (phys_idx < 0 || phys_idx == pri_tool) return;
|
||||
// Only Copy and Mirror print in a zone of their own, so only they
|
||||
// get a marker. Exhaustive with no default so -Wswitch makes a new
|
||||
// role answer "does this carriage get its own marker?" rather than
|
||||
// defaulting into a Copy-shaped one.
|
||||
switch (role) {
|
||||
case ImexRole::Copy:
|
||||
case ImexRole::Mirror:
|
||||
break;
|
||||
case ImexRole::Primary: // not a secondary carriage
|
||||
case ImexRole::Span: // rides in the primary's zone
|
||||
return;
|
||||
}
|
||||
sec_tool_roles[phys_idx] = role;
|
||||
sec_tool_ids.push_back(phys_idx);
|
||||
if (aggregated) sec_aggregated.insert(phys_idx);
|
||||
};
|
||||
// Out-of-grid tool indices (a stale mode string carried over from a
|
||||
// printer with more tools) are handled inconsistently by the code we
|
||||
// must agree with: compute_imex_zone_layout drops them, so they own no
|
||||
// zone, while calc_imex_ghosts still bakes a ghost for them. No single
|
||||
// policy matches both, so the marker roster is left exactly as it was
|
||||
// and such a tool simply falls back to the first zone below. Clamping
|
||||
// pri_tool instead is a trap: the -1 sentinel truncates to gantry 0 in
|
||||
// imex_mirror_axis_for and flips the marker to the opposite mirror
|
||||
// axis from the ghost. It is also why the layout's own `primary_head`
|
||||
// cannot stand in for `pri_tool` here — the layout reports -1 for an
|
||||
// off-grid primary, this roster has to keep the authored index.
|
||||
for (const auto& grp : grouping.groups) {
|
||||
// Markers: an aggregated non-primary gantry shows only its rep.
|
||||
if (grp.aggregate && grp.gantry_index != grouping.primary_gantry)
|
||||
add_tool(grp.representative_phys, grp.representative_role, true);
|
||||
else
|
||||
for (const auto& [phys_idx, role] : grp.tools)
|
||||
add_tool(phys_idx, role, false);
|
||||
}
|
||||
}
|
||||
const int sec_count = (int)sec_tool_ids.size();
|
||||
if (sec_count == 0)
|
||||
return plan;
|
||||
|
||||
// The zone grid is not re-derived here: this is the same call
|
||||
// PartPlate::calc_imex_zones() makes, with the same printer config and the
|
||||
// same bed extents, so the preview and the plate cannot place a tool
|
||||
// differently for the same mode. The T0-corner flips, the active
|
||||
// column/row sets and the zone pitch all live inside the library; the
|
||||
// markers need nothing from it but `head_zone_centers`.
|
||||
// `mode` is already resolved (per-plate mode beats the process preset),
|
||||
// so it goes in as the plate mode with no process fallback.
|
||||
const ImexZoneLayout zone_layout =
|
||||
compute_imex_zone_layout(printer_cfg, mode, std::string(), bed_extents);
|
||||
|
||||
// Centre of zone (0,0), the fallback for any head the layout gave no zone —
|
||||
// a mode string naming a tool outside this printer's grid. Zone indices are
|
||||
// dense, so the lowest centre on each axis IS zone 0's. An empty layout
|
||||
// means one undivided zone, whose centre is the bed's.
|
||||
Vec2d zone0_center = bed_extents.center();
|
||||
if (!zone_layout.head_zone_centers.empty()) {
|
||||
zone0_center = zone_layout.head_zone_centers.begin()->second;
|
||||
for (const auto& head_center : zone_layout.head_zone_centers) {
|
||||
zone0_center.x() = std::min(zone0_center.x(), head_center.second.x());
|
||||
zone0_center.y() = std::min(zone0_center.y(), head_center.second.y());
|
||||
}
|
||||
}
|
||||
auto zone_center_of = [&](int tid) -> Vec2d {
|
||||
auto it = zone_layout.head_zone_centers.find(tid);
|
||||
return (it == zone_layout.head_zone_centers.end()) ? zone0_center : it->second;
|
||||
};
|
||||
const Vec2d pri_center = zone_center_of(pri_tool);
|
||||
|
||||
// An aggregated gantry's zone is a full-X row strip with no column of its
|
||||
// own, so its marker tracks primary's X. calc_imex_ghosts() reaches the same
|
||||
// zero X offset by putting both frames on the bed centreline; for a single
|
||||
// marker, pinning the centre says it directly.
|
||||
auto sec_center_of = [&](int tid) -> Vec2d {
|
||||
Vec2d c = zone_center_of(tid);
|
||||
if (sec_aggregated.count(tid))
|
||||
c.x() = pri_center.x();
|
||||
return c;
|
||||
};
|
||||
|
||||
// Primary carriage box: seeded from whether primary holds the leftmost zone
|
||||
// column, then turned by any secondary sitting to one side of it.
|
||||
plan.pri_box_offset_x = (pri_center.x() <= zone0_center.x()) ? 0.0f : -plan.box_wx;
|
||||
plan.pri_box_offset_y = -plan.box_wy;
|
||||
for (int i = 0; i < sec_count; ++i) {
|
||||
const Vec2d sc = sec_center_of(sec_tool_ids[i]);
|
||||
if (sc.x() > pri_center.x()) { plan.pri_box_offset_x = 0.0f; }
|
||||
else if (sc.x() < pri_center.x()) { plan.pri_box_offset_x = -plan.box_wx; }
|
||||
if (sc.y() > pri_center.y()) { plan.pri_box_offset_y = 0.0f; }
|
||||
else if (sc.y() < pri_center.y()) { plan.pri_box_offset_y = -plan.box_wy; }
|
||||
}
|
||||
|
||||
plan.carriages.reserve(sec_count);
|
||||
for (int i = 0; i < sec_count; ++i) {
|
||||
const int sec_tool = sec_tool_ids[i];
|
||||
const Vec2d sec_center = sec_center_of(sec_tool);
|
||||
const ImexRole sec_role = sec_tool_roles[sec_tool];
|
||||
|
||||
// A carriage stays inside its own zone; a Mirror reflects its
|
||||
// zone-relative offset about that zone's centerline, on the axis of the
|
||||
// boundary it shares with primary — the same rule the ghosts use:
|
||||
// Copy → tracks primary on both axes.
|
||||
// Mirror, same gantry → reflect X (zones sit side by side).
|
||||
// Mirror, other gantry → reflect Y (zones sit front-to-back); X
|
||||
// tracks primary, since the part off that
|
||||
// gantry is a Y-reflection of the tool
|
||||
// directly behind it.
|
||||
// The axis comes from the shared helper, so the markers can never drift
|
||||
// out of step with the plate ghosts.
|
||||
const bool is_mirror = (sec_role == ImexRole::Mirror);
|
||||
const bool cross_gantry = imex_mirror_axis_for(pri_tool, sec_tool,
|
||||
tools_per_gantry) == ImexMirrorAxis::Y;
|
||||
|
||||
// Tracking an axis translates by the gap between the two zone centres.
|
||||
// Mirroring it reflects primary about the midpoint of those centres —
|
||||
// the zones being equal-sized, that midpoint is the boundary between
|
||||
// them, so the carriage still lands inside its own zone. Both are a
|
||||
// single term applied to the live primary position each frame:
|
||||
// `term - pos` when mirrored, `pos + term` when tracked.
|
||||
ImexMarkerPlan::Carriage carriage;
|
||||
carriage.mirror_x = is_mirror && !cross_gantry;
|
||||
carriage.mirror_y = is_mirror && cross_gantry;
|
||||
carriage.x_term = carriage.mirror_x ? (float)(sec_center.x() + pri_center.x())
|
||||
: (float)(sec_center.x() - pri_center.x());
|
||||
carriage.y_term = carriage.mirror_y ? (float)(sec_center.y() + pri_center.y())
|
||||
: (float)(sec_center.y() - pri_center.y());
|
||||
|
||||
// The box shows the side a toolhead could COLLIDE from, not merely which
|
||||
// way its body hangs. Tools sharing a gantry share an X rail and can
|
||||
// actually run into each other, and only in a same-gantry (X-axis) mirror
|
||||
// do they converge — so that is the one case where the secondary's box
|
||||
// flips to face the primary. Tools on different gantries cannot collide
|
||||
// in X at all, so a cross-gantry mirror keeps the primary's facing, the
|
||||
// same as a Copy. Do not "fix" this to follow carriage geometry: a box on
|
||||
// the far side would point away from the only tool it can hit.
|
||||
if (is_mirror && !cross_gantry) {
|
||||
if (sec_center.x() > pri_center.x()) carriage.box_offset_x = -plan.box_wx;
|
||||
else if (sec_center.x() < pri_center.x()) carriage.box_offset_x = 0.0f;
|
||||
else carriage.box_offset_x = plan.pri_box_offset_x;
|
||||
} else {
|
||||
carriage.box_offset_x = plan.pri_box_offset_x;
|
||||
}
|
||||
// Y follows the same collision rule: face the gantry you could hit. A tool
|
||||
// on ANOTHER row faces the primary's row; a tool on the primary's OWN row
|
||||
// shares its gantry and can only hit the same other gantry, so it faces
|
||||
// wherever the primary faces. The old `else` hardcoded -imex_box_wy, which
|
||||
// happens to equal pri_box_offset_y on the rear-* layouts (primary's row
|
||||
// sits above the others, so the loop above settles on -box_wy anyway)
|
||||
// — hence a no-op there. On the front-* layouts the primary flips to 0.0f
|
||||
// and the hardcoded value pointed the same-gantry secondary away from the
|
||||
// only tools it could run into.
|
||||
if (sec_center.y() > pri_center.y()) carriage.box_offset_y = -plan.box_wy;
|
||||
else if (sec_center.y() < pri_center.y()) carriage.box_offset_y = 0.0f;
|
||||
else carriage.box_offset_y = plan.pri_box_offset_y;
|
||||
|
||||
plan.carriages.push_back(carriage);
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
//BBS: GUI refactor: add canvas width and height
|
||||
void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin)
|
||||
{
|
||||
@@ -1658,291 +1881,109 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin)
|
||||
auto* mode_opt = process_cfg.opt<ConfigOptionString>("imex_parallel_mode");
|
||||
std::string mode = mode_opt ? mode_opt->value : kImexPrimaryMode;
|
||||
// Per-plate mode overrides the process preset.
|
||||
if (auto* plate = wxGetApp().plater()->get_partplate_list().get_curr_plate()) {
|
||||
std::string plate_mode = plate->get_imex_mode();
|
||||
PartPlateList& plate_list = wxGetApp().plater()->get_partplate_list();
|
||||
PartPlate* curr_plate = plate_list.get_curr_plate();
|
||||
if (curr_plate) {
|
||||
std::string plate_mode = curr_plate->get_imex_mode();
|
||||
if (plate_mode != kImexPrimaryMode)
|
||||
mode = plate_mode;
|
||||
}
|
||||
|
||||
if (mode != m_imex_last_mode) {
|
||||
m_sequential_view.m_imex_secondary_markers.clear();
|
||||
m_imex_last_mode = mode;
|
||||
// Bed X and Y bounds — read from the current plate's shape, which is
|
||||
// in world/GL coordinates (same space as curr_vertex.position), and is
|
||||
// the exact same source PartPlate::calc_imex_zones() hands to the library.
|
||||
BoundingBoxf bed_extents;
|
||||
{
|
||||
const Pointfs& plate_shape = curr_plate ? curr_plate->get_shape() : Pointfs{};
|
||||
if (!plate_shape.empty()) {
|
||||
bed_extents = get_extents(plate_shape);
|
||||
} else {
|
||||
// Fallback: use toolpath bounding box extent
|
||||
bed_extents = BoundingBoxf(Vec2d(m_paths_bounding_box.min.x(), m_paths_bounding_box.min.y()),
|
||||
Vec2d(m_paths_bounding_box.max.x(), m_paths_bounding_box.max.y()));
|
||||
}
|
||||
}
|
||||
|
||||
// Firmware-managed-zones centers the slice at bed origin, so prim_pos
|
||||
// and the preview toolpaths sit in a shifted frame relative to the
|
||||
// plate-local bed bounds the zone math uses. Computing secondaries here
|
||||
// would place them off-bed. The firmware physically fans the centered
|
||||
// toolpath out into the zones, so the centered preview with no
|
||||
// secondaries is the honest representation.
|
||||
auto* fw_opt = printer_cfg.opt<ConfigOptionBool>("imex_firmware_managed_zones");
|
||||
const bool firmware_managed = fw_opt && fw_opt->value;
|
||||
// Cache guard. The marker plan below is resolved from preset/plate state only,
|
||||
// so it is rebuilt when that state moves and reused otherwise — the sequential
|
||||
// slider is sticky, and re-resolving the zone layout on every frame of the rest
|
||||
// of the session showed up as a measurable per-frame cost. The comparison is
|
||||
// deliberately made against the live config rather than against a freshly built
|
||||
// key, so an unchanged frame allocates nothing at all.
|
||||
static const std::vector<std::string> s_no_strings;
|
||||
auto* gantry_opt = printer_cfg.opt<ConfigOptionInt>("imex_gantry_count");
|
||||
auto* tpg_opt = printer_cfg.opt<ConfigOptionInt>("imex_tools_per_gantry");
|
||||
auto* layout_opt = printer_cfg.opt<ConfigOptionEnum<ImexToolLayout>>("imex_tool_layout");
|
||||
auto* wx_opt = printer_cfg.opt<ConfigOptionFloat>("imex_nozzle_clearance_x");
|
||||
auto* wy_opt = printer_cfg.opt<ConfigOptionFloat>("imex_nozzle_clearance_y");
|
||||
auto* fw_opt = printer_cfg.opt<ConfigOptionBool>("imex_firmware_managed_zones");
|
||||
auto* names_opt = printer_cfg.opt<ConfigOptionStrings>("imex_mode_names");
|
||||
auto* tools_opt = printer_cfg.opt<ConfigOptionStrings>("imex_mode_active_tools");
|
||||
const std::vector<std::string>& mode_names = names_opt ? names_opt->values : s_no_strings;
|
||||
const std::vector<std::string>& active_tools = tools_opt ? tools_opt->values : s_no_strings;
|
||||
|
||||
if (!firmware_managed && !mode.empty() && mode != kImexPrimaryMode) {
|
||||
auto* mode_names_opt = printer_cfg.opt<ConfigOptionStrings>("imex_mode_names");
|
||||
auto* active_tools_opt = printer_cfg.opt<ConfigOptionStrings>("imex_mode_active_tools");
|
||||
ImexMarkerKey::Scalars key;
|
||||
key.plate_index = plate_list.get_curr_plate_index();
|
||||
key.gantry_count = gantry_opt ? gantry_opt->value : 0;
|
||||
key.tools_per_gantry = tpg_opt ? tpg_opt->value : 0;
|
||||
key.tool_layout = layout_opt ? (int)layout_opt->value : -1;
|
||||
key.clearance_x = wx_opt ? wx_opt->value : 0.0;
|
||||
key.clearance_y = wy_opt ? wy_opt->value : 0.0;
|
||||
key.firmware_managed = fw_opt && fw_opt->value;
|
||||
key.bed_min_x = bed_extents.min.x();
|
||||
key.bed_min_y = bed_extents.min.y();
|
||||
key.bed_max_x = bed_extents.max.x();
|
||||
key.bed_max_y = bed_extents.max.y();
|
||||
|
||||
auto* tpg_opt = printer_cfg.opt<ConfigOptionInt>("imex_tools_per_gantry");
|
||||
auto* gc_opt = printer_cfg.opt<ConfigOptionInt>("imex_gantry_count");
|
||||
auto* wx_opt = printer_cfg.opt<ConfigOptionFloat>("imex_nozzle_clearance_x");
|
||||
auto* wy_opt = printer_cfg.opt<ConfigOptionFloat>("imex_nozzle_clearance_y");
|
||||
int tools_per_gantry = tpg_opt ? std::max(1, tpg_opt->value) : 1;
|
||||
int gantry_count = gc_opt ? std::max(1, gc_opt->value) : 1;
|
||||
imex_box_wx = wx_opt ? (float)wx_opt->value : 30.0f;
|
||||
imex_box_wy = wy_opt ? (float)wy_opt->value : 30.0f;
|
||||
if (!(m_imex_marker_key.s == key) || m_imex_marker_key.mode != mode ||
|
||||
m_imex_marker_key.mode_names != mode_names ||
|
||||
m_imex_marker_key.mode_active_tools != active_tools) {
|
||||
m_imex_marker_key = ImexMarkerKey{ key, mode, mode_names, active_tools };
|
||||
m_imex_marker_plan = resolve_imex_marker_plan(printer_cfg, mode, bed_extents);
|
||||
// The carriage roster — and with it the marker count and colour order —
|
||||
// can change with any of those inputs, so drop the markers and let the
|
||||
// lazy init below rebuild them.
|
||||
m_sequential_view.m_imex_secondary_markers.clear();
|
||||
}
|
||||
|
||||
// Parse "idx:P/C/M" via shared helpers — matches PartPlate::calc_imex_zones.
|
||||
// Secondary state is 2=Copy / 3=Mirror to match the legacy encoding used
|
||||
// downstream for marker color selection.
|
||||
int pri_tool = -1;
|
||||
std::vector<int> sec_tool_ids;
|
||||
std::map<int,int> sec_tool_states; // tool_id -> 2=Copy, 3=Mirror
|
||||
std::set<int> sec_aggregated; // representatives standing in for a whole gantry
|
||||
// Which tools SIZE the grid is a different question from which tools get a
|
||||
// MARKER. calc_imex_zones sizes its grid from every Copy/Mirror tool — an
|
||||
// aggregated gantry's non-representatives still donate their column — while only
|
||||
// the representative contributes a cell. Track both sets, or the strips here come
|
||||
// out a different width than the ones painted on the plate and every marker
|
||||
// drifts from its ghost.
|
||||
std::vector<int> grid_tool_ids;
|
||||
if (mode_names_opt && active_tools_opt) {
|
||||
for (size_t i = 0; i < mode_names_opt->values.size(); ++i) {
|
||||
if (i < active_tools_opt->values.size() && mode_names_opt->values[i] == mode) {
|
||||
const std::string& entry = active_tools_opt->values[i];
|
||||
pri_tool = imex_primary_tool_for_mode(entry);
|
||||
// Aggregate per gantry via the same source of truth as PartPlate's
|
||||
// zone/ghost aggregation: in Span modes only one tool prints per
|
||||
// zone at a time, so a non-primary gantry collapses to one
|
||||
// representative marker. Non-Span gantries keep per-tool markers.
|
||||
const ImexGantryGrouping grouping =
|
||||
group_imex_active_tools_by_gantry(entry, tools_per_gantry);
|
||||
auto add_tool = [&](int phys_idx, ImexRole role, bool aggregated) {
|
||||
if (phys_idx < 0 || phys_idx == pri_tool) return;
|
||||
if (role == ImexRole::Mirror) sec_tool_states[phys_idx] = 3;
|
||||
else if (role == ImexRole::Copy) sec_tool_states[phys_idx] = 2;
|
||||
else return; // Primary / Span carry no secondary marker
|
||||
sec_tool_ids.push_back(phys_idx);
|
||||
if (aggregated) sec_aggregated.insert(phys_idx);
|
||||
};
|
||||
// Out-of-grid tool indices (a stale mode string carried over from a
|
||||
// printer with more tools) are handled inconsistently by the code we
|
||||
// must agree with: calc_imex_zones drops them, calc_imex_ghosts keeps
|
||||
// them. No single policy matches both, so bound only what has to
|
||||
// match the ZONE grid — the sizing set — and leave pri_tool and the
|
||||
// markers exactly as they were. Clamping pri_tool instead is a trap:
|
||||
// the -1 sentinel truncates to gantry 0 in imex_mirror_axis_for and
|
||||
// flips the marker to the opposite mirror axis from the ghost.
|
||||
const int grid_slots = tools_per_gantry * gantry_count;
|
||||
for (const auto& grp : grouping.groups) {
|
||||
// Grid sizing: every in-grid Copy/Mirror tool, aggregated or not —
|
||||
// matches calc_imex_zones' ac_set/ar_set exactly.
|
||||
for (const auto& [phys_idx, role] : grp.tools) {
|
||||
if (phys_idx < 0 || phys_idx >= grid_slots) continue;
|
||||
if (phys_idx == pri_tool) continue;
|
||||
if (role == ImexRole::Copy || role == ImexRole::Mirror)
|
||||
grid_tool_ids.push_back(phys_idx);
|
||||
}
|
||||
// Markers: an aggregated non-primary gantry shows only its rep.
|
||||
if (grp.aggregate && grp.gantry_index != grouping.primary_gantry)
|
||||
add_tool(grp.representative_phys, grp.representative_role, true);
|
||||
else
|
||||
for (const auto& [phys_idx, role] : grp.tools)
|
||||
add_tool(phys_idx, role, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
const ImexMarkerPlan& plan = m_imex_marker_plan;
|
||||
const int sec_count = (int)plan.carriages.size();
|
||||
if (sec_count > 0) {
|
||||
imex_active = true;
|
||||
imex_box_wx = plan.box_wx;
|
||||
imex_box_wy = plan.box_wy;
|
||||
|
||||
// Lazy init secondary nozzle markers
|
||||
if ((int)m_sequential_view.m_imex_secondary_markers.size() != sec_count) {
|
||||
m_sequential_view.m_imex_secondary_markers.resize(sec_count);
|
||||
for (int i = 0; i < sec_count; ++i) {
|
||||
m_sequential_view.m_imex_secondary_markers[i].init(m_marker_filename);
|
||||
m_sequential_view.m_imex_secondary_markers[i].set_color(
|
||||
s_carriage_colors[(i + 1) % s_carriage_colors.size()]);
|
||||
}
|
||||
}
|
||||
int sec_count = (int)sec_tool_ids.size();
|
||||
|
||||
if (sec_count > 0) {
|
||||
imex_active = true;
|
||||
const Vec3f prim_pos = libvgcode::convert(curr_vertex.position);
|
||||
carriage_box_draws.push_back({ prim_pos, s_carriage_colors[0],
|
||||
plan.pri_box_offset_x, plan.pri_box_offset_y });
|
||||
|
||||
// Lazy init secondary nozzle markers
|
||||
if ((int)m_sequential_view.m_imex_secondary_markers.size() != sec_count) {
|
||||
m_sequential_view.m_imex_secondary_markers.resize(sec_count);
|
||||
for (int i = 0; i < sec_count; ++i) {
|
||||
m_sequential_view.m_imex_secondary_markers[i].init(m_marker_filename);
|
||||
m_sequential_view.m_imex_secondary_markers[i].set_color(
|
||||
s_carriage_colors[(i + 1) % s_carriage_colors.size()]);
|
||||
}
|
||||
}
|
||||
|
||||
// Bed X and Y bounds — read from the current plate's shape, which is
|
||||
// in world/GL coordinates (same space as curr_vertex.position), and is
|
||||
// the exact same source used by PartPlate::calc_imex_zones().
|
||||
float bed_x_min, bed_x_max, bed_y_min, bed_y_max;
|
||||
{
|
||||
PartPlate* curr_plate = wxGetApp().plater()->get_partplate_list().get_curr_plate();
|
||||
const Pointfs& plate_shape = curr_plate ? curr_plate->get_shape() : Pointfs{};
|
||||
if (!plate_shape.empty()) {
|
||||
bed_x_min = (float)plate_shape[0].x();
|
||||
bed_x_max = bed_x_min;
|
||||
bed_y_min = (float)plate_shape[0].y();
|
||||
bed_y_max = bed_y_min;
|
||||
for (const auto& pt : plate_shape) {
|
||||
bed_x_min = std::min(bed_x_min, (float)pt.x());
|
||||
bed_x_max = std::max(bed_x_max, (float)pt.x());
|
||||
bed_y_min = std::min(bed_y_min, (float)pt.y());
|
||||
bed_y_max = std::max(bed_y_max, (float)pt.y());
|
||||
}
|
||||
} else {
|
||||
// Fallback: use toolpath bounding box extent
|
||||
bed_x_min = (float)m_paths_bounding_box.min.x();
|
||||
bed_x_max = (float)m_paths_bounding_box.max.x();
|
||||
bed_y_min = (float)m_paths_bounding_box.min.y();
|
||||
bed_y_max = (float)m_paths_bounding_box.max.y();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the same flip logic as PartPlate::calc_imex_zones() so physical
|
||||
// grid positions match the bed zone visualization. Use option<>() not
|
||||
// opt<>(): enum options load from presets as ConfigOptionEnumGeneric, so
|
||||
// opt<>()'s dynamic_cast to ConfigOptionEnum<ImexToolLayout> returns null
|
||||
// and silently falls back to the FrontLeft default (flip_y wrong).
|
||||
auto* layout_opt = printer_cfg.option<ConfigOptionEnum<ImexToolLayout>>("imex_tool_layout");
|
||||
const ImexToolLayout layout = layout_opt ? layout_opt->value : ImexToolLayout::FrontLeft;
|
||||
const bool flip_x = (layout == ImexToolLayout::FrontRight || layout == ImexToolLayout::RearRight);
|
||||
const bool flip_y = (layout == ImexToolLayout::RearLeft || layout == ImexToolLayout::RearRight);
|
||||
|
||||
auto phys_col_of = [&](int tid) -> int {
|
||||
int raw = tid % tools_per_gantry;
|
||||
return flip_x ? (tools_per_gantry - 1 - raw) : raw;
|
||||
};
|
||||
auto phys_row_of = [&](int tid) -> int {
|
||||
int raw = tid / tools_per_gantry;
|
||||
return flip_y ? (gantry_count - 1 - raw) : raw;
|
||||
};
|
||||
|
||||
// Build active col/row sets — mirrors PartPlate::calc_imex_zones() so
|
||||
// zone sizes and positions stay in sync with the bed visualization.
|
||||
const int pri_phys_col = (pri_tool >= 0) ? phys_col_of(pri_tool) : 0;
|
||||
const int pri_phys_row = (pri_tool >= 0) ? phys_row_of(pri_tool) : 0;
|
||||
|
||||
// Grid sizing and cell placement answer different questions, and
|
||||
// calc_imex_zones answers them differently — match it on both counts or the
|
||||
// markers drift from the ghosts:
|
||||
// sizing: every Copy/Mirror tool donates its OWN column/row (ac_set is
|
||||
// built from tool_to_phys, unpinned), so an aggregated gantry's
|
||||
// non-representatives still widen the grid.
|
||||
// placement: an aggregated cell is PINNED to the primary's column
|
||||
// (copy_cells/mirror_cells insert {pri_col, r}), because the
|
||||
// row-strip spans full X and has no column of its own.
|
||||
auto eff_col_of = [&](int tid) {
|
||||
return sec_aggregated.count(tid) ? pri_phys_col : phys_col_of(tid);
|
||||
};
|
||||
|
||||
std::map<int,int> phys_col_to_zone_gv, phys_row_to_zone_gv;
|
||||
int n_active_cols_gv = 1, n_active_rows_gv = 1;
|
||||
{
|
||||
std::set<int> ac, ar;
|
||||
ac.insert(pri_phys_col); ar.insert(pri_phys_row);
|
||||
for (int tid : grid_tool_ids) {
|
||||
ac.insert(phys_col_of(tid));
|
||||
ar.insert(phys_row_of(tid));
|
||||
}
|
||||
// col_to_zone / row_to_zone: physical index → zone index (sorted)
|
||||
int k = 0;
|
||||
for (int c : ac) phys_col_to_zone_gv[c] = k++;
|
||||
k = 0;
|
||||
for (int r : ar) phys_row_to_zone_gv[r] = k++;
|
||||
n_active_cols_gv = (int)ac.size();
|
||||
n_active_rows_gv = (int)ar.size();
|
||||
}
|
||||
auto zone_col = [&](int pc) { auto it = phys_col_to_zone_gv.find(pc); return it != phys_col_to_zone_gv.end() ? it->second : 0; };
|
||||
auto zone_row = [&](int pr) { auto it = phys_row_to_zone_gv.find(pr); return it != phys_row_to_zone_gv.end() ? it->second : 0; };
|
||||
|
||||
const Vec3f prim_pos = libvgcode::convert(curr_vertex.position);
|
||||
const float strip_width = (bed_x_max - bed_x_min) / (float)n_active_cols_gv;
|
||||
const float row_strip_height = (bed_y_max - bed_y_min) / (float)n_active_rows_gv;
|
||||
const int pri_zone_col = zone_col(pri_phys_col);
|
||||
const int pri_zone_row = zone_row(pri_phys_row);
|
||||
|
||||
// Primary carriage box
|
||||
float pri_box_offset_x = (pri_zone_col == 0) ? 0.0f : -imex_box_wx;
|
||||
float pri_box_offset_y = -imex_box_wy;
|
||||
for (int i = 0; i < sec_count; ++i) {
|
||||
int sc = eff_col_of(sec_tool_ids[i]);
|
||||
int sr = phys_row_of(sec_tool_ids[i]);
|
||||
if (sc > pri_phys_col) { pri_box_offset_x = 0.0f; }
|
||||
else if (sc < pri_phys_col) { pri_box_offset_x = -imex_box_wx; }
|
||||
if (sr > pri_phys_row) { pri_box_offset_y = 0.0f; }
|
||||
else if (sr < pri_phys_row) { pri_box_offset_y = -imex_box_wy; }
|
||||
}
|
||||
carriage_box_draws.push_back({ prim_pos, s_carriage_colors[0], pri_box_offset_x, pri_box_offset_y });
|
||||
|
||||
const float pri_zone_x = bed_x_min + (float)pri_zone_col * strip_width;
|
||||
const float pri_zone_y = bed_y_min + (float)pri_zone_row * row_strip_height;
|
||||
const float rel_x = prim_pos.x() - pri_zone_x;
|
||||
const float rel_y = prim_pos.y() - pri_zone_y;
|
||||
|
||||
for (int i = 0; i < sec_count; ++i) {
|
||||
int sec_phys_col = eff_col_of(sec_tool_ids[i]);
|
||||
int sec_phys_row = phys_row_of(sec_tool_ids[i]);
|
||||
int sec_zc = zone_col(sec_phys_col);
|
||||
int sec_zr = zone_row(sec_phys_row);
|
||||
const int sec_state = sec_tool_states[sec_tool_ids[i]];
|
||||
|
||||
// A carriage stays inside its own zone; a Mirror reflects its
|
||||
// zone-relative offset about that zone's centerline, on the axis of the
|
||||
// boundary it shares with primary — the same rule the ghosts use:
|
||||
// Copy → tracks primary on both axes.
|
||||
// Mirror, same gantry → reflect X (zones sit side by side).
|
||||
// Mirror, other gantry → reflect Y (zones sit front-to-back); X
|
||||
// tracks primary, since the part off that
|
||||
// gantry is a Y-reflection of the tool
|
||||
// directly behind it.
|
||||
// The axis comes from the shared helper, so the markers can never drift
|
||||
// out of step with the plate ghosts.
|
||||
const bool is_mirror = (sec_state == 3);
|
||||
const bool cross_gantry = imex_mirror_axis_for(pri_tool, sec_tool_ids[i],
|
||||
tools_per_gantry) == ImexMirrorAxis::Y;
|
||||
const float sec_zone_x = bed_x_min + (float)sec_zc * strip_width;
|
||||
const float sec_zone_y = bed_y_min + (float)sec_zr * row_strip_height;
|
||||
|
||||
const float sec_x = (is_mirror && !cross_gantry)
|
||||
? sec_zone_x + (strip_width - rel_x)
|
||||
: sec_zone_x + rel_x;
|
||||
const float sec_y = (is_mirror && cross_gantry)
|
||||
? sec_zone_y + (row_strip_height - rel_y)
|
||||
: sec_zone_y + rel_y;
|
||||
|
||||
Vec3f sec_pos{ sec_x, sec_y, prim_pos.z() };
|
||||
m_sequential_view.m_imex_secondary_markers[i].set_world_position(sec_pos);
|
||||
m_sequential_view.m_imex_secondary_markers[i].set_z_offset(m_z_offset + 0.5f);
|
||||
// The box shows the side a toolhead could COLLIDE from, not merely which
|
||||
// way its body hangs. Tools sharing a gantry share an X rail and can
|
||||
// actually run into each other, and only in a same-gantry (X-axis) mirror
|
||||
// do they converge — so that is the one case where the secondary's box
|
||||
// flips to face the primary. Tools on different gantries cannot collide
|
||||
// in X at all, so a cross-gantry mirror keeps the primary's facing, the
|
||||
// same as a Copy. Do not "fix" this to follow carriage geometry: a box on
|
||||
// the far side would point away from the only tool it can hit.
|
||||
float sec_box_offset_x;
|
||||
if (is_mirror && !cross_gantry) {
|
||||
if (sec_phys_col > pri_phys_col) sec_box_offset_x = -imex_box_wx;
|
||||
else if (sec_phys_col < pri_phys_col) sec_box_offset_x = 0.0f;
|
||||
else sec_box_offset_x = pri_box_offset_x;
|
||||
} else {
|
||||
sec_box_offset_x = pri_box_offset_x;
|
||||
}
|
||||
// Y follows the same collision rule: face the gantry you could hit. A tool
|
||||
// on ANOTHER row faces the primary's row; a tool on the primary's OWN row
|
||||
// shares its gantry and can only hit the same other gantry, so it faces
|
||||
// wherever the primary faces. The old `else` hardcoded -imex_box_wy, which
|
||||
// happens to equal pri_box_offset_y on the rear-* layouts (primary's row
|
||||
// sits above the others, so the loop below settles on -imex_box_wy anyway)
|
||||
// — hence a no-op there. On the front-* layouts the primary flips to 0.0f
|
||||
// and the hardcoded value pointed the same-gantry secondary away from the
|
||||
// only tools it could run into.
|
||||
float sec_box_offset_y;
|
||||
if (sec_phys_row > pri_phys_row) sec_box_offset_y = -imex_box_wy;
|
||||
else if (sec_phys_row < pri_phys_row) sec_box_offset_y = 0.0f;
|
||||
else sec_box_offset_y = pri_box_offset_y;
|
||||
carriage_box_draws.push_back({
|
||||
sec_pos, s_carriage_colors[(i + 1) % s_carriage_colors.size()],
|
||||
sec_box_offset_x, sec_box_offset_y });
|
||||
}
|
||||
for (int i = 0; i < sec_count; ++i) {
|
||||
const ImexMarkerPlan::Carriage& carriage = plan.carriages[i];
|
||||
// Tracking an axis translates the primary by the gap between the two
|
||||
// zone centres; mirroring reflects it about the midpoint of those
|
||||
// centres — the zones being equal-sized, that midpoint is the boundary
|
||||
// between them, so the carriage still lands inside its own zone. Which
|
||||
// axis does which, and both terms, come from resolve_imex_marker_plan().
|
||||
const Vec3f sec_pos{ carriage.mirror_x ? carriage.x_term - prim_pos.x()
|
||||
: prim_pos.x() + carriage.x_term,
|
||||
carriage.mirror_y ? carriage.y_term - prim_pos.y()
|
||||
: prim_pos.y() + carriage.y_term,
|
||||
prim_pos.z() };
|
||||
m_sequential_view.m_imex_secondary_markers[i].set_world_position(sec_pos);
|
||||
m_sequential_view.m_imex_secondary_markers[i].set_z_offset(m_z_offset + 0.5f);
|
||||
carriage_box_draws.push_back({
|
||||
sec_pos, s_carriage_colors[(i + 1) % s_carriage_colors.size()],
|
||||
carriage.box_offset_x, carriage.box_offset_y });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1961,18 +2002,22 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin)
|
||||
// of an app_config entry is also treated as on.
|
||||
const bool show_toolhead_boxes = wxGetApp().app_config->get("show_imex_toolhead_boxes") != "false";
|
||||
if (show_toolhead_boxes && !carriage_box_draws.empty() && imex_box_wx > 0.0f && imex_box_wy > 0.0f) {
|
||||
// Rebuild box mesh every frame — dimensions can change via config edit without a
|
||||
// G-code reload, so dimension-based caching isn't safe.
|
||||
// The mesh depends only on the two clearance dimensions, which a config edit can
|
||||
// change without a G-code reload — so rebuild it when they move (or when it has not
|
||||
// been built yet) rather than on every frame.
|
||||
// Mesh origin: nozzle at x=0, centered in Y, Z starts at nozzle tip level.
|
||||
// Per-carriage box_offset_x shifts the mesh left or right so the nozzle lands
|
||||
// at the correct (collision-side) edge.
|
||||
{
|
||||
if (!m_imex_toolhead_box.is_initialized() ||
|
||||
m_imex_box_mesh_wx != imex_box_wx || m_imex_box_mesh_wy != imex_box_wy) {
|
||||
const float box_h = std::max(imex_box_wx, imex_box_wy);
|
||||
indexed_triangle_set its = its_make_cube((double)imex_box_wx, (double)imex_box_wy, (double)box_h);
|
||||
// No vertex pre-shifting — box_offset_x/y in the per-carriage transform
|
||||
// positions the nozzle at the correct collision-side edge.
|
||||
m_imex_toolhead_box.reset();
|
||||
m_imex_toolhead_box.init_from(its);
|
||||
m_imex_box_mesh_wx = imex_box_wx;
|
||||
m_imex_box_mesh_wy = imex_box_wy;
|
||||
}
|
||||
|
||||
GLShaderProgram* shader = wxGetApp().get_shader("gouraud_light");
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include <cstdint>
|
||||
#include <float.h>
|
||||
#include <set>
|
||||
#include <tuple>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace Slic3r {
|
||||
@@ -210,8 +211,84 @@ private:
|
||||
ConfigOptionMode m_user_mode;
|
||||
bool m_fold = {false};
|
||||
std::string m_marker_filename; // cached for lazy secondary marker init
|
||||
std::string m_imex_last_mode; // detect mode changes for secondary marker rebuild
|
||||
|
||||
// IDEX/IQEX: everything render() needs to place the secondary carriage markers and the
|
||||
// toolhead footprint boxes, resolved from the printer preset, the active mode and the
|
||||
// plate bed. Resolving it walks the mode string through compute_imex_zone_layout()
|
||||
// (several string parses plus a zone-grid rebuild), and none of its inputs change
|
||||
// between frames, so it is resolved once per input change -- see ImexMarkerKey -- and
|
||||
// replayed on every other frame.
|
||||
struct ImexMarkerPlan
|
||||
{
|
||||
// One entry per secondary carriage that owns a zone, in marker order. Each axis
|
||||
// either TRACKS the primary (pos + term) or MIRRORS it about the boundary the two
|
||||
// zones share (term - pos); which of the two, and the term itself, depend only on
|
||||
// the zone geometry, so both are resolved up front.
|
||||
struct Carriage
|
||||
{
|
||||
bool mirror_x = false;
|
||||
bool mirror_y = false;
|
||||
float x_term = 0.0f;
|
||||
float y_term = 0.0f;
|
||||
float box_offset_x = 0.0f;
|
||||
float box_offset_y = 0.0f;
|
||||
};
|
||||
std::vector<Carriage> carriages; // empty => no secondary carriages to draw
|
||||
float pri_box_offset_x = 0.0f;
|
||||
float pri_box_offset_y = 0.0f;
|
||||
float box_wx = 0.0f; // imex_nozzle_clearance_x / _y
|
||||
float box_wy = 0.0f;
|
||||
};
|
||||
|
||||
// Invalidation key for the plan above: every input the plan is derived from, and
|
||||
// nothing that changes between frames. A stale plan would put the preview markers
|
||||
// somewhere the plate's own zones and ghosts do not agree with, which is exactly the
|
||||
// drift the shared layout call exists to prevent -- so this deliberately mirrors
|
||||
// PartPlate::build_imex_cache_key(), and adds the two inputs only the preview reads:
|
||||
// the plate's bed extents (zone centres scale with them) and imex_tool_layout (the
|
||||
// T0-corner flips inside compute_imex_zone_layout). imex_carriage_margin is absent on
|
||||
// purpose: it only sizes the plate's advisory bands, which the preview never draws.
|
||||
struct ImexMarkerKey
|
||||
{
|
||||
// Scalar half. Built fresh on the stack each frame -- it allocates nothing -- and
|
||||
// compared as a tuple.
|
||||
struct Scalars
|
||||
{
|
||||
int plate_index = -1;
|
||||
int gantry_count = 0;
|
||||
int tools_per_gantry = 0;
|
||||
int tool_layout = -1;
|
||||
double clearance_x = 0.0;
|
||||
double clearance_y = 0.0;
|
||||
bool firmware_managed = false;
|
||||
double bed_min_x = 0.0, bed_min_y = 0.0, bed_max_x = 0.0, bed_max_y = 0.0;
|
||||
|
||||
auto tied() const {
|
||||
return std::tie(plate_index, gantry_count, tools_per_gantry, tool_layout,
|
||||
clearance_x, clearance_y, firmware_managed,
|
||||
bed_min_x, bed_min_y, bed_max_x, bed_max_y);
|
||||
}
|
||||
bool operator==(const Scalars& rhs) const { return tied() == rhs.tied(); }
|
||||
};
|
||||
Scalars s;
|
||||
// Resolved active mode (the plate's mode beats the process preset), plus the printer
|
||||
// preset's whole mode table. The name alone is not identity: two presets can carry
|
||||
// the same mode name over different tool rosters, and editing a roster in place
|
||||
// moves neither the name nor any scalar above. Compared by value, never copied
|
||||
// unless something actually changed.
|
||||
std::string mode;
|
||||
std::vector<std::string> mode_names;
|
||||
std::vector<std::string> mode_active_tools;
|
||||
};
|
||||
ImexMarkerKey m_imex_marker_key;
|
||||
ImexMarkerPlan m_imex_marker_plan;
|
||||
static ImexMarkerPlan resolve_imex_marker_plan(const DynamicPrintConfig& printer_cfg,
|
||||
const std::string& mode,
|
||||
const BoundingBoxf& bed_extents);
|
||||
|
||||
GLModel m_imex_toolhead_box; // shared box mesh for all carriage footprint overlays
|
||||
float m_imex_box_mesh_wx{ 0.0f }; // clearance dimensions m_imex_toolhead_box was built for
|
||||
float m_imex_box_mesh_wy{ 0.0f };
|
||||
|
||||
size_t m_extruders_count;
|
||||
std::vector<float> m_filament_diameters;
|
||||
|
||||
Reference in New Issue
Block a user