From f850912123134b8c0a854c677ef0e28addcfc408 Mon Sep 17 00:00:00 2001 From: Clifford Garwood Date: Mon, 13 Jul 2026 23:56:06 -0400 Subject: [PATCH] fix(imex): mirror across the gantry-row axis on two-gantry printers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Mirror tool reflects across the boundary it shares with the primary's zone, and which boundary that is depends on where the tool sits: - Same gantry: the tools are side by side along X, so the shared boundary is vertical and the reflection negates X. This is what single-gantry IDEX does, and it was the only case the code modelled. - Different gantry: the zones are stacked along Y (front strip vs rear strip), so the shared boundary is horizontal and the reflection negates Y. The part that comes off gantry 1 is a Y-reflection of the tool directly behind it. imex_head_transform() hardcoded diag(-1, 1, 1) for every mirror, as its own TODO acknowledged. Lift the axis to a caller-supplied ImexMirrorAxis; PartPlate picks it from the tool's gantry row. Both reflections keep det = -1, so a mirrored part stays a true mirror image rather than a 180-degree rotation, which would print the primary's part merely turned around. The correct axis removes two workarounds. Both ghost paths special-cased aggregated mirrors to "drop the X reflection, translate 1:1 and bake the flip into the mesh" because reflecting X pushed the ghost off-bed as the primary was dragged. With a Y reflection the X translation is already zero for aggregated tools, so that falls out for free and the special cases are deleted. Preview markers follow the same rule, which also fixes two placement bugs: - Mirrors reflected across a Copy tool's zone edge, falling back to the primary's column when a row had no Copy. In iq-mirror (0:P,1:C,2:M,3:M) the front row has no Copy, so t2 and t3 both fell back and computed the identical X — both drawn on top of each other in t3's zone. A mirror now reflects within its own zone. - The toolhead footprint box flipped to the far side of the nozzle for any mirror right of the primary. That only holds for an X-axis mirror, which reverses the carriage's orientation; a cross-gantry mirror keeps the X orientation of the tool behind it, so its box stays on the same side. Tests cover the cross-gantry and diagonal cases, that the axis is caller-supplied rather than inferred from the offset vector, and that both axes are reflections (det = -1) rather than rotations. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/libslic3r/IMEXHelpers.cpp | 38 +++++++------ src/libslic3r/IMEXHelpers.hpp | 28 ++++++---- src/slic3r/GUI/GCodeViewer.cpp | 71 ++++++++++-------------- src/slic3r/GUI/PartPlate.cpp | 77 +++++++++------------------ tests/libslic3r/test_imex_helpers.cpp | 74 ++++++++++++++++++++++--- 5 files changed, 160 insertions(+), 128 deletions(-) diff --git a/src/libslic3r/IMEXHelpers.cpp b/src/libslic3r/IMEXHelpers.cpp index 878e8433b0..556685fea0 100644 --- a/src/libslic3r/IMEXHelpers.cpp +++ b/src/libslic3r/IMEXHelpers.cpp @@ -354,7 +354,8 @@ int resolve_filament_for_head(const std::map& plate_map, Transform3d imex_head_transform(int /*primary*/, int /*target*/, ImexRole role, const Vec2d& gantry_offset, - const Vec2d& primary_zone_center) + const Vec2d& primary_zone_center, + ImexMirrorAxis mirror_axis) { switch (role) { case ImexRole::Primary: @@ -366,22 +367,27 @@ Transform3d imex_head_transform(int /*primary*/, int /*target*/, ImexRole role, const double len2 = gantry_offset.squaredNorm(); if (len2 < 1e-12) return Transform3d::Identity(); - // True reflection about the zone-boundary plane (perpendicular to X, passing - // through primary_zone_center.x + gantry_offset.x/2). This makes the ghost - // land at the mirrored position within the target zone — matching where the - // mirror tool will actually print — and reflects drag motion so X is inverted - // while Y translates 1:1 by gantry_offset.y. Off-row Mirror targets (e.g. T3 - // on a 2x2) still reflect across this X-plane rather than the diagonal, so - // they visually match their on-row counterparts. - // .linear() = Reflect(X) = diag(-1, 1, 1) - // .translation() = (2*primary_zone_center.x + gantry_offset.x, gantry_offset.y, 0) - // TODO: if a future IMEX printer has Y-oriented gantries, lift this to a - // caller-supplied axis. + // True reflection about the zone-boundary plane between the two zones: perpendicular + // to `mirror_axis`, passing through the midpoint of the zone centers along it. The + // ghost lands at the mirrored position within the target zone — matching where the + // mirror tool actually prints — and drag reflects across that plane, so the mirrored + // axis inverts while the other translates 1:1. + // X: linear = diag(-1, 1, 1), translation = (2*center.x + off.x, off.y, 0) + // Y: linear = diag( 1,-1, 1), translation = (off.x, 2*center.y + off.y, 0) + // Both have det = -1: a real mirror image, not a 180° rotation (which would be + // diag(-1,-1,1), det = +1, and would print the primary's part merely turned around). Transform3d out = Transform3d::Identity(); - out.linear() = Eigen::DiagonalMatrix(-1.0, 1.0, 1.0); - out.translation() = Vec3d(2.0 * primary_zone_center.x() + gantry_offset.x(), - gantry_offset.y(), - 0.0); + if (mirror_axis == ImexMirrorAxis::Y) { + out.linear() = Eigen::DiagonalMatrix(1.0, -1.0, 1.0); + out.translation() = Vec3d(gantry_offset.x(), + 2.0 * primary_zone_center.y() + gantry_offset.y(), + 0.0); + } else { + out.linear() = Eigen::DiagonalMatrix(-1.0, 1.0, 1.0); + out.translation() = Vec3d(2.0 * primary_zone_center.x() + gantry_offset.x(), + gantry_offset.y(), + 0.0); + } return out; } } diff --git a/src/libslic3r/IMEXHelpers.hpp b/src/libslic3r/IMEXHelpers.hpp index e2da46b454..1566e5c83b 100644 --- a/src/libslic3r/IMEXHelpers.hpp +++ b/src/libslic3r/IMEXHelpers.hpp @@ -272,24 +272,32 @@ ImexGantryGrouping group_imex_active_tools_by_gantry(const std::string& active_t // World-space transform composed as `head_xf * primary_instance_world` to place a ghost // copy of the primary into `target`'s frame under `role`. +// Which boundary a Mirror reflects across. Tools on the primary's own gantry sit beside +// it along X, so they mirror across the vertical boundary between their zones. Tools on a +// different gantry sit in front of / behind it along Y, so they mirror across the +// horizontal boundary between the gantry row strips — the part comes off that gantry as a +// Y-reflection of the tool directly behind it, not an X-reflection. Single-gantry printers +// only ever use X. +enum class ImexMirrorAxis { X, Y }; + // `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. // Copy: pure translation by gantry_offset. Ghost tracks primary 1:1 during drag. -// Mirror: true reflection about the zone-boundary plane between primary and target. -// The plane is perpendicular to the primary-row gantry axis (X for all current -// IMEX printers) and passes through `primary_zone_center.x + gantry_offset.x / 2`. -// Ghost origin lands at the mirrored position within the target zone (matches -// where the mirror tool will actually print), and primary drag reflects across -// that plane so the ghost's X moves opposite the primary's X while Y tracks 1:1 -// — i.e. the ghost stays a true mirror as you drag. Geometry is X-flipped -// regardless of gantry_offset direction so off-row Mirror targets (e.g. T3 on -// a 2x2) reflect across the same plane as on-row peers. +// 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` +// and passing through the midpoint of the two zone centers along that axis. The +// ghost origin lands at the mirrored position within the target zone, and primary +// drag reflects across that plane, so the mirrored axis moves opposite the primary +// while the other axis tracks 1:1. // Zero-length gantry_offset degenerates to identity. // Primary: identity. Transform3d imex_head_transform(int primary, int target, ImexRole role, const Vec2d& gantry_offset, - const Vec2d& primary_zone_center = Vec2d::Zero()); + const Vec2d& primary_zone_center = Vec2d::Zero(), + ImexMirrorAxis mirror_axis = ImexMirrorAxis::X); // 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 8b3222a2f2..35d067fd38 100644 --- a/src/slic3r/GUI/GCodeViewer.cpp +++ b/src/slic3r/GUI/GCodeViewer.cpp @@ -1795,15 +1795,6 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) const int pri_zone_col = zone_col(pri_phys_col); const int pri_zone_row = zone_row(pri_phys_row); - // Pre-pass: for each physical row, find the Copy reference physical column. - // The primary row's reference is the primary itself. - std::map row_copy_col; // phys_row → phys_col of copy reference - row_copy_col[pri_phys_row] = pri_phys_col; - for (int i = 0; i < sec_count; ++i) { - if (sec_tool_states[sec_tool_ids[i]] == 2) - row_copy_col[phys_row_of(sec_tool_ids[i])] = phys_col_of(sec_tool_ids[i]); - } - // Primary carriage box float pri_box_offset_x = (pri_zone_col == 0) ? 0.0f : -imex_box_wx; float pri_box_offset_y = -imex_box_wy; @@ -1829,49 +1820,43 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) int sec_zr = zone_row(sec_phys_row); const int sec_state = sec_tool_states[sec_tool_ids[i]]; - // Y: all tools on a row share a Y rail — always zone-relative copy. - float sec_zone_y = bed_y_min + (float)sec_zr * row_strip_height; - float sec_y = sec_zone_y + rel_y; + // 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): + // 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; - // X: Copy → same zone-relative position. - // Mirror → reflect copy reference across the boundary it shares with - // this mirror zone (left or right edge of copy zone depending on side). - const bool is_aggregated = sec_aggregated.count(sec_tool_ids[i]) > 0; - float sec_x; - if (sec_state == 2) { - sec_x = bed_x_min + (float)sec_zc * strip_width + rel_x; - } else if (is_aggregated) { - // Aggregated Span gantry: the secondary strip spans full-X, so the - // mirror axis is the bed centerline (no adjacent copy column to - // reflect across). Reflecting across a zone edge here would push - // the marker off the bed. - sec_x = (bed_x_min + bed_x_max) - prim_pos.x(); - } else { - auto ref_it = row_copy_col.find(sec_phys_row); - int ref_phys_col = (ref_it != row_copy_col.end()) ? ref_it->second : pri_phys_col; - int ref_zc = zone_col(ref_phys_col); - float ref_zone_x = bed_x_min + (float)ref_zc * strip_width; - float ref_abs = ref_zone_x + rel_x; - if (sec_phys_col < ref_phys_col) { - // Mirror left of copy — reflects across copy zone's left edge - sec_x = 2.0f * ref_zone_x - ref_abs; - } else { - // Mirror right of copy — reflects across copy zone's right edge - float ref_zone_right = bed_x_min + (float)(ref_zc + 1) * strip_width; - sec_x = 2.0f * ref_zone_right - ref_abs; - } - } + const float sec_x = (is_mirror && !cross_gantry) + ? sec_zone_x + (strip_width - rel_x) + : sec_zone_x + rel_x; + const float sec_y = (is_mirror && cross_gantry) + ? sec_zone_y + (row_strip_height - rel_y) + : sec_zone_y + rel_y; Vec3f sec_pos{ sec_x, sec_y, prim_pos.z() }; m_sequential_view.m_imex_secondary_markers[i].set_world_position(sec_pos); m_sequential_view.m_imex_secondary_markers[i].set_z_offset(m_z_offset + 0.5f); + // 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. float sec_box_offset_x; - if (sec_state == 2) { - sec_box_offset_x = pri_box_offset_x; - } else { + if (is_mirror && !cross_gantry) { if (sec_phys_col > pri_phys_col) sec_box_offset_x = -imex_box_wx; else if (sec_phys_col < pri_phys_col) sec_box_offset_x = 0.0f; else sec_box_offset_x = pri_box_offset_x; + } else { + sec_box_offset_x = pri_box_offset_x; } float sec_box_offset_y; if (sec_phys_row > pri_phys_row) sec_box_offset_y = -imex_box_wy; diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 2a4b7215a7..921ab7d2d0 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1148,6 +1148,13 @@ 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; + }; + // 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 @@ -1215,36 +1222,15 @@ void PartPlate::calc_imex_ghosts() 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 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. - // - // Flip pivots on the mesh's bbox center, not its local origin — - // models whose local origin sits at a corner (calibration cubes, - // STL imports anchored at min) would otherwise shift left by 2x - // the bbox-center offset. - const Vec2d target_off = center_for(phys); - const Vec3d bc = mo->raw_mesh_bounding_box().center(); - ghost_xf = inst_world; - ghost_xf.linear() = ghost_xf.linear() - * Eigen::DiagonalMatrix(-1.0, 1.0, 1.0); - ghost_xf.translation() += inst_world.linear() - * Vec3d(2.0 * bc.x(), 0.0, 0.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; - } + const auto [pri_center, gantry] = resolve_centers(phys); + // A mirror on the primary's own gantry sits beside it in X, so it reflects across + // the vertical boundary between their zones. A mirror on another gantry sits in + // front of / behind it, so it reflects across the horizontal boundary between the + // 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)); + const Transform3d ghost_xf = head_xf * inst_world; ColorRGBA color = get_imex_head_filament_color(phys); color.a(GHOST_ALPHA); @@ -1313,6 +1299,9 @@ 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; + }; // Build a phys → role map once so the per-ghost loop is a lookup, not a reparse. std::map role_by_phys; @@ -1346,27 +1335,13 @@ void PartPlate::update_imex_ghost_transforms( } 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, pivoting on the bbox center so - // off-origin meshes don't shift sideways. Same math as calc_imex_ghosts. - const Vec2d target_off = center_for(head); - const Vec3d bc = mo->raw_mesh_bounding_box().center(); - ghost_xf = primary_xf; - ghost_xf.linear() = ghost_xf.linear() - * Eigen::DiagonalMatrix(-1.0, 1.0, 1.0); - ghost_xf.translation() += primary_xf.linear() - * Vec3d(2.0 * bc.x(), 0.0, 0.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; - } + const auto [pri_center, gantry] = resolve_centers(head); + // Mirror axis follows the gantry row — see calc_imex_ghosts(). A cross-gantry mirror + // 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)); + 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 e1001929d7..13b76b6fcb 100644 --- a/tests/libslic3r/test_imex_helpers.cpp +++ b/tests/libslic3r/test_imex_helpers.cpp @@ -492,16 +492,74 @@ TEST_CASE("imex_head_transform — mirror reflects model point across primary or REQUIRE_THAT(out.y(), WithinAbs(7.0, 1e-9)); } -TEST_CASE("imex_head_transform — mirror reflection is X-axis regardless of offset direction", "[IMEX]") { - // Mirror's reflection plane normal is the primary-row gantry axis (X), not - // gantry_offset.normalized(). A pure-Y offset (off-row target) must still flip - // X, not Y — otherwise off-row Mirror ghosts end up rotated vs their on-row peers. +TEST_CASE("imex_head_transform — mirror axis comes from the caller, not the offset direction", "[IMEX]") { + // The reflection plane normal is caller-supplied, never inferred from + // gantry_offset.normalized(). A diagonal target (different column AND different gantry) + // has offset components on both axes, so the vector alone cannot pick an axis. const Vec2d offset{0.0, 80.0}; - Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset); const Vec3d in{3.0, 10.0, 0.0}; - const Vec3d out = xf * in; - REQUIRE_THAT(out.x(), WithinAbs(-3.0, 1e-9)); // X flipped about origin - REQUIRE_THAT(out.y(), WithinAbs(90.0, 1e-9)); // Y translated by gantry, unflipped + + const Vec3d as_x = imex_head_transform(0, 1, ImexRole::Mirror, offset, Vec2d::Zero(), + ImexMirrorAxis::X) * in; + REQUIRE_THAT(as_x.x(), WithinAbs(-3.0, 1e-9)); // X flipped about origin + REQUIRE_THAT(as_x.y(), WithinAbs(90.0, 1e-9)); // Y translated by gantry, unflipped + + const Vec3d as_y = imex_head_transform(0, 1, ImexRole::Mirror, offset, Vec2d::Zero(), + ImexMirrorAxis::Y) * in; + REQUIRE_THAT(as_y.x(), WithinAbs(3.0, 1e-9)); // X tracks 1:1 + REQUIRE_THAT(as_y.y(), WithinAbs(70.0, 1e-9)); // Y flipped about origin, then translated +} + +TEST_CASE("imex_head_transform — cross-gantry mirror reflects Y and tracks X", "[IMEX]") { + // Two-gantry machine: gantry 1's zone sits in FRONT of the primary's, stacked along Y. + // The part that comes off it is a Y-reflection of the tool directly behind it, so the + // mirror plane is the horizontal boundary between the two row strips. + // Primary zone centered (50,150), target (50,50) → boundary at y = 100. + const Vec2d offset{0.0, -100.0}; + const Vec2d primary_zone_center{50.0, 150.0}; + Transform3d xf = imex_head_transform(0, 2, ImexRole::Mirror, offset, primary_zone_center, + ImexMirrorAxis::Y); + + // Primary at (20,130) reflects through y=100 to (20,70): X unchanged, Y mirrored. + const Vec3d primary{20.0, 130.0, 0.0}; + const Vec3d ghost_origin = xf * primary; + REQUIRE_THAT(ghost_origin.x(), WithinAbs(20.0, 1e-9)); + REQUIRE_THAT(ghost_origin.y(), WithinAbs(70.0, 1e-9)); + + // Geometry is flipped in Y: a model point +5 in Y lands 5 BELOW the ghost origin. + const Vec3d mapped = xf * (primary + Vec3d(0.0, 5.0, 0.0)); + REQUIRE_THAT(mapped.y(), WithinAbs(65.0, 1e-9)); + + // Drag: primary +X → ghost +X (1:1), primary +Y → ghost -Y (mirrored). + const Vec3d d = (xf * (primary + Vec3d(7.0, 3.0, 0.0))) - ghost_origin; + REQUIRE_THAT(d.x(), WithinAbs( 7.0, 1e-9)); + REQUIRE_THAT(d.y(), WithinAbs(-3.0, 1e-9)); +} + +TEST_CASE("imex_head_transform — diagonal cross-gantry mirror translates X, reflects Y", "[IMEX]") { + // T3 on a 2x2: different column AND different gantry. It mirrors the tool directly + // behind it (T1), so it is a Y-reflection translated into its own column — NOT a + // double flip, which would compose to a 180° rotation and print an unmirrored part. + const Vec2d offset{200.0, -100.0}; + const Vec2d primary_zone_center{100.0, 150.0}; + Transform3d xf = imex_head_transform(0, 3, ImexRole::Mirror, offset, primary_zone_center, + ImexMirrorAxis::Y); + + const Vec3d primary{40.0, 130.0, 0.0}; + const Vec3d ghost = xf * primary; + REQUIRE_THAT(ghost.x(), WithinAbs(240.0, 1e-9)); // 40 + 200: translated, not flipped + REQUIRE_THAT(ghost.y(), WithinAbs(70.0, 1e-9)); // reflected through y=100 +} + +TEST_CASE("imex_head_transform — mirror is a reflection, not a rotation, on both axes", "[IMEX]") { + // det = -1 means chirality flips: an asymmetric part comes off the mirror tool as a + // true mirror image. A 180° rotation (diag(-1,-1,1)) has det = +1 and would print the + // primary's part merely turned around — a different physical result. + const Vec2d offset{120.0, -80.0}; + for (ImexMirrorAxis axis : {ImexMirrorAxis::X, ImexMirrorAxis::Y}) { + Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, Vec2d{10.0, 20.0}, axis); + REQUIRE_THAT(xf.linear().determinant(), WithinAbs(-1.0, 1e-9)); + } } TEST_CASE("imex_head_transform — primary is identity", "[IMEX]") {