From a62db72e023c6c85adadd2d175637087f8d9ad81 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Fri, 28 Aug 2026 15:24:03 +0800 Subject: [PATCH] Publish 3MF: import-side hardening and test coverage Validate mixed-filament definitions during the published material pass: definitions whose components reference slots that do not exist or hold other mixed filaments, or that carry fewer than two components, are reported through the shared skipped_keys channel instead of shipping a mix the GUI integrity check would only flag later. Fix the slot-limit exhaustion report being silently dropped: it wrote to published_config->skipped_keys, which the pass's final move-assignment from the local vector clobbers. All rejections now go through the local. Remove the unreachable persist branch from add_detached_preset: no caller passes save_to_project=false, so the parameter is gone and the copy is always project-embedded. Tests: cover the exhaustion path, the new definition validation, the identity-tier matching matrix (including substitute reporting), the structural-key denylist, whole-vector size-mismatch skips, relocation payload degradation, the "(Published 2)" uniquify chain, mixed blend colours staying out of shared preset configs, and duplicate-slot last-wins. Also fix the legacy-3mf scenario passing vacuously behind an if-guarded assertion. All existing published/3mf tests pass unchanged. --- src/libslic3r/Preset.cpp | 20 +- src/libslic3r/Preset.hpp | 12 +- src/libslic3r/PresetBundle.cpp | 53 +- tests/libslic3r/test_3mf.cpp | 7 +- .../libslic3r/test_preset_bundle_loading.cpp | 517 ++++++++++++++++++ 5 files changed, 582 insertions(+), 27 deletions(-) diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 908bdec2b3..6646d98e71 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -3064,14 +3064,14 @@ void PresetCollection::save_current_preset(const std::string &new_name, bool det // diff against a parent; the caller decides whether to select it. // The published entry's filament_id is forwarded so user bases keep their stable // material grouping (get_filament_presets() groups user bases by filament_id). -// save_to_project=true (the Full Publish default) creates a project-embedded preset: -// it lives inside the loaded project only (serialized into the saved .3mf, restored by -// load_project_embedded_presets) and never touches the user's library directory; -// Preset::save() early-returns for embedded presets, so persistence is skipped here too. +// The copy is a project-embedded preset: it lives inside the loaded project only +// (serialized into the saved .3mf, restored by load_project_embedded_presets) and +// never touches the user's library directory; Preset::save() early-returns for +// embedded presets, so persistence is skipped here too. // Returns the final (uniquified) name; on collision "" -> " (Published)" -> // " (Published 2)" ... std::string PresetCollection::add_detached_preset(const std::string &name_base, DynamicPrintConfig config, - const std::string &filament_id, bool save_to_project) + const std::string &filament_id) { if (name_base.empty()) return std::string(); @@ -3111,7 +3111,7 @@ std::string PresetCollection::add_detached_preset(const std::string &name_base, preset.bundle_id.clear(); preset.file = this->path_for_preset(preset); preset.is_visible = true; - preset.is_project_embedded = save_to_project; + preset.is_project_embedded = true; if (m_type == Preset::TYPE_PRINT) preset.config.option("print_settings_id", true)->value = final_name; else if (m_type == Preset::TYPE_FILAMENT) @@ -3120,14 +3120,6 @@ std::string PresetCollection::add_detached_preset(const std::string &name_base, preset.config.option("printer_settings_id", true)->value = final_name; unlock(); - if (!save_to_project) { - // Persist the full resolved config (no parent). Project-embedded presets are - // serialized into the .3mf instead; Preset::save() would early-return anyway. - // find by final_name — m_presets may have reallocated, so don't keep a raw ref. - auto persist_it = this->find_preset_internal(final_name); - if (persist_it != m_presets.end() && persist_it->name == final_name) - persist_it->save(nullptr); - } return final_name; } diff --git a/src/libslic3r/Preset.hpp b/src/libslic3r/Preset.hpp index e904851a1d..85908b95a5 100644 --- a/src/libslic3r/Preset.hpp +++ b/src/libslic3r/Preset.hpp @@ -639,16 +639,14 @@ public: // preset's stable material grouping (get_filament_presets groups user bases by // filament_id); the published entry's filament_id is forwarded so the copy keeps // the author's grouping. - // With save_to_project=true (default) the copy is a project-embedded preset - // ("Preset Inside Project"): it lives inside the loaded project only, is serialized - // into the saved .3mf via get_current_project_embedded_presets(), and is never - // written to the user's library directory. With false it persists as a normal - // user preset file. + // The copy is a project-embedded preset ("Preset Inside Project"): it lives inside + // the loaded project only, is serialized into the saved .3mf via + // get_current_project_embedded_presets(), and is never written to the user's + // library directory. // Returns the final (uniquified) name; on collision the suffix rule is: // "" -> " (Published)" -> " (Published 2)" ... std::string add_detached_preset(const std::string &name_base, DynamicPrintConfig config, - const std::string &filament_id = std::string(), - bool save_to_project = true); + const std::string &filament_id = std::string()); // Delete the current preset, activate the first visible preset. // returns true if the preset was deleted successfully. diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index b6b816b587..3219dec6d8 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -5466,8 +5466,10 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, const std::string material_label = !entry.filament_id.empty() ? entry.filament_id : !entry.publish_type_value.empty() ? entry.publish_type_value : entry.filament_type; - published_config->skipped_keys.emplace_back("material:" + material_label + - " (mixed filament definition: filament slot limit reached)"); + // 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"; entry_it = published_config->material_keys.erase(entry_it); @@ -5880,6 +5882,19 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, // 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; + // 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. + std::set mixed_final_slots; + for (const PublishedMaterialEntry& mix_entry : published_config->material_keys) + if (mix_entry.slot >= 0 && is_mixed_definition(mix_entry)) + mixed_final_slots.insert(mix_entry.slot); + for (size_t i = 0; i < this->filament_presets.size(); ++i) + if (this->is_mixed_filament(i)) + mixed_final_slots.insert(int(i)); + const size_t mixed_final_slot_count = this->filament_presets.size(); // 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. @@ -5900,6 +5915,40 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, entry.publish_type_value) : entry.filament_id; + // Import-side validation of a mixed-filament definition: the publish + // dialog cannot produce a definition whose components reference slots + // that do not exist or hold other mixed filaments, so a broken one here + // means the payload itself is broken (hand-crafted or corrupt file). + // Report it through the same channel as every other rejected input and + // skip the entry, instead of shipping a mix the GUI integrity check + // (check_mixed_filament_integrity) would only flag later. A payload + // that omits the mixed arrays entirely is not an error here: the key + // routing below reports those per key as usual. + if (is_mixed_definition(entry)) { + std::string mix_error; + if (const ConfigOptionStrings* comp_opt = config.opt("filament_mixed_components"); + comp_opt != nullptr && entry.slot < static_cast(comp_opt->values.size())) { + const std::vector comps = parse_mixed_components(comp_opt->values[entry.slot]); + if (comps.size() < 2) + // An empty definition cell counts as broken too: applying it + // would ship a mix the sidebar would only flag later. + mix_error = "needs at least two components"; + else + for (unsigned int comp : comps) + if (comp < 1 || size_t(comp) > mixed_final_slot_count || + mixed_final_slots.count(int(comp) - 1) != 0) { + mix_error = "components reference missing slots"; + break; + } + } + if (!mix_error.empty()) { + 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; + continue; + } + } + // Full Publish: always create a standalone detached copy (even on exact // identity match) as a "Preset Inside Project" (project-embedded: lives // in this project only, never written to the library), universally diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index 37147b93b8..f595926699 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -688,10 +688,9 @@ SCENARIO("Legacy 3MF without published metadata loads unchanged", "[3mf]") { LoadStrategy::LoadModel | LoadStrategy::LoadConfig); THEN("no published key is fabricated") { REQUIRE(loaded); - if (dst_model.model_info != nullptr) { - REQUIRE(dst_model.model_info->metadata_items.count(ORCA_PUBLISHED_TAG) == 0); - REQUIRE(dst_model.model_info->metadata_items.count(ORCA_PUBLISHED_KEYS_TAG) == 0); - } + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items.count(ORCA_PUBLISHED_TAG) == 0); + REQUIRE(dst_model.model_info->metadata_items.count(ORCA_PUBLISHED_KEYS_TAG) == 0); } release_PlateData_list(dst_plates); } diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 931f9e96c3..356645d06c 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -3279,3 +3279,520 @@ TEST_CASE("remap_model_filament_slots repoints extruder configs and color painti CHECK_FALSE(TriangleSelector::has_facets(chained.objects[0]->volumes.front()->mmu_segmentation_facets.get_data(), EnforcerBlockerType(5))); } + +// The slot ceiling (EnforcerBlockerType::ExtruderMax) is what the color-painting encoding can +// address, so a mixed filament that does not fit must be dropped and reported instead of being +// forced onto one of the receiver's physical filaments. +TEST_CASE("Published 3MF drops a mixed filament that does not fit the slot limit and reports it", "[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" }; + // A receiver already at the format's slot ceiling. + bundle.set_num_filaments(unsigned(EnforcerBlockerType::ExtruderMax), "#123456"); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.slot = int(EnforcerBlockerType::ExtruderMax) + 6; // beyond the ceiling + 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 = { mix }; + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Nothing grew and no slot became a virtual mix. + REQUIRE(bundle.filament_presets.size() == size_t(EnforcerBlockerType::ExtruderMax)); + for (size_t i = 0; i < bundle.filament_presets.size(); ++i) + CHECK_FALSE(bundle.is_mixed_filament(i)); + // The receiver's colours were not touched. + CHECK(bundle.project_config.opt("filament_colour")->values == receiver_colours); + // The mix was reported instead of being applied. + REQUIRE(contains_key(pub.skipped_keys, "material:PLA (mixed filament definition: filament slot limit reached)")); + CHECK(pub.material_replacements.empty()); +} + +// The publish dialog can only produce definitions whose components reference existing physical +// slots, so a payload whose components point at slots that do not exist (or at another mixed +// slot) or that carries fewer than two components is broken. The load reports it through the +// same channel as every other rejected input instead of shipping a mix the GUI integrity check +// would only flag later. +TEST_CASE("Published 3MF rejects a mixed filament definition with impossible components", "[Preset][Bundle][Published]") +{ + // A two-physical-plus-one-mix author file; the definition under test sits on slot 2. + auto make_file_config = [](const std::string &components) { + 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", "#0000FF", "#800080" }; + config.opt("filament_type")->values = { "PLA", "PETG", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_is_mixed")->values = { 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", components }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" }; + return config; + }; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.filament_id = "GFL99"; + mix.slot = 2; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + + for (const char *components : { "1,4", "1,3", "1" }) { + // The claimed components: "1,4" names a slot past the final count, "1,3" names the mix + // slot itself (1-based), "1" is not enough components to blend. + INFO("components = " << components); + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + bundle.filament_presets = { "My PLA" }; + + 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(components); + 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. + REQUIRE(bundle.filament_presets.size() == 3); + CHECK_FALSE(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. + if (std::string(components) == "1") + 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 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 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. +TEST_CASE("Published 3MF reports a relocated mixed filament whose payload cells are missing", "[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", "My PLA", "My PLA" }; + bundle.set_num_filaments(5, "#123456"); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.filament_id = "GFL99"; + mix.slot = 3; // authored slot 3; the receiver's five real slots occupy 0-4 + 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 = { mix }; + // The file's mixed arrays only cover its single physical slot: the definition data for + // slot 3 does not exist in the payload. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_is_mixed")->values = { 0 }; + config.opt("filament_mixed_components")->values = { "" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "" }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // 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); + 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. + REQUIRE(bundle.filament_presets.size() == 6); + CHECK_FALSE(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)")); + // The five real slots kept their colours. + CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), + bundle.project_config.opt("filament_colour")->values.begin())); +} + +// A grown slot's material is chosen by identity tiers: exact preset name, then the bare +// name/alias form, then exact setting_id, then exact filament_id, then vendor+type, then type +// only. Each section pits two adjacent tiers against each other. +TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + 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", "#00FF00", "#0000FF", "#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; + }; + + PublishedMaterialEntry entry; + entry.slot = 2; + entry.filament_type = "PLA"; + + SECTION("an exact preset name outranks the bare-name form") + { + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &bare = add_inmemory_preset(bundle.filaments, "Authored PLA"); + bare.config.opt_string("filament_type", 0u) = "PLA"; + Preset &exact = add_inmemory_preset(bundle.filaments, "Authored PLA @Vendor"); + exact.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + + entry.preset_name = "Authored PLA @Vendor"; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "Authored PLA @Vendor"); + CHECK(pub.material_replacements.empty()); + } + + SECTION("a bare name outranks an exact setting_id") + { + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &bare = add_inmemory_preset(bundle.filaments, "Authored PLA"); + bare.config.opt_string("filament_type", 0u) = "PLA"; + Preset &sid = add_inmemory_preset(bundle.filaments, "Bbb PLA"); + sid.config.opt_string("filament_type", 0u) = "PLA"; + sid.setting_id = "SID123"; + bundle.filament_presets = { "My PLA" }; + + entry.preset_name = "Authored PLA @Vendor"; // no library preset carries this name + entry.setting_id = "SID123"; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "Authored PLA"); + } + + SECTION("an exact setting_id outranks an exact filament_id") + { + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &sid = add_inmemory_preset(bundle.filaments, "Bbb PLA"); + sid.config.opt_string("filament_type", 0u) = "PLA"; + sid.setting_id = "SID123"; + Preset &fid = add_inmemory_preset(bundle.filaments, "Ccc PLA"); + fid.config.opt_string("filament_type", 0u) = "PLA"; + fid.filament_id = "GFA00"; + bundle.filament_presets = { "My PLA" }; + + entry.preset_name = "Authored PLA @Vendor"; // no library preset carries this name + entry.setting_id = "SID123"; + entry.filament_id = "GFA00"; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "Bbb PLA"); + } + + SECTION("a vendor+type match is reported as a substitute") + { + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &exact_vendor = add_inmemory_preset(bundle.filaments, "Aaa PLA"); + exact_vendor.config.opt_string("filament_type", 0u) = "PLA"; + exact_vendor.config.opt_string("filament_vendor", 0u) = "Generic"; + Preset &other_vendor = add_inmemory_preset(bundle.filaments, "Zzz PLA"); + other_vendor.config.opt_string("filament_type", 0u) = "PLA"; + other_vendor.config.opt_string("filament_vendor", 0u) = "Other"; + bundle.filament_presets = { "My PLA" }; + + entry.filament_vendor = "Generic"; // no name or id identity: the family tiers decide + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + // The same-vendor PLA outranks the type-only candidate... + CHECK(bundle.filament_presets[2] == "Aaa PLA"); + // ...and since it is not an exact material match, the load says so. + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: Aaa PLA (substitute: no exact material match)"); + } +} + +// Structural keys (identity links like filament_ids / inherits / printer_settings_id) are +// never applied onto the receiver and never reported: a hand-crafted file listing them must +// not trigger the "could not be applied" warning, while unknown keys still do. +TEST_CASE("Published 3MF silently ignores structural keys in published_keys", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt_float("layer_height") = 0.28; + Preset::normalize(config); + + PresetBundle bundle; + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + const std::vector ids_before = + bundle.filaments.get_edited_preset().config.opt("filament_settings_id")->values; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "filament_ids", "inherits", "printer_settings_id", "layer_height", "not_a_setting" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The real setting applied... + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 1e-6)); + CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); + // ...the structural keys were neither applied nor reported... + CHECK_FALSE(contains_key(pub.skipped_keys, "filament_ids")); + CHECK_FALSE(contains_key(pub.skipped_keys, "inherits")); + CHECK_FALSE(contains_key(pub.skipped_keys, "printer_settings_id")); + CHECK(bundle.filaments.get_edited_preset().config.opt("filament_settings_id")->values == ids_before); + // ...while an unknown key still reports. + CHECK(contains_key(pub.skipped_keys, "not_a_setting")); +} + +// A whole-vector key requires the receiver's vector to have the same number of elements as the +// author's: pasting a 3-extruder list into a 2-extruder machine would overwrite the wrong +// elements, so the key is reported as skipped and the receiver keeps its own values. +TEST_CASE("Published 3MF skips a whole-vector key whose size does not match the receiver", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt_float("layer_height") = 0.28; + // Author's wiping matrix sized for three extruders. + config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 10., 20., 30. })); + Preset::normalize(config); + + PresetBundle bundle; + // Receiver sized for two extruders. + bundle.prints.get_edited_preset().config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 40., 50. })); + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "wiping_volumes_extruders", "layer_height" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values, + { 40., 50. }); + CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders")); + // The matching scalar key still applied. + CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 1e-6)); +} + +// The uniquify chain continues past the first suffix: with both "X" and "X (Published)" already +// present, the next imported copy of "X" lands as "X (Published 2)" and leaves the others alone. +TEST_CASE("Published 3MF uniquifies a second imported full material as (Published 2)", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + Preset &bare = add_inmemory_preset(bundle.filaments, "Generic PLA"); + bare.config.opt_string("filament_type", 0u) = "PLA"; + bare.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &pub1 = add_inmemory_preset(bundle.filaments, "Generic PLA (Published)"); + pub1.config.opt_string("filament_type", 0u) = "PLA"; + pub1.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.preset_name = "Generic PLA @Qidi Q2 0.4 nozzle"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets[0] == "Generic PLA (Published 2)"); + check_double_vector(bundle.filaments.find_preset("Generic PLA (Published 2)", false, true) + ->config.opt("filament_retraction_length")->values, + { 0.9 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true) + ->config.opt("filament_retraction_length")->values, + { 0.5 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA (Published)", false, true) + ->config.opt("filament_retraction_length")->values, + { 0.5 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA (Published 2) (published material imported)"); + CHECK(pub.skipped_keys.empty()); +} + +// A mixed filament's blended colour is a swatch for the project's colour strip only: it must +// never be written into the slot's (possibly shared) preset config, or every slot referencing +// that preset would turn into the blend colour. +TEST_CASE("Published 3MF never writes a mixed filament's blended colour into the slot's preset", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(3); + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[2] = "1,1"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = "0.5,0.5"; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.filament_id = "GFL99"; + mix.slot = 2; + 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 = { mix }; + 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", "#0000FF", "#800080" }; + config.opt("filament_type")->values = { "PLA", "PETG", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_is_mixed")->values = { 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "1,2" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" }; + config.opt("filament_mixed_gradient")->values = { 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, 1 }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The definition applied like-for-like onto the virtual slot... + CHECK(bundle.filament_presets.size() == 3); + CHECK(bundle.is_mixed_filament(2)); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[2] == "1,2"); + // ...the blend colour landed in the project strip only... + CHECK(bundle.project_config.opt("filament_colour")->values[2] == "#800080"); + // ...and the shared preset kept its own colour: slots 0 and 1 render unchanged. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == + std::vector{ "#123456" }); + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.material_replacements.empty()); +} + +// Duplicate entries for the same authored slot only occur in hand-crafted files (the dialog +// emits one entry per slot); the load's contract under that input is deterministic last-wins, +// not corruption. +TEST_CASE("Published 3MF applies duplicate entries for one slot last-wins", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#000000" }; + bundle.filament_presets = { "My PLA" }; + + auto make_entry = [](const char *color) { + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_color = true; + entry.color = color; + entry.keys = { "filament_retraction_length" }; + return entry; + }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_entry("#AA0000"), make_entry("#BB0000") }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The second entry won both the project strip and the slot's preset. + CHECK(bundle.project_config.opt("filament_colour")->values[0] == "#BB0000"); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == + std::vector{ "#BB0000" }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true) + ->config.opt("filament_retraction_length")->values, + { 0.9 }); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.material_replacements.empty()); +}