diff --git a/src/libslic3r/IMEXHelpers.cpp b/src/libslic3r/IMEXHelpers.cpp index 556685fea0..db986ae114 100644 --- a/src/libslic3r/IMEXHelpers.cpp +++ b/src/libslic3r/IMEXHelpers.cpp @@ -352,6 +352,12 @@ int resolve_filament_for_head(const std::map& plate_map, return first_filament_for_physical_head(pem, physical); } +ImexMirrorAxis imex_mirror_axis_for(int primary_phys, int target_phys, int tools_per_gantry) +{ + const int tpg = std::max(1, tools_per_gantry); + return (target_phys / tpg) == (primary_phys / tpg) ? ImexMirrorAxis::X : ImexMirrorAxis::Y; +} + Transform3d imex_head_transform(int /*primary*/, int /*target*/, ImexRole role, const Vec2d& gantry_offset, const Vec2d& primary_zone_center, diff --git a/src/libslic3r/IMEXHelpers.hpp b/src/libslic3r/IMEXHelpers.hpp index 1566e5c83b..f51656bc30 100644 --- a/src/libslic3r/IMEXHelpers.hpp +++ b/src/libslic3r/IMEXHelpers.hpp @@ -280,11 +280,22 @@ ImexGantryGrouping group_imex_active_tools_by_gantry(const std::string& active_t // only ever use X. enum class ImexMirrorAxis { X, Y }; +// The single source of truth for that choice. Gantry row is `phys / tools_per_gantry`, the +// same derivation PartPlate and GCodeViewer use to build their zone grids. Keep every caller +// on this helper: if the three of them ever disagree about the axis, the plate ghosts and the +// preview markers place the same tool in different spots for the same mode. +// `tools_per_gantry` is clamped to >= 1, so a zero/negative config divides safely. Note the +// clamp lands on "one tool per gantry", meaning every secondary is then cross-gantry and +// mirrors on Y — not "everything on one gantry". That is the honest reading of tpg = 1. +ImexMirrorAxis imex_mirror_axis_for(int primary_phys, int target_phys, int tools_per_gantry); + // `gantry_offset` = center_for(target) - center_for(primary) (XY, in mm). // `primary_zone_center` = XY center of the primary head's zone in world coords. Only // consulted for Mirror; Copy/Primary ignore it. -// `mirror_axis` = axis the Mirror reflection negates. Callers know each tool's gantry row, -// so they pick: same row as primary → X, different row → Y. Ignored by Copy/Primary. +// `mirror_axis` = axis the Mirror reflection negates; get it from imex_mirror_axis_for(). +// Deliberately NOT defaulted: a default would silently hand a forgetful caller the X +// reflection, which is wrong for every cross-gantry tool and would fail silently — the +// ghosts would just quietly go back to mirroring on the wrong axis. Ignored by Copy/Primary. // Copy: pure translation by gantry_offset. Ghost tracks primary 1:1 during drag. // Mirror: true reflection (det = -1, so chirality flips — a real mirror image) about the // zone-boundary plane between primary and target, perpendicular to `mirror_axis` @@ -296,8 +307,8 @@ enum class ImexMirrorAxis { X, Y }; // Primary: identity. Transform3d imex_head_transform(int primary, int target, ImexRole role, const Vec2d& gantry_offset, - const Vec2d& primary_zone_center = Vec2d::Zero(), - ImexMirrorAxis mirror_axis = ImexMirrorAxis::X); + const Vec2d& primary_zone_center, + ImexMirrorAxis mirror_axis); // Slice-time XY shift for printers that delegate copy/mirror placement to firmware. // When `imex_firmware_managed_zones` is on AND the active mode is non-primary, returns diff --git a/src/slic3r/GUI/GCodeViewer.cpp b/src/slic3r/GUI/GCodeViewer.cpp index 35d067fd38..9098afc1f1 100644 --- a/src/slic3r/GUI/GCodeViewer.cpp +++ b/src/slic3r/GUI/GCodeViewer.cpp @@ -1658,9 +1658,11 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) auto* active_tools_opt = printer_cfg.opt("imex_mode_active_tools"); auto* tpg_opt = printer_cfg.opt("imex_tools_per_gantry"); + auto* gc_opt = printer_cfg.opt("imex_gantry_count"); auto* wx_opt = printer_cfg.opt("imex_nozzle_clearance_x"); auto* wy_opt = printer_cfg.opt("imex_nozzle_clearance_y"); int tools_per_gantry = tpg_opt ? std::max(1, tpg_opt->value) : 1; + int gantry_count = gc_opt ? std::max(1, gc_opt->value) : 1; imex_box_wx = wx_opt ? (float)wx_opt->value : 30.0f; imex_box_wy = wy_opt ? (float)wy_opt->value : 30.0f; @@ -1671,6 +1673,13 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) std::vector sec_tool_ids; std::map sec_tool_states; // tool_id -> 2=Copy, 3=Mirror std::set sec_aggregated; // representatives standing in for a whole gantry + // Which tools SIZE the grid is a different question from which tools get a + // MARKER. calc_imex_zones sizes its grid from every Copy/Mirror tool — an + // aggregated gantry's non-representatives still donate their column — while only + // the representative contributes a cell. Track both sets, or the strips here come + // out a different width than the ones painted on the plate and every marker + // drifts from its ghost. + std::vector grid_tool_ids; if (mode_names_opt && active_tools_opt) { for (size_t i = 0; i < mode_names_opt->values.size(); ++i) { if (i < active_tools_opt->values.size() && mode_names_opt->values[i] == mode) { @@ -1690,7 +1699,25 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) sec_tool_ids.push_back(phys_idx); if (aggregated) sec_aggregated.insert(phys_idx); }; + // Out-of-grid tool indices (a stale mode string carried over from a + // printer with more tools) are handled inconsistently by the code we + // must agree with: calc_imex_zones drops them, calc_imex_ghosts keeps + // them. No single policy matches both, so bound only what has to + // match the ZONE grid — the sizing set — and leave pri_tool and the + // markers exactly as they were. Clamping pri_tool instead is a trap: + // the -1 sentinel truncates to gantry 0 in imex_mirror_axis_for and + // flips the marker to the opposite mirror axis from the ghost. + const int grid_slots = tools_per_gantry * gantry_count; for (const auto& grp : grouping.groups) { + // Grid sizing: every in-grid Copy/Mirror tool, aggregated or not — + // matches calc_imex_zones' ac_set/ar_set exactly. + for (const auto& [phys_idx, role] : grp.tools) { + if (phys_idx < 0 || phys_idx >= grid_slots) continue; + if (phys_idx == pri_tool) continue; + if (role == ImexRole::Copy || role == ImexRole::Mirror) + grid_tool_ids.push_back(phys_idx); + } + // Markers: an aggregated non-primary gantry shows only its rep. if (grp.aggregate && grp.gantry_index != grouping.primary_gantry) add_tool(grp.representative_phys, grp.representative_role, true); else @@ -1743,9 +1770,6 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) } } - auto* gc_opt = printer_cfg.opt("imex_gantry_count"); - int gantry_count = gc_opt ? std::max(1, gc_opt->value) : 1; - // Apply the same flip logic as PartPlate::calc_imex_zones() so physical // grid positions match the bed zone visualization. Use option<>() not // opt<>(): enum options load from presets as ConfigOptionEnumGeneric, so @@ -1769,12 +1793,26 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) // zone sizes and positions stay in sync with the bed visualization. const int pri_phys_col = (pri_tool >= 0) ? phys_col_of(pri_tool) : 0; const int pri_phys_row = (pri_tool >= 0) ? phys_row_of(pri_tool) : 0; + + // Grid sizing and cell placement answer different questions, and + // calc_imex_zones answers them differently — match it on both counts or the + // markers drift from the ghosts: + // sizing: every Copy/Mirror tool donates its OWN column/row (ac_set is + // built from tool_to_phys, unpinned), so an aggregated gantry's + // non-representatives still widen the grid. + // placement: an aggregated cell is PINNED to the primary's column + // (copy_cells/mirror_cells insert {pri_col, r}), because the + // row-strip spans full X and has no column of its own. + auto eff_col_of = [&](int tid) { + return sec_aggregated.count(tid) ? pri_phys_col : phys_col_of(tid); + }; + std::map phys_col_to_zone_gv, phys_row_to_zone_gv; int n_active_cols_gv = 1, n_active_rows_gv = 1; { std::set ac, ar; ac.insert(pri_phys_col); ar.insert(pri_phys_row); - for (int tid : sec_tool_ids) { + for (int tid : grid_tool_ids) { ac.insert(phys_col_of(tid)); ar.insert(phys_row_of(tid)); } @@ -1799,7 +1837,7 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) 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 sc = eff_col_of(sec_tool_ids[i]); int sr = phys_row_of(sec_tool_ids[i]); if (sc > pri_phys_col) { pri_box_offset_x = 0.0f; } else if (sc < pri_phys_col) { pri_box_offset_x = -imex_box_wx; } @@ -1814,7 +1852,7 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) 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_col = eff_col_of(sec_tool_ids[i]); int sec_phys_row = phys_row_of(sec_tool_ids[i]); int sec_zc = zone_col(sec_phys_col); int sec_zr = zone_row(sec_phys_row); @@ -1822,18 +1860,20 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) // A carriage stays inside its own zone; a Mirror reflects its // zone-relative offset about that zone's centerline, on the axis of the - // boundary it shares with primary — the same rule the ghosts use (see - // imex_head_transform / ImexMirrorAxis): + // boundary it shares with primary — the same rule the ghosts use: // Copy → tracks primary on both axes. // Mirror, same gantry → reflect X (zones sit side by side). // Mirror, other gantry → reflect Y (zones sit front-to-back); X // tracks primary, since the part off that // gantry is a Y-reflection of the tool // directly behind it. - const bool is_mirror = (sec_state == 3); - const bool cross_gantry = (sec_phys_row != pri_phys_row); - const float sec_zone_x = bed_x_min + (float)sec_zc * strip_width; - const float sec_zone_y = bed_y_min + (float)sec_zr * row_strip_height; + // The axis comes from the shared helper, so the markers can never drift + // out of step with the plate ghosts. + const bool is_mirror = (sec_state == 3); + const bool cross_gantry = imex_mirror_axis_for(pri_tool, sec_tool_ids[i], + tools_per_gantry) == ImexMirrorAxis::Y; + const float sec_zone_x = bed_x_min + (float)sec_zc * strip_width; + const float sec_zone_y = bed_y_min + (float)sec_zr * row_strip_height; const float sec_x = (is_mirror && !cross_gantry) ? sec_zone_x + (strip_width - rel_x) @@ -1845,11 +1885,14 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) 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); - // Only an X-axis (same-gantry) mirror flips which side of the nozzle the - // toolhead body sits on: reflecting the carriage in X reverses its - // orientation. A cross-gantry mirror reflects in Y, so its X orientation - // matches the tool directly behind it and the body stays on the same side - // as primary's — same as a Copy. + // The box shows the side a toolhead could COLLIDE from, not merely which + // way its body hangs. Tools sharing a gantry share an X rail and can + // actually run into each other, and only in a same-gantry (X-axis) mirror + // do they converge — so that is the one case where the secondary's box + // flips to face the primary. Tools on different gantries cannot collide + // in X at all, so a cross-gantry mirror keeps the primary's facing, the + // same as a Copy. Do not "fix" this to follow carriage geometry: a box on + // the far side would point away from the only tool it can hit. float sec_box_offset_x; if (is_mirror && !cross_gantry) { if (sec_phys_col > pri_phys_col) sec_box_offset_x = -imex_box_wx; @@ -1858,10 +1901,19 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) } else { sec_box_offset_x = pri_box_offset_x; } + // Y follows the same collision rule: face the gantry you could hit. A tool + // on ANOTHER row faces the primary's row; a tool on the primary's OWN row + // shares its gantry and can only hit the same other gantry, so it faces + // wherever the primary faces. The old `else` hardcoded -imex_box_wy, which + // happens to equal pri_box_offset_y on the rear-* layouts (primary's row + // sits above the others, so the loop below settles on -imex_box_wy anyway) + // — hence a no-op there. On the front-* layouts the primary flips to 0.0f + // and the hardcoded value pointed the same-gantry secondary away from the + // only tools it could run into. float sec_box_offset_y; if (sec_phys_row > pri_phys_row) sec_box_offset_y = -imex_box_wy; else if (sec_phys_row < pri_phys_row) sec_box_offset_y = 0.0f; - else sec_box_offset_y = -imex_box_wy; + else sec_box_offset_y = pri_box_offset_y; carriage_box_draws.push_back({ sec_pos, s_carriage_colors[(i + 1) % s_carriage_colors.size()], sec_box_offset_x, sec_box_offset_y }); diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 921ab7d2d0..9cd7e5f57a 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1148,11 +1148,8 @@ void PartPlate::calc_imex_ghosts() return false; }; - // Tools on the primary's gantry are beside it along X; tools on another gantry are in - // front of / behind it along Y. A mirror reflects across the boundary separating the two - // zones, so the axis follows the gantry row. Single-gantry printers always land on X. - auto imex_mirror_axis_for = [&](int phys) -> ImexMirrorAxis { - return (phys / tpg) == (primary_phys / tpg) ? ImexMirrorAxis::X : ImexMirrorAxis::Y; + auto mirror_axis_for = [&](int phys) { + return imex_mirror_axis_for(primary_phys, phys, tpg); }; // Zone centers are the source of truth for ghost placement: they come from the @@ -1166,10 +1163,14 @@ 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. + // An aggregated gantry's zone is a full-X row strip, so it has no column of its own to + // align to. Putting BOTH the primary and target X frames on the bed centerline makes + // gantry_offset.x zero, which is what we want: a cross-gantry tool tracks primary's X + // (it reflects in Y, not X — see imex_mirror_axis_for) and only translates along Y. + // NB: the X frame is NOT a reflection plane. Do not "simplify" the bed_x_center + // substitution away on the grounds that the mirror no longer reflects in X — dropping it + // reintroduces a nonzero gantry_offset.x and shoves aggregated ghosts a column off their + // 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 { @@ -1178,8 +1179,6 @@ void PartPlate::calc_imex_ghosts() // 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}; @@ -1229,7 +1228,7 @@ void PartPlate::calc_imex_ghosts() // row strips — the Y axis. Copy translates by `gantry`; aggregated tools resolve // gantry.x to 0, so a cross-gantry mirror tracks primary's X and reflects only Y. const Transform3d head_xf = imex_head_transform( - primary_phys, phys, role, gantry, pri_center, imex_mirror_axis_for(phys)); + primary_phys, phys, role, gantry, pri_center, mirror_axis_for(phys)); const Transform3d ghost_xf = head_xf * inst_world; ColorRGBA color = get_imex_head_filament_color(phys); @@ -1274,9 +1273,10 @@ 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. + // Same aggregation-aware center resolution as calc_imex_ghosts uses, so a live drag + // moves the ghost exactly as a rebuild would place it. See the note there: for an + // aggregated gantry both X frames sit on the bed centerline, which zeroes gantry_offset.x + // so the cross-gantry mirror tracks primary's X and reflects in Y. int tpg = 1; if (auto* tpg_opt = wxGetApp().preset_bundle->printers.get_edited_preset() .config.option("imex_tools_per_gantry")) @@ -1299,8 +1299,8 @@ void PartPlate::update_imex_ghost_transforms( const Vec2d at{bed_x_center, target_off.y()}; return {ap, at - ap}; }; - auto imex_mirror_axis_for = [&](int phys) -> ImexMirrorAxis { - return (phys / tpg) == (primary_phys / tpg) ? ImexMirrorAxis::X : ImexMirrorAxis::Y; + auto mirror_axis_for = [&](int phys) { + return imex_mirror_axis_for(primary_phys, phys, tpg); }; // Build a phys → role map once so the per-ghost loop is a lookup, not a reparse. @@ -1340,7 +1340,7 @@ void PartPlate::update_imex_ghost_transforms( // reflects in Y and tracks primary's X, so drag no longer pushes it off-bed and the // old bake-the-flip-into-the-mesh workaround is unnecessary. const Transform3d head_xf = imex_head_transform( - primary_phys, head, role, gantry, pri_center, imex_mirror_axis_for(head)); + primary_phys, head, role, gantry, pri_center, mirror_axis_for(head)); const Transform3d ghost_xf = head_xf * primary_xf; ghost->set_instance_transformation(ghost_xf); } diff --git a/tests/libslic3r/test_imex_helpers.cpp b/tests/libslic3r/test_imex_helpers.cpp index 70849be304..7b3ca74449 100644 --- a/tests/libslic3r/test_imex_helpers.cpp +++ b/tests/libslic3r/test_imex_helpers.cpp @@ -422,7 +422,7 @@ TEST_CASE("has_non_primary_mmu - empty / single-entry pem", "[IMEX]") { TEST_CASE("imex_head_transform - copy mode is pure translation", "[IMEX]") { const Vec2d offset{120.0, 0.0}; - Transform3d xf = imex_head_transform(0, 1, ImexRole::Copy, offset); + Transform3d xf = imex_head_transform(0, 1, ImexRole::Copy, offset, Vec2d::Zero(), ImexMirrorAxis::X); const Vec3d in{10.0, 20.0, 30.0}; const Vec3d out = xf * in; REQUIRE_THAT(out.x(), WithinAbs(130.0, 1e-9)); @@ -434,7 +434,7 @@ TEST_CASE("imex_head_transform - mirror at origin places ghost at gantry offset" // Primary instance at origin: ghost origin lands at the gantry offset (Copy-style), // and applying mirror flips geometry about origin (= primary's translation). const Vec2d offset{120.0, 0.0}; - Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset); + Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, Vec2d::Zero(), ImexMirrorAxis::X); const Vec3d mapped = xf * Vec3d::Zero(); REQUIRE_THAT(mapped.x(), WithinAbs(120.0, 1e-9)); REQUIRE_THAT(mapped.y(), WithinAbs(0.0, 1e-9)); @@ -448,7 +448,7 @@ TEST_CASE("imex_head_transform - mirror lands ghost at reflected position in tar // (x = 100), i.e. world-space (120, 20, 0) — NOT the Copy-style (180, 20, 0). const Vec2d offset{100.0, 0.0}; const Vec2d primary_zone_center{50.0, 50.0}; - Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, primary_zone_center); + Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, primary_zone_center, ImexMirrorAxis::X); const Vec3d primary{80.0, 20.0, 0.0}; const Vec3d ghost_origin = xf * primary; @@ -467,7 +467,7 @@ TEST_CASE("imex_head_transform - mirror reflects primary drag motion", "[IMEX]") // ghost stops being a true mirror once the primary moves. const Vec2d offset{120.0, 0.0}; const Vec2d primary_zone_center{60.0, 50.0}; - Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, primary_zone_center); + Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, primary_zone_center, ImexMirrorAxis::X); const Vec3d p0{10.0, 20.0, 0.0}; const Vec3d p1{40.0, 15.0, 0.0}; @@ -485,7 +485,7 @@ TEST_CASE("imex_head_transform - mirror reflects model point across primary orig // Primary at origin, offset +X. Model point at +5 X lands 5 left of ghost origin. // Matches the pre-refactor semantics for the special case primary_origin = 0. const Vec2d offset{120.0, 0.0}; - Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset); + Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, Vec2d::Zero(), ImexMirrorAxis::X); const Vec3d in{5.0, 7.0, 0.0}; const Vec3d out = xf * in; REQUIRE_THAT(out.x(), WithinAbs(115.0, 1e-9)); @@ -564,36 +564,58 @@ TEST_CASE("imex_head_transform - mirror is a reflection, not a rotation, on both TEST_CASE("imex_head_transform - primary is identity", "[IMEX]") { const Vec2d offset{120.0, 30.0}; - Transform3d xf = imex_head_transform(0, 0, ImexRole::Primary, offset); + Transform3d xf = imex_head_transform(0, 0, ImexRole::Primary, offset, Vec2d::Zero(), ImexMirrorAxis::X); REQUIRE(xf.isApprox(Transform3d::Identity())); } TEST_CASE("imex_head_transform - mirror with zero offset is identity", "[IMEX]") { const Vec2d offset{0.0, 0.0}; - Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset); + Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, Vec2d::Zero(), ImexMirrorAxis::X); REQUIRE(xf.isApprox(Transform3d::Identity())); } -TEST_CASE("imex_head_transform - mirror on 2x2 off-row target (diagonal offset) flips X only", "[IMEX]") { - // 2x2 IMEX layout: primary T0 at rear-left, target T3 at front-right → diagonal - // gantry_offset. Reflection plane must still be X-axis (same as on-row T1 mirror), - // not the diagonal direction — otherwise T3's ghost reads as rotated ~45° in plan - // view (the bug this test guards against). Primary origin (0,0,0) maps to target - // origin (100,100,0) regardless. +TEST_CASE("imex_head_transform - mirror on 2x2 off-row target reflects Y, not X", "[IMEX]") { + // Supersedes an earlier test that asserted the diagonal target "flips X only". T3 is on + // the OTHER gantry, so it mirrors the tool directly in front of/behind it — a Y + // reflection — and merely translates in X into its own column. Reflecting X here is what + // stacked T2 and T3 on the same marker position and mirrored the ghosts on the wrong axis. const Vec2d offset{100.0, 100.0}; - Transform3d xf = imex_head_transform(0, 3, ImexRole::Mirror, offset); + const ImexMirrorAxis axis = imex_mirror_axis_for(/*primary=*/0, /*target=*/3, /*tpg=*/2); + REQUIRE(axis == ImexMirrorAxis::Y); + Transform3d xf = imex_head_transform(0, 3, ImexRole::Mirror, offset, Vec2d::Zero(), axis); + + // Primary origin still lands on the target zone origin. const Vec3d mapped = xf * Vec3d::Zero(); REQUIRE_THAT(mapped.x(), WithinAbs(100.0, 1e-9)); REQUIRE_THAT(mapped.y(), WithinAbs(100.0, 1e-9)); REQUIRE_THAT(mapped.z(), WithinAbs(0.0, 1e-9)); - // A model point offset +5 X from primary ends up 5 LEFT of the ghost origin - // (X flipped), while Y translates 1:1 (Y unflipped). - const Vec3d in{5.0, 7.0, 0.0}; - const Vec3d out = xf * in; - REQUIRE_THAT(out.x(), WithinAbs(95.0, 1e-9)); // 100 - 5 - REQUIRE_THAT(out.y(), WithinAbs(107.0, 1e-9)); // 100 + 7 + // A model point +5 X / +7 Y from primary: X translates 1:1, Y is flipped. + const Vec3d out = xf * Vec3d{5.0, 7.0, 0.0}; + REQUIRE_THAT(out.x(), WithinAbs(105.0, 1e-9)); // 100 + 5, translated + REQUIRE_THAT(out.y(), WithinAbs(93.0, 1e-9)); // 100 - 7, reflected +} + +TEST_CASE("imex_mirror_axis_for - axis follows the gantry row", "[IMEX]") { + // 2x2: T0/T1 on gantry 0, T2/T3 on gantry 1. + REQUIRE(imex_mirror_axis_for(0, 1, 2) == ImexMirrorAxis::X); // same gantry, beside it + REQUIRE(imex_mirror_axis_for(0, 2, 2) == ImexMirrorAxis::Y); // other gantry, in front + REQUIRE(imex_mirror_axis_for(0, 3, 2) == ImexMirrorAxis::Y); // other gantry, diagonal + REQUIRE(imex_mirror_axis_for(2, 3, 2) == ImexMirrorAxis::X); // primary on gantry 1 + + // Single-gantry IDEX (all 4 tools on one gantry, tpg=4): every tool shares the primary's + // gantry, so mirrors stay on X. This is the pre-existing behavior and must not change. + REQUIRE(imex_mirror_axis_for(0, 1, 4) == ImexMirrorAxis::X); + REQUIRE(imex_mirror_axis_for(0, 3, 4) == ImexMirrorAxis::X); + + // tools_per_gantry = 1: every tool is its own gantry, so any secondary is cross-gantry. + REQUIRE(imex_mirror_axis_for(0, 1, 1) == ImexMirrorAxis::Y); + + // Degenerate tools_per_gantry clamps to 1 rather than dividing by zero. + REQUIRE(imex_mirror_axis_for(0, 1, 0) == ImexMirrorAxis::Y); + REQUIRE(imex_mirror_axis_for(0, 1, -3) == ImexMirrorAxis::Y); + REQUIRE(imex_mirror_axis_for(0, 0, 0) == ImexMirrorAxis::X); } TEST_CASE("resolve_filament_for_head - no routing returns -1 (ghost color fallback)", "[IMEX]") {