Extend Publish workflow with full-filament and type/color requirements

Per material slot, the Publish dialog can now embed the entire filament preset ("Full Publish") and require a curated filament type and/or colour:

- On export, full-publish vector options are masked to the author's slot so unrelated slot data never leaks into the published file.

- On load, slots are matched by the published type: a match keeps the receiver's material (full dumps ignored, partial keys applied); a mismatch replaces the slot with the first visible same-type library filament, falling back to a temporary embedded preset or skipped keys when none exists. Required colours apply regardless of the type match.

- The receiver's slot count grows only to the highest published slot.

- Published 3MFs load as a new project: the file's path is not adopted as the project filename, published metadata is stripped from the model, and the file is added to recent projects.

- Notifications list replaced slots, and the edited filament preset is refreshed so applied values surface in the GUI.

- Dialog: "Full Publish" toggle replaces the material opt-in and select-all headers; new Color/Type requirement rows with swatches.

- Add Ctrl+Shift+E shortcut for the Publish dialog (menu, key handling, and the keyboard shortcuts dialog).

- Tests for export slot masking, metadata round-trip, replacement semantics, slot growth, and skipped-key reporting.
This commit is contained in:
Lam Wei Lun
2026-08-18 13:49:10 +08:00
parent 2b18744cc2
commit 6c429059e0
13 changed files with 1037 additions and 135 deletions
+95
View File
@@ -752,3 +752,98 @@ SCENARIO("Minimal published 3MF serialization filters config and omits embedded
}
}
}
// Filament-publishing v2: a "full publish" entry carries the whole slot's key list. Its vector
// options keep only the author's slot value; the other slots are masked to their defaults so a
// slot-1 full publish does not leak slot 0's data into the file.
SCENARIO("Full-publish entries filter the whole slot and mask the other slots", "[3mf]") {
GIVEN("a full print configuration with two filament slots") {
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
full_cfg.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
full_cfg.opt<ConfigOptionStrings>("filament_colour")->values = { "#111111", "#222222" };
// filament_flow_ratio carries a non-empty option default (1.0) of the same type, so the
// mask can restore it on the non-published slot.
full_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1.02, 0.98 };
PublishedMaterialEntry full_entry;
full_entry.slot = 1;
full_entry.full = true;
full_entry.full_keys = { "filament_flow_ratio" };
WHEN("filtering with a full entry for slot 1") {
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { full_entry });
THEN("the full key list is present with the author's slot value") {
REQUIRE(filtered_cfg.option("filament_flow_ratio") != nullptr);
REQUIRE(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[1] == 0.98);
}
THEN("the non-published slot is masked to its default") {
REQUIRE(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[0] == 1.0);
}
THEN("the identity keys stay present") {
REQUIRE(filtered_cfg.option("filament_colour") != nullptr);
}
}
}
}
// Filament-publishing v2: the extended per-entry fields (full dump list, published type and
// colour) travel inside the published_material_keys metadata and round-trip unchanged.
SCENARIO("Published 3MF round-trips the filament-publishing-v2 material metadata", "[3mf]") {
GIVEN("a model carrying extended published material keys metadata") {
Model model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
const std::string material_keys_json =
R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99"},"slot":1,"keys":[],"full":true,"full_keys":["filament_retraction_length","filament_colour"],"publish_type":true,"type":"PLA","publish_color":false,"color":""}])";
model.model_info = std::make_shared<ModelInfo>();
model.model_info->metadata_items["published_material_keys"] = material_keys_json;
ScopedTemporaryDir backup_dir("orca_pub_mat2");
model.set_backup_path(backup_dir.string());
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("the extended material metadata round-trips unchanged") {
REQUIRE(loaded);
REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items["published_material_keys"] == material_keys_json);
// The value must parse back with every filament-publishing-v2 field intact.
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
REQUIRE(entries.is_array());
REQUIRE(entries.size() == 1);
REQUIRE(entries[0]["full"].get<bool>() == true);
REQUIRE(entries[0]["full_keys"].is_array());
REQUIRE(entries[0]["full_keys"].size() == 2);
REQUIRE(entries[0]["publish_type"].get<bool>() == true);
REQUIRE(entries[0]["type"] == "PLA");
REQUIRE(entries[0]["publish_color"].get<bool>() == false);
}
release_PlateData_list(dst_plates);
}
}
}
@@ -784,6 +784,409 @@ TEST_CASE("Published 3MF applies material retraction keys onto the receiver's ma
CHECK(pub.skipped_keys.empty());
}
// Filament-publishing v2: a "full publish" slot serializes the entire filament of the slot. On
// load the slot is matched positionally against the published (curated, vendor-agnostic) type:
// a matching receiver type leaves the slot untouched, a mismatched type replaces it with the
// first same-type visible preset (applying the author's full values on top), and a slot whose
// type cannot be found in the receiver's library falls back to the author's values in-memory.
TEST_CASE("Published 3MF full-published slots replace or ignore the receiver material by type", "[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<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG" };
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99" };
config.option<ConfigOptionFloatsNullable>("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 leaves a full-published slot untouched") {
PresetBundle bundle;
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
pla.config.opt_string("filament_type", 0u) = "PLA";
pla.config.opt<ConfigOptionFloatsNullable>("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 receiver keeps its own material and its own values: the full dump is ignored.
CHECK(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.5 });
CHECK(pub.skipped_keys.empty());
CHECK(pub.material_replacements.empty());
}
SECTION("type mismatch replaces the slot with the first same-type preset and applies the full dump") {
PresetBundle bundle;
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
pla.config.opt_string("filament_type", 0u) = "PLA";
pla.config.opt<ConfigOptionFloatsNullable>("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<ConfigOptionFloatsNullable>("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);
CHECK(bundle.filament_presets.size() == 1);
CHECK(bundle.filament_presets[0] == "My ABS");
// The author's slot-0 full values were applied onto the replacement.
CHECK(bundle.filaments.find_preset("My ABS")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.9 });
CHECK(pub.skipped_keys.empty());
REQUIRE(pub.material_replacements.size() == 1);
}
SECTION("no same-type match creates a temporary project-embedded custom preset") {
PresetBundle bundle;
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
pla.config.opt_string("filament_type", 0u) = "PLA";
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
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);
// No ABS in the library: a temporary embedded preset is created and selected.
CHECK(bundle.filament_presets[0] == "ABS (Published)");
Preset *created = bundle.filaments.find_preset("ABS (Published)");
REQUIRE(created != nullptr);
CHECK(created->is_project_embedded);
CHECK(created->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.9 });
// The original user preset remains untouched. Re-fetch by name: load_preset's deque
// insertion relocated the presets, so the pre-load `pla` reference points at the
// newly created "ABS (Published)" slot.
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.5 });
CHECK(pub.skipped_keys.empty());
REQUIRE(pub.material_replacements.size() == 1);
}
}
// Filament-publishing v2: 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<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG" };
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99" };
config.option<ConfigOptionFloatsNullable>("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<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
pla.config.opt<ConfigOptionStrings>("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 applied, colour applied.
CHECK(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.9 });
CHECK(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#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<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
pla.config.opt<ConfigOptionStrings>("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);
// Colour still applies (type-independent); the material is kept and the keys skipped.
CHECK(bundle.filament_presets[0] == "My PLA");
CHECK(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.5 });
CHECK(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#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<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
pla.config.opt<ConfigOptionStrings>("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<ConfigOptionFloatsNullable>("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, automatically assigning the PETG preset.
REQUIRE(bundle.filament_presets.size() == 2);
CHECK(bundle.filament_presets[1] == "My PETG");
}
}
// The receiver's slot list grows only as far as the highest author slot that carries published
// content: a 4-filament 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<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" };
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" };
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
config.opt<ConfigOptionStrings>("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<ConfigOptionStrings>("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<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#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);
CHECK(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
}
// Slot 3 published: the receiver grows to 4 so the published slot exists; the filler is
// the receiver's own first visible material.
{
PresetBundle bundle;
add_pla_preset(bundle);
bundle.filament_presets = { "My PLA" };
const std::string filler = bundle.filaments.first_visible().name;
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[3] == filler);
}
// 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);
}
}
// The GUI displays the edited preset, a snapshot of the selected collection preset taken at
// selection time. The published overlay modifies the collection presets in place, so the load
// must re-select the first slot's filament (mirroring a normal project load) for the applied
// colour/type/keys - and slot replacements - to 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<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99" };
config.option<ConfigOptionFloatsNullable>("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<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
pla.config.opt<ConfigOptionFloatsNullable>("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();
CHECK(edited.config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
CHECK(edited.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.9 });
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<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
pla.config.opt<ConfigOptionFloatsNullable>("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<ConfigOptionFloatsNullable>("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<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
CHECK(edited.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.9 });
}
}
// Material-qualified keys whose receiver-side material match is missing or ambiguous must be
// reported as skipped (material-qualified) and never applied; a single unqualified type
// fallback still applies.