diff --git a/src/libslic3r/IMEXHelpers.cpp b/src/libslic3r/IMEXHelpers.cpp index 5ffa82d616..a5827b6c7a 100644 --- a/src/libslic3r/IMEXHelpers.cpp +++ b/src/libslic3r/IMEXHelpers.cpp @@ -167,7 +167,7 @@ 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 Vec3d& primary_origin) + const Vec2d& primary_zone_center) { switch (role) { case ImexRole::Primary: @@ -179,29 +179,22 @@ Transform3d imex_head_transform(int /*primary*/, int /*target*/, ImexRole role, const double len2 = gantry_offset.squaredNorm(); if (len2 < 1e-12) return Transform3d::Identity(); - // Reflection plane normal is the primary-row gantry axis (X for all - // current IMEX printers), not gantry_offset.normalized(). An off-row - // Mirror target (e.g. T3 on a 2x2 where primary is T0) has a diagonal - // gantry_offset; reflecting across that diagonal plane rotates the - // ghost ~45° in plan view, which visually reads as the object laying - // on its side. All Mirror ghosts must flip across the same plane - // (the one the primary-row Primary↔Mirror pair defines), so that - // off-row mirrors look like their on-row counterparts, just placed - // at the off-row position. - // Formula: head_xf = T(gantry) * T(p) * Reflect(n) * T(-p) - // .linear() = Reflect(n) = I - 2 n n^T - // .translation() = gantry + (I - Reflect) * p = gantry + 2 n n^T p - // TODO: if a future IMEX printer has Y-oriented gantries, lift this - // to a caller-supplied axis. - const Vec3d n(1.0, 0.0, 0.0); - Eigen::Matrix3d I3 = Eigen::Matrix3d::Identity(); - Eigen::Matrix3d nnT = n * n.transpose(); - Eigen::Matrix3d L = I3 - 2.0 * nnT; - Vec3d t = Vec3d(gantry_offset.x(), gantry_offset.y(), 0.0) - + (I3 - L) * primary_origin; + // 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. Transform3d out = Transform3d::Identity(); - out.linear() = L; - out.translation() = t; + 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 4b487ab7f6..aa0489d268 100644 --- a/src/libslic3r/IMEXHelpers.hpp +++ b/src/libslic3r/IMEXHelpers.hpp @@ -91,18 +91,22 @@ std::vector> parse_imex_active_tools(const std::string& // 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). -// `primary_origin` = world-space translation of the primary instance (use its matrix's -// translation column). Only consulted for Mirror; Copy/Primary ignore it. +// `primary_zone_center` = XY center of the primary head's zone in world coords. Only +// consulted for Mirror; Copy/Primary ignore it. // Copy: pure translation by gantry_offset. Ghost tracks primary 1:1 during drag. -// Mirror: translate by gantry_offset (Copy-style placement AND motion), then flip the -// ghost's geometry about a plane through primary_origin whose normal is the -// primary-row gantry axis (X for all current IMEX printers), NOT the gantry_offset -// direction. This keeps off-row Mirror ghosts (e.g. T3 on a 2x2) reflected across -// the same plane as on-row mirrors (e.g. T1), just placed at the off-row position. +// 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. // Zero-length gantry_offset degenerates to identity. // Primary: identity. Transform3d imex_head_transform(int primary, int target, ImexRole role, const Vec2d& gantry_offset, - const Vec3d& primary_origin = Vec3d::Zero()); + const Vec2d& primary_zone_center = Vec2d::Zero()); } // namespace Slic3r diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 8df101c560..badc0a4c92 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1092,10 +1092,11 @@ void PartPlate::calc_imex_ghosts() if (phys >= IMEX_GHOST_MAX_HEADS) continue; const Vec2d gantry = center_for(phys) - primary_off; - // Mirror needs the primary instance's origin so the geometric flip happens - // about that point instead of the old gantry midplane; Copy ignores it. + // 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, inst_world.translation()); + primary_phys, phys, role, gantry, primary_off); ColorRGBA color = get_imex_head_filament_color(phys); color.a(GHOST_ALPHA); @@ -1171,11 +1172,11 @@ void PartPlate::update_imex_ghost_transforms( } const Vec2d gantry = center_for(head) - primary_off; - // Mirror uses primary_xf.translation() as the flip plane anchor so the ghost's - // geometry reflects about the primary's current position rather than a fixed - // midplane; Copy ignores this argument. + // 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_xf.translation()); + primary_phys, head, role_for(head), gantry, primary_off); ghost->set_instance_transformation(head_xf * primary_xf); } } diff --git a/tests/libslic3r/test_imex_helpers.cpp b/tests/libslic3r/test_imex_helpers.cpp index 7f660be78f..3f95a3e9d8 100644 --- a/tests/libslic3r/test_imex_helpers.cpp +++ b/tests/libslic3r/test_imex_helpers.cpp @@ -211,43 +211,44 @@ TEST_CASE("imex_head_transform — mirror at origin places ghost at gantry offse REQUIRE_THAT(mapped.z(), WithinAbs(0.0, 1e-9)); } -TEST_CASE("imex_head_transform — mirror flips geometry in-place about primary origin", "[IMEX]") { - // Primary sits at (50, 10, 0). Gantry +120 in X → ghost origin at (170, 10, 0), - // NOT reflected about a midplane. A model point at +5 X from primary origin ends - // up at -5 X from the ghost origin (geometric flip preserved). - const Vec2d offset{120.0, 0.0}; - const Vec3d primary_origin{50.0, 10.0, 0.0}; - Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, primary_origin); +TEST_CASE("imex_head_transform — mirror lands ghost at reflected position in target zone", "[IMEX]") { + // User's example: primary zone is 100x100 centered at (50, 50), primary at (80, 20), + // gantry offset (100, 0) places the mirror zone centered at (150, 50). Ghost origin + // should land at the reflection of the primary through the zone-boundary plane + // (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); - const Vec3d ghost_origin = xf * primary_origin; - REQUIRE_THAT(ghost_origin.x(), WithinAbs(170.0, 1e-9)); // 50 + 120 (Copy-style) - REQUIRE_THAT(ghost_origin.y(), WithinAbs(10.0, 1e-9)); + const Vec3d primary{80.0, 20.0, 0.0}; + const Vec3d ghost_origin = xf * primary; + REQUIRE_THAT(ghost_origin.x(), WithinAbs(120.0, 1e-9)); + REQUIRE_THAT(ghost_origin.y(), WithinAbs(20.0, 1e-9)); - const Vec3d plus5 = primary_origin + Vec3d(5.0, 0.0, 0.0); - const Vec3d mapped = xf * plus5; - REQUIRE_THAT(mapped.x(), WithinAbs(165.0, 1e-9)); // ghost_origin - 5 (flipped) - REQUIRE_THAT(mapped.y(), WithinAbs(10.0, 1e-9)); + // Model point at primary + (+5 X) lands 5 LEFT of ghost origin (geometry still flipped). + const Vec3d mapped = xf * (primary + Vec3d(5.0, 0.0, 0.0)); + REQUIRE_THAT(mapped.x(), WithinAbs(115.0, 1e-9)); + REQUIRE_THAT(mapped.y(), WithinAbs(20.0, 1e-9)); } -TEST_CASE("imex_head_transform — mirror tracks primary motion (same delta as Copy)", "[IMEX]") { - // When the primary moves by some delta, the ghost origin must move by the SAME delta - // (not the reflected delta). This is the core user requirement: mirrored geometry, - // un-mirrored motion. +TEST_CASE("imex_head_transform — mirror reflects primary drag motion", "[IMEX]") { + // Dragging the primary must reflect the ghost across the zone-boundary plane: + // primary +X → ghost -X (mirrored), primary +Y → ghost +Y (1:1). Without this the + // ghost stops being a true mirror once the primary moves. const Vec2d offset{120.0, 0.0}; - const Vec3d p0{0.0, 0.0, 0.0}; - const Vec3d p1{30.0, -5.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 xf0 = imex_head_transform(0, 1, ImexRole::Mirror, offset, p0); - Transform3d xf1 = imex_head_transform(0, 1, ImexRole::Mirror, offset, p1); - - const Vec3d ghost0 = xf0 * p0; - const Vec3d ghost1 = xf1 * p1; - const Vec3d ghost_delta = ghost1 - ghost0; + const Vec3d p0{10.0, 20.0, 0.0}; + const Vec3d p1{40.0, 15.0, 0.0}; + const Vec3d ghost0 = xf * p0; + const Vec3d ghost1 = xf * p1; + const Vec3d ghost_delta = ghost1 - ghost0; const Vec3d primary_delta = p1 - p0; - REQUIRE_THAT(ghost_delta.x(), WithinAbs(primary_delta.x(), 1e-9)); - REQUIRE_THAT(ghost_delta.y(), WithinAbs(primary_delta.y(), 1e-9)); - REQUIRE_THAT(ghost_delta.z(), WithinAbs(primary_delta.z(), 1e-9)); + REQUIRE_THAT(ghost_delta.x(), WithinAbs(-primary_delta.x(), 1e-9)); // X inverted + REQUIRE_THAT(ghost_delta.y(), WithinAbs( primary_delta.y(), 1e-9)); // Y 1:1 + REQUIRE_THAT(ghost_delta.z(), WithinAbs( primary_delta.z(), 1e-9)); } TEST_CASE("imex_head_transform — mirror reflects model point across primary origin", "[IMEX]") {