From f1719b558064c571485a7c544cef4f3be8141d08 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Wed, 2 Sep 2026 14:29:15 +0800 Subject: [PATCH] Fixes mixed filament growth bug. Fixes unit test --- src/libslic3r/PresetBundle.cpp | 25 ++ tests/libslic3r/test_3mf.cpp | 141 +++++++---- .../libslic3r/test_preset_bundle_loading.cpp | 232 +++++++++++++++--- 3 files changed, 329 insertions(+), 69 deletions(-) diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 76211314b0..f1535fca90 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -5595,6 +5595,12 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, for (const PublishedMaterialEntry& entry : published_config->material_keys) if (entry.slot >= 0) published_slots.insert(entry.slot); + // Grown slots the receiver creates that carry no published content of their own + // (e.g. an unpublished mixed slot left as a gap by a published mix's authored + // position) at or beyond the receiver's physical capacity. They must not become + // physical filaments (that would overflow the nozzle count): finalized as empty + // mixed placeholders below, like the surplus-material placeholders. + std::set virtual_gap_slots; // Exact-name resolution of each published slot's preset through the collection's // own name machinery: find_preset2 follows renamed_from (vendor profile renames) // and canonical bundle names, and auto-matches removed vendor-generic profiles @@ -5665,6 +5671,10 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, while (this->filament_presets.size() < target_slots) { const size_t new_slot_idx = this->filament_presets.size(); std::string initial_preset; + if (new_slot_idx >= physical_capacity && published_slots.count(static_cast(new_slot_idx)) == 0) + // An unpublished grown slot past the printer's physical capacity cannot + // host a real filament: finalize it as a virtual placeholder below. + virtual_gap_slots.insert(static_cast(new_slot_idx)); if (published_slots.count(static_cast(new_slot_idx)) != 0) { // Grow the slot the way the sidebar "add filament" does: seed it with // the receiver's last preset. @@ -5927,6 +5937,10 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, for (size_t i = 0; i < this->filament_presets.size(); ++i) if (this->is_mixed_filament(i)) mixed_final_slots.insert(int(i)); + // Unpublished gap slots past the capacity are virtual too: count them in the + // final layout so a published mix whose components collide with one is caught. + for (int gap_slot : virtual_gap_slots) + mixed_final_slots.insert(gap_slot); 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 @@ -6320,6 +6334,17 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path, apply_slot_keys(write_config, preset_keys, entry.slot, material_label); } } + // Unpublished gap slots past the printer's physical capacity: the receiver grew + // them only to reach a published definition, so they must not become physical + // filaments (that would overflow the nozzle count). Finalize each as an empty + // mixed placeholder, exactly like the surplus-material placeholders above. + for (int gap_slot : virtual_gap_slots) + if (!this->is_mixed_filament(size_t(gap_slot))) { + finalize_mixed_placeholder(size_t(gap_slot)); + published_config->material_replacements.emplace_back( + "slot " + std::to_string(gap_slot) + + ": unassigned mixed filament (printer supports only " + std::to_string(physical_capacity) + " filaments)"); + } } } diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index f595926699..d30b40004d 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -866,10 +866,12 @@ SCENARIO("Minimal published 3MF omits project config, preset dumps and slicer ta } } -// A "full publish" entry carries the whole slot's key list: its vector options keep only the -// author's slot value, the other slots are masked to their defaults so a slot-1 full publish -// does not leak slot 0's data into the file. -SCENARIO("Full-publish entries filter the whole slot and mask the other slots", "[3mf]") { +// An entry masks the non-published slots to their defaults so publishing slot 1 never leaks slot +// 0's value into the file. Both a full entry (the whole-slot key list) and a partial entry (a +// per-slot key) go through the same masking path in filter_published_config (keys and full_keys +// are filtered identically), so the two forms are exercised together. +SCENARIO("Published entries mask the other slots to their defaults", "[3mf]") { + const bool full = GENERATE(true, false); GIVEN("a full print configuration with two filament slots") { DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); full_cfg.opt("filament_diameter")->values = { 1.75, 1.75 }; @@ -878,45 +880,18 @@ SCENARIO("Full-publish entries filter the whole slot and mask the other slots", // mask can restore it on the non-published slot. full_cfg.opt("filament_flow_ratio", true)->values = { 1.02, 0.98 }; - PublishedMaterialEntry full_entry; - full_entry.slot = 1; - full_entry.full = true; - full_entry.full_keys = { "filament_flow_ratio" }; - - WHEN("filtering with a full entry for slot 1") { - DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { full_entry }); - - THEN("the full key list is present with the author's slot value") { - REQUIRE(filtered_cfg.option("filament_flow_ratio") != nullptr); - REQUIRE_THAT(filtered_cfg.opt("filament_flow_ratio")->values[1], Catch::Matchers::WithinAbs(0.98, 1e-6)); + WHEN("filtering with a published entry for slot 1") { + PublishedMaterialEntry entry; + entry.slot = 1; + if (full) { + entry.full = true; + entry.full_keys = { "filament_flow_ratio" }; + } else { + entry.keys = { "filament_flow_ratio" }; } - THEN("the non-published slot is masked to its default") { - REQUIRE_THAT(filtered_cfg.opt("filament_flow_ratio")->values[0], Catch::Matchers::WithinAbs(1.0, 1e-6)); - } - THEN("the identity keys stay present") { - REQUIRE(filtered_cfg.option("filament_colour") != nullptr); - } - } - } -} + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { entry }); -// A partial-publish entry (per-slot keys) is masked to the author's slot exactly like a full -// entry, so publishing one slot's retraction does not ship the other slots' values in the file. -SCENARIO("Partial-publish entries mask the other slots like full entries", "[3mf]") { - GIVEN("a full print configuration with two filament slots") { - DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); - full_cfg.opt("filament_diameter")->values = { 1.75, 1.75 }; - full_cfg.opt("filament_colour")->values = { "#111111", "#222222" }; - full_cfg.opt("filament_flow_ratio", true)->values = { 1.02, 0.98 }; - - PublishedMaterialEntry partial_entry; - partial_entry.slot = 1; - partial_entry.keys = { "filament_flow_ratio" }; - - WHEN("filtering with a partial entry for slot 1") { - DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { partial_entry }); - - THEN("the partial key is present with the author's slot value") { + THEN("the selected key is present with the author's slot value") { REQUIRE(filtered_cfg.option("filament_flow_ratio") != nullptr); REQUIRE_THAT(filtered_cfg.opt("filament_flow_ratio")->values[1], Catch::Matchers::WithinAbs(0.98, 1e-6)); } @@ -1075,3 +1050,87 @@ SCENARIO("Published mixed-filament keys are masked to the author's slot", "[3mf] } } +// The published flag is gated on the exact string "1": any other serialized value means "not +// published", so a receiver never treats a file as published on a loose truthiness check. +TEST_CASE("is_published_3mf_flag accepts only the literal \"1\"", "[3mf]") { + CHECK(is_published_3mf_flag("1")); + CHECK_FALSE(is_published_3mf_flag("0")); + CHECK_FALSE(is_published_3mf_flag("false")); + CHECK_FALSE(is_published_3mf_flag("true")); + CHECK_FALSE(is_published_3mf_flag("")); + CHECK_FALSE(is_published_3mf_flag("YES")); +} + +// bbs_3mf_is_published is the lightweight metadata probe used to decide whether a file was +// produced by the publish feature (GUI "recently published" tracking). It must return true only +// for a file whose metadata carries the flag set to "1", and false for legacy files and for a +// file whose flag is present but not "1" (which loads as a normal, non-published 3MF). +SCENARIO("bbs_3mf_is_published detects only genuinely published 3MFs", "[3mf]") { + auto store_model = [](const std::string &path, const std::string &flag_value, const std::string &keys_value) { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + model.model_info = std::make_shared(); + // An empty flag_value means "don't write the flag at all" (a legacy file). + if (!flag_value.empty()) + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = flag_value; + model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = keys_value; + ScopedTemporaryDir backup_dir("orca_is_pub"); + model.set_backup_path(backup_dir.string()); + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = path.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + }; + + GIVEN("a minimal published 3MF whose flag is \"1\"") { + ScopedTemporaryFile temp(".3mf"); + store_model(temp.string(), "1", R"(["layer_height"])"); + WHEN("probed by bbs_3mf_is_published") { + THEN("it is recognized as published") { + CHECK(bbs_3mf_is_published(temp.string())); + } + } + } + GIVEN("a legacy 3MF without any published flag") { + ScopedTemporaryFile temp(".3mf"); + store_model(temp.string(), "", R"(["layer_height"])"); + WHEN("probed by bbs_3mf_is_published") { + THEN("it is not recognized as published") { + CHECK_FALSE(bbs_3mf_is_published(temp.string())); + } + } + } + GIVEN("a 3MF carrying the flag set to \"0\"") { + ScopedTemporaryFile temp(".3mf"); + store_model(temp.string(), "0", R"(["layer_height"])"); + WHEN("probed and loaded") { + THEN("it is not recognized as published") { + CHECK_FALSE(bbs_3mf_is_published(temp.string())); + } + THEN("it loads as a normal, non-published 3MF") { + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + REQUIRE(load_bbs_3mf(temp.string().c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig)); + REQUIRE(dst_model.model_info != nullptr); + // The key is present but not "1", so nothing treats the file as published; the + // stored keys still round-trip verbatim. + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "0"); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height"])"); + release_PlateData_list(dst_plates); + } + } + } +} + diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 17e33f3320..d2d0560be2 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -1212,13 +1212,14 @@ TEST_CASE("Published 3MF imports a full material under the author's stripped nam REQUIRE(bundle.filament_presets.size() == 2); CHECK(bundle.filament_presets[0] == "My PETG"); - // The grown slot lands on a freshly created copy carrying the author's slot-1 value; - // the library preset that seeded it stays untouched. + // The grown slot seeds the receiver's last preset ("My PETG"), then the full material + // detaches onto a copy carrying the author's slot-1 value; "Generic PLA @System" is + // left untouched. CHECK(bundle.filament_presets[1] == "Generic PLA"); check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.8 }); check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); REQUIRE(pub.material_replacements.size() == 1); - CHECK(pub.material_replacements[0] == "slot 1: Generic PLA @System -> Generic PLA"); + CHECK(pub.material_replacements[0] == "slot 1: My PETG -> Generic PLA"); CHECK(pub.skipped_keys.empty()); } } @@ -1922,7 +1923,7 @@ TEST_CASE("Published 3MF grows the receiver's slots only as far as the published // A published slot is seeded from an unused library preset and the values are written onto it // in place, so the receiver's own material (slot 0) is never overwritten. -TEST_CASE("Published 3MF seeds published slots from unused presets and mutates them in place", "[Preset][Bundle][Published]") +TEST_CASE("Published 3MF grows published slots to the receiver's last preset and recolors it in place", "[Preset][Bundle][Published]") { auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); @@ -1937,8 +1938,9 @@ TEST_CASE("Published 3MF seeds published slots from unused presets and mutates t }; // Receiver with its own material plus one more library preset; author publishes only slot 4 - // (Red). The grown slot is seeded from the unused library preset, so the published red - // recolors that preset in place and never the receiver's own material. + // (Red). Growth always repeats the receiver's last filament ("Add one filament"), so the + // grown slot references the shared "My PLA" preset and the published red recolors it in + // place; the unused "Other PLA" preset is left untouched. PresetBundle bundle; Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); mine.config.opt_string("filament_type", 0u) = "PLA"; @@ -1960,14 +1962,14 @@ TEST_CASE("Published 3MF seeds published slots from unused presets and mutates t bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 4); - // Unpublished filler slots repeat the receiver's last preset ("Add one filament"). + // Every grown slot (published or filler) repeats the receiver's last preset. CHECK(bundle.filament_presets[1] == "My PLA"); CHECK(bundle.filament_presets[2] == "My PLA"); - // The published slot was seeded from the unused library preset; the published colour was - // written onto it in place, never onto the receiver's own material. - CHECK(bundle.filament_presets[3] == "Other PLA"); - CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#123456" }); - CHECK(bundle.filaments.find_preset("Other PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + // The published red recolors the shared "My PLA" preset in place; the unused "Other PLA" + // preset is left untouched. + CHECK(bundle.filament_presets[3] == "My PLA"); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + CHECK(bundle.filaments.find_preset("Other PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#654321" }); // The project-level colours are sized and seeded for every grown slot. CHECK(bundle.project_config.opt("filament_colour")->values.size() == 4); CHECK(bundle.project_config.opt("filament_colour")->values[1] == "#123456"); @@ -1977,10 +1979,11 @@ TEST_CASE("Published 3MF seeds published slots from unused presets and mutates t CHECK(bundle.project_config.opt("filament_map")->values.size() == 4); } -// Without a checked Type row, a grown published slot is still seeded from the published -// material's identity - an exact filament_id outranks any arbitrary unused preset, and an -// entry carrying only a family constrains the pick to that family. -TEST_CASE("Published 3MF seeds a grown slot by published identity or family without a type requirement", "[Preset][Bundle][Published]") +// Growth always repeats the receiver's last preset; a published slot only lands on its +// material identity when the aliased grown slot is re-pointed (de-alias fires on a preset +// key). Lock the identity priority there: an exact filament_id outranks an arbitrary unused +// preset, and an entry carrying only a family constrains the pick to that family. +TEST_CASE("Published 3MF re-points an aliased grown slot by published identity or family without a type requirement", "[Preset][Bundle][Published]") { auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); @@ -1998,6 +2001,7 @@ TEST_CASE("Published 3MF seeds a grown slot by published identity or family with entry.slot = 2; entry.filament_type = "PLA"; entry.filament_vendor = "Generic"; + entry.keys = { "filament_retraction_length" }; // An unused preset sorting before everything else: an unconstrained pick would take it. PresetBundle bundle; Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); @@ -2022,8 +2026,9 @@ TEST_CASE("Published 3MF seeds a grown slot by published identity or family with REQUIRE(bundle.filament_presets.size() == 3); CHECK(bundle.filament_presets[1] == "My PLA"); CHECK(bundle.filament_presets[2] == "Zzz PLA"); - // An exact identity match is not a substitute, so nothing is reported. - CHECK(pub.material_replacements.empty()); + // The exact-id preset outranks the type-only "Aaa PLA"; the re-point is reported. + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Zzz PLA"); } SECTION("a family-only entry picks an unused preset of that family") { @@ -2039,7 +2044,11 @@ TEST_CASE("Published 3MF seeds a grown slot by published identity or family with bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 3); + // The family pick lands on the only PETG preset (the PLA presets and the receiver's own + // material lose), and the re-point is reported. CHECK(bundle.filament_presets[2] == "Bbb PETG"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Bbb PETG"); } } @@ -3797,6 +3806,103 @@ TEST_CASE("Published 3MF finalizes a mixed filament rejected over a placeholder })); } +// The receiver must not overflow its physical capacity when it grows slots to reach a published +// mixed definition: an unpublished mixed slot that lands as a gap past the nozzle count becomes +// an empty mixed placeholder, not a physical filament. An author with six physical slots (0-5) +// and two tail mixes (slots 6 and 7) publishes only 0-5 and 7; the receiver has four nozzles. +// Slots 4 and 5 become surplus placeholders, slot 7 keeps its authored mix position, and the +// unpublished gap slot 6 is finalized as a virtual placeholder - never a fifth physical slot. +TEST_CASE("Published 3MF turns an unpublished gap slot past the printer's capacity into an empty mixed placeholder", "[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.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); + + // 8 authored slots: 0-5 physical, 6 unpublished mixed, 7 published mixed. The payload masks + // the unpublished slot's mixed flag (filter_published_config), so slot 6 reads as physical. + 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, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "", "", "", "", "", "1,2" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "", "", "", "", "", "0.5,0.5" }; + config.opt("filament_mixed_gradient")->values = std::vector(8, 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 = std::vector(8, 0); + + auto make_full_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + 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.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; + }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_full_entry(0), make_full_entry(1), make_full_entry(2), make_full_entry(3), + make_full_entry(4), make_full_entry(5), make_mix_entry(7, "#804000") }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + 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 stay physical; 4 and 5 are surplus placeholders; 6 is the unpublished gap; 7 is + // the published mix. All four tail slots are virtual. + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + CHECK(is_mixed[5]); + CHECK(is_mixed[6]); + CHECK(is_mixed[7]); + // Surplus physical slots (4,5) and the unpublished gap (6) carry no definition; the + // published mix on slot 7 keeps its own. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 8); + CHECK(components[4].empty()); + CHECK(components[5].empty()); + CHECK(components[6].empty()); + CHECK(components[7] == "1,2"); + // Exactly four physical slots remain (never a fifth past the nozzle count). + size_t physical_count = 0; + for (bool mixed : is_mixed) + if (!mixed) + ++physical_count; + CHECK(physical_count == 4); + // The unpublished gap's conversion is surfaced through the post-import notice. + bool gap_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("slot 6: unassigned mixed filament") != std::string::npos) + gap_reported = true; + CHECK(gap_reported); + CHECK(pub.skipped_keys.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. @@ -3859,10 +3965,12 @@ TEST_CASE("Published 3MF reports a relocated mixed filament whose payload cells 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]") +// A grown published slot always repeats the receiver's last preset; it can only move to the +// published material's identity when a replacement is warranted (an aliased slot that would +// otherwise leak keys, or a type mismatch). The tier priority candidate_score uses is locked +// here: exact preset name > bare name > exact setting_id > exact filament_id > vendor+type, +// with the lower tiers reported as a substitute. +TEST_CASE("Published 3MF re-points an aliased grown slot's material by identity tiers", "[Preset][Bundle][Published]") { auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); @@ -3894,6 +4002,7 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr bundle.filament_presets = { "My PLA" }; entry.preset_name = "Authored PLA @Vendor"; + entry.keys = { "filament_retraction_length" }; PublishedConfig pub; pub.published = true; pub.material_keys = { entry }; @@ -3903,8 +4012,11 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr REQUIRE(bundle.filament_presets.size() == 3); CHECK(bundle.filament_presets[1] == "My PLA"); + // The aliased grown slot is re-pointed at the exact-name preset; the bare-name and the + // receiver's own preset lose. CHECK(bundle.filament_presets[2] == "Authored PLA @Vendor"); - CHECK(pub.material_replacements.empty()); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Authored PLA @Vendor"); } SECTION("a bare name outranks an exact setting_id") @@ -3921,6 +4033,7 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr entry.preset_name = "Authored PLA @Vendor"; // no library preset carries this name entry.setting_id = "SID123"; + entry.keys = { "filament_retraction_length" }; PublishedConfig pub; pub.published = true; pub.material_keys = { entry }; @@ -3931,6 +4044,8 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr REQUIRE(bundle.filament_presets.size() == 3); CHECK(bundle.filament_presets[1] == "My PLA"); CHECK(bundle.filament_presets[2] == "Authored PLA"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Authored PLA"); } SECTION("an exact setting_id outranks an exact filament_id") @@ -3949,6 +4064,7 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr entry.preset_name = "Authored PLA @Vendor"; // no library preset carries this name entry.setting_id = "SID123"; entry.filament_id = "GFA00"; + entry.keys = { "filament_retraction_length" }; PublishedConfig pub; pub.published = true; pub.material_keys = { entry }; @@ -3959,22 +4075,26 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr REQUIRE(bundle.filament_presets.size() == 3); CHECK(bundle.filament_presets[1] == "My PLA"); CHECK(bundle.filament_presets[2] == "Bbb PLA"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> 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 &mine = add_inmemory_preset(bundle.filaments, "My PETG"); + mine.config.opt_string("filament_type", 0u) = "PETG"; 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" }; + bundle.filament_presets = { "My PETG" }; entry.filament_vendor = "Generic"; // no name or id identity: the family tiers decide + entry.publish_type = true; + entry.publish_type_value = "PLA"; // the grown slot seeds "My PETG" -> the gate reads a mismatch PublishedConfig pub; pub.published = true; pub.material_keys = { entry }; @@ -3983,12 +4103,12 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr 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[1] == "My PETG"); // 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)"); + CHECK(pub.material_replacements[0] == "slot 2: My PETG -> Aaa PLA (substitute)"); } } @@ -4203,3 +4323,59 @@ TEST_CASE("Published 3MF applies duplicate entries for one slot last-wins", "[Pr CHECK(pub.skipped_keys.empty()); CHECK(pub.material_replacements.empty()); } + +// normalize_filament_type maps "PLA High Speed" onto the canonical family "PLA" (a space- +// separated modifier is dropped) but leaves dash-separated composite types like "PA-CF" intact, +// and passes through unknown types and the empty string unchanged. +TEST_CASE("normalize_filament_type strips a space modifier but keeps dash types", "[Preset][Bundle][Published]") +{ + CHECK(normalize_filament_type("PLA High Speed") == "PLA"); + CHECK(normalize_filament_type("PA-CF") == "PA-CF"); + CHECK(normalize_filament_type("PETG-CF") == "PETG-CF"); + CHECK(normalize_filament_type("PLA") == "PLA"); + CHECK(normalize_filament_type("ABC") == "ABC"); + CHECK(normalize_filament_type("") == ""); +} + +// collect_dirty_settings_keys feeds the Publish dialog's pre-check: it must be the set union of +// the dirty options across the edited print, printer and filament presets. +TEST_CASE("collect_dirty_settings_keys unions the dirty settings from all three presets", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + // The edited preset is initialised as a copy of the selected (default) preset, so a single + // edit makes exactly that option dirty. deep_diff reports scalar keys by name but per-element + // vector keys as "key#", so a vector edit surfaces as "key#0". + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.28; + bundle.filaments.get_edited_preset().config.opt("filament_type", true)->values = { "ABS" }; + bundle.printers.get_edited_preset().config.opt("nozzle_diameter", true)->values = { 0.6 }; + + const std::vector dirty = collect_dirty_settings_keys(bundle); + for (const char *key : { "layer_height", "filament_type#0", "nozzle_diameter#0" }) + CHECK(contains_key(dirty, key)); +} + +// The publish denylist and the mixed-key list are single sources of truth for the import path: +// lock their members so a silent edit to either cannot drift away from the contract the import +// and export masks rely on. +TEST_CASE("Published 3MF denylist and mixed-key sets match the import/export contract", "[Preset][Bundle][Published]") +{ + const std::set& structural = publish_structural_keys(); + // Structural / inheritance keys must never be applied onto a receiver's presets. + for (const char *key : { "printer_settings_id", "filament_settings_id", "print_settings_id", + "compatible_printers", "compatible_prints", "compatible_printers_condition", + "compatible_prints_condition", "default_filament_profile", "default_print_profile", + "inherits", "extruder_count", "printer_model", "filament_ids" }) + CHECK(structural.count(key) == 1); + // ...but a per-slot publishable material key is not structural. + CHECK(structural.count("filament_retraction_length") == 0); + CHECK(structural.count("filament_colour") == 0); + + const std::set& mixed = publish_mixed_keys(); + CHECK(mixed == std::set{ + "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" }); + // Mixed keys are project-level arrays, not material-preset keys, so none is structural. + for (const std::string &key : mixed) + CHECK(structural.count(key) == 0); +}