From 171a7213040cc0aabf8546b7364913be45cada1f Mon Sep 17 00:00:00 2001 From: Clifford Garwood Date: Thu, 3 Sep 2026 00:55:03 -0400 Subject: [PATCH] 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) --- src/libslic3r/IMEXHelpers.cpp | 176 +++++++- src/libslic3r/IMEXHelpers.hpp | 171 ++++++- tests/libslic3r/test_imex_helpers.cpp | 621 ++++++++++++++++++++++++++ 3 files changed, 951 insertions(+), 17 deletions(-) diff --git a/src/libslic3r/IMEXHelpers.cpp b/src/libslic3r/IMEXHelpers.cpp index 3b45d24357..159d2f505a 100644 --- a/src/libslic3r/IMEXHelpers.cpp +++ b/src/libslic3r/IMEXHelpers.cpp @@ -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> parse_imex_active_tools(const std::string& active_tools_for_mode) { std::vector> out; @@ -187,21 +222,18 @@ std::vector> 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 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& used_slots_1b, return -1; } +ImexRouting imex_resolve_routing(const ConfigBase& cfg, + const std::string& parallel_mode, + const std::vector& 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 imex_secondary_logical_slots(const std::vector& active_physicals, int primary_physical, const std::map& plate_head_filament_map, @@ -357,7 +437,16 @@ int resolve_filament_for_head(const std::map& 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("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("imex_mode_active_tools"); + const auto* gcodes = cfg.option("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 imex_mode_table(const ConfigBase& cfg) +{ + std::vector table; + const auto* names = cfg.option("imex_mode_names"); + if (names == nullptr) + return table; + const auto* tools = cfg.option("imex_mode_active_tools"); + const auto* gcodes = cfg.option("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& zones, const Polygon& hull) { if (hull.points.empty()) diff --git a/src/libslic3r/IMEXHelpers.hpp b/src/libslic3r/IMEXHelpers.hpp index 6942e9d273..8a5b59deea 100644 --- a/src/libslic3r/IMEXHelpers.hpp +++ b/src/libslic3r/IMEXHelpers.hpp @@ -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), 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 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 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& plate_map, const ConfigOptionInts& pem, int physical); @@ -233,6 +307,40 @@ std::vector imex_secondary_logical_slots(const std::vector& 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> 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> 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 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& 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, diff --git a/tests/libslic3r/test_imex_helpers.cpp b/tests/libslic3r/test_imex_helpers.cpp index 9c66bb871b..05c98b9a5a 100644 --- a/tests/libslic3r/test_imex_helpers.cpp +++ b/tests/libslic3r/test_imex_helpers.cpp @@ -4,6 +4,9 @@ #include "libslic3r/PrintConfig.hpp" #include "libslic3r/Point.hpp" +#include +#include + 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 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 letters; + std::set 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& names, + const std::vector& tools, + const std::vector& 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(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 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{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{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{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{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> slot_sets = { {}, {1}, {2}, {3}, {1, 2}, {2, 3}, {4} }; + const std::vector 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{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); +}