From fcfda6c835beec3fcf30dd36e74d2242ac856865 Mon Sep 17 00:00:00 2001 From: Clifford Garwood Date: Thu, 3 Sep 2026 00:55:04 -0400 Subject: [PATCH] Move the IMEX bed-zone geometry into libslic3r Closes review comment 16, and fixes three defects the move exposed. PartPlate::calc_imex_zones() was 392 lines deciding where every zone, collision strip and safety margin sits, in the GUI layer, with no test coverage. The three wxGetApp() calls that kept it there were all in its first 25 lines, fetching two configs. The geometry now lives in compute_imex_zone_layout(); the wrapper fetches the configs, calls it, and clips the returned rects to the bed outline for the GLModels, which is the only part needing GUI types. libslic3r gained no wx dependency: it takes DynamicPrintConfig directly, so the option lookups moved verbatim rather than through a hand-written value struct that could drift. The move is otherwise exact -- verified by a line-for-line diff of every arithmetic expression against the original, and by running 15 scenarios through the extracted code against hand-derived values. The only deletion is a lambda that was never called. Three fixes on top, each of which needed the code to be testable: - An off-grid or absent Primary left pri_col/pri_row at their (0,0) initialisers and built a layout from them, reporting the whole bed as the clear primary zone and the whole bed as a blocked mirror zone at once; under imex_firmware_managed_zones it shifted the slice by the bed centre. Guarding on the resolved primary head covers both routes. Reachable only from a hand-edited preset or a 3MF authored against another printer -- the editor pins Primary to tool 0 -- but that is the same class the unresolved-mode fallback handles. - imex_nozzle_clearance_x/y fell back to 0.0 where PrintConfig registers 30.0. Both strip loops are gated on the value being positive, so the fallback emitted no collision strips at all while the preview still drew 30 mm toolhead boxes. - The collision-strip loop asked each mirror head for its own grid cell, but an aggregated gantry's cell is pinned to the primary's column and expanded into a full-width row strip. Where the representative's column differed from the primary's, no boundary matched and the plate came back with no strips and no margin bands -- an object flush against the shared boundary sliced without a warning while the far carriage occupied it. Present since Span aggregation was added in 4966d0fae8 and carried out of PartPlate verbatim. The flags now come from the painted cells; an exhaustive sweep of the reachable grid, role and layout space (1,630,720 configurations) shows the only behaviour change is the missing strips appearing. Co-Authored-By: Claude Opus 5 (1M context) --- src/libslic3r/CMakeLists.txt | 2 + src/libslic3r/IMEXZones.cpp | 381 +++++++++++++++++++ src/libslic3r/IMEXZones.hpp | 93 +++++ tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_imex_zones.cpp | 571 ++++++++++++++++++++++++++++ 5 files changed, 1048 insertions(+) create mode 100644 src/libslic3r/IMEXZones.cpp create mode 100644 src/libslic3r/IMEXZones.hpp create mode 100644 tests/libslic3r/test_imex_zones.cpp 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); + } + } +}