From 4966d0fae865bdf848179ecc34b2956a2ee4074c Mon Sep 17 00:00:00 2001 From: Clifford Garwood Date: Fri, 1 May 2026 01:16:02 -0400 Subject: [PATCH] feat(imex): Span tile state for paired-gantry multicolor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/libslic3r/IMEXHelpers.cpp | 93 +++++++++++--- src/libslic3r/IMEXHelpers.hpp | 56 +++++++-- src/slic3r/GUI/PartPlate.cpp | 175 ++++++++++++++++++++++---- src/slic3r/GUI/Tab.cpp | 69 +++++++--- tests/libslic3r/test_imex_helpers.cpp | 130 +++++++++++++++++-- 5 files changed, 451 insertions(+), 72 deletions(-) diff --git a/src/libslic3r/IMEXHelpers.cpp b/src/libslic3r/IMEXHelpers.cpp index ea58fcdddf..21a704cfbd 100644 --- a/src/libslic3r/IMEXHelpers.cpp +++ b/src/libslic3r/IMEXHelpers.cpp @@ -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 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> 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> 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>> 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()) diff --git a/src/libslic3r/IMEXHelpers.hpp b/src/libslic3r/IMEXHelpers.hpp index b29a385404..7b58ba8933 100644 --- a/src/libslic3r/IMEXHelpers.hpp +++ b/src/libslic3r/IMEXHelpers.hpp @@ -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 imex_secondary_logical_slots(const std::vector& active_p const std::map& 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> 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> 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 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). diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index d5d09ff79d..743f8121d3 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -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 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> 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("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 { + 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(-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(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("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 { + 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 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(-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); } } diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 0231364f26..6354b7c880 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -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 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 diff --git a/tests/libslic3r/test_imex_helpers.cpp b/tests/libslic3r/test_imex_helpers.cpp index c20319c425..9fd7633328 100644 --- a/tests/libslic3r/test_imex_helpers.cpp +++ b/tests/libslic3r/test_imex_helpers.cpp @@ -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 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 +}