From fff0efdb275003cf19d2c04b9605f3af6270a37e Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Mon, 31 Aug 2026 13:57:21 +0800 Subject: [PATCH] Fixes filament import bug --- src/libslic3r/PresetBundle.cpp | 61 ++++++++- .../libslic3r/test_preset_bundle_loading.cpp | 123 +++++++++++++++++- 2 files changed, 173 insertions(+), 11 deletions(-) diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 2a7e97a34e..688d2b90b0 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -5979,6 +5979,22 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, if (this->is_mixed_filament(i)) mixed_final_slots.insert(int(i)); const size_t mixed_final_slot_count = this->filament_presets.size(); + // Finalize a slot as an empty mixed-filament placeholder: mark it virtual with + // an intentionally empty definition, and add it to the final-layout set so a + // later entry's components validate against the new state. Used by the capacity + // placeholders and by any mixed definition that has to be rejected after its + // slot was already grown and seeded - without it such a slot would keep the + // seeded preset and masquerade as a real filament. No definition is written: + // the placeholder carries no material of its own (the GUI flags the empty mix + // via check_mixed_filament_integrity and blocks slicing until components are + // assigned). The slot's colour was already seeded by the growth pass above. + auto finalize_mixed_placeholder = [&](size_t slot_idx) { + if (ConfigOptionBools* is_mixed_opt = this->project_config.opt("filament_is_mixed"); + is_mixed_opt != nullptr && slot_idx < is_mixed_opt->values.size()) + is_mixed_opt->values[slot_idx] = true; + mixed_final_slots.insert(int(slot_idx)); + material_applied = true; + }; // Full Publish within-load dedup: identical Full materials (same setting_id // + preset_name identity) share one created instance, so an author who // pointed two slots at one preset yields one standalone copy here. @@ -5996,10 +6012,7 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, // 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; + finalize_mixed_placeholder(slot); continue; } @@ -6007,8 +6020,19 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, // find_preset would return &m_edited_preset for the selected slot. The // overlay target below decides between the edited layer and this preset. Preset* recv = this->filaments.find_preset(this->filament_presets[slot], false, true); - if (recv == nullptr) + if (recv == nullptr) { + // Defensive: the slot exists (grown and seeded above) but its preset + // could not be resolved. A mixed definition left unapplied on such a + // slot would keep the seeded preset and look like a real filament: + // finalize it as an empty placeholder instead, like a rejected + // definition below. + if (is_mixed_definition(entry) && !this->is_mixed_filament(slot)) { + finalize_mixed_placeholder(slot); + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + + ": mixed filament definition could not be imported"); + } continue; + } const std::string material_label = entry.filament_id.empty() ? (entry.publish_type_value.empty() ? entry.filament_type : @@ -6045,6 +6069,23 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, skipped_keys.emplace_back("material:" + material_label + " (mixed filament definition: " + mix_error + ")"); BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF mixed filament from slot " << entry.slot << " rejected: " << mix_error; + // Degradation, not silent corruption: the slot was already grown and + // seeded by the pass above, so skipping the definition alone would + // leave it holding the seeded preset and looking like a real + // filament that carries the mix identity but no mix. + // - a slot that was NOT already a receiver mix is finalized as an + // empty mixed placeholder (consistent with the capacity + // placeholders; the user assigns components there); + // - a like-for-like override of the receiver's own mix leaves that + // valid definition untouched: nothing was applied, so the + // receiver's cells are intact and only the author's definition + // is reported as skipped above. + if (!this->is_mixed_filament(slot)) { + finalize_mixed_placeholder(slot); + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + + ": mixed filament definition could not be imported (" + + mix_error + ")"); + } continue; } } @@ -6247,6 +6288,16 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, for (const std::string& key : entry.keys) skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); apply_slot = false; + // A mixed definition left unapplied here would keep the grown + // slot's seeded preset and masquerade as a real filament: + // finalize it as an empty placeholder like a rejected + // definition (a like-for-like override of the receiver's own + // mix is left untouched). + if (is_mixed_definition(entry) && !this->is_mixed_filament(slot)) { + finalize_mixed_placeholder(slot); + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + + ": mixed filament definition could not be imported"); + } } } } diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index a493ea9b7b..17e33f3320 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -11,6 +11,7 @@ #include "test_utils.hpp" #include +#include #include using namespace Slic3r; @@ -3671,9 +3672,11 @@ TEST_CASE("Published 3MF rejects a mixed filament definition with impossible com Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); - // The definition was rejected, not applied: the slot stays a plain (grown) slot. + // The definition was rejected, not applied: the grown slot is finalized as an empty + // mixed placeholder instead of keeping the seeded preset and masquerading as a real + // filament. REQUIRE(bundle.filament_presets.size() == 3); - CHECK_FALSE(bundle.is_mixed_filament(2)); + CHECK(bundle.is_mixed_filament(2)); CHECK(bundle.project_config.opt("filament_mixed_components")->values[2].empty()); CHECK(bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2].empty()); // Reported through the shared rejection channel. @@ -3681,13 +3684,119 @@ TEST_CASE("Published 3MF rejects a mixed filament definition with impossible com CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: needs at least two components)")); else CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: components reference missing slots)")); + // The slot change was surfaced like the other slot adaptations. + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0].find("slot 2: mixed filament definition could not be imported") != std::string::npos); // The blended colour was not written into the (shared) slot preset either. CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#123456" }); - CHECK(pub.material_replacements.empty()); } } +// The reported scenario: an author publishes two mixed filaments whose components are +// full-published physical slots; the receiver is a smaller tool-changer, so some of those +// component slots become empty mixed placeholders. A mix whose component turned into a +// placeholder can never be valid (mixes cannot reference mixes): it is rejected, and its +// grown slot must be finalized as an empty mixed placeholder too - not keep the seeded +// preset and masquerade as a real filament carrying the mix identity. +TEST_CASE("Published 3MF finalizes a mixed filament rejected over a placeholder component as an empty placeholder", "[Preset][Bundle][Published]") +{ + // An author project: six physical slots plus two tail mixes, the second referencing the + // sixth physical slot (H2C-style: slot 7 = 1+2, slot 8 = 2+6). + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = std::vector(8, 1.75); + config.opt("filament_self_index")->values = { 1, 2, 3, 4, 5, 6, 7, 8 }; + config.opt("filament_extruder_variant")->values = std::vector(8, "Direct Drive Standard"); + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00", + "#FF00FF", "#00FFFF", "#800080", "#804000" }; + config.opt("filament_type")->values.assign(8, "PLA"); + config.opt("filament_vendor")->values.assign(8, "Generic"); + config.opt("filament_is_mixed")->values = { 0, 0, 0, 0, 0, 0, 1, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "", "", "", "", "1,2", "2,6" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "", "", "", "", "0.6,0.4", "0.5,0.5" }; + // The export always serializes all seven masked mixed arrays, not just the ones in + // use; the unused gradient arrays ride along as defaults. + config.opt("filament_mixed_gradient")->values = { 0, 0, 0, 0, 0, 0, 0, 0 }; + config.opt("filament_mixed_gradient_range")->values.assign(8, ""); + config.opt("filament_mixed_gradient_curve")->values.assign(8, ""); + config.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 0, 0, 0, 0, 0, 0 }; + return config; + }; + // A non-SEMM receiver with four nozzles and four slots (tool-changer style). + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets.assign(4, "My PLA"); + bundle.set_num_filaments(4, "#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(4, 0.4); + + auto make_full_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.full = true; + entry.full_keys = { "filament_retraction_length" }; + return entry; + }; + auto make_mix_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; + entry.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" }; + return entry; + }; + + // The dialog's emit order: the full-published physical slots (1, 2, 5, 6) and both mixes. + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_full_entry(0), make_full_entry(1), make_full_entry(4), make_full_entry(5), + make_mix_entry(6, "#800080"), make_mix_entry(7, "#804000") }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grew to the author's slot count, all virtual territory at the tail. + REQUIRE(bundle.filament_presets.size() == 8); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 8); + // Slots 0-3 stayed physical; authored slots 5 and 6 (0-based 4 and 5) became capacity + // placeholders. + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + CHECK(is_mixed[5]); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[4].empty()); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[5].empty()); + // The first mix applied onto its uncontended tail slot. + CHECK(is_mixed[6]); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[6] == "1,2"); + // The second mix was rejected - its second component (authored slot 6) turned into a + // placeholder - and its slot was finalized as an empty mixed placeholder instead of + // keeping the seeded preset as a phantom real filament. + CHECK(is_mixed[7]); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[7].empty()); + CHECK(bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[7].empty()); + REQUIRE(pub.skipped_keys.size() == 1); + CHECK(pub.skipped_keys[0] == "material:PLA (mixed filament definition: components reference missing slots)"); + // Two Full Publish detach lines (slots 0-1), two capacity placeholder lines (slots 4-5), + // and the rejected mix's finalization line (slot 7). + REQUIRE(pub.material_replacements.size() == 5); + CHECK(std::any_of(pub.material_replacements.begin(), pub.material_replacements.end(), + [](const std::string &line) { + return line.find("slot 7: mixed filament definition could not be imported") != std::string::npos; + })); +} + // The relocation shifts cells inside the file's per-slot mixed arrays; a payload too short to // actually carry the definition degrades to empty cells, which the definition validation then // reports - an empty mix must not ship as a virtual slot. @@ -3734,15 +3843,17 @@ TEST_CASE("Published 3MF reports a relocated mixed filament whose payload cells // The mix was relocated past the physical territory... REQUIRE(pub.mixed_slot_relocations.size() == 1); CHECK(pub.mixed_slot_relocations.at(3) == 5); - REQUIRE(pub.material_replacements.size() == 1); + REQUIRE(pub.material_replacements.size() == 2); CHECK(pub.material_replacements[0].find("slot 3 -> slot 5") != std::string::npos); // ...and the receiver grew to hold the destination slot, but the definition itself was - // rejected: the relocated cells degraded to empty defaults and were reported. + // rejected: the relocated cells degraded to empty defaults, the slot was finalized as an + // empty mixed placeholder (not a real filament), and both facts were reported. REQUIRE(bundle.filament_presets.size() == 6); - CHECK_FALSE(bundle.is_mixed_filament(5)); + CHECK(bundle.is_mixed_filament(5)); CHECK(bundle.project_config.opt("filament_mixed_components")->values[5].empty()); CHECK(bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[5].empty()); CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: needs at least two components)")); + CHECK(pub.material_replacements[1].find("slot 5: mixed filament definition could not be imported") != std::string::npos); // The five real slots kept their colours. CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), bundle.project_config.opt("filament_colour")->values.begin()));