fix(imex): align the preview marker grid with the plate zone grid

Follow-up to the cross-gantry mirror axis change, from an adversarial review of it.

GCodeViewer builds its own copy of the zone grid to place the sequential-preview
carriage markers, and it must reproduce PartPlate::calc_imex_zones exactly or the
markers drift away from the ghosts they are meant to track. It did not, in two ways,
because sizing the grid and placing a cell answer different questions:

  - Sizing: calc_imex_zones counts every Copy/Mirror tool's OWN column, including the
    non-representatives of an aggregated (Span) gantry -- they still donate a column.
    GCodeViewer only ever saw the representative, so on an aggregated gantry it could
    count fewer columns than the plate and lay its markers out against wider strips.

  - Placement: calc_imex_zones PINS an aggregated cell to the primary's column, because
    that row-strip spans the full bed and has no column of its own. GCodeViewer used the
    representative's own column, which put the marker a strip away from the ghost
    whenever the representative was not column-paired with the primary.

Track the two sets separately: grid_tool_ids sizes the grid from own columns, eff_col_of
pins only aggregated tools when placing. Out-of-grid tool indices are deliberately left
unfiltered -- calc_imex_zones drops them while calc_imex_ghosts keeps them, so no policy
here can agree with both, and a comment says so rather than pretending otherwise.

Also:
  - The mirror-axis rule lived in three copies (two PartPlate lambdas plus an inline
    re-derivation here). Hoist it to imex_mirror_axis_for() so the ghosts and the markers
    cannot drift apart, and unit-test it, including degenerate tools_per_gantry.
  - Drop imex_head_transform's mirror_axis default. A defaulted axis silently hands a
    forgetful caller the X reflection, which is wrong for every cross-gantry tool and
    fails silently -- exactly how a stale test kept certifying the old rule.
  - Replace that stale test: it asserted a diagonal mirror "flips X only", the rule this
    work overturned, and stayed green only because of the default.
  - A secondary sharing the primary's gantry now takes the primary's Y box facing. The
    box shows the side a tool could be hit from, and two tools on one beam can only be
    hit by the same other gantry. No-op on the rear-* layouts, where the hardcoded value
    already matched; on front-* layouts it pointed the box away from the only tools that
    could reach it.
  - Correct two comments that described the aggregated X-frame substitution as a
    reflection plane. It is not one: it exists to zero gantry_offset.x, and removing it
    would push aggregated ghosts a column off their strip.

Verified: 385/385 tests; CLI slice of the IMEX regression project is byte-identical to
the previous commit's G-code apart from the timestamp, confirming this is
visualization-only and cannot affect sliced output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clifford Garwood
2026-07-14 10:01:09 -04:00
co-authored by Claude Opus 4.8
parent 0cb1788b23
commit 90e8ff7cee
5 changed files with 151 additions and 60 deletions
+6
View File
@@ -352,6 +352,12 @@ int resolve_filament_for_head(const std::map<int,int>& 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,
+15 -4
View File
@@ -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
+70 -18
View File
@@ -1658,9 +1658,11 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin)
auto* active_tools_opt = printer_cfg.opt<ConfigOptionStrings>("imex_mode_active_tools");
auto* tpg_opt = printer_cfg.opt<ConfigOptionInt>("imex_tools_per_gantry");
auto* gc_opt = printer_cfg.opt<ConfigOptionInt>("imex_gantry_count");
auto* wx_opt = printer_cfg.opt<ConfigOptionFloat>("imex_nozzle_clearance_x");
auto* wy_opt = printer_cfg.opt<ConfigOptionFloat>("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<int> sec_tool_ids;
std::map<int,int> sec_tool_states; // tool_id -> 2=Copy, 3=Mirror
std::set<int> 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<int> 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<ConfigOptionInt>("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<int,int> phys_col_to_zone_gv, phys_row_to_zone_gv;
int n_active_cols_gv = 1, n_active_rows_gv = 1;
{
std::set<int> 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 });
+18 -18
View File
@@ -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<Vec2d, Vec2d> {
@@ -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<ConfigOptionInt>("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);
}
+42 -20
View File
@@ -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]") {