#include #include #include #include #include "libslic3r/PresetBundle.hpp" #include "libslic3r/AppConfig.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/TriangleMesh.hpp" #include "test_utils.hpp" #include #include #include using namespace Slic3r; namespace { namespace fs = boost::filesystem; // Whether a key is listed in a vector of keys (published_keys / skipped_keys). bool contains_key(const std::vector &keys, const std::string &key) { return std::find(keys.begin(), keys.end(), key) != keys.end(); } void check_double_vector(const std::vector &actual, std::initializer_list expected) { REQUIRE(actual.size() == expected.size()); size_t index = 0; for (double value : expected) REQUIRE_THAT(actual[index++], Catch::Matchers::WithinAbs(value, 1e-6)); } void write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {}) { DynamicPrintConfig config(default_config); config.option("print_settings_id", true)->value = name; config.option(BBL_JSON_KEY_INHERITS, true)->value = inherits; fs::create_directories(file.parent_path()); config.save_to_json(file.string(), name, "User", "1.0.0"); } // Write a preset json carrying a name and an "inherits" value, using the given collection's // default config so it loads back into that collection. Works for any preset type. void write_preset_with_inherits(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits) { DynamicPrintConfig config(default_config); config.option(BBL_JSON_KEY_INHERITS, true)->value = inherits; fs::create_directories(file.parent_path()); config.save_to_json(file.string(), name, "User", "1.0.0"); } // Add an in-memory preset (no file) with the given inherits value (empty => root preset). Preset &add_inmemory_preset(PresetCollection &coll, const std::string &name, const std::string &inherits = {}) { DynamicPrintConfig config(coll.default_preset().config); config.option(BBL_JSON_KEY_INHERITS, true)->value = inherits; return coll.load_preset(std::string(), name, config, /*select=*/false); } // Mark an already-loaded preset as renamed from one or more former names. void set_renamed_from(PresetCollection &coll, const std::string &preset_name, std::vector old_names) { for (auto it = coll.begin(); it != coll.end(); ++it) if (it->name == preset_name) it->renamed_from = std::move(old_names); } // A single-slot PLA file config for the published-material load tests (mirrors what the GUI // builds from a 3mf's project settings before load_config_model). DynamicPrintConfig published_pla_file_config() { 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_ids")->values = { "GFL99" }; config.option("filament_retraction_length", true)->values = { 0.9 }; return config; } // A standalone print preset collection that exposes the protected rename-map builder, so a // renamed_from scenario can be set up without the full system-profile load pipeline. // (PresetCollection is non-copyable - it holds a mutex - so it is constructed directly with // the same type/keys/defaults PresetBundle uses for its print collection.) struct RenameTestCollection : public PresetCollection { RenameTestCollection() : PresetCollection(Preset::TYPE_PRINT, Preset::print_options(), static_cast(FullPrintConfig::defaults())) {} using PresetCollection::update_map_system_profile_renamed; }; } // namespace TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]") { ScopedTemporaryDir temp_dir; PresetBundle bundle; PresetsConfigSubstitutions substitutions; write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_PRINT_NAME / "User.json", "User"); write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle"); write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed"); bundle.prints.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable); bundle.prints.load_presets((temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable); bundle.prints.load_presets((temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable); const Preset *root_user = bundle.prints.find_preset("User"); REQUIRE(root_user != nullptr); CHECK(root_user->name == "User"); CHECK_FALSE(root_user->is_from_bundle()); const Preset *local_bundle = bundle.prints.find_preset("_local/bundle-1/LocalBundle"); REQUIRE(local_bundle != nullptr); CHECK(local_bundle->name == "_local/bundle-1/LocalBundle"); CHECK(local_bundle->is_from_bundle()); const Preset *subscribed = bundle.prints.find_preset("_subscribed/remote-1/Subscribed"); REQUIRE(subscribed != nullptr); CHECK(subscribed->name == "_subscribed/remote-1/Subscribed"); CHECK(subscribed->is_from_bundle()); } TEST_CASE("Legacy bundle import without bundle metadata stays in the user preset directory", "[Preset][Identity]") { ScopedTemporaryDir temp_dir; PresetBundle bundle; PresetsConfigSubstitutions substitutions; std::vector result; int overwrite = 0; std::string file = (temp_dir.path() / "legacy-bundle" / "Imported.json").string(); const fs::path user_root = temp_dir.path() / "user"; write_print_preset(bundle.prints.default_preset().config, file, "Imported"); fs::create_directories(user_root); bundle.prints.update_user_presets_directory(user_root.string(), PRESET_PRINT_NAME); REQUIRE(bundle.import_json_presets( substitutions, file, [](std::string const &) { return 1; }, ForwardCompatibilitySubstitutionRule::Disable, overwrite, result)); const Preset *imported = bundle.prints.find_preset("Imported"); REQUIRE(imported != nullptr); CHECK(imported->name == "Imported"); CHECK(imported->bundle_id.empty()); CHECK_FALSE(imported->is_from_bundle()); // Detached user presets (no inherits) are saved in the "base" subfolder of the user preset root. CHECK(fs::equivalent(fs::path(imported->file).parent_path().parent_path(), user_root / PRESET_PRINT_NAME)); } TEST_CASE("Current vendor type tolerates missing printer model", "[Preset][Bundle]") { PresetBundle bundle; VendorProfile orca_vendor; orca_vendor.id = "ORCA"; VendorProfile::PrinterModel model; model.name = "Orca Test"; orca_vendor.models.emplace_back(model); bundle.vendors.emplace("ORCA", std::move(orca_vendor)); bundle.printers.get_edited_preset().config.erase("printer_model"); CHECK(bundle.get_current_vendor_type() == VendorType::Unknown); } TEST_CASE("A malformed entry in a vendor's preset list is counted, not thrown", "[Preset][Bundle]") { ScopedTemporaryDir dir; // A bare number where the list wants an object. An array element has no key, // so reporting one as if it did throws nlohmann's invalid_iterator - which is // not a parse_error, and escapes the catch around the vendor profile parse. std::ofstream((dir.path() / "Acme.json").string()) << R"({"version":"1.0.0","name":"Acme","process_list":[123,)" << R"({"name":"0.20mm Standard @Acme","sub_path":"process/standard.json"}]})"; fs::create_directories(dir.path() / "Acme" / "process"); std::ofstream((dir.path() / "Acme" / "process" / "standard.json").string()) << R"({"type":"process","name":"0.20mm Standard @Acme","from":"system",)" << R"("instantiation":"true","layer_height":"0.2"})"; PresetBundle bundle; size_t loaded = 0; REQUIRE_NOTHROW(loaded = bundle.load_vendor_configs_from_json( dir.path().string(), "Acme", PresetBundle::LoadSystem, ForwardCompatibilitySubstitutionRule::EnableSilent).second); CHECK(bundle.error_count() > 0); // the malformed element was counted CHECK(loaded == 1); // the well-formed one beside it still loaded } TEST_CASE("Printer extruder count tolerates missing nozzle diameter", "[Preset][Bundle]") { PresetBundle bundle; DynamicPrintConfig& config = bundle.printers.get_edited_preset().config; config.erase("nozzle_diameter"); CHECK(bundle.get_printer_extruder_count() == 1); config.set_key_value("nozzle_diameter", new ConfigOptionFloats()); CHECK(bundle.get_printer_extruder_count() == 1); config.set_key_value("nozzle_diameter", new ConfigOptionFloats({ 0.4, 0.6 })); CHECK(bundle.get_printer_extruder_count() == 2); } TEST_CASE("Selected printer uses its default or saved bed type", "[Preset][Bundle]") { PresetBundle bundle; Preset& printer = add_inmemory_preset(bundle.printers, "Test Printer"); printer.is_system = true; printer.config.option("printer_model")->value = "TEST-MODEL"; printer.config.option("printer_variant")->value = "0.4"; printer.config.option("default_bed_type")->value = "Engineering Plate"; AppConfig app_config; app_config.set("curr_bed_type", std::to_string(static_cast(btPTE))); PresetBundle::PresetPreferences preferred_selection; BedType expected_bed_type; SECTION("New printer uses its symbolic default") { expected_bed_type = btEP; preferred_selection = {"TEST-MODEL", "0.4"}; } SECTION("Re-enabled printer uses its saved selection") { expected_bed_type = btPC; preferred_selection = {"TEST-MODEL", "0.4"}; app_config.set_printer_setting("Test Printer", "curr_bed_type", std::to_string(static_cast(expected_bed_type))); } SECTION("Existing printer keeps its saved selection after presets reload") { expected_bed_type = btPCT; app_config.set("presets", PRESET_PRINTER_NAME, "Test Printer"); app_config.set_printer_setting("Test Printer", "curr_bed_type", std::to_string(static_cast(expected_bed_type))); } bundle.load_selections(app_config, preferred_selection); bundle.export_selections(app_config); CHECK(bundle.project_config.opt_enum("curr_bed_type") == expected_bed_type); CHECK(app_config.get_printer_setting("Test Printer", "curr_bed_type") == std::to_string(static_cast(expected_bed_type))); } TEST_CASE("find_preset resolves a system preset's renamed_from", "[Preset][Rename]") { RenameTestCollection coll; // "New Process" is the current preset; it was renamed from "Old Process". add_inmemory_preset(coll, "New Process"); set_renamed_from(coll, "New Process", { "Old Process" }); coll.update_map_system_profile_renamed(); // The rename map knows the old name... const std::string *renamed = coll.get_preset_name_renamed("Old Process"); REQUIRE(renamed != nullptr); CHECK(*renamed == "New Process"); // ...and plain find_preset() now follows it (the core of this PR; previously this // resolution lived only in find_preset2 and a few call sites). const Preset *resolved = coll.find_preset("Old Process"); REQUIRE(resolved != nullptr); CHECK(resolved->name == "New Process"); // A genuinely unknown name still returns null (no spurious match). CHECK(coll.find_preset("Totally Unknown") == nullptr); // A child that still inherits the OLD name resolves through the runtime walker, // which uses plain find_preset(). Preset &child = add_inmemory_preset(coll, "Child Process", "Old Process"); const Preset *parent = coll.get_preset_parent(child); REQUIRE(parent != nullptr); CHECK(parent->name == "New Process"); } TEST_CASE("find_preset resolves a preset renamed more than once", "[Preset][Rename]") { RenameTestCollection coll; // "New Process" was renamed twice, so it carries both former names in renamed_from. add_inmemory_preset(coll, "New Process"); set_renamed_from(coll, "New Process", { "Original Process", "Old Process" }); coll.update_map_system_profile_renamed(); // Each historical name resolves to the current preset. for (const char *old_name : { "Original Process", "Old Process" }) { INFO("resolving old name: " << old_name); const std::string *renamed = coll.get_preset_name_renamed(old_name); REQUIRE(renamed != nullptr); CHECK(*renamed == "New Process"); const Preset *resolved = coll.find_preset(old_name); REQUIRE(resolved != nullptr); CHECK(resolved->name == "New Process"); } // A child inheriting either former name resolves through the runtime walker. Preset &child = add_inmemory_preset(coll, "Child Process", "Original Process"); REQUIRE(coll.get_preset_parent(child) != nullptr); CHECK(coll.get_preset_parent(child)->name == "New Process"); } TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the library", "[Preset][Rename]") { PresetBundle bundle; // The OrcaFilamentLibrary replacement that removed empty " Generic" profiles map to. add_inmemory_preset(bundle.filaments, "Generic PLA @System"); // Plain lookups do NOT fuzzy-match a removed vendor profile. CHECK(bundle.filaments.find_preset("Voron Generic PLA") == nullptr); CHECK(bundle.filaments.find_preset2("Voron Generic PLA", /*auto_match=*/false) == nullptr); // With auto_match, the removed "Voron Generic PLA" resolves to "Generic PLA @System". const Preset *matched = bundle.filaments.find_preset2("Voron Generic PLA", /*auto_match=*/true); REQUIRE(matched != nullptr); CHECK(matched->name == "Generic PLA @System"); // No library preset exists for an unrelated material => still no match. CHECK(bundle.filaments.find_preset2("BrandX Generic PETG", /*auto_match=*/true) == nullptr); } TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]") { ScopedTemporaryDir temp_dir; RenameTestCollection coll; // Current parent, renamed from "Old Process". add_inmemory_preset(coll, "New Process"); set_renamed_from(coll, "New Process", { "Old Process" }); coll.update_map_system_profile_renamed(); // A user preset on disk that still inherits the OLD name. write_preset_with_inherits(coll.default_preset().config, temp_dir.path() / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process"); PresetsConfigSubstitutions substitutions; coll.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable); const Preset *child = coll.find_preset("Child"); REQUIRE(child != nullptr); // The dangling "Old Process" was rewritten to the resolved parent name at load time, // so the runtime walker (plain find_preset) can resolve the chain. CHECK(child->inherits() == "New Process"); REQUIRE(coll.get_preset_parent(*child) != nullptr); CHECK(coll.get_preset_parent(*child)->name == "New Process"); } TEST_CASE("Removed Generic parent is normalized into a loaded filament's inherits", "[Preset][Rename]") { ScopedTemporaryDir temp_dir; PresetBundle bundle; add_inmemory_preset(bundle.filaments, "Generic PLA @System"); // A user filament that still inherits a removed " Generic PLA" profile. write_preset_with_inherits(bundle.filaments.default_preset().config, temp_dir.path() / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA"); PresetsConfigSubstitutions substitutions; bundle.filaments.load_presets(temp_dir.path().string(), PRESET_FILAMENT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable); const Preset *child = bundle.filaments.find_preset("MyPLA"); REQUIRE(child != nullptr); CHECK(child->inherits() == "Generic PLA @System"); REQUIRE(bundle.filaments.get_preset_parent(*child) != nullptr); CHECK(bundle.filaments.get_preset_parent(*child)->name == "Generic PLA @System"); } namespace { // A live reference to a preset's compatible_printers / compatible_prints list. Fetches the *stored* // preset (real=true) so writes and reads hit the same object; creates the option if absent. std::vector &compatible_list(PresetCollection &coll, const std::string &preset_name, const char *field_key) { Preset *preset = coll.find_preset(preset_name, /*first_visible_if_not_found=*/false, /*real=*/true); REQUIRE(preset != nullptr); return preset->config.option(field_key, true)->values; } } // namespace TEST_CASE("Renamed printer/process names are normalized into compatible lists on load", "[Preset][Rename]") { PresetBundle bundle; // Current printer + process, each renamed from an older name. add_inmemory_preset(bundle.printers, "New Printer"); set_renamed_from(bundle.printers, "New Printer", { "Old Printer" }); add_inmemory_preset(bundle.prints, "New Process"); set_renamed_from(bundle.prints, "New Process", { "Old Process" }); // A user process still compatible with the OLD printer name. add_inmemory_preset(bundle.prints, "My Process"); compatible_list(bundle.prints, "My Process", "compatible_printers") = { "Old Printer" }; // A user filament referencing the OLD printer AND OLD process names, plus an unknown printer. add_inmemory_preset(bundle.filaments, "My Filament"); compatible_list(bundle.filaments, "My Filament", "compatible_printers") = { "Old Printer", "Unknown Printer" }; compatible_list(bundle.filaments, "My Filament", "compatible_prints") = { "Old Process" }; // Build the rename maps (done during system load in the real pipeline), then normalize. AppConfig app_config; bundle.load_installed_printers(app_config); // rebuilds every collection's rename map bundle.normalize_compatible_presets(); // The stale printer name in a process' compatible_printers is rewritten to the current name. CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector{ "New Printer" }); // The stale process name in a filament's compatible_prints is rewritten (this field has no // runtime rename fallback, so load-time normalization is the only fix). CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_prints") == std::vector{ "New Process" }); // The renamed printer is rewritten while the unknown/deleted name is preserved as-is. CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") == (std::vector{ "New Printer", "Unknown Printer" })); // Normalizing rewrites config in place without flagging the preset dirty. CHECK_FALSE(bundle.prints.find_preset("My Process", false, true)->is_dirty); // A system preset that already references the current name is left untouched (idempotent no-op). bundle.normalize_compatible_presets(); CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector{ "New Printer" }); } TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]") { PresetBundle bundle; // Current printer + process, each renamed from an older name. add_inmemory_preset(bundle.printers, "New Printer"); set_renamed_from(bundle.printers, "New Printer", { "Old Printer" }); add_inmemory_preset(bundle.prints, "New Process"); set_renamed_from(bundle.prints, "New Process", { "Old Process" }); // A *system* (vendor) filament whose own compatible lists still reference the OLD names. A vendor // profile can point at a sibling preset that was later renamed, so system presets must be // normalized too (they are skipped by neither collection walk). add_inmemory_preset(bundle.filaments, "System Filament").is_system = true; compatible_list(bundle.filaments, "System Filament", "compatible_printers") = { "Old Printer" }; compatible_list(bundle.filaments, "System Filament", "compatible_prints") = { "Old Process" }; AppConfig app_config; bundle.load_installed_printers(app_config); // build the rename maps bundle.normalize_compatible_presets(); // The stale references in the system preset are rewritten to the current names. CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") == std::vector{ "New Printer" }); CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") == std::vector{ "New Process" }); // The rewrite does not flag the system preset dirty, and is idempotent. CHECK_FALSE(bundle.filaments.find_preset("System Filament", false, true)->is_dirty); bundle.normalize_compatible_presets(); CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") == std::vector{ "New Printer" }); } TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]") { PresetBundle bundle; // A renamed SLA process, and a same-named FFF process that must NOT be picked up: resolving the // SLA material's compatible_prints against `prints` would wrongly rewrite to "Wrong FFF Process". add_inmemory_preset(bundle.sla_prints, "New SLA Process"); set_renamed_from(bundle.sla_prints, "New SLA Process", { "Old SLA Process" }); add_inmemory_preset(bundle.prints, "Wrong FFF Process"); set_renamed_from(bundle.prints, "Wrong FFF Process", { "Old SLA Process" }); add_inmemory_preset(bundle.sla_materials, "My SLA Material"); compatible_list(bundle.sla_materials, "My SLA Material", "compatible_prints") = { "Old SLA Process" }; AppConfig app_config; bundle.load_installed_printers(app_config); bundle.normalize_compatible_presets(); CHECK(compatible_list(bundle.sla_materials, "My SLA Material", "compatible_prints") == std::vector{ "New SLA Process" }); } TEST_CASE("Profile validator flags dangling and renamed preset references", "[Preset][Validate]") { PresetBundle bundle; // Current printers: a real one, and a renamed one (its old name resolves via renamed_from). add_inmemory_preset(bundle.printers, "Real Printer"); add_inmemory_preset(bundle.printers, "New Printer"); set_renamed_from(bundle.printers, "New Printer", { "Old Printer" }); // A real process, referenced from a filament's compatible_prints. add_inmemory_preset(bundle.prints, "Real Process").is_system = true; // A fully valid system filament: references only current names. add_inmemory_preset(bundle.filaments, "Good Filament").is_system = true; compatible_list(bundle.filaments, "Good Filament", "compatible_printers") = { "Real Printer" }; compatible_list(bundle.filaments, "Good Filament", "compatible_prints") = { "Real Process" }; AppConfig app_config; bundle.load_installed_printers(app_config); // build the rename maps // With only valid references, the validator is clean. CHECK_FALSE(bundle.check_preset_references()); SECTION("deleted compatible_printers is flagged") { add_inmemory_preset(bundle.filaments, "Ghost Ref Filament").is_system = true; compatible_list(bundle.filaments, "Ghost Ref Filament", "compatible_printers") = { "Ghost Printer" }; CHECK(bundle.check_preset_references()); } SECTION("renamed compatible_printers (old name) is flagged") { add_inmemory_preset(bundle.filaments, "Old Ref Filament").is_system = true; compatible_list(bundle.filaments, "Old Ref Filament", "compatible_printers") = { "Old Printer" }; CHECK(bundle.check_preset_references()); } SECTION("deleted compatible_prints is flagged") { add_inmemory_preset(bundle.filaments, "Bad Process Ref").is_system = true; compatible_list(bundle.filaments, "Bad Process Ref", "compatible_prints") = { "Ghost Process" }; CHECK(bundle.check_preset_references()); } SECTION("deleted inherits parent is flagged") { add_inmemory_preset(bundle.filaments, "Orphan Filament", "Ghost Parent").is_system = true; CHECK(bundle.check_preset_references()); } SECTION("non-system preset with a dangling reference is ignored") { add_inmemory_preset(bundle.filaments, "User Filament"); // is_system stays false compatible_list(bundle.filaments, "User Filament", "compatible_printers") = { "Ghost Printer" }; CHECK_FALSE(bundle.check_preset_references()); } } // Under a shared override key, the last preset merged into the full config overwrote the others', so an // edited slicing-pipeline override never reached Print::apply's diff and re-configuring a plugin never // re-sliced. Per-type keys make that collision impossible; guard the scoping here. TEST_CASE("Plugin capability override keys are scoped per preset type", "[Preset][Plugin]") { // Pin the key names: presets and 3mf files store them verbatim, so a rename is a format change. CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINT) == std::string("print_plugin_config_overrides")); CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINTER) == std::string("printer_plugin_config_overrides")); CHECK(Preset::plugin_overrides_key(Preset::TYPE_FILAMENT) == std::string("filament_plugin_config_overrides")); // ...and each key lives on exactly its own preset type's option list, so no two ever share a slot. const std::pair*> scopes[] = { {Preset::TYPE_PRINT, &Preset::print_options()}, {Preset::TYPE_PRINTER, &Preset::printer_options()}, {Preset::TYPE_FILAMENT, &Preset::filament_options()}, }; for (const auto &owner : scopes) for (const auto &scoped : scopes) { const std::string key = Preset::plugin_overrides_key(scoped.first); CAPTURE(owner.first, key); CHECK(contains(*owner.second, key) == (owner.first == scoped.first)); } } namespace { // A standalone filament collection that exposes the protected library masking builder, so the Orca // Filament Library scenario can be set up without the full system-profile load pipeline. struct LibraryFilamentTestCollection : public PresetCollection { LibraryFilamentTestCollection() : PresetCollection(Preset::TYPE_FILAMENT, Preset::filament_options(), static_cast(FullPrintConfig::defaults())) {} using PresetCollection::update_library_profile_excluded_from; }; } // namespace TEST_CASE("Missing app config is accepted as default CLI state", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; AppConfig app_config; app_config.set_loading_path((dir.path() / "missing.conf").string()); CHECK(app_config.load_if_exists().empty()); } TEST_CASE("Read-only user preset loading does not create or delete files", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; PresetBundle bundle; PresetsConfigSubstitutions substitutions; const fs::path missing_root = dir.path() / "missing-user"; bundle.prints.load_presets(missing_root.string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::EnableSilent, nullptr, PresetOrigin(), true); CHECK_FALSE(fs::exists(missing_root / PRESET_PRINT_NAME)); const fs::path malformed = dir.path() / "existing-user" / PRESET_PRINT_NAME / "malformed.json"; fs::create_directories(malformed.parent_path()); std::ofstream(malformed.string()) << "{not-json"; bundle.prints.load_presets((dir.path() / "existing-user").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::EnableSilent, nullptr, PresetOrigin(), true); CHECK(fs::exists(malformed)); } TEST_CASE("Typeless preset resolution probes loaded FFF collections", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path source_file = dir.path() / "typeless-process.json"; std::ofstream(source_file.string()) << R"({"name":"Typeless Process","from":"User"})"; PresetBundle bundle; Preset &process = add_inmemory_preset(bundle.prints, "Typeless Process"); process.file = source_file.string(); process.config.option("travel_speed", true)->values = {321.0}; DynamicPrintConfig raw; Preset::Type resolved_type = Preset::TYPE_INVALID; std::string error; REQUIRE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); CHECK(error.empty()); CHECK(resolved_type == Preset::TYPE_PRINT); REQUIRE(raw.option("travel_speed")->values.size() == 1); CHECK_THAT(raw.option("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6)); } TEST_CASE("Typeless preset resolution preserves duplicate identity ambiguity", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path source_file = dir.path() / "duplicate-process.json"; std::ofstream(source_file.string()) << "{}"; PresetBundle bundle; add_inmemory_preset(bundle.prints, "First Process Identity").file = source_file.string(); add_inmemory_preset(bundle.prints, "Second Process Identity").file = source_file.string(); DynamicPrintConfig raw; Preset::Type resolved_type = Preset::TYPE_INVALID; std::string error; CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); CHECK(error == "Preset identity is ambiguous"); CHECK(resolved_type == Preset::TYPE_INVALID); } TEST_CASE("Typeless preset resolution rejects cross-type ambiguity", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path source_file = dir.path() / "ambiguous.json"; std::ofstream(source_file.string()) << "{}"; PresetBundle bundle; add_inmemory_preset(bundle.prints, "Process Identity").file = source_file.string(); add_inmemory_preset(bundle.filaments, "Filament Identity").file = source_file.string(); DynamicPrintConfig raw; Preset::Type resolved_type = Preset::TYPE_INVALID; std::string error; CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); CHECK(error == "Preset type is ambiguous"); CHECK(resolved_type == Preset::TYPE_INVALID); } TEST_CASE("Typeless preset resolution rejects a missing type candidate", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path source_file = dir.path() / "unknown.json"; std::ofstream(source_file.string()) << "{}"; PresetBundle bundle; DynamicPrintConfig raw; Preset::Type resolved_type = Preset::TYPE_INVALID; std::string error; CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); CHECK(error == "Preset type could not be resolved"); CHECK(resolved_type == Preset::TYPE_INVALID); } TEST_CASE("Exact file resolution rejects multiple preset identities", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path source_file = dir.path() / "duplicate.json"; std::ofstream(source_file.string()) << "{}"; PresetBundle bundle; Preset &first = add_inmemory_preset(bundle.prints, "First Identity"); first.file = source_file.string(); Preset &second = add_inmemory_preset(bundle.prints, "Second Identity"); second.file = source_file.string(); DynamicPrintConfig raw; raw.option(BBL_JSON_KEY_INHERITS, true)->value = "Parent"; std::string error; CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); CHECK(error == "Preset identity is ambiguous"); } TEST_CASE("System preset resolution returns the canonical vendor configuration", "[Preset][Bundle][Regression]") { ScopedTemporaryDir source_dir; PresetBundle bundle; VendorProfile vendor("VendorB"); vendor.name = "Vendor B"; auto [vendor_it, inserted] = bundle.vendors.emplace(vendor.id, std::move(vendor)); REQUIRE(inserted); Preset &resolved = add_inmemory_preset(bundle.prints, "Vendor B Process", "fdm_process_common"); resolved.is_system = true; resolved.vendor = &vendor_it->second; resolved.file = (source_dir.path() / "vendor-b-process.json").string(); std::ofstream(resolved.file) << "{}"; resolved.config.option("travel_speed", true)->values = {321.0}; resolved.config.option("wall_loops", true)->value = 2; DynamicPrintConfig raw; raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; raw.option("wall_loops", true)->value = 5; std::string error; REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, resolved.file, ForwardCompatibilitySubstitutionRule::EnableSilent, error)); CHECK(error.empty()); REQUIRE(raw.option("travel_speed")->values.size() == 1); CHECK_THAT(raw.option("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6)); CHECK(raw.option("wall_loops")->value == 2); } TEST_CASE("Manifest-backed preset resolution loads the source vendor tree", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path vendor_dir = dir.path() / "Acme"; const fs::path child_file = vendor_dir / "process" / "nested" / "child.json"; std::ofstream((dir.path() / "Acme.json").string()) << R"({"version":"1.0.0","name":"Acme","process_list":[)" << R"({"name":"fdm_process_common","sub_path":"process/base.json"},)" << R"({"name":"Acme Process","sub_path":"process/nested/child.json"}]})"; fs::create_directories(child_file.parent_path()); std::ofstream((vendor_dir / "process" / "base.json").string()) << R"({"type":"process","name":"fdm_process_common","from":"system",)" << R"("instantiation":"false","travel_speed":["321"]})"; std::ofstream(child_file.string()) << R"({"type":"process","name":"Acme Process","from":"system",)" << R"("instantiation":"true","inherits":"fdm_process_common","wall_loops":"5"})"; DynamicPrintConfig raw; raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; raw.option("wall_loops", true)->value = 5; PresetBundle bundle; std::string error; REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error)); CHECK(error.empty()); REQUIRE(raw.option("travel_speed")->values.size() == 1); CHECK_THAT(raw.option("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6)); CHECK(raw.option("wall_loops")->value == 5); } TEST_CASE("Manifest-backed resolution is scoped to the explicit source root", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; auto write_vendor = [&](const std::string &root_name, double travel_speed) { const fs::path root = dir.path() / root_name; const fs::path child_file = root / "Acme" / "process" / "child.json"; fs::create_directories(child_file.parent_path()); std::ofstream((root / "Acme.json").string()) << R"({"version":"1.0.0","name":"Acme","process_list":[)" << R"({"name":"fdm_process_common","sub_path":"process/base.json"},)" << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; std::ofstream((root / "Acme" / "process" / "base.json").string()) << R"({"type":"process","name":"fdm_process_common","from":"system",)" << R"("instantiation":"false","travel_speed":[")" << travel_speed << R"("]})"; std::ofstream(child_file.string()) << R"({"type":"process","name":"Acme Process","from":"system",)" << R"("instantiation":"true","inherits":"fdm_process_common"})"; return child_file; }; const fs::path source_a = write_vendor("root-a", 111.0); const fs::path source_b = write_vendor("root-b", 222.0); REQUIRE(fs::exists(source_a)); DynamicPrintConfig raw; raw.option(BBL_JSON_KEY_INHERITS, true)->value = "synthetic-parent-marker"; PresetBundle bundle; std::string error; REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_b.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error)); REQUIRE(raw.option("travel_speed")->values.size() == 1); CHECK_THAT(raw.option("travel_speed")->values.front(), Catch::Matchers::WithinAbs(222.0, 1e-6)); } TEST_CASE("Exact-only resolution rejects an unconfigured manifest-backed file", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path source_file = dir.path() / "Acme" / "process" / "child.json"; fs::create_directories(source_file.parent_path()); std::ofstream((dir.path() / "Acme.json").string()) << R"({"version":"1.0.0","name":"Acme","process_list":[)" << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; std::ofstream(source_file.string()) << R"({"type":"process","name":"Acme Process","from":"system",)" << R"("instantiation":"true","layer_height":"0.2"})"; DynamicPrintConfig raw; raw.option(BBL_JSON_KEY_INHERITS, true)->value = "Some Parent"; PresetBundle bundle; std::string error; CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); CHECK(error == "Preset was not found in the loaded bundle"); } TEST_CASE("Vendor filament resolution uses the shared Orca library base", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path library_dir = dir.path() / PresetBundle::ORCA_FILAMENT_LIBRARY; const fs::path vendor_dir = dir.path() / "Acme"; const fs::path child_file = vendor_dir / "filament" / "nested" / "petg.json"; std::ofstream((dir.path() / (std::string(PresetBundle::ORCA_FILAMENT_LIBRARY) + ".json")).string()) << R"({"version":"1.0.0","name":"OrcaFilamentLibrary","filament_list":[)" << R"({"name":"fdm_filament_pet","sub_path":"filament/pet.json","filament_id":"GFL99"}]})"; fs::create_directories(library_dir / "filament"); std::ofstream((library_dir / "filament" / "pet.json").string()) << R"({"type":"filament","name":"fdm_filament_pet","from":"system",)" << R"("filament_id":"GFL99","instantiation":"false",)" << R"("filament_type":["PETG"],"filament_density":["1.27"]})"; std::ofstream((dir.path() / "Acme.json").string()) << R"({"version":"1.0.0","name":"Acme","filament_list":[)" << R"({"name":"Acme PETG","sub_path":"filament/nested/petg.json","filament_id":"GFA00"}]})"; fs::create_directories(child_file.parent_path()); std::ofstream(child_file.string()) << R"({"type":"filament","name":"Acme PETG","from":"system",)" << R"("filament_id":"GFA00","instantiation":"true","inherits":"fdm_filament_pet"})"; DynamicPrintConfig raw; raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_pet"; PresetBundle bundle; std::string error; REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, child_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error)); CHECK(error.empty()); CHECK(raw.opt_string("filament_type", 0u) == "PETG"); REQUIRE(raw.option("filament_density")->values.size() == 1); CHECK_THAT(raw.option("filament_density")->values.front(), Catch::Matchers::WithinAbs(1.27, 1e-6)); } TEST_CASE("Manifest-backed resolution rejects a missing parent", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path child_file = dir.path() / "Acme" / "process" / "child.json"; std::ofstream((dir.path() / "Acme.json").string()) << R"({"version":"1.0.0","name":"Acme","process_list":[)" << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; fs::create_directories(child_file.parent_path()); std::ofstream(child_file.string()) << R"({"type":"process","name":"Acme Process","from":"system",)" << R"("instantiation":"true","inherits":"Missing Parent","layer_height":"0.2"})"; DynamicPrintConfig raw; raw.option(BBL_JSON_KEY_INHERITS, true)->value = "Missing Parent"; PresetBundle bundle; std::string error; CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error)); CHECK_FALSE(error.empty()); } TEST_CASE("Manifest-backed resolution rejects a vendor load with malformed entries", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path child_file = dir.path() / "Acme" / "process" / "child.json"; std::ofstream((dir.path() / "Acme.json").string()) << R"({"version":"1.0.0","name":"Acme","process_list":[123,)" << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; fs::create_directories(child_file.parent_path()); std::ofstream(child_file.string()) << R"({"type":"process","name":"Acme Process","from":"system",)" << R"("instantiation":"true","layer_height":"0.2"})"; DynamicPrintConfig raw; raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; PresetBundle bundle; std::string error; CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error)); CHECK_FALSE(error.empty()); } TEST_CASE("Manifest-backed resolution rejects files absent from the vendor manifest", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path listed_file = dir.path() / "Acme" / "process" / "listed.json"; const fs::path unlisted_file = dir.path() / "Acme" / "process" / "unlisted.json"; std::ofstream((dir.path() / "Acme.json").string()) << R"({"version":"1.0.0","name":"Acme","process_list":[)" << R"({"name":"Listed Process","sub_path":"process/listed.json"}]})"; fs::create_directories(listed_file.parent_path()); std::ofstream(listed_file.string()) << R"({"type":"process","name":"Listed Process","from":"system",)" << R"("instantiation":"true","layer_height":"0.2"})"; std::ofstream(unlisted_file.string()) << R"({"type":"process","name":"Unlisted Process","from":"system",)" << R"("instantiation":"true","inherits":"fdm_process_common"})"; DynamicPrintConfig raw; raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; PresetBundle bundle; std::string error; CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, unlisted_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error)); CHECK(error == "Source file is not an instantiated preset in its vendor manifest"); } TEST_CASE("Manifest-backed resolution rejects a mismatched preset type", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path process_file = dir.path() / "Acme" / "process" / "child.json"; std::ofstream((dir.path() / "Acme.json").string()) << R"({"version":"1.0.0","name":"Acme","process_list":[)" << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; fs::create_directories(process_file.parent_path()); std::ofstream(process_file.string()) << R"({"type":"process","name":"Acme Process","from":"system",)" << R"("instantiation":"true","layer_height":"0.2"})"; DynamicPrintConfig raw; raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_common"; PresetBundle bundle; std::string error; CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, process_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error)); CHECK(error == "Source file is not an instantiated preset in its vendor manifest"); } TEST_CASE("Resolution terminates when no vendor manifest exists", "[Preset][Bundle][Regression]") { ScopedTemporaryDir dir; const fs::path detached_file = dir.path() / "detached.json"; std::ofstream(detached_file.string()) << "{}"; DynamicPrintConfig raw; raw.option(BBL_JSON_KEY_INHERITS, true)->value = "Missing Parent"; PresetBundle bundle; std::string error; CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, detached_file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, error)); CHECK(error == "Preset was not found in the loaded bundle"); } // Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic // library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible // with that printer and the plater combo box lists the shared alias twice. TEST_CASE("A printer specific filament supersedes the generic library filament with the same alias", "[Preset][Bundle]") { LibraryFilamentTestCollection filaments; PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(), static_cast(FullPrintConfig::defaults())); // The masking keys off the vendor name, which VendorProfile's constructor does not derive from the id. VendorProfile library(PresetBundle::ORCA_FILAMENT_LIBRARY); VendorProfile vendor("Vendor"); library.name = PresetBundle::ORCA_FILAMENT_LIBRARY; vendor.name = "Vendor"; auto add_filament = [&filaments](const VendorProfile &owner, const std::string &name, std::vector compatible_printers) { Preset &preset = add_inmemory_preset(filaments, name); preset.alias = "Generic ABS"; preset.vendor = &owner; preset.config.option("compatible_printers", true)->values = std::move(compatible_printers); }; add_filament(library, "Generic ABS @System", {}); add_filament(library, "Generic ABS @Printer A", { "Printer A" }); add_filament(vendor, "Generic ABS @Printer B", { "Printer B" }); filaments.update_library_profile_excluded_from(); const Preset *generic = filaments.find_preset("Generic ABS @System"); REQUIRE(generic != nullptr); CHECK(generic->m_excluded_from.count("Printer A") == 1); CHECK(generic->m_excluded_from.count("Printer B") == 1); CHECK(generic->m_excluded_from.size() == 2); // A printer specific profile names printers, so it is never the one being hidden - not even by itself. const Preset *specific = filaments.find_preset("Generic ABS @Printer A"); REQUIRE(specific != nullptr); CHECK(specific->m_excluded_from.empty()); // ...and the generic profile really drops out of the compatible set on the printer it is hidden from. add_inmemory_preset(printers, "Printer A"); add_inmemory_preset(printers, "Printer C"); const Preset *printer_a = printers.find_preset("Printer A"); const Preset *printer_c = printers.find_preset("Printer C"); REQUIRE(printer_a != nullptr); REQUIRE(printer_c != nullptr); const PresetWithVendorProfile generic_lib(*generic, &library); CHECK_FALSE(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_a, nullptr))); CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr))); } namespace { // One system printer plus the filament presets a machine facing dialog has to choose between: // an Orca Filament Library generic with no compatible_printers, a same alias vendor filament // that names the printer, a library filament with no vendor twin, and a vendor filament that // belongs to a different printer. struct MachineFilaments { PresetBundle bundle; VendorProfile library{PresetBundle::ORCA_FILAMENT_LIBRARY}; VendorProfile vendor{"Vendor"}; MachineFilaments() { // VendorProfile's constructor takes an id; the library rule keys off the name. library.name = PresetBundle::ORCA_FILAMENT_LIBRARY; vendor.name = "Vendor"; Preset &printer = add_inmemory_preset(bundle.printers, "Printer A 0.4 nozzle"); printer.is_system = true; printer.vendor = &vendor; printer.config.option("printer_model", true)->value = "Printer A"; add_filament(library, "Generic ABS @System", "Generic ABS", {}); add_filament(vendor, "Generic ABS @Printer A", "Generic ABS", { "Printer A 0.4 nozzle" }); add_filament(library, "FilAr ABS @System", "FilAr ABS", {}); add_filament(vendor, "Vendor PLA @Printer B", "Vendor PLA", { "Printer B 0.4 nozzle" }); // update_library_profile_excluded_from() is protected and has its own test above; record // the exclusion it derives from the same alias vendor filament. Preset *shadowed = bundle.filaments.find_preset("Generic ABS @System"); REQUIRE(shadowed != nullptr); shadowed->m_excluded_from.insert("Printer A 0.4 nozzle"); } void add_filament(const VendorProfile &owner, const std::string &name, const std::string &alias, std::vector compatible_printers) { Preset &preset = add_inmemory_preset(bundle.filaments, name); preset.is_system = true; preset.alias = alias; preset.vendor = &owner; compatible_list(bundle.filaments, name, "compatible_printers") = std::move(compatible_printers); } bool offers(const std::string &preset_name, bool include_user_presets = false) { const std::vector offered = bundle.get_filament_presets_for_machine("Printer A", "0.4", include_user_presets); return std::any_of(offered.begin(), offered.end(), [&preset_name](const Preset *p) { return p->name == preset_name; }); } }; } // namespace TEST_CASE("Filaments offered for a machine follow the app's compatibility rule", "[Preset][Bundle]") { MachineFilaments f; SECTION("a library filament with no compatible_printers is offered") { CHECK(f.offers("FilAr ABS @System")); } SECTION("a same alias vendor filament shadows the library generic") { CHECK(f.offers("Generic ABS @Printer A")); CHECK_FALSE(f.offers("Generic ABS @System")); } SECTION("a filament naming a different printer is not offered") { CHECK_FALSE(f.offers("Vendor PLA @Printer B")); } SECTION("a user filament is offered only when the printer supports user presets") { add_inmemory_preset(f.bundle.filaments, "My PLA"); CHECK_FALSE(f.offers("My PLA", /*include_user_presets=*/false)); CHECK(f.offers("My PLA", /*include_user_presets=*/true)); } } namespace { const char *kMixedKeys[] = { "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", }; } // namespace // Mixed-color filament metadata lives in project_config as parallel per-filament arrays. // set_num_filaments() is the single place that grows them alongside filament_colour; if it // misses them, creating a mixed slot writes past the end of the short arrays. TEST_CASE("set_num_filaments keeps mixed-color arrays in step with the filament count", "[Preset][Bundle][FilamentMixer]") { auto mixed_array_size = [](const DynamicPrintConfig &cfg, const std::string &key) -> size_t { if (const auto *b = cfg.option(key)) return b->values.size(); if (const auto *s = cfg.option(key)) return s->values.size(); return size_t(-1); // key missing entirely }; PresetBundle bundle; const unsigned int n = GENERATE(2u, 4u, 8u); bundle.set_num_filaments(n, std::string("#FF0000")); REQUIRE(bundle.project_config.option("filament_colour")->values.size() == n); for (const char *key : kMixedKeys) { DYNAMIC_SECTION("grown: " << key) { CHECK(mixed_array_size(bundle.project_config, key) == n); } } SECTION("shrinking keeps them in step too") { bundle.set_num_filaments(1, std::string("#00FF00")); REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 1); for (const char *key : kMixedKeys) CHECK(mixed_array_size(bundle.project_config, key) == 1); } } // A mix is described by 1-based indices into the project's filament list, which Orca rebuilds // from the selected printer's snapshot (filament_%02u / filament_colors) at startup and on every // printer selection. Held anywhere but that same per-printer snapshot, the mixed arrays end up // indexing a filament list they were never saved against. TEST_CASE("Mixed-color filament metadata is snapshotted per printer, with its filament list", "[Preset][Bundle][FilamentMixer]") { PresetBundle bundle; // export_selections skips the built-in "Default Printer" placeholder entirely. add_inmemory_preset(bundle.printers, "Test Printer"); bundle.printers.select_preset_by_name("Test Printer", true); bundle.set_num_filaments(2u, std::string("#FF0000")); bundle.project_config.option("filament_is_mixed")->values = { false, true }; bundle.project_config.option("filament_mixed_components")->values = { "", "1,2" }; bundle.project_config.option("filament_mixed_sublayer_ratios")->values = { "", "0.5,0.5" }; AppConfig app_config; bundle.export_selections(app_config); const std::string printer_name = bundle.printers.get_selected_preset_name(); for (const char *key : kMixedKeys) { DYNAMIC_SECTION("per printer, not global: " << key) { CHECK(app_config.has_printer_setting(printer_name, key)); CHECK_FALSE(app_config.has("presets", key)); } } SECTION("with the encoding load_selections reads back") { CHECK(app_config.get_printer_setting(printer_name, "filament_is_mixed") == "0,1"); CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_components") == "|1,2"); CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_sublayer_ratios") == "|0.5,0.5"); } } // The gradient curve is the one mixed array whose values contain '|' themselves — it separates the // control points — so it cannot be '|'-joined into the app config like its siblings without a // multi-point curve being split across filament slots on the way back in. TEST_CASE("A multi-point gradient curve survives the app-config snapshot", "[Preset][Bundle][FilamentMixer]") { const std::vector curves = { "", "", "0,0|0.5,0.3|1,1" }; PresetBundle bundle; add_inmemory_preset(bundle.printers, "Test Printer"); bundle.printers.select_preset_by_name("Test Printer", true); bundle.set_num_filaments(3u, std::string("#FF0000")); bundle.project_config.option("filament_mixed_gradient_curve")->values = curves; AppConfig app_config; bundle.export_selections(app_config); // Decoding the stored form returns the three slots intact, curve delimiters and all. A plain // '|' join would decode as five slots here instead of three. std::vector decoded; REQUIRE(unescape_strings_cstyle( app_config.get_printer_setting(bundle.printers.get_selected_preset_name(), "filament_mixed_gradient_curve"), decoded)); CHECK(decoded == curves); } // A multi-tool printer sizes the filament list from its nozzle count. Mixed-color slots are extra // virtual filaments at the tail of that list with no nozzle of their own, so the count has to // allow for them: sizing to the nozzle count alone drops the project's mixes and strips every // painted facet above the new count. TEST_CASE("Sizing the filament list to a multi-tool nozzle count keeps mixed slots", "[Preset][Bundle][FilamentMixer]") { // The 5-slot layout of a 4-tool project carrying one mix of filaments 2 and 3. const size_t nozzle_count = 4; PresetBundle bundle; bundle.set_num_filaments(5u, std::string("#FF0000")); bundle.project_config.option("filament_is_mixed")->values = { false, false, false, false, true }; bundle.project_config.option("filament_mixed_components")->values = { "", "", "", "", "2,3" }; REQUIRE(bundle.num_mixed_filaments() == 1); SECTION("nozzle count plus the mixed slots preserves the mix") { bundle.set_num_filaments(nozzle_count + bundle.num_mixed_filaments(), std::string("#00FF00")); CHECK(bundle.filament_presets.size() == 5); CHECK(bundle.num_mixed_filaments() == 1); CHECK(bundle.is_mixed_filament(4)); CHECK(bundle.project_config.option("filament_mixed_components")->values[4] == "2,3"); } SECTION("the nozzle count alone is what truncated it away") { bundle.set_num_filaments(nozzle_count, std::string("#00FF00")); CHECK(bundle.filament_presets.size() == nozzle_count); CHECK(bundle.num_mixed_filaments() == 0); } } // A "published" 3MF keeps the user's currently-selected presets and overlays only the // author-selected process keys onto the edited preset (mirrors the GUI load path: normalize // before load_config_model, then the overlay in load_config_file_config). TEST_CASE("Published 3MF overlays only the author-selected process keys onto the edited preset", "[Preset][Bundle][Published]") { // The file config the GUI builds from a .3mf's project settings. auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); // The loader derives the filament count from filament_colour and throws when it is // empty; a 3mf always carries it. config.opt("filament_colour")->values = { "#FF0000" }; config.opt_float("layer_height") = 0.28; // process scalar config.opt("wiping_volumes_extruders")->values = { 140., 150. }; // matching-size vector config.opt("post_process")->values = { "script-a", "script-b" }; // mismatched vector config.opt("nozzle_temperature")->values = { 220 }; // filament key (not applied anywhere) // Structural (denylisted) key: must be silently ignored. full_print_config() omits the // *_settings_id keys, so create one explicitly. config.opt_string("print_settings_id", true) = "file process"; config.opt("flush_multiplier")->values = { 2., 2. }; // must NOT cross over config.opt("wipe_tower_x")->values = { 100. }; // plate geometry, does cross over config.opt("wipe_tower_rotation_angle")->value = 45.; // published-only plate geometry, crosses over config.option("curr_bed_type")->setInt(BedType::btPC); // must NOT cross over return config; }; const std::vector published_keys = { "layer_height", "wiping_volumes_extruders", "post_process", "nozzle_temperature", "print_settings_id" }; PresetBundle bundle; const std::string pre_load_name = bundle.prints.get_edited_preset().name; const size_t pre_load_size = bundle.prints.size(); // The edited presets are the overlay targets: recognizable pre-load values. bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values = { 10., 20. }; bundle.prints.get_edited_preset().config.opt("post_process")->values = { "existing-script" }; bundle.prints.get_edited_preset().config.opt_string("print_settings_id") = "user process"; // Capture the ctor-seeded project_config values so the assertions below check the load // leaves them untouched rather than hardcoding the defaults. const std::vector seed_filament_colour = bundle.project_config.opt("filament_colour")->values; const std::vector seed_flush_multiplier = bundle.project_config.opt("flush_multiplier")->values; const int seed_bed_type = bundle.project_config.option("curr_bed_type")->getInt(); DynamicPrintConfig config = make_file_config(); // The GUI normalizes the config before load; mirror that so only the production path runs. Preset::normalize(config); PublishedConfig pub; pub.published = true; pub.published_keys = published_keys; bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // a) Process scalar overlaid; matching-size vector applied, mismatched one lands in // skipped_keys; applied keys are not reported. CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001)); check_double_vector(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values, { 140., 150. }); CHECK(bundle.prints.get_edited_preset().config.opt("post_process")->values == std::vector{ "existing-script" }); CHECK(contains_key(pub.skipped_keys, "post_process")); CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders")); // b) A filament key is never applied anywhere and is reported as skipped. CHECK(bundle.prints.get_edited_preset().config.option("nozzle_temperature") == nullptr); CHECK(contains_key(pub.skipped_keys, "nozzle_temperature")); // c) A structural key is silently ignored: neither applied nor reported as skipped. CHECK(bundle.prints.get_edited_preset().config.opt_string("print_settings_id") == "user process"); CHECK_FALSE(contains_key(pub.skipped_keys, "print_settings_id")); // d) Only plate/bed geometry crosses in published mode: filament/purge data and bed type // stay at the ctor seeds. CHECK(bundle.project_config.opt("filament_colour")->values == seed_filament_colour); CHECK(bundle.project_config.opt("flush_multiplier")->values == seed_flush_multiplier); CHECK(bundle.project_config.option("curr_bed_type")->getInt() == seed_bed_type); check_double_vector(bundle.project_config.opt("wipe_tower_x")->values, { 100. }); CHECK_THAT(bundle.project_config.opt("wipe_tower_rotation_angle")->value, Catch::Matchers::WithinAbs(45., 0.000001)); // e) The published path keeps the user's currently-selected presets: same preset, same size. CHECK(bundle.prints.get_edited_preset().name == pre_load_name); CHECK(bundle.prints.size() == pre_load_size); // f) Non-published control: the overlay is disabled, the file's presets are imported // instead, and no skipped_keys are produced. PresetBundle control_bundle; const size_t control_pre_size = control_bundle.prints.size(); PublishedConfig control_pub; control_pub.published = false; control_pub.published_keys = published_keys; DynamicPrintConfig control_config = make_file_config(); Preset::normalize(control_config); control_bundle.load_config_model("test.3mf", std::move(control_config), Semver(), &control_pub); CHECK(control_pub.skipped_keys.empty()); CHECK(control_bundle.prints.size() > control_pre_size); // The file's layer_height reached the edited preset via the normal import, not the overlay. CHECK_THAT(control_bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001)); } // The published printer overlay is restricted to the publishable retraction/z-hop allowlist: // matching-size vectors apply, mismatched vectors are reported as skipped, and any other // printer-class key (e.g. machine_start_gcode) is contract-excluded (never applied, never // reported). TEST_CASE("Published 3MF overlays only the allowlisted retraction and z-hop keys onto the edited printer preset", "[Preset][Bundle][Published]") { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); config.opt("filament_colour")->values = { "#FF0000" }; Preset::normalize(config); config.opt("retraction_length")->values = { 1.4 }; // matching size (1 extruder) config.opt("retraction_speed")->values = { 45., 55. }; // size 2: mismatched config.opt_string("machine_start_gcode") = "G28 ; from file"; // outside the allowlist PresetBundle bundle; bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; // A recognizable non-default value: the skipped mismatch below must leave it untouched // (asserting the default instead would silently test PrintConfig's retraction_speed). bundle.printers.get_edited_preset().config.opt("retraction_speed")->values = { 33. }; bundle.printers.get_edited_preset().config.opt_string("machine_start_gcode") = "G28 ; user"; PublishedConfig pub; pub.published = true; pub.published_keys = { "retraction_length", "retraction_speed", "machine_start_gcode" }; bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // Matching-size retraction vector applied; mismatched vector reported as skipped and the // receiver's own value survives. check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 1.4 }); check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_speed")->values, { 33. }); CHECK(contains_key(pub.skipped_keys, "retraction_speed")); // Contract-excluded printer key: silently ignored, absent from skipped_keys. CHECK(bundle.printers.get_edited_preset().config.opt_string("machine_start_gcode") == "G28 ; user"); CHECK_FALSE(contains_key(pub.skipped_keys, "machine_start_gcode")); } // A published 3MF carries per-slot material keys; on load they are applied positionally to the // receiver's slot N (a key-only entry has no type gate), written onto the slot's stored preset // in place. TEST_CASE("Published 3MF applies positional material keys onto the receiver's material presets", "[Preset][Bundle][Published]") { auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); // Two filament slots; filament_diameter drives the normalized per-slot vector sizes. config.opt("filament_diameter")->values = { 1.75, 1.75 }; // Keep the multi-extruder consistency validation happy for a 2-slot config. 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", "PETG" }; config.opt("filament_vendor")->values = { "Generic", "Generic" }; config.opt("filament_ids")->values = { "GFL99", "GFT99" }; // Author per-slot retraction values. These are per-filament override keys that are not // members of the static PrintRegionConfig, so full_print_config() omits them and they // must be created explicitly (as nullable, matching the real 3MF project config). config.option("filament_retraction_length", true)->values = { 0.9, 1.2 }; config.option("filament_z_hop", true)->values = { 0.2, 0.3 }; return config; }; PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.filament_id = "GFL99"; pla.config.opt_string("filament_type", 0u) = "PLA"; pla.config.opt_string("filament_vendor", 0u) = "Generic"; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; pla.config.opt("filament_settings_id")->values = { "receiver-pla" }; Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); petg.filament_id = "GFT99"; petg.config.opt_string("filament_type", 0u) = "PETG"; petg.config.opt_string("filament_vendor", 0u) = "Generic"; petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; petg.config.opt("filament_z_hop", true)->values = { 0.1 }; bundle.filament_presets = { "My PLA", "My PETG" }; PublishedMaterialEntry pla_entry; pla_entry.filament_id = "GFL99"; pla_entry.slot = 0; // the author's PLA slot pla_entry.keys = { "filament_retraction_length", "filament_settings_id" }; PublishedMaterialEntry petg_entry; petg_entry.filament_id = "GFT99"; petg_entry.slot = 1; // the author's PETG slot petg_entry.keys = { "filament_retraction_length", "filament_z_hop" }; // A slot-less entry (no slot field, only possible in hand-crafted files): silently skipped. PublishedMaterialEntry noslot_entry; noslot_entry.filament_type = "ABS"; noslot_entry.keys = { "filament_retraction_length" }; PublishedConfig pub; pub.published = true; pub.material_keys = { pla_entry, petg_entry, noslot_entry }; DynamicPrintConfig config = make_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // The author's slot values are written onto the receiver's stored presets in place. check_double_vector(bundle.filaments.find_preset("My PLA")->config.opt("filament_retraction_length")->values, { 0.9 }); check_double_vector(bundle.filaments.find_preset("My PETG")->config.opt("filament_retraction_length")->values, { 1.2 }); check_double_vector(bundle.filaments.find_preset("My PETG")->config.opt("filament_z_hop")->values, { 0.3 }); // Structural keys inside a material entry are silently ignored: the receiver's own // filament_settings_id is untouched and nothing is reported for it. CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_settings_id")->values == std::vector{ "receiver-pla" }); CHECK_FALSE(contains_key(pub.skipped_keys, "material:GFL99 (filament_settings_id)")); // Everything applied; the slot-less entry produced no skipped entry. CHECK(pub.skipped_keys.empty()); } // A "full publish" slot serializes the whole filament. On load the slot always receives a // standalone detached copy of the author's material - created even when the receiver's own // material matches the published type - and no receiver library preset is ever mutated. TEST_CASE("Published 3MF full-published slots are imported as standalone detached copies", "[Preset][Bundle][Published]") { auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); // Two author slots; filament_diameter drives the normalized per-slot vector sizes. 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", "PETG" }; config.opt("filament_vendor")->values = { "Generic", "Generic" }; config.opt("filament_ids")->values = { "GFL99", "GFT99" }; config.option("filament_retraction_length", true)->values = { 0.9, 1.2 }; return config; }; // The full dump of slot 0, publishing the whole filament as type "ABS". auto make_full_abs_entry = [] { PublishedMaterialEntry entry; entry.slot = 0; entry.full = true; entry.publish_type = true; entry.publish_type_value = "ABS"; entry.full_keys = { "filament_retraction_length" }; return entry; }; SECTION("type match still creates a detached copy instead of mutating the receiver material") { PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; bundle.filament_presets = { "My PLA", "My PLA" }; PublishedMaterialEntry full = make_full_abs_entry(); full.publish_type_value = "PLA"; // author requires PLA, receiver slot is PLA PublishedConfig pub; pub.published = true; pub.material_keys = { full }; DynamicPrintConfig config = make_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // The type matches, but the import still detaches: the slot lands on a fresh copy // named from the published type (no identity fields in this entry), carrying the // author's values; the receiver's own material is untouched. CHECK(bundle.filament_presets[0] == "PLA"); Preset *copy = bundle.filaments.find_preset("PLA", false, true); REQUIRE(copy != nullptr); check_double_vector(copy->config.opt("filament_retraction_length")->values, { 0.9 }); check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); CHECK(pub.skipped_keys.empty()); REQUIRE(pub.material_replacements.size() == 1); CHECK(pub.material_replacements[0] == "slot 0: My PLA -> PLA"); } SECTION("type mismatch also detaches: a fresh same-type copy replaces the slot") { PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; Preset &abs = add_inmemory_preset(bundle.filaments, "My ABS"); abs.config.opt_string("filament_type", 0u) = "ABS"; abs.config.opt("filament_retraction_length", true)->values = { 0.3 }; bundle.filament_presets = { "My PLA" }; PublishedConfig pub; pub.published = true; pub.material_keys = { make_full_abs_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() == 1); // No substitution search runs: a brand-new copy named after the published type is // created and both pre-existing presets stay exactly as they were. CHECK(bundle.filament_presets[0] == "ABS"); Preset *copy = bundle.filaments.find_preset("ABS", false, true); REQUIRE(copy != nullptr); check_double_vector(copy->config.opt("filament_retraction_length")->values, { 0.9 }); check_double_vector(bundle.filaments.find_preset("My ABS", false, true)->config.opt("filament_retraction_length")->values, { 0.3 }); check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); CHECK(pub.skipped_keys.empty()); REQUIRE(pub.material_replacements.size() == 1); CHECK(pub.material_replacements[0] == "slot 0: My PLA -> ABS"); } SECTION("the author's identity rides on the copy when the type has no library match") { PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; Preset &other = add_inmemory_preset(bundle.filaments, "Other PLA"); other.config.opt_string("filament_type", 0u) = "PLA"; other.config.opt("filament_retraction_length", true)->values = { 0.7 }; bundle.filament_presets = { "My PLA" }; PublishedMaterialEntry full = make_full_abs_entry(); // The dump carries the identity too, so the created copy takes the author's type // and vendor instead of the baseline clone's. full.full_keys = { "filament_retraction_length", "filament_type", "filament_vendor" }; PublishedConfig pub; pub.published = true; pub.material_keys = { full }; DynamicPrintConfig config = make_file_config(); // The author's slot 0 really is ABS. config.opt("filament_type")->values = { "ABS", "PETG" }; Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // No ABS preset needs to exist in the library: the copy carries the author's values, // type and vendor included. Neither receiver preset was touched. CHECK(bundle.filament_presets[0] == "ABS"); Preset *copy = bundle.filaments.find_preset("ABS", false, true); REQUIRE(copy != nullptr); check_double_vector(copy->config.opt("filament_retraction_length")->values, { 0.9 }); CHECK(copy->config.opt_string("filament_type", 0u) == "ABS"); CHECK(copy->config.opt_string("filament_vendor", 0u) == "Generic"); check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt_string("filament_type", 0u) == "PLA"); check_double_vector(bundle.filaments.find_preset("Other PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.7 }); CHECK(pub.skipped_keys.empty()); REQUIRE(pub.material_replacements.size() == 1); CHECK(pub.material_replacements[0] == "slot 0: My PLA -> ABS"); } } // The author's preset name travels in the file and names the created standalone copy (variant // tail stripped), regardless of what the receiver's library holds: an exact-name library preset // is never reused nor mutated. TEST_CASE("Published 3MF imports a full material under the author's stripped name", "[Preset][Bundle][Published]") { auto make_file_config = [] { 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_ids")->values = { "OGFL99" }; config.option("filament_retraction_length", true)->values = { 0.9 }; return config; }; auto add_pla = [](PresetBundle &bundle, const char *name, const char *id, const char *vendor, const char *setting_id) { Preset &preset = add_inmemory_preset(bundle.filaments, name); preset.filament_id = id; preset.setting_id = setting_id; preset.config.opt_string("filament_type", 0u) = "PLA"; preset.config.opt_string("filament_vendor", 0u) = vendor; preset.config.opt("filament_retraction_length", true)->values = { 0.5 }; return &preset; }; SECTION("an exact-name library preset exists: a detached copy is created beside it") { 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 }; add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz"); add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw"); bundle.filament_presets = { "My PETG" }; PublishedMaterialEntry entry; entry.slot = 0; entry.full = true; entry.publish_type = true; entry.publish_type_value = "PLA"; entry.filament_id = "OGFL99"; entry.filament_vendor = "Generic"; entry.setting_id = "RcBNzytWgwRrwXXz"; entry.preset_name = "Generic PLA @System"; entry.full_keys = { "filament_retraction_length" }; 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); // The copy is named after the stripped author name; the receiver's exact-name preset // keeps its own values. CHECK(bundle.filament_presets[0] == "Generic PLA"); check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values, { 0.6 }); REQUIRE(pub.material_replacements.size() == 1); CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA"); CHECK(pub.skipped_keys.empty()); } SECTION("only the name is present (broken/stale ids): the copy is still created") { 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 }; add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz"); add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw"); 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 @System"; entry.full_keys = { "filament_retraction_length" }; 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); CHECK(bundle.filament_presets[0] == "Generic PLA"); check_double_vector(bundle.filaments.find_preset("Bambu PLA Basic @System", 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"); CHECK(pub.skipped_keys.empty()); } SECTION("grown slot (author slot 1) receives its own detached copy") { auto two_slot_config = [] { 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", "#FFFF00" }; config.opt("filament_type")->values = { "PLA", "PLA" }; config.opt("filament_vendor")->values = { "Generic", "Generic" }; config.opt("filament_ids")->values = { "OGFL99", "OGFL99" }; config.option("filament_retraction_length", true)->values = { 0.9, 0.8 }; return config; }; 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 }; add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz"); add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw"); bundle.filament_presets = { "My PETG" }; PublishedMaterialEntry entry; entry.slot = 1; entry.full = true; entry.publish_type = true; entry.publish_type_value = "PLA"; entry.preset_name = "Generic PLA @System"; entry.full_keys = { "filament_retraction_length" }; PublishedConfig pub; pub.published = true; pub.material_keys = { entry }; DynamicPrintConfig config = two_slot_config(); 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 PETG"); // 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: My PETG -> Generic PLA"); CHECK(pub.skipped_keys.empty()); } } // The stripped author name can collide with an existing library preset ("Generic PLA"): the // created copy must uniquify with the "(Published)" suffix rather than overwrite, reuse or // mutate any of the receiver's own presets. TEST_CASE("Published 3MF uniquifies an imported full material name on collision", "[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 }; // A legacy bundle preset literally named "Generic PLA" - collides with the stripped name. Preset &bare = add_inmemory_preset(bundle.filaments, "Generic PLA"); bare.config.opt_string("filament_type", 0u) = "PLA"; bare.config.opt_string("filament_vendor", 0u) = "Generic"; bare.config.opt("filament_retraction_length", true)->values = { 0.5 }; // The author's exact preset. Preset &qidi = add_inmemory_preset(bundle.filaments, "Generic PLA @Qidi Q2 0.4 nozzle"); qidi.config.opt_string("filament_type", 0u) = "PLA"; qidi.config.opt_string("filament_vendor", 0u) = "Generic"; qidi.config.opt("filament_retraction_length", true)->values = { 0.5 }; // The Orca library preset. Preset &sys = add_inmemory_preset(bundle.filaments, "Generic PLA @System"); sys.config.opt_string("filament_type", 0u) = "PLA"; sys.config.opt_string("filament_vendor", 0u) = "Generic"; sys.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); // The copy lands beside the collision, suffixed; every pre-existing preset keeps its own // values. CHECK(bundle.filament_presets[0] == "Generic PLA (Published)"); check_double_vector(bundle.filaments.find_preset("Generic PLA (Published)", 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 @Qidi Q2 0.4 nozzle", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); 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 0: My PETG -> Generic PLA (Published)"); CHECK(pub.skipped_keys.empty()); } // The overlay writes onto the edited layer only when that layer survives the load (slot 0's // preset still matches the edited preset). When the user views a non-first slot's material and // the published entry targets that slot, the final re-select would destroy the edited layer - // so the values must land on the stored preset instead and survive. TEST_CASE("Published 3MF writes to the stored preset when the edited layer is re-selected away", "[Preset][Bundle][Published]") { // Two-slot author config: slot 1 carries the published retraction value. auto make_file_config = [] { 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 = { "PETG", "PLA" }; config.opt("filament_vendor")->values = { "Generic", "Generic" }; config.opt("filament_ids")->values = { "GFT99", "GFL99" }; config.option("filament_retraction_length", true)->values = { 0.6, 0.9 }; return config; }; 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 &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; pla.config.opt("filament_colour", true)->values = { "#123456" }; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; bundle.filament_presets = { "My PETG", "My PLA" }; // The user is viewing slot 1's material. REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); PublishedMaterialEntry entry; entry.slot = 1; entry.publish_type = true; entry.publish_type_value = "PLA"; entry.publish_color = true; entry.color = "#ABCDEF"; entry.keys = { "filament_retraction_length" }; 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); // The published values survived on the stored preset (the edited layer was re-selected to // slot 0's material and must not have been the only copy). Preset *stored = bundle.filaments.find_preset("My PLA", false, true); REQUIRE(stored != nullptr); CHECK(stored->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); check_double_vector(stored->config.opt("filament_retraction_length")->values, { 0.9 }); // The load re-selected slot 0's material, mirroring a normal project load. CHECK(bundle.filaments.get_edited_preset().name == "My PETG"); // Selecting the slot's material afterwards surfaces the applied values. REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); CHECK(bundle.filaments.get_edited_preset().config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); check_double_vector(bundle.filaments.get_edited_preset().config.opt("filament_retraction_length")->values, { 0.9 }); CHECK(pub.skipped_keys.empty()); } // The created copy is named after the author's preset with the "@variant" tail stripped; // trailing whitespace left behind by the truncation must be trimmed away, and names without // a tail pass through unchanged. TEST_CASE("publish_material_base_name strips the variant tail from a published preset name", "[Preset][Bundle][Published]") { CHECK(publish_material_base_name("Generic PLA @System") == "Generic PLA"); CHECK(publish_material_base_name("Generic PLA @Qidi Q2 0.4 nozzle") == "Generic PLA"); // Truncation at '@' leaves the space before the tail; it must not survive. CHECK(publish_material_base_name("Generic PLA @System") == "Generic PLA"); CHECK(publish_material_base_name("Voron Generic PLA") == "Voron Generic PLA"); CHECK(publish_material_base_name("") == ""); // A tail-only name strips to nothing; the caller falls back to identity fields. CHECK(publish_material_base_name("@System").empty()); } // A full-published material arrives as a brand-new standalone preset: parentless, visible, // project-embedded ("Preset Inside Project"), carrying the author's values and colour - and // never touching any of the receiver's own presets. TEST_CASE("Published 3MF imports a full material as a detached project-embedded preset", "[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 &spare = add_inmemory_preset(bundle.filaments, "Spare PLA"); spare.config.opt_string("filament_type", 0u) = "PLA"; spare.config.opt("filament_retraction_length", true)->values = { 0.4 }; bundle.filament_presets = { "My PETG" }; PublishedMaterialEntry entry; entry.slot = 0; entry.full = true; entry.publish_type = true; entry.publish_type_value = "PLA"; entry.publish_color = true; entry.color = "#ABCDEF"; entry.filament_id = "AFL01"; entry.setting_id = "Sid000111222"; entry.preset_name = "Author PLA @Vendor"; 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); // The slot lands on the freshly created copy, named after the stripped author name. CHECK(bundle.filament_presets[0] == "Author PLA"); Preset *copy = bundle.filaments.find_preset("Author PLA", false, true); REQUIRE(copy != nullptr); // Detached + project-embedded contract. CHECK(copy->is_project_embedded); CHECK(copy->inherits().empty()); CHECK(copy->setting_id.empty()); CHECK(copy->vendor == nullptr); CHECK_FALSE(copy->is_system); CHECK_FALSE(copy->is_default); CHECK_FALSE(copy->is_external); CHECK(copy->is_visible); CHECK(copy->filament_id == "AFL01"); CHECK(copy->config.opt("filament_settings_id")->values == std::vector{ "Author PLA" }); // The published values and colour live on the copy. check_double_vector(copy->config.opt("filament_retraction_length")->values, { 0.9 }); CHECK(copy->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); // Universally compatible: no printer/print restrictions survive the import. CHECK(copy->config.opt("compatible_printers")->values.empty()); CHECK(copy->config.opt("compatible_prints")->values.empty()); CHECK(copy->config.opt("compatible_printers_condition")->value.empty()); CHECK(copy->config.opt("compatible_prints_condition")->value.empty()); // Nothing pre-existing was touched. check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values, { 0.6 }); check_double_vector(bundle.filaments.find_preset("Spare PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.4 }); CHECK(pub.skipped_keys.empty()); REQUIRE(pub.material_replacements.size() == 1); CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Author PLA"); } // Compatibility restrictions riding on the receiver's baseline preset must not leak onto the // imported copy: a detached full material is usable with every printer and print profile. TEST_CASE("Published 3MF clears printer restrictions on the imported full material", "[Preset][Bundle][Published]") { PresetBundle bundle; Preset &restricted = add_inmemory_preset(bundle.filaments, "Restricted PLA"); restricted.config.opt_string("filament_type", 0u) = "PLA"; restricted.config.opt("filament_retraction_length", true)->values = { 0.5 }; restricted.config.set_key_value("compatible_printers", new ConfigOptionStrings({ "Unrelated Printer" })); restricted.config.set_key_value("compatible_prints", new ConfigOptionStrings({ "Unrelated Print" })); restricted.config.option("compatible_printers_condition", true)->value = "printer_settings_id==\"Nope\""; restricted.config.option("compatible_prints_condition", true)->value = "print_settings_id==\"Nope\""; bundle.filament_presets = { "Restricted PLA" }; PublishedMaterialEntry entry; entry.slot = 0; entry.full = true; entry.publish_type = true; entry.publish_type_value = "PLA"; 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); // The copy (named from the published type; no identity fields) has no restrictions left. CHECK(bundle.filament_presets[0] == "PLA"); Preset *copy = bundle.filaments.find_preset("PLA", false, true); REQUIRE(copy != nullptr); CHECK(copy->config.opt("compatible_printers")->values.empty()); CHECK(copy->config.opt("compatible_prints")->values.empty()); CHECK(copy->config.opt("compatible_printers_condition")->value.empty()); CHECK(copy->config.opt("compatible_prints_condition")->value.empty()); // The receiver's own restricted preset keeps its restrictions. const Preset *original = bundle.filaments.find_preset("Restricted PLA", false, true); REQUIRE(original != nullptr); CHECK(original->config.opt("compatible_printers")->values == std::vector{ "Unrelated Printer" }); CHECK(original->config.opt("compatible_printers_condition")->value == "printer_settings_id==\"Nope\""); CHECK(pub.skipped_keys.empty()); } // Identical Full materials (same setting_id + preset_name identity) share one created // instance: an author who pointed several slots at one material gets one standalone copy, // and the first entry's slot values win. TEST_CASE("Published 3MF shares one imported copy between identical full slots", "[Preset][Bundle][Published]") { auto make_file_config = [] { 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 = { "AFL01", "AFL01" }; config.option("filament_retraction_length", true)->values = { 0.9, 0.8 }; return config; }; auto make_entry = [](int slot) { PublishedMaterialEntry entry; entry.slot = slot; entry.full = true; entry.publish_type = true; entry.publish_type_value = "PLA"; entry.filament_id = "AFL01"; entry.setting_id = "Sid000111222"; entry.preset_name = "Author PLA @Vendor"; entry.full_keys = { "filament_retraction_length" }; return entry; }; 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 &other = add_inmemory_preset(bundle.filaments, "Other PETG"); other.config.opt_string("filament_type", 0u) = "PETG"; other.config.opt("filament_retraction_length", true)->values = { 0.65 }; bundle.filament_presets = { "My PETG", "Other PETG" }; PublishedConfig pub; pub.published = true; pub.material_keys = { make_entry(0), make_entry(1) }; DynamicPrintConfig config = make_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // Both slots point at the single shared copy; no second "(Published)" instance exists. REQUIRE(bundle.filament_presets.size() == 2); CHECK(bundle.filament_presets[0] == "Author PLA"); CHECK(bundle.filament_presets[1] == "Author PLA"); CHECK(bundle.filaments.find_preset("Author PLA", false, true) != nullptr); CHECK(bundle.filaments.find_preset("Author PLA (Published)", false, true) == nullptr); // The first entry's slot values won. check_double_vector(bundle.filaments.find_preset("Author PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); // Both slots reported, same target; originals untouched. REQUIRE(pub.material_replacements.size() == 2); CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Author PLA"); CHECK(pub.material_replacements[1] == "slot 1: Other PETG -> Author PLA"); check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values, { 0.6 }); check_double_vector(bundle.filaments.find_preset("Other PETG", false, true)->config.opt("filament_retraction_length")->values, { 0.65 }); CHECK(pub.skipped_keys.empty()); } // Without a preset name the copy falls back to the stable material id; fully anonymous // hand-crafted entries never share instances between slots (their dedup key is slot-scoped). TEST_CASE("Published 3MF names unidentified full materials from their fallback fields", "[Preset][Bundle][Published]") { auto make_file_config = [] { 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", "GFT99" }; config.option("filament_retraction_length", true)->values = { 0.9, 1.2 }; return config; }; SECTION("empty preset_name falls back to the filament_id") { PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; bundle.filament_presets = { "My PLA" }; PublishedMaterialEntry entry; entry.slot = 0; entry.full = true; entry.publish_type = true; entry.publish_type_value = "PLA"; entry.filament_id = "AFL01"; 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] == "AFL01"); CHECK(bundle.filaments.find_preset("AFL01", false, true) != nullptr); CHECK(pub.skipped_keys.empty()); } SECTION("fully anonymous entries get slot-scoped copies") { PresetBundle bundle; Preset &first = add_inmemory_preset(bundle.filaments, "First PLA"); first.config.opt_string("filament_type", 0u) = "PLA"; first.config.opt("filament_retraction_length", true)->values = { 0.5 }; Preset &second = add_inmemory_preset(bundle.filaments, "Second PLA"); second.config.opt_string("filament_type", 0u) = "PLA"; second.config.opt("filament_retraction_length", true)->values = { 0.55 }; bundle.filament_presets = { "First PLA", "Second PLA" }; PublishedMaterialEntry entry0; entry0.slot = 0; entry0.full = true; entry0.publish_type = true; entry0.publish_type_value = "ABS"; // the only naming field present entry0.full_keys = { "filament_retraction_length" }; PublishedMaterialEntry entry1 = entry0; entry1.slot = 1; PublishedConfig pub; pub.published = true; pub.material_keys = { entry0, entry1 }; DynamicPrintConfig config = make_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // No shared identity: each slot gets its own uniquified copy. CHECK(bundle.filament_presets[0] == "ABS"); CHECK(bundle.filament_presets[1] == "ABS (Published)"); CHECK(bundle.filaments.find_preset("ABS", false, true) != nullptr); CHECK(bundle.filaments.find_preset("ABS (Published)", false, true) != nullptr); CHECK(pub.skipped_keys.empty()); } } // Within-load dedup does not span loads: importing the same published file again into the same // session creates a second, uniquified copy instead of mutating or reusing the first. TEST_CASE("Re-importing a published full material uniquifies the second copy", "[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 }; bundle.filament_presets = { "My PETG" }; auto make_entry = [] { PublishedMaterialEntry entry; entry.slot = 0; entry.full = true; entry.publish_type = true; entry.publish_type_value = "PLA"; entry.filament_id = "AFL01"; entry.setting_id = "Sid000111222"; entry.preset_name = "Author PLA @Vendor"; entry.full_keys = { "filament_retraction_length" }; return entry; }; for (int round = 0; round < 2; ++round) { PublishedConfig pub; pub.published = true; pub.material_keys = { make_entry() }; DynamicPrintConfig config = published_pla_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); if (round == 0) { CHECK(bundle.filament_presets[0] == "Author PLA"); CHECK(bundle.filaments.find_preset("Author PLA (Published)", false, true) == nullptr); } else { // The second import uniquifies beside the first instead of touching it. CHECK(bundle.filament_presets[0] == "Author PLA (Published)"); check_double_vector(bundle.filaments.find_preset("Author PLA (Published)", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); check_double_vector(bundle.filaments.find_preset("Author PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); REQUIRE(pub.material_replacements.size() == 1); CHECK(pub.material_replacements[0] == "slot 0: Author PLA -> Author PLA (Published)"); } CHECK(pub.skipped_keys.empty()); } } // A partially-published slot can carry a curated type and/or colour. The colour is applied // regardless of the type match; a type mismatch with no same-type replacement keeps the // receiver's material and reports the slot's keys as skipped. The receiver's slot count grows // only as far as the highest slot with published content. TEST_CASE("Published 3MF partial slots apply colour and gate keys by the published type", "[Preset][Bundle][Published]") { auto make_file_config = [] { 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", "PETG" }; config.opt("filament_vendor")->values = { "Generic", "Generic" }; config.opt("filament_ids")->values = { "GFL99", "GFT99" }; config.option("filament_retraction_length", true)->values = { 0.9, 1.2 }; return config; }; SECTION("matching type applies the keys and the colour") { PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; pla.config.opt("filament_colour", true)->values = { "#123456" }; bundle.filament_presets = { "My PLA" }; PublishedMaterialEntry entry; entry.slot = 0; entry.publish_type = true; entry.publish_type_value = "PLA"; entry.publish_color = true; entry.color = "#ABCDEF"; entry.keys = { "filament_retraction_length" }; 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); // Type matched: keys and colour applied onto the receiver's preset in place. Preset *pla_preset = bundle.filaments.find_preset("My PLA", false, true); REQUIRE(pla_preset != nullptr); check_double_vector(pla_preset->config.opt("filament_retraction_length")->values, { 0.9 }); CHECK(pla_preset->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); CHECK(pub.skipped_keys.empty()); CHECK(pub.material_replacements.empty()); } SECTION("type mismatch without a replacement keeps the material and skips the keys") { PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; pla.config.opt("filament_colour", true)->values = { "#123456" }; bundle.filament_presets = { "My PLA" }; PublishedMaterialEntry entry; entry.slot = 0; entry.publish_type = true; entry.publish_type_value = "ABS"; // no ABS in the receiver library entry.publish_color = true; entry.color = "#ABCDEF"; entry.keys = { "filament_retraction_length" }; 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); // The slot keeps the receiver's material: the colour applies in place, the keys are // skipped. CHECK(bundle.filament_presets[0] == "My PLA"); Preset *pla_preset = bundle.filaments.find_preset("My PLA", false, true); REQUIRE(pla_preset != nullptr); check_double_vector(pla_preset->config.opt("filament_retraction_length")->values, { 0.5 }); CHECK(pla_preset->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); CHECK(contains_key(pub.skipped_keys, "material:ABS (filament_retraction_length)")); CHECK(pub.material_replacements.empty()); } SECTION("receiver slot count grows to fit the highest published slot and assigns matching type preset") { PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; 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"; petg.config.opt("filament_retraction_length", true)->values = { 0.8 }; // The receiver has a single slot; the file carries two, only slot 1 is published. bundle.filament_presets = { "My PLA" }; PublishedMaterialEntry entry; entry.slot = 1; entry.publish_type = true; entry.publish_type_value = "PETG"; entry.keys = { "filament_retraction_length" }; 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); // The slot list was grown so author slot 1 has a material (the PETG preset), which // then receives the author's values in place. REQUIRE(bundle.filament_presets.size() == 2); CHECK(bundle.filament_presets[1] == "My PETG"); check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values, { 1.2 }); check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); } } // The receiver's slot list grows only as far as the highest published slot: a file whose author // published nothing (or only a low slot) must not pull filler materials into the receiver's // setup, and the receiver never grows to the file's count. TEST_CASE("Published 3MF grows the receiver's slots only as far as the published slots", "[Preset][Bundle][Published]") { auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); // Four author slots (a 4-filament model). 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; }; auto add_pla_preset = [](PresetBundle &bundle) { Preset &preset = add_inmemory_preset(bundle.filaments, "My PLA"); preset.config.opt_string("filament_type", 0u) = "PLA"; preset.config.opt("filament_colour", true)->values = { "#123456" }; return &preset; }; auto make_color_entry = [](int slot) { PublishedMaterialEntry entry; entry.slot = slot; entry.publish_color = true; entry.color = "#ABCDEF"; return entry; }; // A file whose author published nothing for any slot: the receiver's setup is untouched. { PresetBundle bundle; add_pla_preset(bundle); bundle.filament_presets = { "My PLA" }; PublishedConfig pub; pub.published = true; // no material entries at all DynamicPrintConfig config = make_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); CHECK(bundle.filament_presets.size() == 1); CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_colour")->values == std::vector{ "#123456" }); CHECK(pub.skipped_keys.empty()); } // Only slot 0 published: a single-slot receiver keeps its single slot; the file's other // three slots pull nothing in. { PresetBundle bundle; add_pla_preset(bundle); bundle.filament_presets = { "My PLA" }; PublishedConfig pub; pub.published = true; pub.material_keys = { make_color_entry(0) }; DynamicPrintConfig config = make_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); CHECK(bundle.filament_presets.size() == 1); // The published colour lands on the slot's preset in place. CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); } // Slot 3 published: the receiver grows to 4 so the published slot exists. Unpublished // filler slots repeat the receiver's last preset ("Add one filament" behaviour). { PresetBundle bundle; add_pla_preset(bundle); bundle.filament_presets = { "My PLA" }; PublishedConfig pub; pub.published = true; pub.material_keys = { make_color_entry(3) }; DynamicPrintConfig config = make_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 4); CHECK(bundle.filament_presets[1] == "My PLA"); CHECK(bundle.filament_presets[2] == "My PLA"); // Only "My PLA" exists in the library, so the published slot keeps the aliasing and the // colour is written onto the shared preset (every slot references it). CHECK(bundle.filament_presets[3] == "My PLA"); CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); // The project-level per-slot vectors were grown and seeded: fillers take their preset's // colour, the published slot its published colour. CHECK(bundle.project_config.opt("filament_colour")->values.size() == 4); CHECK(bundle.project_config.opt("filament_colour")->values[1] == "#123456"); CHECK(bundle.project_config.opt("filament_colour")->values[3] == "#ABCDEF"); CHECK(bundle.project_config.opt("filament_multi_colour")->values.size() == 4); CHECK(bundle.project_config.opt("filament_colour_type")->values.size() == 4); CHECK(bundle.project_config.opt("filament_map")->values.size() == 4); CHECK(bundle.project_config.opt("flush_volumes_matrix")->values.size() == 16); } // Slots 0 and 2 published: the receiver grows to 3, never to the file's 4. { PresetBundle bundle; add_pla_preset(bundle); bundle.filament_presets = { "My PLA" }; PublishedConfig pub; pub.published = true; pub.material_keys = { make_color_entry(0), make_color_entry(2) }; 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"); } // A receiver with more slots than the file's filament count keeps its setup: neither the // preset list nor the project-level vectors are shrunk to the file's smaller size. { PresetBundle bundle; add_pla_preset(bundle); bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; // Distinct project colours make a shrink observable. bundle.project_config.opt("filament_colour")->values = { "#111111", "#222222", "#333333" }; bundle.project_config.opt("filament_multi_colour")->values = { "#111111", "#222222", "#333333" }; PublishedConfig pub; pub.published = true; pub.material_keys = { make_color_entry(0) }; // highest published slot: 0 DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); // A one-filament file: num_filaments (1) is below the receiver's slot count (3). 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_ids")->values = { "GFL99" }; Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 3); // No shrink: all three project entries survive, with slot 0 synced to the published // colour at its unshifted index and slots 1-2 untouched. CHECK(bundle.project_config.opt("filament_colour")->values == std::vector{ "#ABCDEF", "#222222", "#333333" }); CHECK(bundle.project_config.opt("filament_multi_colour")->values == std::vector{ "#ABCDEF", "#222222", "#333333" }); CHECK(bundle.project_config.opt("filament_map")->values.size() == 3); // The published colour still reached slot 0's preset in place. CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); } } // 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 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(); 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; }; // Receiver with its own material plus one more library preset; author publishes only slot 4 // (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"; mine.config.opt("filament_colour", true)->values = { "#123456" }; Preset &other = add_inmemory_preset(bundle.filaments, "Other PLA"); other.config.opt_string("filament_type", 0u) = "PLA"; other.config.opt("filament_colour", true)->values = { "#654321" }; bundle.filament_presets = { "My PLA" }; PublishedMaterialEntry entry; entry.slot = 3; entry.publish_color = true; entry.color = "#ABCDEF"; 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() == 4); // 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 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"); CHECK(bundle.project_config.opt("filament_colour")->values[3] == "#ABCDEF"); CHECK(bundle.project_config.opt("filament_multi_colour")->values.size() == 4); CHECK(bundle.project_config.opt("filament_colour_type")->values.size() == 4); CHECK(bundle.project_config.opt("filament_map")->values.size() == 4); } // 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(); 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"; 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"); 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"); // 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") { 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); // 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"); } } // 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 // in the GUI. TEST_CASE("Published 3MF refreshes the edited preset so the applied material values surface", "[Preset][Bundle][Published]") { auto make_file_config = [] { 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_ids")->values = { "GFL99" }; config.option("filament_retraction_length", true)->values = { 0.9 }; return config; }; auto make_entry = [] { PublishedMaterialEntry entry; entry.slot = 0; entry.publish_type = true; entry.publish_type_value = "PLA"; entry.publish_color = true; entry.color = "#ABCDEF"; entry.keys = { "filament_retraction_length" }; return entry; }; SECTION("the edited preset carries the applied colour and keys") { 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" }; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; bundle.filament_presets = { "My PLA" }; // Mirror the GUI: the displayed preset is the collection's edited preset. REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); PublishedConfig pub; pub.published = true; pub.material_keys = { make_entry() }; DynamicPrintConfig config = make_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); const Preset &edited = bundle.filaments.get_edited_preset(); // The slot references the edited preset, so the overlay lands on the edited layer: // visible as a modification while the stored preset stays untouched. CHECK(edited.name == "My PLA"); CHECK(edited.config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); check_double_vector(edited.config.opt("filament_retraction_length")->values, { 0.9 }); CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#123456" }); check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); // The overlay is a visible, revertible modification of the edited preset. CHECK(bundle.filaments.current_is_dirty()); CHECK(pub.skipped_keys.empty()); } SECTION("a slot replacement is reflected in the edited preset") { 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" }; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; Preset &abs = add_inmemory_preset(bundle.filaments, "My ABS"); abs.config.opt_string("filament_type", 0u) = "ABS"; abs.config.opt("filament_retraction_length", true)->values = { 0.3 }; bundle.filament_presets = { "My PLA" }; REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); PublishedMaterialEntry entry = make_entry(); entry.publish_type_value = "ABS"; // mismatch: replaced by the library's ABS 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[0] == "My ABS"); // The edited preset now displays the replacement with the author's values on top. const Preset &edited = bundle.filaments.get_edited_preset(); CHECK(edited.name == "My ABS"); CHECK(edited.config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); check_double_vector(edited.config.opt("filament_retraction_length")->values, { 0.9 }); } } // The overlay lands on the edited layer when the slot references the collection's edited // preset, so the user's unsaved in-memory edits on it survive a published load (only the // published keys are touched) and the change shows as a visible, revertible modification. TEST_CASE("Published 3MF preserves unsaved edits on the edited filament 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" }; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; pla.config.opt("filament_z_hop", true)->values = { 0.1 }; bundle.filament_presets = { "My PLA" }; REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); // The user has unsaved in-memory edits on the preset being shown. bundle.filaments.get_edited_preset().config.opt("filament_z_hop")->values = { 0.7 }; PublishedMaterialEntry entry; entry.slot = 0; entry.publish_type = true; entry.publish_type_value = "PLA"; entry.publish_color = true; entry.color = "#ABCDEF"; entry.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); // Published values land on the edited layer... const Preset &edited = bundle.filaments.get_edited_preset(); CHECK(edited.name == "My PLA"); CHECK(edited.config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); check_double_vector(edited.config.opt("filament_retraction_length")->values, { 0.9 }); // ...the user's unsaved edit on a non-published key survives... check_double_vector(edited.config.opt("filament_z_hop")->values, { 0.7 }); // ...and the stored preset is untouched. Preset *stored = bundle.filaments.find_preset("My PLA", false, true); REQUIRE(stored != nullptr); CHECK(stored->config.opt("filament_colour")->values == std::vector{ "#123456" }); check_double_vector(stored->config.opt("filament_retraction_length")->values, { 0.5 }); check_double_vector(stored->config.opt("filament_z_hop")->values, { 0.1 }); // The overlay is a visible, revertible modification of the edited preset. CHECK(bundle.filaments.current_is_dirty()); CHECK(pub.skipped_keys.empty()); CHECK(pub.material_replacements.empty()); } // The published overlay must validate '#' variant indices: an out-of-range index is reported as // skipped and must NOT resize/corrupt the receiver's vector, and a variant suffix on a scalar // key is rejected instead of silently no-op'd. TEST_CASE("Published 3MF rejects out-of-range vector variants and variant-suffixed scalar keys", "[Preset][Bundle][Published]") { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); config.opt("filament_colour")->values = { "#FF0000" }; // Vector key, size 2 (matches the receiver's resized vector); distinct values make the // applied element observable. config.opt("wiping_volumes_extruders")->values = { 140., 150. }; config.opt_float("layer_height") = 0.28; Preset::normalize(config); PresetBundle bundle; bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values = { 10., 20. }; bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; PublishedConfig pub; pub.published = true; pub.published_keys = { "wiping_volumes_extruders#5", "wiping_volumes_extruders#1", "wiping_volumes_extruders#abc", "layer_height#0" }; bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // In-range variant applied element-wise; the out-of-range one did not resize the vector. check_double_vector(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values, { 10., 150. }); CHECK(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values.size() == 2); // Out-of-range variant, malformed variant and variant-suffixed scalar are reported as // skipped; the malformed one must not fall back to element 0. CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders#5")); CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders#abc")); CHECK(contains_key(pub.skipped_keys, "layer_height#0")); CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders#1")); // The scalar was never applied. CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.1, 0.000001)); } // The print/printer overlay guards option types like the material pass does: a published key // whose file-side option kind differs from the receiver's is reported as skipped instead of // throwing ConfigurationError out of load_config_model, which would abort the whole project // load. (Nullable variants share the type() of their non-nullable base, so this covers // genuinely different option kinds - e.g. a string where a float vector is expected.) TEST_CASE("Published 3MF reports type-mismatched keys as skipped instead of aborting", "[Preset][Bundle][Published]") { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); config.opt("filament_colour")->values = { "#FF0000" }; // The file carries the vector key as a string option (equal size to the receiver's)... config.set_key_value("wiping_volumes_extruders", new ConfigOptionStrings({ "140", "150" })); config.opt_float("layer_height") = 0.28; PresetBundle bundle; // ...while the receiver's edited print preset holds the float variant of the same key: // without the type guard, ConfigOptionVector::set() throws ConfigurationError out of // load_config_model. bundle.prints.get_edited_preset().config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 10., 20. })); 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); // The load completes; the type-mismatched key is reported as skipped and the receiver's // value is untouched; the matching scalar key still applies. CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders")); check_double_vector(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values, { 10., 20. }); CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001)); CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); } // A receiver filament preset missing its material identity (hand-edited file) must not crash // the type gate: the gate reads it as a type mismatch, and the slot falls back to the // "no replacement" path (keys skipped, colour still applied to the slot's preset). TEST_CASE("Published 3MF survives a receiver preset missing its material identity", "[Preset][Bundle][Published]") { 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_ids")->values = { "GFL99" }; config.option("filament_retraction_length", true)->values = { 0.9 }; Preset::normalize(config); PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); // Malformed receiver preset: the identity options are missing entirely. pla.config.erase("filament_type"); pla.config.erase("filament_vendor"); pla.config.opt("filament_colour", true)->values = { "#123456" }; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; bundle.filament_presets = { "My PLA" }; PublishedMaterialEntry entry; entry.filament_type = "PLA"; entry.filament_vendor = "Generic"; entry.filament_id = "GFL99"; entry.slot = 0; entry.publish_type = true; // exercises the type gate against the missing identity // Require ABS: the receiver library (PLA-typed default preset, typeless slot preset) has no // ABS candidate, so the gate falls into the "no replacement" path (a PLA requirement would // legitimately replace the slot with the visible PLA default). entry.publish_type_value = "ABS"; entry.publish_color = true; entry.color = "#ABCDEF"; entry.keys = { "filament_retraction_length" }; PublishedConfig pub; pub.published = true; pub.material_keys = { entry }; bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // The gate reads the missing identity as a type mismatch; no same-type replacement exists, // so the keys are skipped while the colour applies to the slot's preset in place. No crash. CHECK(contains_key(pub.skipped_keys, "material:GFL99 (filament_retraction_length)")); CHECK(bundle.filament_presets[0] == "My PLA"); Preset *mine_preset = bundle.filaments.find_preset("My PLA", false, true); REQUIRE(mine_preset != nullptr); CHECK(mine_preset->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); } // Lock the exact contents and order of the printer allowlist (the union of the tab's // "Retraction" and "Z-Hop" optgroup lists, Tab.cpp). TEST_CASE("Printer publishable allowlist matches the printer tab's Retraction and Z-Hop optgroups", "[Preset][Bundle][Published]") { auto keys_of = [](const std::vector& opts) { std::vector keys; keys.reserve(opts.size()); for (const PublishablePrinterOption& opt : opts) keys.emplace_back(opt.key); return keys; }; const std::vector expected_retraction = { "retraction_length", "retract_restart_extra", "retraction_speed", "deretraction_speed", "retraction_minimum_travel", "retract_when_changing_layer", "wipe", "wipe_distance", "retract_before_wipe", "retract_after_wipe" }; const std::vector expected_z_hop = { "retract_lift_enforce", "z_hop_types", "z_hop", "travel_slope", "retract_lift_above", "retract_lift_below" }; CHECK(keys_of(publishable_printer_retraction_options()) == expected_retraction); CHECK(keys_of(publishable_printer_z_hop_options()) == expected_z_hop); std::set expected_union(expected_retraction.begin(), expected_retraction.end()); expected_union.insert(expected_z_hop.begin(), expected_z_hop.end()); CHECK(publishable_printer_keys() == expected_union); } // Loading the same published file twice must not compound values on the receiver's presets: // each load re-applies the same absolute values, so the result is idempotent. TEST_CASE("Published 3MF reloading does not compound values on the receiver's presets", "[Preset][Bundle][Published]") { auto make_file_config = [] { 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_ids")->values = { "GFL99" }; config.option("filament_retraction_length", true)->values = { 0.9 }; return config; }; auto make_entry = [] { PublishedMaterialEntry entry; entry.slot = 0; entry.publish_color = true; entry.color = "#ABCDEF"; entry.keys = { "filament_retraction_length" }; return entry; }; 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" }; pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; bundle.filament_presets = { "My PLA" }; auto load = [&] { PublishedConfig pub; pub.published = true; pub.material_keys = { make_entry() }; DynamicPrintConfig config = make_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); }; load(); // First load: the receiver's preset carries the published values (mutated in place). CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); load(); // Each load re-applies the same values onto the (already mutated) preset: no accumulation. CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); } // A receiver with several slots aliasing the same preset (multi-extruder profile with one // filament) and an author publishing keys on several slots: each published slot is re-pointed // at its own distinct preset so values never leak between slots. TEST_CASE("Published 3MF gives each published slot its own preset on an aliased receiver", "[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" }; config.option("filament_retraction_length", true)->values = { 0.6, 0.9, 1.2, 1.5 }; return config; }; auto make_key_entry = [](int slot) { PublishedMaterialEntry entry; entry.slot = slot; entry.keys = { "filament_retraction_length" }; return entry; }; // A 4-extruder receiver with a single filament preset: the slots alias [A, A, A, A] before // the published pass. 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 }; // Spare library presets for the re-pointing to fall back on. for (const char *name : { "Extra PLA A", "Extra PLA B", "Extra PLA C" }) { Preset &extra = add_inmemory_preset(bundle.filaments, name); extra.config.opt_string("filament_type", 0u) = "PLA"; extra.config.opt("filament_retraction_length", true)->values = { 0.5 }; } bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA" }; PublishedConfig pub; pub.published = true; pub.material_keys = { make_key_entry(0), make_key_entry(1), make_key_entry(2), make_key_entry(3) }; DynamicPrintConfig config = make_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 4); // Every published slot references its own distinct preset: slot 0 keeps the receiver's // material, slots 1-3 are re-pointed at the spare library presets. CHECK(bundle.filament_presets[0] == "My PLA"); CHECK(bundle.filament_presets[1] != bundle.filament_presets[0]); CHECK(bundle.filament_presets[2] != bundle.filament_presets[0]); CHECK(bundle.filament_presets[2] != bundle.filament_presets[1]); CHECK(bundle.filament_presets[3] != bundle.filament_presets[0]); CHECK(bundle.filament_presets[3] != bundle.filament_presets[1]); CHECK(bundle.filament_presets[3] != bundle.filament_presets[2]); // Each slot's stored preset carries its own slot's retraction (mutated in place). const std::vector expected = { 0.6, 0.9, 1.2, 1.5 }; for (size_t slot = 0; slot < 4; ++slot) { Preset *preset = bundle.filaments.find_preset(bundle.filament_presets[slot], false, true); REQUIRE(preset != nullptr); check_double_vector(preset->config.opt("filament_retraction_length")->values, { expected[slot] }); } 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_double_vector(target->config.opt("filament_retraction_length")->values, { 0.9 }); REQUIRE(pub.material_replacements.size() == 1); CHECK(pub.material_replacements[0] == "slot 1: My PLA -> Zzz PLA"); 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. TEST_CASE("Published 3MF applies per-extruder printer keys across extruder-count mismatches", "[Preset][Bundle][Published]") { auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); config.opt("filament_colour")->values = { "#FF0000" }; // Author has 4 extruders. config.opt("retraction_length")->values = { 0.6, 0.9, 1.2, 1.5 }; Preset::normalize(config); return config; }; // Receiver with a single extruder: only "#0" is in range; "#1..#3" are skipped. { PresetBundle bundle; bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; PublishedConfig pub; pub.published = true; pub.published_keys = { "retraction_length#0", "retraction_length#1", "retraction_length#2", "retraction_length#3" }; DynamicPrintConfig config = make_file_config(); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6 }); CHECK(contains_key(pub.skipped_keys, "retraction_length#1")); CHECK(contains_key(pub.skipped_keys, "retraction_length#2")); CHECK(contains_key(pub.skipped_keys, "retraction_length#3")); CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_length#0")); } // Receiver with four extruders and a 1-extruder author: only "#0" is published; the // receiver's other extruders keep their own values. { PresetBundle bundle; bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8, 0.8, 0.8, 0.8 }; PublishedConfig pub; pub.published = true; pub.published_keys = { "retraction_length#0" }; DynamicPrintConfig config = make_file_config(); // The author's file carries a single-extruder value. config.opt("retraction_length")->values = { 0.7 }; bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.7, 0.8, 0.8, 0.8 }); CHECK(pub.skipped_keys.empty()); } } // A published mixed filament serializes its definition (components, ratios, gradient) into the // receiver's project_config - the project-level parallel arrays, not a filament preset. The // mix's own blended colour is carried as publish_color so the receiver renders the swatch. TEST_CASE("Published 3MF applies a mixed filament definition onto the receiver's project config", "[Preset][Bundle][Published]") { auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); // Three author slots: two physical PLA/PETG plus one virtual mixed slot (index 2) // blending slots 1 and 2 at 60/40 with a gradient. 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_ids")->values = { "GFL99", "GFT99", "GFL99" }; // The mixed slot's definition. These keys are project-level arrays in the full config; // on export they are masked so only the published slot's entry survives. 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 }; return config; }; // A receiver that already carries the mix slot at index 2 as an actual mixed slot (e.g. a // two-physical-plus-one-mix project with the same layout): the incoming definition is a // like-for-like override of the virtual slot and applies in place without relocation. { PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); petg.config.opt_string("filament_type", 0u) = "PETG"; bundle.filament_presets = { "My PLA", "My PETG", "My PLA" }; // Grow the receiver's project arrays to 3 slots first, as set_num_filaments would, // then mark the third slot as the receiver's own mixed filament. 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 = make_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // The definition landed in project_config's parallel arrays at the author slot. const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 3); CHECK(is_mixed[2]); const auto &components = bundle.project_config.opt("filament_mixed_components")->values; REQUIRE(components.size() == 3); CHECK(components[2] == "1,2"); const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; REQUIRE(ratios.size() == 3); CHECK(ratios[2] == "0.6,0.4"); const auto &gradient = bundle.project_config.opt("filament_mixed_gradient")->values; CHECK(gradient[2]); const auto &range = bundle.project_config.opt("filament_mixed_gradient_range")->values; CHECK(range[2] == "0.9,0.1"); const auto &curve = bundle.project_config.opt("filament_mixed_gradient_curve")->values; CHECK(curve[2] == "0,0.1|1,0.9"); const auto &per_part = bundle.project_config.opt("filament_mixed_gradient_per_part")->values; CHECK(per_part[2]); // The mix's blended colour crossed into project_config for the swatch. const auto &colour = bundle.project_config.opt("filament_colour")->values; REQUIRE(colour.size() == 3); CHECK(colour[2] == "#800080"); // The other slots were not overwritten by the mask. CHECK_FALSE(is_mixed[0]); CHECK_FALSE(is_mixed[1]); // Nothing skipped: every serialized mixed key was applied. CHECK(pub.skipped_keys.empty()); // Like-for-like override: no slot was relocated. CHECK(pub.material_replacements.empty()); } // A receiver with fewer slots: the slot is grown and seeded before the definition applies. { PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; bundle.filament_presets = { "My PLA" }; PublishedMaterialEntry mix; mix.slot = 2; 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(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 3); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 3); CHECK(is_mixed[2]); const auto &components = bundle.project_config.opt("filament_mixed_components")->values; REQUIRE(components.size() == 3); CHECK(components[2] == "1,2"); CHECK(pub.skipped_keys.empty()); } } // A published mixed filament whose definition cannot be applied is reported as skipped instead // of aborting the load: the entry lists a mixed key that the file's payload does not carry. TEST_CASE("Published 3MF reports an unappliable mixed filament definition as skipped", "[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" }; // Two author slots (so slot 1 is in range) but the payload omits the mixed arrays: the // entry lists them, the file config does not. DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); config.opt("filament_diameter")->values = { 1.75, 1.75 }; config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; config.opt("filament_type")->values = { "PLA", "PLA" }; config.opt("filament_vendor")->values = { "Generic", "Generic" }; PublishedMaterialEntry mix; mix.slot = 1; mix.keys = { "filament_mixed_components", "filament_mixed_sublayer_ratios" }; PublishedConfig pub; pub.published = true; pub.material_keys = { mix }; Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // The receiver grows to two slots; the missing payload keys are reported as skipped rather // than dropped silently (material_label is empty for this entry). REQUIRE(bundle.filament_presets.size() == 2); CHECK(contains_key(pub.skipped_keys, "material: (filament_mixed_components)")); CHECK(contains_key(pub.skipped_keys, "material: (filament_mixed_sublayer_ratios)")); } // A published mixed filament must never convert one of the receiver's real, physical slots // into a virtual mix: definitions that collide with a physical slot are relocated past every // positional (real-filament) destination, while ones colliding with an existing mixed slot // override it in place. TEST_CASE("Published 3MF relocates a mixed filament instead of overwriting a physical slot", "[Preset][Bundle][Published]") { // An author project with slots whose last slot is a mixed filament. auto make_file_config = [](size_t num_author_slots, size_t num_tail_mixes = 1) { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); std::vector diameters(num_author_slots, 1.75); std::vector self_index; std::vector variants; std::vector types; for (size_t i = 0; i < num_author_slots; ++i) { self_index.push_back(int(i + 1)); variants.emplace_back("Direct Drive Standard"); types.push_back(i % 2 == 0 ? "PLA" : "PETG"); } config.opt("filament_diameter")->values = diameters; config.opt("filament_self_index")->values = self_index; config.opt("filament_extruder_variant")->values = variants; config.opt("filament_colour")->values = { "#FF0000", "#00AA00", "#0000FF", "#FFFF00", "#800080" }; config.opt("filament_colour")->values.resize(num_author_slots, "#808080"); config.opt("filament_type")->values = types; config.opt("filament_vendor")->values.assign(num_author_slots, "Generic"); config.opt("filament_ids")->values.resize(num_author_slots); // The last author slots are mixed ones (components differ per slot so // the definitions are distinguishable after relocation). const size_t first_mix_slot = num_author_slots - num_tail_mixes; config.opt("filament_is_mixed")->values.assign(num_author_slots, 0); config.opt("filament_mixed_components")->values.assign(num_author_slots, ""); config.opt("filament_mixed_sublayer_ratios")->values.assign(num_author_slots, ""); for (size_t i = first_mix_slot; i < num_author_slots; ++i) { config.opt("filament_is_mixed")->values[i] = 1; config.opt("filament_mixed_components")->values[i] = i % 2 == 0 ? std::string("1,2") : std::string("1,3"); config.opt("filament_mixed_sublayer_ratios")->values[i] = i % 2 == 0 ? std::string("0.6,0.4") : std::string("0.3,0.7"); } return config; }; // The reported bug: an author publishes with physical filaments on slots 1-2 and a mixed // filament on slot 5; the receiver runs five real filaments of his own. Slot 5 must stay // untouched and the mix lands as a newly appended virtual slot 6. { 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.slot = 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 }; DynamicPrintConfig config = make_file_config(5); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // The receiver grew by exactly one extra virtual slot. REQUIRE(bundle.filament_presets.size() == 6); // All five physical slots kept their meaning: no mixed flag, untouched names/colours. const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 6); CHECK_FALSE(is_mixed[0]); CHECK_FALSE(is_mixed[1]); CHECK_FALSE(is_mixed[2]); CHECK_FALSE(is_mixed[3]); CHECK_FALSE(is_mixed[4]); CHECK(is_mixed[5]); CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), bundle.project_config.opt("filament_colour")->values.begin())); CHECK(bundle.filament_presets[0] == "My PLA"); CHECK(bundle.filament_presets[4] == "My PLA"); // The definition itself is readable at the new index. const auto &components = bundle.project_config.opt("filament_mixed_components")->values; REQUIRE(components.size() == 6); CHECK(components[5] == "1,2"); const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; REQUIRE(ratios.size() == 6); CHECK(ratios[5] == "0.6,0.4"); // The blended colour seeds the swatch of the new slot only. const auto &colour = bundle.project_config.opt("filament_colour")->values; REQUIRE(colour.size() == 6); CHECK(colour[5] == "#800080"); // The relocation is surfaced to the user through the post-import notice (the de-alias // pass may contribute further messages, so presence is asserted, not the count). bool relocated_reported = false; for (const std::string &message : pub.material_replacements) if (message.find("slot 4 -> slot 5") != std::string::npos) relocated_reported = true; CHECK(relocated_reported); CHECK(pub.skipped_keys.empty()); } // A definition colliding with the receiver's own mixed filament is overridden in place: // nothing grows, nothing is reported as moved. { 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" }; 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.9,0.1"; PublishedMaterialEntry mix; mix.slot = 2; mix.publish_color = true; mix.color = "#800080"; 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(3); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); CHECK(bundle.filament_presets.size() == 3); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 3); CHECK_FALSE(is_mixed[0]); CHECK_FALSE(is_mixed[1]); CHECK(is_mixed[2]); const auto &components = bundle.project_config.opt("filament_mixed_components")->values; REQUIRE(components.size() == 3); CHECK(components[2] == "1,2"); const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; REQUIRE(ratios.size() == 3); CHECK(ratios[2] == "0.6,0.4"); CHECK(pub.skipped_keys.empty()); CHECK(pub.material_replacements.empty()); } // Author publishes four physical filaments plus two mixed ones on slots 5 and 6; the // receiver runs five real filaments. Both mixes relocate onto consecutive fresh slots, // preserving their author order (slot 5 -> slot 6, slot 6 -> slot 7); no receiver slot is // converted into a virtual mix. { 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; auto make_mix_entry = [](int authored_slot, const char *color) { PublishedMaterialEntry entry; entry.slot = authored_slot; entry.publish_color = true; entry.color = color; entry.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; return entry; }; PublishedMaterialEntry mix_a = make_mix_entry(4, "#800080"); PublishedMaterialEntry mix_b = make_mix_entry(5, "#FF69B4"); PublishedConfig pub; pub.published = true; pub.material_keys = { mix_a, mix_b }; DynamicPrintConfig config = make_file_config(6, 2); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // Two fresh virtual slots were appended. REQUIRE(bundle.filament_presets.size() == 7); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 7); for (size_t i = 0; i < 5; ++i) CHECK_FALSE(is_mixed[i]); CHECK(is_mixed[5]); CHECK(is_mixed[6]); // The definitions follow their author order. const auto &components = bundle.project_config.opt("filament_mixed_components")->values; REQUIRE(components.size() == 7); CHECK(components[5] == "1,2"); CHECK(components[6] == "1,3"); const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; REQUIRE(ratios.size() == 7); CHECK(ratios[5] == "0.6,0.4"); CHECK(ratios[6] == "0.3,0.7"); // The five real slots kept their colours; each mix's blended colour seeded its new slot. const auto &colour = bundle.project_config.opt("filament_colour")->values; REQUIRE(colour.size() == 7); CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), colour.begin())); CHECK(colour[5] == "#800080"); CHECK(colour[6] == "#FF69B4"); // Both relocations are reported with the correct mapping. bool a_reported = false, b_reported = false; for (const std::string &message : pub.material_replacements) { if (message.find("slot 4 -> slot 5") != std::string::npos) a_reported = true; if (message.find("slot 5 -> slot 6") != std::string::npos) b_reported = true; } CHECK(a_reported); CHECK(b_reported); CHECK(pub.skipped_keys.empty()); // The relocation table is exposed for the model-reference remapping. REQUIRE(pub.mixed_slot_relocations.size() == 2); CHECK(pub.mixed_slot_relocations.at(4) == 5); CHECK(pub.mixed_slot_relocations.at(5) == 6); } } // A receiver that already owns a MIXED filament must keep the physical-first invariant after a // published-3MF import: when incoming physical filaments would land on (or ahead of) the // receiver's mixed slot, that mix is displaced to a fresh tail slot instead of being left // interleaved with them (the R,M,R bug). TEST_CASE("Published 3MF relocates the receiver's mixed filament past the incoming physical slots", "[Preset][Bundle][Published]") { // Build the receiver's tool-changer with three slots, the third being the receiver's own // mixed filament. A SEMM (single_extruder_multi_material) receiver sizes its slot list by // hand, so a lower slot count than the printer's nozzle count is preserved on load - a // non-SEMM tool-changer would top the preset list up to the nozzle count and shift the // expected sizes (the rebalance logic under test is the same either way). auto make_receiver = [](PresetBundle &bundle, const std::string &components, const std::string &ratios) { 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" }; bundle.set_num_filaments(3, "#123456"); bundle.printers.get_edited_preset().config.opt("single_extruder_multi_material", true)->value = true; bundle.project_config.opt("filament_is_mixed")->values[2] = 1; bundle.project_config.opt("filament_mixed_components")->values[2] = components; bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = ratios; bundle.project_config.opt("filament_colour")->values[2] = "#800080"; bundle.project_config.opt("filament_multi_colour")->values[2] = "#800080"; }; auto make_real_entry = [](int slot, const char *color) { PublishedMaterialEntry entry; entry.slot = slot; entry.filament_type = "PLA"; entry.filament_vendor = "Generic"; entry.publish_color = true; entry.color = color; return entry; }; // A four-physical author project with no mixed slots (colour publish only), as in the // reported Ferrari reference file. auto make_config_4_real = [] { 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", "#000000", "#FFFFFF", "#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; }; // [R, R, M] + four colour-only physical slots at authored 0..3 -> [R, R, R, R, M]. { PresetBundle bundle; make_receiver(bundle, "1,2", "0.5,0.5"); PublishedConfig pub; pub.published = true; pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#000000"), make_real_entry(2, "#FFFFFF"), make_real_entry(3, "#FFFF00") }; DynamicPrintConfig config = make_config_4_real(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // The receiver grows by one extra virtual slot; the mix lands at the tail. REQUIRE(bundle.filament_presets.size() == 5); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 5); for (size_t i = 0; i < 4; ++i) CHECK_FALSE(is_mixed[i]); CHECK(is_mixed[4]); // The definition travelled with its swatch colour; the vacated slot 2 became physical. const auto &components = bundle.project_config.opt("filament_mixed_components")->values; REQUIRE(components.size() == 5); CHECK(components[4] == "1,2"); CHECK(components[2].empty()); const auto &colour = bundle.project_config.opt("filament_colour")->values; REQUIRE(colour.size() == 5); CHECK(colour[4] == "#800080"); CHECK(colour[2] == "#FFFFFF"); CHECK(pub.mixed_slot_relocations.at(2) == 4); bool relocated_reported = false; for (const std::string &message : pub.material_replacements) if (message.find("slot 2 -> slot 4") != std::string::npos && message.find("mixed filament") != std::string::npos) relocated_reported = true; CHECK(relocated_reported); CHECK(pub.skipped_keys.empty()); } // [R, R, M] plus a payload mix authored at slot 3: the receiver mix (displaced to slot 3) sits // ahead of the appended payload mix (slot 4), preserving physical-first tail ordering. { PresetBundle bundle; make_receiver(bundle, "1,2", "0.5,0.5"); 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", "#000000", "#0000FF", "#800080" }; 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" }; // Authored slot 3 is a payload mixed definition blending slots 1 and 3. config.opt("filament_is_mixed")->values = { 0, 0, 0, 1 }; config.opt("filament_mixed_components")->values = { "", "", "", "1,3" }; config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "", "0.6,0.4" }; config.opt("filament_mixed_gradient")->values = { 0, 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, 0, 1 }; PublishedMaterialEntry mix; mix.slot = 3; mix.filament_type = "PLA"; 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 = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#000000"), make_real_entry(2, "#0000FF"), mix }; Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 5); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 5); CHECK_FALSE(is_mixed[0]); CHECK_FALSE(is_mixed[1]); CHECK_FALSE(is_mixed[2]); CHECK(is_mixed[3]); // receiver's mix, displaced to slot 3 first CHECK(is_mixed[4]); // payload's mix, appended after const auto &components = bundle.project_config.opt("filament_mixed_components")->values; REQUIRE(components.size() == 5); CHECK(components[3] == "1,2"); CHECK(components[4] == "1,3"); REQUIRE(pub.mixed_slot_relocations.size() == 2); CHECK(pub.mixed_slot_relocations.at(2) == 3); CHECK(pub.mixed_slot_relocations.at(3) == 4); CHECK(pub.skipped_keys.empty()); } } // Multiple receiver mixed slots interleaved with multiple incoming physical slots all rebalance // onto consecutive tail slots in index order (no cascade/overlap). TEST_CASE("Published 3MF rebalances several receiver mixed slots past the physical region", "[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" }; bundle.set_num_filaments(3, "#123456"); bundle.printers.get_edited_preset().config.opt("single_extruder_multi_material", true)->value = true; // Receiver: slot 1 and slot 2 are mixed. bundle.project_config.opt("filament_is_mixed")->values[1] = 1; bundle.project_config.opt("filament_is_mixed")->values[2] = 1; bundle.project_config.opt("filament_mixed_components")->values[1] = "1,2"; bundle.project_config.opt("filament_mixed_components")->values[2] = "1,3"; bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[1] = "0.5,0.5"; bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = "0.4,0.6"; 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", "#00AA00", "#0000FF" }; config.opt("filament_type")->values = { "PLA", "PLA", "PLA" }; config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99" }; auto make_real_entry = [](int slot, const char *color) { PublishedMaterialEntry entry; entry.slot = slot; entry.filament_type = "PLA"; entry.filament_vendor = "Generic"; entry.publish_color = true; entry.color = color; return entry; }; PublishedConfig pub; pub.published = true; pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#00AA00"), make_real_entry(2, "#0000FF") }; Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 5); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 5); for (size_t i = 0; i < 3; ++i) CHECK_FALSE(is_mixed[i]); CHECK(is_mixed[3]); CHECK(is_mixed[4]); const auto &components = bundle.project_config.opt("filament_mixed_components")->values; REQUIRE(components.size() == 5); CHECK(components[3] == "1,2"); CHECK(components[4] == "1,3"); REQUIRE(pub.mixed_slot_relocations.size() == 2); CHECK(pub.mixed_slot_relocations.at(1) == 3); CHECK(pub.mixed_slot_relocations.at(2) == 4); CHECK(pub.skipped_keys.empty()); } // The receiver's printer gates how many PHYSICAL filament slots a published 3MF may add: a // non-SEMM tool-changer feeds filament N from nozzle N, so a published slot past the nozzle // count cannot become a physical filament. It becomes an empty mixed-filament placeholder // instead - a virtual tail slot the GUI flags (broken mix) and the user fills with components // from their own filaments. SEMM receivers keep the ungated behaviour. TEST_CASE("Published 3MF turns a surplus slot past the printer's filament capacity into an empty mixed placeholder", "[Preset][Bundle][Published]") { // An author project with physical slots, no mixed ones. auto make_file_config = [](size_t num_author_slots) { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); std::vector diameters(num_author_slots, 1.75); std::vector self_index; std::vector variants; for (size_t i = 0; i < num_author_slots; ++i) { self_index.push_back(int(i + 1)); variants.emplace_back("Direct Drive Standard"); } config.opt("filament_diameter")->values = diameters; config.opt("filament_self_index")->values = self_index; config.opt("filament_extruder_variant")->values = variants; config.opt("filament_colour")->values.resize(num_author_slots, "#808080"); config.opt("filament_type")->values.assign(num_author_slots, "PLA"); config.opt("filament_vendor")->values.assign(num_author_slots, "Generic"); config.opt("filament_ids")->values.resize(num_author_slots); return config; }; // A non-SEMM receiver with nozzles running copies of one preset. auto make_receiver = [](PresetBundle &bundle, size_t nozzles, size_t slots) { Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; bundle.filament_presets.assign(slots, "My PLA"); bundle.set_num_filaments(slots, "#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(nozzles, 0.4); }; auto make_physical_entry = [](int slot, const char *color) { PublishedMaterialEntry entry; entry.slot = slot; entry.filament_type = "PLA"; entry.filament_vendor = "Generic"; entry.publish_color = true; entry.color = color; return entry; }; // The reported case: an author publishes with a filament on slot 5; the receiver is a // 4-filament tool-changer. The receiver keeps its four physical slots and the surplus // material lands as an empty mixed placeholder at the tail. { PresetBundle bundle; make_receiver(bundle, 4, 4); const std::vector receiver_colours = bundle.project_config.opt("filament_colour")->values; PublishedConfig pub; pub.published = true; pub.material_keys = { make_physical_entry(4, "#ABCDEF") }; DynamicPrintConfig config = make_file_config(5); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // The receiver grew by exactly one virtual slot, not a fifth physical one. REQUIRE(bundle.filament_presets.size() == 5); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 5); for (size_t i = 0; i < 4; ++i) CHECK_FALSE(is_mixed[i]); CHECK(is_mixed[4]); // The placeholder carries no definition: the GUI's integrity check flags it and // blocks slicing until the user assigns components. const auto &components = bundle.project_config.opt("filament_mixed_components")->values; REQUIRE(components.size() == 5); CHECK(components[4].empty()); // The four physical slots kept their meaning and colours. CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), bundle.project_config.opt("filament_colour")->values.begin())); CHECK(bundle.filament_presets[0] == "My PLA"); CHECK(bundle.filament_presets[3] == "My PLA"); // The published colour seeds the placeholder's swatch. CHECK(bundle.project_config.opt("filament_colour")->values[4] == "#ABCDEF"); // The conversion is surfaced through the post-import notice. bool placeholder_reported = false; for (const std::string &message : pub.material_replacements) if (message.find("unassigned mixed filament") != std::string::npos) placeholder_reported = true; CHECK(placeholder_reported); CHECK(pub.skipped_keys.empty()); CHECK(pub.mixed_slot_relocations.empty()); } // Two surplus slots (5 and 6) become two consecutive empty placeholders. { PresetBundle bundle; make_receiver(bundle, 4, 4); PublishedConfig pub; pub.published = true; pub.material_keys = { make_physical_entry(4, "#ABCDEF"), make_physical_entry(5, "#F0F0F0") }; DynamicPrintConfig config = make_file_config(6); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 6); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 6); for (size_t i = 0; i < 4; ++i) CHECK_FALSE(is_mixed[i]); CHECK(is_mixed[4]); CHECK(is_mixed[5]); const auto &components = bundle.project_config.opt("filament_mixed_components")->values; REQUIRE(components.size() == 6); CHECK(components[4].empty()); CHECK(components[5].empty()); const auto &colour = bundle.project_config.opt("filament_colour")->values; REQUIRE(colour.size() == 6); CHECK(colour[4] == "#ABCDEF"); CHECK(colour[5] == "#F0F0F0"); CHECK(pub.skipped_keys.empty()); CHECK(pub.mixed_slot_relocations.empty()); } // A surplus slot past both the receiver's list and the capacity packs onto the next free // tail slot (never max(authored, next_free), which would grow filler physical slots past // the capacity), and the relocation is recorded for the model-reference remapping. { PresetBundle bundle; make_receiver(bundle, 2, 2); PublishedConfig pub; pub.published = true; pub.material_keys = { make_physical_entry(3, "#ABCDEF") }; DynamicPrintConfig config = make_file_config(4); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 3); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 3); CHECK_FALSE(is_mixed[0]); CHECK_FALSE(is_mixed[1]); CHECK(is_mixed[2]); const auto &components = bundle.project_config.opt("filament_mixed_components")->values; REQUIRE(components.size() == 3); CHECK(components[2].empty()); REQUIRE(pub.mixed_slot_relocations.size() == 1); CHECK(pub.mixed_slot_relocations.at(3) == 2); bool relocation_reported = false; for (const std::string &message : pub.material_replacements) if (message.find("slot 3 -> slot 2") != std::string::npos && message.find("unassigned mixed filament") != std::string::npos) relocation_reported = true; CHECK(relocation_reported); CHECK(pub.skipped_keys.empty()); } // A Full Publish entry past the capacity becomes a placeholder too: no standalone // detached copy is created for a material that got no physical slot. { PresetBundle bundle; make_receiver(bundle, 4, 4); PublishedMaterialEntry entry = make_physical_entry(4, "#ABCDEF"); entry.full = true; entry.preset_name = "Generic PLA @System"; entry.filament_id = "GFL99"; entry.full_keys = { "filament_retraction_length" }; PublishedConfig pub; pub.published = true; pub.material_keys = { entry }; DynamicPrintConfig config = make_file_config(5); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 5); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 5); CHECK(is_mixed[4]); // No detached copy under the stripped name or its uniquified forms. CHECK(bundle.filaments.find_preset("Generic PLA", false, true) == nullptr); CHECK(bundle.filaments.find_preset("Generic PLA (Published)", false, true) == nullptr); CHECK(pub.skipped_keys.empty()); } // A SEMM receiver (the default printer preset) sizes its slot list by hand: the published // slot past the nozzle count still grows physically, as before the capacity gate. { 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" }; bundle.set_num_filaments(4, "#123456"); PublishedConfig pub; pub.published = true; pub.material_keys = { make_physical_entry(4, "#ABCDEF") }; DynamicPrintConfig config = make_file_config(5); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 5); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 5); for (size_t i = 0; i < 5; ++i) CHECK_FALSE(is_mixed[i]); CHECK(bundle.project_config.opt("filament_colour")->values[4] == "#ABCDEF"); CHECK(pub.skipped_keys.empty()); } // A pre-existing oversized slot list is never shrunk: a published entry pointing at one // of its slots is applied positionally even though the list exceeds the nozzle count. { PresetBundle bundle; make_receiver(bundle, 4, 5); PublishedConfig pub; pub.published = true; pub.material_keys = { make_physical_entry(4, "#ABCDEF") }; DynamicPrintConfig config = make_file_config(5); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 5); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 5); for (size_t i = 0; i < 5; ++i) CHECK_FALSE(is_mixed[i]); // The published colour reached the addressed slot's (shared) preset in place. CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); CHECK(bundle.project_config.opt("filament_colour")->values[4] == "#ABCDEF"); CHECK(pub.skipped_keys.empty()); } // On a single-physical-slot receiver an empty mix could never be edited (the sidebar's // mixed section needs two physical filaments), so the surplus entry is dropped and // reported instead of becoming an unfixable placeholder. { PresetBundle bundle; make_receiver(bundle, 1, 1); PublishedConfig pub; pub.published = true; pub.material_keys = { make_physical_entry(1, "#ABCDEF") }; DynamicPrintConfig config = make_file_config(2); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); CHECK(bundle.filament_presets.size() == 1); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 1); CHECK_FALSE(is_mixed[0]); REQUIRE(pub.skipped_keys.size() == 1); CHECK(pub.skipped_keys.front().find("printer supports only 1") != std::string::npos); } // A payload mixed definition is exempt from the capacity gate: mixes are virtual slots // that consume no nozzle, so a published mix past the nozzle count still lands. { PresetBundle bundle; make_receiver(bundle, 4, 4); PublishedMaterialEntry mix; mix.slot = 4; mix.publish_color = true; mix.color = "#800080"; 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(5); config.opt("filament_is_mixed")->values.assign(5, 0); config.opt("filament_mixed_components")->values.assign(5, ""); config.opt("filament_mixed_sublayer_ratios")->values.assign(5, ""); config.opt("filament_is_mixed")->values[4] = 1; config.opt("filament_mixed_components")->values[4] = "1,2"; config.opt("filament_mixed_sublayer_ratios")->values[4] = "0.6,0.4"; Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); REQUIRE(bundle.filament_presets.size() == 5); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 5); CHECK(is_mixed[4]); const auto &components = bundle.project_config.opt("filament_mixed_components")->values; REQUIRE(components.size() == 5); CHECK(components[4] == "1,2"); const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; REQUIRE(ratios.size() == 5); CHECK(ratios[4] == "0.6,0.4"); CHECK(pub.skipped_keys.empty()); } } // A single-extruder receiver collapses the author's per-extruder printer slots onto its single // slot: the first serialized variant of a base key is applied, the remaining variants of that // base key are reported as skipped. TEST_CASE("Published 3MF collapses a multi-extruder publish onto a single-extruder receiver", "[Preset][Bundle][Published]") { auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); config.opt("filament_colour")->values = { "#FF0000" }; // Author has two extruders. config.opt("retraction_length")->values = { 0.6, 0.9 }; config.opt("retraction_speed")->values = { 30.0, 40.0 }; Preset::normalize(config); return config; }; // Both extruders published: the first serialized variant (#0, left) lands on the receiver's // single slot; the second variant (#1) is reported as skipped. { PresetBundle bundle; bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; bundle.printers.get_edited_preset().config.opt("retraction_speed")->values = { 25.0 }; PublishedConfig pub; pub.published = true; pub.published_keys = { "retraction_length#0", "retraction_length#1", "retraction_speed#0", "retraction_speed#1" }; DynamicPrintConfig config = make_file_config(); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6 }); check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_speed")->values, { 30.0 }); CHECK(contains_key(pub.skipped_keys, "retraction_length#1")); CHECK(contains_key(pub.skipped_keys, "retraction_speed#1")); CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_length#0")); CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_speed#0")); } // Only the second extruder published: the single-extruder receiver still applies it (the // author's "right" is the only serialized slot) and reports nothing skipped. { PresetBundle bundle; bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; PublishedConfig pub; pub.published = true; pub.published_keys = { "retraction_length#1" }; DynamicPrintConfig config = make_file_config(); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.9 }); CHECK(pub.skipped_keys.empty()); } } // A multi-extruder "similar setup" receiver overrides each published extruder slot element-wise // (no collapsing): each '#N' variant applies to the matching receiver slot, out-of-range ones are // reported as skipped. TEST_CASE("Published 3MF overrides each extruder slot on a similar multi-extruder receiver", "[Preset][Bundle][Published]") { auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; // Author has two extruders. config.opt("retraction_length")->values = { 0.6, 0.9 }; Preset::normalize(config); return config; }; // Receiver with two extruders: both published slots override element-wise. { PresetBundle bundle; bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8, 0.8 }; PublishedConfig pub; pub.published = true; pub.published_keys = { "retraction_length#0", "retraction_length#1" }; DynamicPrintConfig config = make_file_config(); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6, 0.9 }); CHECK(pub.skipped_keys.empty()); } // Receiver with three extruders: slots 0 and 1 override, slot 2 keeps its own value. { PresetBundle bundle; bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8, 0.8, 0.7 }; PublishedConfig pub; pub.published = true; pub.published_keys = { "retraction_length#0", "retraction_length#1" }; DynamicPrintConfig config = make_file_config(); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6, 0.9, 0.7 }); CHECK(pub.skipped_keys.empty()); } } // The nozzle-count top-up in update_multi_material_filament_presets() grows filament_presets on // its own, so a physical count derived from that list reports a slot no per-filament array has // yet. That is what made the extruder-count handler conclude there was nothing to add and leave // the new sidebar combo with no colour to draw. TEST_CASE("The physical filament count is not fooled by a lone filament_presets top-up", "[Preset][Bundle][FilamentMixer]") { PresetBundle bundle; SECTION("no mixed slots") { bundle.set_num_filaments(4u, std::string("#FF0000")); bundle.printers.get_edited_preset().config.option("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4, 0.4 }; bundle.update_multi_material_filament_presets(); REQUIRE(bundle.filament_presets.size() == 5); // the top-up moved this list on its own REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 4); CHECK(bundle.num_physical_filaments() == 4); } SECTION("behind a mixed tail") { bundle.set_num_filaments(5u, std::string("#FF0000")); bundle.project_config.option("filament_is_mixed")->values = { false, false, false, false, true }; bundle.printers.get_edited_preset().config.option("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4, 0.4, 0.4 }; bundle.update_multi_material_filament_presets(); REQUIRE(bundle.filament_presets.size() == 6); REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 5); CHECK(bundle.num_physical_filaments() == 4); CHECK(bundle.num_mixed_filaments() == 1); } } // Which slots are new is a fact about the per-filament arrays, not about filament_presets, for the // same reason. Keyed off the wrong one, a freshly opened slot silently keeps filament 1's colour. TEST_CASE("New filament colours are placed by array position", "[Preset][Bundle][FilamentMixer]") { PresetBundle bundle; bundle.set_num_filaments(4u, std::string("#FF0000")); bundle.printers.get_edited_preset().config.option("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4, 0.4 }; bundle.update_multi_material_filament_presets(); REQUIRE(bundle.filament_presets.size() == 5); REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 4); // The call Sidebar::add_custom_filament makes once the extruder count opens a slot. bundle.set_num_filaments(5u, std::string("#00FF00")); const auto &colours = bundle.project_config.option("filament_colour")->values; REQUIRE(colours.size() == 5); CHECK(colours[4] == "#00FF00"); // not colours[0], which resize() would have padded with } // The mixed-slot flags are written into the app config on exit and read back on the next start. // If the read side loses them the slots survive as filaments but stop being mixes, so the project // comes back with the mix showing as an ordinary physical filament. TEST_CASE("A saved mix is still a mix after an app restart", "[Preset][Bundle][FilamentMixer]") { AppConfig app_config; // Last session: a 4-tool project carrying one mix of filaments 2 and 3 at the tail. { PresetBundle bundle; add_inmemory_preset(bundle.printers, "Test Printer"); bundle.printers.select_preset_by_name("Test Printer", true); add_inmemory_preset(bundle.filaments, "Test Filament"); bundle.filaments.select_preset_by_name("Test Filament", true); bundle.set_num_filaments(5u, std::string("#FF0000")); bundle.filament_presets.assign(5, "Test Filament"); bundle.project_config.option("filament_is_mixed")->values = { false, false, false, false, true }; bundle.project_config.option("filament_mixed_components")->values = { "", "", "", "", "2,3" }; bundle.export_selections(app_config); REQUIRE(app_config.get_printer_setting("Test Printer", "filament_is_mixed") == "0,0,0,0,1"); } // This session. PresetBundle bundle; add_inmemory_preset(bundle.printers, "Test Printer"); add_inmemory_preset(bundle.filaments, "Test Filament"); bundle.load_selections(app_config); CHECK(bundle.filament_presets.size() == 5); CHECK(bundle.num_mixed_filaments() == 1); CHECK(bundle.is_mixed_filament(4)); CHECK(bundle.project_config.option("filament_mixed_components")->values[4] == "2,3"); } // The same restart, on the printer shape that actually shows the bug: a 4-tool changer whose // saved filament list is one longer than its nozzle count, because the extra slot is the mix. TEST_CASE("A saved mix survives a restart on a multi-tool printer", "[Preset][Bundle][FilamentMixer]") { auto make_toolchanger = [](PresetBundle &bundle) -> Preset & { Preset &p = add_inmemory_preset(bundle.printers, "Tool Changer"); p.config.option("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4 }; p.config.option("single_extruder_multi_material", true)->value = false; return p; }; AppConfig app_config; { PresetBundle bundle; make_toolchanger(bundle); bundle.printers.select_preset_by_name("Tool Changer", true); add_inmemory_preset(bundle.filaments, "Test Filament"); bundle.filaments.select_preset_by_name("Test Filament", true); bundle.set_num_filaments(5u, std::string("#FF0000")); bundle.filament_presets.assign(5, "Test Filament"); bundle.project_config.option("filament_is_mixed")->values = { false, false, false, false, true }; bundle.project_config.option("filament_mixed_components")->values = { "", "", "", "", "1,2" }; bundle.export_selections(app_config); REQUIRE(app_config.get_printer_setting("Tool Changer", "filament_is_mixed") == "0,0,0,0,1"); } PresetBundle bundle; make_toolchanger(bundle); add_inmemory_preset(bundle.filaments, "Test Filament"); bundle.load_selections(app_config); CHECK(bundle.filament_presets.size() == 5); CHECK(bundle.num_mixed_filaments() == 1); CHECK(bundle.is_mixed_filament(4)); SECTION("and through the GUI startup calls that follow it") { // GUI_App::load_current_presets sizes the list for a non-SEMM printer, growing only. const size_t target = 4u + bundle.num_mixed_filaments(); if (target > bundle.filament_presets.size()) bundle.set_num_filaments(target); CHECK(bundle.num_mixed_filaments() == 1); // TabPrinter::extruders_count_changed. bundle.on_extruders_count_changed(4); CHECK(bundle.num_mixed_filaments() == 1); // Tab::select_preset re-reads the snapshot when remember_printer_config is on. bundle.update_selections(app_config); CHECK(bundle.filament_presets.size() == 5); CHECK(bundle.num_mixed_filaments() == 1); CHECK(bundle.is_mixed_filament(4)); } } // The startup sizing in GUI_App::load_current_presets targets the nozzle count plus the mixes. // That is a floor, never a ceiling: set_num_filaments() trims at the raw tail, which is exactly // where the mixes live, so applying the target to a longer list deletes them. A list longer than // the target is reachable - raising the extruder count without saving the printer preset leaves // the extra physical slot behind on the next start - so the startup sizing must only ever grow. TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tail", "[Preset][Bundle][FilamentMixer]") { // 5 physical + 1 mix, on a printer preset still reporting 4 nozzles. const size_t nozzle_count = 4; PresetBundle bundle; bundle.set_num_filaments(6u, std::string("#FF0000")); bundle.project_config.option("filament_is_mixed")->values = { false, false, false, false, false, true }; bundle.project_config.option("filament_mixed_components")->values = { "", "", "", "", "", "1,2" }; REQUIRE(bundle.num_physical_filaments() == 5); const size_t target = nozzle_count + bundle.num_mixed_filaments(); REQUIRE(target < bundle.filament_presets.size()); SECTION("applied as written, the mix is gone and every slot reads physical") { bundle.set_num_filaments(target); CHECK(bundle.filament_presets.size() == target); CHECK(bundle.num_mixed_filaments() == 0); CHECK(bundle.num_physical_filaments() == target); } SECTION("applied as a floor, the mix is left alone") { if (target > bundle.filament_presets.size()) bundle.set_num_filaments(target); CHECK(bundle.filament_presets.size() == 6); CHECK(bundle.num_mixed_filaments() == 1); CHECK(bundle.is_mixed_filament(5)); CHECK(bundle.project_config.option("filament_mixed_components")->values[5] == "1,2"); } } // After a published-3MF import relocated mixed-filament definitions, the freshly loaded // model's slot references must follow: object/volume "extruder" configs and multi-material // color-painting states (which store the one-based slot number) are re-pointed to where each // definition landed; everything else keeps its state. TEST_CASE("remap_model_filament_slots repoints extruder configs and color painting", "[Preset][Bundle][Published]") { auto make_model = [] { Model model; ModelObject *object_a = model.add_object(); object_a->name = "relocated mix"; ModelVolume *vol_a = object_a->add_volume(make_cube(10., 10., 10.)); vol_a->config.set_key_value("extruder", new ConfigOptionInt(5)); // author slot 5 (0-based 4) // Author painted one facet with the mix (slot 5) and another with a physical (slot 2). { TriangleSelector selector(vol_a->mesh()); selector.set_facet(0, EnforcerBlockerType(5)); selector.set_facet(1, EnforcerBlockerType(2)); vol_a->mmu_segmentation_facets.set_data(selector.serialize()); } // A second object that does not reference the relocated slot at all. Painted with a // real, non-relocated state (NONE is never serialized: an unsplit triangle without a // state is the unpainted default and is skipped by TriangleSelector::serialize()). ModelObject *object_b = model.add_object(); object_b->name = "untouched"; object_b->config.set_key_value("extruder", new ConfigOptionInt(1)); ModelVolume *vol_b = object_b->add_volume(make_cube(5., 5., 5.)); vol_b->config.set_key_value("extruder", new ConfigOptionInt(2)); { TriangleSelector selector(vol_b->mesh()); selector.set_facet(0, EnforcerBlockerType(2)); vol_b->mmu_segmentation_facets.set_data(selector.serialize()); } return model; }; const std::map relocations = {{4, 5}}; Model model = make_model(); Slic3r::remap_model_filament_slots(model, relocations); const ModelVolume *vol_a = model.objects[0]->volumes.front(); CHECK(vol_a->config.extruder() == 6); // author slot 5 -> final slot 6 // Painted states follow: the mix facet moved 5 -> 6, the physical one is untouched. REQUIRE(TriangleSelector::has_facets(vol_a->mmu_segmentation_facets.get_data(), EnforcerBlockerType(6))); REQUIRE_FALSE(TriangleSelector::has_facets(vol_a->mmu_segmentation_facets.get_data(), EnforcerBlockerType(5))); CHECK(TriangleSelector::has_facets(vol_a->mmu_segmentation_facets.get_data(), EnforcerBlockerType(2))); const ModelVolume *vol_b = model.objects[1]->volumes.front(); CHECK(vol_b->config.extruder() == 2); // The untouched volume's paint (a non-relocated state) survives as-is. CHECK(TriangleSelector::has_facets(vol_b->mmu_segmentation_facets.get_data(), EnforcerBlockerType(2))); // The mapping is applied simultaneously: each entry reads the original slot number, so // relocating onto another relocated-from slot number must not chase chains. With the // 0-based relocations {3->4, 4->6} the 1-based config map is {4->5, 5->7}: a volume on // 1-based slot 4 lands on 5 and does NOT continue to 7. Model chained = make_model(); chained.objects[0]->volumes.front()->config.set_key_value("extruder", new ConfigOptionInt(4)); Slic3r::remap_model_filament_slots(chained, std::map{{3, 4}, {4, 6}}); CHECK(chained.objects[0]->volumes.front()->config.extruder() == 5); // The chained model's paint follows its own single-step mapping: painted state 5 -> 7, // and nothing lands back on 5. CHECK(TriangleSelector::has_facets(chained.objects[0]->volumes.front()->mmu_segmentation_facets.get_data(), EnforcerBlockerType(7))); 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 grown slot is finalized as an empty // mixed placeholder instead of keeping the seeded preset and masquerading as a real // filament. REQUIRE(bundle.filament_presets.size() == 3); CHECK(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 slot change was surfaced like the other slot adaptations. REQUIRE(pub.material_replacements.size() == 1); CHECK(pub.material_replacements[0].find("slot 2: mixed filament definition could not be imported") != std::string::npos); // The blended colour was not written into the (shared) slot preset either. CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#123456" }); } } // The reported scenario: an author publishes two mixed filaments whose components are // full-published physical slots; the receiver is a smaller tool-changer, so some of those // component slots become empty mixed placeholders. A mix whose component turned into a // placeholder can never be valid (mixes cannot reference mixes): it is rejected, and its // grown slot must be finalized as an empty mixed placeholder too - not keep the seeded // preset and masquerade as a real filament carrying the mix identity. TEST_CASE("Published 3MF finalizes a mixed filament rejected over a placeholder component as an empty placeholder", "[Preset][Bundle][Published]") { // An author project: six physical slots plus two tail mixes, the second referencing the // sixth physical slot (H2C-style: slot 7 = 1+2, slot 8 = 2+6). auto make_file_config = [] { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); config.opt("filament_diameter")->values = std::vector(8, 1.75); config.opt("filament_self_index")->values = { 1, 2, 3, 4, 5, 6, 7, 8 }; config.opt("filament_extruder_variant")->values = std::vector(8, "Direct Drive Standard"); config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#800080", "#804000" }; config.opt("filament_type")->values.assign(8, "PLA"); config.opt("filament_vendor")->values.assign(8, "Generic"); config.opt("filament_is_mixed")->values = { 0, 0, 0, 0, 0, 0, 1, 1 }; config.opt("filament_mixed_components")->values = { "", "", "", "", "", "", "1,2", "2,6" }; config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "", "", "", "", "0.6,0.4", "0.5,0.5" }; // The export always serializes all seven masked mixed arrays, not just the ones in // use; the unused gradient arrays ride along as defaults. config.opt("filament_mixed_gradient")->values = { 0, 0, 0, 0, 0, 0, 0, 0 }; config.opt("filament_mixed_gradient_range")->values.assign(8, ""); config.opt("filament_mixed_gradient_curve")->values.assign(8, ""); config.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 0, 0, 0, 0, 0, 0 }; return config; }; // A non-SEMM receiver with four nozzles and four slots (tool-changer style). PresetBundle bundle; Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); pla.config.opt_string("filament_type", 0u) = "PLA"; bundle.filament_presets.assign(4, "My PLA"); bundle.set_num_filaments(4, "#123456"); auto &printer_config = bundle.printers.get_edited_preset().config; printer_config.opt("single_extruder_multi_material", true)->value = false; printer_config.opt("nozzle_diameter", true)->values.assign(4, 0.4); auto make_full_entry = [](int slot) { PublishedMaterialEntry entry; entry.slot = slot; entry.filament_type = "PLA"; entry.filament_vendor = "Generic"; entry.full = true; entry.full_keys = { "filament_retraction_length" }; return entry; }; auto make_mix_entry = [](int slot, const char *color) { PublishedMaterialEntry entry; entry.slot = slot; entry.filament_type = "PLA"; entry.filament_vendor = "Generic"; entry.publish_color = true; entry.color = color; entry.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios", "filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve", "filament_mixed_gradient_per_part" }; return entry; }; // The dialog's emit order: the full-published physical slots (1, 2, 5, 6) and both mixes. PublishedConfig pub; pub.published = true; pub.material_keys = { make_full_entry(0), make_full_entry(1), make_full_entry(4), make_full_entry(5), make_mix_entry(6, "#800080"), make_mix_entry(7, "#804000") }; DynamicPrintConfig config = make_file_config(); Preset::normalize(config); bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); // The receiver grew to the author's slot count, all virtual territory at the tail. REQUIRE(bundle.filament_presets.size() == 8); const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; REQUIRE(is_mixed.size() == 8); // Slots 0-3 stayed physical; authored slots 5 and 6 (0-based 4 and 5) became capacity // placeholders. for (size_t i = 0; i < 4; ++i) CHECK_FALSE(is_mixed[i]); CHECK(is_mixed[4]); CHECK(is_mixed[5]); CHECK(bundle.project_config.opt("filament_mixed_components")->values[4].empty()); CHECK(bundle.project_config.opt("filament_mixed_components")->values[5].empty()); // The first mix applied onto its uncontended tail slot. CHECK(is_mixed[6]); CHECK(bundle.project_config.opt("filament_mixed_components")->values[6] == "1,2"); // The second mix was rejected - its second component (authored slot 6) turned into a // placeholder - and its slot was finalized as an empty mixed placeholder instead of // keeping the seeded preset as a phantom real filament. CHECK(is_mixed[7]); CHECK(bundle.project_config.opt("filament_mixed_components")->values[7].empty()); CHECK(bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[7].empty()); REQUIRE(pub.skipped_keys.size() == 1); CHECK(pub.skipped_keys[0] == "material:PLA (mixed filament definition: components reference missing slots)"); // Two Full Publish detach lines (slots 0-1), two capacity placeholder lines (slots 4-5), // and the rejected mix's finalization line (slot 7). REQUIRE(pub.material_replacements.size() == 5); CHECK(std::any_of(pub.material_replacements.begin(), pub.material_replacements.end(), [](const std::string &line) { return line.find("slot 7: mixed filament definition could not be imported") != std::string::npos; })); } // The 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. 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() == 2); CHECK(pub.material_replacements[0].find("slot 3 -> slot 5") != std::string::npos); // ...and the receiver grew to hold the destination slot, but the definition itself was // rejected: the relocated cells degraded to empty defaults, the slot was finalized as an // empty mixed placeholder (not a real filament), and both facts were reported. REQUIRE(bundle.filament_presets.size() == 6); CHECK(bundle.is_mixed_filament(5)); CHECK(bundle.project_config.opt("filament_mixed_components")->values[5].empty()); CHECK(bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[5].empty()); CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: needs at least two components)")); CHECK(pub.material_replacements[1].find("slot 5: mixed filament definition could not be imported") != std::string::npos); // The five real slots kept their colours. CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), bundle.project_config.opt("filament_colour")->values.begin())); } // 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(); 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"; entry.keys = { "filament_retraction_length" }; 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 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"); 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") { 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"; entry.keys = { "filament_retraction_length" }; 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"); 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") { 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"; entry.keys = { "filament_retraction_length" }; 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"); 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 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 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 }; 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 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: My PETG -> Aaa PLA (substitute)"); } } // 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)"); 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()); } // 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); }