From 06517e623f0bcd61518dfe9409d77eac2b73ada2 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Mon, 31 Aug 2026 10:46:23 +0800 Subject: [PATCH] Fixes issue where user imports a 3MF file where filament slots exceeds the maximum number of slots that user has on its printer --- src/libslic3r/PresetBundle.cpp | 183 ++++++++--- src/libslic3r/PublishSettings.hpp | 7 + .../libslic3r/test_preset_bundle_loading.cpp | 296 ++++++++++++++++++ 3 files changed, 446 insertions(+), 40 deletions(-) diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 3219dec6d8..0e82046ce9 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -5407,6 +5407,11 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, // preset no other slot references wins on equal scores; with no replacement // available the receiver's material is kept and the keys are reported as skipped; // - colour: applied to the slot regardless of the type gate. + // - capacity: on a non-SEMM receiver whose printer has fewer nozzles than the + // authored slot needs, the entry becomes an empty mixed-filament placeholder + // appended at the tail (the GUI flags it; the user assigns components from their + // own filaments); on a single-physical-slot receiver it is dropped and reported + // instead, since an empty mix could never be edited there. // Applied partial values land on the collection's edited layer when the slot references // it and that layer survives the load (visible as a modification, revertible, the user's // unsaved edits preserved), otherwise on the stored preset in place. @@ -5417,11 +5422,38 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, // Grow the receiver's slots only as far as the highest published slot (never // shrink, never pull filler materials for unpublished slots). bool has_published_entries = false; - size_t grow_target = 0; + // Physical filament capacity of the receiver's printer: a non-SEMM tool-changer + // feeds filament N from nozzle N, so the nozzle count is the hard limit; a SEMM + // printer (single_extruder_multi_material) sizes its slot list by hand, so only + // the global slot limit applies (same condition as GUI_App::load_current_presets). + // Published entries that would need a NEW physical slot past this capacity are + // appended as empty mixed-filament placeholders instead of growing the list. + size_t physical_capacity = size_t(EnforcerBlockerType::ExtruderMax); + { + const Preset& receiver_printer = this->printers.get_edited_preset(); + if (receiver_printer.printer_technology() == ptFFF && + !receiver_printer.config.opt_bool("single_extruder_multi_material")) { + if (const auto* nozzle_diameter = receiver_printer.config.option("nozzle_diameter"); + nozzle_diameter != nullptr && !nozzle_diameter->values.empty()) + physical_capacity = nozzle_diameter->values.size(); + } + } + const std::set& mixed_definitions = publish_mixed_keys(); + auto is_mixed_definition = [&mixed_definitions](const PublishedMaterialEntry& entry) { + return std::any_of(entry.keys.begin(), entry.keys.end(), [&](const std::string& key) { + return mixed_definitions.count(publish_base_key(key)) != 0; + }); + }; + size_t grow_target = 0; for (const PublishedMaterialEntry& entry : published_config->material_keys) { has_published_entries = true; - if (entry.slot >= 0) - grow_target = std::max(grow_target, size_t(entry.slot) + 1); + if (entry.slot < 0) + continue; + // A physical entry past the capacity becomes a tail placeholder below; its + // growth is covered by the append counter, not the positional target. + if (!is_mixed_definition(entry) && size_t(entry.slot) >= physical_capacity) + continue; + grow_target = std::max(grow_target, size_t(entry.slot) + 1); } // Mixed-filament definitions live in project-level virtual slots, so applying one // positionally onto a receiver slot that holds a real, physical filament would @@ -5439,13 +5471,12 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, // from the new index. No existing slot changes meaning. // - destinations are also capped: appends past the extruder limit are dropped // and reported instead of being forced onto a physical filament. - const std::set& mixed_definitions = publish_mixed_keys(); - auto is_mixed_definition = [&mixed_definitions](const PublishedMaterialEntry& entry) { - return std::any_of(entry.keys.begin(), entry.keys.end(), [&](const std::string& key) { - return mixed_definitions.count(publish_base_key(key)) != 0; - }); - }; - size_t next_free_slot = this->filament_presets.size(); + // Physical entries past the printer's capacity join the same append counter + // (dest = next_free, packed consecutively at the tail - never max(authored, + // next_free), which would grow filler physical slots past the capacity) and are + // flagged mixed_placeholder: they become empty mixed-filament placeholders the + // GUI flags for the user to assign components to. + size_t next_free_slot = 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 @@ -5455,42 +5486,95 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, std::vector> mixed_moves; for (auto entry_it = published_config->material_keys.begin(); entry_it != published_config->material_keys.end();) { PublishedMaterialEntry& entry = *entry_it; - if (entry.slot < 0 || !is_mixed_definition(entry) || - // Like-for-like override of a virtual receiver slot (bounds-checked). - this->is_mixed_filament(size_t(entry.slot))) { + if (entry.slot < 0) { ++entry_it; continue; } + const bool is_payload_mix = is_mixed_definition(entry); + // Does this entry need a tail slot at all? A payload mixed definition does, + // except when it like-for-like overrides a receiver slot that is already a + // mix (bounds-checked). A physical entry only becomes a placeholder when it + // would need a NEW physical slot: an authored position the receiver's list + // already covers is applied positionally as before, even when that list sits + // 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)); + else + keep_place = size_t(entry.slot) < this->filament_presets.size() || + size_t(entry.slot) < physical_capacity; + if (keep_place) { + ++entry_it; + continue; + } + const std::string material_label = !entry.filament_id.empty() ? entry.filament_id : + !entry.publish_type_value.empty() ? entry.publish_type_value : + entry.filament_type; if (std::max(size_t(entry.slot), next_free_slot) >= size_t(EnforcerBlockerType::ExtruderMax)) { // No free virtual slot left: report instead of destroying a real filament. - const std::string material_label = !entry.filament_id.empty() ? entry.filament_id : - !entry.publish_type_value.empty() ? entry.publish_type_value : - entry.filament_type; // The local skipped_keys is published wholesale at the end of the pass; // writing published_config->skipped_keys here would be clobbered by it. - skipped_keys.emplace_back("material:" + material_label + - " (mixed filament definition: filament slot limit reached)"); - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF mixed filament from slot " << entry.slot - << " could not be placed: all " << next_free_slot << " slots exhausted"; + skipped_keys.emplace_back("material:" + material_label + (is_payload_mix ? + " (mixed filament definition: filament slot limit reached)" : + " (filament slot limit reached)")); + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF " << (is_payload_mix ? "mixed filament definition" : "material") + << " from slot " << entry.slot << " could not be placed: all " << next_free_slot + << " slots exhausted"; + entry_it = published_config->material_keys.erase(entry_it); + continue; + } + if (!is_payload_mix && physical_capacity < 2) { + // A single physical slot can never host a mixed-filament editor (the + // sidebar's mixed section needs two physical filaments to mix), so a + // placeholder would be invisible and unfixable: drop the entry and + // report it like any other unappliable input. + skipped_keys.emplace_back("material:" + material_label + " (printer supports only " + + std::to_string(physical_capacity) + " filament)"); + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF material from slot " << entry.slot + << " dropped: printer supports only " << physical_capacity << " filament"; entry_it = published_config->material_keys.erase(entry_it); continue; } const int authored_slot = entry.slot; - const int dest_slot = int(std::max(size_t(authored_slot), next_free_slot)); + const int dest_slot = is_payload_mix ? int(std::max(size_t(authored_slot), next_free_slot)) : int(next_free_slot); next_free_slot = size_t(dest_slot) + 1; - if (dest_slot == authored_slot) - // Uncontended fresh tail slot: the definition is already readable there. - ++entry_it; - else { - entry.slot = dest_slot; + if (is_payload_mix) { + if (dest_slot == authored_slot) + // Uncontended fresh tail slot: the definition is already readable there. + ++entry_it; + else { + 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->material_replacements.emplace_back("slot " + std::to_string(authored_slot) + " -> slot " + + std::to_string(entry.slot) + + ": mixed filament relocated (would have replaced a physical filament)"); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF relocated mixed filament slot " << authored_slot + << " -> " << entry.slot; + ++entry_it; + } + } else { + // Surplus published material beyond the printer's capacity: become an + // empty mixed-filament placeholder at the next free tail slot. The flag + // routes the entry to the placeholder finalize below; the slot's + // definition stays empty until the user assigns components. + entry.mixed_placeholder = true; 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->material_replacements.emplace_back("slot " + std::to_string(authored_slot) + " -> slot " + - std::to_string(entry.slot) + - ": mixed filament relocated (would have replaced a physical filament)"); - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF relocated mixed filament slot " << authored_slot - << " -> " << entry.slot; + 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->material_replacements.emplace_back( + (dest_slot != authored_slot ? + "slot " + std::to_string(authored_slot) + " -> slot " + std::to_string(dest_slot) : + "slot " + std::to_string(dest_slot)) + + ": " + material_label + " placed as an unassigned mixed filament (printer supports only " + + std::to_string(physical_capacity) + " filaments)"); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF material from slot " << authored_slot + << " placed as an unassigned mixed filament at slot " << dest_slot + << " (printer supports only " << physical_capacity << " filaments)"; ++entry_it; } } @@ -5501,9 +5585,10 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, // Defensive cap: growth never exceeds the file's own filament count. The // receiver's current slot count is a floor: neither the preset list nor the // project vectors are ever shrunk, even when the file carries fewer filaments - // than the receiver has slots. Relocated mixed entries legitimately land past - // the file's own slot count (virtual slots consume no nozzle or tray), so - // their destinations lift the ceiling explicitly. + // than the receiver has slots. Relocated mixed entries and capacity + // placeholders legitimately land past the file's own slot count (virtual + // slots consume no nozzle or tray), so their append destinations lift the + // ceiling explicitly. const size_t target_slots = std::max({this->filament_presets.size(), std::min(grow_target, num_filaments), any_mixed_relocated ? next_free_slot : size_t(0)}); // Slots carrying published content, steering the initial preset selection of @@ -5884,12 +5969,14 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, this->filament_presets.front() == this->filaments.get_edited_preset().name; // Final layout for mix-definition validation: every slot that will hold a // mixed definition once this load completes - the receiver's own virtual - // slots plus each published mixed entry's final (possibly relocated) slot. - // Mix components are 1-based slot numbers, so a component is valid only - // when the slot it names exists and does not itself hold a mixed filament. + // slots, each published mixed entry's final (possibly relocated) slot, and + // each capacity placeholder (they become mixes in the finalize below, before + // this loop's is_mixed_filament scan would see them). Mix components are + // 1-based slot numbers, so a component is valid only when the slot it names + // exists and does not itself hold a mixed filament. std::set mixed_final_slots; for (const PublishedMaterialEntry& mix_entry : published_config->material_keys) - if (mix_entry.slot >= 0 && is_mixed_definition(mix_entry)) + if (mix_entry.slot >= 0 && (is_mixed_definition(mix_entry) || mix_entry.mixed_placeholder)) mixed_final_slots.insert(mix_entry.slot); for (size_t i = 0; i < this->filament_presets.size(); ++i) if (this->is_mixed_filament(i)) @@ -5903,6 +5990,22 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, if (entry.slot < 0 || size_t(entry.slot) >= this->filament_presets.size()) continue; // out of range: nothing to do for this slot const size_t slot = size_t(entry.slot); + + // Surplus published material beyond the printer's capacity: finalize the + // tail placeholder - mark the slot virtual with an intentionally empty + // definition. The GUI flags the empty mix (check_mixed_filament_integrity) + // and blocks slicing until the user assigns components from their own + // filaments. The entry's keys are deliberately not applied and no preset + // is detached: the placeholder carries no material of its own. The colour + // was already seeded into the project arrays by the growth pass above. + if (entry.mixed_placeholder) { + if (ConfigOptionBools* is_mixed_opt = this->project_config.opt("filament_is_mixed"); + is_mixed_opt != nullptr && slot < is_mixed_opt->values.size()) + is_mixed_opt->values[slot] = true; + material_applied = true; + continue; + } + // Resolve the stored preset itself (real=true), never the edited snapshot: // find_preset would return &m_edited_preset for the selected slot. The // overlay target below decides between the edited layer and this preset. diff --git a/src/libslic3r/PublishSettings.hpp b/src/libslic3r/PublishSettings.hpp index 038a6eca11..d7f2c13c5f 100644 --- a/src/libslic3r/PublishSettings.hpp +++ b/src/libslic3r/PublishSettings.hpp @@ -80,6 +80,13 @@ struct PublishedMaterialEntry { // Required filament colour, applied on load regardless of the type match. bool publish_color{false}; std::string color; + // Import-side only, never serialized: the entry's authored slot sits past the receiver + // printer's physical filament capacity, so instead of growing a physical slot the entry + // is appended as an empty mixed-filament placeholder (virtual tail slot; the GUI flags + // it and the user assigns components from their own filaments). The flag also keeps the + // entry out of the payload mixed-definition validation and the value-apply passes, + // which only make sense for a slot that carries a real material. + bool mixed_placeholder{false}; }; // "PLA High Speed" -> "PLA" (strip a space modifier); dash types like "PA-CF" are kept intact. diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 356645d06c..069d40b8e5 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -2932,6 +2932,302 @@ TEST_CASE("Published 3MF relocates a mixed filament instead of overwriting a phy } } +// 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 +// instead - a virtual tail slot the GUI flags (broken mix) and the user fills with components +// from their own filaments. SEMM receivers keep the ungated behaviour. +TEST_CASE("Published 3MF turns a surplus slot past the printer's filament capacity into an empty mixed placeholder", "[Preset][Bundle][Published]") +{ + // An author project with physical slots, no mixed ones. + auto make_file_config = [](size_t num_author_slots) { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + std::vector diameters(num_author_slots, 1.75); + std::vector self_index; + std::vector variants; + for (size_t i = 0; i < num_author_slots; ++i) { + self_index.push_back(int(i + 1)); + variants.emplace_back("Direct Drive Standard"); + } + config.opt("filament_diameter")->values = diameters; + config.opt("filament_self_index")->values = self_index; + config.opt("filament_extruder_variant")->values = variants; + config.opt("filament_colour")->values.resize(num_author_slots, "#808080"); + config.opt("filament_type")->values.assign(num_author_slots, "PLA"); + config.opt("filament_vendor")->values.assign(num_author_slots, "Generic"); + config.opt("filament_ids")->values.resize(num_author_slots); + return config; + }; + // A non-SEMM receiver with nozzles running copies of one preset. + auto make_receiver = [](PresetBundle &bundle, size_t nozzles, size_t slots) { + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets.assign(slots, "My PLA"); + bundle.set_num_filaments(slots, "#123456"); + auto &printer_config = bundle.printers.get_edited_preset().config; + printer_config.opt("single_extruder_multi_material", true)->value = false; + printer_config.opt("nozzle_diameter", true)->values.assign(nozzles, 0.4); + }; + auto make_physical_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; + }; + + // The reported case: an author publishes with a filament on slot 5; the receiver is a + // 4-filament tool-changer. The receiver keeps its four physical slots and the surplus + // material lands as an empty mixed placeholder at the tail. + { + PresetBundle bundle; + make_receiver(bundle, 4, 4); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(4, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(5); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grew by exactly one virtual slot, not a fifth physical one. + 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 placeholder carries no definition: the GUI's integrity check flags it and + // blocks slicing until the user assigns components. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[4].empty()); + // The four physical slots kept their meaning and colours. + CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), + bundle.project_config.opt("filament_colour")->values.begin())); + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[3] == "My PLA"); + // The published colour seeds the placeholder's swatch. + CHECK(bundle.project_config.opt("filament_colour")->values[4] == "#ABCDEF"); + // The conversion is surfaced through the post-import notice. + bool placeholder_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("unassigned mixed filament") != std::string::npos) + placeholder_reported = true; + CHECK(placeholder_reported); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.mixed_slot_relocations.empty()); + } + + // Two surplus slots (5 and 6) become two consecutive empty placeholders. + { + PresetBundle bundle; + make_receiver(bundle, 4, 4); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(4, "#ABCDEF"), make_physical_entry(5, "#F0F0F0") }; + DynamicPrintConfig config = make_file_config(6); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 6); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 6); + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + CHECK(is_mixed[5]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 6); + CHECK(components[4].empty()); + CHECK(components[5].empty()); + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 6); + CHECK(colour[4] == "#ABCDEF"); + CHECK(colour[5] == "#F0F0F0"); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.mixed_slot_relocations.empty()); + } + + // A surplus slot past both the receiver's list and the capacity packs onto the next free + // tail slot (never max(authored, next_free), which would grow filler physical slots past + // the capacity), and the relocation is recorded for the model-reference remapping. + { + PresetBundle bundle; + make_receiver(bundle, 2, 2); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(3, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(4); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 3); + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + CHECK(is_mixed[2]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2].empty()); + REQUIRE(pub.mixed_slot_relocations.size() == 1); + CHECK(pub.mixed_slot_relocations.at(3) == 2); + bool relocation_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("slot 3 -> slot 2") != std::string::npos && + message.find("unassigned mixed filament") != std::string::npos) + relocation_reported = true; + CHECK(relocation_reported); + CHECK(pub.skipped_keys.empty()); + } + + // A Full Publish entry past the capacity becomes a placeholder too: no standalone + // detached copy is created for a material that got no physical slot. + { + PresetBundle bundle; + make_receiver(bundle, 4, 4); + + PublishedMaterialEntry entry = make_physical_entry(4, "#ABCDEF"); + entry.full = true; + entry.preset_name = "Generic PLA @System"; + entry.filament_id = "GFL99"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(5); + 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(is_mixed[4]); + // No detached copy under the stripped name or its uniquified forms. + CHECK(bundle.filaments.find_preset("Generic PLA", false, true) == nullptr); + CHECK(bundle.filaments.find_preset("Generic PLA (Published)", false, true) == nullptr); + CHECK(pub.skipped_keys.empty()); + } + + // A SEMM receiver (the default printer preset) sizes its slot list by hand: the published + // slot past the nozzle count still grows physically, as before the capacity gate. + { + 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", "My PLA" }; + bundle.set_num_filaments(4, "#123456"); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(4, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(5); + 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 < 5; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(bundle.project_config.opt("filament_colour")->values[4] == "#ABCDEF"); + CHECK(pub.skipped_keys.empty()); + } + + // A pre-existing oversized slot list is never shrunk: a published entry pointing at one + // of its slots is applied positionally even though the list exceeds the nozzle count. + { + PresetBundle bundle; + make_receiver(bundle, 4, 5); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(4, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(5); + 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 < 5; ++i) + CHECK_FALSE(is_mixed[i]); + // The published colour reached the addressed slot's (shared) preset in place. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == + std::vector{ "#ABCDEF" }); + CHECK(bundle.project_config.opt("filament_colour")->values[4] == "#ABCDEF"); + CHECK(pub.skipped_keys.empty()); + } + + // On a single-physical-slot receiver an empty mix could never be edited (the sidebar's + // mixed section needs two physical filaments), so the surplus entry is dropped and + // reported instead of becoming an unfixable placeholder. + { + PresetBundle bundle; + make_receiver(bundle, 1, 1); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(1, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(2); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets.size() == 1); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 1); + CHECK_FALSE(is_mixed[0]); + REQUIRE(pub.skipped_keys.size() == 1); + CHECK(pub.skipped_keys.front().find("printer supports only 1") != std::string::npos); + } + + // A payload mixed definition is exempt from the capacity gate: mixes are virtual slots + // that consume no nozzle, so a published mix past the nozzle count still lands. + { + PresetBundle bundle; + make_receiver(bundle, 4, 4); + + PublishedMaterialEntry mix; + mix.slot = 4; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(5); + config.opt("filament_is_mixed")->values.assign(5, 0); + config.opt("filament_mixed_components")->values.assign(5, ""); + config.opt("filament_mixed_sublayer_ratios")->values.assign(5, ""); + config.opt("filament_is_mixed")->values[4] = 1; + config.opt("filament_mixed_components")->values[4] = "1,2"; + config.opt("filament_mixed_sublayer_ratios")->values[4] = "0.6,0.4"; + 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(is_mixed[4]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[4] == "1,2"); + const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; + REQUIRE(ratios.size() == 5); + CHECK(ratios[4] == "0.6,0.4"); + CHECK(pub.skipped_keys.empty()); + } +} + // A single-extruder receiver collapses the author's per-extruder printer slots onto its single // slot: the first serialized variant of a base key is applied, the remaining variants of that // base key are reported as skipped.