feat: 4-tool support, primary row, zone sizing, and stability fixes

IMEXModesCtrl:
- Primary mode is now a non-deletable first row stored as sentinel
  "primary" in imex_mode_names; older configs load cleanly
- New rows default T0 → Primary when no tool assignment is stored
- Filter "primary" from plater popup/cycle list to prevent double entry
- imex_tools_per_gantry cap raised 2 → 4

Zone sizing:
- Zone width/height now based on active tool count only; inactive tools
  donate their bed share to active neighbors (fixes 4-tool layout)
- Active col/row maps (col_to_zone/row_to_zone) applied consistently
  across zone fills, collision strips, and primary zone box

GCodeViewer animation:
- Mirror position formula fixed: left-of-copy reflects across copy
  zone's left edge; right-of-copy reflects across right edge
  (T3 was rendering on top of T1)
- strip_width/row_strip_height use active counts, matching PartPlate

Stability:
- Early return in calc_imex_zones() when tool_states is empty; prevents
  OOB crash on new printer with stale process-preset mode name
- is_imex toggle in on_config_change calls refresh_imex_icons() so the
  plate mode icon appears without requiring a new project

GCode:
- Remove "primary" guard so Primary mode gcode field is emitted at
  start of print

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Clifford Garwood
2026-04-09 19:08:57 -04:00
co-authored by Claude Sonnet 4.6
parent 0d73cd13ba
commit 9a87363044
11 changed files with 220 additions and 134 deletions
-10
View File
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="43" height="43" viewBox="0 0 43 43">
<rect x="1.5" y="1.5" width="40" height="40" rx="3.5" style="fill:#e9e9e9"/>
<path d="M38,2a3,3,0,0,1,3,3V38a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2H38m0-1H5A4,4,0,0,0,1,5V38a4,4,0,0,0,4,4H38a4,4,0,0,0,4-4V5a4,4,0,0,0-4-4Z" style="fill:#545454;stroke-width:1.0015748;stroke-dasharray:none"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#545454;stroke-width:0.8;stroke-dasharray:none" transform="translate(2,0.6593574)"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#545454;stroke-width:0.8;stroke-dasharray:none" transform="translate(15.543848,0.6593574)"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#545454;stroke-width:0.8;stroke-dasharray:none" transform="translate(1.9999996,17.214798)"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#545454;stroke-width:0.8;stroke-dasharray:none" transform="translate(15.517972,17.291251)"/>
<path d="M 21.527502,7.146574 V 35.745655" style="fill:#545454;stroke:#545454;stroke-width:0.8;stroke-linecap:round;stroke-dasharray:none"/>
<path d="M 8.3002566,21.575891 H 34.251585" style="fill:#545454;stroke:#545454;stroke-width:0.8;stroke-linecap:round;stroke-dasharray:none"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-10
View File
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="43" height="43" viewBox="0 0 43 43">
<rect x="1.5" y="1.5" width="40" height="40" rx="3.5" style="fill:#54545a"/>
<path d="M38,2a3,3,0,0,1,3,3V38a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2H38m0-1H5A4,4,0,0,0,1,5V38a4,4,0,0,0,4,4H38a4,4,0,0,0,4-4V5a4,4,0,0,0-4-4Z" style="fill:#b3b3b3;stroke-width:1.0015748;stroke-dasharray:none"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#c4c4c4;stroke-width:0.8;stroke-dasharray:none" transform="translate(2,0.6593574)"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#c4c4c4;stroke-width:0.8;stroke-dasharray:none" transform="translate(15.543848,0.6593574)"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#c4c4c4;stroke-width:0.8;stroke-dasharray:none" transform="translate(1.9999996,17.214798)"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#c4c4c4;stroke-width:0.8;stroke-dasharray:none" transform="translate(15.517972,17.291251)"/>
<path d="M 21.527502,7.146574 V 35.745655" style="fill:#c4c4c4;stroke:#c4c4c4;stroke-width:0.8;stroke-linecap:round;stroke-dasharray:none"/>
<path d="M 8.3002566,21.575891 H 34.251585" style="fill:#c4c4c4;stroke:#c4c4c4;stroke-width:0.8;stroke-linecap:round;stroke-dasharray:none"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="43" height="43" viewBox="0 0 43 43">
<rect x="1.5" y="1.5" width="40" height="40" rx="3.5" style="fill:#e9e9e9"/>
<path d="M38,2a3,3,0,0,1,3,3V38a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2H38m0-1H5A4,4,0,0,0,1,5V38a4,4,0,0,0,4,4H38a4,4,0,0,0,4-4V5a4,4,0,0,0-4-4Z" style="fill:#009688;stroke-width:1.0015748;stroke-dasharray:none"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#009688;stroke-width:0.8;stroke-dasharray:none" transform="translate(2,0.6593574)"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#009688;stroke-width:0.8;stroke-dasharray:none" transform="translate(15.543848,0.6593574)"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#009688;stroke-width:0.8;stroke-dasharray:none" transform="translate(1.9999996,17.214798)"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#009688;stroke-width:0.8;stroke-dasharray:none" transform="translate(15.517972,17.291251)"/>
<path d="M 21.527502,7.146574 V 35.745655" style="fill:#009688;stroke:#009688;stroke-width:0.8;stroke-linecap:round;stroke-dasharray:none"/>
<path d="M 8.3002566,21.575891 H 34.251585" style="fill:#009688;stroke:#009688;stroke-width:0.8;stroke-linecap:round;stroke-dasharray:none"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="43" height="43" viewBox="0 0 43 43">
<rect x="1.5" y="1.5" width="40" height="40" rx="3.5" style="fill:#54545a"/>
<path d="M38,2a3,3,0,0,1,3,3V38a3,3,0,0,1-3,3H5a3,3,0,0,1-3-3V5A3,3,0,0,1,5,2H38m0-1H5A4,4,0,0,0,1,5V38a4,4,0,0,0,4,4H38a4,4,0,0,0,4-4V5a4,4,0,0,0-4-4Z" style="fill:#009688;stroke-width:1.0015748;stroke-dasharray:none"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#009688;stroke-width:0.8;stroke-dasharray:none" transform="translate(2,0.6593574)"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#009688;stroke-width:0.8;stroke-dasharray:none" transform="translate(15.543848,0.6593574)"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#009688;stroke-width:0.8;stroke-dasharray:none" transform="translate(1.9999996,17.214798)"/>
<path d="M 16.798963,13.679793 14.08412,14.062706 12.860469,16.516209 11.657363,14.052567 8.9458146,13.646979 10.917098,11.741451 10.46492,9.0372811 12.886345,10.323242 15.318433,9.0575608 14.843673,11.757857 Z" style="fill:none;stroke:#009688;stroke-width:0.8;stroke-dasharray:none" transform="translate(15.517972,17.291251)"/>
<path d="M 21.527502,7.146574 V 35.745655" style="fill:#009688;stroke:#009688;stroke-width:0.8;stroke-linecap:round;stroke-dasharray:none"/>
<path d="M 8.3002566,21.575891 H 34.251585" style="fill:#009688;stroke:#009688;stroke-width:0.8;stroke-linecap:round;stroke-dasharray:none"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

