diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index da5696840b..dab0dd4de3 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -4759,6 +4759,98 @@ static void apply_mixed_config_relocations(DynamicPrintConfig& } } +// Relocate the per-slot cells of the receiver's OWN mixed-filament definitions (the ones that +// pre-existed in project_config) onto fresh tail slots, clearing each vacated source cell so an +// incoming published real filament can claim it. Unlike apply_mixed_config_relocations - which +// moves the incoming file's config and leaves sources alone - a displaced receiver mix must not +// keep its mixed flag in the physical region: the source slot becomes a physical slot, so its +// mixed flag and definition are reset, while its swatch colour and mapping travel with the +// definition to the tail slot. Reads come from a frozen snapshot so an earlier move's +// destination never overwrites a later move's still-unread source (sources sit in the physical +// region and destinations past it, so they cannot overlap, but the snapshot keeps the helper +// safe for any future reordering). +static void apply_receiver_mix_relocations(DynamicPrintConfig& config, + std::vector>& ams_multi_color_filment, + const std::vector>& moves) +{ + if (moves.empty()) + return; + + auto move_bools = [&](const char* key, bool clear_source) { + ConfigOption* opt = config.optptr(key); + if (opt == nullptr) + return; + auto* live = static_cast(opt); + std::unique_ptr snapshot(opt->clone()); + const auto* frozen = static_cast(snapshot.get()); + for (const auto [from, to] : moves) { + const bool cell = from < frozen->values.size() ? frozen->values[from] : false; + if (live->values.size() <= to) + live->values.resize(to + 1, false); + live->values[to] = cell; + if (clear_source && from < live->values.size()) + live->values[from] = false; + } + }; + auto move_strings = [&](const char* key, bool clear_source) { + ConfigOption* opt = config.optptr(key); + if (opt == nullptr) + return; + auto* live = static_cast(opt); + std::unique_ptr snapshot(opt->clone()); + const auto* frozen = static_cast(snapshot.get()); + for (const auto [from, to] : moves) { + const std::string cell = from < frozen->values.size() ? frozen->values[from] : std::string(); + if (live->values.size() <= to) + live->values.resize(to + 1, std::string{}); + live->values[to] = cell; + if (clear_source && from < live->values.size()) + live->values[from] = std::string{}; + } + }; + auto move_ints = [&](const char* key) { + ConfigOption* opt = config.optptr(key); + if (opt == nullptr) + return; + auto* live = static_cast(opt); + std::unique_ptr snapshot(opt->clone()); + const auto* frozen = static_cast(snapshot.get()); + for (const auto [from, to] : moves) { + const int cell = from < frozen->values.size() ? frozen->values[from] : 0; + if (live->values.size() <= to) + live->values.resize(to + 1, 0); + live->values[to] = cell; + } + }; + + // Mixed-definition cells: move to the tail and clear the source - the vacated physical slot + // no longer holds a mix. + move_bools("filament_is_mixed", true); + move_strings("filament_mixed_components", true); + move_strings("filament_mixed_sublayer_ratios", true); + move_bools("filament_mixed_gradient", true); + move_strings("filament_mixed_gradient_range", true); + move_strings("filament_mixed_gradient_curve", true); + move_bools("filament_mixed_gradient_per_part", true); + // Swatch colour and mapping travel with the definition; the source colour is left for the + // incoming real's publish_color (or the slot's resolved preset) to fill in. + move_strings("filament_colour", false); + move_strings("filament_multi_colour", false); + move_strings("filament_colour_type", false); + move_ints("filament_map"); + move_ints("filament_nozzle_map"); + move_ints("filament_volume_map"); + { + const std::vector> frozen = ams_multi_color_filment; + for (const auto [from, to] : moves) { + const std::vector cell = from < frozen.size() ? frozen[from] : std::vector(); + if (ams_multi_color_filment.size() <= to) + ams_multi_color_filment.resize(to + 1, std::vector{}); + ams_multi_color_filment[to] = cell; + } + } +} + //convert the old filament preset to new one after split static void convert_filament_preset_name(std::string& machine_name, std::string& filament_name) @@ -5287,7 +5379,24 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool // counter preserving author order (dest = max(authored, next_free)). Appends past // the extruder limit are dropped and reported. Physical entries past capacity join // the same counter as mixed_placeholder empties the GUI flags for assignment. - size_t next_free_slot = this->filament_presets.size(); + // + // The finished project keeps the physical-first invariant (see the sidebar's + // add_custom_filament: mixed slots always sit at the tail, physical slots packed + // first). That invariant must survive an import, so every receiver mixed slot that + // would end up inside (or ahead of) the incoming physical region is displaced to a + // fresh tail slot, and the tail allocator starts at the physical boundary rather + // than the receiver's slot count - otherwise a relocated mix can collide with a + // keep-placed new real slot. + size_t physical_boundary = this->num_physical_filaments(); + for (const PublishedMaterialEntry& entry : published_config->material_keys) + // Mirror the payload-real keep-place decision below: a real keeps its authored + // slot when that slot already exists on the receiver, or lies within the + // printer's physical capacity. Both end up as physical slots at index + // entry.slot, so they bound the physical region even past the capacity. + if (entry.slot >= 0 && !is_mixed_definition(entry) && + (size_t(entry.slot) < this->filament_presets.size() || size_t(entry.slot) < physical_capacity)) + physical_boundary = std::max(physical_boundary, size_t(entry.slot) + 1); + size_t next_free_slot = std::max(physical_boundary, this->filament_presets.size()); bool any_mixed_relocated = false; // All authored-slot -> destination moves decided by this pass, applied to the // incoming config in one batched snapshot step below (an earlier move's @@ -5295,6 +5404,33 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool // onto consecutive slots, so incremental in-place shifts would overwrite a // definition that has not been moved yet). std::vector> mixed_moves; + // Receiver-mix relocations land in project_config, not the incoming config, so they + // get their own move list, applied below after the arrays grow. The swept set lets + // the payload loop below treat a displaced receiver mix as the physical slot it will + // become (a payload mix like-for-like overriding such a slot must itself relocate). + std::vector> receiver_mix_moves; + std::set swept_mix_slots; + for (size_t slot = 0; slot < this->filament_presets.size(); ++slot) { + if (!this->is_mixed_filament(slot)) + continue; + // Slot 0 is the receiver's base filament and is never a virtual mix; the + // sidebar's physical-first layout guarantees it, so never displace it. + if (slot == 0) + continue; + if (slot >= physical_boundary) + continue; // already lives in the tail region + const size_t dest = next_free_slot++; + swept_mix_slots.insert(slot); + receiver_mix_moves.emplace_back(slot, dest); + any_mixed_relocated = true; + // A payload mix authored at the same slot (a mix inside the physical region) is + // processed after this sweep and overrides its own destination below. + published_config->mixed_slot_relocations.insert_or_assign(int(slot), int(dest)); + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + " -> slot " + + std::to_string(dest) + ": mixed filament"); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF relocated receiver mixed filament slot " << slot + << " -> " << dest << " (physical-first rebalance)"; + } for (auto entry_it = published_config->material_keys.begin(); entry_it != published_config->material_keys.end();) { PublishedMaterialEntry& entry = *entry_it; if (entry.slot < 0) { @@ -5310,7 +5446,7 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool // above the printer's capacity (pre-existing state is never shrunk). bool keep_place = false; if (is_payload_mix) - keep_place = this->is_mixed_filament(size_t(entry.slot)); + keep_place = this->is_mixed_filament(size_t(entry.slot)) && swept_mix_slots.count(size_t(entry.slot)) == 0; else keep_place = size_t(entry.slot) < this->filament_presets.size() || size_t(entry.slot) < physical_capacity; @@ -5357,7 +5493,7 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool entry.slot = dest_slot; any_mixed_relocated = true; mixed_moves.emplace_back(size_t(authored_slot), size_t(entry.slot)); - published_config->mixed_slot_relocations.emplace(authored_slot, entry.slot); + published_config->mixed_slot_relocations.insert_or_assign(authored_slot, entry.slot); published_config->material_replacements.emplace_back("slot " + std::to_string(authored_slot) + " -> slot " + std::to_string(entry.slot) + ": mixed filament"); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF relocated mixed filament slot " << authored_slot @@ -5374,7 +5510,7 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool if (dest_slot != authored_slot) { entry.slot = dest_slot; mixed_moves.emplace_back(size_t(authored_slot), size_t(dest_slot)); - published_config->mixed_slot_relocations.emplace(authored_slot, dest_slot); + published_config->mixed_slot_relocations.insert_or_assign(authored_slot, dest_slot); } published_config->material_replacements.emplace_back( (dest_slot != authored_slot ? @@ -5725,6 +5861,11 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool // slots wrote to it; that compound case is not chased.) const bool edited_survives_load = this->filament_presets.empty() || this->filament_presets.front() == this->filaments.get_edited_preset().name; + // Displaced receiver mixes (see the physical-first rebalance above): move their + // per-slot cells onto the grown tail slots and reset the vacated physical slots, + // so the incoming real filaments can claim them. Runs before mixed_final_slots is + // built, so the rebalanced layout is what the mix validation sees. + apply_receiver_mix_relocations(this->project_config, this->ams_multi_color_filment, receiver_mix_moves); // Final layout for mix-definition validation: every slot that will hold a // mixed definition once this load completes - the receiver's own virtual // slots, each published mixed entry's final (possibly relocated) slot, and diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 9526a64b02..f7971cd1d2 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -2980,6 +2980,215 @@ TEST_CASE("Published 3MF relocates a mixed filament instead of overwriting a phy } } +// A receiver that already owns a MIXED filament must keep the physical-first invariant after a +// published-3MF import: when incoming physical filaments would land on (or ahead of) the +// receiver's mixed slot, that mix is displaced to a fresh tail slot instead of being left +// interleaved with them (the R,M,R bug). +TEST_CASE("Published 3MF relocates the receiver's mixed filament past the incoming physical slots", "[Preset][Bundle][Published]") +{ + // Build the receiver's tool-changer with three slots, the third being the receiver's own + // mixed filament. A SEMM (single_extruder_multi_material) receiver sizes its slot list by + // hand, so a lower slot count than the printer's nozzle count is preserved on load - a + // non-SEMM tool-changer would top the preset list up to the nozzle count and shift the + // expected sizes (the rebalance logic under test is the same either way). + auto make_receiver = [](PresetBundle &bundle, const std::string &components, const std::string &ratios) { + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(3, "#123456"); + bundle.printers.get_edited_preset().config.opt("single_extruder_multi_material", true)->value = true; + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[2] = components; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = ratios; + bundle.project_config.opt("filament_colour")->values[2] = "#800080"; + bundle.project_config.opt("filament_multi_colour")->values[2] = "#800080"; + }; + auto make_real_entry = [](int slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.publish_color = true; + entry.color = color; + return entry; + }; + // A four-physical author project with no mixed slots (colour publish only), as in the + // reported Ferrari reference file. + auto make_config_4_real = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#000000", "#FFFFFF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + return config; + }; + + // [R, R, M] + four colour-only physical slots at authored 0..3 -> [R, R, R, R, M]. + { + PresetBundle bundle; + make_receiver(bundle, "1,2", "0.5,0.5"); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#000000"), + make_real_entry(2, "#FFFFFF"), make_real_entry(3, "#FFFF00") }; + DynamicPrintConfig config = make_config_4_real(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grows by one extra virtual slot; the mix lands at the tail. + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + // The definition travelled with its swatch colour; the vacated slot 2 became physical. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[4] == "1,2"); + CHECK(components[2].empty()); + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 5); + CHECK(colour[4] == "#800080"); + CHECK(colour[2] == "#FFFFFF"); + CHECK(pub.mixed_slot_relocations.at(2) == 4); + bool relocated_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("slot 2 -> slot 4") != std::string::npos && + message.find("mixed filament") != std::string::npos) + relocated_reported = true; + CHECK(relocated_reported); + CHECK(pub.skipped_keys.empty()); + } + + // [R, R, M] plus a payload mix authored at slot 3: the receiver mix (displaced to slot 3) sits + // ahead of the appended payload mix (slot 4), preserving physical-first tail ordering. + { + PresetBundle bundle; + make_receiver(bundle, "1,2", "0.5,0.5"); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#000000", "#0000FF", "#800080" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + // Authored slot 3 is a payload mixed definition blending slots 1 and 3. + config.opt("filament_is_mixed")->values = { 0, 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "", "1,3" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "", "0.6,0.4" }; + config.opt("filament_mixed_gradient")->values = { 0, 0, 0, 1 }; + config.opt("filament_mixed_gradient_range")->values = { "", "", "", "0.9,0.1" }; + config.opt("filament_mixed_gradient_curve")->values = { "", "", "", "0,0.1|1,0.9" }; + config.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 0, 1 }; + + PublishedMaterialEntry mix; + mix.slot = 3; + mix.filament_type = "PLA"; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#000000"), + make_real_entry(2, "#0000FF"), mix }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + CHECK_FALSE(is_mixed[2]); + CHECK(is_mixed[3]); // receiver's mix, displaced to slot 3 first + CHECK(is_mixed[4]); // payload's mix, appended after + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[3] == "1,2"); + CHECK(components[4] == "1,3"); + REQUIRE(pub.mixed_slot_relocations.size() == 2); + CHECK(pub.mixed_slot_relocations.at(2) == 3); + CHECK(pub.mixed_slot_relocations.at(3) == 4); + CHECK(pub.skipped_keys.empty()); + } +} + +// Multiple receiver mixed slots interleaved with multiple incoming physical slots all rebalance +// onto consecutive tail slots in index order (no cascade/overlap). +TEST_CASE("Published 3MF rebalances several receiver mixed slots past the physical region", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(3, "#123456"); + bundle.printers.get_edited_preset().config.opt("single_extruder_multi_material", true)->value = true; + // Receiver: slot 1 and slot 2 are mixed. + bundle.project_config.opt("filament_is_mixed")->values[1] = 1; + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[1] = "1,2"; + bundle.project_config.opt("filament_mixed_components")->values[2] = "1,3"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[1] = "0.5,0.5"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = "0.4,0.6"; + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#00AA00", "#0000FF" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99" }; + + auto make_real_entry = [](int slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.publish_color = true; + entry.color = color; + return entry; + }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#00AA00"), make_real_entry(2, "#0000FF") }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + for (size_t i = 0; i < 3; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[3]); + CHECK(is_mixed[4]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[3] == "1,2"); + CHECK(components[4] == "1,3"); + REQUIRE(pub.mixed_slot_relocations.size() == 2); + CHECK(pub.mixed_slot_relocations.at(1) == 3); + CHECK(pub.mixed_slot_relocations.at(2) == 4); + CHECK(pub.skipped_keys.empty()); +} + // The receiver's printer gates how many PHYSICAL filament slots a published 3MF may add: a // non-SEMM tool-changer feeds filament N from nozzle N, so a published slot past the nozzle // count cannot become a physical filament. It becomes an empty mixed-filament placeholder