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
+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);
}