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]") {