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());
+}