From c8db06b1d4d37ee08913640c1478cc43f0ec363c Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 12 Jul 2026 03:24:35 +0800 Subject: [PATCH] feat(engine): stitch per-object selector plans for sequential prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sequential (by-object) prints were incoherent with the per-layer filament selector (enable_filament_dynamic_map): the by-object branch published a static grouping while each per-object ToolOrdering independently ran the dynamic planner from an empty nozzle status and wrote its own map to the config (one write per object, last object wins). The exported toolchange sequences then disagreed with the published result that drives the per-layer maps, placeholders, and selector emission. Now the by-object branch, when the selector is enabled, plans each unique object once — threading the physical nozzle occupancy and the previous object's last filament into the next plan — stitches the per-object per-layer nozzle maps into one print-wide result (gap-filled by the new normalize_nozzle_map_per_layer so any layer index resolves a filament's nozzle consistently), publishes it, and writes the derived extruder map back once. The plans are cached on the Print and g-code export consumes the cache: the ToolOrdering seed changes the plan input (dontcare assignment, first-layer reorder), so a fresh export-time construction could re-plan differently from the published stitch. The per-object dynamic write-back is gated off for sequential prints. Every change is gated behind is_dynamic_group_reorder(); no profile sets the flag, so the static fleet's instruction stream is unchanged (20/20 pinned-slice byte gate identical, incl. the by-object repro sliced twice). Tests: normalize unit coverage (carry-forward, back-fill, ragged input), stitched-blocks selector detection, and an end-to-end by-object selector slice (apply -> process -> export) asserting the published stitched result, one cached plan per object, the config write-back, and a clean export. Suites green (libslic3r 48958/165, fff_print 633/60). --- src/libslic3r/GCode.cpp | 27 ++- src/libslic3r/GCode/ToolOrdering.cpp | 43 +++-- src/libslic3r/GCode/ToolOrdering.hpp | 23 +++ src/libslic3r/MultiNozzleUtils.cpp | 48 +++++ src/libslic3r/MultiNozzleUtils.hpp | 8 + src/libslic3r/Print.cpp | 141 ++++++++++----- src/libslic3r/Print.hpp | 19 +- .../test_toolordering_nozzle_group.cpp | 166 ++++++++++++++++++ 8 files changed, 412 insertions(+), 63 deletions(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index cdd567457d..858ccd670b 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -2954,6 +2954,12 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato first_non_support_filaments.resize(print.config().nozzle_diameter.size(), -1); first_filaments.resize(print.config().nozzle_diameter.size(), -1); float max_additional_fan = 0.f; + // Sequential selector prints consume the per-object plans cached by Print::process — they were + // planned with cross-object nozzle-status threading and match the published stitched result; a + // fresh construction here would re-plan from a different seed. Static sequential prints keep + // the fresh per-object construction (byte-identical output). + const auto &seq_dynamic_orderings = print.sequential_dynamic_orderings(); + const bool use_seq_dynamic_cache = print.is_dynamic_group_reorder() && !seq_dynamic_orderings.empty(); if (print.config().print_sequence == PrintSequence::ByObject) { // Order object instances for sequential print. print_object_instances_ordering = sort_object_instances_by_model_order(print); @@ -2963,9 +2969,13 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato first_has_extrude_print_object = print_object_instance_sequential_active; bool find_fist_non_support_filament = false; for (; print_object_instance_sequential_active != print_object_instances_ordering.end(); ++ print_object_instance_sequential_active) { - tool_ordering = ToolOrdering(*(*print_object_instance_sequential_active)->print_object, initial_extruder_id); - - tool_ordering.sort_and_build_data(*(*print_object_instance_sequential_active)->print_object,initial_extruder_id); + auto cached_ordering = use_seq_dynamic_cache ? seq_dynamic_orderings.find((*print_object_instance_sequential_active)->print_object) : seq_dynamic_orderings.end(); + if (cached_ordering != seq_dynamic_orderings.end()) { + tool_ordering = cached_ordering->second; + } else { + tool_ordering = ToolOrdering(*(*print_object_instance_sequential_active)->print_object, initial_extruder_id); + tool_ordering.sort_and_build_data(*(*print_object_instance_sequential_active)->print_object,initial_extruder_id); + } float temp_max_additional_fan = tool_ordering.cal_max_additional_fan(print.config()); if(temp_max_additional_fan > max_additional_fan ) max_additional_fan = temp_max_additional_fan; @@ -3568,8 +3578,15 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato for (; print_object_instance_sequential_active != print_object_instances_ordering.end(); ++ print_object_instance_sequential_active) { const PrintObject &object = *(*print_object_instance_sequential_active)->print_object; if (&object != prev_object || tool_ordering.first_extruder() != final_extruder_id) { - tool_ordering = ToolOrdering(object, final_extruder_id); - tool_ordering.sort_and_build_data(object, final_extruder_id); + auto cached_ordering = use_seq_dynamic_cache ? seq_dynamic_orderings.find(&object) : seq_dynamic_orderings.end(); + if (cached_ordering != seq_dynamic_orderings.end()) { + // Never re-plan a selector object mid-export: the cached plan is what the + // published stitched result was built from. + tool_ordering = cached_ordering->second; + } else { + tool_ordering = ToolOrdering(object, final_extruder_id); + tool_ordering.sort_and_build_data(object, final_extruder_id); + } unsigned int new_extruder_id = tool_ordering.first_extruder(); if (new_extruder_id == (unsigned int)-1) // Skip this object. diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index f9c0f31c3e..0faa7b29b9 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -1914,6 +1914,24 @@ static std::vector plan_filament_mapping_and_order_by_combo_ran return results; } +MultiNozzleUtils::LayeredNozzleGroupResult ToolOrdering::build_sequential_group_result( + Print* print, + std::vector> nozzle_map_per_layer, + const std::vector>& layer_filaments, + const std::vector>& layer_sequences, + const std::vector& used_filaments, + const std::vector>& physical_unprintables, + const std::vector>& geometric_unprintables, + const std::map>& unprintable_volumes) +{ + MultiNozzleUtils::normalize_nozzle_map_per_layer(nozzle_map_per_layer, layer_filaments); + auto context = build_filament_group_context(print, layer_filaments, physical_unprintables, geometric_unprintables, + unprintable_volumes, FilamentMapMode::fmmAutoForFlush, {}); + auto result = MultiNozzleUtils::LayeredNozzleGroupResult::create(nozzle_map_per_layer, context.nozzle_info.nozzle_list, + used_filaments, layer_sequences); + return result ? *result : MultiNozzleUtils::LayeredNozzleGroupResult(); +} + void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first_layer) { const PrintConfig* print_config = m_print_config_ptr; @@ -2031,6 +2049,12 @@ void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first // whole shipping fleet AND H2C static mode — the static branch below is the only one they take, so // their g-code is byte-identical. Only an H2C profile that enables the selector opens this branch. const bool dynamic_reorder = m_print && m_print->is_dynamic_group_reorder(); + // Orca: there is no is_sequential_print() helper, so the not-sequential check is mirrored with + // the same predicate the static by-object gate below uses. Sequential prints (with more than + // one object) publish and write back from the by-object branch in Print::process instead of + // from each per-object ordering. + const bool not_sequential = print_config->print_sequence != PrintSequence::ByObject || + (m_print && m_print->objects().size() == 1); if (dynamic_reorder) { // Build the grouping context, plan per-combo-range nozzle maps + filament orders, then wrap the @@ -2063,7 +2087,10 @@ void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first std::vector derived_maps = grouping_result.get_extruder_map(false); // 1-based if (!derived_maps.empty()) { filament_maps = derived_maps; - m_print->update_filament_maps_to_config(filament_maps); + // A sequential per-object plan must not write its own map: the objects' plans are + // stitched print-wide afterwards and written back once from there. + if (not_sequential) + m_print->update_filament_maps_to_config(filament_maps); } std::transform(filament_maps.begin(), filament_maps.end(), filament_maps.begin(), [](int value) { return value - 1; }); } @@ -2113,16 +2140,10 @@ void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first // placeholders are unchanged; H2C/A2L resolve to a nozzle-granular result (dynamic mode // resolves per-layer). GCode consumes this via Print::get_layered_nozzle_group_result(). m_nozzle_group_result = grouping_result; - { - // Orca: the ToolOrdering member is stored unconditionally, but the Print-level store is gated - // behind a not-sequential check. There is no is_sequential_print() here, so it is mirrored - // with the same predicate the branch above uses. Sequential prints publish their result - // from the by-object branch in Print::process instead. - const bool not_sequential = print_config->print_sequence != PrintSequence::ByObject || - (m_print && m_print->objects().size() == 1); - if (m_print != nullptr && not_sequential) - m_print->set_nozzle_group_result(std::make_shared(m_nozzle_group_result)); - } + // Orca: the ToolOrdering member is stored unconditionally, but the Print-level store is gated + // behind the not-sequential check hoisted above. + if (m_print != nullptr && not_sequential) + m_print->set_nozzle_group_result(std::make_shared(m_nozzle_group_result)); auto maps_without_group = filament_maps; for (auto& item : maps_without_group) diff --git a/src/libslic3r/GCode/ToolOrdering.hpp b/src/libslic3r/GCode/ToolOrdering.hpp index b67fea119a..c77b152fe9 100644 --- a/src/libslic3r/GCode/ToolOrdering.hpp +++ b/src/libslic3r/GCode/ToolOrdering.hpp @@ -246,6 +246,13 @@ public: // For single-nozzle printers this is one logical nozzle per extruder (nozzle id == extruder id). // Consumed by GCode (get_nozzle_id / get_first_nozzle_for_filament). const MultiNozzleUtils::LayeredNozzleGroupResult &get_layered_nozzle_group_result() const { return m_nozzle_group_result; } + + // Physical nozzle occupancy threading for the sequential (by-object) selector regroup: the + // setter seeds both the initial recorder (the state the per-layer plan starts from) and the + // running recorder (read back after sort_and_build_data via get_nozzle_status()), so each + // object's plan continues from the nozzle state the previous object ended with. + const MultiNozzleUtils::NozzleStatusRecorder &get_nozzle_status() const { return m_nozzle_status; } + void set_nozzle_status(const MultiNozzleUtils::NozzleStatusRecorder &status) { m_initial_nozzle_status = status; m_nozzle_status = status; } /* * called in single extruder mode, the value in map are all 0 * called in dual extruder mode, the value in map will be 0 or 1 @@ -257,6 +264,22 @@ public: // path; the per-layer engine supplies non-empty values. static MultiNozzleUtils::LayeredNozzleGroupResult get_recommended_filament_maps(const std::vector>& layer_filaments, const Print* print,const FilamentMapMode mode, const std::vector>& physical_unprintables, const std::vector>& geometric_unprintables, const std::map>& unprintable_volumes = {}, const std::unordered_map& nozzle_status = {}); + // Wrap stitched per-layer filament->nozzle maps from a sequential (by-object) selector regroup + // into one print-wide result. nozzle_map_per_layer / layer_filaments / layer_sequences are the + // per-object planned layers concatenated in print order; nozzle_map_per_layer is taken by value + // and normalized in place. The nozzle list is rebuilt from the print's grouping context. Returns + // an empty result when the wrap fails. Lives here (not in Print) to reach the file-local + // grouping-context builder. + static MultiNozzleUtils::LayeredNozzleGroupResult build_sequential_group_result( + Print* print, + std::vector> nozzle_map_per_layer, + const std::vector>& layer_filaments, + const std::vector>& layer_sequences, + const std::vector& used_filaments, + const std::vector>& physical_unprintables, + const std::vector>& geometric_unprintables, + const std::map>& unprintable_volumes); + // should be called after doing reorder FilamentChangeStats get_filament_change_stats(FilamentChangeMode mode); void cal_most_used_extruder(const PrintConfig &config); diff --git a/src/libslic3r/MultiNozzleUtils.cpp b/src/libslic3r/MultiNozzleUtils.cpp index e8ceadbbb7..d5ce550536 100644 --- a/src/libslic3r/MultiNozzleUtils.cpp +++ b/src/libslic3r/MultiNozzleUtils.cpp @@ -52,6 +52,54 @@ std::vector build_nozzle_list(double diameter, const std::vector> &layer_filament_nozzle_maps, + const std::vector> &layer_filaments) +{ + if (layer_filament_nozzle_maps.empty()) + return; + + const int total_layers = static_cast(layer_filament_nozzle_maps.size()); + int filament_count = 0; + for (const auto &layer_map : layer_filament_nozzle_maps) + filament_count = std::max(filament_count, static_cast(layer_map.size())); + + auto layer_uses_filament = [](const std::vector &filaments, int filament_id) { + return std::find(filaments.begin(), filaments.end(), static_cast(filament_id)) != filaments.end(); + }; + + std::vector last_used_nozzle(filament_count, -1); + std::unordered_map first_used_nozzle; + std::unordered_map first_used_layer; + + // Forward pass: layers that extrude a filament define its nozzle; layers that don't inherit + // the nozzle it last used (carry-forward), remembering the first-ever nozzle for the back-fill. + for (int layer_id = 0; layer_id < total_layers; ++layer_id) { + auto &layer_map = layer_filament_nozzle_maps[layer_id]; + const auto &used = layer_id < static_cast(layer_filaments.size()) ? layer_filaments[layer_id] : std::vector(); + + for (int filament_id = 0; filament_id < static_cast(layer_map.size()); ++filament_id) { + if (layer_uses_filament(used, filament_id)) { + last_used_nozzle[filament_id] = layer_map[filament_id]; + if (first_used_nozzle.count(filament_id) == 0) { + first_used_nozzle[filament_id] = layer_map[filament_id]; + first_used_layer[filament_id] = layer_id; + } + } else if (last_used_nozzle[filament_id] >= 0) { + layer_map[filament_id] = last_used_nozzle[filament_id]; + } + } + } + + // Back-fill pass: layers before a filament's first use inherit the first nozzle it ever uses. + for (int layer_id = 0; layer_id < total_layers; ++layer_id) { + auto &layer_map = layer_filament_nozzle_maps[layer_id]; + for (int filament_id = 0; filament_id < static_cast(layer_map.size()); ++filament_id) { + if (first_used_layer.count(filament_id) != 0 && layer_id < first_used_layer[filament_id]) + layer_map[filament_id] = first_used_nozzle[filament_id]; + } + } +} + // ==================== LayeredNozzleGroupResult ==================== static bool has_filament_mapped_to_multiple_nozzles(const std::vector> &layer_filament_nozzle_maps, const std::vector &used_filaments) diff --git a/src/libslic3r/MultiNozzleUtils.hpp b/src/libslic3r/MultiNozzleUtils.hpp index f9b96bd79a..5ae56e497d 100644 --- a/src/libslic3r/MultiNozzleUtils.hpp +++ b/src/libslic3r/MultiNozzleUtils.hpp @@ -269,6 +269,14 @@ FilamentChangeSimResult simulate_filament_change_time( bool calc_sliced_time = false); // ==================== tool functions ==================== +// Make each filament's per-layer nozzle assignment gap-free: layers where a filament is not +// extruded inherit the nozzle it last used (forward carry); layers before its first use inherit +// the first nozzle it ever uses (back-fill). Entries on layers where the filament is actually +// used stay untouched. Needed for stitched sequential maps, where consumers indexing with an +// object-local layer id must resolve the same nozzle as global-id consumers except across a +// genuine mid-print reassignment. +void normalize_nozzle_map_per_layer(std::vector>& layer_filament_nozzle_maps, + const std::vector>& layer_filaments); std::vector build_nozzle_list(std::vector info); std::vector build_nozzle_list(double diameter, const std::vector& filament_nozzle_map, const std::vector& filament_volume_map, const std::vector& filament_map); diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 54609f481b..7373623d7c 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -2488,6 +2488,9 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) std::vector::const_iterator print_object_instance_sequential_active; std::vector>> layers_to_print = GCode::collect_layers_to_print(*this); std::vector printExtruders; + // Cleared on every process so a print-sequence or selector-mode change can never leave + // stale object pointers behind; repopulated below only by the sequential selector path. + m_sequential_dynamic_orderings.clear(); if (this->config().print_sequence == PrintSequence::ByObject) { // Order object instances for sequential print. print_object_instances_ordering = sort_object_instances_by_model_order(*this); @@ -2509,53 +2512,104 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) auto physical_unprintables = this->get_physical_unprintable_filaments(used_filaments); auto geometric_unprintables = this->get_geometric_unprintable_filaments(); auto filament_unprintable_volumes = this->get_filament_unprintable_flow(used_filaments); - std::vectorfilament_maps = this->get_filament_maps(); - auto map_mode = get_filament_map_mode(); - // Grouping returns a nozzle-aware result; the 1-based extruder map for the by-object - // path is derived from it. It is computed in every static map mode (in manual modes it - // mirrors the user's assignment) and published print-wide: GCode's per-nozzle - // placeholder and config-index lookups read it via get_layered_nozzle_group_result(), - // and without it sequential exports on multi-nozzle printers see an empty nozzle table - // (e.g. nozzle_diameter_at_nozzle_id[]) and custom g-code fails to resolve. - auto grouping_result = ToolOrdering::get_recommended_filament_maps(all_filaments, this, map_mode, physical_unprintables, geometric_unprintables, filament_unprintable_volumes); - this->set_nozzle_group_result(std::make_shared(grouping_result)); - // Orca: the sequential write-back stays gated to auto modes. In manual modes the - // config maps already carry the user's assignment (the per-object ToolOrdering below - // consumes them directly), so a write-back would only re-store the pre-slice values; - // keeping the gate avoids churning the config on every sequential manual slice. - if (map_mode < FilamentMapMode::fmmManual) { - auto derived_maps = grouping_result.get_extruder_map(false); - if (!derived_maps.empty()) { - filament_maps = derived_maps; - // Write the maps back: used filaments adopt the engine's extruder/nozzle - // choice, unused ones keep their config assignment. - // Orca: the config maps are the merge base; fall back to a synthesized base - // when no producer sized them to the filament count (CLI runs until the - // per-filament synthesis lands there), where indexing per filament would - // run out of bounds. - std::vector base_filament_map = m_config.filament_map.values; - if (base_filament_map.size() != derived_maps.size()) - base_filament_map.assign(derived_maps.size(), 1); - std::vector base_volume_map = m_config.filament_volume_map.values; - if (base_volume_map.size() != derived_maps.size()) - base_volume_map.assign(derived_maps.size(), (int)nvtStandard); - update_filament_maps_to_config(FilamentGroupUtils::update_used_filament_values(base_filament_map, derived_maps, used_filaments), - FilamentGroupUtils::update_used_filament_values(base_volume_map, grouping_result.get_volume_map(), used_filaments), - grouping_result.get_nozzle_map()); + // Selector (per-layer regroup) prints skip the static grouping: their print-wide result + // is stitched from the per-object plans after the ordering loop below. + const bool dynamic_reorder = this->is_dynamic_group_reorder(); + if (!dynamic_reorder) { + std::vectorfilament_maps = this->get_filament_maps(); + auto map_mode = get_filament_map_mode(); + // Grouping returns a nozzle-aware result; the 1-based extruder map for the by-object + // path is derived from it. It is computed in every static map mode (in manual modes it + // mirrors the user's assignment) and published print-wide: GCode's per-nozzle + // placeholder and config-index lookups read it via get_layered_nozzle_group_result(), + // and without it sequential exports on multi-nozzle printers see an empty nozzle table + // (e.g. nozzle_diameter_at_nozzle_id[]) and custom g-code fails to resolve. + auto grouping_result = ToolOrdering::get_recommended_filament_maps(all_filaments, this, map_mode, physical_unprintables, geometric_unprintables, filament_unprintable_volumes); + this->set_nozzle_group_result(std::make_shared(grouping_result)); + // Orca: the sequential write-back stays gated to auto modes. In manual modes the + // config maps already carry the user's assignment (the per-object ToolOrdering below + // consumes them directly), so a write-back would only re-store the pre-slice values; + // keeping the gate avoids churning the config on every sequential manual slice. + if (map_mode < FilamentMapMode::fmmManual) { + auto derived_maps = grouping_result.get_extruder_map(false); + if (!derived_maps.empty()) { + filament_maps = derived_maps; + // Write the maps back: used filaments adopt the engine's extruder/nozzle + // choice, unused ones keep their config assignment. + // Orca: the config maps are the merge base; fall back to a synthesized base + // when no producer sized them to the filament count (CLI runs until the + // per-filament synthesis lands there), where indexing per filament would + // run out of bounds. + std::vector base_filament_map = m_config.filament_map.values; + if (base_filament_map.size() != derived_maps.size()) + base_filament_map.assign(derived_maps.size(), 1); + std::vector base_volume_map = m_config.filament_volume_map.values; + if (base_volume_map.size() != derived_maps.size()) + base_volume_map.assign(derived_maps.size(), (int)nvtStandard); + update_filament_maps_to_config(FilamentGroupUtils::update_used_filament_values(base_filament_map, derived_maps, used_filaments), + FilamentGroupUtils::update_used_filament_values(base_volume_map, grouping_result.get_volume_map(), used_filaments), + grouping_result.get_nozzle_map()); + } } + // check map valid both in auto and mannual mode + std::transform(filament_maps.begin(), filament_maps.end(), filament_maps.begin(), [](int value) {return value - 1; }); } - // check map valid both in auto and mannual mode - std::transform(filament_maps.begin(), filament_maps.end(), filament_maps.begin(), [](int value) {return value - 1; }); // print_object_instances_ordering = sort_object_instances_by_max_z(print); + const PrintObject *prev_planned_object = nullptr; + unsigned int seq_last_extruder = (unsigned int)-1; + MultiNozzleUtils::NozzleStatusRecorder nozzle_status; + std::vector> nozzle_map_per_layer; + std::vector> stitched_layer_filaments; print_object_instance_sequential_active = print_object_instances_ordering.begin(); for (; print_object_instance_sequential_active != print_object_instances_ordering.end(); ++print_object_instance_sequential_active) { - tool_ordering = ToolOrdering(*(*print_object_instance_sequential_active)->print_object, initial_extruder_id); - tool_ordering.sort_and_build_data(*(*print_object_instance_sequential_active)->print_object, initial_extruder_id); + const PrintObject *print_object = (*print_object_instance_sequential_active)->print_object; + if (dynamic_reorder) { + if (print_object != prev_planned_object) { + // Plan each unique object once, threading the physical nozzle occupancy and + // the previous object's last filament into the next plan; repeated instances + // of an object reuse the plan, mirroring the export loop's reuse. + ToolOrdering ordering(*print_object, seq_last_extruder); + ordering.set_nozzle_status(nozzle_status); + ordering.sort_and_build_data(*print_object, seq_last_extruder); + nozzle_status = ordering.get_nozzle_status(); + if (ordering.last_extruder() != static_cast(-1)) + seq_last_extruder = ordering.last_extruder(); + const auto &object_maps = ordering.get_layered_nozzle_group_result().get_layer_filament_nozzle_maps(); + nozzle_map_per_layer.insert(nozzle_map_per_layer.end(), object_maps.begin(), object_maps.end()); + // Orca: the stitch input comes from the same orderings that produced the + // per-layer maps — the collection loop above is per-instance and seeded -1, + // so its layers are misaligned with these plans. layer_tools() of a sorted + // ordering already carries the planned per-layer filament order. + for (const auto &layer_tool : ordering.layer_tools()) + stitched_layer_filaments.emplace_back(layer_tool.extruders); + m_sequential_dynamic_orderings[print_object] = std::move(ordering); + prev_planned_object = print_object; + } + tool_ordering = m_sequential_dynamic_orderings.at(print_object); + } else { + tool_ordering = ToolOrdering(*print_object, initial_extruder_id); + tool_ordering.sort_and_build_data(*print_object, initial_extruder_id); + } if ((initial_extruder_id = tool_ordering.first_extruder()) != static_cast(-1)) { append(printExtruders, tool_ordering.tools_for_layer(layers_to_print.front().first).extruders); } } + if (dynamic_reorder && m_objects.size() > 1) { + // Stitch the per-object plans into one print-wide selector result. A single-object + // sequential print publishes (and writes back) from its own ordering instead: the + // per-object publish gate treats one object as not sequential. + auto stitched = ToolOrdering::build_sequential_group_result(this, std::move(nozzle_map_per_layer), stitched_layer_filaments, + stitched_layer_filaments, used_filaments, physical_unprintables, + geometric_unprintables, filament_unprintable_volumes); + this->set_nozzle_group_result(std::make_shared(stitched)); + // Orca: the dynamic (per-layer) result carries no single volume/nozzle map, so only + // the extruder map is written back (matching the by-layer selector branch); the full + // per-nozzle config write-back is a follow-up behind the dev flag. + std::vector derived_maps = stitched.get_extruder_map(false); // 1-based + if (!derived_maps.empty()) + update_filament_maps_to_config(derived_maps); + } } else { tool_ordering = this->tool_ordering(); @@ -3475,12 +3529,13 @@ std::shared_ptr Print::get_layered_n } // Dynamic (per-layer selector) regroup predicate. -// Orca: enable_filament_dynamic_map is a develop-only config key registered in the ConfigDef but NOT -// a static PrintConfig member, so it is read from the applied full config; it is absent for every -// shipping printer/profile -> nullptr -> false, which keeps the static grouping path (identical -// output) the only one the current fleet takes. There is no mixed-colour-filament guard (mixed-colour -// filaments are not supported). The remaining gates (auto-for-flush mode, multi-extruder machine) -// read the static PrintConfig members. +// Orca: enable_filament_dynamic_map is a project flag registered in the ConfigDef but NOT a static +// PrintConfig member, so it is read from the applied full config. No profile sets it; it is turned +// on per project by the "smart filament assign" checkbox (shown when a filament track switch is +// ready), so absent-key -> nullptr -> false keeps the static grouping path (identical output) for +// everything else. There is no mixed-colour-filament guard (mixed-colour filaments are not +// supported). The remaining gates (auto-for-flush mode, multi-extruder machine) read the static +// PrintConfig members. bool Print::is_dynamic_group_reorder() const { const auto *opt = m_full_print_config.option("enable_filament_dynamic_map"); diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index a64c2bc7f5..00460dbcd8 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -1028,13 +1028,20 @@ public: std::shared_ptr get_nozzle_group_result() const { return m_nozzle_group_result; } std::shared_ptr get_layered_nozzle_group_result() const; - // True only when the printer opts into the per-layer filament selector + // True only when the project opts into the per-layer filament selector // (enable_filament_dynamic_map) in auto-for-flush mode on a multi-extruder machine. Gates the - // dynamic (per-layer) regroup branch in ToolOrdering::reorder_extruders_for_minimum_flush_volume. - // Closed for every current profile, so the static grouping path (byte-identical output) is the - // only one taken by the shipping fleet. + // dynamic (per-layer) regroup branch in ToolOrdering::reorder_extruders_for_minimum_flush_volume, + // the sequential (by-object) plan stitching in Print::process, and GCode's use of the cached + // sequential plans. No profile sets the flag, so the static grouping path (byte-identical + // output) is the only one taken unless the user enables the selector. bool is_dynamic_group_reorder() const; + // Per-object tool orderings planned by the sequential (by-object) selector regroup with + // cross-object nozzle-status threading. GCode export must consume these exact plans: a fresh + // per-object construction would re-plan from a different seed and diverge from the published + // stitched result. Empty on the static path. + const std::map& sequential_dynamic_orderings() const { return m_sequential_dynamic_orderings; } + const std::vector>& get_extruder_filament_info() const { return m_extruder_filament_info; } void set_extruder_filament_info(const std::vector>& filament_info) { m_extruder_filament_info = filament_info; } @@ -1271,6 +1278,10 @@ private: // Logical (extruder, nozzle) grouping result, set by ToolOrdering during reorder. std::shared_ptr m_nozzle_group_result; + // Sequential (by-object) selector plans, keyed by object; see sequential_dynamic_orderings(). + // Rebuilt (or cleared) on every process(). + std::map m_sequential_dynamic_orderings; + // Used to cache filament parameter information FilamentIndexMap m_filament_index_map; // Used to cache printer and process parameter information diff --git a/tests/libslic3r/test_toolordering_nozzle_group.cpp b/tests/libslic3r/test_toolordering_nozzle_group.cpp index dcc2ae9521..2caab39c7c 100644 --- a/tests/libslic3r/test_toolordering_nozzle_group.cpp +++ b/tests/libslic3r/test_toolordering_nozzle_group.cpp @@ -13,6 +13,8 @@ #include #include +#include + // H2C/A2L multi-nozzle filament grouping core. // // These tests pin the behaviour of the grouping result type @@ -496,3 +498,167 @@ TEST_CASE("Re-applying an unchanged config after slicing keeps the result valid" REQUIRE(status != PrintBase::APPLY_STATUS_INVALIDATED); REQUIRE(print.is_step_done(psSlicingFinished)); } + +TEST_CASE("normalize_nozzle_map_per_layer makes per-filament assignments gap-free", "[MultiNozzle][H2C][Dynamic]") +{ + SECTION("gaps inherit the last used nozzle, entries on used layers stay untouched") { + // Filament 1 extrudes on layers 0 (nozzle 1) and 3 (nozzle 2); the planner leaves stale + // entries on the layers in between. + std::vector> maps = { + {0, 1}, + {0, -1}, // filament 1 idle + {0, -1}, // filament 1 idle + {0, 2}, + }; + std::vector> filaments = {{0, 1}, {0}, {0}, {0, 1}}; + + normalize_nozzle_map_per_layer(maps, filaments); + + REQUIRE(maps[0] == std::vector({0, 1})); + REQUIRE(maps[1] == std::vector({0, 1})); // carried forward + REQUIRE(maps[2] == std::vector({0, 1})); // carried forward + REQUIRE(maps[3] == std::vector({0, 2})); // used layer untouched + } + + SECTION("layers before a filament's first use inherit its first nozzle") { + std::vector> maps = { + {0, -1}, + {0, -1}, + {0, 3}, // filament 1 first extrudes here + }; + std::vector> filaments = {{0}, {0}, {0, 1}}; + + normalize_nozzle_map_per_layer(maps, filaments); + + REQUIRE(maps[0] == std::vector({0, 3})); // back-filled + REQUIRE(maps[1] == std::vector({0, 3})); // back-filled + REQUIRE(maps[2] == std::vector({0, 3})); + } + + SECTION("empty and ragged inputs are safe no-ops") { + std::vector> empty_maps; + std::vector> no_filaments; + REQUIRE_NOTHROW(normalize_nozzle_map_per_layer(empty_maps, no_filaments)); + REQUIRE(empty_maps.empty()); + + // Rows of different widths and a filament list shorter than the map list. + std::vector> ragged = {{0}, {0, 1, 2}}; + std::vector> short_filaments = {{0}}; + REQUIRE_NOTHROW(normalize_nozzle_map_per_layer(ragged, short_filaments)); + REQUIRE(ragged[0] == std::vector({0})); + } + + SECTION("a single layer is left unchanged") { + std::vector> maps = {{2, 1, 0}}; + std::vector> filaments = {{0, 1, 2}}; + normalize_nozzle_map_per_layer(maps, filaments); + REQUIRE(maps[0] == std::vector({2, 1, 0})); + } +} + +TEST_CASE("Stitched sequential blocks resolve per-layer after normalization", "[MultiNozzle][H2C][Dynamic]") +{ + // Shape of the sequential (by-object) stitch: two per-object plan blocks concatenated on one + // global layer axis, where the second object's plan moves filament 1 to another physical + // nozzle. After normalization the 4-arg create() must detect the migration (selector result) + // and resolve stable ids inside each object's layer range. + std::vector nozzle_list; + for (int g = 0; g < 3; ++g) { + NozzleInfo n; + n.diameter = "0.4"; + n.volume_type = nvtStandard; + n.extruder_id = (g == 0) ? 0 : 1; + n.group_id = g; + nozzle_list.push_back(n); + } + + // Object A (layers 0-1): filament 1 on nozzle 1, filament 0 idle until layer 1. + // Object B (layers 2-3): filament 1 moved to nozzle 2. + std::vector> stitched_maps = { + {-1, 1}, + {0, 1}, + {0, 2}, + {0, 2}, + }; + std::vector> stitched_filaments = {{1}, {0, 1}, {0, 1}, {0, 1}}; + std::vector used_filaments = {0, 1}; + + normalize_nozzle_map_per_layer(stitched_maps, stitched_filaments); + REQUIRE(stitched_maps[0] == std::vector({0, 1})); // filament 0 back-filled to its first nozzle + + auto group_opt = LayeredNozzleGroupResult::create(stitched_maps, nozzle_list, used_filaments, stitched_filaments); + REQUIRE(group_opt.has_value()); + auto &group = *group_opt; + + // A filament on two physical nozzles across the objects => selector result. + REQUIRE(group.is_support_dynamic_nozzle_map()); + REQUIRE(group.get_nozzle_id(1, 0) == 1); + REQUIRE(group.get_nozzle_id(1, 1) == 1); + REQUIRE(group.get_nozzle_id(1, 2) == 2); // second object's range + REQUIRE(group.get_nozzle_id(1, 3) == 2); + // The default (out-of-range) map is the first layer's normalized row. + REQUIRE(group.get_nozzle_id(0, 999) == 0); + REQUIRE(group.get_nozzle_id(1, 999) == 1); +} + +TEST_CASE("Sequential selector prints publish a stitched result and cache the plans", "[Print][H2C][Dynamic]") +{ + // By-object + smart filament assign: the by-object branch of Print::process must plan each + // object with nozzle-status threading, cache the plans for the g-code export, stitch them + // into the published print-wide result, and write the derived extruder map back once + // (per-object orderings must not churn the config). + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.option("nozzle_diameter", true)->values = {0.4, 0.4}; + config.option("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"}; + config.option("extruder_type", true)->values = {etDirectDrive, etDirectDrive}; + config.option("nozzle_volume_type", true)->values = {nvtStandard, nvtStandard}; + config.option("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow", + "Direct Drive Standard,Direct Drive High Flow"}; + config.option("print_extruder_id", true)->values = {1, 1, 2, 2}; + config.option("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow", + "Direct Drive Standard", "Direct Drive High Flow"}; + config.option("filament_diameter", true)->values = {1.75, 1.75}; + config.option("filament_colour", true)->values = {"#FF0000", "#00FF00"}; + config.option("filament_map", true)->values = {1, 2}; + config.option("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard}; + config.set_key_value("enable_filament_dynamic_map", new ConfigOptionBool(true)); + config.option>("filament_map_mode", true)->value = FilamentMapMode::fmmAutoForFlush; + config.option>("print_sequence", true)->value = PrintSequence::ByObject; + // Export validates flush_volumes_matrix as filaments^2 values per head. + config.option("flush_volumes_matrix", true)->values = std::vector(8, 140.); + config.option("flush_multiplier", true)->values = {1., 1.}; + + Model model; + ModelObject *object_a = model.add_object("cube_a", "", make_cube(20, 20, 20)); + ModelInstance *instance_a = object_a->add_instance(); + instance_a->set_offset(Vec3d(70., 100., 0.)); + ModelObject *object_b = model.add_object("cube_b", "", make_cube(20, 20, 20)); + object_b->config.set_key_value("extruder", new ConfigOptionInt(2)); + ModelInstance *instance_b = object_b->add_instance(); + instance_b->set_offset(Vec3d(150., 100., 0.)); + // The sequential instance ordering keys on arrange_order, which validate() assigns before + // process() in the real pipeline (instances tying at 0 get dropped from the ordering); + // initialize it here since the test drives process() directly. + instance_a->arrange_order = 1; + instance_b->arrange_order = 2; + + Print print; + print.apply(model, config); + REQUIRE(print.objects().size() == 2); + print.process(); + REQUIRE(print.is_step_done(psSlicingFinished)); + + auto result = print.get_layered_nozzle_group_result(); + REQUIRE(result != nullptr); + // One cached plan per unique object, and a stitched layer axis spanning both objects. + REQUIRE(print.sequential_dynamic_orderings().size() == 2); + REQUIRE(result->get_layer_count() > 0); + // The write-back mirrors the stitched result's extruder map. + REQUIRE(print.config().filament_map.values == result->get_extruder_map(false)); + + // Export must consume the cached plans and produce g-code without throwing. + boost::filesystem::path gcode_path = boost::filesystem::temp_directory_path() / "orca_seq_dynamic_publish_test.gcode"; + REQUIRE_NOTHROW(print.export_gcode(gcode_path.string(), nullptr, nullptr)); + REQUIRE(boost::filesystem::exists(gcode_path)); + boost::filesystem::remove(gcode_path); +}