From c94e8324d050b003ca68a70f5aeae9023825bbab Mon Sep 17 00:00:00 2001 From: Clifford Garwood Date: Sat, 22 Aug 2026 00:22:35 -0400 Subject: [PATCH 1/3] fix(imex): give the printing head its second-layer temperature in parallel modes The IMEX branch of the 1st->2nd layer temperature transition is mutually exclusive with the standard per-extruder path in its `else`, but it skipped the head carrying the print's own toolpaths on the premise that "the standard per-extruder temp path already addresses it". That path is the `else` branch and never runs for a parallel mode, so the printing head received no transition at all and held nozzle_temperature_initial_layer for the entire job. Emit for every carriage the mode drives, the printing one included. The printing head takes this layer's own filament; the parallel carriages, which carry no toolpaths of their own, keep resolving through the per-plate head map with pem inversion as the fallback. The lookup now goes through get_filament_config_index() like the standard path, since a variant-expanded printer gives a filament its own column and a raw index would read the wrong one. Reproduced on a 4-carriage IQEX in copy mode: the only temperature command in the whole file set the idle secondary carriage to the value it already had, while the head doing the printing never left its first-layer temperature. The defect is invisible whenever initial and regular temperatures match, which is why earlier per-tool validation passed. Tests cover both gantry counts, since the active set comes from the mode's tool roster: an IDEX copy mode drives two carriages, an IQEX mode drives four, and the IQEX case asserts a first-layer filament and a second-layer transition for each of the four. Co-Authored-By: Claude Opus 5 (1M context) --- src/libslic3r/GCode.cpp | 23 +++-- tests/fff_print/test_multifilament.cpp | 122 +++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 10 deletions(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index b12515d13e..eb54d1f18a 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -3885,6 +3885,8 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato // for the PA setting lookup. Guarded on non-empty pem above. const int initial_physical = m_config.physical_extruder_map.get_at((int)initial_extruder_id); for (int tool_idx : get_imex_active_tools(print)) { + // Unlike the second-layer temperature loop, the primary is skipped here: + // set_extruder() above already emitted its PA with the pem tool qualifier. if (tool_idx == initial_physical) continue; const int logical = resolve_filament_for_head( m_imex_head_filament_map, m_config.physical_extruder_map, tool_idx); @@ -5902,20 +5904,21 @@ LayerResult GCode::process_layer( // All active tools need explicit temps — none receive tool-change commands, // so we can't rely on the condition used for non-IMEX (temp != initial_layer_temp). // A tool whose initial and regular temps are the same still needs to be set here. - // Skip the primary head: its filament is governed by the object sidebar and - // the standard per-extruder temp path already addresses it (matching the PA - // emission site above). `tool_idx` is physical; resolve to a filament slot - // via pem inversion for temp lookup. - const int num_nozzles = (int)print.config().nozzle_temperature.values.size(); + // Mutually exclusive with the `else` below, so a head skipped here gets no + // transition at all. `tool_idx` is physical; the printing head uses this layer's + // own filament, the parallel carriages resolve through the head map. + const int num_filament_columns = (int)print.config().nozzle_temperature.values.size(); const int initial_physical = m_config.physical_extruder_map.values.empty() ? -1 : m_config.physical_extruder_map.get_at((int)first_extruder_id); for (int tool_idx : get_imex_active_tools(print)) { - if (tool_idx == initial_physical) continue; - const int logical = resolve_filament_for_head( - m_imex_head_filament_map, m_config.physical_extruder_map, tool_idx); - if (logical < 0 || logical >= num_nozzles) continue; - int temperature = print.config().nozzle_temperature.values[logical]; + const int logical = (tool_idx == initial_physical) + ? (int)first_extruder_id + : resolve_filament_for_head( + m_imex_head_filament_map, m_config.physical_extruder_map, tool_idx); + if (logical < 0 || logical >= num_filament_columns) continue; + // Variant-expanded printers column each filament; index as the `else` does. + int temperature = print.config().nozzle_temperature.get_at(get_filament_config_index(logical)); if (temperature > 0) gcode += GCodeWriter::set_temperature(temperature, m_writer.config.gcode_flavor, false, tool_idx, "set IMEX tool temperature"); diff --git a/tests/fff_print/test_multifilament.cpp b/tests/fff_print/test_multifilament.cpp index 484b7fb68a..7f8f5b5c6f 100644 --- a/tests/fff_print/test_multifilament.cpp +++ b/tests/fff_print/test_multifilament.cpp @@ -715,3 +715,125 @@ TEST_CASE("Multi-extruder slice stays in bounds with a short max_layer_height", REQUIRE_FALSE(print.objects().front()->layers().empty()); } +// Shared IMEX printer geometry: 7 logical extruders across 4 physical heads. +// physical_extruder_map is only honoured when its length matches the nozzle count +// (PrintApply feeds effective_physical_extruder_map the nozzle_diameter size), so the +// nozzle keys must be sized to 7 or the map is silently replaced with the identity and +// every logical slot resolves to its own head -- which hides the defects under test. +static void imex_7x4_printer(DynamicPrintConfig &config) +{ + config.set_deserialize_strict({ + { "nozzle_diameter", "0.4,0.4,0.4,0.4,0.4,0.4,0.4" }, + { "printer_extruder_id", "1,2,3,4,5,6,7" }, + { "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard,Direct Drive Standard," + "Direct Drive Standard,Direct Drive Standard,Direct Drive Standard," + "Direct Drive Standard" }, + { "extruder_printable_height", "0,0,0,0,0,0,0" }, + { "physical_extruder_map", "0,0,0,0,1,2,3" }, + { "is_imex", "1" }, + { "imex_mode_names", "primary;copy" }, + { "imex_mode_active_tools", "0:P;0:P,1:C" }, + { "skirt_loops", "0" }, + { "brim_type", "no_brim" }, + // Temperature assertions below spell "M104 S T"; RepRapFirmware would emit + // "G10 S P" from the same code, so the flavor is pinned rather than defaulted. + { "gcode_flavor", "klipper" }, + }); +} + +// Route every region to one filament. An unset *_filament_id is not "inherit": +// clamp_feature_filament_to_valid rewrites <=0 to 1, which would drag tool 0 into +// tool_ordering and mask what these tests assert. PrintObject.cpp's call to that +// function is the source of truth for this key list -- a new one has to be added here. +static void all_regions_on_filament(DynamicPrintConfig &config, int filament_1based) +{ + for (const char *key : { "outer_wall_filament_id", "inner_wall_filament_id", + "sparse_infill_filament_id", "internal_solid_filament_id", + "top_surface_filament_id", "bottom_surface_filament_id" }) + config.set_deserialize_strict({ { key, std::to_string(filament_1based) } }); +} + +// IMEX parallel modes emit per-carriage temperatures from a branch that is mutually +// exclusive with the standard per-extruder path, and that branch skipped the head the +// print's own toolpaths run on. That head therefore never received its 1st->2nd layer +// transition and held nozzle_temperature_initial_layer for the whole job. +TEST_CASE("Parallel-mode IMEX prints transition the printing head to its second-layer temperature", + "[MultiFilament][IMEX]") +{ + DynamicPrintConfig config = multifilament_config(7); + imex_7x4_printer(config); + all_regions_on_filament(config, 1); // filament 1 => logical slot 0 => physical head 0 + // The multi-extruder normalization collapses per-filament temperature vectors to a + // single value, so heads are told apart by their tool qualifier, not by temperature. + config.set_deserialize_strict({ + { "imex_parallel_mode", "copy" }, + { "nozzle_temperature_initial_layer", "200" }, + { "nozzle_temperature", "240" }, + }); + + const std::string gcode = slice({ cube(20) }, config); + + // Head 0 runs the print's own toolpaths and must step 200 -> 240 at the second layer. + CHECK(gcode.find("M104 S240 T0") != std::string::npos); + // Head 1 is the copy carriage; it already worked and must keep working. + CHECK(gcode.find("M104 S240 T1") != std::string::npos); +} + +// The two IMEX changes are coupled: get_imex_active_tools() now returns an empty roster in +// primary mode, so if the temperature branch ever stopped excluding primary it would enter, +// emit nothing, skip the standard path, and silently restore the bug the copy-mode case above +// covers -- with every other test still green. +TEST_CASE("Primary-mode IMEX prints still transition to the second-layer temperature", + "[MultiFilament][IMEX]") +{ + DynamicPrintConfig config = multifilament_config(7); + imex_7x4_printer(config); + all_regions_on_filament(config, 1); + config.set_deserialize_strict({ + { "imex_parallel_mode", "primary" }, + { "nozzle_temperature_initial_layer", "200" }, + { "nozzle_temperature", "240" }, + }); + + const std::string gcode = slice({ cube(20) }, config); + + CHECK(gcode.find("M104 S240") != std::string::npos); +} + +// IQEX: when the second gantry is active the mode drives all four carriages, so every one of +// them needs its own filament resolved -- for the first layer via is_extruder_used (consumed by +// machine_start_gcode) and for the second via the per-tool transition. pem routes filament 1 to +// head 0, and heads 1/2/3 to filament slots 4/5/6, so all four slots must appear. +TEST_CASE("IQEX modes emit first- and second-layer temperatures for every active carriage", + "[MultiFilament][IMEX]") +{ + DynamicPrintConfig config = multifilament_config(7); + imex_7x4_printer(config); + all_regions_on_filament(config, 1); + config.set_deserialize_strict({ + { "imex_mode_names", "primary;copy;iq-copy" }, + { "imex_mode_active_tools", "0:P;0:P,1:C;0:P,1:C,2:C,3:C" }, + { "imex_parallel_mode", "iq-copy" }, + { "nozzle_temperature_initial_layer", "200" }, + { "nozzle_temperature", "240" }, + { "machine_start_gcode", + ";USED0:{if is_extruder_used[0]}1{else}0{endif}\n" + ";USED4:{if is_extruder_used[4]}1{else}0{endif}\n" + ";USED5:{if is_extruder_used[5]}1{else}0{endif}\n" + ";USED6:{if is_extruder_used[6]}1{else}0{endif}\n" }, + }); + + const std::string gcode = slice({ cube(20) }, config); + + // First layer: every active carriage's filament is declared to machine_start_gcode. + CHECK(gcode.find(";USED0:1") != std::string::npos); + CHECK(gcode.find(";USED4:1") != std::string::npos); + CHECK(gcode.find(";USED5:1") != std::string::npos); + CHECK(gcode.find(";USED6:1") != std::string::npos); + + // Second layer: every active carriage gets its own transition. + CHECK(gcode.find("M104 S240 T0") != std::string::npos); + CHECK(gcode.find("M104 S240 T1") != std::string::npos); + CHECK(gcode.find("M104 S240 T2") != std::string::npos); + CHECK(gcode.find("M104 S240 T3") != std::string::npos); +} From ef8d80980d506a0c41a0d9b12e45e7f2ec305c07 Mon Sep 17 00:00:00 2001 From: Clifford Garwood Date: Sat, 22 Aug 2026 00:23:43 -0400 Subject: [PATCH 2/3] fix(imex): enumerate no secondary carriages in single-tool primary mode get_imex_active_tools returned every physical head named by the active mode's tool string, including the one carrying the Primary role. The pressure-advance and nozzle-temperature sites are already gated on the mode not being primary, so only the is_extruder_used supplement was exposed. In primary mode that supplement treated the mode's single declared tool as a secondary carriage and marked its filament slot used, so machine_start_gcode emitted a heat command for an extruder that never prints. The phantom slot appears when the mode's declared tool differs from the head the initial tool routes to through physical_extruder_map -- on an AFC/MMU layout, printing with a filament that lives on any head other than the declared one. Return an empty roster for primary mode, where there are no parallel carriages by definition. This lives in the enumerator rather than at the call site because the mode is already resolved and normalized there, and the two guarded callers cannot reach it in that mode, so their behaviour is unchanged. Scope: this closes the primary-mode instance. The same phantom slot still occurs in a parallel mode when the initial tool's head is not the mode's declared Primary, which turns on which of the two notions of "primary" the three emission sites should skip. That question is unresolved and deliberately left alone here. Co-Authored-By: Claude Opus 5 (1M context) --- src/libslic3r/GCode.cpp | 8 ++++- tests/fff_print/test_multifilament.cpp | 48 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index eb54d1f18a..0de4c16944 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -2887,7 +2887,9 @@ static BambuBedType to_bambu_bed_type(BedType type) return bambu_bed_type; } -// Orca IMEX: Returns the tool indices active in the current IMEX mode. +// Orca IMEX: Returns the PHYSICAL tool indices the active IMEX mode drives, or an EMPTY +// vector in primary mode -- a single tool, so there are no parallel carriages to list. +// The mode's Primary head IS included in parallel modes; callers handle it themselves. // In copy/mirror parallel modes, secondary carriages never receive tool-change // commands — the firmware mirrors the primary's moves — so they don't appear in // tool_ordering.all_extruders(). This helper extracts the active mode's tool string @@ -2906,6 +2908,10 @@ static std::vector get_imex_active_tools(const Print& print) const std::string& raw_mode = print.objects().front()->config().imex_parallel_mode.value; const std::string active_mode = raw_mode.empty() ? kImexPrimaryMode : raw_mode; + // Primary mode drives a single tool, so there are no parallel carriages to list. + if (active_mode == kImexPrimaryMode) + return active_tools; + const auto* mode_names_opt = print.config().option("imex_mode_names"); const auto* tools_opt = print.config().option("imex_mode_active_tools"); diff --git a/tests/fff_print/test_multifilament.cpp b/tests/fff_print/test_multifilament.cpp index 7f8f5b5c6f..0656193a6d 100644 --- a/tests/fff_print/test_multifilament.cpp +++ b/tests/fff_print/test_multifilament.cpp @@ -715,6 +715,7 @@ TEST_CASE("Multi-extruder slice stays in bounds with a short max_layer_height", REQUIRE_FALSE(print.objects().front()->layers().empty()); } + // Shared IMEX printer geometry: 7 logical extruders across 4 physical heads. // physical_extruder_map is only honoured when its length matches the nozzle count // (PrintApply feeds effective_physical_extruder_map the nozzle_diameter size), so the @@ -779,6 +780,53 @@ TEST_CASE("Parallel-mode IMEX prints transition the printing head to its second- CHECK(gcode.find("M104 S240 T1") != std::string::npos); } +// IMEX supplements is_extruder_used for the secondary carriages a parallel mode drives. +// `primary` drives exactly one tool, so the supplement must not run: routing every region +// to filament 6 puts the initial tool on physical head 2, while the mode's only declared +// head is 0, which the unguarded supplement resolved back to filament slot 0. +TEST_CASE("Primary-mode IMEX prints mark only the filament slot they print with", + "[MultiFilament][IMEX]") +{ + DynamicPrintConfig config = multifilament_config(7); + imex_7x4_printer(config); + all_regions_on_filament(config, 6); // filament 6 => logical slot 5 => physical head 2 + config.set_deserialize_strict({ + { "imex_parallel_mode", "primary" }, + { "machine_start_gcode", + ";USED0:{if is_extruder_used[0]}1{else}0{endif}\n" + ";USED5:{if is_extruder_used[5]}1{else}0{endif}\n" }, + }); + + const std::string gcode = slice({ cube(20) }, config); + + CHECK(gcode.find(";USED5:1") != std::string::npos); + CHECK(gcode.find(";USED0:0") != std::string::npos); +} + +// 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. +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); + config.set_deserialize_strict({ + { "imex_parallel_mode", "copy" }, + { "machine_start_gcode", + ";USED0:{if is_extruder_used[0]}1{else}0{endif}\n" + ";USED4:{if is_extruder_used[4]}1{else}0{endif}\n" + ";USED5:{if is_extruder_used[5]}1{else}0{endif}\n" }, + }); + + 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); +} + // The two IMEX changes are coupled: get_imex_active_tools() now returns an empty roster in // primary mode, so if the temperature branch ever stopped excluding primary it would enter, // emit nothing, skip the standard path, and silently restore the bug the copy-mode case above From a5b2600b2cc12bb3b1409df2be7036ba22459268 Mon Sep 17 00:00:00 2001 From: Clifford Garwood Date: Sat, 22 Aug 2026 00:27:57 -0400 Subject: [PATCH 3/3] fix(imex): re-register the IMEX mode icon's bed raycaster after rebuilding it SceneRaycasterItem keeps the MeshRaycaster it was registered with as a raw pointer, while PickingModel::reset() destroys it through a unique_ptr. Rebuilding an icon therefore invalidates any registration still referring to it. refresh_imex_icon(), reached only from Plater::on_config_change when is_imex or the bed shape changes, rebuilt the IMEX mode icon without touching the SceneRaycaster. The stale entry survived, and the next picking pass dereferenced freed memory inside AABBMesh::intersect_ray. Swap that one registration in place, matching how calc_vertex_for_plate_name() handles the name-edit icon. Only the mode icon is registered for picking -- the warning badge beside it is a plain GLModel -- so a single id is affected and the blanket remove/re-register reload_scene() performs is not needed here. Crashes were delayed and looked unrelated to the config change, because bed raycasters are only tested when the camera looks down (SceneRaycaster::hit). The reported dump landed on File > New Project, whose render ran a picking pass with a registration that had gone stale earlier. Co-Authored-By: Claude Opus 5 (1M context) --- src/slic3r/GUI/PartPlate.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 8dece5360b..46215b2d87 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1025,6 +1025,8 @@ std::string PartPlate::build_imex_cache_key() const } // Reposition the IMEX mode icon without requiring a full set_shape() rebuild. +static void register_model_for_picking(GLCanvas3D &canvas, PickingModel &model, int id); + // Called when is_imex is toggled on a printer whose bed shape matches the current plate, // which would otherwise cause set_shape() to short-circuit before reaching icon calc. void PartPlate::refresh_imex_icon() @@ -1038,6 +1040,15 @@ void PartPlate::refresh_imex_icon() int imex_slot = dual_bbl ? 7 : 6; calc_vertex_for_icons(imex_slot, m_imex_mode_icon); calc_vertex_for_imex_warn_badge(imex_slot, m_imex_warn_icon); + + // calc_vertex_for_icons() destroyed the MeshRaycaster that SceneRaycaster still + // holds by raw pointer; swap the registration as calc_vertex_for_plate_name() does. + // Only the mode icon is picked -- the warn badge is a plain GLModel. + if (GLCanvas3D *canvas = m_plater->get_view3D_canvas3D()) { + canvas->remove_raycasters_for_picking(SceneRaycaster::EType::Bed, + picking_id_component(PLATE_IMEX_MODE_ID)); + register_model_for_picking(*canvas, m_imex_mode_icon, picking_id_component(PLATE_IMEX_MODE_ID)); + } } }