From bc016af1c959a61e470d740c5f8d7df8c28aef23 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 27 Jul 2026 00:25:59 +0800 Subject: [PATCH] Fix post-slice self-invalidation on custom multi-extruder printers --- src/libslic3r/Config.hpp | 21 ++- src/libslic3r/Print.cpp | 6 +- src/libslic3r/PrintApply.cpp | 16 ++- src/libslic3r/PrintConfig.cpp | 40 ++++++ .../test_config_variant_expansion.cpp | 121 +++++++++++++++++- .../test_toolordering_nozzle_group.cpp | 46 +++++++ 6 files changed, 240 insertions(+), 10 deletions(-) diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index da36321b6f..6f4117d249 100644 --- a/src/libslic3r/Config.hpp +++ b/src/libslic3r/Config.hpp @@ -2,6 +2,7 @@ #define slic3r_Config_hpp_ #include +#include #include #include #include @@ -780,10 +781,14 @@ public: this->values[i] = rhs_vec->values[i]; modified = true; } else { - if ((i < default_index.size()) && (default_index[i] < default_value.size())) + // Orca: a negative slot (failed variant lookup) must not silently collapse the + // whole array to the first slot's value — the int-vs-size_t comparison used to + // promote -1 past the bounds check. Keep the slot's own value (get_at-style + // clamp) when no valid index is available. + if ((i < default_index.size()) && (default_index[i] >= 0) && (size_t(default_index[i]) < default_value.size())) this->values[i] = default_value[default_index[i]]; else - this->values[i] = default_value[0]; + this->values[i] = default_value[std::min(i, default_value.size() - 1)]; } } return modified; @@ -2106,6 +2111,11 @@ public: throw ConfigurationError("ConfigOptionEnumGeneric: Assigning an incompatible type"); // rhs could be of the following type: ConfigOptionEnumGeneric or ConfigOptionEnum this->value = rhs->getInt(); + // Orca: options embedded in a StaticPrintConfig are constructed without a keys_map; + // adopt the source's so a later serialize() can emit names. + if (this->keys_map == nullptr) + if (auto rhs_generic = dynamic_cast(rhs)) + this->keys_map = rhs_generic->keys_map; } std::string serialize() const override @@ -2162,7 +2172,12 @@ public: if (rhs->type() != this->type()) throw ConfigurationError("ConfigOptionEnumGeneric: Assigning an incompatible type"); // rhs could be of the following type: ConfigOptionEnumsGeneric - this->values = dynamic_cast(rhs)->values; + auto rhs_enums = dynamic_cast(rhs); + this->values = rhs_enums->values; + // Orca: options embedded in a StaticPrintConfig are constructed without a keys_map; + // adopt the source's so a later serialize() emits names instead of empty tokens. + if (this->keys_map == nullptr) + this->keys_map = rhs_enums->keys_map; } std::string serialize() const override diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 3f06cd5b37..ecc2f39c88 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -3409,7 +3409,11 @@ void Print::update_filament_maps_to_config(std::vector f_maps, std::vector< } else if ((extruder_volume_type_count > extruder_count) && (m_config.filament_volume_map.values.size() > index)) nozzle_volume_type = (NozzleVolumeType)(m_config.filament_volume_map.values[index]); - m_config.filament_map_2.values[index] = m_ori_full_print_config.get_index_for_extruder(f_maps[index], "print_extruder_id", extruder_type, nozzle_volume_type, "print_extruder_variant"); + // Orca: when the process variant columns cannot be matched (degenerate + // print_extruder_id), key the override by plain extruder index like the seeding + // above instead of poisoning the map with -1. + int slot_index = m_ori_full_print_config.get_index_for_extruder(f_maps[index], "print_extruder_id", extruder_type, nozzle_volume_type, "print_extruder_variant"); + m_config.filament_map_2.values[index] = slot_index >= 0 ? slot_index : f_maps[index] - 1; } m_full_print_config = m_ori_full_print_config; diff --git a/src/libslic3r/PrintApply.cpp b/src/libslic3r/PrintApply.cpp index 0956cfe154..e2e9bc737d 100644 --- a/src/libslic3r/PrintApply.cpp +++ b/src/libslic3r/PrintApply.cpp @@ -1355,7 +1355,11 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ if ((extruder_volume_type_count > extruder_count) && opt_filament_volume_maps && opt_filament_volume_maps->values.size() == filament_maps.size()) nozzle_volume_type = (NozzleVolumeType)(opt_filament_volume_maps->values[index]); - m_config.filament_map_2.values[index] = new_full_config.get_index_for_extruder(filament_maps[index], "print_extruder_id", extruder_type, nozzle_volume_type, "print_extruder_variant"); + // Orca: when the process variant columns cannot be matched (degenerate + // print_extruder_id), key the override by plain extruder index like the seeding + // above instead of poisoning the map with -1. + int slot_index = new_full_config.get_index_for_extruder(filament_maps[index], "print_extruder_id", extruder_type, nozzle_volume_type, "print_extruder_variant"); + m_config.filament_map_2.values[index] = slot_index >= 0 ? slot_index : filament_maps[index] - 1; } // Do not use the ApplyStatus as we will use the max function when updating apply_status. @@ -1411,6 +1415,16 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ num_extruders_changed = true; } } + else if (! print_diff.empty()) { + // Orca: m_config can diverge from an unchanged full config (e.g. the in-slice retract + // override recompute writing different values than the apply-time computation). The + // invalidation above already fired for print_diff, so repair m_config here as well; + // otherwise the divergence is never corrected and every subsequent apply of the same + // config invalidates the result again, forever. + m_placeholder_parser.apply_config(filament_overrides); + m_config.apply_only(new_full_config, print_diff, true); + m_config.apply(filament_overrides); + } ModelObjectStatusDB model_object_status_db; diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 8129ff741e..f0d82a9264 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -10495,6 +10495,44 @@ int DynamicPrintConfig::get_extruder_nozzle_volume_count(int extruder_count, std return count; } +// Orca: BBL system profiles ship full-width print_extruder_id/print_extruder_variant columns, but +// custom multi-extruder printers only ever get the machine-scope columns synthesized for them (see +// extend_extruder_variant); the process scope keeps the length-1 defaults, both in presets and in +// 3mf project configs. Expanding with that degenerate map makes every per-extruder lookup fail, and +// because both keys are themselves in print_options_with_variant, the expansion then latches a +// full-width-but-wrong [1,1,...] map that also defeats the generated_extruder_id fallback in +// get_index_for_extruder. Synthesize the process columns from the printer's extruder_variant_list +// (same token walk as extend_extruder_variant) before expanding. +static void ensure_process_variant_columns(DynamicPrintConfig &config, const DynamicPrintConfig &printer_config) +{ + auto id_opt = dynamic_cast(config.option("print_extruder_id")); + auto variant_opt = dynamic_cast(config.option("print_extruder_variant")); + auto list_opt = dynamic_cast(printer_config.option("extruder_variant_list")); + if (!id_opt || !variant_opt || !list_opt) + return; + if (id_opt->values.size() != 1 || variant_opt->values.size() != 1) + return; + + std::vector ids; + std::vector variants; + for (int i = 0; i < int(list_opt->values.size()); ++i) { + std::vector tokens; + boost::split(tokens, list_opt->get_at(i), boost::is_any_of(","), boost::token_compress_on); + for (std::string &token : tokens) { + boost::trim(token); + if (token.empty()) + continue; + ids.push_back(i + 1); + variants.push_back(token); + } + } + // A single column is the legitimate single-extruder layout, not a degenerate one. + if (ids.size() <= 1) + return; + id_opt->values = std::move(ids); + variant_opt->values = std::move(variants); +} + std::vector DynamicPrintConfig::update_values_to_printer_extruders(DynamicPrintConfig& printer_config, int extruder_count, int extruder_nozzle_volume_count, std::vector>& nv_types, std::set& key_set, std::string id_name, std::string variant_name, unsigned int stride, unsigned int extruder_id, NozzleVolumeType filament_nvt) { @@ -10536,6 +10574,8 @@ std::vector DynamicPrintConfig::update_values_to_printer_extruders(DynamicP variant_count = 1; } else { + if (id_name == "print_extruder_id") + ensure_process_variant_columns(*this, printer_config); // Orca: emit the slots first, then size variant_count from what was actually // emitted. extruder_nozzle_volume_count only equals the emitted total when every // extruder carries per-type stats; an extruder with an empty stats entry combined diff --git a/tests/libslic3r/test_config_variant_expansion.cpp b/tests/libslic3r/test_config_variant_expansion.cpp index 59f311eae5..5cff6ecee5 100644 --- a/tests/libslic3r/test_config_variant_expansion.cpp +++ b/tests/libslic3r/test_config_variant_expansion.cpp @@ -43,18 +43,33 @@ TEST_CASE("apply_override fills nil entries from the 0-based default index", "[C REQUIRE(resolved.values == std::vector({30., 42.})); } - SECTION("an index past the machine slots falls back to the first slot") { + SECTION("an index past the machine slots keeps the slot's own value") { std::vector slot_index{5, 0}; ConfigOptionFloats resolved(machine); REQUIRE(resolved.apply_override(&filament, slot_index)); REQUIRE(resolved.values == std::vector({10., 42.})); } - SECTION("a negative index (unresolved slot) falls back to the first slot") { - std::vector slot_index{-1, 0}; + SECTION("a negative index (unresolved slot) keeps the slot's own value") { + ConfigOptionFloatsNullable all_nil; + all_nil.values = {ConfigOptionFloatsNullable::nil_value(), ConfigOptionFloatsNullable::nil_value(), + ConfigOptionFloatsNullable::nil_value()}; + std::vector slot_index{2, -1, 0}; ConfigOptionFloats resolved(machine); - REQUIRE(resolved.apply_override(&filament, slot_index)); - REQUIRE(resolved.values == std::vector({10., 42.})); + REQUIRE(!resolved.apply_override(&all_nil, slot_index)); + REQUIRE(resolved.values == std::vector({30., 20., 10.})); + } + + SECTION("all-nil overrides keyed by unresolved slots leave the machine values intact") { + // The failed-lookup map a degenerate print_extruder_id used to produce; the negative + // slots must not collapse the machine array to its first value. + ConfigOptionFloats per_extruder({100., 70., 70., 70., 100.}); + ConfigOptionFloatsNullable all_nil; + all_nil.values.assign(5, ConfigOptionFloatsNullable::nil_value()); + std::vector slot_index{0, -1, -1, -1, 0}; + ConfigOptionFloats resolved(per_extruder); + REQUIRE(!resolved.apply_override(&all_nil, slot_index)); + REQUIRE(resolved.values == std::vector({100., 70., 70., 70., 100.})); } } @@ -238,6 +253,102 @@ TEST_CASE("update_values_to_printer_extruders expands one slot per (extruder x v } } +TEST_CASE("update_values_to_printer_extruders synthesizes degenerate process variant columns", "[Config]") +{ + // Non-BBL process presets and 3mf project configs keep the length-1 defaults for + // print_extruder_id/print_extruder_variant; only BBL system presets ship full-width columns. + auto add_degenerate_print_columns = [](DynamicPrintConfig &config) { + config.option("print_extruder_id", true)->values = {1}; + config.option("print_extruder_variant", true)->values = {"Direct Drive Standard"}; + config.option("outer_wall_speed", true)->values = {30.}; + }; + + SECTION("a single-column pair on a multi-extruder machine expands to one column per extruder") { + DynamicPrintConfig config; + 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 Standard"}; + add_degenerate_print_columns(config); + + std::vector> nozzle_volume_types; + int extruder_count = 2; + int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types); + + std::vector variant_index = config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types, + print_options_with_variant, "print_extruder_id", "print_extruder_variant"); + + REQUIRE(variant_index == std::vector({0, 1})); + REQUIRE(config.option("print_extruder_id")->values == std::vector({1, 2})); + REQUIRE(config.option("print_extruder_variant")->values == + std::vector({"Direct Drive Standard", "Direct Drive Standard"})); + // width-1 data arrays replicate their only column into every slot + REQUIRE(config.option("outer_wall_speed")->values == std::vector({30., 30.})); + } + + SECTION("a multi-variant list synthesizes one column per (extruder x variant)") { + DynamicPrintConfig config = make_hybrid_printer_config(); + add_degenerate_print_columns(config); + + std::vector> nozzle_volume_types; + int extruder_count = 2; + int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types); + REQUIRE(count == 3); + + std::vector variant_index = config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types, + print_options_with_variant, "print_extruder_id", "print_extruder_variant"); + + // same slot resolution as the explicit BBL-style 4-column layout + REQUIRE(variant_index == std::vector({0, 2, 3})); + REQUIRE(config.option("print_extruder_id")->values == std::vector({1, 2, 2})); + REQUIRE(config.option("print_extruder_variant")->values == + std::vector({"Direct Drive Standard", "Direct Drive Standard", "Direct Drive High Flow"})); + REQUIRE(config.option("outer_wall_speed")->values == std::vector({30., 30., 30.})); + } + + SECTION("a single-extruder single-column layout is not treated as degenerate") { + DynamicPrintConfig config; + config.option("extruder_type", true)->values = {etDirectDrive}; + config.option("nozzle_volume_type", true)->values = {nvtStandard}; + config.option("extruder_variant_list", true)->values = {"Direct Drive Standard"}; + add_degenerate_print_columns(config); + + std::vector> nozzle_volume_types; + int extruder_count = 1; + int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types); + + config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types, + print_options_with_variant, "print_extruder_id", "print_extruder_variant"); + + REQUIRE(config.option("print_extruder_id")->values == std::vector({1})); + REQUIRE(config.option("outer_wall_speed")->values == std::vector({30.})); + } + + SECTION("a second expansion leaves the synthesized layout unchanged") { + DynamicPrintConfig config; + 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 Standard"}; + add_degenerate_print_columns(config); + + std::vector> nozzle_volume_types; + int extruder_count = 2; + int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types); + + config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types, + print_options_with_variant, "print_extruder_id", "print_extruder_variant"); + DynamicPrintConfig once = config; + config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types, + print_options_with_variant, "print_extruder_id", "print_extruder_variant"); + + REQUIRE(config.option("print_extruder_id")->values == + once.option("print_extruder_id")->values); + REQUIRE(config.option("print_extruder_variant")->values == + once.option("print_extruder_variant")->values); + REQUIRE(config.option("outer_wall_speed")->values == + once.option("outer_wall_speed")->values); + } +} + TEST_CASE("update_values_to_printer_extruders_for_multiple_filaments resolves per-filament slots", "[Config]") { auto make_filament_arrays = [](DynamicPrintConfig &config) { diff --git a/tests/libslic3r/test_toolordering_nozzle_group.cpp b/tests/libslic3r/test_toolordering_nozzle_group.cpp index ae6aac4dbe..5eee164eb0 100644 --- a/tests/libslic3r/test_toolordering_nozzle_group.cpp +++ b/tests/libslic3r/test_toolordering_nozzle_group.cpp @@ -500,6 +500,52 @@ TEST_CASE("Re-applying an unchanged config after slicing keeps the result valid" REQUIRE(print.is_step_done(psSlicingFinished)); } +TEST_CASE("A degenerate process variant map on a custom multi-extruder printer slices to a stable result", "[Print][Regression]") +{ + // Non-BBL multi-extruder printers get machine-scope variant columns synthesized on preset + // load (extend_extruder_variant), but nothing ships process-scope print_extruder_id / + // print_extruder_variant: presets and 3mf project configs carry the length-1 defaults. The + // apply-time expansion must synthesize the process columns from extruder_variant_list; + // otherwise the failed per-extruder lookups collapse the per-extruder retract overrides + // during slicing and the post-slice re-apply invalidates every fresh result, forever. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.option("nozzle_diameter", true)->values = {0.4, 0.4, 0.4, 0.4, 0.4}; + config.set_num_extruders(5); + // per-extruder machine values that a first-slot collapse would destroy + config.option("retract_before_wipe", true)->values = {100., 70., 70., 70., 100.}; + config.option("z_hop_types", true)->values = {zhtSlope, zhtNormal, zhtNormal, zhtNormal, zhtSlope}; + // filament presets carry the nullable override twins (all-nil = "no override"); they are what + // routes the machine values through apply_override in the in-slice override recompute + config.option("filament_retract_before_wipe", true)->values = + std::vector(5, ConfigOptionPercentsNullable::nil_value()); + config.option("filament_z_hop_types", true)->values = + std::vector(5, ConfigOptionEnumsGenericNullable::nil_value()); + config.option("filament_diameter", true)->values = std::vector(5, 1.75); + config.option("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#00FFFF"}; + config.option("filament_map", true)->values = {1, 2, 3, 4, 1}; + + Model model; + model.add_object("cube", "", make_cube(20, 20, 20))->add_instance()->set_offset(Vec3d(100., 100., 0.)); + + Print print; + print.apply(model, config); + print.process(); + REQUIRE(print.is_step_done(psSlicingFinished)); + + // BackgroundSlicingProcess reads the engine-computed maps back into the plate config after + // slicing; the next apply overlays that written-back state. + config.option("filament_map", true)->values = print.get_filament_maps(); + config.option("filament_volume_map", true)->values = print.get_filament_volume_maps(); + config.option("filament_nozzle_map", true)->values = print.get_filament_nozzle_maps(); + + auto status = print.apply(model, config); + REQUIRE(status == PrintBase::APPLY_STATUS_UNCHANGED); + REQUIRE(print.is_step_done(psSlicingFinished)); + // the per-extruder machine values must survive the in-slice override recompute + REQUIRE(print.config().retract_before_wipe.values == std::vector({100., 70., 70., 70., 100.})); + REQUIRE(print.config().z_hop_types.values == std::vector({zhtSlope, zhtNormal, zhtNormal, zhtNormal, zhtSlope})); +} + 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") {