mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-11 18:01:14 +00:00
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) <noreply@anthropic.com>
572 lines
23 KiB
C++
572 lines
23 KiB
C++
#include <catch2/catch_all.hpp>
|
|
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#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<ImexToolLayout>(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<std::string>{ kImexPrimaryMode, kMode }));
|
|
cfg.set_key_value("imex_mode_active_tools",
|
|
new ConfigOptionStrings(std::vector<std::string>{ 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);
|
|
}
|
|
}
|
|
}
|