feat(imex): Span tile state for paired-gantry multicolor

Introduce ImexRole::Span as a 5th tile cycle state that declares "this
tool is the multicolor partner of Primary on the same gantry." Encoded
as the `S` role suffix in `imex_mode_active_tools` (e.g. `0:P,1:S,2:M,3:M`).
Disambiguates paired-gantry mc-mirror from 4-independent-copies — both
share the same active_tools shape sans the marker.

Span drives:
- Multicolor block rule: now requires Span on primary's gantry to allow
  multi-color slicing in a parallel mode. Replaces the prior "≥2 tools
  on primary's gantry" check; pre-existing 4-tool multicolor configs
  need T1 flipped to Span.
- Ghost aggregation: one ghost per non-primary gantry when Span is
  present, using the column-paired representative. Aggregated-mirror
  drag tracks primary 1:1 in X (gantries don't share an X rail) with
  X-flip baked into mesh-local frame so geometry still reads as mirrored.
- Zone aggregation: one full-X row strip per non-primary gantry instead
  of per-tool quadrants.
- UI: 5th button in IMEXModesCtrl. Cycle Off→P→C→M→S→Off, only offered
  on multi-gantry printers and only on tiles sharing primary's gantry row.

Single source of pairing truth: group_imex_active_tools_by_gantry in
IMEXHelpers, consumed by ghost factory and zone calculator.

Also fixes the carriage collision strip's X-boundary check, which lacked
the row constraint its Y-boundary counterpart already had — paired-gantry
mc-mirror was drawing a spurious right-edge strip from T3 sitting
diagonally from primary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Clifford Garwood
2026-05-01 01:16:02 -04:00
co-authored by Claude Opus 4.7
parent a8dbdf90de
commit 4966d0fae8
5 changed files with 451 additions and 72 deletions
+78 -15
View File
@@ -86,17 +86,20 @@ std::string imex_multicolor_block_reason(const std::string& parallel_mode,
"editor and assign a Primary role to one tool.";
}
// Walk the active tools once: count how many sit on the primary's gantry, and
// collect the set of distinct gantries spanned by the mode. Both the single-
// gantry check and the within-gantry-swap check fall out of this walk.
// Walk the active tools once: collect the set of distinct gantries spanned by
// the mode and check for an explicit Span marker on the primary's gantry.
// Span declares "this tool is the multicolor partner of primary on the same
// gantry" — without it, two tools on primary's gantry mean two independent
// copies, not a within-gantry toolchange topology, which can't carry
// multicolor in a parallel mode.
const int tpg = std::max(1, tools_per_gantry);
const int primary_gantry = primary_physical / tpg;
int tools_on_primary_gantry = 0;
bool span_on_primary_gantry = false;
std::set<int> active_gantries;
for (const auto& [phys, role] : parse_imex_active_tools(active_tools_str)) {
active_gantries.insert(phys / tpg);
if (phys / tpg == primary_gantry)
++tools_on_primary_gantry;
if (phys / tpg == primary_gantry && role == ImexRole::Span)
span_on_primary_gantry = true;
}
// Single-gantry mode: the active tools all sit on one gantry, so no second
@@ -113,15 +116,17 @@ std::string imex_multicolor_block_reason(const std::string& parallel_mode,
"tools on a second gantry.";
}
// Dual-gantry mode but no within-gantry toolchange topology on the primary's
// own gantry — the slicer would emit toolchanges between filaments but the
// primary gantry has only one active tool, so the swap can't physically happen.
if (tools_on_primary_gantry < 2) {
return "Multi-color prints in IDEX/IQEX parallel modes require at least two tools "
"assigned to the primary tool's gantry — there is no within-gantry "
"toolchange path otherwise. Either reduce the print to a single filament, "
"switch to Primary mode, or define a parallel mode that pairs two tools on "
"the primary gantry.";
// Dual-gantry mode but no Span tool on the primary's gantry — the slicer would
// emit toolchanges between filaments but the mode doesn't declare a within-gantry
// multicolor partner, so the swap can't carry. The user might intend independent
// copies (each gantry tool prints its own object) which is incompatible with
// mid-print multicolor in a parallel mode.
if (!span_on_primary_gantry) {
return "Multi-color prints in IDEX/IQEX parallel modes require a Span tool on the "
"primary's gantry — without one, the mode doesn't declare a within-gantry "
"multicolor partner. Either reduce the print to a single filament, switch to "
"Primary mode, or open the printer settings IDEX/IQEX Modes editor and mark a "
"tool on the primary's gantry as Span.";
}
return {};
}
@@ -183,6 +188,7 @@ std::vector<std::pair<int, ImexRole>> parse_imex_active_tools(const std::string&
const std::string r = tok.substr(colon + 1);
if (r == "P") role = ImexRole::Primary;
else if (r == "M") role = ImexRole::Mirror;
else if (r == "S") role = ImexRole::Span;
// "C" and anything else → Copy.
}
out.emplace_back(phys, role);
@@ -190,6 +196,63 @@ std::vector<std::pair<int, ImexRole>> parse_imex_active_tools(const std::string&
return out;
}
ImexGantryGrouping group_imex_active_tools_by_gantry(const std::string& active_tools_for_mode,
int tools_per_gantry)
{
ImexGantryGrouping out;
const int tpg = std::max(1, tools_per_gantry);
const int primary = imex_primary_tool_for_mode(active_tools_for_mode);
if (primary < 0)
return out;
out.primary_phys = primary;
out.primary_gantry = primary / tpg;
const int primary_col = primary % tpg;
// Bucket every active tool by its gantry index. Skip the primary entry itself
// since it carries no role-driven semantics for grouping (it's just the source).
std::map<int, std::vector<std::pair<int, ImexRole>>> by_gantry;
bool span_seen = false;
for (const auto& [phys, role] : parse_imex_active_tools(active_tools_for_mode)) {
if (phys < 0) continue;
const int g = phys / tpg;
by_gantry[g].emplace_back(phys, role);
if (g == out.primary_gantry && role == ImexRole::Span)
span_seen = true;
}
out.span_on_primary = span_seen;
for (auto& [g, tools] : by_gantry) {
ImexGantryGroup grp;
grp.gantry_index = g;
grp.tools = tools;
// Pick representative: column-paired to primary if active, else lowest phys.
const int paired_phys = g * tpg + primary_col;
auto pick = std::find_if(tools.begin(), tools.end(),
[&](const auto& pr) { return pr.first == paired_phys; });
if (pick == tools.end())
pick = std::min_element(tools.begin(), tools.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
grp.representative_phys = pick->first;
grp.representative_role = pick->second;
// Aggregate iff Span is on primary's gantry, this is a non-primary gantry,
// it has ≥2 tools, and all those tools share the same role (so the merged
// visualization is unambiguous). Mixed-role gantries fall back to per-tool.
const bool same_role = std::all_of(tools.begin(), tools.end(),
[&](const auto& pr) { return pr.second == grp.representative_role; });
grp.aggregate = span_seen
&& g != out.primary_gantry
&& tools.size() >= 2
&& same_role;
out.groups.push_back(std::move(grp));
}
return out;
}
int imex_primary_tool_for_mode(const std::string& active_tools_for_mode)
{
if (active_tools_for_mode.empty())
+48 -8
View File
@@ -111,17 +111,20 @@ bool imex_suppresses_bare_toolchange(const std::string& parallel_mode, unsigned
// Multi-color in a parallel mode requires the firmware to swap tools mid-print on the
// primary gantry while the slaved gantry follows automatically. That works only when
// every used filament has its own dedicated physical head (no MMU lane sharing) AND
// the active mode definition assigns at least 2 roles to tools on the primary's gantry
// (so there's a within-gantry toolchange topology to swap among).
// the active mode definition explicitly marks a Span tool on the primary's gantry
// (Span declares "this tool is the within-gantry multicolor partner of Primary"; it
// disambiguates the multicolor topology from independent same-gantry copies).
//
// Catches:
// - IDEX (1 tool per gantry): only 1 tool on primary's gantry → blocked
// - IQEX 2-tool-active (e.g. T0 primary + T2 copy on different gantries): only 1 tool
// on primary's gantry → blocked
// - IDEX (1 tool per gantry): primary's gantry can never carry a Span partner → blocked
// - IQEX 2-tool-active (e.g. T0 primary + T2 copy on different gantries): no Span on
// primary's gantry → blocked
// - MMU/AFC sharing among used filaments: multiple used filaments routed to same
// physical head → blocked (the slaved gantry can't follow MMU lane swaps)
// - IQEX 4-tool-active (T0+T1 on primary gantry, T2+T3 paired): 2 tools on primary's
// gantry, no MMU sharing → ALLOWED
// - IQEX 4-tool independent copies (T0:P,T1:C,T2:M,T3:M): no Span declared, T1 is an
// independent copy → blocked
// - IQEX paired-gantry multicolor (T0:P,T1:S,T2:M,T3:M): Span on T1 declares the
// multicolor partner, no MMU sharing → ALLOWED
//
// Returns empty string for: non-IMEX (empty parallel_mode), Primary mode, single-color
// prints, or any configuration where multi-color is physically supportable.
@@ -212,7 +215,7 @@ std::vector<int> imex_secondary_logical_slots(const std::vector<int>& active_p
const std::map<int,int>& plate_head_filament_map,
const ConfigOptionInts& pem);
enum class ImexRole { Primary, Copy, Mirror };
enum class ImexRole { Primary, Copy, Mirror, Span };
// Parses `imex_mode_active_tools[mode]` into a list of (physical_head, role) pairs.
// Accepted token forms (comma-separated, whitespace-tolerant):
@@ -220,6 +223,10 @@ enum class ImexRole { Primary, Copy, Mirror };
// "phys:P" — Primary
// "phys:C" — Copy
// "phys:M" — Mirror
// "phys:S" — Span (multicolor partner of Primary on the same gantry; only
// meaningful on tools sharing primary's gantry, and only on a
// multi-gantry printer. Drives the multicolor-allow path and
// paired-gantry ghost/zone aggregation.)
// "phys:???" — unknown role suffix, treated as Copy
// Malformed tokens (unparseable int, negative phys) are skipped.
// NOTE: the bare-token → Copy default differs from `imex_primary_tool_for_mode`,
@@ -228,6 +235,39 @@ enum class ImexRole { Primary, Copy, Mirror };
// in hand and need the full head/role list (e.g. ghost factory/updater).
std::vector<std::pair<int, ImexRole>> parse_imex_active_tools(const std::string& active_tools_for_mode);
// Per-gantry grouping derived from the active_tools string. The single source of
// truth for paired-gantry visualization aggregation: when the primary's gantry
// has at least one Span tool, every non-primary gantry's representative tool
// stands in for the whole gantry (one ghost, one zone strip). Gantries without
// the aggregation trigger keep per-tool semantics.
//
// `representative_phys` is the column-paired tool to primary on that gantry —
// i.e. `gantry_index * tools_per_gantry + (primary_phys % tools_per_gantry)` if
// active, else the lowest active phys on that gantry. Aggregation falls back
// when tools on the same non-primary gantry carry mixed roles (e.g. one Copy
// + one Mirror), since the user explicitly authored two distinct topologies.
struct ImexGantryGroup {
int gantry_index = -1;
int representative_phys = -1;
ImexRole representative_role = ImexRole::Copy;
bool aggregate = false; // collapse this gantry to one ghost/zone
std::vector<std::pair<int, ImexRole>> tools; // every active tool on this gantry
};
struct ImexGantryGrouping {
int primary_phys = -1;
int primary_gantry = -1;
bool span_on_primary = false; // any Span tool on primary's gantry
std::vector<ImexGantryGroup> groups; // every gantry with ≥1 active tool, sorted by index
};
// Groups parsed active tools by gantry (`phys / tools_per_gantry`). Aggregation
// triggers iff `span_on_primary` is true AND a non-primary gantry's tools all
// carry the same role; mixed-role non-primary gantries fall back to per-tool.
// Returns an empty grouping when active_tools_str is empty / has no primary.
ImexGantryGrouping group_imex_active_tools_by_gantry(const std::string& active_tools_for_mode,
int tools_per_gantry);
// World-space transform composed as `head_xf * primary_instance_world` to place a ghost
// copy of the primary into `target`'s frame under `role`.
// `gantry_offset` = center_for(target) - center_for(primary) (XY, in mm).
+154 -21
View File
@@ -644,13 +644,39 @@ void PartPlate::calc_imex_zones()
m_imex_primary_head = idx; break; }
}
// Per-gantry grouping drives Span-based aggregation. When the mode declares a
// Span partner on primary's gantry, non-primary gantries with multiple same-role
// tools collapse to one cell each (placed at primary's column so has_col_sep
// stays false and make_boxes expands the cell into a full-X row-strip).
// Mixed-role / single-tool / no-Span configurations keep per-tool cells.
const ImexGantryGrouping grouping =
group_imex_active_tools_by_gantry(active_tools_str, n_cols);
std::map<int, const ImexGantryGroup*> group_by_gantry;
for (const auto& grp : grouping.groups)
group_by_gantry[grp.gantry_index] = &grp;
// Separate copy and mirror secondary cells using physical coordinates
std::set<std::pair<int,int>> copy_cells, mirror_cells;
for (auto& [idx, state] : tool_states) {
auto [c, r] = tool_to_phys(idx);
if (c == pri_col && r == pri_row) continue; // skip primary
if (state == 2) copy_cells.insert({c, r});
else if (state == 3) mirror_cells.insert({c, r});
if (state == 1) continue; // primary handled separately
const int phys_gantry = idx / n_cols;
auto git = group_by_gantry.find(phys_gantry);
const ImexGantryGroup* grp = (git == group_by_gantry.end()) ? nullptr : git->second;
if (grp && grp->aggregate) {
// Only the representative contributes a cell; non-reps are folded into
// the same row-strip and skipped entirely.
if (idx != grp->representative_phys) continue;
auto [rep_c, r] = tool_to_phys(idx);
(void)rep_c; // intentionally discarded — aggregated cell pins to pri_col
if (state == 2) copy_cells.insert({pri_col, r});
else if (state == 3) mirror_cells.insert({pri_col, r});
} else {
auto [c, r] = tool_to_phys(idx);
if (state == 2) copy_cells.insert({c, r});
else if (state == 3) mirror_cells.insert({c, r});
}
}
// All active secondary cells combined (for separation axis computation)
@@ -854,6 +880,12 @@ void PartPlate::calc_imex_zones()
// Determine which directions have mirror secondaries adjacent to the primary.
// Only mirror tools can cause carriage collisions — they move toward each other.
// Copy tools always move in the same direction, so no collision strip is needed.
//
// Both axes require zone-adjacent AND same row/column on the OTHER axis: a mirror
// diagonally offset from primary (different row AND different column) can't collide
// with primary's carriage on either axis since the gantries don't overlap there.
// Without these checks, paired-gantry mc-mirror (`0:P,1:S,2:M,3:M`) would draw a
// spurious right-edge strip from T3 even though T3 lives on the other gantry's row.
bool has_right_sec = false, has_left_sec = false;
bool has_top_sec = false, has_bottom_sec = false;
for (auto& [idx, state] : tool_states) {
@@ -861,9 +893,9 @@ void PartPlate::calc_imex_zones()
auto [c, r] = tool_to_phys(idx);
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;
// X-boundary strips: mirror zone-adjacent in X, same physical row as primary.
if (zr == pri_row_k && zc == pri_col_k + 1) has_right_sec = true;
if (zr == pri_row_k && 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;
@@ -1056,6 +1088,31 @@ void PartPlate::calc_imex_ghosts()
const auto heads = parse_imex_active_tools(active_tools_str);
// When primary's gantry has a Span tool, paired-gantry aggregation means each
// non-primary gantry is represented by a single ghost — its column-paired rep —
// so non-rep tools on aggregated gantries are skipped. This single source of
// pairing truth keeps ghost emission and zone aggregation in lockstep.
int tpg = 1;
if (auto* tpg_opt = wxGetApp().preset_bundle->printers.get_edited_preset()
.config.option<ConfigOptionInt>("imex_tools_per_gantry"))
tpg = std::max(1, tpg_opt->value);
const ImexGantryGrouping grouping =
group_imex_active_tools_by_gantry(active_tools_str, tpg);
auto is_aggregated = [&](int phys) -> bool {
const int g = phys / tpg;
for (const auto& grp : grouping.groups)
if (grp.gantry_index == g) return grp.aggregate;
return false;
};
auto skip_for_aggregation = [&](int phys) -> bool {
const int g = phys / tpg;
for (const auto& grp : grouping.groups) {
if (grp.gantry_index != g) continue;
return grp.aggregate && phys != grp.representative_phys;
}
return false;
};
// Zone centers are the source of truth for ghost placement: they come from the
// same grid math that paints the colored secondary zones, so a ghost always lands
// in its own tool's zone. extruder_offset is physical-nozzle data and is left at
@@ -1067,6 +1124,25 @@ void PartPlate::calc_imex_ghosts()
};
const Vec2d primary_off = center_for(primary_phys);
// For aggregated gantries the zone is a full-X row strip, so mirror has to
// reflect across the bed centerline (not primary's column-aligned center) and
// copy translates purely along Y. Compose primary_zone_center + gantry_offset
// to land each role correctly inside the strip.
auto bed_ext = get_extents(m_shape);
const double bed_x_center = 0.5 * (bed_ext.min(0) + bed_ext.max(0));
auto resolve_centers = [&](int phys) -> std::pair<Vec2d, Vec2d> {
const Vec2d target_off = center_for(phys);
if (!is_aggregated(phys)) {
// Per-tool: target stays at its column-aligned zone center.
return {primary_off, target_off - primary_off};
}
// Aggregated: shift the X frame onto bed centerline so mirror reflects
// across the whole bed and copy stays at primary's X within the strip.
const Vec2d aggregated_primary{bed_x_center, primary_off.y()};
const Vec2d aggregated_target {bed_x_center, target_off.y()};
return {aggregated_primary, aggregated_target - aggregated_primary};
};
constexpr float GHOST_ALPHA = 0.55f;
// Mesh is object-local and identical across all instances and heads of a given object.
@@ -1101,19 +1177,38 @@ void PartPlate::calc_imex_ghosts()
for (const auto& [phys, role] : heads) {
if (phys == primary_phys) continue;
if (phys >= IMEX_GHOST_MAX_HEADS) continue;
if (role == ImexRole::Span) continue; // within-gantry partner; primary's zone covers it
if (skip_for_aggregation(phys)) continue; // non-rep on an aggregated gantry
const Vec2d gantry = center_for(phys) - primary_off;
// Mirror reflects about the zone-boundary plane through the primary zone
// center, so the ghost lands at the mirrored position within the target
// zone and drag motion inverts X while Y tracks 1:1. Copy ignores it.
const Transform3d head_xf = imex_head_transform(
primary_phys, phys, role, gantry, primary_off);
const bool aggregated_mirror = is_aggregated(phys) && role == ImexRole::Mirror;
Transform3d ghost_xf;
if (aggregated_mirror) {
// Span aggregation: gantries don't share an X rail, so reflecting
// ghost X motion against primary serves no collision purpose and
// makes the ghost drift off-bed when primary drags. Translate 1:1
// with primary (copy-style position) and bake the X-flip into the
// mesh-local frame so geometry still reads as mirrored. Equivalent
// to: ghost_xf = Translate(0, gantry_y, 0) * inst_world * X-flip.
const Vec2d target_off = center_for(phys);
ghost_xf = inst_world;
ghost_xf.linear() = ghost_xf.linear()
* Eigen::DiagonalMatrix<double, 3>(-1.0, 1.0, 1.0);
ghost_xf.translation().y() += target_off.y() - primary_off.y();
} else {
const auto [pri_center, gantry] = resolve_centers(phys);
// Per-tool mirror still reflects about pri_center.x + gantry.x/2 so
// each individual mirror lands inside its own zone. Copy translates by
// `gantry`; aggregated copy resolves gantry.x to 0 → pure-Y translate.
const Transform3d head_xf = imex_head_transform(
primary_phys, phys, role, gantry, pri_center);
ghost_xf = head_xf * inst_world;
}
ColorRGBA color = get_imex_head_filament_color(phys);
color.a(GHOST_ALPHA);
auto ghost = std::make_unique<GLVolume>(color);
ghost->set_instance_transformation(head_xf * inst_world);
ghost->set_instance_transformation(ghost_xf);
ghost->force_transparent = 1;
ghost->force_native_color = 1;
ghost->disabled = 1; // skip selection path
@@ -1151,6 +1246,32 @@ void PartPlate::update_imex_ghost_transforms(
};
const Vec2d primary_off = center_for(primary_phys);
// Same aggregation-aware center resolution as calc_imex_ghosts uses, so live
// drags reflect the ghost across bed centerline (not the rep's column-aligned
// center) when the gantry is aggregated by Span.
int tpg = 1;
if (auto* tpg_opt = wxGetApp().preset_bundle->printers.get_edited_preset()
.config.option<ConfigOptionInt>("imex_tools_per_gantry"))
tpg = std::max(1, tpg_opt->value);
const ImexGantryGrouping grouping =
group_imex_active_tools_by_gantry(active_tools_str, tpg);
auto is_aggregated = [&](int phys) -> bool {
const int g = phys / tpg;
for (const auto& grp : grouping.groups)
if (grp.gantry_index == g) return grp.aggregate;
return false;
};
auto bed_ext = get_extents(m_shape);
const double bed_x_center = 0.5 * (bed_ext.min(0) + bed_ext.max(0));
auto resolve_centers = [&](int phys) -> std::pair<Vec2d, Vec2d> {
const Vec2d target_off = center_for(phys);
if (!is_aggregated(phys))
return {primary_off, target_off - primary_off};
const Vec2d ap{bed_x_center, primary_off.y()};
const Vec2d at{bed_x_center, target_off.y()};
return {ap, at - ap};
};
// Build a phys → role map once so the per-ghost loop is a lookup, not a reparse.
std::map<int, ImexRole> role_by_phys;
for (const auto& [phys, role] : parse_imex_active_tools(active_tools_str))
@@ -1182,13 +1303,25 @@ void PartPlate::update_imex_ghost_transforms(
primary_xf = mo->instances[inst_idx]->get_matrix();
}
const Vec2d gantry = center_for(head) - primary_off;
// Mirror reflects about the zone-boundary plane through the primary zone
// center, so the ghost lands at the mirrored position within the target
// zone and drag motion inverts X while Y tracks 1:1. Copy ignores it.
const Transform3d head_xf = imex_head_transform(
primary_phys, head, role_for(head), gantry, primary_off);
ghost->set_instance_transformation(head_xf * primary_xf);
const ImexRole role = role_for(head);
const bool aggregated_mirror = is_aggregated(head) && role == ImexRole::Mirror;
Transform3d ghost_xf;
if (aggregated_mirror) {
// Span aggregation: drop X reflection — gantries don't share an X rail
// so reflecting motion serves no collision purpose. Translate 1:1 in X
// and bake X-flip into mesh-local frame so geometry still mirrors.
const Vec2d target_off = center_for(head);
ghost_xf = primary_xf;
ghost_xf.linear() = ghost_xf.linear()
* Eigen::DiagonalMatrix<double, 3>(-1.0, 1.0, 1.0);
ghost_xf.translation().y() += target_off.y() - primary_off.y();
} else {
const auto [pri_center, gantry] = resolve_centers(head);
const Transform3d head_xf = imex_head_transform(
primary_phys, head, role, gantry, pri_center);
ghost_xf = head_xf * primary_xf;
}
ghost->set_instance_transformation(ghost_xf);
}
}
+52 -17
View File
@@ -4395,8 +4395,13 @@ void TabPrinter::build()
// 1 = Primary (green) — the tool the slicer generates paths for
// 2 = Copy (blue) — firmware duplicates Primary at an offset
// 3 = Mirror (amber) — firmware mirrors Primary about an axis
// 4 = Span (yellow) — multicolor partner of Primary on the same gantry;
// declares paired-gantry multicolor topology and
// unlocks the multicolor block at slice time. Only
// offered on tools sharing primary's gantry, and
// only on multi-gantry printers (n_rows >= 2).
//
// Active-tools string format: "idx:P,idx:C,idx:M" (backwards-compat: plain "idx" = Primary)
// Active-tools string format: "idx:P,idx:C,idx:M,idx:S" (backwards-compat: plain "idx" = Primary)
// ---------------------------------------------------------------------------
class IMEXModesCtrl : public wxPanel {
public:
@@ -4430,11 +4435,16 @@ public:
info_panel->SetBackgroundColour(GetBackgroundColour());
auto* info_sizer = new wxBoxSizer(wxVERTICAL);
auto* inst = new wxStaticText(info_panel, wxID_ANY,
wxString instructions =
_L("Each mode defines which tool heads participate and their roles. "
"The Primary tool (green) drives all sliced paths. "
"Copy (blue) and Mirror (amber) tools follow the Primary at the firmware level. "
"Click a tool button to cycle its role — clear the Primary before reassigning it."));
"Click a tool button to cycle its role — clear the Primary before reassigning it.");
if (m_n_rows >= 2)
instructions += " " + _L("Span (yellow) marks a tool on the Primary's gantry as the "
"multicolor partner — required to enable multi-color printing in "
"paired-gantry IDEX/IQEX modes.");
auto* inst = new wxStaticText(info_panel, wxID_ANY, instructions);
inst->Wrap(FromDIP(620));
info_sizer->Add(inst, 0, wxBOTTOM, FromDIP(6));
@@ -4442,9 +4452,12 @@ public:
// matched pairs with their labels regardless of system DPI / font scale,
// matching the on-hover ghost tooltip swatch's visual weight.
auto* leg_sizer = new wxBoxSizer(wxHORIZONTAL);
struct { int state; const char* label; } legend[] = {
struct LegendEntry { int state; const char* label; };
std::vector<LegendEntry> legend = {
{1,"Primary"},{2,"Copy"},{3,"Mirror"}
};
if (m_n_rows >= 2)
legend.push_back({4, "Span"}); // only meaningful on multi-gantry printers
const int swatch_side = info_panel->GetCharHeight();
for (auto& l : legend) {
auto* swatch = new wxPanel(info_panel, wxID_ANY, wxDefaultPosition, wxSize(swatch_side, swatch_side));
@@ -4583,8 +4596,9 @@ public:
if (colon != std::string::npos) {
idx = std::stoi(tok.substr(0, colon));
char role = std::toupper((unsigned char)tok[colon + 1]);
if (role == 'C') state = 2;
if (role == 'C') state = 2;
else if (role == 'M') state = 3;
else if (role == 'S') state = 4;
} else {
idx = std::stoi(tok);
}
@@ -4595,18 +4609,25 @@ public:
}
private:
// State 0=inactive, 1=primary, 2=copy, 3=mirror
// State 0=inactive, 1=primary, 2=copy, 3=mirror, 4=span
static wxColour btn_color(int state) {
switch (state) {
case 1: return wxColour(50, 160, 50); // green — Primary
case 2: return wxColour(60, 120, 210); // blue — Copy
case 3: return wxColour(210, 130, 20); // amber — Mirror
default: return wxColour(90, 90, 90); // grey — Inactive
case 1: return wxColour(50, 160, 50); // green — Primary
case 2: return wxColour(60, 120, 210); // blue — Copy
case 3: return wxColour(210, 130, 20); // amber — Mirror
case 4: return wxColour(180, 180, 40); // yellow — Span
default: return wxColour(90, 90, 90); // grey — Inactive
}
}
static wxColour btn_fg(int /*state*/) { return *wxWHITE; }
static const char* state_role(int state) {
switch (state) { case 1: return "P"; case 2: return "C"; case 3: return "M"; default: return ""; }
switch (state) {
case 1: return "P";
case 2: return "C";
case 3: return "M";
case 4: return "S";
default: return "";
}
}
void apply_btn(wxButton* btn, int tool_idx, int state) {
@@ -4754,19 +4775,33 @@ private:
for (auto& row_ref : m_rows) {
if (row_ref.panel != this_panel) continue;
int& st = row_ref.btn_states[btn_pos];
const int tidx = row_ref.btn_tool_idx[btn_pos];
// Check if another button already holds the Primary state
// Check if another button already holds the Primary state,
// and locate the primary's gantry row for Span eligibility.
bool other_primary = false;
for (int j = 0; j < (int)row_ref.btn_states.size(); ++j)
if (j != btn_pos && row_ref.btn_states[j] == 1) { other_primary = true; break; }
int primary_gantry = -1;
for (int j = 0; j < (int)row_ref.btn_states.size(); ++j) {
if (row_ref.btn_states[j] == 1) {
if (j != btn_pos) other_primary = true;
primary_gantry = row_ref.btn_tool_idx[j] / m_n_cols;
}
}
// Span makes sense only on multi-gantry printers, and only
// on a tile sharing primary's gantry (it declares "this tool
// is the within-gantry multicolor partner of Primary").
const bool span_eligible = (m_n_rows >= 2)
&& (primary_gantry >= 0)
&& (tidx / m_n_cols == primary_gantry);
// Advance state, skipping Primary(1) if another tool is already Primary
st = (st + 1) % 4;
// Cycle: 0 → 1 → 2 → 3 → 4 → 0 with skips.
st = (st + 1) % 5;
if (st == 1 && other_primary)
st = 2; // skip Primary → go straight to Copy
if (st == 4 && !span_eligible)
st = 0; // skip Span → wrap to Inactive
// Keep all_tool_states in sync so off-screen tools are preserved
int tidx = row_ref.btn_tool_idx[btn_pos];
if (st == 0)
row_ref.all_tool_states.erase(tidx);
else
+119 -11
View File
@@ -130,12 +130,12 @@ TEST_CASE("imex_multicolor_block_reason — single-color prints never block", "[
TEST_CASE("imex_multicolor_block_reason — IDEX (1 tool per gantry) blocks multi-color", "[IMEX]") {
// Two physical heads, each on its own gantry: tools_per_gantry=1, primary=T0
// on gantry 0, copy=T1 on gantry 1. Primary's gantry has only 1 active tool,
// so there's no within-gantry toolchange path. Block.
// on gantry 0, copy=T1 on gantry 1. Primary's gantry can never carry a Span
// partner (only 1 tool slot), so multicolor in this mode is never declarable.
auto pem = make_pem({0, 1});
const std::string reason = imex_multicolor_block_reason("copy", "0:P,1:C", 1, {0, 1}, pem);
REQUIRE_FALSE(reason.empty());
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("primary tool's gantry"));
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("Span tool"));
}
TEST_CASE("imex_multicolor_block_reason — single-gantry IMEX mode blocks multi-color", "[IMEX]") {
@@ -153,20 +153,31 @@ TEST_CASE("imex_multicolor_block_reason — single-gantry IMEX mode blocks multi
TEST_CASE("imex_multicolor_block_reason — IQEX 2-tool-active mode blocks multi-color", "[IMEX]") {
// 2x2 IQEX, mode has T0 primary + T2 copy (one tool per gantry, different
// gantries). Primary's gantry (gantry 0) has only T0 active → no within-gantry
// swap target. Block.
// gantries). No Span on primary's gantry → multicolor partner not declared. Block.
auto pem = make_pem({0, 1, 2, 3});
const std::string reason = imex_multicolor_block_reason("copy", "0:P,2:C", 2, {0, 2}, pem);
REQUIRE_FALSE(reason.empty());
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("primary tool's gantry"));
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("Span tool"));
}
TEST_CASE("imex_multicolor_block_reason — IQEX 4-tool-active multi-color allowed", "[IMEX]") {
// 2x2 IQEX, all four tools active in the mode: T0 primary + T1 also on gantry 0,
// T2/T3 on gantry 1 mirroring. Primary's gantry has 2 tools active → within-
// gantry toolchange topology is present. Used filaments {0, 1} both on gantry 0.
TEST_CASE("imex_multicolor_block_reason — IQEX 4-tool independent copies block multi-color", "[IMEX]") {
// 2x2 IQEX, T0:P,T1:C,T2:M,T3:M — user's real-world 4-independent-copies job.
// No Span on primary's gantry: T1 is an independent copy, not a multicolor
// partner, so multicolor here would be incoherent (T1 prints its own object,
// it can't sync color changes with T0). Block.
auto pem = make_pem({0, 1, 2, 3});
REQUIRE(imex_multicolor_block_reason("copy", "0:P,1:C,2:M,3:M", 2, {0, 1}, pem).empty());
const std::string reason = imex_multicolor_block_reason("copy", "0:P,1:C,2:M,3:M", 2, {0, 1}, pem);
REQUIRE_FALSE(reason.empty());
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("Span tool"));
}
TEST_CASE("imex_multicolor_block_reason — IQEX paired-gantry multicolor allowed with Span", "[IMEX]") {
// 2x2 IQEX, T0:P,T1:S,T2:M,T3:M — Span on T1 declares the within-gantry multicolor
// partner; T2/T3 mirror with column-pairing T2↔T0, T3↔T1. The slaved gantry can
// follow the primary's mid-print T0↔T1 toolchange because both colors live on the
// same gantry. Allowed.
auto pem = make_pem({0, 1, 2, 3});
REQUIRE(imex_multicolor_block_reason("copy", "0:P,1:S,2:M,3:M", 2, {0, 1}, pem).empty());
}
TEST_CASE("imex_multicolor_block_reason — MMU lane sharing blocks multi-color", "[IMEX]") {
@@ -537,3 +548,100 @@ TEST_CASE("resolve_filament_for_head — no routing returns -1 (ghost color fall
std::map<int,int> override_on_5 = {{5, 7}};
REQUIRE(resolve_filament_for_head(override_on_5, pem, 5) == 6);
}
TEST_CASE("parse_imex_active_tools — Span role parsed from S suffix", "[IMEX]") {
auto out = parse_imex_active_tools("0:P,1:S,2:M,3:M");
REQUIRE(out.size() == 4);
REQUIRE(out[0].first == 0);
REQUIRE(out[0].second == ImexRole::Primary);
REQUIRE(out[1].first == 1);
REQUIRE(out[1].second == ImexRole::Span);
REQUIRE(out[2].first == 2);
REQUIRE(out[2].second == ImexRole::Mirror);
REQUIRE(out[3].first == 3);
REQUIRE(out[3].second == ImexRole::Mirror);
}
TEST_CASE("parse_imex_active_tools — Span suffix whitespace tolerant", "[IMEX]") {
auto out = parse_imex_active_tools(" 0 : P , 1 : S ");
REQUIRE(out.size() == 2);
REQUIRE(out[1].second == ImexRole::Span);
}
TEST_CASE("group_imex_active_tools_by_gantry — paired-gantry mc-mirror aggregates", "[IMEX]") {
// 2x2 IQEX, primary T0, T1 declared Span (multicolor partner on primary's gantry),
// T2/T3 mirror with column-pairing T2↔T0 and T3↔T1.
auto g = group_imex_active_tools_by_gantry("0:P,1:S,2:M,3:M", 2);
REQUIRE(g.primary_phys == 0);
REQUIRE(g.primary_gantry == 0);
REQUIRE(g.span_on_primary);
REQUIRE(g.groups.size() == 2);
REQUIRE(g.groups[0].gantry_index == 0);
REQUIRE_FALSE(g.groups[0].aggregate); // primary's gantry never aggregates
REQUIRE(g.groups[0].tools.size() == 2);
REQUIRE(g.groups[1].gantry_index == 1);
REQUIRE(g.groups[1].aggregate);
REQUIRE(g.groups[1].representative_phys == 2); // column-paired to primary T0
REQUIRE(g.groups[1].representative_role == ImexRole::Mirror);
}
TEST_CASE("group_imex_active_tools_by_gantry — 4 independent copies (no Span) stay per-tool", "[IMEX]") {
// User's real-world 4-copy job. Same active_tools shape as the mc-mirror case but no
// Span marker → each tool keeps its own ghost + zone. This is the disambiguation that
// motivates the Span tile state.
auto g = group_imex_active_tools_by_gantry("0:P,1:C,2:M,3:M", 2);
REQUIRE_FALSE(g.span_on_primary);
REQUIRE(g.groups.size() == 2);
REQUIRE_FALSE(g.groups[0].aggregate);
REQUIRE_FALSE(g.groups[1].aggregate);
}
TEST_CASE("group_imex_active_tools_by_gantry — non-primary gantry with single tool stays per-tool", "[IMEX]") {
// 2-tool mirror on 2x2 IQEX (T0 primary, T2 mirror) — even with Span elsewhere on
// primary's gantry, a 1-tool non-primary gantry has nothing to aggregate.
auto g = group_imex_active_tools_by_gantry("0:P,1:S,2:M", 2);
REQUIRE(g.span_on_primary);
REQUIRE(g.groups.size() == 2);
REQUIRE(g.groups[1].gantry_index == 1);
REQUIRE(g.groups[1].tools.size() == 1);
REQUIRE_FALSE(g.groups[1].aggregate);
}
TEST_CASE("group_imex_active_tools_by_gantry — mixed-role non-primary gantry falls back", "[IMEX]") {
// T2:C, T3:M on the same non-primary gantry — user explicitly authored two distinct
// topologies for that gantry. Aggregation would lose information; stay per-tool.
auto g = group_imex_active_tools_by_gantry("0:P,1:S,2:C,3:M", 2);
REQUIRE(g.span_on_primary);
REQUIRE(g.groups.size() == 2);
REQUIRE_FALSE(g.groups[1].aggregate);
}
TEST_CASE("group_imex_active_tools_by_gantry — IDEX (tpg=1) never aggregates", "[IMEX]") {
// IDEX has 1 tool per gantry by definition — primary's gantry has no Span partner,
// and non-primary gantries each have 1 tool. Aggregation never triggers.
auto g = group_imex_active_tools_by_gantry("0:P,1:M", 1);
REQUIRE_FALSE(g.span_on_primary);
REQUIRE(g.groups.size() == 2);
REQUIRE_FALSE(g.groups[0].aggregate);
REQUIRE_FALSE(g.groups[1].aggregate);
}
TEST_CASE("group_imex_active_tools_by_gantry — empty / no primary returns empty grouping", "[IMEX]") {
auto g = group_imex_active_tools_by_gantry("", 2);
REQUIRE(g.primary_phys == -1);
REQUIRE(g.groups.empty());
}
TEST_CASE("group_imex_active_tools_by_gantry — column pairing picks correct representative", "[IMEX]") {
// Primary at T1 (col=1, gantry=0). On gantry 1, the column-pair is T3 (col=1, gantry=1).
// Representative for gantry 1 must be T3, not T2 — drives mirror geometry through
// the column-paired tool's role.
auto g = group_imex_active_tools_by_gantry("0:S,1:P,2:M,3:M", 2);
REQUIRE(g.primary_phys == 1);
REQUIRE(g.span_on_primary);
REQUIRE(g.groups.size() == 2);
REQUIRE(g.groups[1].gantry_index == 1);
REQUIRE(g.groups[1].representative_phys == 3); // column-paired to primary T1
}