+1 -1
View File
@@ -3058,7 +3058,7 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato
// macro is active before any motion, homing, or probing in start G-code.
if (print.config().is_imex.value && !print.objects().empty()) {
const std::string& imex_mode = print.objects().front()->config().imex_parallel_mode.value;
if (!imex_mode.empty() && imex_mode != "primary") {
if (!imex_mode.empty()) {
const auto& mode_names = print.config().imex_mode_names.values;
const auto& mode_gcodes = print.config().imex_mode_gcodes.values;
for (size_t i = 0; i < mode_names.size() && i < mode_gcodes.size(); ++i) {
+1 -1
View File
@@ -5506,7 +5506,7 @@ void PrintConfigDef::init_fff_params()
def->label = L("Tools per Gantry");
def->tooltip = L("Number of independent toolheads per gantry along X. 2 for IDEX-style.");
def->min = 1;
def->max = 2;
def->max = 4;
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionInt(2));
+60 -41
View File
@@ -1652,78 +1652,99 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin)
return flip_y ? (gantry_count - 1 - raw) : raw;
};
const Vec3f prim_pos = libvgcode::convert(curr_vertex.position);
const float strip_width = (bed_x_max - bed_x_min) / (float)tools_per_gantry;
const float row_strip_height = (bed_y_max - bed_y_min) / (float)gantry_count;
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;
// 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;
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 : sec_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; };
// Pre-pass: for each physical row, find the Copy reference column.
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);
// Pre-pass: for each physical row, find the Copy reference physical column.
// The primary row's reference is the primary itself.
// Non-primary rows need a Copy tool; Mirror tools on that row
// reflect that Copy's X position.
std::map<int,int> row_copy_col;
std::map<int,int> row_copy_col; // phys_row → phys_col of copy reference
row_copy_col[pri_phys_row] = pri_phys_col;
for (int i = 0; i < sec_count; ++i) {
if (sec_tool_states[sec_tool_ids[i]] == 2) {
row_copy_col[phys_row_of(sec_tool_ids[i])] =
phys_col_of(sec_tool_ids[i]);
}
if (sec_tool_states[sec_tool_ids[i]] == 2)
row_copy_col[phys_row_of(sec_tool_ids[i])] = phys_col_of(sec_tool_ids[i]);
}
// Primary carriage box: nozzle sits at the collision-side edge.
// Default to centered; override once we know which side the secondaries are on.
float pri_box_offset_x = (pri_phys_col == 0) ? 0.0f : -imex_box_wx; // nozzle at inner edge (facing center of bed)
float pri_box_offset_y = -imex_box_wy; // nozzle at high-Y edge by default (gantry always behind nozzle); override if secondary is behind
// 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 = phys_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; }
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_phys_col * strip_width;
const float pri_zone_y = bed_y_min + (float)pri_phys_row * row_strip_height;
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 = phys_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]];
// Y: all tools in a row share a Y rail, so Y is always
// zone-relative copy from the primary.
float sec_zone_y = bed_y_min + (float)sec_phys_row * row_strip_height;
// Y: all tools on a row share a Y rail — always zone-relative copy.
float sec_zone_y = bed_y_min + (float)sec_zr * row_strip_height;
float sec_y = sec_zone_y + rel_y;
// X: Copy places the tool at the same relative position within
// its own zone. Mirror reflects the Copy reference on this row.
// X: Copy → same zone-relative position.
// Mirror → reflect copy reference across the boundary it shares with
// this mirror zone (left or right edge of copy zone depending on side).
float sec_x;
if (sec_state == 2) {
float sec_zone_x = bed_x_min + (float)sec_phys_col * strip_width;
sec_x = sec_zone_x + rel_x;
sec_x = bed_x_min + (float)sec_zc * strip_width + rel_x;
} else {
// Mirror: find the Copy on the same row and reflect its X.
auto ref_it = row_copy_col.find(sec_phys_row);
if (ref_it != row_copy_col.end()) {
float ref_zone_x = bed_x_min + (float)ref_it->second * strip_width;
float ref_x = ref_zone_x + rel_x;
sec_x = bed_x_min + bed_x_max - ref_x;
int ref_phys_col = (ref_it != row_copy_col.end()) ? ref_it->second : pri_phys_col;
int ref_zc = zone_col(ref_phys_col);
float ref_zone_x = bed_x_min + (float)ref_zc * strip_width;
float ref_abs = ref_zone_x + rel_x;
if (sec_phys_col < ref_phys_col) {
// Mirror left of copy — reflects across copy zone's left edge
sec_x = 2.0f * ref_zone_x - ref_abs;
} else {
sec_x = bed_x_min + bed_x_max - prim_pos.x();
// Mirror right of copy — reflects across copy zone's right edge
float ref_zone_right = bed_x_min + (float)(ref_zc + 1) * strip_width;
sec_x = 2.0f * ref_zone_right - ref_abs;
}
}
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);
// X: copy tools move in sync with primary so they share the same
// X-facing orientation. Mirror tools approach from the opposite side
// so the nozzle sits at the collision-side edge.
float sec_box_offset_x;
if (sec_state == 2) {
sec_box_offset_x = pri_box_offset_x;
@@ -1732,8 +1753,6 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin)
else if (sec_phys_col < pri_phys_col) sec_box_offset_x = 0.0f;
else sec_box_offset_x = pri_box_offset_x;
}
// Y: always row-based — gantry is always behind nozzle regardless of
// copy/mirror (a back-row copy still has its gantry at the back).
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;
+85 -25
View File
@@ -579,6 +579,11 @@ void PartPlate::calc_imex_zones()
}
}
// If the mode name was found but has no tools (e.g. stale process-preset mode on a new
// printer that has no modes defined yet), there is nothing to compute.
if (tool_states.empty())
return;
// Identify the Primary tool from the mode definition
for (auto& [idx, state] : tool_states) {
if (state == 1) { auto [c, r] = tool_to_phys(idx); pri_col = c; pri_row = r; break; }
@@ -601,8 +606,33 @@ void PartPlate::calc_imex_zones()
auto bed_ext = get_extents(m_shape);
double x_min = bed_ext.min(0), x_max = bed_ext.max(0);
double y_min = bed_ext.min(1), y_max = bed_ext.max(1);
double zone_w = (x_max - x_min) / n_cols;
double zone_h = (y_max - y_min) / n_rows;
// Zone sizing is based on ACTIVE tool count per axis, not total grid dimensions.
// Inactive tools (absent from tool_states) donate their bed share to active neighbors.
// Sorted active col/row lists map physical index k → zone index k.
std::vector<int> active_cols_v, active_rows_v;
{
std::set<int> ac_set, ar_set;
for (auto& [idx, state] : tool_states) {
auto [c, r] = tool_to_phys(idx);
ac_set.insert(c); ar_set.insert(r);
}
active_cols_v.assign(ac_set.begin(), ac_set.end()); // sorted ascending
active_rows_v.assign(ar_set.begin(), ar_set.end());
}
int n_active_cols = std::max(1, (int)active_cols_v.size());
int n_active_rows = std::max(1, (int)active_rows_v.size());
std::map<int,int> col_to_zone, row_to_zone;
for (int k = 0; k < n_active_cols; ++k) col_to_zone[active_cols_v[k]] = k;
for (int k = 0; k < n_active_rows; ++k) row_to_zone[active_rows_v[k]] = k;
double zone_w = (x_max - x_min) / n_active_cols;
double zone_h = (y_max - y_min) / n_active_rows;
// Zone index of the primary tool
int pri_col_k = col_to_zone.count(pri_col) ? col_to_zone[pri_col] : 0;
int pri_row_k = row_to_zone.count(pri_row) ? row_to_zone[pri_row] : 0;
// Separation axes: row-sep = secondaries on a different gantry, col-sep = different column
bool has_row_sep = false, has_col_sep = false;
@@ -647,18 +677,26 @@ void PartPlate::calc_imex_zones()
auto make_boxes = [&](const std::set<std::pair<int,int>>& cells) -> std::vector<BoxRect> {
std::vector<BoxRect> boxes;
if (cells.empty()) return boxes;
auto ck = [&](int c) { return col_to_zone.count(c) ? col_to_zone.at(c) : 0; };
auto rk = [&](int r) { return row_to_zone.count(r) ? row_to_zone.at(r) : 0; };
if (has_row_sep && !has_col_sep) {
std::set<int> rows; for (auto& [c,r] : cells) rows.insert(r);
for (int sr : rows)
boxes.push_back({x_min, x_max, y_min + sr*zone_h, y_min + (sr+1)*zone_h});
for (int sr : rows) {
int k = rk(sr);
boxes.push_back({x_min, x_max, y_min + k*zone_h, y_min + (k+1)*zone_h});
}
} else if (has_col_sep && !has_row_sep) {
std::set<int> cols; for (auto& [c,r] : cells) cols.insert(c);
for (int sc : cols)
boxes.push_back({x_min + sc*zone_w, x_min + (sc+1)*zone_w, y_min, y_max});
for (int sc : cols) {
int k = ck(sc);
boxes.push_back({x_min + k*zone_w, x_min + (k+1)*zone_w, y_min, y_max});
}
} else {
for (auto& [sc,sr] : cells)
boxes.push_back({x_min + sc*zone_w, x_min + (sc+1)*zone_w,
y_min + sr*zone_h, y_min + (sr+1)*zone_h});
for (auto& [sc,sr] : cells) {
int ck_ = ck(sc), rk_ = rk(sr);
boxes.push_back({x_min + ck_*zone_w, x_min + (ck_+1)*zone_w,
y_min + rk_*zone_h, y_min + (rk_+1)*zone_h});
}
}
return boxes;
};
@@ -718,11 +756,11 @@ void PartPlate::calc_imex_zones()
}
};
// Primary zone extent — mirrors in_primary_zone / make_boxes expansion logic
double pz_x0 = has_col_sep ? x_min + pri_col * zone_w : x_min;
double pz_x1 = has_col_sep ? x_min + (pri_col + 1) * zone_w : x_max;
double pz_y0 = has_row_sep ? y_min + pri_row * zone_h : y_min;
double pz_y1 = has_row_sep ? y_min + (pri_row + 1) * zone_h : y_max;
// Primary zone extent — uses zone indices so inactive tools don't shrink the zone
double pz_x0 = has_col_sep ? x_min + pri_col_k * zone_w : x_min;
double pz_x1 = has_col_sep ? x_min + (pri_col_k + 1) * zone_w : x_max;
double pz_y0 = has_row_sep ? y_min + pri_row_k * zone_h : y_min;
double pz_y1 = has_row_sep ? y_min + (pri_row_k + 1) * zone_h : y_max;
m_imex_primary_zone_box = BoundingBoxf(Vec2d(pz_x0, pz_y0), Vec2d(pz_x1, pz_y1));
// Helper: build a BoundingBoxf3 strip, clip to bed, add to members
@@ -753,26 +791,27 @@ void PartPlate::calc_imex_zones()
for (auto& [idx, state] : tool_states) {
if (state != 3) continue; // only mirror tools (state==3) require collision strips
auto [c, r] = tool_to_phys(idx);
// X-boundary strips: any mirror in an adjacent column can cause an X collision.
if (c == pri_col + 1) has_right_sec = true;
if (c == pri_col - 1) has_left_sec = true;
// Y-boundary strips: only a mirror DIRECTLY above/below (same column) causes a Y collision.
// A mirror in a different column that happens to be in an adjacent row is only an X risk.
if (r == pri_row + 1 && c == pri_col) has_top_sec = true;
if (r == pri_row - 1 && c == pri_col) has_bottom_sec = true;
int zc = col_to_zone.count(c) ? col_to_zone.at(c) : -1;
int zr = row_to_zone.count(r) ? row_to_zone.at(r) : -1;
// X-boundary strips: mirror in the zone immediately adjacent to the primary zone.
if (zc == pri_col_k + 1) has_right_sec = true;
if (zc == pri_col_k - 1) has_left_sec = true;
// Y-boundary strips: mirror zone-adjacent in Y, same physical column as primary.
if (zr == pri_row_k + 1 && c == pri_col) has_top_sec = true;
if (zr == pri_row_k - 1 && c == pri_col) has_bottom_sec = true;
}
// X-axis boundaries (vertical strips, width = nozzle_clearance_x on primary side)
if (carriage_w > 0.0) {
if (has_right_sec) {
double bnd_x = x_min + (pri_col + 1) * zone_w;
double bnd_x = x_min + (pri_col_k + 1) * zone_w;
double strip_inner = bnd_x - carriage_w;
add_strip(strip_inner, bnd_x, pz_y0, pz_y1);
if (margin > 0.0)
add_margin_fill(strip_inner - margin, strip_inner, pz_y0, pz_y1);
}
if (has_left_sec) {
double bnd_x = x_min + pri_col * zone_w;
double bnd_x = x_min + pri_col_k * zone_w;
double strip_inner = bnd_x + carriage_w;
add_strip(bnd_x, strip_inner, pz_y0, pz_y1);
if (margin > 0.0)
@@ -783,14 +822,14 @@ void PartPlate::calc_imex_zones()
// Y-axis boundaries (horizontal strips, width = nozzle_clearance_y on primary side)
if (carriage_h > 0.0) {
if (has_top_sec) {
double bnd_y = y_min + (pri_row + 1) * zone_h;
double bnd_y = y_min + (pri_row_k + 1) * zone_h;
double strip_inner = bnd_y - carriage_h;
add_strip(pz_x0, pz_x1, strip_inner, bnd_y);
if (margin > 0.0)
add_margin_fill(pz_x0, pz_x1, strip_inner - margin, strip_inner);
}
if (has_bottom_sec) {
double bnd_y = y_min + pri_row * zone_h;
double bnd_y = y_min + pri_row_k * zone_h;
double strip_inner = bnd_y + carriage_h;
add_strip(pz_x0, pz_x1, bnd_y, strip_inner);
if (margin > 0.0)
@@ -830,6 +869,20 @@ std::string PartPlate::build_imex_cache_key() const
+ "|mg" + std::to_string(mgn_opt ? (int)(mgn_opt->value * 10) : 0);
}
// Reposition the IMEX mode icon without requiring a full set_shape() rebuild.
// Called when is_imex is toggled on a printer whose bed shape matches the current plate,
// which would otherwise cause set_shape() to short-circuit before reaching icon calc.
void PartPlate::refresh_imex_icon()
{
if (!m_plater) return;
PresetBundle* preset = wxGetApp().preset_bundle;
if (!preset) return;
bool dual_bbl = preset->is_bbl_vendor() && preset->get_printer_extruder_count() == 2;
auto* is_imex_opt = preset->printers.get_edited_preset().config.option<ConfigOptionBool>("is_imex");
if (is_imex_opt && is_imex_opt->value)
calc_vertex_for_icons(dual_bbl ? 7 : 6, m_imex_mode_icon);
}
// Ensure zone geometry (secondary boxes, collision strips, visual meshes) is up to date.
// Called before any collision check AND before rendering so both paths share the same data.
void PartPlate::ensure_imex_zones()
@@ -7190,6 +7243,13 @@ void PartPlateList::load_cali_textures()
PartPlateList::is_load_cali_texture = true;
}
void PartPlateList::refresh_imex_icons()
{
const std::lock_guard<std::mutex> local_lock(m_plates_mutex);
for (PartPlate* plate : m_plate_list)
plate->refresh_imex_icon();
}
void PartPlateList::on_extruder_count_changed(int extruder_count)
{
for (unsigned int i = 0; i < (unsigned int) m_plate_list.size(); ++i) {
+3
View File
@@ -191,6 +191,7 @@ private:
void ensure_imex_zones();
std::string build_imex_cache_key() const;
void render_imex_zones(bool force_default_color);
void refresh_imex_icon();
void calc_vertex_for_number(int index, bool one_number, GLModel &buffer);
void calc_vertex_for_plate_name_edit_icon(GLTexture *texture, int index, PickingModel &model);
void calc_vertex_for_icons(int index, PickingModel &model);
@@ -952,6 +953,8 @@ public:
void load_cali_textures();
void on_extruder_count_changed(int extruder_count);
// Re-position the IMEX mode icon on all plates without a full shape rebuild.
void refresh_imex_icons();
void set_filament_count(int filament_count);
void on_filament_deleted(int filament_count, int filament_id);
+7 -1
View File
@@ -16268,6 +16268,12 @@ void Plater::on_config_change(const DynamicPrintConfig &config)
opt_key == "sparse_infill_filament" || opt_key == "solid_infill_filament") {
update_scheduled = true;
}
// IDEX/IQEX: when is_imex toggles the mode icon needs to be repositioned on every plate.
// set_shape() short-circuits when the bed geometry is unchanged, so we refresh explicitly.
else if (opt_key == "is_imex") {
p->partplate_list.refresh_imex_icons();
update_scheduled = true;
}
}
if (bed_shape_changed)
@@ -17652,7 +17658,7 @@ int Plater::select_plate_by_hover_id(int hover_id, bool right_click, bool isModi
auto* names_opt = printer_cfg.option<ConfigOptionStrings>("imex_mode_names");
if (names_opt) {
for (const auto& n : names_opt->values)
if (!n.empty()) modes.push_back(n);
if (!n.empty() && n != "primary") modes.push_back(n);
}
if (right_click) {
+63 -25
View File
@@ -4431,7 +4431,7 @@ public:
m_n_rows = n_rows;
m_layout = layout;
for (size_t i = 0; i < names.size(); ++i)
add_row(names[i], tools[i], gcodes[i]);
add_row(names[i], tools[i], gcodes[i], /*is_primary=*/(names[i] == "primary"));
Layout();
}
@@ -4440,11 +4440,31 @@ public:
auto* names = cfg.option<ConfigOptionStrings>("imex_mode_names");
auto* tools = cfg.option<ConfigOptionStrings>("imex_mode_active_tools");
auto* gcodes = cfg.option<ConfigOptionStrings>("imex_mode_gcodes");
// Primary row is always first and non-deletable. Look for an existing
// "primary" entry in the config (present in configs saved after #8 was
// implemented); fall back to empty tool/gcode for older configs.
std::string primary_tools, primary_gcode;
size_t primary_cfg_idx = std::string::npos;
if (names) {
for (size_t i = 0; i < names->values.size(); ++i) {
if (names->values[i] == "primary") {
primary_cfg_idx = i;
if (tools && i < tools->values.size()) primary_tools = tools->values[i];
if (gcodes && i < gcodes->values.size()) primary_gcode = gcodes->values[i];
break;
}
}
}
add_row("primary", primary_tools, primary_gcode, /*is_primary=*/true);
size_t n = names ? names->values.size() : 0;
for (size_t i = 0; i < n; ++i)
for (size_t i = 0; i < n; ++i) {
if (i == primary_cfg_idx) continue; // already added above
add_row(names->values[i],
(tools && i < tools->values.size()) ? tools->values[i] : "",
(gcodes && i < gcodes->values.size()) ? gcodes->values[i] : "");
}
Layout();
}
@@ -4452,7 +4472,7 @@ public:
get_mode_data() const {
std::vector<std::string> names, tools, gcodes;
for (auto& r : m_rows) {
std::string nm = r.name->GetValue().ToStdString();
std::string nm = r.is_primary ? "primary" : r.name->GetValue().ToStdString();
if (nm.empty()) continue;
names.push_back(nm);
tools.push_back(active_tools_string(r));
@@ -4513,32 +4533,44 @@ private:
struct Row {
wxPanel* panel;
wxTextCtrl* name;
wxTextCtrl* name; // nullptr for primary row (name is fixed)
wxTextCtrl* gcode;
std::vector<wxButton*> btns;
std::vector<int> btn_states; // parallel to btns
// tool_idx for btns[i]: computed during add_row, stored here for quick lookup
std::vector<int> btn_states;
std::vector<int> btn_tool_idx;
// Full assignment map including tools not currently visible due to grid size.
// Preserved across grid resizes so assignments restore when grid expands again.
std::map<int,int> all_tool_states;
bool is_primary {false};
};
void add_row(const std::string& name = "",
const std::string& active_tools = "",
const std::string& gcode = "")
const std::string& gcode = "",
bool is_primary = false)
{
Row r;
r.is_primary = is_primary;
r.panel = new wxPanel(this, wxID_ANY);
auto* sizer = new wxBoxSizer(wxHORIZONTAL);
r.name = new wxTextCtrl(r.panel, wxID_ANY, name, wxDefaultPosition, wxSize(130, -1));
r.name->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); });
if (is_primary) {
r.name = nullptr;
auto* lbl = new wxStaticText(r.panel, wxID_ANY, _L("Primary"),
wxDefaultPosition, wxSize(130, -1));
wxFont f = lbl->GetFont();
f.SetWeight(wxFONTWEIGHT_BOLD);
lbl->SetFont(f);
sizer->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
} else {
r.name = new wxTextCtrl(r.panel, wxID_ANY, name, wxDefaultPosition, wxSize(130, -1));
r.name->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); });
}
auto* grid_panel = new wxPanel(r.panel, wxID_ANY);
auto* grid_sizer = new wxGridSizer(m_n_rows, m_n_cols, 2, 2);
auto tool_states = parse_tool_states(active_tools);
if (tool_states.empty())
tool_states[0] = 1; // default T0 → Primary when no assignment is stored
r.all_tool_states = tool_states; // preserve all assignments, including off-screen tools
wxPanel* this_panel = r.panel;
@@ -4617,17 +4649,21 @@ private:
wxDefaultPosition, wxSize(220, 54), wxTE_MULTILINE);
r.gcode->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); });
auto* rm = new wxButton(r.panel, wxID_ANY, wxT("\u00d7"),
wxDefaultPosition, wxSize(24, 24), wxBU_EXACTFIT);
rm->Bind(wxEVT_BUTTON, [this, this_panel](wxCommandEvent&) {
remove_row(this_panel);
notify();
});
sizer->Add(r.name, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
if (!is_primary) {
sizer->Add(r.name, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
}
sizer->Add(grid_panel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
sizer->Add(r.gcode, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
sizer->Add(rm, 0, wxALIGN_CENTER_VERTICAL);
if (!is_primary) {
auto* rm = new wxButton(r.panel, wxID_ANY, wxT("\u00d7"),
wxDefaultPosition, wxSize(24, 24), wxBU_EXACTFIT);
rm->Bind(wxEVT_BUTTON, [this, this_panel](wxCommandEvent&) {
remove_row(this_panel);
notify();
});
sizer->Add(rm, 0, wxALIGN_CENTER_VERTICAL);
}
r.panel->SetSizer(sizer);
m_rows_sizer->Add(r.panel, 0, wxEXPAND | wxBOTTOM, 4);
@@ -4638,6 +4674,7 @@ private:
void remove_row(wxPanel* panel) {
for (size_t i = 0; i < m_rows.size(); ++i) {
if (m_rows[i].panel == panel) {
if (m_rows[i].is_primary) return; // primary row is non-deletable
m_rows_sizer->Detach(panel);
m_rows.erase(m_rows.begin() + i);
Layout();
@@ -5734,13 +5771,14 @@ void TabPrinter::toggle_options()
// IDEX/IQEX: show carriage config options only when is_imex is enabled
bool is_imex = m_config->opt_bool("is_imex");
for (auto el : {"imex_gantry_count", "imex_tools_per_gantry", "imex_tool_layout",
for (auto el : {"imex_gantry_count", "imex_tools_per_gantry",
"imex_nozzle_clearance_x", "imex_nozzle_clearance_y",
"imex_carriage_margin", "imex_viz_theme"})
"imex_carriage_margin"})
toggle_option(el, is_imex);
if (m_imex_layout_combo) m_imex_layout_combo->Show(is_imex);
if (m_imex_theme_combo) m_imex_theme_combo->Show(is_imex);
if (m_imex_modes_ctrl) m_imex_modes_ctrl->Show(is_imex);
// These lines have custom combo widgets — toggle_line hides the full row
toggle_line("imex_tool_layout", is_imex);
toggle_line("imex_viz_theme", is_imex);
if (m_imex_modes_ctrl) m_imex_modes_ctrl->Show(is_imex);
}
wxString extruder_number;
long val = 1;