Harden the IMEX helper layer and give it one mode lookup

Closes review comments 3, 5, 7, 8, 18 and 19, plus the library half of 20.
These share a file, so they share a commit; each is independent of the others.

- 3: ::isspace(char) is undefined for bytes above 0x7F because char is signed on
  our targets. Three call sites now go through one strip_whitespace() using an
  unsigned char cast. Line 308 parses imex_head_filament_map straight out of 3MF
  metadata, so a non-ASCII byte reached it without passing through the UI.
- 5: an imex_head_filament_map override past the end of physical_extruder_map now
  falls through to the printer's own routing instead of resolving to a wrong
  filament. Bounded in resolve_filament_for_head, where the slot count is known,
  rather than at the parse site, which has no count to check against; the parse
  site also gains the absolute MAXIMUM_EXTRUDER_NUMBER cap its sibling already had.
- 7: imex_physical_heater_for's !is_imex early return is what keeps a stock BBL
  profile (physical_extruder_map [1,0]) out of the heater remap, and had no test.
  Six cases now cover it, pinning pass-through rather than get_at()'s clamp.
- 8: ImexRole::Span was missing from the imex_head_transform switch, so it warned
  under -Wswitch. Identity is correct, not merely convenient: a Span tool prints
  the primary's own zone through mid-print toolchanges and has no zone to be
  translated into.
- 18: three positionally coupled string vectors were resolved by nine open-coded
  lookups using three incompatible bounds idioms. None read out of bounds, but six
  folded the guard into the match condition, so a ragged row did not stop the scan
  and a later duplicate name could win. struct ImexMode + find_imex_mode() is now
  the only resolution rule: the names array is the roster, first match wins, a
  short sibling pads to empty and sets ragged, not-found is an explicit -1.
- 19: the letters P/C/M/S existed in three independent copies, one of which was the
  writer of the on-disk format. kImexRoleTable is now the single source, read by
  both parsers and the serializer. Adding a role was 14 edit sites with one
  compiler-enforced; it is now the enum, the table entry, and four -Wswitch
  switches. Verified by adding a fifth enumerator and recompiling: exactly four
  warnings, nothing else.
- 20: imex_resolve_routing() extracts the mode/primary/routing chain that
  Print::validate and the plater's warning collector each derived separately.

