diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 755f2590e2..ce88c7a030 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -5020,7 +5020,12 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool const std::string vendor = (vendors != nullptr && !vendors->values.empty()) ? vendors->get_at(0) : std::string(); if (!entry.filament_id.empty() && candidate.filament_id == entry.filament_id) return 2; - if (normalize_filament_type(type) == entry.publish_type_value) { + // Tier 0/1 family gate: the explicit type requirement when published as + // such, otherwise the entry's own material family - so identity scoring + // also applies to entries published without a checked Type row. + const std::string required_type = !entry.publish_type_value.empty() + ? entry.publish_type_value : normalize_filament_type(entry.filament_type); + if (!required_type.empty() && normalize_filament_type(type) == required_type) { if (!entry.filament_vendor.empty() && vendor == entry.filament_vendor) return 1; return 0; @@ -5036,7 +5041,10 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool // presets first and falling back to incompatible ones only when no // compatible candidate exists... for (const PublishedMaterialEntry &entry : published_config->material_keys) { - if (entry.slot != static_cast(new_slot_idx) || !entry.publish_type || entry.publish_type_value.empty()) + // Scored for every entry with an identity (name / ids / family), + // not only when a Type requirement was checked; candidate_score's + // family tiers fall back to the entry's own filament_type. + if (entry.slot != static_cast(new_slot_idx)) continue; const std::string resolved_name = resolved_name_for(entry.slot); int best_score = -1; @@ -5073,14 +5081,32 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF grew slot " << new_slot_idx << " with " << initial_preset << " (score " << best_score << ", preset_name \"" << entry.preset_name << "\", type \"" << entry.publish_type_value << "\")"; + if (best_score >= 0 && best_score < 2 && (!entry.filament_id.empty() || !entry.filament_vendor.empty())) + published_config->material_replacements.emplace_back( + "slot " + std::to_string(new_slot_idx) + ": " + initial_preset + + " (substitute: no exact material match)"); break; } - // ...otherwise any visible preset not already used by another slot. + // ...otherwise any visible preset not already used by another slot, + // preferring the published material's own family when it is known. if (initial_preset.empty()) { + std::string slot_family; + for (const PublishedMaterialEntry &entry : published_config->material_keys) + if (entry.slot == static_cast(new_slot_idx)) { + slot_family = !entry.publish_type_value.empty() + ? entry.publish_type_value : normalize_filament_type(entry.filament_type); + break; + } for (size_t i = first_candidate; i < this->filaments.size(); ++i) { const Preset &candidate = this->filaments.preset(i); if (!candidate.is_visible || used_preset_names.count(candidate.name) != 0) continue; + if (!slot_family.empty()) { + const ConfigOptionStrings *types = candidate.config.opt("filament_type"); + const std::string cand_type = (types != nullptr && !types->values.empty()) ? types->get_at(0) : std::string(); + if (normalize_filament_type(cand_type) != slot_family) + continue; + } initial_preset = candidate.name; break; } @@ -5114,7 +5140,9 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool std::string replacement; int best_score = -1; for (const PublishedMaterialEntry &entry : published_config->material_keys) { - if (entry.slot != static_cast(slot) || !entry.publish_type || entry.publish_type_value.empty()) + // Scored for every entry with an identity, not only when a Type + // requirement was checked (same as the growth seeding above). + if (entry.slot != static_cast(slot)) continue; const std::string resolved_name = resolved_name_for(entry.slot); auto scan = [&](bool compatible_only) -> std::pair { @@ -5153,20 +5181,39 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool << entry.preset_name << "\", type \"" << entry.publish_type_value << "\")"; break; } - // ...otherwise any distinct visible preset not referenced by another slot. + // ...otherwise any distinct visible preset not referenced by another slot, + // preferring the published material's own family when it is known. if (replacement.empty()) { - for (size_t i = first_candidate; i < this->filaments.size(); ++i) { - const Preset &candidate = this->filaments.preset(i); - if (candidate.is_visible && !referenced_elsewhere(candidate.name, size_t(-1))) { - replacement = candidate.name; + std::string slot_family; + for (const PublishedMaterialEntry &entry : published_config->material_keys) + if (entry.slot == static_cast(slot)) { + slot_family = !entry.publish_type_value.empty() + ? entry.publish_type_value : normalize_filament_type(entry.filament_type); break; } + for (size_t i = first_candidate; i < this->filaments.size(); ++i) { + const Preset &candidate = this->filaments.preset(i); + if (!candidate.is_visible || referenced_elsewhere(candidate.name, size_t(-1))) + continue; + if (!slot_family.empty()) { + const ConfigOptionStrings *types = candidate.config.opt("filament_type"); + const std::string cand_type = (types != nullptr && !types->values.empty()) ? types->get_at(0) : std::string(); + if (normalize_filament_type(cand_type) != slot_family) + continue; + } + replacement = candidate.name; + break; } } if (replacement.empty()) continue; // every visible preset is referenced: aliasing is unavoidable + const std::string aliased_name = this->filament_presets[slot]; this->filament_presets[slot] = replacement; material_applied = true; + // The re-point used to be silent; surface it like the other slot changes. + published_config->material_replacements.emplace_back( + "slot " + std::to_string(slot) + ": " + aliased_name + " -> " + replacement + + " (de-aliased: shared profile)"); } // Grow the per-slot colour/type/map project vectors to the new slot count and // seed the new entries so the slots render with colours instead of blank chips @@ -5367,25 +5414,43 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool // No same-type library preset: fall back to the first available // visible preset, preferring one no other slot references, and // apply the author's full values on top of it (the dump carries - // filament_type, so the preset takes the author's type). + // filament_type, so the preset takes the author's type). A preset + // of the published material's own family is preferred overall - + // except that a referenced preset must never win just on family, + // since the dump would mutate it for every sharing slot too. std::string fallback; - for (size_t i = first_candidate; i < this->filaments.size(); ++i) { - const Preset &candidate = this->filaments.preset(i); - if (!candidate.is_visible) - continue; - if (fallback.empty()) - fallback = candidate.name; - bool referenced = false; - for (size_t s = 0; s < this->filament_presets.size(); ++s) - if (this->filament_presets[s] == candidate.name) { - referenced = true; - break; + const std::string wanted_family = !entry.publish_type_value.empty() + ? entry.publish_type_value : normalize_filament_type(entry.filament_type); + auto pick_fallback = [&](bool want_family) -> std::string { + std::string first; + for (size_t i = first_candidate; i < this->filaments.size(); ++i) { + const Preset &candidate = this->filaments.preset(i); + if (!candidate.is_visible) + continue; + if (want_family) { + if (wanted_family.empty()) + break; + const ConfigOptionStrings *cand_types = candidate.config.opt("filament_type"); + if (normalize_filament_type(cand_types != nullptr && !cand_types->values.empty() ? cand_types->get_at(0) : std::string()) != wanted_family) + continue; } - if (!referenced) { - fallback = candidate.name; - break; + bool referenced = false; + for (size_t s = 0; s < this->filament_presets.size(); ++s) + if (this->filament_presets[s] == candidate.name) { + referenced = true; + break; + } + if (!referenced) + return candidate.name; + if (first.empty()) + first = candidate.name; } - } + return first; + }; + if (!wanted_family.empty()) + fallback = pick_fallback(true); + if (fallback.empty()) + fallback = pick_fallback(false); if (!fallback.empty() && fallback != recv->name) { const std::string old_name = recv->name; this->filament_presets[slot] = fallback; diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 6909b9b335..3cf5e95ce7 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -2010,6 +2010,72 @@ 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]") +{ + 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"; + entry.filament_vendor = "Generic"; + // An unused preset sorting before everything else: an unconstrained pick would take it. + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &arbitrary = add_inmemory_preset(bundle.filaments, "Aaa PLA"); + arbitrary.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + + SECTION("an exact filament_id outranks the first unused preset") { + Preset &authored = add_inmemory_preset(bundle.filaments, "Zzz PLA"); + authored.config.opt_string("filament_type", 0u) = "PLA"; + authored.filament_id = "GFA00"; + 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] == "Zzz PLA"); + // An exact identity match is not a substitute, so nothing is reported. + CHECK(pub.material_replacements.empty()); + } + + SECTION("a family-only entry picks an unused preset of that family") { + Preset &petg = add_inmemory_preset(bundle.filaments, "Bbb PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + entry.filament_type = "PETG"; + + 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[2] == "Bbb PETG"); + } +} + // The GUI displays the edited preset, a snapshot of the selected collection preset taken at // selection time. Since the overlay mutates the collection presets in place, the load must // re-select the first slot's filament so the applied values - and slot replacements - surface @@ -2415,6 +2481,59 @@ TEST_CASE("Published 3MF gives each published slot its own preset on an aliased CHECK(pub.skipped_keys.empty()); } +// De-aliasing runs on the exported identity even without a checked Type row: the re-pointed +// slot lands on the exact published material (by filament_id) rather than an arbitrary spare, +// and the formerly silent re-point is surfaced through the replacements notification list. +TEST_CASE("Published 3MF de-aliases an aliased slot by published identity without a type requirement", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.6, 0.9 }; + + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + mine.config.opt("filament_retraction_length", true)->values = { 0.5 }; + // A spare sorting before the exact match: an unconstrained pick would take it. + Preset &spare = add_inmemory_preset(bundle.filaments, "Aaa PLA"); + spare.config.opt_string("filament_type", 0u) = "PLA"; + spare.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &match = add_inmemory_preset(bundle.filaments, "Zzz PLA"); + match.config.opt_string("filament_type", 0u) = "PLA"; + match.config.opt("filament_retraction_length", true)->values = { 0.5 }; + match.filament_id = "GFA00"; + bundle.filament_presets = { "My PLA", "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 1; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.filament_id = "GFA00"; + entry.keys = { "filament_retraction_length" }; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 2); + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[1] == "Zzz PLA"); + // The published key was written onto the re-pointed slot's own preset. + Preset *target = bundle.filaments.find_preset("Zzz PLA", false, true); + REQUIRE(target != nullptr); + CHECK(target->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0].find("(de-aliased") != std::string::npos); + CHECK(pub.skipped_keys.empty()); +} + // Printer retraction keys are published per-extruder ("#N"): a receiver with a different // extruder count still receives the in-range elements; out-of-range variants are reported as // skipped instead of corrupting the receiver's vector.