mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-10 17:21:10 +00:00
Three of the four IMEX physical-vs-logical fixes landed earlier on this
branch (fbc58d2a1d, fa048babeb, a38b95bf45, e11e7d46df) used inline
lambdas / direct loops to translate physical extruder indices to logical
filament slots. No test coverage existed for the specific composition,
even though the underlying primitives (resolve_filament_for_head,
first_filament_for_physical_head) were tested.
Pull two patterns out of Plater.cpp and GCode.cpp into IMEXHelpers as
named helpers, then unit-test them:
imex_primary_logical_from_objects(used_slots_1b, pem, primary_physical)
Walks the plate's used filament slots (1-based) and returns the
first one whose pem entry maps to the primary's physical extruder.
This is what the warning's `logical_for_primary` now delegates to —
moves the "look at object assignments, not pem first-routed default"
behavior introduced in fa048babeb out of the lambda and into a
separately-testable function.
imex_secondary_logical_slots(active_physicals, primary_physical,
plate_head_filament_map, pem)
Iterates IMEX active physicals, skips the one matching primary,
resolves each remainder via resolve_filament_for_head (per-plate
override + first-routed fallback), deduplicates, drops -1 entries.
Replaces the inline loop in GCode::_do_export's is_extruder_used
marking (a38b95bf45 + e11e7d46df).
10 new test cases in test_imex_helpers.cpp cover the cases that
correspond directly to bugs hit:
imex_primary_logical_from_objects:
- AFC primary picks the object's slot (the user's specific bug)
- Multi-color AFC primary returns first input-order match
- Direct extruder primary unambiguous (no MMU)
- No object routed to primary returns -1
- Empty inputs (no objects, empty pem)
imex_secondary_logical_slots:
- Copy mode skips primary, falls back to first-routed
- IQEX 4-mode enumerates all three secondaries
- Per-plate override wins over first-routed
- Drops unrouted physicals + deduplicates
- Only-primary-active returns empty
All [IMEX] + [Variant] + [3mf] regression: 166 assertions / 61 cases.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
437 lines
20 KiB
C++
437 lines
20 KiB
C++
#include <catch2/catch_all.hpp>
|
|
|
|
#include "libslic3r/IMEXHelpers.hpp"
|
|
#include "libslic3r/PrintConfig.hpp"
|
|
#include "libslic3r/Point.hpp"
|
|
|
|
using namespace Slic3r;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
static ConfigOptionInts make_pem(std::vector<int> v) {
|
|
ConfigOptionInts o;
|
|
o.values = std::move(v);
|
|
return o;
|
|
}
|
|
|
|
TEST_CASE("effective_physical_extruder_map — explicit wins", "[IMEX]") {
|
|
auto explicit_pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
auto pei = make_pem({1, 2, 3, 4}); // would derive to {0,1,2,3}
|
|
auto out = effective_physical_extruder_map(&explicit_pem, &pei);
|
|
REQUIRE(out.values == std::vector<int>{0, 0, 0, 0, 1, 2, 3});
|
|
}
|
|
|
|
TEST_CASE("effective_physical_extruder_map — default pem falls back to pei derive", "[IMEX]") {
|
|
auto default_pem = make_pem({0}); // size 1, the PrintConfig default
|
|
auto pei = make_pem({1, 2}); // 1-indexed IDEX
|
|
auto out = effective_physical_extruder_map(&default_pem, &pei);
|
|
REQUIRE(out.values == std::vector<int>{0, 1}); // 1-indexed → 0-indexed
|
|
}
|
|
|
|
TEST_CASE("effective_physical_extruder_map — null explicit, pei present", "[IMEX]") {
|
|
auto pei = make_pem({1, 2, 3});
|
|
auto out = effective_physical_extruder_map(nullptr, &pei);
|
|
REQUIRE(out.values == std::vector<int>{0, 1, 2});
|
|
}
|
|
|
|
TEST_CASE("effective_physical_extruder_map — both absent yields empty", "[IMEX]") {
|
|
auto out = effective_physical_extruder_map(nullptr, nullptr);
|
|
REQUIRE(out.values.empty());
|
|
}
|
|
|
|
TEST_CASE("effective_physical_extruder_map — IDEX ghost-color regression guard", "[IMEX]") {
|
|
// Printer with printer_extruder_id = [1, 2] and no explicit pem (default {0}).
|
|
// Before the GUI fix, this scenario produced a black ghost on T1 because
|
|
// first_filament_for_physical_head({0}, 1) == -1.
|
|
auto default_pem = make_pem({0});
|
|
auto pei = make_pem({1, 2});
|
|
auto pem = effective_physical_extruder_map(&default_pem, &pei);
|
|
REQUIRE(first_filament_for_physical_head(pem, 0) == 0);
|
|
REQUIRE(first_filament_for_physical_head(pem, 1) == 1); // no longer -1
|
|
}
|
|
|
|
TEST_CASE("imex_pem_tool_for — non-parallel mode returns -1", "[IMEX]") {
|
|
// Empty mode string (not in IMEX) and "primary" (IMEX present but not parallel) both
|
|
// short-circuit to bare-firmware PA/temp emission.
|
|
auto pem = make_pem({0, 1, 2});
|
|
REQUIRE(imex_pem_tool_for(0, "", pem) == -1);
|
|
REQUIRE(imex_pem_tool_for(1, "primary", pem) == -1);
|
|
REQUIRE(imex_pem_tool_for(2, "primary", pem) == -1);
|
|
}
|
|
|
|
TEST_CASE("imex_pem_tool_for — parallel mode with empty pem returns -1", "[IMEX]") {
|
|
// Defense-in-depth for exotic profiles where pem never gets populated. get_at would
|
|
// throw on an empty pem; the helper must return -1 instead so the writer emits bare.
|
|
ConfigOptionInts empty_pem;
|
|
REQUIRE(imex_pem_tool_for(0, "copy_mode", empty_pem) == -1);
|
|
REQUIRE(imex_pem_tool_for(3, "mirror_mode", empty_pem) == -1);
|
|
}
|
|
|
|
TEST_CASE("imex_pem_tool_for — identity pem routes filament to itself", "[IMEX]") {
|
|
// IDEX with printer_extruder_id = [1, 2] auto-derives pem = [0, 1]; non-MMU is identity.
|
|
auto pem = make_pem({0, 1});
|
|
REQUIRE(imex_pem_tool_for(0, "copy_mode", pem) == 0);
|
|
REQUIRE(imex_pem_tool_for(1, "copy_mode", pem) == 1);
|
|
}
|
|
|
|
TEST_CASE("imex_pem_tool_for — MMU collapse routes multiple logical slots to one physical", "[IMEX]") {
|
|
// 7-slot printer: first four slots share physical extruder 0 (a 4-lane MMU),
|
|
// slots 4/5/6 are independent on 1/2/3. Filament index is the logical slot;
|
|
// the helper returns the physical extruder carrying it.
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
REQUIRE(imex_pem_tool_for(0, "copy_mode", pem) == 0);
|
|
REQUIRE(imex_pem_tool_for(3, "copy_mode", pem) == 0); // MMU collapse: T3 logical → T0 physical
|
|
REQUIRE(imex_pem_tool_for(6, "copy_mode", pem) == 3);
|
|
}
|
|
|
|
TEST_CASE("first_filament_for_physical_head — identity pem", "[IMEX]") {
|
|
auto pem = make_pem({0, 1, 2, 3});
|
|
REQUIRE(first_filament_for_physical_head(pem, 0) == 0);
|
|
REQUIRE(first_filament_for_physical_head(pem, 1) == 1);
|
|
REQUIRE(first_filament_for_physical_head(pem, 2) == 2);
|
|
REQUIRE(first_filament_for_physical_head(pem, 3) == 3);
|
|
REQUIRE(first_filament_for_physical_head(pem, 4) == -1);
|
|
}
|
|
|
|
TEST_CASE("first_filament_for_physical_head — AFC routing", "[IMEX]") {
|
|
// User's IQEX: 4 AFC lanes on T0, direct extruders on T1, T2, T3
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
REQUIRE(first_filament_for_physical_head(pem, 0) == 0); // first of T0's lanes
|
|
REQUIRE(first_filament_for_physical_head(pem, 1) == 4);
|
|
REQUIRE(first_filament_for_physical_head(pem, 2) == 5);
|
|
REQUIRE(first_filament_for_physical_head(pem, 3) == 6);
|
|
REQUIRE(first_filament_for_physical_head(pem, 5) == -1); // no head 5
|
|
}
|
|
|
|
TEST_CASE("first_filament_for_physical_head — empty pem", "[IMEX]") {
|
|
ConfigOptionInts pem;
|
|
REQUIRE(first_filament_for_physical_head(pem, 0) == 0);
|
|
REQUIRE(first_filament_for_physical_head(pem, 1) == -1);
|
|
}
|
|
|
|
TEST_CASE("has_mmu — pure IDEX", "[IMEX]") {
|
|
REQUIRE_FALSE(has_mmu(make_pem({0, 1})));
|
|
REQUIRE_FALSE(has_mmu(make_pem({0, 1, 2, 3})));
|
|
}
|
|
|
|
TEST_CASE("has_mmu — MMU on one head", "[IMEX]") {
|
|
REQUIRE(has_mmu(make_pem({0, 0, 0, 0, 1, 2, 3}))); // user's IQEX
|
|
REQUIRE(has_mmu(make_pem({0, 0}))); // tiny MMU
|
|
}
|
|
|
|
TEST_CASE("has_mmu — MMU on second head", "[IMEX]") {
|
|
// Hypothetical future: direct extruder on T0, MMU on T1
|
|
REQUIRE(has_mmu(make_pem({0, 1, 1, 1})));
|
|
}
|
|
|
|
TEST_CASE("has_mmu — empty / single-entry pem", "[IMEX]") {
|
|
REQUIRE_FALSE(has_mmu(make_pem({})));
|
|
REQUIRE_FALSE(has_mmu(make_pem({0})));
|
|
}
|
|
|
|
TEST_CASE("imex_primary_logical_from_objects — AFC primary picks the object's slot", "[IMEX]") {
|
|
// User's Neo XP 0.6: pem maps slots 0-3 to physical 0 (4-lane AFC manifold),
|
|
// slots 4-6 to physicals 1/2/3. Object assigned to 1-based slot 3 = 0-based 2.
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
REQUIRE(imex_primary_logical_from_objects({3}, pem, 0) == 2);
|
|
}
|
|
|
|
TEST_CASE("imex_primary_logical_from_objects — multi-color AFC primary returns first match", "[IMEX]") {
|
|
// Two objects on the AFC manifold (slots 0 and 2 in 1-based = slots 0 and 2 in
|
|
// 0-based wait that's wrong let me redo). Two objects: 1-based slots 1 and 3
|
|
// (= 0-based 0 and 2). Both route to physical 0 via pem. First in input wins.
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
REQUIRE(imex_primary_logical_from_objects({1, 3}, pem, 0) == 0); // first match
|
|
REQUIRE(imex_primary_logical_from_objects({3, 1}, pem, 0) == 2); // order matters
|
|
}
|
|
|
|
TEST_CASE("imex_primary_logical_from_objects — direct extruder primary unambiguous", "[IMEX]") {
|
|
// Object on 1-based slot 5 = 0-based 4 (direct extruder T1 in the user's layout).
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
REQUIRE(imex_primary_logical_from_objects({5}, pem, 1) == 4);
|
|
}
|
|
|
|
TEST_CASE("imex_primary_logical_from_objects — no object routed to primary returns -1", "[IMEX]") {
|
|
// Object on 1-based slot 5 (= physical 1) but primary_physical is 0. No object
|
|
// on the plate routes to T0 — caller should fall back / treat as missing.
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
REQUIRE(imex_primary_logical_from_objects({5}, pem, 0) == -1);
|
|
}
|
|
|
|
TEST_CASE("imex_primary_logical_from_objects — empty inputs", "[IMEX]") {
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
REQUIRE(imex_primary_logical_from_objects({}, pem, 0) == -1); // no objects
|
|
ConfigOptionInts empty_pem;
|
|
REQUIRE(imex_primary_logical_from_objects({1, 3}, empty_pem, 0) == -1); // empty pem
|
|
}
|
|
|
|
TEST_CASE("imex_secondary_logical_slots — copy mode skips primary, falls back to first-routed", "[IMEX]") {
|
|
// User's setup: copy mode active = [0, 1] (T0 primary, T1 copy), no plate map override.
|
|
// Secondary T1 should resolve to first slot whose pem is 1 = slot 4.
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
auto out = imex_secondary_logical_slots({0, 1}, /*primary*/0, /*plate_map*/{}, pem);
|
|
REQUIRE(out == std::vector<int>{4});
|
|
}
|
|
|
|
TEST_CASE("imex_secondary_logical_slots — IQEX 4-mode enumerates all secondaries", "[IMEX]") {
|
|
// iq-copy / iq-mirror: active = [0, 1, 2, 3], primary = 0.
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
auto out = imex_secondary_logical_slots({0, 1, 2, 3}, /*primary*/0, /*plate_map*/{}, pem);
|
|
REQUIRE(out == std::vector<int>{4, 5, 6}); // first slot for each physical 1, 2, 3
|
|
}
|
|
|
|
TEST_CASE("imex_secondary_logical_slots — per-plate override wins for secondary", "[IMEX]") {
|
|
// User picks slot 6 (1-based) for T1 via the IMEX ghost picker. plate_map[1] = 6.
|
|
// resolve_filament_for_head should subtract 1: 0-based slot 5.
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
std::map<int, int> plate_map{{1, 6}};
|
|
auto out = imex_secondary_logical_slots({0, 1}, /*primary*/0, plate_map, pem);
|
|
REQUIRE(out == std::vector<int>{5});
|
|
}
|
|
|
|
TEST_CASE("imex_secondary_logical_slots — drops unrouted physicals, deduplicates", "[IMEX]") {
|
|
// pem only has 2 entries (slot 0 -> phys 0, slot 1 -> phys 1). active includes
|
|
// a phys 2 that has no logical → should be dropped. Also: same pem has both
|
|
// slots routing to the same physical to test dedup.
|
|
auto pem = make_pem({0, 1});
|
|
auto out = imex_secondary_logical_slots({0, 1, 2}, /*primary*/0, {}, pem);
|
|
REQUIRE(out == std::vector<int>{1}); // phys 2 unrouted, primary skipped, only phys 1's slot 1 left
|
|
|
|
// Dedup: two physicals resolving to the same logical (via plate_map override).
|
|
std::map<int, int> dup_map{{1, 1}, {2, 1}}; // both T1 and T2 → 1-based slot 1 = 0-based 0
|
|
auto pem2 = make_pem({0, 0, 0});
|
|
auto out2 = imex_secondary_logical_slots({0, 1, 2}, /*primary*/0, dup_map, pem2);
|
|
REQUIRE(out2 == std::vector<int>{0}); // both secondaries point at slot 0; only emitted once
|
|
}
|
|
|
|
TEST_CASE("imex_secondary_logical_slots — only-primary-active returns empty", "[IMEX]") {
|
|
// Primary mode (just T0 active) → no secondaries.
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
auto out = imex_secondary_logical_slots({0}, /*primary*/0, {}, pem);
|
|
REQUIRE(out.empty());
|
|
}
|
|
|
|
TEST_CASE("parse_imex_head_filament_map — round-trip", "[IMEX]") {
|
|
auto m = parse_imex_head_filament_map("0:3,4:5");
|
|
REQUIRE(m.size() == 2);
|
|
REQUIRE(m[0] == 3);
|
|
REQUIRE(m[4] == 5);
|
|
}
|
|
|
|
TEST_CASE("parse_imex_head_filament_map — whitespace + empty tokens", "[IMEX]") {
|
|
auto m = parse_imex_head_filament_map(" 0 : 3 , , 4:5 ");
|
|
REQUIRE(m.size() == 2);
|
|
REQUIRE(m[0] == 3);
|
|
REQUIRE(m[4] == 5);
|
|
}
|
|
|
|
TEST_CASE("parse_imex_head_filament_map — empty string", "[IMEX]") {
|
|
REQUIRE(parse_imex_head_filament_map("").empty());
|
|
}
|
|
|
|
TEST_CASE("resolve_filament_for_head — override wins", "[IMEX]") {
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
std::map<int,int> plate_map{{0, 3}}; // 1-based slot 3 = 0-based logical 2
|
|
REQUIRE(resolve_filament_for_head(plate_map, pem, 0) == 2);
|
|
}
|
|
|
|
TEST_CASE("resolve_filament_for_head — fallback when unset", "[IMEX]") {
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
std::map<int,int> plate_map{}; // empty
|
|
REQUIRE(resolve_filament_for_head(plate_map, pem, 0) == 0); // first pem-routed
|
|
REQUIRE(resolve_filament_for_head(plate_map, pem, 1) == 4);
|
|
}
|
|
|
|
TEST_CASE("resolve_filament_for_head — no routing for head", "[IMEX]") {
|
|
auto pem = make_pem({0, 1}); // no head 2
|
|
std::map<int,int> plate_map{};
|
|
REQUIRE(resolve_filament_for_head(plate_map, pem, 2) == -1);
|
|
}
|
|
|
|
TEST_CASE("imex_primary_tool_for_mode — role marker authoritative", "[IMEX]") {
|
|
// Tab.cpp enforces one Primary per mode; position is not semantically meaningful.
|
|
REQUIRE(imex_primary_tool_for_mode("0:P,1:C,2:C") == 0);
|
|
REQUIRE(imex_primary_tool_for_mode("1:C,0:P,2:M") == 0);
|
|
REQUIRE(imex_primary_tool_for_mode("1:C,2:M,3:P") == 3);
|
|
}
|
|
|
|
TEST_CASE("imex_primary_tool_for_mode — backwards-compat plain index", "[IMEX]") {
|
|
REQUIRE(imex_primary_tool_for_mode("0") == 0);
|
|
REQUIRE(imex_primary_tool_for_mode("2") == 2);
|
|
// Bare index wins when no :P marker is present; first bare index takes primary.
|
|
REQUIRE(imex_primary_tool_for_mode("1,2,3") == 1);
|
|
}
|
|
|
|
TEST_CASE("imex_primary_tool_for_mode — explicit :P beats bare index", "[IMEX]") {
|
|
// Mixed serialization: role marker must dominate over bare-index fallback.
|
|
REQUIRE(imex_primary_tool_for_mode("1,2:P,3") == 2);
|
|
}
|
|
|
|
TEST_CASE("imex_primary_tool_for_mode — empty + malformed", "[IMEX]") {
|
|
REQUIRE(imex_primary_tool_for_mode("") == -1);
|
|
REQUIRE(imex_primary_tool_for_mode(",,") == -1);
|
|
REQUIRE(imex_primary_tool_for_mode("abc:P") == -1); // idx not parseable
|
|
REQUIRE(imex_primary_tool_for_mode("-1:P") == -1); // negative idx rejected
|
|
}
|
|
|
|
TEST_CASE("imex_primary_tool_for_mode — whitespace tolerant", "[IMEX]") {
|
|
REQUIRE(imex_primary_tool_for_mode(" 0 : P , 1 : C ") == 0);
|
|
}
|
|
|
|
TEST_CASE("has_non_primary_mmu — MMU on primary only", "[IMEX]") {
|
|
// User's Neo XP 0.6: 4 AFC lanes on T0, singles on T4/T5/T6.
|
|
// Primary = 0 → no other head has MMU.
|
|
auto pem = make_pem({0, 0, 0, 0, 4, 5, 6});
|
|
REQUIRE_FALSE(has_non_primary_mmu(pem, 0));
|
|
}
|
|
|
|
TEST_CASE("has_non_primary_mmu — MMU on secondary", "[IMEX]") {
|
|
// Direct extruder on T0, MMU on T1 → non-primary MMU present.
|
|
auto pem = make_pem({0, 1, 1, 1});
|
|
REQUIRE(has_non_primary_mmu(pem, 0));
|
|
}
|
|
|
|
TEST_CASE("has_non_primary_mmu — pure IDEX", "[IMEX]") {
|
|
REQUIRE_FALSE(has_non_primary_mmu(make_pem({0, 1}), 0));
|
|
REQUIRE_FALSE(has_non_primary_mmu(make_pem({0, 1, 2, 3}), 0));
|
|
}
|
|
|
|
TEST_CASE("has_non_primary_mmu — secondary single-lane", "[IMEX]") {
|
|
// Primary is T4 (single filament); T0 has MMU.
|
|
auto pem = make_pem({0, 0, 0, 0, 4});
|
|
REQUIRE(has_non_primary_mmu(pem, 4));
|
|
}
|
|
|
|
TEST_CASE("has_non_primary_mmu — empty / single-entry pem", "[IMEX]") {
|
|
REQUIRE_FALSE(has_non_primary_mmu(make_pem({}), 0));
|
|
REQUIRE_FALSE(has_non_primary_mmu(make_pem({0}), 0));
|
|
}
|
|
|
|
TEST_CASE("imex_head_transform — copy mode is pure translation", "[IMEX]") {
|
|
const Vec2d offset{120.0, 0.0};
|
|
Transform3d xf = imex_head_transform(0, 1, ImexRole::Copy, offset);
|
|
const Vec3d in{10.0, 20.0, 30.0};
|
|
const Vec3d out = xf * in;
|
|
REQUIRE_THAT(out.x(), WithinAbs(130.0, 1e-9));
|
|
REQUIRE_THAT(out.y(), WithinAbs(20.0, 1e-9));
|
|
REQUIRE_THAT(out.z(), WithinAbs(30.0, 1e-9));
|
|
}
|
|
|
|
TEST_CASE("imex_head_transform — mirror at origin places ghost at gantry offset", "[IMEX]") {
|
|
// Primary instance at origin: ghost origin lands at the gantry offset (Copy-style),
|
|
// and applying mirror flips geometry about origin (= primary's translation).
|
|
const Vec2d offset{120.0, 0.0};
|
|
Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset);
|
|
const Vec3d mapped = xf * Vec3d::Zero();
|
|
REQUIRE_THAT(mapped.x(), WithinAbs(120.0, 1e-9));
|
|
REQUIRE_THAT(mapped.y(), WithinAbs(0.0, 1e-9));
|
|
REQUIRE_THAT(mapped.z(), WithinAbs(0.0, 1e-9));
|
|
}
|
|
|
|
TEST_CASE("imex_head_transform — mirror lands ghost at reflected position in target zone", "[IMEX]") {
|
|
// User's example: primary zone is 100x100 centered at (50, 50), primary at (80, 20),
|
|
// gantry offset (100, 0) places the mirror zone centered at (150, 50). Ghost origin
|
|
// should land at the reflection of the primary through the zone-boundary plane
|
|
// (x = 100), i.e. world-space (120, 20, 0) — NOT the Copy-style (180, 20, 0).
|
|
const Vec2d offset{100.0, 0.0};
|
|
const Vec2d primary_zone_center{50.0, 50.0};
|
|
Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, primary_zone_center);
|
|
|
|
const Vec3d primary{80.0, 20.0, 0.0};
|
|
const Vec3d ghost_origin = xf * primary;
|
|
REQUIRE_THAT(ghost_origin.x(), WithinAbs(120.0, 1e-9));
|
|
REQUIRE_THAT(ghost_origin.y(), WithinAbs(20.0, 1e-9));
|
|
|
|
// Model point at primary + (+5 X) lands 5 LEFT of ghost origin (geometry still flipped).
|
|
const Vec3d mapped = xf * (primary + Vec3d(5.0, 0.0, 0.0));
|
|
REQUIRE_THAT(mapped.x(), WithinAbs(115.0, 1e-9));
|
|
REQUIRE_THAT(mapped.y(), WithinAbs(20.0, 1e-9));
|
|
}
|
|
|
|
TEST_CASE("imex_head_transform — mirror reflects primary drag motion", "[IMEX]") {
|
|
// Dragging the primary must reflect the ghost across the zone-boundary plane:
|
|
// primary +X → ghost -X (mirrored), primary +Y → ghost +Y (1:1). Without this the
|
|
// ghost stops being a true mirror once the primary moves.
|
|
const Vec2d offset{120.0, 0.0};
|
|
const Vec2d primary_zone_center{60.0, 50.0};
|
|
Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, primary_zone_center);
|
|
|
|
const Vec3d p0{10.0, 20.0, 0.0};
|
|
const Vec3d p1{40.0, 15.0, 0.0};
|
|
const Vec3d ghost0 = xf * p0;
|
|
const Vec3d ghost1 = xf * p1;
|
|
const Vec3d ghost_delta = ghost1 - ghost0;
|
|
const Vec3d primary_delta = p1 - p0;
|
|
|
|
REQUIRE_THAT(ghost_delta.x(), WithinAbs(-primary_delta.x(), 1e-9)); // X inverted
|
|
REQUIRE_THAT(ghost_delta.y(), WithinAbs( primary_delta.y(), 1e-9)); // Y 1:1
|
|
REQUIRE_THAT(ghost_delta.z(), WithinAbs( primary_delta.z(), 1e-9));
|
|
}
|
|
|
|
TEST_CASE("imex_head_transform — mirror reflects model point across primary origin", "[IMEX]") {
|
|
// Primary at origin, offset +X. Model point at +5 X lands 5 left of ghost origin.
|
|
// Matches the pre-refactor semantics for the special case primary_origin = 0.
|
|
const Vec2d offset{120.0, 0.0};
|
|
Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset);
|
|
const Vec3d in{5.0, 7.0, 0.0};
|
|
const Vec3d out = xf * in;
|
|
REQUIRE_THAT(out.x(), WithinAbs(115.0, 1e-9));
|
|
REQUIRE_THAT(out.y(), WithinAbs(7.0, 1e-9));
|
|
}
|
|
|
|
TEST_CASE("imex_head_transform — mirror reflection is X-axis regardless of offset direction", "[IMEX]") {
|
|
// Mirror's reflection plane normal is the primary-row gantry axis (X), not
|
|
// gantry_offset.normalized(). A pure-Y offset (off-row target) must still flip
|
|
// X, not Y — otherwise off-row Mirror ghosts end up rotated vs their on-row peers.
|
|
const Vec2d offset{0.0, 80.0};
|
|
Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset);
|
|
const Vec3d in{3.0, 10.0, 0.0};
|
|
const Vec3d out = xf * in;
|
|
REQUIRE_THAT(out.x(), WithinAbs(-3.0, 1e-9)); // X flipped about origin
|
|
REQUIRE_THAT(out.y(), WithinAbs(90.0, 1e-9)); // Y translated by gantry, unflipped
|
|
}
|
|
|
|
TEST_CASE("imex_head_transform — primary is identity", "[IMEX]") {
|
|
const Vec2d offset{120.0, 30.0};
|
|
Transform3d xf = imex_head_transform(0, 0, ImexRole::Primary, offset);
|
|
REQUIRE(xf.isApprox(Transform3d::Identity()));
|
|
}
|
|
|
|
TEST_CASE("imex_head_transform — mirror with zero offset is identity", "[IMEX]") {
|
|
const Vec2d offset{0.0, 0.0};
|
|
Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset);
|
|
REQUIRE(xf.isApprox(Transform3d::Identity()));
|
|
}
|
|
|
|
TEST_CASE("imex_head_transform — mirror on 2x2 off-row target (diagonal offset) flips X only", "[IMEX]") {
|
|
// 2x2 IMEX layout: primary T0 at rear-left, target T3 at front-right → diagonal
|
|
// gantry_offset. Reflection plane must still be X-axis (same as on-row T1 mirror),
|
|
// not the diagonal direction — otherwise T3's ghost reads as rotated ~45° in plan
|
|
// view (the bug this test guards against). Primary origin (0,0,0) maps to target
|
|
// origin (100,100,0) regardless.
|
|
const Vec2d offset{100.0, 100.0};
|
|
Transform3d xf = imex_head_transform(0, 3, ImexRole::Mirror, offset);
|
|
|
|
const Vec3d mapped = xf * Vec3d::Zero();
|
|
REQUIRE_THAT(mapped.x(), WithinAbs(100.0, 1e-9));
|
|
REQUIRE_THAT(mapped.y(), WithinAbs(100.0, 1e-9));
|
|
REQUIRE_THAT(mapped.z(), WithinAbs(0.0, 1e-9));
|
|
|
|
// A model point offset +5 X from primary ends up 5 LEFT of the ghost origin
|
|
// (X flipped), while Y translates 1:1 (Y unflipped).
|
|
const Vec3d in{5.0, 7.0, 0.0};
|
|
const Vec3d out = xf * in;
|
|
REQUIRE_THAT(out.x(), WithinAbs(95.0, 1e-9)); // 100 - 5
|
|
REQUIRE_THAT(out.y(), WithinAbs(107.0, 1e-9)); // 100 + 7
|
|
}
|
|
|
|
TEST_CASE("resolve_filament_for_head — no routing returns -1 (ghost color fallback)", "[IMEX]") {
|
|
// User's IQEX pem: T0 has 4 AFC lanes, T1/T2/T3 direct. T5 is unrouted.
|
|
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
|
|
std::map<int,int> no_override;
|
|
REQUIRE(resolve_filament_for_head(no_override, pem, 5) == -1);
|
|
|
|
// Plate override for an unrouted head still resolves (user's explicit choice wins).
|
|
std::map<int,int> override_on_5 = {{5, 7}};
|
|
REQUIRE(resolve_filament_for_head(override_on_5, pem, 5) == 6);
|
|
}
|