diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 59261a52e6..7d67d45b67 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -295,6 +295,8 @@ set(lisbslic3r_sources Geometry/VoronoiVisualUtils.hpp IMEXHelpers.cpp IMEXHelpers.hpp + IMEXZones.cpp + IMEXZones.hpp Int128.hpp KDTreeIndirect.hpp Layer.cpp diff --git a/src/libslic3r/IMEXZones.cpp b/src/libslic3r/IMEXZones.cpp new file mode 100644 index 0000000000..15c3fa140a --- /dev/null +++ b/src/libslic3r/IMEXZones.cpp @@ -0,0 +1,381 @@ +#include "libslic3r/IMEXZones.hpp" + +#include +#include +#include + +#include "libslic3r/IMEXHelpers.hpp" + +namespace Slic3r { + +ImexZoneLayout compute_imex_zone_layout(const DynamicPrintConfig& printer_cfg, + const std::string& plate_mode, + const std::string& process_mode, + const BoundingBoxf& bed_extents) +{ + ImexZoneLayout out; + + auto* is_imex_opt = printer_cfg.option("is_imex"); + if (!is_imex_opt || !is_imex_opt->value) + return out; + + // Per-plate mode takes priority over the process preset. + std::string active_mode = plate_mode; + if (active_mode == kImexPrimaryMode && !process_mode.empty()) + active_mode = process_mode; + if (active_mode == kImexPrimaryMode || active_mode.empty()) + return out; + + // Grid dimensions and tool layout from printer config + auto* gantry_opt = printer_cfg.option("imex_gantry_count"); + auto* tpg_opt = printer_cfg.option("imex_tools_per_gantry"); + auto* layout_opt = printer_cfg.option>("imex_tool_layout"); + + int n_cols = tpg_opt ? std::max(1, tpg_opt->value) : 2; + int n_rows = gantry_opt ? std::max(1, gantry_opt->value) : 1; + + // Which corner is T0? flip_x: col 0 is right(max-X); flip_y: row 0 is rear(max-Y) + const ImexToolLayout layout = layout_opt ? layout_opt->value : ImexToolLayout::FrontLeft; + bool flip_x = (layout == ImexToolLayout::FrontRight || layout == ImexToolLayout::RearRight); + bool flip_y = (layout == ImexToolLayout::RearLeft || layout == ImexToolLayout::RearRight); + + // Convert tool index to physical (col=X-index, row=Y-index), col/row 0 = min-X/min-Y + auto tool_to_phys = [&](int idx) -> std::pair { + int raw_col = idx % n_cols, raw_row = idx / n_cols; + return { flip_x ? (n_cols - 1 - raw_col) : raw_col, + flip_y ? (n_rows - 1 - raw_row) : raw_row }; + }; + + int pri_col = 0, pri_row = 0; + + if (n_cols == 1 && n_rows == 1) + return out; // nothing to dim with a single zone + + // Look up secondary tool indices (active in mode, but NOT the primary tool). + // An unresolved mode, and a mode whose row the tools array does not reach, both come + // back as an empty string; the zone_roles guard below then returns an empty layout. + const std::string active_tools_str = find_imex_mode(printer_cfg, active_mode).active_tools; + + // Which heads own a zone cell, and in what role. Roles carry through from + // parse_imex_active_tools() rather than being flattened into small ints, so the + // classification below is a comparison the compiler checks and a new role cannot slip + // through as an unhandled number. + // + // imex_primary_tool_for_mode handles the Primary slot (it owns the bare-legacy-token → + // Primary rule); parse_imex_active_tools fills in the Copy/Mirror secondaries. Mode + // strings use physical T-indices directly; filament routing is separate + // (imex_head_filament_map). + // + // Only Primary / Copy / Mirror land in this map. A Span head shares the primary's zone + // instead of owning one, so it deliberately gets no entry (and hence no zone centre -- + // see ImexZoneLayout::head_zone_centers), and an extra Primary entry beyond the first is + // ignored. Any further role must decide here whether it owns a cell. + std::map zone_roles; + { + const int primary = imex_primary_tool_for_mode(active_tools_str); + if (primary >= 0 && primary < n_rows * n_cols) + zone_roles[primary] = ImexRole::Primary; + for (const auto& [phys_idx, role] : parse_imex_active_tools(active_tools_str)) { + if (phys_idx < 0 || phys_idx >= n_rows * n_cols) continue; + if (phys_idx == primary) continue; + // Exhaustive on purpose, with no default: a role added to the enum has to answer + // "does this own a zone cell?" here, and -Wswitch asks the question at compile + // time instead of letting the head silently vanish from the layout. + switch (role) { + case ImexRole::Copy: + case ImexRole::Mirror: + zone_roles[phys_idx] = role; + break; + case ImexRole::Primary: // an extra Primary beyond the first is ignored + case ImexRole::Span: // shares the primary's zone rather than owning one + break; + } + } + } + + // If the mode name was found but has no tools (e.g. stale process-preset mode on a new + // printer that has no modes defined yet), there is nothing to compute. + if (zone_roles.empty()) + return out; + + // Identify the Primary tool from the mode definition + for (const auto& [idx, role] : zone_roles) { + if (role == ImexRole::Primary) { auto [c, r] = tool_to_phys(idx); pri_col = c; pri_row = r; + out.primary_head = idx; break; } + } + + // No Primary landed inside the grid. Either the mode declares none at all, or its `:P` + // index is off-grid: the modes editor deliberately preserves tool assignments across + // imex_gantry_count changes, so a mode authored on a 4-tool IQEX with Primary on T3 keeps + // that assignment when the printer is reconfigured as a 2-tool IDEX, and the + // `primary < n_rows * n_cols` bound above then drops it from zone_roles. + // + // Every zone, strip, ghost centre and slice offset below is derived from pri_col/pri_row, + // which would still be sitting at their (0,0) initialisers — a position no active tool + // occupies. That yields a primary zone and a secondary zone that both claim the whole bed: + // imex_primary_zone() reports the plate clear while the blocking boxes report it full, and + // under imex_firmware_managed_zones compute_imex_slice_offset() shifts the slice by the bed + // centre. Degrade to "this plate has no IMEX zones" instead — the same answer the earlier + // early returns give, and the one every consumer already handles. + if (out.primary_head < 0) + return ImexZoneLayout{}; + + // Per-gantry grouping drives Span-based aggregation. When the mode declares a + // Span partner on primary's gantry, non-primary gantries with multiple same-role + // tools collapse to one cell each (placed at primary's column so has_col_sep + // stays false and make_boxes expands the cell into a full-X row-strip). + // Mixed-role / single-tool / no-Span configurations keep per-tool cells. + const ImexGantryGrouping grouping = + group_imex_active_tools_by_gantry(active_tools_str, n_cols); + std::map group_by_gantry; + for (const auto& grp : grouping.groups) + group_by_gantry[grp.gantry_index] = &grp; + + // Separate copy and mirror secondary cells using physical coordinates + std::set> copy_cells, mirror_cells; + for (const auto& [idx, role] : zone_roles) { + if (role == ImexRole::Primary) continue; // primary handled separately + + const int phys_gantry = idx / n_cols; + auto git = group_by_gantry.find(phys_gantry); + const ImexGantryGroup* grp = (git == group_by_gantry.end()) ? nullptr : git->second; + + if (grp && grp->aggregate) { + // Only the representative contributes a cell; non-reps are folded into + // the same row-strip and skipped entirely. + if (idx != grp->representative_phys) continue; + auto [rep_c, r] = tool_to_phys(idx); + (void)rep_c; // intentionally discarded — aggregated cell pins to pri_col + if (role == ImexRole::Copy) copy_cells.insert({pri_col, r}); + else if (role == ImexRole::Mirror) mirror_cells.insert({pri_col, r}); + } else { + auto [c, r] = tool_to_phys(idx); + if (role == ImexRole::Copy) copy_cells.insert({c, r}); + else if (role == ImexRole::Mirror) mirror_cells.insert({c, r}); + } + } + + // All active secondary cells combined (for separation axis computation) + std::set> all_secondary; + for (auto& p : copy_cells) all_secondary.insert(p); + for (auto& p : mirror_cells) all_secondary.insert(p); + + double x_min = bed_extents.min(0), x_max = bed_extents.max(0); + double y_min = bed_extents.min(1), y_max = bed_extents.max(1); + + // Zone sizing is based on ACTIVE tool count per axis, not total grid dimensions. + // Inactive tools (absent from zone_roles) donate their bed share to active neighbors. + // Sorted active col/row lists map physical index k → zone index k. + std::vector active_cols_v, active_rows_v; + { + std::set ac_set, ar_set; + for (const auto& [idx, role] : zone_roles) { + auto [c, r] = tool_to_phys(idx); + ac_set.insert(c); ar_set.insert(r); + } + active_cols_v.assign(ac_set.begin(), ac_set.end()); // sorted ascending + active_rows_v.assign(ar_set.begin(), ar_set.end()); + } + int n_active_cols = std::max(1, (int)active_cols_v.size()); + int n_active_rows = std::max(1, (int)active_rows_v.size()); + + std::map col_to_zone, row_to_zone; + for (int k = 0; k < n_active_cols; ++k) col_to_zone[active_cols_v[k]] = k; + for (int k = 0; k < n_active_rows; ++k) row_to_zone[active_rows_v[k]] = k; + + double zone_w = (x_max - x_min) / n_active_cols; + double zone_h = (y_max - y_min) / n_active_rows; + + // Zone index of the primary tool + int pri_col_k = col_to_zone.count(pri_col) ? col_to_zone[pri_col] : 0; + int pri_row_k = row_to_zone.count(pri_row) ? row_to_zone[pri_row] : 0; + + // Zone center per physical head — ghost placement consumes this so that + // ghosts land in their own secondary zone instead of stacking on primary. + // Every active tool (including primary) gets an entry; ghost offset math is + // simply center[head] - center[primary]. + for (const auto& [idx, role] : zone_roles) { + auto [c, r] = tool_to_phys(idx); + int ck = col_to_zone.count(c) ? col_to_zone.at(c) : 0; + int rk = row_to_zone.count(r) ? row_to_zone.at(r) : 0; + out.head_zone_centers[idx] = Vec2d( + x_min + (ck + 0.5) * zone_w, + y_min + (rk + 0.5) * zone_h); + } + + // Separation axes: row-sep = secondaries on a different gantry, col-sep = different column + bool has_row_sep = false, has_col_sep = false; + for (const auto& [sc, sr] : all_secondary) { + if (sr != pri_row) has_row_sep = true; + if (sc != pri_col) has_col_sep = true; + } + + // Build expanded zone rects for a set of cells. + // When secondaries share only a row difference (same column as primary) → expand to full X width. + // When secondaries share only a column difference → expand to full Y height. + // When both axes differ → per-quadrant. + auto make_boxes = [&](const std::set>& cells) -> std::vector { + std::vector boxes; + if (cells.empty()) return boxes; + auto ck = [&](int c) { return col_to_zone.count(c) ? col_to_zone.at(c) : 0; }; + auto rk = [&](int r) { return row_to_zone.count(r) ? row_to_zone.at(r) : 0; }; + if (has_row_sep && !has_col_sep) { + std::set rows; for (auto& [c,r] : cells) rows.insert(r); + for (int sr : rows) { + int k = rk(sr); + boxes.emplace_back(Vec2d(x_min, y_min + k*zone_h), Vec2d(x_max, y_min + (k+1)*zone_h)); + } + } else if (has_col_sep && !has_row_sep) { + std::set cols; for (auto& [c,r] : cells) cols.insert(c); + for (int sc : cols) { + int k = ck(sc); + boxes.emplace_back(Vec2d(x_min + k*zone_w, y_min), Vec2d(x_min + (k+1)*zone_w, y_max)); + } + } else { + for (auto& [sc,sr] : cells) { + int ck_ = ck(sc), rk_ = rk(sr); + boxes.emplace_back(Vec2d(x_min + ck_*zone_w, y_min + rk_*zone_h), + Vec2d(x_min + (ck_+1)*zone_w, y_min + (rk_+1)*zone_h)); + } + } + return boxes; + }; + + out.copy_zones = make_boxes(copy_cells); + out.mirror_zones = make_boxes(mirror_cells); + + // --- Secondary zone blocking --- + // The expanded bounding boxes for all secondary (copy+mirror) zones. check_outside() + // uses these to prevent objects being placed outside the primary zone. + auto push_secondary_box = [&](const std::vector& boxes) { + for (const auto& b : boxes) + out.secondary_zone_boxes.emplace_back(Vec3d(b.min.x(), b.min.y(), -1.0), + Vec3d(b.max.x(), b.max.y(), 1e4)); + }; + push_secondary_box(out.copy_zones); + push_secondary_box(out.mirror_zones); + + // --- Carriage collision danger strips --- + // Only add strips at boundaries of the PRIMARY zone — objects are only placed in the + // primary zone, so secondary-to-secondary boundaries have no relevance. + // + // Strip width is the literal nozzle clearance value on the primary side only: + // right X boundary: [bnd_x - nozzle_clearance_x, bnd_x] + // left X boundary: [bnd_x, bnd_x + nozzle_clearance_x] + // top Y boundary: [bnd_y - nozzle_clearance_y, bnd_y] + // bottom Y boundary:[bnd_y, bnd_y + nozzle_clearance_y] + // + // Strip length matches the primary zone extent (same expansion logic as make_boxes): + // !has_row_sep → full bed height; has_row_sep → primary row only + // !has_col_sep → full bed width; has_col_sep → primary column only + + auto* cw_opt = printer_cfg.option("imex_nozzle_clearance_x"); + auto* ch_opt = printer_cfg.option("imex_nozzle_clearance_y"); + auto* mgn_opt = printer_cfg.option("imex_carriage_margin"); + // Fallbacks mirror the values registered in PrintConfig.cpp: 30.0 for both clearances + // (6664, 6672), 0.0 for the margin (6680). The clearances must NOT fall back to 0.0 -- + // both strip loops below are gated on `carriage_w > 0.0` / `carriage_h > 0.0`, so a zero + // would silently emit no collision strips at all while the preview still draws 30 mm + // toolhead boxes. A config built from the ConfigDef always carries these; a partial or + // hand-built one is the only way to reach the fallback. + double carriage_w = cw_opt ? cw_opt->value : 30.0; + double carriage_h = ch_opt ? ch_opt->value : 30.0; + double margin = mgn_opt ? mgn_opt->value : 0.0; + + // Primary zone extent (the clear printable area): + // row-sep only → full bed width × primary row's Y band + // col-sep only → primary col's X band × full bed height + // both → primary quadrant + // Uses zone indices so inactive tools don't shrink the zone. + double pz_x0 = has_col_sep ? x_min + pri_col_k * zone_w : x_min; + double pz_x1 = has_col_sep ? x_min + (pri_col_k + 1) * zone_w : x_max; + double pz_y0 = has_row_sep ? y_min + pri_row_k * zone_h : y_min; + double pz_y1 = has_row_sep ? y_min + (pri_row_k + 1) * zone_h : y_max; + out.primary_zone_box = BoundingBoxf(Vec2d(pz_x0, pz_y0), Vec2d(pz_x1, pz_y1)); + + auto add_strip = [&](double sx0, double sx1, double sy0, double sy1) { + out.collision_zones.emplace_back(Vec3d(sx0, sy0, -1.0), Vec3d(sx1, sy1, 1e4)); + }; + auto add_margin_band = [&](double sx0, double sx1, double sy0, double sy1) { + out.margin_bands.emplace_back(Vec2d(sx0, sy0), Vec2d(sx1, sy1)); + }; + + // Determine which directions have mirror secondaries adjacent to the primary. + // Only mirror tools can cause carriage collisions — they move toward each other. + // Copy tools always move in the same direction, so no collision strip is needed. + // + // Driven by `mirror_cells`, not by zone_roles: on an AGGREGATED gantry the cell is + // pinned to the primary's column and make_boxes then expands it into a full-width row + // strip, so the painted zone — not any one head's own cell — is what the primary's + // carriage can meet. Reading each head's own cell instead lost the strip whenever the + // representative's column differed from the primary's: at tpg=3, gantry_count=2, mode + // "0:P,1:S,4:M,5:M" the rear gantry paints a full-width mirror strip against the + // primary's rear boundary, yet neither T4 (col 1) nor T5 (col 2) is in the primary's + // column, so no strip and no margin band were emitted at all. + // + // Both axes require zone-adjacent AND same row/column on the OTHER axis: a mirror + // diagonally offset from primary (different row AND different column) can't collide + // with primary's carriage on either axis since the gantries don't overlap there. + // Without these checks, paired-gantry mc-mirror (`0:P,1:S,2:M,3:M`) would draw a + // spurious right-edge strip from T3 even though T3 lives on the other gantry's row. + // + // The adjacency tests (±1) are in ZONE indices so an inactive column or row between + // two active ones cannot hide the boundary they really share (see "inactive tools + // donate their bed share"), while the co-linearity test on the other axis stays in + // physical indices. The two forms agree for any cell that owns a zone — col_to_zone + // and row_to_zone are order-preserving bijections over exactly the active indices — + // so this is one rule written two ways, not two rules. + bool has_right_sec = false, has_left_sec = false; + bool has_top_sec = false, has_bottom_sec = false; + for (const auto& [sc, sr] : mirror_cells) { + int zc = col_to_zone.count(sc) ? col_to_zone.at(sc) : -1; + int zr = row_to_zone.count(sr) ? row_to_zone.at(sr) : -1; + if (zc < 0 || zr < 0) continue; // no zone of its own, so no boundary with primary + // X-boundary strips: mirror zone-adjacent in X, same physical row as primary. + if (zr == pri_row_k && zc == pri_col_k + 1) has_right_sec = true; + if (zr == pri_row_k && zc == pri_col_k - 1) has_left_sec = true; + // Y-boundary strips: mirror zone-adjacent in Y, same physical column as primary. + if (zr == pri_row_k + 1 && sc == pri_col) has_top_sec = true; + if (zr == pri_row_k - 1 && sc == pri_col) has_bottom_sec = true; + } + + // X-axis boundaries (vertical strips, width = nozzle_clearance_x on primary side) + if (carriage_w > 0.0) { + if (has_right_sec) { + double bnd_x = x_min + (pri_col_k + 1) * zone_w; + double strip_inner = bnd_x - carriage_w; + add_strip(strip_inner, bnd_x, pz_y0, pz_y1); + if (margin > 0.0) + add_margin_band(strip_inner - margin, strip_inner, pz_y0, pz_y1); + } + if (has_left_sec) { + double bnd_x = x_min + pri_col_k * zone_w; + double strip_inner = bnd_x + carriage_w; + add_strip(bnd_x, strip_inner, pz_y0, pz_y1); + if (margin > 0.0) + add_margin_band(strip_inner, strip_inner + margin, pz_y0, pz_y1); + } + } + + // Y-axis boundaries (horizontal strips, width = nozzle_clearance_y on primary side) + if (carriage_h > 0.0) { + if (has_top_sec) { + double bnd_y = y_min + (pri_row_k + 1) * zone_h; + double strip_inner = bnd_y - carriage_h; + add_strip(pz_x0, pz_x1, strip_inner, bnd_y); + if (margin > 0.0) + add_margin_band(pz_x0, pz_x1, strip_inner - margin, strip_inner); + } + if (has_bottom_sec) { + double bnd_y = y_min + pri_row_k * zone_h; + double strip_inner = bnd_y + carriage_h; + add_strip(pz_x0, pz_x1, bnd_y, strip_inner); + if (margin > 0.0) + add_margin_band(pz_x0, pz_x1, strip_inner, strip_inner + margin); + } + } + + return out; +} + +} // namespace Slic3r diff --git a/src/libslic3r/IMEXZones.hpp b/src/libslic3r/IMEXZones.hpp new file mode 100644 index 0000000000..d0c0e29912 --- /dev/null +++ b/src/libslic3r/IMEXZones.hpp @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include +#include + +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/PrintConfig.hpp" + +namespace Slic3r { + +// Where every IMEX bed zone, carriage collision strip and advisory margin band sits, in +// plate-local millimetres. Purely geometric: no rendering, no clipping against the bed +// outline, no GUI types. PartPlate turns the rectangles into GLModels (clipping each one +// to the bed polygon as it goes) and copies the boxes straight into the members that +// check_outside() consults, so this struct is the whole answer to "which boxes". +// +// Every list is empty and `primary_zone_box` unset when no parallel mode is in effect — +// IMEX off, Primary mode, a 1x1 tool grid, a mode whose tool roster is empty, or a mode +// whose Primary tool is not on the current grid (no `:P` marker, or a `:P` index left +// over from a wider grid). The whole layout hangs off the primary's cell, so without one +// there is nothing to divide and consumers must see the same "no zones" answer. +struct ImexZoneLayout +{ + // Physical T-index of the active mode's Primary tool; -1 when the layout is empty. + int primary_head = -1; + + // Physical head -> XY centre of that head's zone. One entry per tool the mode makes + // active, Primary included. A Span tool has no entry: it shares the primary's zone + // rather than owning one. Ghost placement offsets by centre[head] - centre[primary]. + // + // EXCEPTION, read before using the X component. These centres are built from the head's + // OWN grid cell, but an AGGREGATED representative's zone rectangle is pinned to the + // primary's column and then expanded to a full-width row strip. So for an aggregated head + // whose own column differs from the primary's, centre[head].x names a column its painted + // zone does not have, and `centre[head] - centre[primary]` carries a bogus X offset. + // Reachable at imex_tools_per_gantry >= 3 when the primary-column tool on the aggregated + // gantry is inactive (e.g. tpg=3, gantry_count=2, mode "0:P,1:S,4:M,5:M"). + // Both current consumers pin X to the primary's centre for aggregated heads -- + // PartPlate::calc_imex_ghosts() via bed_x_center, GCodeViewer via sec_center_of() -- and a + // new consumer must do the same until this is either pinned here or the aggregate flag is + // exposed on the layout. + std::map head_zone_centers; + + // The clear printable area — the primary tool's zone. Objects go here and nowhere else. + std::optional primary_zone_box; + + // Copy / Mirror zone rectangles, already expanded along whichever axis carries no + // separation: secondaries differing from primary only by row give full-bed-width + // strips, only by column give full-bed-height strips, and differing on both axes give + // per-tool quadrants. + std::vector copy_zones; + std::vector mirror_zones; + + // `copy_zones` followed by `mirror_zones`, as full-height boxes for the placement + // check. An object overlapping one of these is outside the primary zone. + std::vector secondary_zone_boxes; + + // Carriage danger strips lying just inside the primary zone's boundary, one per + // boundary that faces an adjacent Mirror ZONE -- an aggregated gantry contributes the + // one pinned zone it paints, not one per head -- in right / left / top / bottom order. + // Width is the literal nozzle clearance for that axis. Copy tools never contribute: + // they travel in the same direction as the primary and cannot close on it. + std::vector collision_zones; + + // Advisory (non-blocking) bands immediately inside each collision strip, present only + // when imex_carriage_margin > 0. Same boundary order as `collision_zones`. + std::vector margin_bands; +}; + +// Computes the IMEX zone layout for one plate. +// +// `printer_cfg` — the edited printer preset's config. Read for is_imex, the tool grid +// (imex_gantry_count / imex_tools_per_gantry / imex_tool_layout), the +// mode roster (imex_mode_names / imex_mode_active_tools) and the strip +// widths (imex_nozzle_clearance_x / _y, imex_carriage_margin). A missing +// option falls back to the same default the caller would have seen. +// `plate_mode` — the plate's own IMEX mode. Wins over the process preset unless it is +// `kImexPrimaryMode`. +// `process_mode` — the process preset's `imex_parallel_mode`, used only as the fallback +// when `plate_mode` is `kImexPrimaryMode`. Pass "" when unavailable. +// `bed_extents` — XY extents of the plate shape, in plate-local mm. Zones subdivide it. +// +// Zone sizing keys off the count of ACTIVE tools per axis rather than the grid dimensions, +// so a tool the mode leaves out donates its share of the bed to its active neighbours. +ImexZoneLayout compute_imex_zone_layout(const DynamicPrintConfig& printer_cfg, + const std::string& plate_mode, + const std::string& process_mode, + const BoundingBoxf& bed_extents); + +} // namespace Slic3r diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index f67316980a..5763b64dd5 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -24,6 +24,7 @@ add_executable(${_TEST_NAME}_tests test_fill_plane_path.cpp test_geometry.cpp test_imex_helpers.cpp + test_imex_zones.cpp test_multimaterial_segmentation.cpp test_placeholder_parser.cpp test_polygon.cpp diff --git a/tests/libslic3r/test_imex_zones.cpp b/tests/libslic3r/test_imex_zones.cpp new file mode 100644 index 0000000000..4946afc27a --- /dev/null +++ b/tests/libslic3r/test_imex_zones.cpp @@ -0,0 +1,571 @@ +#include + +#include +#include + +#include "libslic3r/IMEXHelpers.hpp" +#include "libslic3r/IMEXZones.hpp" +#include "libslic3r/PrintConfig.hpp" + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; + +namespace { + +// A square 200x200 bed keeps every expected coordinate an exact round number, so a +// failure reports a wrong decision rather than a rounding difference. +const BoundingBoxf kBed{ Vec2d(0.0, 0.0), Vec2d(200.0, 200.0) }; + +constexpr const char* kMode = "parallel"; + +struct PrinterCfg +{ + int gantry_count = 1; + int tools_per_gantry = 2; + ImexToolLayout tool_layout = ImexToolLayout::FrontLeft; + std::string active_tools; // the imex_mode_active_tools entry for kMode + double clearance_x = 0.0; + double clearance_y = 0.0; + double margin = 0.0; + bool is_imex = true; +}; + +// Builds the subset of the printer preset compute_imex_zone_layout actually reads. Every +// key is set explicitly so no assertion below depends on a PrintConfig default. +DynamicPrintConfig make_cfg(const PrinterCfg& p) +{ + DynamicPrintConfig cfg; + cfg.set_key_value("is_imex", new ConfigOptionBool(p.is_imex)); + cfg.set_key_value("imex_gantry_count", new ConfigOptionInt(p.gantry_count)); + cfg.set_key_value("imex_tools_per_gantry", new ConfigOptionInt(p.tools_per_gantry)); + cfg.set_key_value("imex_tool_layout", new ConfigOptionEnum(p.tool_layout)); + // Slot 0 is the reserved Primary mode, which never carries tools; kMode is slot 1. + cfg.set_key_value("imex_mode_names", + new ConfigOptionStrings(std::vector{ kImexPrimaryMode, kMode })); + cfg.set_key_value("imex_mode_active_tools", + new ConfigOptionStrings(std::vector{ std::string(), p.active_tools })); + cfg.set_key_value("imex_nozzle_clearance_x", new ConfigOptionFloat(p.clearance_x)); + cfg.set_key_value("imex_nozzle_clearance_y", new ConfigOptionFloat(p.clearance_y)); + cfg.set_key_value("imex_carriage_margin", new ConfigOptionFloat(p.margin)); + return cfg; +} + +ImexZoneLayout layout_for(const PrinterCfg& p, + const std::string& plate_mode = kMode, + const std::string& process_mode = std::string()) +{ + return compute_imex_zone_layout(make_cfg(p), plate_mode, process_mode, kBed); +} + +void check_rect(const BoundingBoxf& b, double x0, double y0, double x1, double y1) +{ + CHECK_THAT(b.min.x(), WithinAbs(x0, 1e-9)); + CHECK_THAT(b.min.y(), WithinAbs(y0, 1e-9)); + CHECK_THAT(b.max.x(), WithinAbs(x1, 1e-9)); + CHECK_THAT(b.max.y(), WithinAbs(y1, 1e-9)); +} + +void check_box_xy(const BoundingBoxf3& b, double x0, double y0, double x1, double y1) +{ + CHECK_THAT(b.min.x(), WithinAbs(x0, 1e-9)); + CHECK_THAT(b.min.y(), WithinAbs(y0, 1e-9)); + CHECK_THAT(b.max.x(), WithinAbs(x1, 1e-9)); + CHECK_THAT(b.max.y(), WithinAbs(y1, 1e-9)); +} + +void check_point(const Vec2d& p, double x, double y) +{ + CHECK_THAT(p.x(), WithinAbs(x, 1e-9)); + CHECK_THAT(p.y(), WithinAbs(y, 1e-9)); +} + +bool is_empty(const ImexZoneLayout& l) +{ + return l.primary_head == -1 + && !l.primary_zone_box.has_value() + && l.head_zone_centers.empty() + && l.copy_zones.empty() + && l.mirror_zones.empty() + && l.secondary_zone_boxes.empty() + && l.collision_zones.empty() + && l.margin_bands.empty(); +} + +} // namespace + +// D1 regression: both strip loops are gated on `carriage_w > 0.0`, so a clearance fallback of +// 0.0 would emit no collision strips at all -- the geometry that keeps two carriages from +// meeting -- while the preview still drew 30 mm toolhead boxes. The fallback must match the +// value registered in PrintConfig.cpp (30.0), not zero. +// +// So the strip's WIDTH is the claim, not its existence: a fallback of 0.1 would leave the zone +// list non-empty and the carriages a nozzle's width apart. 1 gantry x 2 tools on the 200mm bed +// puts the column boundary at x = 100, and the strip lies one clearance inside the primary's +// column, so the registered 30.0 is the rectangle x [70, 100] over the primary's full depth. +TEST_CASE("a config missing the nozzle clearance keys falls back to the registered clearance", + "[IMEXZones]") +{ + // Deliberately NOT via make_cfg(): the point is a config that never carries the keys. + // imex_tool_layout is left out too, so the layout falls back to FrontLeft and T0 owns the + // left column -- the same corner every other case in this file starts from. + DynamicPrintConfig cfg; + cfg.set_key_value("is_imex", new ConfigOptionBool(true)); + cfg.set_key_value("imex_gantry_count", new ConfigOptionInt(1)); + cfg.set_key_value("imex_tools_per_gantry", new ConfigOptionInt(2)); + cfg.set_key_value("imex_mode_names", new ConfigOptionStrings({ kMode })); + cfg.set_key_value("imex_mode_active_tools", new ConfigOptionStrings({ "0:P,1:M" })); + + const ImexZoneLayout l = compute_imex_zone_layout(cfg, kMode, std::string(), kBed); + + REQUIRE(l.primary_head == 0); + REQUIRE(l.primary_zone_box.has_value()); + check_rect(*l.primary_zone_box, 0.0, 0.0, 100.0, 200.0); + + // One X boundary, one strip, 30mm wide against it. + REQUIRE(l.collision_zones.size() == 1); + check_box_xy(l.collision_zones[0], 70.0, 0.0, 100.0, 200.0); + + // The margin's own fallback is 0.0, and a zero margin raises no band. + CHECK(l.margin_bands.empty()); +} + +TEST_CASE("an IDEX mirror pair splits the bed into two columns", "[IMEXZones]") +{ + // 1 gantry x 2 tools: the classic IDEX case. T1 mirrors T0, so the split is on X and + // the boundary between the two zones carries a carriage danger strip. + PrinterCfg p; + p.gantry_count = 1; + p.tools_per_gantry = 2; + p.active_tools = "0:P,1:M"; + p.clearance_x = 5.0; + p.margin = 2.0; + + const ImexZoneLayout l = layout_for(p); + + REQUIRE(l.primary_head == 0); + REQUIRE(l.primary_zone_box.has_value()); + check_rect(*l.primary_zone_box, 0.0, 0.0, 100.0, 200.0); + + CHECK(l.copy_zones.empty()); + REQUIRE(l.mirror_zones.size() == 1); + check_rect(l.mirror_zones[0], 100.0, 0.0, 200.0, 200.0); + + // Both heads get a zone centre; the ghost offset is the difference between them. + REQUIRE(l.head_zone_centers.size() == 2); + check_point(l.head_zone_centers.at(0), 50.0, 100.0); + check_point(l.head_zone_centers.at(1), 150.0, 100.0); + + // The mirror zone is off limits to objects, over the full Z range. + REQUIRE(l.secondary_zone_boxes.size() == 1); + check_box_xy(l.secondary_zone_boxes[0], 100.0, 0.0, 200.0, 200.0); + CHECK_THAT(l.secondary_zone_boxes[0].min.z(), WithinAbs(-1.0, 1e-9)); + CHECK(l.secondary_zone_boxes[0].max.z() > 1000.0); + + // Danger strip sits inside the primary zone, against the boundary, one clearance wide. + REQUIRE(l.collision_zones.size() == 1); + check_box_xy(l.collision_zones[0], 95.0, 0.0, 100.0, 200.0); + + // The advisory margin band abuts the strip on the primary side, one margin wide. + REQUIRE(l.margin_bands.size() == 1); + check_rect(l.margin_bands[0], 93.0, 0.0, 95.0, 200.0); +} + +TEST_CASE("a copy secondary claims a zone but raises no collision strip", "[IMEXZones]") +{ + // Copy tools travel in the same direction as the primary and can never close on it, + // so the shared boundary needs no danger strip -- only mirrors do. + PrinterCfg p; + p.gantry_count = 1; + p.tools_per_gantry = 2; + p.active_tools = "0:P,1:C"; + p.clearance_x = 5.0; + p.margin = 2.0; + + const ImexZoneLayout l = layout_for(p); + + REQUIRE(l.copy_zones.size() == 1); + check_rect(l.copy_zones[0], 100.0, 0.0, 200.0, 200.0); + CHECK(l.mirror_zones.empty()); + CHECK(l.secondary_zone_boxes.size() == 1); + CHECK(l.collision_zones.empty()); + CHECK(l.margin_bands.empty()); +} + +TEST_CASE("the tool layout decides which corner the primary zone occupies", "[IMEXZones]") +{ + // imex_tool_layout names the bed corner T0 sits in. With all four tools of a 2x2 IQEX + // active the bed splits into quadrants, so the primary quadrant is a direct readout of + // how tool indices were mapped onto columns and rows. + struct Case { ImexToolLayout layout; const char* name; double x0, y0, x1, y1; }; + const Case cases[] = { + { ImexToolLayout::FrontLeft, "FrontLeft", 0.0, 0.0, 100.0, 100.0 }, + { ImexToolLayout::FrontRight, "FrontRight", 100.0, 0.0, 200.0, 100.0 }, + { ImexToolLayout::RearLeft, "RearLeft", 0.0, 100.0, 100.0, 200.0 }, + { ImexToolLayout::RearRight, "RearRight", 100.0, 100.0, 200.0, 200.0 }, + }; + + for (const Case& c : cases) { + DYNAMIC_SECTION(c.name) { + PrinterCfg p; + p.gantry_count = 2; + p.tools_per_gantry = 2; + p.tool_layout = c.layout; + p.active_tools = "0:P,1:C,2:M,3:M"; + + const ImexZoneLayout l = layout_for(p); + + REQUIRE(l.primary_zone_box.has_value()); + check_rect(*l.primary_zone_box, c.x0, c.y0, c.x1, c.y1); + // The primary's zone centre must agree with its zone. + REQUIRE(l.head_zone_centers.count(0) == 1); + check_point(l.head_zone_centers.at(0), 0.5 * (c.x0 + c.x1), 0.5 * (c.y0 + c.y1)); + // Three secondaries, each with its own quadrant. + CHECK(l.secondary_zone_boxes.size() == 3); + } + } +} + +TEST_CASE("a 2x2 IQEX with independent secondaries gives one quadrant per tool", "[IMEXZones]") +{ + // No Span is declared, so nothing aggregates: T1, T2 and T3 each keep their own cell. + // Only T2 shares the primary's column, so it alone can collide with the primary + // carriage -- the diagonal T3 cannot, and must not raise a strip of its own. + PrinterCfg p; + p.gantry_count = 2; + p.tools_per_gantry = 2; + p.active_tools = "0:P,1:C,2:M,3:M"; + p.clearance_x = 6.0; + p.clearance_y = 4.0; + + const ImexZoneLayout l = layout_for(p); + + REQUIRE(l.primary_zone_box.has_value()); + check_rect(*l.primary_zone_box, 0.0, 0.0, 100.0, 100.0); + + REQUIRE(l.copy_zones.size() == 1); + check_rect(l.copy_zones[0], 100.0, 0.0, 200.0, 100.0); + + REQUIRE(l.mirror_zones.size() == 2); + check_rect(l.mirror_zones[0], 0.0, 100.0, 100.0, 200.0); + check_rect(l.mirror_zones[1], 100.0, 100.0, 200.0, 200.0); + + // Blocking boxes are the copy rects followed by the mirror rects, so the placement + // check and the rendered overlay always describe the same three areas. + REQUIRE(l.secondary_zone_boxes.size() == 3); + check_box_xy(l.secondary_zone_boxes[0], 100.0, 0.0, 200.0, 100.0); + check_box_xy(l.secondary_zone_boxes[1], 0.0, 100.0, 100.0, 200.0); + check_box_xy(l.secondary_zone_boxes[2], 100.0, 100.0, 200.0, 200.0); + + // T2 is directly behind the primary: one strip on the primary's rear boundary, + // clearance_y deep, spanning the primary zone's width. T3 contributes nothing. + REQUIRE(l.collision_zones.size() == 1); + check_box_xy(l.collision_zones[0], 0.0, 96.0, 100.0, 100.0); + CHECK(l.margin_bands.empty()); // margin defaults to 0 +} + +TEST_CASE("a Span partner collapses the far gantry into one full-width strip", "[IMEXZones]") +{ + // Paired-gantry multicolor: T1 is the primary's within-gantry Span partner, so the + // second gantry acts as a single mirrored unit rather than two independent tools. Its + // two mirrors merge into one row strip spanning the whole bed width, and the primary + // zone widens to match. + PrinterCfg p; + p.gantry_count = 2; + p.tools_per_gantry = 2; + p.active_tools = "0:P,1:S,2:M,3:M"; + p.clearance_x = 6.0; + p.clearance_y = 4.0; + + const ImexZoneLayout l = layout_for(p); + + REQUIRE(l.primary_head == 0); + REQUIRE(l.primary_zone_box.has_value()); + check_rect(*l.primary_zone_box, 0.0, 0.0, 200.0, 100.0); + + REQUIRE(l.mirror_zones.size() == 1); + check_rect(l.mirror_zones[0], 0.0, 100.0, 200.0, 200.0); + CHECK(l.copy_zones.empty()); + CHECK(l.secondary_zone_boxes.size() == 1); + + // A Span tool prints in the primary's own zone, so it owns no zone centre of its own. + CHECK(l.head_zone_centers.count(1) == 0); + check_point(l.head_zone_centers.at(0), 50.0, 50.0); + check_point(l.head_zone_centers.at(2), 50.0, 150.0); + check_point(l.head_zone_centers.at(3), 150.0, 150.0); + + // Regression guard: T3 sits on the other gantry's row, so the gantries never overlap + // in X and no right-edge strip may be drawn. Only the rear boundary is dangerous, and + // it runs the full width of the widened primary zone. + REQUIRE(l.collision_zones.size() == 1); + check_box_xy(l.collision_zones[0], 0.0, 96.0, 200.0, 100.0); +} + +TEST_CASE("inactive tools donate their bed share to the active ones", "[IMEXZones]") +{ + // Three tools on one gantry, but the mode activates only T0 and T2. Zones are sized by + // active tool count, so each gets half the bed rather than a third with a dead middle + // band -- and T2's zone starts where the primary's ends. + PrinterCfg p; + p.gantry_count = 1; + p.tools_per_gantry = 3; + p.active_tools = "0:P,2:M"; + p.clearance_x = 5.0; + + const ImexZoneLayout l = layout_for(p); + + REQUIRE(l.primary_zone_box.has_value()); + check_rect(*l.primary_zone_box, 0.0, 0.0, 100.0, 200.0); + CHECK_THAT(l.primary_zone_box->size().x(), WithinAbs(100.0, 1e-9)); // half, not 200/3 + + REQUIRE(l.mirror_zones.size() == 1); + check_rect(l.mirror_zones[0], 100.0, 0.0, 200.0, 200.0); + + REQUIRE(l.head_zone_centers.size() == 2); + check_point(l.head_zone_centers.at(0), 50.0, 100.0); + check_point(l.head_zone_centers.at(2), 150.0, 100.0); + + // The two zones are adjacent, so the boundary between them is a real collision risk. + REQUIRE(l.collision_zones.size() == 1); + check_box_xy(l.collision_zones[0], 95.0, 0.0, 100.0, 200.0); +} + +TEST_CASE("the plate mode falls back to the process preset only when it is Primary", "[IMEXZones]") +{ + PrinterCfg p; + p.gantry_count = 1; + p.tools_per_gantry = 2; + p.active_tools = "0:P,1:M"; + + SECTION("a plate still on Primary picks up the process preset's mode") { + const ImexZoneLayout l = layout_for(p, kImexPrimaryMode, kMode); + REQUIRE(l.primary_zone_box.has_value()); + check_rect(*l.primary_zone_box, 0.0, 0.0, 100.0, 200.0); + } + + SECTION("a plate with its own mode ignores the process preset") { + // The process preset names a mode that does not exist; the plate's own mode wins, + // so the layout is still built from kMode's tools. + const ImexZoneLayout l = layout_for(p, kMode, "no-such-mode"); + REQUIRE(l.primary_zone_box.has_value()); + check_rect(*l.primary_zone_box, 0.0, 0.0, 100.0, 200.0); + } + + SECTION("Primary on both sides yields no zones at all") { + CHECK(is_empty(layout_for(p, kImexPrimaryMode, std::string()))); + CHECK(is_empty(layout_for(p, kImexPrimaryMode, kImexPrimaryMode))); + } +} + +TEST_CASE("configurations with nothing to divide yield an empty layout", "[IMEXZones]") +{ + PrinterCfg base; + base.gantry_count = 1; + base.tools_per_gantry = 2; + base.active_tools = "0:P,1:M"; + + SECTION("a non-IMEX printer") { + PrinterCfg p = base; + p.is_imex = false; + CHECK(is_empty(layout_for(p))); + } + + SECTION("an empty plate mode") { + CHECK(is_empty(layout_for(base, std::string(), std::string()))); + } + + SECTION("a single-tool grid has no second zone to dim") { + PrinterCfg p = base; + p.gantry_count = 1; + p.tools_per_gantry = 1; + p.active_tools = "0:P"; + CHECK(is_empty(layout_for(p))); + } + + SECTION("a mode name that the printer does not define") { + // A stale mode carried in from another printer's process preset: the name resolves + // to no tool roster, so there is nothing to lay out. + CHECK(is_empty(layout_for(base, "carried-over-mode"))); + } + + SECTION("a mode whose tool roster is empty") { + PrinterCfg p = base; + p.active_tools = ""; + CHECK(is_empty(layout_for(p))); + } +} + +TEST_CASE("a Primary that is not on the grid yields no zones at all", "[IMEXZones]") +{ + // The modes editor preserves tool assignments across imex_gantry_count changes, so a mode + // authored on a 4-tool IQEX with Primary on T3 keeps that assignment after the printer is + // reconfigured as a 2-tool IDEX. T3 is then off the grid and cannot anchor a layout: the + // primary's cell is what every zone, strip and offset is measured from. + PrinterCfg p; + p.gantry_count = 1; + p.tools_per_gantry = 2; + p.active_tools = "3:P,0:M"; + p.clearance_x = 5.0; + p.margin = 2.0; + + const ImexZoneLayout l = layout_for(p); + + CHECK(is_empty(l)); + + // Spelled out, because the failure this guards is a self-contradiction rather than a + // wrong rectangle: the primary zone used to report the whole bed printable while the + // secondary box reported the very same rectangle blocked. + CHECK(l.primary_head == -1); + CHECK_FALSE(l.primary_zone_box.has_value()); + CHECK(l.secondary_zone_boxes.empty()); + CHECK(l.mirror_zones.empty()); + // No zone centre either, so no ghost is placed from a cell no tool occupies. + CHECK(l.head_zone_centers.empty()); + // And with no primary zone, a firmware-managed printer shifts the slice by nothing + // rather than by the bed centre. + check_point(compute_imex_slice_offset(true, kMode, l.primary_zone_box), 0.0, 0.0); +} + +TEST_CASE("a mode with no Primary marker at all yields no zones", "[IMEXZones]") +{ + // Same defect by a different route: every tool is a secondary, so there is no `:P` to + // anchor the layout even though the roster is non-empty and entirely on the grid. + PrinterCfg p; + p.gantry_count = 1; + p.tools_per_gantry = 2; + p.active_tools = "0:M,1:M"; + p.clearance_x = 5.0; + + CHECK(is_empty(layout_for(p))); +} + +TEST_CASE("a Primary on the last tool of the grid lays out normally", "[IMEXZones]") +{ + // Boundary of the bound that drops an off-grid Primary: the highest valid index must + // still be accepted, so the guard cannot be an off-by-one that swallows real modes. + SECTION("last column of a 1x2 IDEX") { + PrinterCfg p; + p.gantry_count = 1; + p.tools_per_gantry = 2; + p.active_tools = "1:P,0:M"; + p.clearance_x = 5.0; + p.margin = 2.0; + + const ImexZoneLayout l = layout_for(p); + + REQUIRE(l.primary_head == 1); + REQUIRE(l.primary_zone_box.has_value()); + check_rect(*l.primary_zone_box, 100.0, 0.0, 200.0, 200.0); + + REQUIRE(l.mirror_zones.size() == 1); + check_rect(l.mirror_zones[0], 0.0, 0.0, 100.0, 200.0); + + REQUIRE(l.head_zone_centers.size() == 2); + check_point(l.head_zone_centers.at(0), 50.0, 100.0); + check_point(l.head_zone_centers.at(1), 150.0, 100.0); + + // The mirror sits to the primary's left, so the strip hugs the primary zone's + // left boundary and the advisory band lies further inside it. + REQUIRE(l.collision_zones.size() == 1); + check_box_xy(l.collision_zones[0], 100.0, 0.0, 105.0, 200.0); + REQUIRE(l.margin_bands.size() == 1); + check_rect(l.margin_bands[0], 105.0, 0.0, 107.0, 200.0); + } + + SECTION("last tool of a 2x2 IQEX") { + PrinterCfg p; + p.gantry_count = 2; + p.tools_per_gantry = 2; + p.active_tools = "3:P,0:M"; + p.clearance_x = 5.0; + p.clearance_y = 5.0; + + const ImexZoneLayout l = layout_for(p); + + REQUIRE(l.primary_head == 3); + REQUIRE(l.primary_zone_box.has_value()); + check_rect(*l.primary_zone_box, 100.0, 100.0, 200.0, 200.0); + + REQUIRE(l.mirror_zones.size() == 1); + check_rect(l.mirror_zones[0], 0.0, 0.0, 100.0, 100.0); + + // T0 is diagonally opposite the primary, so the gantries never overlap and no + // boundary of the primary zone is a collision risk. + CHECK(l.collision_zones.empty()); + } +} + +TEST_CASE("zero clearance and zero margin suppress the strips they size", "[IMEXZones]") +{ + // The strip width is the literal clearance value, so a printer that has not configured + // one gets no strip rather than a zero-width box the placement check would still test. + PrinterCfg p; + p.gantry_count = 1; + p.tools_per_gantry = 2; + p.active_tools = "0:P,1:M"; + + SECTION("no clearance, no strip") { + const ImexZoneLayout l = layout_for(p); + CHECK(l.collision_zones.empty()); + CHECK(l.margin_bands.empty()); + } + + SECTION("clearance without margin gives the strip but no advisory band") { + p.clearance_x = 5.0; + const ImexZoneLayout l = layout_for(p); + CHECK(l.collision_zones.size() == 1); + CHECK(l.margin_bands.empty()); + } +} + +TEST_CASE("an aggregated gantry raises its collision strip from the zone it paints", "[IMEXZones]") +{ + // A Span partner on the primary's gantry collapses the far gantry to a single cell, + // pinned to the primary's column and expanded into a full-bed-width row strip. The + // danger strip on the primary's rear boundary has to follow that painted strip, not any + // one head's own grid cell -- with three tools per gantry the surviving mirrors sit in + // columns 1 and 2, neither of which is the primary's, yet the zone they jointly paint + // still runs the full width of the bed and still closes on the primary's carriage. + // + // Both rosters describe the same machine geometry (one primary gantry, one mirrored + // gantry behind it), so both must produce the same rectangles. + struct Case { const char* name; int tools_per_gantry; const char* active_tools; }; + const Case cases[] = { + { "two tools per gantry, mirrors on T2 and T3", 2, "0:P,1:S,2:M,3:M" }, + { "three tools per gantry, mirrors on T4 and T5", 3, "0:P,1:S,4:M,5:M" }, + }; + + for (const Case& c : cases) { + DYNAMIC_SECTION(c.name) { + PrinterCfg p; + p.gantry_count = 2; + p.tools_per_gantry = c.tools_per_gantry; + p.active_tools = c.active_tools; + p.clearance_x = 6.0; + p.clearance_y = 4.0; + p.margin = 2.0; + + const ImexZoneLayout l = layout_for(p); + + REQUIRE(l.primary_head == 0); + REQUIRE(l.primary_zone_box.has_value()); + check_rect(*l.primary_zone_box, 0.0, 0.0, 200.0, 100.0); + + // One merged rear strip for the whole far gantry, however many heads it carries. + CHECK(l.copy_zones.empty()); + REQUIRE(l.mirror_zones.size() == 1); + check_rect(l.mirror_zones[0], 0.0, 100.0, 200.0, 200.0); + REQUIRE(l.secondary_zone_boxes.size() == 1); + check_box_xy(l.secondary_zone_boxes[0], 0.0, 100.0, 200.0, 200.0); + + // The rear boundary is the collision risk, one clearance_y deep and as wide as + // the widened primary zone. Exactly one strip: the two gantries share no X + // extent, so neither side boundary may raise one of its own. + REQUIRE(l.collision_zones.size() == 1); + check_box_xy(l.collision_zones[0], 0.0, 96.0, 200.0, 100.0); + + // And the advisory band immediately inside it, one margin deep. + REQUIRE(l.margin_bands.size() == 1); + check_rect(l.margin_bands[0], 0.0, 94.0, 200.0, 96.0); + } + } +}