Files
OrcaSlicer/tests/libslic3r/test_imex_helpers.cpp
T
Clifford Garwood 77c32a2e15 feat(imex): plate ghost renderer + per-plate filament map
Replaces the plater-icon popover with colored transparent ghost copies
of primary-head instances on the plate, one per secondary active head
under its Copy/Mirror role transform. Left-click on a ghost opens a
compact filament picker popover for the ghost's head (MMU lane override).
Ghosts track the primary through drag/rotate/scale/mirror and invalidate
on mode or pem changes.

Key pieces:
  IMEXHelpers -- imex_head_transform (Primary/Copy/Mirror), shared role
    parser, per-head filament resolution with X-axis Mirror anchor.
  PartPlate -- ghost state, volume rebuild on mode/map/object mutation,
    primary_origin plumbed for Mirror reflection across the primary-row
    gantry plane.
  GLCanvas3D -- ghost rendering with per-head filament color and
    translucent blending; picking routed via volume composite id.
  Plater -- ghost click + tooltip; plater icon left-click always cycles.
  IMEXFilamentPickerPopover -- BitmapComboBox row for one secondary head,
    writes imex_head_filament_map on selection.
  bbs_3mf -- round-trip the per-plate imex_head_filament_map option.
  PrintConfig -- add imex_head_filament_map as a plate option.

MMU/AFC routing for parallel modes relies on the printer profile's
physical_extruder_map (see prior commit for authoring format). Primary-
row heads and their per-plate filament overrides are resolved through
that map, so PA and temperature emission address the correct physical
extruder when multiple logical slots share one carriage.

Tests: IMEXHelpers coverage for Primary/Copy/Mirror transforms
including a 2x2 off-row regression guard for the X-axis reflection fix.
2026-04-21 14:56:22 -04:00

284 lines
12 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("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("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 flips geometry in-place about primary origin", "[IMEX]") {
// Primary sits at (50, 10, 0). Gantry +120 in X → ghost origin at (170, 10, 0),
// NOT reflected about a midplane. A model point at +5 X from primary origin ends
// up at -5 X from the ghost origin (geometric flip preserved).
const Vec2d offset{120.0, 0.0};
const Vec3d primary_origin{50.0, 10.0, 0.0};
Transform3d xf = imex_head_transform(0, 1, ImexRole::Mirror, offset, primary_origin);
const Vec3d ghost_origin = xf * primary_origin;
REQUIRE_THAT(ghost_origin.x(), WithinAbs(170.0, 1e-9)); // 50 + 120 (Copy-style)
REQUIRE_THAT(ghost_origin.y(), WithinAbs(10.0, 1e-9));
const Vec3d plus5 = primary_origin + Vec3d(5.0, 0.0, 0.0);
const Vec3d mapped = xf * plus5;
REQUIRE_THAT(mapped.x(), WithinAbs(165.0, 1e-9)); // ghost_origin - 5 (flipped)
REQUIRE_THAT(mapped.y(), WithinAbs(10.0, 1e-9));
}
TEST_CASE("imex_head_transform — mirror tracks primary motion (same delta as Copy)", "[IMEX]") {
// When the primary moves by some delta, the ghost origin must move by the SAME delta
// (not the reflected delta). This is the core user requirement: mirrored geometry,
// un-mirrored motion.
const Vec2d offset{120.0, 0.0};
const Vec3d p0{0.0, 0.0, 0.0};
const Vec3d p1{30.0, -5.0, 0.0};
Transform3d xf0 = imex_head_transform(0, 1, ImexRole::Mirror, offset, p0);
Transform3d xf1 = imex_head_transform(0, 1, ImexRole::Mirror, offset, p1);
const Vec3d ghost0 = xf0 * p0;
const Vec3d ghost1 = xf1 * p1;
const Vec3d ghost_delta = ghost1 - ghost0;
const Vec3d primary_delta = p1 - p0;
REQUIRE_THAT(ghost_delta.x(), WithinAbs(primary_delta.x(), 1e-9));
REQUIRE_THAT(ghost_delta.y(), WithinAbs(primary_delta.y(), 1e-9));
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);
}