The three config keys keep their names, types and on-disk representation. This is
a read-side view only; presets and 3MF files are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clifford Garwood
2026-09-03 00:55:03 -04:00
co-authored by Claude Opus 5
parent 855c1b51ac
commit 171a721304
3 changed files with 951 additions and 17 deletions
+161 -15
View File
@@ -13,6 +13,22 @@
namespace Slic3r {
namespace {
// Strip every whitespace byte from a token in place.
//
// Not `::isspace` directly: it takes an int that must be representable as unsigned char
// (or equal EOF), and `char` is signed on all three targets, so any byte >= 0x80 is sign-
// extended to a negative int and the call is undefined. The strings parsed below come from
// printer profiles and 3MF metadata, neither of which is guaranteed ASCII, so the unsigned
// char cast is the same one the rest of the codebase already applies to std::toupper.
void strip_whitespace(std::string& token)
{
token.erase(std::remove_if(token.begin(), token.end(),
[](unsigned char c) { return std::isspace(c) != 0; }),
token.end());
}
} // namespace
ConfigOptionInts effective_physical_extruder_map(const ConfigOptionInts* explicit_pem, int nozzle_count)
{
// One entry per logical extruder, so a profile has authored a map only when its length
@@ -180,6 +196,25 @@ bool has_non_primary_mmu(const ConfigOptionInts& pem, int primary_physical)
return false;
}
char imex_role_letter(ImexRole role)
{
for (const ImexRoleDesc& d : kImexRoleTable)
if (d.role == role)
return d.letter;
// Only reachable if a role was added to the enum but not to kImexRoleTable. Serializing
// it as Copy keeps the string parseable rather than emitting a token no parser accepts.
return 'C';
}
ImexRole imex_role_from_suffix(const std::string& suffix)
{
if (suffix.size() == 1)
for (const ImexRoleDesc& d : kImexRoleTable)
if (d.letter == suffix[0])
return d.role;
return ImexRole::Copy;
}
std::vector<std::pair<int, ImexRole>> parse_imex_active_tools(const std::string& active_tools_for_mode)
{
std::vector<std::pair<int, ImexRole>> out;
@@ -187,21 +222,18 @@ std::vector<std::pair<int, ImexRole>> parse_imex_active_tools(const std::string&
std::istringstream ss(active_tools_for_mode);
std::string tok;
while (std::getline(ss, tok, ',')) {
tok.erase(std::remove_if(tok.begin(), tok.end(), ::isspace), tok.end());
strip_whitespace(tok);
if (tok.empty()) continue;
const auto colon = tok.find(':');
const std::string idx_str = (colon == std::string::npos) ? tok : tok.substr(0, colon);
int phys = -1;
try { phys = std::stoi(idx_str); } catch (...) { continue; }
if (phys < 0 || phys >= (int)MAXIMUM_EXTRUDER_NUMBER) continue;
ImexRole role = ImexRole::Copy;
if (colon != std::string::npos) {
const std::string r = tok.substr(colon + 1);
if (r == "P") role = ImexRole::Primary;
else if (r == "M") role = ImexRole::Mirror;
else if (r == "S") role = ImexRole::Span;
// "C" and anything else → Copy.
}
// A bare token (no ':') is Copy here by design -- see the header note; the
// "bare == Primary" backwards-compat rule lives in imex_primary_tool_for_mode().
const ImexRole role = (colon == std::string::npos)
? ImexRole::Copy
: imex_role_from_suffix(tok.substr(colon + 1));
out.emplace_back(phys, role);
}
return out;
@@ -272,7 +304,7 @@ int imex_primary_tool_for_mode(const std::string& active_tools_for_mode)
std::string token;
int first_bare = -1;
while (std::getline(ss, token, ',')) {
token.erase(std::remove_if(token.begin(), token.end(), ::isspace), token.end());
strip_whitespace(token);
if (token.empty())
continue;
const auto colon = token.find(':');
@@ -291,8 +323,9 @@ int imex_primary_tool_for_mode(const std::string& active_tools_for_mode)
first_bare = idx;
continue;
}
const std::string role = token.substr(colon + 1);
if (role == "P")
// Same suffix reading as parse_imex_active_tools(), so the two can never disagree
// about which letter means Primary.
if (imex_role_from_suffix(token.substr(colon + 1)) == ImexRole::Primary)
return idx;
}
return first_bare;
@@ -305,14 +338,19 @@ std::map<int,int> parse_imex_head_filament_map(const std::string& s)
std::istringstream ss(s);
std::string token;
while (std::getline(ss, token, ',')) {
token.erase(std::remove_if(token.begin(), token.end(), ::isspace), token.end());
strip_whitespace(token);
if (token.empty()) continue;
auto colon = token.find(':');
if (colon == std::string::npos) continue;
try {
int phys = std::stoi(token.substr(0, colon));
int slot = std::stoi(token.substr(colon + 1));
if (phys >= 0 && slot >= 1)
// Same absolute sanity cap parse_imex_active_tools applies to its own physical
// index: neither can exceed the slicer-wide extruder ceiling. This only rejects
// nonsense; the bound that matters (slot must index a filament that exists) is
// in resolve_filament_for_head, which is the one that knows the count.
if (phys >= 0 && phys < (int) MAXIMUM_EXTRUDER_NUMBER
&& slot >= 1 && slot <= (int) MAXIMUM_EXTRUDER_NUMBER)
result[phys] = slot;
} catch (...) {}
}
@@ -333,6 +371,48 @@ int imex_primary_logical_from_objects(const std::vector<int>& used_slots_1b,
return -1;
}
ImexRouting imex_resolve_routing(const ConfigBase& cfg,
const std::string& parallel_mode,
const std::vector<int>& used_slots_1b,
const ConfigOptionInts& pem)
{
ImexRouting out;
// An empty mode is a plate that never carried one; kImexPrimaryMode is the reserved
// "no parallel printing" sentinel. Neither resolves a row, so neither is looked up.
if (parallel_mode.empty() || parallel_mode == kImexPrimaryMode)
return out;
out.parallel = true;
// One lookup of the mode table, one parse of its roster, one search for the Primary
// marker. Every field below is derived from these, so no caller can re-derive one of
// them differently.
out.active_tools = find_imex_mode(cfg, parallel_mode).active_tools;
out.tools = parse_imex_active_tools(out.active_tools);
out.primary_phys = imex_primary_tool_for_mode(out.active_tools);
if (out.primary_phys >= 0)
out.primary_logical = imex_primary_logical_from_objects(used_slots_1b, pem, out.primary_phys);
// Bounds-checked exactly as imex_primary_logical_from_objects checks: get_at() would
// clamp an out-of-range slot to values.front(), which is how a head that nothing routes
// to can end up listed as a head something routes to.
out.routed_heads.reserve(used_slots_1b.size());
for (int slot_1b : used_slots_1b) {
const int slot_0b = slot_1b - 1;
if (slot_0b >= 0 && slot_0b < (int) pem.values.size())
out.routed_heads.push_back(pem.values[slot_0b]);
}
std::sort(out.routed_heads.begin(), out.routed_heads.end());
out.routed_heads.erase(std::unique(out.routed_heads.begin(), out.routed_heads.end()),
out.routed_heads.end());
// The pem emptiness test is not folded into primary_logical: an unpopulated map means
// the printer has declared no routing at all, which is not the same claim as "this
// plate's filaments go somewhere else", and only the latter is an error.
out.primary_unrouted = out.primary_phys >= 0 && !pem.values.empty() && out.primary_logical < 0;
return out;
}
std::vector<int> imex_secondary_logical_slots(const std::vector<int>& active_physicals,
int primary_physical,
const std::map<int,int>& plate_head_filament_map,
@@ -357,7 +437,16 @@ int resolve_filament_for_head(const std::map<int,int>& plate_map,
auto it = plate_map.find(physical);
if (it != plate_map.end()) {
const int zero_based = it->second - 1;
if (zero_based >= 0)
// pem has one entry per logical filament slot, so its size IS the slot count and
// an override outside it names a filament that does not exist. Bounding here rather
// than at the parse site is deliberate: the parser is handed a raw string with no
// notion of how many filaments the project has, while every consumer of this
// function's result indexes a per-filament array. The picker only ever offers
// indices into pem (IMEXFilamentPickerPopover::build_row), so nothing the UI can
// author is rejected -- only a hand-edited or corrupt 3MF. Such an override falls
// through to the printer's own routing, i.e. it behaves as if it were absent,
// rather than resolving to a wrong filament that get_at() would silently clamp.
if (zero_based >= 0 && zero_based < (int) pem.values.size())
return zero_based;
}
return first_filament_for_physical_head(pem, physical);
@@ -376,6 +465,14 @@ Transform3d imex_head_transform(int /*primary*/, int /*target*/, ImexRole role,
{
switch (role) {
case ImexRole::Primary:
case ImexRole::Span:
// Span is the within-gantry multicolor partner of Primary: it prints the same
// objects in the same zone via mid-print toolchanges, so it has no zone of its own
// to be translated or reflected into. Identity is the only transform that keeps it
// on top of the primary. calc_imex_ghosts skips Span outright (no ghost is baked
// for it, since the primary's ghost already covers that zone), so today this case
// is unreachable — it is here so that a future caller which does reach it gets the
// right answer rather than the fall-through, and so -Wswitch stays quiet.
return Transform3d::Identity();
case ImexRole::Copy:
return Eigen::Translation3d(gantry_offset.x(), gantry_offset.y(), 0.0)
@@ -422,6 +519,55 @@ Vec2d compute_imex_slice_offset(bool firmware_managed,
return primary_zone_box->center();
}
namespace {
// Builds the ImexMode for row `i`, padding whatever sibling array does not reach it.
// `names` is known non-null and `i` known in range; `tools` / `gcodes` may be either.
ImexMode imex_mode_at(const ConfigOptionStrings* names,
const ConfigOptionStrings* tools,
const ConfigOptionStrings* gcodes,
size_t i)
{
ImexMode m;
m.index = (int) i;
m.name = names->values[i];
const bool has_tools = tools != nullptr && i < tools->values.size();
const bool has_gcode = gcodes != nullptr && i < gcodes->values.size();
if (has_tools) m.active_tools = tools->values[i];
if (has_gcode) m.gcode = gcodes->values[i];
m.ragged = !has_tools || !has_gcode;
return m;
}
} // namespace
ImexMode find_imex_mode(const ConfigBase& cfg, const std::string& name)
{
const auto* names = cfg.option<ConfigOptionStrings>("imex_mode_names");
if (names == nullptr)
return {};
// The siblings are fetched once, not per row: a missing option is the same answer for
// every row, and it is padding rather than a reason to fail the lookup.
const auto* tools = cfg.option<ConfigOptionStrings>("imex_mode_active_tools");
const auto* gcodes = cfg.option<ConfigOptionStrings>("imex_mode_gcodes");
for (size_t i = 0; i < names->values.size(); ++i)
if (names->values[i] == name)
return imex_mode_at(names, tools, gcodes, i);
return {};
}
std::vector<ImexMode> imex_mode_table(const ConfigBase& cfg)
{
std::vector<ImexMode> table;
const auto* names = cfg.option<ConfigOptionStrings>("imex_mode_names");
if (names == nullptr)
return table;
const auto* tools = cfg.option<ConfigOptionStrings>("imex_mode_active_tools");
const auto* gcodes = cfg.option<ConfigOptionStrings>("imex_mode_gcodes");
table.reserve(names->values.size());
for (size_t i = 0; i < names->values.size(); ++i)
table.push_back(imex_mode_at(names, tools, gcodes, i));
return table;
}
bool imex_hull_violates_zones(const std::vector<BoundingBoxf3>& zones, const Polygon& hull)
{
if (hull.points.empty())
+169 -2
View File
@@ -23,6 +23,69 @@ class PresetBundle;
// plate mode equals this. Treat any value NOT equal to this as a parallel mode.
inline constexpr const char* kImexPrimaryMode = "primary";
// =============================================================================
// The printer's IMEX mode table
// =============================================================================
// `imex_mode_names`, `imex_mode_active_tools` and `imex_mode_gcodes` are three
// independent ConfigOptionStrings coupled only by POSITION: entry i of each describes
// mode i. Nothing in the config system enforces equal length, and all three are on-disk
// format, so a hand-edited preset, a profile written before a key existed, or a 3MF from
// another build can arrive with a sibling array shorter than `imex_mode_names`.
//
// ImexMode is the in-memory view of one row. It is NOT a config type — nothing
// serializes it, and the three keys keep their names, their types and their on-disk
// representation exactly as before.
//
// Resolution rule. Applied by find_imex_mode() / imex_mode_table() and NOWHERE else;
// every consumer in the tree goes through one of the two:
// * `imex_mode_names` is the roster. A mode exists iff a row of that array carries its
// name, and the FIRST such row wins. (The modes editor uniquifies names on entry, so
// duplicates only reach here from a hand-edited profile; first-match is what a
// std::find over the names array already did, and what the editor's own row order
// means.)
// * A sibling array too short to reach that row yields an EMPTY string for that field
// and sets `ragged`. It never yields an out-of-range read, and it never degrades to
// "no such mode": a missing script is a mode with no script, and the row's index
// still has to be reported to {imex_mode_index}. Callers that need tools already
// treat an empty tools string as "no tools" (imex_primary_tool_for_mode() returns -1,
// parse_imex_active_tools() returns {}), which is the behaviour they had before.
// * `ragged` separates "the profile omitted this row" from "the author wrote an empty
// entry" — a distinction every open-coded call site used to lose.
//
// Reporting: a non-Primary mode that resolves to an empty tool roster produces G-code
// that is wrong rather than merely unconfigured (no per-head temperature transition, and
// a suppressed initial T<n>), so GCode::_do_export() warns and falls back to Primary,
// exactly as it already does for a name that resolves to no row at all.
struct ImexMode
{
// Position in `imex_mode_names`. -1 means no row carries the requested name — the
// not-found case, explicit rather than implied by an out-of-range index.
int index = -1;
// The row's entry in `imex_mode_names`. Empty when !found().
std::string name;
// `imex_mode_active_tools[index]`, or "" when that array is shorter than index + 1.
std::string active_tools;
// `imex_mode_gcodes[index]`, or "" when that array is shorter than index + 1.
std::string gcode;
// At least one sibling array was too short to cover `index`. Always false when
// !found().
bool ragged = false;
bool found() const { return this->index >= 0; }
explicit operator bool() const { return this->found(); }
};
// Resolves `name` against the printer's mode table; see the rule above. `cfg` is any
// config carrying the printer keys (a DynamicPrintConfig, a PrintConfig, ...). A config
// with no `imex_mode_names` yields a not-found ImexMode rather than throwing, so callers
// need no null check of their own.
ImexMode find_imex_mode(const ConfigBase& cfg, const std::string& name);
// The whole roster: one ImexMode per entry of `imex_mode_names`, in config order, padded
// by the same rule. Empty when `imex_mode_names` is absent. Use this where the caller
// wants every mode (the modes editor, the plate's mode menu) rather than one by name.
std::vector<ImexMode> imex_mode_table(const ConfigBase& cfg);
// =============================================================================
// PHYSICAL vs LOGICAL extruder indices — read this before adding a new IMEX call site
// =============================================================================
@@ -189,13 +252,24 @@ int imex_primary_tool_for_mode(const std::string& active_tools_for_mode);
// Parses the "phys:slot,phys:slot" serialization of imex_head_filament_map.
// Keys are physical T-indices (0-based); values are 1-based filament slots.
// Returns empty map on empty/malformed input.
// Returns empty map on empty/malformed input. This string arrives straight from 3MF
// metadata, so tokens outside [0, MAXIMUM_EXTRUDER_NUMBER) are dropped here as an absolute
// sanity cap; the bound against the *project's* filament count lives in
// resolve_filament_for_head, which is where that count is actually known.
std::map<int,int> parse_imex_head_filament_map(const std::string& s);
// Resolves which 0-based logical filament to use for a physical head.
// 1. If the plate map overrides `physical`, returns (slot - 1) — 1-based → 0-based.
// 1. If the plate map overrides `physical` with a slot that indexes an existing filament,
// returns (slot - 1) — 1-based → 0-based.
// 2. Else falls back to first_filament_for_physical_head(pem, physical).
// Returns -1 if neither source yields a valid filament.
//
// `pem` has one entry per logical filament slot, so its size is the slot count and the
// result is always a valid index into it (or -1). An override naming a slot at or past
// that size cannot be honoured — every consumer indexes a per-filament array, and the
// ConfigOption get_at() several of them use clamps rather than failing, which would turn a
// corrupt 3MF's "1:9999" into a silently wrong filament. Such an override is ignored and
// the printer's own routing applies, exactly as if the override were absent.
int resolve_filament_for_head(const std::map<int,int>& plate_map,
const ConfigOptionInts& pem,
int physical);
@@ -233,6 +307,40 @@ std::vector<int> imex_secondary_logical_slots(const std::vector<int>& active_p
enum class ImexRole { Primary, Copy, Mirror, Span };
// The one table a new role has to be added to.
//
// `letter` is the suffix the role carries in the `imex_mode_active_tools` on-disk format
// ("0:P,1:C,2:M,3:S"). That string is written into printer profiles and into 3MF projects,
// so the letters are FORMAT: never renumber, rename or reorder them, only append. The table
// order is also the order the modes editor cycles its tiles through and lists its colour
// legend in, which is why Primary comes first.
//
// Everything that used to open-code the letters or a parallel small-int encoding of the
// enum now goes through this table or through ImexRole itself, so adding a fifth role means
// editing the enum, this table, and the places that must genuinely make a new decision
// about it (the transform switch, the zone/marker classification, the editor's per-role
// colour + label) — not a scattered set of int mappings that fail silently when missed.
struct ImexRoleDesc {
ImexRole role;
char letter;
};
inline constexpr ImexRoleDesc kImexRoleTable[] = {
{ ImexRole::Primary, 'P' },
{ ImexRole::Copy, 'C' },
{ ImexRole::Mirror, 'M' },
{ ImexRole::Span, 'S' },
};
// The on-disk suffix letter for `role`. Inverse of imex_role_from_suffix().
char imex_role_letter(ImexRole role);
// Reads a whole token suffix — everything after the ':' — back into a role. Anything that
// is not exactly one of the table's letters is Copy: that covers "C" itself, an empty
// suffix ("3:"), a multi-character suffix, and any letter a future build might write that
// this one does not know. Copy is the historical fallback for an unrecognised suffix and
// must stay so, or a project saved by a newer build changes meaning when reopened here.
ImexRole imex_role_from_suffix(const std::string& suffix);
// Parses `imex_mode_active_tools[mode]` into a list of (physical_head, role) pairs.
// Accepted token forms (comma-separated, whitespace-tolerant):
// "phys" — bare index, role defaults to Copy
@@ -251,6 +359,61 @@ enum class ImexRole { Primary, Copy, Mirror, Span };
// in hand and need the full head/role list (e.g. ghost factory/updater).
std::vector<std::pair<int, ImexRole>> parse_imex_active_tools(const std::string& active_tools_for_mode);
// =============================================================================
// The one derivation of "what does this plate's mode do, and does its filament reach
// the primary" — shared by the hard block and the pre-slice warning
// =============================================================================
// Print::validate() refuses a plate whose filaments do not route to the mode's declared
// primary; Plater's collect_imex_warnings() names that same primary's filament in its
// bed-temperature and filament-type warnings. Both need the identical chain: resolve the
// mode row -> parse its tool roster -> find the declared primary -> ask whether any used
// filament slot routes there. Computed twice, the two drift, and the warning ends up
// describing a plate the block describes differently. Computed here, they cannot.
//
// Everything in ImexRouting is a pure function of the four inputs. Deliberately absent:
// the `is_imex` gate (Print::validate's, and only its, outer condition) and any bound on
// the tool roster against the project's filament count (the plater's, and only its, way of
// dropping tools it has no filament preset to name). Those are genuinely per-caller and
// each stays with the caller that owns it.
struct ImexRouting
{
// The mode is a parallel mode: non-empty and not kImexPrimaryMode. False leaves every
// other field at its default — no mode row is even looked up.
bool parallel = false;
// `imex_mode_active_tools` for this mode, or "" when the mode resolves to no row / a
// ragged table. See find_imex_mode().
std::string active_tools;
// The parsed roster, physical head + role, in the order the string lists them.
std::vector<std::pair<int, ImexRole>> tools;
// Declared primary PHYSICAL head, from imex_primary_tool_for_mode(). -1 when the mode
// resolves to no row, an empty roster, or a roster with no Primary marker.
int primary_phys = -1;
// The 0-based LOGICAL filament slot the primary prints with: the first entry of
// `used_slots_1b` whose pem entry maps to `primary_phys`. -1 when nothing on the plate
// routes there (which is exactly what `primary_unrouted` reports as a hard error, and
// what the plater's warning path falls back out of so it still has a filament to name).
int primary_logical = -1;
// Sorted, deduplicated PHYSICAL heads that `used_slots_1b` actually reach. Slots outside
// the pem are dropped rather than clamped: ConfigOption::get_at() clamps to values front,
// which would let an error message name the very head it just said nothing routes to.
std::vector<int> routed_heads;
// The mode declares a primary, the printer has a routing map, and no used filament
// reaches that primary. The precise condition Print::validate refuses the plate on.
bool primary_unrouted = false;
bool routes_to_primary() const { return this->primary_logical >= 0; }
};
// `cfg` is any config carrying the printer's mode-table keys — the Print's applied config
// in the slicer, the edited printer preset in the GUI. `used_slots_1b` is 1-based filament
// indices (Print::extruders() + 1, or PartPlate::get_extruders()). `pem` is the effective
// physical_extruder_map: already normalised on the Print's config by Print::apply(), and
// obtained from effective_physical_extruder_map(PresetBundle) in the GUI.
ImexRouting imex_resolve_routing(const ConfigBase& cfg,
const std::string& parallel_mode,
const std::vector<int>& used_slots_1b,
const ConfigOptionInts& pem);
// Per-gantry grouping derived from the active_tools string. The single source of
// truth for paired-gantry visualization aggregation: when the primary's gantry
// has at least one Span tool, every non-primary gantry's representative tool
@@ -319,6 +482,10 @@ ImexMirrorAxis imex_mirror_axis_for(int primary_phys, int target_phys, int tools
// while the other axis tracks 1:1.
// Zero-length gantry_offset degenerates to identity.
// Primary: identity.
// Span: identity, same as Primary. A Span tool shares the primary's gantry AND its zone,
// printing the same objects through mid-print toolchanges rather than a duplicate
// placed elsewhere, so there is nothing to translate or reflect. calc_imex_ghosts
// bakes no ghost for a Span head, so no caller reaches this today.
Transform3d imex_head_transform(int primary, int target, ImexRole role,
const Vec2d& gantry_offset,
const Vec2d& primary_zone_center,
+621
View File
@@ -4,6 +4,9 @@
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Point.hpp"
#include <iterator>
#include <set>
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
@@ -97,6 +100,72 @@ TEST_CASE("imex_pem_tool_for - MMU collapse routes multiple logical slots to one
REQUIRE(imex_pem_tool_for(6, "copy_mode", pem) == 3);
}
// ---------------------------------------------------------------------------
// imex_physical_heater_for
//
// GCodeWriter::set_temperature(temp, wait, tool) is logical-in, and M104/M109 name a
// physical heater, so this is the single translation point for every non-SEMM
// multi-extruder printer in the tree — IMEX or not. `is_imex` is what keeps non-IMEX
// profiles out of the remap, and physical_extruder_map carries a *different* meaning on
// those (indexed by extruder id, not filament id), so remapping them would retarget
// heaters. fdm_bbl_3dp_002_common ships a non-identity [1, 0], which is the case that
// makes the guard load-bearing rather than cosmetic.
// ---------------------------------------------------------------------------
TEST_CASE("imex_physical_heater_for - non-IMEX printer passes the logical id through untouched", "[IMEX]") {
// The shipping BBL dual-nozzle map. With is_imex=0 the id must come out unchanged;
// remapping here would send filament 0's M109 to heater 1 and vice versa.
auto pem = make_pem({1, 0});
REQUIRE(imex_physical_heater_for(false, pem, 0) == 0);
REQUIRE(imex_physical_heater_for(false, pem, 1) == 1);
// Same guard on an AFC-shaped map, where a remap would collapse four ids onto heater 0.
auto afc = make_pem({0, 0, 0, 0, 1, 2, 3});
REQUIRE(imex_physical_heater_for(false, afc, 3) == 3);
REQUIRE(imex_physical_heater_for(false, afc, 6) == 6);
}
TEST_CASE("imex_physical_heater_for - IMEX identity map is a no-op", "[IMEX]") {
auto pem = make_pem({0, 1, 2, 3});
for (int id = 0; id < 4; ++id)
REQUIRE(imex_physical_heater_for(true, pem, id) == id);
}
TEST_CASE("imex_physical_heater_for - IMEX permutation remaps to the physical heater", "[IMEX]") {
// The same [1, 0] map read the IMEX way: filament 0 lives on heater 1.
auto pem = make_pem({1, 0});
REQUIRE(imex_physical_heater_for(true, pem, 0) == 1);
REQUIRE(imex_physical_heater_for(true, pem, 1) == 0);
}
TEST_CASE("imex_physical_heater_for - IMEX MMU collapse targets the shared heater", "[IMEX]") {
// User's IQEX: logical 0-3 are AFC lanes on heater 0, so all four heat the same hotend.
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
REQUIRE(imex_physical_heater_for(true, pem, 0) == 0);
REQUIRE(imex_physical_heater_for(true, pem, 3) == 0);
REQUIRE(imex_physical_heater_for(true, pem, 4) == 1);
REQUIRE(imex_physical_heater_for(true, pem, 6) == 3);
}
TEST_CASE("imex_physical_heater_for - out-of-range ids pass through instead of clamping", "[IMEX]") {
// Deliberately NOT get_at(), which clamps to values.front() and would silently retarget
// an out-of-range id at whatever heater sits in slot 0 — heater 1 for this map.
auto pem = make_pem({1, 0});
REQUIRE(imex_physical_heater_for(true, pem, -1) == -1); // the "no T parameter" sentinel
REQUIRE(imex_physical_heater_for(true, pem, 2) == 2); // one past the end
REQUIRE(imex_physical_heater_for(true, pem, 99) == 99);
// Same for the single-entry PrintConfig default, which every non-IMEX printer carries.
auto lone = make_pem({0});
REQUIRE(imex_physical_heater_for(true, lone, 1) == 1);
}
TEST_CASE("imex_physical_heater_for - empty pem never remaps", "[IMEX]") {
ConfigOptionInts empty_pem;
REQUIRE(imex_physical_heater_for(true, empty_pem, 0) == 0);
REQUIRE(imex_physical_heater_for(false, empty_pem, 2) == 2);
}
TEST_CASE("imex_suppresses_bare_toolchange - non-IMEX printer never suppresses", "[IMEX]") {
REQUIRE_FALSE(imex_suppresses_bare_toolchange("", 0));
REQUIRE_FALSE(imex_suppresses_bare_toolchange("", 1));
@@ -356,6 +425,40 @@ TEST_CASE("parse_imex_head_filament_map - empty string", "[IMEX]") {
REQUIRE(parse_imex_head_filament_map("").empty());
}
TEST_CASE("parse_imex_head_filament_map - a high byte makes its token unparseable", "[IMEX]") {
// This string comes straight from 3MF metadata, so bytes above 0x7F are reachable without
// ever passing through the UI. 0xFF is a letter, never whitespace in any single-byte locale,
// so it survives the whitespace strip and makes the physical index unparseable — that token
// is dropped and the well-formed token beside it still parses.
//
// What this case does NOT demonstrate is the undefined behaviour of handing a negative char
// to isspace: glibc's ctype table is defined over -128..255 and isspace(-1) returns 0, so
// this passes just as well against the unguarded version. Catching that needs a sanitizer
// build (UBSan) or an MSVC debug CRT, not an assertion here.
auto m = parse_imex_head_filament_map("\xFF" "0:3,4:5");
REQUIRE(m.size() == 1);
REQUIRE(m.count(0) == 0);
REQUIRE(m[4] == 5);
// A high byte in the slot half is likewise rejected rather than read out of range.
REQUIRE(parse_imex_head_filament_map("0:\xFF").empty());
}
TEST_CASE("parse_imex_head_filament_map - absurd indices are rejected at parse", "[IMEX]") {
// Absolute sanity cap only: nothing can exceed the slicer-wide extruder ceiling.
// The bound against the project's actual filament count is resolve_filament_for_head's.
REQUIRE(parse_imex_head_filament_map("1:9999").empty());
REQUIRE(parse_imex_head_filament_map("9999:1").empty());
REQUIRE(parse_imex_head_filament_map("1:0").empty()); // slots are 1-based
REQUIRE(parse_imex_head_filament_map("-1:2").empty());
REQUIRE(parse_imex_head_filament_map("1:-2").empty());
// A bad token does not poison the good ones beside it.
auto m = parse_imex_head_filament_map("0:9999,1:2");
REQUIRE(m.size() == 1);
REQUIRE(m[1] == 2);
}
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
@@ -375,6 +478,26 @@ TEST_CASE("resolve_filament_for_head - no routing for head", "[IMEX]") {
REQUIRE(resolve_filament_for_head(plate_map, pem, 2) == -1);
}
TEST_CASE("resolve_filament_for_head - override past the filament count is ignored", "[IMEX]") {
// pem has one entry per logical filament slot, so a slot at or past its size names a
// filament that does not exist. A project file carrying such an override must not
// resolve to it: downstream lookups use ConfigOption::get_at, which clamps instead of
// failing, so an honoured 1:9999 would be a silently wrong filament rather than a crash.
auto pem = make_pem({0, 0, 0, 0, 1, 2, 3}); // 7 slots -> valid 0-based logicals are 0..6
// Highest legal slot still resolves — the bound must not be off by one.
REQUIRE(resolve_filament_for_head({{1, 7}}, pem, 1) == 6);
// One past it, and far past it, fall back to the printer's own routing for that head
// (first_filament_for_physical_head(pem, 1) == 4), i.e. the override is treated as absent.
REQUIRE(resolve_filament_for_head({{1, 8}}, pem, 1) == 4);
REQUIRE(resolve_filament_for_head({{1, 9999}}, pem, 1) == 4);
// With no pem routing for the head either, an out-of-range override yields -1 rather
// than a wrong slot, so callers take their "no filament" branch.
REQUIRE(resolve_filament_for_head({{5, 9999}}, pem, 5) == -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);
@@ -582,6 +705,22 @@ TEST_CASE("imex_head_transform - primary is identity", "[IMEX]") {
REQUIRE(xf.isApprox(Transform3d::Identity()));
}
TEST_CASE("imex_head_transform - span is identity like primary", "[IMEX]") {
// A Span tool is the primary's within-gantry multicolor partner: it prints the same
// objects in the primary's own zone through mid-print toolchanges, so it has no zone of
// its own to be translated or reflected into. It must ignore gantry_offset entirely —
// translating it by one would place a phantom copy in a neighbouring zone.
const Vec2d offset{120.0, 30.0};
Transform3d xf = imex_head_transform(0, 1, ImexRole::Span, offset, Vec2d{50.0, 50.0},
ImexMirrorAxis::X);
REQUIRE(xf.isApprox(Transform3d::Identity()));
// Cross-gantry axis choice is irrelevant for Span for the same reason.
Transform3d xf_y = imex_head_transform(0, 2, ImexRole::Span, offset, Vec2d{50.0, 50.0},
ImexMirrorAxis::Y);
REQUIRE(xf_y.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, Vec2d::Zero(), ImexMirrorAxis::X);
@@ -643,6 +782,60 @@ TEST_CASE("resolve_filament_for_head - no routing returns -1 (ghost color fallba
REQUIRE(resolve_filament_for_head(override_on_5, pem, 5) == 6);
}
TEST_CASE("imex_role_letter - the historical letters are pinned", "[IMEX]") {
// The write half of the on-disk format for imex_mode_active_tools, spelled out rather than
// read back off kImexRoleTable: both imex_role_letter() and imex_role_from_suffix() are
// linear scans of that table, so driving them from it asserts only that the table agrees
// with itself. These are the letters already written into printer profiles and 3MF projects.
REQUIRE(imex_role_letter(ImexRole::Primary) == 'P');
REQUIRE(imex_role_letter(ImexRole::Copy) == 'C');
REQUIRE(imex_role_letter(ImexRole::Mirror) == 'M');
REQUIRE(imex_role_letter(ImexRole::Span) == 'S');
}
TEST_CASE("the role table gives every role its own letter", "[IMEX]") {
// The one property the table can actually violate, and the one the round trip depends on:
// a duplicated letter makes the on-disk format ambiguous (imex_role_from_suffix returns the
// first row, so the second role silently reads back as the first), and a duplicated role
// makes imex_role_letter's answer depend on row order. Appending a fifth role with a letter
// already in use is exactly how that happens.
std::set<char> letters;
std::set<ImexRole> roles;
for (const ImexRoleDesc& d : kImexRoleTable) {
INFO("role table row with letter '" << d.letter << "'");
CHECK(letters.insert(d.letter).second);
CHECK(roles.insert(d.role).second);
}
REQUIRE(letters.size() == std::size(kImexRoleTable));
}
TEST_CASE("imex_role_from_suffix - the historical letters are pinned", "[IMEX]") {
// Spelled out rather than derived from the table, so a change to the table that would
// reinterpret an existing preset fails here instead of silently agreeing with itself.
REQUIRE(imex_role_from_suffix("P") == ImexRole::Primary);
REQUIRE(imex_role_from_suffix("C") == ImexRole::Copy);
REQUIRE(imex_role_from_suffix("M") == ImexRole::Mirror);
REQUIRE(imex_role_from_suffix("S") == ImexRole::Span);
}
TEST_CASE("imex_role_from_suffix - anything unrecognised stays Copy", "[IMEX]") {
// Copy is the long-standing fallback for an unknown suffix; a project written by a build
// that knows a role this one does not must degrade to Copy, not to Primary.
REQUIRE(imex_role_from_suffix("") == ImexRole::Copy);
REQUIRE(imex_role_from_suffix("X") == ImexRole::Copy);
REQUIRE(imex_role_from_suffix("p") == ImexRole::Copy); // case sensitive, as before
REQUIRE(imex_role_from_suffix("PP") == ImexRole::Copy); // suffix is the whole token tail
}
TEST_CASE("imex_primary_tool_for_mode - only an exact P suffix names the primary", "[IMEX]") {
// Shares imex_role_from_suffix() with parse_imex_active_tools(), so this pins that the
// two agree on which token is the Primary rather than each deciding for itself.
REQUIRE(imex_primary_tool_for_mode("0:C,1:P,2:M") == 1);
REQUIRE(imex_primary_tool_for_mode("0:PP,1:M") == -1);
REQUIRE(imex_primary_tool_for_mode("0:p,1:M") == -1);
REQUIRE(imex_primary_tool_for_mode("0,1,2") == 0); // legacy bare index
}
TEST_CASE("parse_imex_active_tools - Span role parsed from S suffix", "[IMEX]") {
auto out = parse_imex_active_tools("0:P,1:S,2:M,3:M");
REQUIRE(out.size() == 4);
@@ -884,3 +1077,431 @@ TEST_CASE("imex_hull_violates_zones - violating any one of several zones is enou
// Overlaps none of the three (sits in the gaps between them).
REQUIRE_FALSE(imex_hull_violates_zones(zones, scaled_rect(12, 12, 18, 18)));
}
// ---------------------------------------------------------------------------
// find_imex_mode / imex_mode_table
//
// The three mode options are positionally coupled ConfigOptionStrings with nothing
// enforcing equal length, so these pin the resolution rule that every IMEX call site
// now shares: the names array is the roster, the first matching row wins, and a sibling
// array too short to reach that row pads to an empty string and flags `ragged` instead
// of reading out of bounds or degrading into "no such mode".
// ---------------------------------------------------------------------------
namespace {
// Only the keys the mode lookup reads. Any of the three may be omitted by passing an
// empty vector for it -- `imex_mode_table` and `find_imex_mode` must cope with a config
// that never registered the option at all, which is what a non-IMEX preset looks like.
DynamicPrintConfig mode_cfg(const std::vector<std::string>& names,
const std::vector<std::string>& tools,
const std::vector<std::string>& gcodes)
{
DynamicPrintConfig cfg;
if (!names.empty()) cfg.set_key_value("imex_mode_names", new ConfigOptionStrings(names));
if (!tools.empty()) cfg.set_key_value("imex_mode_active_tools", new ConfigOptionStrings(tools));
if (!gcodes.empty()) cfg.set_key_value("imex_mode_gcodes", new ConfigOptionStrings(gcodes));
return cfg;
}
} // namespace
TEST_CASE("find_imex_mode - an exact name yields that row's index, tools and script", "[IMEX]") {
// The defining property: all three fields come from the SAME position, so a lookup
// that resolved the index against one array and read another cannot pass.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy", "iq-copy" },
{ "0:P", "0:P,1:C", "0:P,1:C,2:C,3:C" },
{ "", "COPY_SCRIPT", "QUAD_SCRIPT" });
const ImexMode m = find_imex_mode(cfg, "copy");
REQUIRE(m.found());
CHECK(m.index == 1);
CHECK(m.name == "copy");
CHECK(m.active_tools == "0:P,1:C");
CHECK(m.gcode == "COPY_SCRIPT");
CHECK_FALSE(m.ragged);
const ImexMode last = find_imex_mode(cfg, "iq-copy");
REQUIRE(last.found());
CHECK(last.index == 2);
CHECK(last.active_tools == "0:P,1:C,2:C,3:C");
CHECK(last.gcode == "QUAD_SCRIPT");
}
TEST_CASE("find_imex_mode - a name no row carries is not found", "[IMEX]") {
// What a plate's mode becomes once it is renamed or deleted in Printer Settings, or
// once the project is opened against a preset that names its modes differently. The
// not-found case has to be explicit: an index of -1, not an out-of-range index that
// happens to read empty.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy" },
{ "0:P", "0:P,1:C" },
{ "", "COPY_SCRIPT" });
const ImexMode m = find_imex_mode(cfg, "copy-renamed");
CHECK_FALSE(m.found());
CHECK(m.index == -1);
CHECK(m.name.empty());
CHECK(m.active_tools.empty());
CHECK(m.gcode.empty());
CHECK_FALSE(m.ragged);
CHECK_FALSE(static_cast<bool>(m));
}
TEST_CASE("find_imex_mode - a short active-tools array pads and reports ragged", "[IMEX]") {
// The reviewer's opening case: a profile whose imex_mode_active_tools does not reach
// the last mode. The mode still exists -- it is named, and its index still has to be
// reported to {imex_mode_index} -- but it has no tools, and `ragged` is what lets a
// caller say so instead of silently printing a parallel mode with an empty roster.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy" },
{ "0:P" },
{ "", "COPY_SCRIPT" });
const ImexMode m = find_imex_mode(cfg, "copy");
REQUIRE(m.found());
CHECK(m.index == 1);
CHECK(m.active_tools.empty());
CHECK(m.gcode == "COPY_SCRIPT");
CHECK(m.ragged);
// The row the short array does reach is not ragged.
CHECK_FALSE(find_imex_mode(cfg, kImexPrimaryMode).ragged);
}
TEST_CASE("find_imex_mode - a short gcodes array pads and reports ragged", "[IMEX]") {
// Raggedness in the other direction. A mode with no script is a legitimate profile --
// Primary is normally exactly that -- so this must not cost the mode its tools.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy" },
{ "0:P", "0:P,1:C" },
{ "" });
const ImexMode m = find_imex_mode(cfg, "copy");
REQUIRE(m.found());
CHECK(m.index == 1);
CHECK(m.active_tools == "0:P,1:C");
CHECK(m.gcode.empty());
CHECK(m.ragged);
}
TEST_CASE("find_imex_mode - a missing sibling option is padding, not a failed lookup", "[IMEX]") {
// A profile old enough to predate a key, or a config assembled by hand. Absent is the
// limiting case of short, and must be answered the same way.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy" }, {}, {});
const ImexMode m = find_imex_mode(cfg, "copy");
REQUIRE(m.found());
CHECK(m.index == 1);
CHECK(m.active_tools.empty());
CHECK(m.gcode.empty());
CHECK(m.ragged);
}
TEST_CASE("find_imex_mode - a config with no mode table finds nothing", "[IMEX]") {
// Every non-IMEX printer, and any caller that reaches the helper before the printer
// preset is loaded. Must answer not-found rather than dereference a null option.
const DynamicPrintConfig empty;
CHECK_FALSE(find_imex_mode(empty, "copy").found());
CHECK_FALSE(find_imex_mode(empty, kImexPrimaryMode).found());
CHECK(imex_mode_table(empty).empty());
// An empty names array is the same answer, even with siblings present.
const DynamicPrintConfig no_names = mode_cfg({}, { "0:P,1:C" }, { "COPY_SCRIPT" });
CHECK_FALSE(find_imex_mode(no_names, "copy").found());
CHECK(imex_mode_table(no_names).empty());
}
TEST_CASE("find_imex_mode - a duplicated mode name resolves to the first row", "[IMEX]") {
// The modes editor uniquifies names on entry, so duplicates only arrive from a
// hand-edited profile -- but the eight open-coded lookups this replaced disagreed
// about them: the ones that folded the bounds check into the match condition skipped
// a first row the tools array did not reach and silently took the second. First match
// wins, unconditionally, so the plate, the preview and the emitted G-code cannot pick
// different rows for the same name.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy", "copy" },
{ "0:P", "0:P,1:C", "0:P,1:M" },
{ "", "FIRST", "SECOND" });
const ImexMode m = find_imex_mode(cfg, "copy");
REQUIRE(m.found());
CHECK(m.index == 1);
CHECK(m.active_tools == "0:P,1:C");
CHECK(m.gcode == "FIRST");
// Still the first row when the tools array is too short to cover it. This is the case
// where the old idioms diverged from each other.
const DynamicPrintConfig ragged = mode_cfg({ kImexPrimaryMode, "copy", "copy" },
{ "0:P" },
{ "", "FIRST", "SECOND" });
const ImexMode rm = find_imex_mode(ragged, "copy");
REQUIRE(rm.found());
CHECK(rm.index == 1);
CHECK(rm.active_tools.empty());
CHECK(rm.gcode == "FIRST");
CHECK(rm.ragged);
}
TEST_CASE("imex_mode_table - one row per name, in config order, padded the same way", "[IMEX]") {
// The roster view the modes editor and the plate's mode menu consume. Its size is the
// names array's size and nothing else's, or the editor would drop or invent rows when
// a profile's arrays disagree.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy", "mirror" },
{ "0:P", "0:P,1:C" },
{ "", "COPY_SCRIPT", "MIRROR_SCRIPT", "EXTRA" });
const std::vector<ImexMode> table = imex_mode_table(cfg);
REQUIRE(table.size() == 3);
CHECK(table[0].index == 0);
CHECK(table[0].name == kImexPrimaryMode);
CHECK(table[0].active_tools == "0:P");
CHECK_FALSE(table[0].ragged);
CHECK(table[1].index == 1);
CHECK(table[1].name == "copy");
CHECK(table[1].active_tools == "0:P,1:C");
CHECK(table[1].gcode == "COPY_SCRIPT");
CHECK_FALSE(table[1].ragged);
// Past the end of the tools array: padded, flagged, and NOT dropped from the roster.
CHECK(table[2].index == 2);
CHECK(table[2].name == "mirror");
CHECK(table[2].active_tools.empty());
CHECK(table[2].gcode == "MIRROR_SCRIPT");
CHECK(table[2].ragged);
// A sibling array LONGER than the names array contributes no row: the names array is
// the roster, so the trailing "EXTRA" script belongs to no mode.
for (const ImexMode& m : table)
CHECK(m.gcode != "EXTRA");
}
TEST_CASE("imex_mode_table - every row agrees with find_imex_mode on that name", "[IMEX]") {
// The two entry points must not be able to drift: find_imex_mode is the hot path and
// does not build the table, so this pins them to the same answer.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy", "iq-copy" },
{ "0:P", "0:P,1:C" },
{ "", "COPY_SCRIPT" });
for (const ImexMode& row : imex_mode_table(cfg)) {
DYNAMIC_SECTION("row " << row.index << " (" << row.name << ")") {
const ImexMode found = find_imex_mode(cfg, row.name);
CHECK(found.index == row.index);
CHECK(found.active_tools == row.active_tools);
CHECK(found.gcode == row.gcode);
CHECK(found.ragged == row.ragged);
}
}
}
// ---------------------------------------------------------------------------
// imex_resolve_routing — the one derivation shared by the hard block and the warning
// ---------------------------------------------------------------------------
//
// Print::validate() refuses a plate whose filaments do not route to the mode's declared
// primary; Plater's collect_imex_warnings() names that same primary's filament. Both used
// to re-derive the mode, its roster, the primary and the routing test for themselves, which
// is how they could describe the same plate differently. These pin the single derivation
// they now share, and in particular the physical/logical distinction that
// collect_imex_warnings got wrong once already.
TEST_CASE("imex_resolve_routing - a primary mode derives nothing", "[IMEX]") {
// The reserved sentinel is "no parallel printing": no mode row is looked up, so no
// roster, no primary, and above all no block. Same for a plate that never carried a mode.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy" }, { "0:P", "0:P,1:C" }, { "", "S" });
const auto pem = make_pem({0, 1});
for (const std::string& mode : { std::string(kImexPrimaryMode), std::string() }) {
DYNAMIC_SECTION("mode '" << mode << "'") {
const ImexRouting r = imex_resolve_routing(cfg, mode, { 1 }, pem);
CHECK_FALSE(r.parallel);
CHECK(r.active_tools.empty());
CHECK(r.tools.empty());
CHECK(r.primary_phys == -1);
CHECK(r.primary_logical == -1);
CHECK_FALSE(r.routes_to_primary());
CHECK_FALSE(r.primary_unrouted);
CHECK(r.routed_heads.empty());
}
}
}
TEST_CASE("imex_resolve_routing - plain IDEX copy mode routes its single filament", "[IMEX]") {
// The ordinary case: two carriages, one logical filament each, the plate's object on
// filament 1. Nothing to warn about and nothing to block.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy" }, { "0:P", "0:P,1:C" }, { "", "S" });
const ImexRouting r = imex_resolve_routing(cfg, "copy", { 1 }, make_pem({0, 1}));
CHECK(r.parallel);
CHECK(r.active_tools == "0:P,1:C");
REQUIRE(r.tools.size() == 2);
CHECK(r.tools[0].first == 0);
CHECK(r.tools[0].second == ImexRole::Primary);
CHECK(r.tools[1].first == 1);
CHECK(r.tools[1].second == ImexRole::Copy);
CHECK(r.primary_phys == 0);
CHECK(r.primary_logical == 0);
CHECK(r.routes_to_primary());
CHECK_FALSE(r.primary_unrouted);
CHECK(r.routed_heads == std::vector<int>{0});
}
TEST_CASE("imex_resolve_routing - a plate whose filament misses the primary is unrouted", "[IMEX]") {
// The hard block's own case: IDEX copy mode declares T0 primary, but the object is
// assigned filament 2, which the map routes to T1. `primary_unrouted` is the whole
// condition Print::validate refuses on, and routed_heads is what names T1 in the message.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy" }, { "0:P", "0:P,1:C" }, { "", "S" });
const ImexRouting r = imex_resolve_routing(cfg, "copy", { 2 }, make_pem({0, 1}));
CHECK(r.primary_phys == 0);
CHECK(r.primary_logical == -1);
CHECK_FALSE(r.routes_to_primary());
CHECK(r.primary_unrouted);
CHECK(r.routed_heads == std::vector<int>{1});
}
TEST_CASE("imex_resolve_routing - AFC manifold: primary is a PHYSICAL index, not a logical slot", "[IMEX]") {
// The index confusion collect_imex_warnings shipped with (fixed in fbc58d2a1d): the
// mode's tool numbers are PHYSICAL carriages, while filament presets and bed temps are
// indexed by LOGICAL slot, and on an AFC manifold the two diverge. Here logical slots
// 0-3 are four AFC lanes on physical T0; T1, T2, T3 are direct carriages fed by logical
// 4, 5, 6.
//
// The mode makes physical T1 the primary and the plate's object is on filament 5
// (1-based) = logical slot 4. The routing must report slot 4. Reporting slot 1 -- the
// primary's own physical number read as a logical index -- is the bug: logical 1 is an
// AFC lane on T0, so the warning named a filament that is not on the primary at all.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "afc-copy" },
{ "0:P", "1:P,2:C,3:C" },
{ "", "S" });
const auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
const ImexRouting r = imex_resolve_routing(cfg, "afc-copy", { 5 }, pem);
CHECK(r.primary_phys == 1);
CHECK(r.primary_logical == 4);
CHECK(r.primary_logical != r.primary_phys);
CHECK(r.routes_to_primary());
CHECK_FALSE(r.primary_unrouted);
CHECK(r.routed_heads == std::vector<int>{1});
}
TEST_CASE("imex_resolve_routing - AFC manifold: an AFC lane does not satisfy a direct primary", "[IMEX]") {
// Same manifold, same mode, but the object is on filament 2 (logical slot 1), an AFC
// lane on physical T0. T1 is the declared primary and nothing reaches it, so the plate
// is unrouted -- which is precisely the answer a physical-as-logical read would have
// inverted, since logical slot 1 exists and would have looked like a hit.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "afc-copy" },
{ "0:P", "1:P,2:C,3:C" },
{ "", "S" });
const ImexRouting r = imex_resolve_routing(cfg, "afc-copy", { 2 }, make_pem({0, 0, 0, 0, 1, 2, 3}));
CHECK(r.primary_phys == 1);
CHECK(r.primary_logical == -1);
CHECK(r.primary_unrouted);
CHECK(r.routed_heads == std::vector<int>{0});
}
TEST_CASE("imex_resolve_routing - AFC manifold: any lane on the primary head satisfies it", "[IMEX]") {
// Four logical lanes share physical T0. With T0 primary, an object on any of them
// routes, and the first used slot wins -- the rule imex_primary_logical_from_objects
// documents, pinned here because the warning names the filament it picks.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "afc-copy" },
{ "0:P", "0:P,1:C,2:C,3:C" },
{ "", "S" });
const auto pem = make_pem({0, 0, 0, 0, 1, 2, 3});
CHECK(imex_resolve_routing(cfg, "afc-copy", { 3 }, pem).primary_logical == 2);
CHECK(imex_resolve_routing(cfg, "afc-copy", { 4, 2 }, pem).primary_logical == 3);
CHECK(imex_resolve_routing(cfg, "afc-copy", { 5, 2 }, pem).primary_logical == 1);
}
TEST_CASE("imex_resolve_routing - warning and block can never disagree about the primary", "[IMEX]") {
// The defining property of the extraction. Plater reads primary_logical to name a
// filament and Print::validate reads primary_unrouted to refuse the plate; they are two
// views of one value, so a plate is unrouted exactly when no slot was found.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy", "quad" },
{ "0:P", "0:P,1:C", "0:P,1:S,2:M,3:M" },
{ "", "S", "Q" });
const std::vector<std::vector<int>> slot_sets = { {}, {1}, {2}, {3}, {1, 2}, {2, 3}, {4} };
const std::vector<ConfigOptionInts> pems = { make_pem({0, 1}), make_pem({0, 0, 1, 2}), make_pem({1, 0}) };
for (const std::string& mode : { "copy", "quad" }) {
for (size_t s = 0; s < slot_sets.size(); ++s) {
for (size_t p = 0; p < pems.size(); ++p) {
DYNAMIC_SECTION(mode << " slots#" << s << " pem#" << p) {
const ImexRouting r = imex_resolve_routing(cfg, mode, slot_sets[s], pems[p]);
REQUIRE(r.primary_phys >= 0);
REQUIRE_FALSE(pems[p].values.empty());
CHECK(r.primary_unrouted == !r.routes_to_primary());
}
}
}
}
}
TEST_CASE("imex_resolve_routing - an empty routing map is not an unrouted plate", "[IMEX]") {
// A printer that declares no physical_extruder_map has said nothing about where its
// filaments go, which is not the same claim as "they go somewhere other than the
// primary". Only the latter is an error, so this must not block.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy" }, { "0:P", "0:P,1:C" }, { "", "S" });
const ImexRouting r = imex_resolve_routing(cfg, "copy", { 1 }, ConfigOptionInts{});
CHECK(r.primary_phys == 0);
CHECK(r.primary_logical == -1);
CHECK_FALSE(r.primary_unrouted);
CHECK(r.routed_heads.empty());
}
TEST_CASE("imex_resolve_routing - a mode with no primary marker never blocks", "[IMEX]") {
// A hand-edited roster that declares only copies. There is no primary to route to, so
// there is nothing to refuse -- the roster still parses, which is what the plater needs
// to decide it has fewer than two tools to compare.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "broken" }, { "0:P", "0:C,1:C" }, { "", "S" });
const ImexRouting r = imex_resolve_routing(cfg, "broken", { 2 }, make_pem({0, 1}));
CHECK(r.parallel);
CHECK(r.tools.size() == 2);
CHECK(r.primary_phys == -1);
CHECK(r.primary_logical == -1);
CHECK_FALSE(r.primary_unrouted);
}
TEST_CASE("imex_resolve_routing - an unresolved or ragged mode yields an empty roster", "[IMEX]") {
// A plate whose mode was renamed or deleted in Printer Settings, and a profile whose
// active-tools array does not reach the named row. Both are "no tools, no primary,
// no block" -- the answer both call sites relied on the mode lookup for.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "copy", "ragged" },
{ "0:P", "0:P,1:C" },
{ "", "S", "R" });
const auto pem = make_pem({0, 1});
for (const std::string& mode : { "copy-renamed", "ragged" }) {
DYNAMIC_SECTION("mode " << mode) {
const ImexRouting r = imex_resolve_routing(cfg, mode, { 2 }, pem);
CHECK(r.parallel);
CHECK(r.active_tools.empty());
CHECK(r.tools.empty());
CHECK(r.primary_phys == -1);
CHECK_FALSE(r.primary_unrouted);
}
}
}
TEST_CASE("imex_resolve_routing - routed_heads is sorted, deduplicated and never clamps", "[IMEX]") {
// routed_heads is what the block's message lists as "this plate's filaments are on ...".
// Slots past the end of the map are DROPPED, not clamped: ConfigOption::get_at() clamps
// to values.front(), which would let the sentence name T0 -- the very head it just said
// nothing routes to.
const DynamicPrintConfig cfg = mode_cfg({ kImexPrimaryMode, "quad" },
{ "0:P", "0:P,1:C,2:C,3:C" },
{ "", "Q" });
const auto pem = make_pem({0, 0, 1, 2});
// Slots 3 and 4 (logical 2, 3) reach T1 and T2; slot 2 (logical 1) reaches T0; slot 9
// is past the map and contributes nothing.
const ImexRouting r = imex_resolve_routing(cfg, "quad", { 4, 2, 3, 9, 4 }, pem);
CHECK(r.routed_heads == std::vector<int>{0, 1, 2});
// Every used slot out of range: the message would otherwise read "on T0".
const ImexRouting all_out = imex_resolve_routing(cfg, "quad", { 9, 12 }, pem);
CHECK(all_out.routed_heads.empty());
CHECK(all_out.primary_unrouted);
}