mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-10 17:21:10 +00:00
refactor(imex): Mirror reflects about zone-boundary plane, not primary origin
Replace imex_head_transform's fifth argument (Vec3d primary_origin) with a Vec2d primary_zone_center and rewrite the Mirror branch as a true reflection about the plane x = primary_zone_center.x + gantry_offset.x/2. Previous math flipped about the primary's current origin, which: * let the ghost drift out of the target zone as the primary moved, and * made mirrored drag motion track 1:1 with the primary instead of reflecting. The new transform places the ghost at the mirrored position within the target zone (matching where the mirror tool actually prints) and reflects drag so primary +X → ghost -X while Y tracks 1:1 — i.e. the ghost stays a true mirror while the user drags. Off-row Mirror targets (e.g. T3 on a 2x2) still reflect across the same X-plane as on-row peers. PartPlate::calc_imex_ghosts and update_imex_ghost_transforms now feed primary_off (the primary head's zone center) instead of an instance-space Vec3d. Mirror tests rewritten against the new geometric contract: ghost origin at the reflected position, primary drag deltas reflected across the zone-boundary plane. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6a3de6a28f
commit
9a87b96ded
@@ -167,7 +167,7 @@ int resolve_filament_for_head(const std::map<int,int>& 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<double, 3>(-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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,18 +91,22 @@ std::vector<std::pair<int, ImexRole>> 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]") {
|
||||
|
||||
Reference in New Issue
Block a user