diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index db78326bf1..3934cd7924 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -1331,11 +1331,14 @@ StringObjectException Print::validate(std::vector *warnin // IDEX/IQEX: block multi-color in non-primary parallel modes when the active // configuration can't physically support it (no within-gantry toolchange path // or MMU lane sharing). See imex_multicolor_block_reason() for the full rule. - if (m_config.is_imex.value && extruders.size() > 1 && !m_objects.empty()) { + // Both IMEX checks below need the active mode's tools string, so the parallel table is + // walked once here. Note this block is NOT gated on extruders.size() > 1: the primary + // routing check applies to a single-filament plate too, which is its most common case. + if (m_config.is_imex.value && !m_objects.empty()) { const std::string& parallel_mode = m_objects.front()->config().imex_parallel_mode.value; if (!parallel_mode.empty() && parallel_mode != kImexPrimaryMode) { // Look up the active mode's tools string in the printer config's parallel - // table. Fall back to empty (block helper handles it gracefully). + // table. Fall back to empty (the helpers below handle it gracefully). std::string active_tools_str; const auto& mode_names = m_config.imex_mode_names.values; const auto& mode_tools = m_config.imex_mode_active_tools.values; @@ -1349,14 +1352,94 @@ StringObjectException Print::validate(std::vector *warnin used_filaments_0b.reserve(extruders.size()); for (unsigned int e : extruders) used_filaments_0b.push_back((int)e); - const std::string reason = imex_multicolor_block_reason( - parallel_mode, - active_tools_str, - m_config.imex_tools_per_gantry.value, - used_filaments_0b, - m_config.physical_extruder_map); - if (!reason.empty()) - return { reason }; + + // Multi-color has the more specific rule and its remedies are self-contained + // (an MMU manifold sharing one head cannot be fixed by switching mode), so it runs + // first: validate() returns on the first error, and the routing block below would + // otherwise mask it with advice that leads to this error on the next slice. + if (used_filaments_0b.size() > 1) { + const std::string reason = imex_multicolor_block_reason( + parallel_mode, + active_tools_str, + m_config.imex_tools_per_gantry.value, + used_filaments_0b, + m_config.physical_extruder_map); + if (!reason.empty()) + return { reason }; + } + + // The IMEX Primary tool prints the sliced paths directly, so it can only use a + // filament the printer's physical_extruder_map routes to it. The ghost filament + // picker already enforces this for the secondary tools; the primary's filament + // comes from the ordinary object filament selector, which has no IMEX awareness, + // so nothing detected the mismatch. collect_imex_warnings() computes the same + // condition and discards it into a display fallback. + // + // Blocks rather than warns, matching the multi-color rule above. The plate is not + // printable as configured: the primary tool executes the toolpaths while the flow + // and temperatures were computed for a filament it cannot load. Where the routed + // head is also absent from the mode's active tools, the 1st->2nd layer temperature + // branch (GCode.cpp, mutually exclusive with the standard path) skips it too, so + // that head holds nozzle_temperature_initial_layer for the whole job. First-layer + // temperatures are unaffected -- _print_first_layer_extruder_temperatures is not + // IMEX-branched -- so this is a stuck-hot nozzle, not a cold one. + const ConfigOptionInts& pem = m_config.physical_extruder_map; + const int declared_primary = imex_primary_tool_for_mode(active_tools_str); + if (declared_primary >= 0 && !pem.values.empty()) { + std::vector used_slots_1b; + used_slots_1b.reserve(used_filaments_0b.size()); + for (int slot_0b : used_filaments_0b) + used_slots_1b.push_back(slot_0b + 1); + if (imex_primary_logical_from_objects(used_slots_1b, pem, declared_primary) < 0) { + // Bounds-check exactly as imex_primary_logical_from_objects does. get_at() + // CLAMPS out-of-range slots to values.front(), which would let the message + // name the very tool it just said nothing routes to. + std::vector routed_heads; + routed_heads.reserve(used_filaments_0b.size()); + for (int slot_0b : used_filaments_0b) + if (slot_0b >= 0 && slot_0b < (int) pem.values.size()) + routed_heads.push_back(pem.values[slot_0b]); + std::sort(routed_heads.begin(), routed_heads.end()); + routed_heads.erase(std::unique(routed_heads.begin(), routed_heads.end()), routed_heads.end()); + std::string routed_list; + for (int head : routed_heads) + routed_list += (routed_list.empty() ? "T" : ", T") + std::to_string(head); + if (routed_list.empty()) + routed_list = L("no configured extruder"); + + // Name the modes that would actually work, by primary tool. Suggesting a + // tool number is useless on its own -- the plate's mode menu lists mode + // names, never their primaries. + std::string candidate_modes; + for (size_t i = 0; i < mode_names.size() && i < mode_tools.size(); ++i) { + const int primary_i = imex_primary_tool_for_mode(mode_tools[i]); + if (primary_i >= 0 && std::binary_search(routed_heads.begin(), routed_heads.end(), primary_i)) + candidate_modes += (candidate_modes.empty() ? "\"" : ", \"") + mode_names[i] + "\""; + } + + StringObjectException err; + err.string = candidate_modes.empty() + ? Slic3r::format( + L("IMEX mode \"%1%\" prints with tool T%2%, but no filament used on this plate " + "is loaded on T%2% -- the plate's filaments are on %3%. The Primary tool " + "prints the sliced paths directly, so it can only use a filament the " + "printer's physical extruder map routes to it. Assign an object a filament " + "loaded on T%2%, switch this plate to Primary mode, or edit the mode in " + "Printer Settings so its Primary tool is one of %3%."), + parallel_mode, declared_primary, routed_list) + : Slic3r::format( + L("IMEX mode \"%1%\" prints with tool T%2%, but no filament used on this plate " + "is loaded on T%2% -- the plate's filaments are on %3%. The Primary tool " + "prints the sliced paths directly, so it can only use a filament the " + "printer's physical extruder map routes to it. Assign an object a filament " + "loaded on T%2%, switch this plate to Primary mode, or switch it to one of: %4%."), + parallel_mode, declared_primary, routed_list, candidate_modes); + // Gives the notification a "Jump to " link, which selects the object + // and switches to Prepare -- directly enabling the first suggested remedy. + err.object = m_objects.front(); + return err; + } + } } } diff --git a/tests/fff_print/test_multifilament.cpp b/tests/fff_print/test_multifilament.cpp index 0656193a6d..9ec70a2c89 100644 --- a/tests/fff_print/test_multifilament.cpp +++ b/tests/fff_print/test_multifilament.cpp @@ -804,14 +804,22 @@ TEST_CASE("Primary-mode IMEX prints mark only the filament slot they print with" } // Guard rail for the fix above: the modes the supplement exists for must keep marking their -// secondaries. `copy` declares heads 0 and 1; neither is the initial tool's head (filament 6 -// routes to head 2), so both are still enumerated. Head 0 -> slot 0, head 1 -> slot 4. +// secondaries. Printed on filament 1 (slot 0), which pem routes to head 0 -- the head `copy` +// declares Primary -- so the plate is well-formed and validate() lets it through. Head 0 is +// the initial tool's head and is skipped (tool_ordering already marked slot 0); head 1 is the +// secondary and resolves to slot 4. +// +// This case previously printed on filament 6, which routes to head 2 while `copy` declares +// head 0 Primary. Print::validate() now refuses that plate outright (no filament on it can +// feed the Primary tool), so asserting it slices correctly would contradict +// "An IMEX plate whose filament never routes to the primary carriage is blocked" below. +// slice() does not surface validate()'s return, so the contradiction would have gone unnoticed. TEST_CASE("Copy-mode IMEX prints still mark every secondary carriage's filament slot", "[MultiFilament][IMEX]") { DynamicPrintConfig config = multifilament_config(7); imex_7x4_printer(config); - all_regions_on_filament(config, 6); + all_regions_on_filament(config, 1); config.set_deserialize_strict({ { "imex_parallel_mode", "copy" }, { "machine_start_gcode", @@ -822,9 +830,9 @@ TEST_CASE("Copy-mode IMEX prints still mark every secondary carriage's filament const std::string gcode = slice({ cube(20) }, config); - CHECK(gcode.find(";USED5:1") != std::string::npos); - CHECK(gcode.find(";USED0:1") != std::string::npos); - CHECK(gcode.find(";USED4:1") != std::string::npos); + CHECK(gcode.find(";USED0:1") != std::string::npos); // the filament actually printed + CHECK(gcode.find(";USED4:1") != std::string::npos); // head 1, the secondary carriage + CHECK(gcode.find(";USED5:0") != std::string::npos); } // The two IMEX changes are coupled: get_imex_active_tools() now returns an empty roster in @@ -885,3 +893,57 @@ TEST_CASE("IQEX modes emit first- and second-layer temperatures for every active CHECK(gcode.find("M104 S240 T2") != std::string::npos); CHECK(gcode.find("M104 S240 T3") != std::string::npos); } + +// The IMEX Primary tool prints the sliced paths directly, so it can only use a filament the +// printer's physical_extruder_map routes to it. The ghost filament picker enforces that for +// the secondary tools; the primary's filament comes from the ordinary object selector, which +// has no IMEX awareness. `copy` declares T0 Primary, but every filament this plate uses -- +// filament 6, slot 5 -- routes to head 2, so nothing can feed T0 and validate() must refuse. +// +// The object's own extruder is pinned too: ModelVolume::get_extruders() reports the volume's +// extruder_id (1 by default), which would put slot 0 on the plate. Slot 0 routes to head 0, +// the declared primary, so the plate would be well-formed and correctly NOT blocked. +TEST_CASE("An IMEX plate whose filament never routes to the Primary tool is blocked", + "[MultiFilament][IMEX]") +{ + DynamicPrintConfig config = multifilament_config(7); + imex_7x4_printer(config); + all_regions_on_filament(config, 6); + config.set_deserialize_strict({ { "imex_parallel_mode", "copy" } }); + + std::vector meshes; + meshes.push_back(cube(20)); + const std::vector> overrides{ { { "extruder", "6" } } }; + + Slic3r::Model model; + Slic3r::Print print; + init_print(std::move(meshes), print, model, config, &overrides, false); + + std::vector warnings; + const StringObjectException err = print.validate(&warnings); + + REQUIRE_FALSE(err.string.empty()); + CHECK(err.string.find("Primary tool") != std::string::npos); + CHECK(err.string.find("T0") != std::string::npos); // the declared primary + CHECK(err.string.find("T2") != std::string::npos); // where the filament actually lives +} + +// Guard rail: the block must not fire on a well-formed plate. Filament 1 (slot 0) routes to +// head 0, which `copy` declares Primary, so the Primary tool has something to print with. +TEST_CASE("An IMEX plate whose filament routes to the Primary tool validates", + "[MultiFilament][IMEX]") +{ + DynamicPrintConfig config = multifilament_config(7); + imex_7x4_printer(config); + all_regions_on_filament(config, 1); + config.set_deserialize_strict({ { "imex_parallel_mode", "copy" } }); + + Slic3r::Model model; + Slic3r::Print print; + init_print({ cube(20) }, print, model, config); + + std::vector warnings; + const StringObjectException err = print.validate(&warnings); + + CHECK(err.string.empty()); +}