From 6c429059e019d558bf9502aadf886df6e9539fc7 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Tue, 18 Aug 2026 13:49:10 +0800 Subject: [PATCH] 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. --- src/libslic3r/PresetBundle.cpp | 219 ++++++++++ src/libslic3r/PresetBundle.hpp | 3 + src/libslic3r/PublishSettings.cpp | 81 +++- src/libslic3r/PublishSettings.hpp | 22 + src/slic3r/GUI/KBShortcutsDialog.cpp | 1 + src/slic3r/GUI/MainFrame.cpp | 25 +- src/slic3r/GUI/MainFrame.hpp | 2 + src/slic3r/GUI/Plater.cpp | 73 +++- src/slic3r/GUI/Plater.hpp | 2 +- src/slic3r/GUI/PublishSettingsDialog.cpp | 213 ++++----- src/slic3r/GUI/PublishSettingsDialog.hpp | 33 +- tests/libslic3r/test_3mf.cpp | 95 +++++ .../libslic3r/test_preset_bundle_loading.cpp | 403 ++++++++++++++++++ 13 files changed, 1037 insertions(+), 135 deletions(-) diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index febfa5d4dc..8a047fea00 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -4783,6 +4783,11 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool if (is_published) { std::vector skipped_keys; std::set applied_keys; + // Set whenever the material overlay actually modifies a receiver filament preset + // (applied key, colour or slot replacement). Only then must the edited preset be + // re-snapshotted: re-selecting unconditionally would discard the user's unsaved + // in-memory filament edits when the published file touches nothing. + bool material_applied = false; // Structural keys must never be applied to the user's presets: doing so would // rewrite their preset inheritance/structure. This is the single source of truth // shared with PublishSettingsDialog.cpp (publish_structural_keys in @@ -4869,6 +4874,11 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool return true; }; for (const PublishedMaterialEntry &entry : published_config->material_keys) { + // Entries using the filament-publishing-v2 features (full dump, published type or + // colour) are handled by the positional per-slot pass below; the legacy identity + // matching here applies only to files that predate those features. + if (entry.full || entry.publish_type || entry.publish_color) + continue; // Resolve the author's source slot and its ordinal among the author slots // carrying this entry's identity. A slotted entry (slot >= 0) names the exact // author slot and targets the receiver's Nth matching preset (N = ordinal); @@ -5002,11 +5012,212 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool continue; } static_cast(dst_opt)->set_at(src_opt, 0, author_slot); + material_applied = true; } } } } + // Filament-publishing-v2: positional per-slot entries. The author published, per slot, + // either the entire filament (full) or specific keys plus optionally a curated type + // and/or colour. The receiver's slot is matched positionally against the published type: + // - colour: always applied to the slot, independent of the type gate; + // - type match: a full dump is intentionally ignored (the receiver keeps its material), + // a partial entry's keys are applied as usual; + // - type mismatch: the slot is replaced with the first visible same-type filament from + // the receiver's library; the author's values are applied on top of it (full) or the + // published keys are applied (partial); + // - no replacement available: a full entry falls back to applying the author's values + // in-memory onto the receiver's current preset (no library import); a partial entry + // keeps the receiver's material and reports its keys as skipped. + { + // Slot growth is tied to the author slots that carry published content (full, + // type or colour): the file's total filament count is irrelevant, and a slot the + // author left unpublished must not pull a filler material into the receiver's + // setup. Grow only as far as the highest published slot (never shrink, never + // remove the receiver's existing materials). + bool has_new_semantics = false; + size_t target_slots = this->filament_presets.size(); + for (const PublishedMaterialEntry &entry : published_config->material_keys) { + if (!entry.full && !entry.publish_type && !entry.publish_color) + continue; // legacy entry, handled above + has_new_semantics = true; + if (entry.slot >= 0) + target_slots = std::max(target_slots, size_t(entry.slot) + 1); + } + if (has_new_semantics) { + // Defensive cap: never exceed the file's own filament count. + target_slots = std::min(target_slots, num_filaments); + while (this->filament_presets.size() < target_slots) { + const size_t new_slot_idx = this->filament_presets.size(); + std::string initial_preset; + // Proactively assign matching candidate preset if this slot carries a published type + for (const PublishedMaterialEntry &entry : published_config->material_keys) { + if (entry.slot == static_cast(new_slot_idx) && entry.publish_type && !entry.publish_type_value.empty()) { + for (size_t i = 0; i < this->filaments.size(); ++i) { + const Preset &candidate = this->filaments.preset(i); + if (!candidate.is_visible) + continue; + if (normalize_filament_type(candidate.config.opt_string("filament_type", 0u)) == entry.publish_type_value) { + initial_preset = candidate.name; + break; + } + } + break; + } + } + if (initial_preset.empty()) + initial_preset = this->filaments.first_visible().name; + this->filament_presets.emplace_back(initial_preset); + } + + auto apply_slot_keys = [&](Preset &preset, const std::vector &slot_keys, int author_slot, + const std::string &material_label) { + for (const std::string &key : slot_keys) { + const std::string base_key = key.substr(0, key.find('#')); + if (structural_keys.count(base_key) != 0) + continue; + const ConfigOption *src_opt = config.option(base_key); + if (src_opt == nullptr || !src_opt->is_vector() || + author_slot < 0 || author_slot >= static_cast(static_cast(src_opt)->size())) { + skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); + continue; + } + ConfigOption *dst_opt = preset.config.option(base_key); + if (dst_opt == nullptr || !dst_opt->is_vector() || + static_cast(dst_opt)->empty() || + dst_opt->type() != src_opt->type()) { + skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); + continue; + } + // Per-slot scalar copy: the receiver's filament preset holds a single + // value per key (vector of size 1), the file holds the per-slot vector. + static_cast(dst_opt)->set_at(src_opt, 0, author_slot); + material_applied = true; + } + }; + + for (const PublishedMaterialEntry &entry : published_config->material_keys) { + if (!entry.full && !entry.publish_type && !entry.publish_color) + continue; // legacy entry, handled above + if (entry.slot < 0 || size_t(entry.slot) >= this->filament_presets.size()) + continue; // out of range: nothing to do for this slot + const size_t slot = size_t(entry.slot); + // Modify the stored preset itself (real=true), never the edited snapshot: + // find_preset would return &m_edited_preset for the currently selected slot, + // and the re-select at the end of this block re-snapshots from the stored + // preset, silently discarding any values applied to the snapshot. + Preset *recv = this->filaments.find_preset(this->filament_presets[slot], false, true); + if (recv == nullptr) + continue; + + const std::string material_label = entry.filament_id.empty() + ? (entry.publish_type_value.empty() ? entry.filament_type : entry.publish_type_value) + : entry.filament_id; + + bool apply_slot = true; + if (entry.publish_type && !entry.publish_type_value.empty()) { + const std::string recv_type = normalize_filament_type(recv->config.opt_string("filament_type", 0u)); + if (recv_type == entry.publish_type_value) { + // Type match: the receiver keeps its material. A full dump is + // intentionally ignored for this slot; partial keys still apply. + if (entry.full) + apply_slot = false; + } else { + // Type mismatch: replace the slot with the first visible same-type + // filament from the receiver's library. + std::string replacement; + for (size_t i = 0; i < this->filaments.size(); ++i) { + const Preset &candidate = this->filaments.preset(i); + if (!candidate.is_visible) + continue; + if (normalize_filament_type(candidate.config.opt_string("filament_type", 0u)) != entry.publish_type_value) + continue; + replacement = candidate.name; + break; + } + if (!replacement.empty()) { + const std::string old_name = recv->name; + this->filament_presets[slot] = replacement; + recv = this->filaments.find_preset(replacement, false, true); + material_applied = true; + published_config->material_replacements.emplace_back( + "slot " + std::to_string(slot) + ": " + old_name + " -> " + replacement); + } else if (entry.full) { + // No library match: create a temporary project-embedded custom preset + // populated with default settings and overlaid with the author's values. + std::string custom_name = entry.publish_type_value + " (Published)"; + for (size_t idx = 1; this->filaments.find_preset(custom_name, false) != nullptr; ++idx) + custom_name = entry.publish_type_value + " (Published " + std::to_string(idx) + ")"; + + // Capture the slot's current name BEFORE load_preset: the custom + // name sorts ahead of the slot's material, so the deque insertion + // relocates it and recv would dangle after the call. + const std::string old_name = recv->name; + + DynamicPrintConfig custom_cfg = this->filaments.default_preset_for(config).config; + // filament_type is a per-slot vector option: set it via the strings + // accessor. opt_string(key, bool) would ask for the scalar + // ConfigOptionString, fail the cast and dereference nullptr. + if (ConfigOptionStrings *type_opt = custom_cfg.opt("filament_type", true)) { + if (type_opt->values.empty()) + type_opt->values.emplace_back(); + type_opt->values[0] = entry.publish_type_value; + } + if (ConfigOptionStrings *id_opt = custom_cfg.opt("filament_settings_id", true)) + if (!id_opt->values.empty()) + id_opt->values[0] = custom_name; + + Preset &created = this->filaments.load_preset("", custom_name, std::move(custom_cfg), false, file_version); + created.is_project_embedded = true; + created.is_visible = true; + + this->filament_presets[slot] = custom_name; + recv = &created; + material_applied = true; + published_config->material_replacements.emplace_back( + "slot " + std::to_string(slot) + ": " + old_name + " -> " + custom_name); + } else { + // Partial publish with no replacement available: keep the + // receiver's material and report this slot's keys as skipped. + for (const std::string &key : entry.keys) + skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); + apply_slot = false; + } + } + } + + // Colour is slot-scoped and independent of the type gate: it is applied to + // whichever material ends up in the slot (original, replacement or the + // in-memory fallback), and synced into project_config for GUI rendering. + if (entry.publish_color && !entry.color.empty()) { + if (recv != nullptr) { + // Create the key when the target preset lacks it (e.g. a replacement + // built from the static defaults): the colour is a requirement, not + // an optional override. + if (ConfigOptionStrings *colour = recv->config.opt("filament_colour", true)) { + if (colour->values.empty()) + colour->values.emplace_back(); + colour->values[0] = entry.color; + material_applied = true; + } + } + if (ConfigOptionStrings *proj_colour = this->project_config.opt("filament_colour")) { + if (slot < proj_colour->values.size()) + proj_colour->values[slot] = entry.color; + } + if (ConfigOptionStrings *proj_multi_colour = this->project_config.opt("filament_multi_colour")) { + if (slot < proj_multi_colour->values.size()) + proj_multi_colour->values[slot] = entry.color; + } + } + + if (apply_slot && recv != nullptr) + apply_slot_keys(*recv, entry.full ? entry.full_keys : entry.keys, entry.slot, material_label); + } + } + } + for (const std::string &key : published_config->published_keys) { if (applied_keys.count(key) != 0) continue; @@ -5021,6 +5232,14 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool skipped_keys.emplace_back(key); } published_config->skipped_keys = std::move(skipped_keys); + + // The material overlay above modified the filament collection presets in place, but + // the edited preset (what the GUI displays) is a snapshot taken when the preset was + // last selected. Re-select the first slot's filament (mirroring a normal project load) + // so the applied values (colour, type, keys and slot replacements) surface in the GUI; + // selecting any other slot's filament afterwards snapshots its modified preset too. + if (material_applied && !this->filament_presets.empty()) + this->filaments.select_preset_by_name(this->filament_presets.front(), true); } //BBS diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index c8af2e4b71..d568de30e3 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -181,6 +181,9 @@ struct PublishedConfig // Keys that could not be applied (missing on the user's machine or vector size mismatch), // filled in by load_config_file_config for notification purposes. std::vector skipped_keys; + // Human-readable notices of the slot material replacements performed while loading a + // published project (e.g. "Slot 2: replaced PETG with PLA"), for the load notification. + std::vector material_replacements; }; // Bundle of Print + Filament + Printer presets. diff --git a/src/libslic3r/PublishSettings.cpp b/src/libslic3r/PublishSettings.cpp index 9ac0c49472..0d1bd07989 100644 --- a/src/libslic3r/PublishSettings.cpp +++ b/src/libslic3r/PublishSettings.cpp @@ -3,11 +3,31 @@ #include "PresetBundle.hpp" #include "Preset.hpp" #include "PrintConfig.hpp" +#include "MaterialType.hpp" #include +#include +#include namespace Slic3r { +std::string normalize_filament_type(const std::string& type) +{ + if (type.empty()) + return type; + if (MaterialType::find(type) != nullptr) + return type; + // "PLA High Speed" -> "PLA": strip a space-separated modifier, but keep dash-separated + // types like "PA-CF" / "PETG-CF" intact (they are distinct materials, not modifiers). + const size_t sep = type.find(' '); + if (sep != std::string::npos) { + const std::string base = type.substr(0, sep); + if (MaterialType::find(base) != nullptr) + return base; + } + return type; +} + const std::set& publish_structural_keys() { // Structural / non-publishable keys. The *_settings_id keys are also part of @@ -111,6 +131,13 @@ DynamicPrintConfig filter_published_config( DynamicPrintConfig filtered; std::set base_keys_to_include; + // Base keys that must never be masked: identity, plate geometry, process/printer keys and + // partially-published material keys keep today's whole-vector serialization (all slots). + std::set mask_exempt_keys; + // For keys carried only by "full" entries: base key -> author slots whose values must + // survive; the other slots are masked to their defaults so a full publish does not leak + // the author's unrelated slot data. + std::map> full_slot_map; // 1. Mandatory material identity & slot count keys for 3MF validation/normalization static const std::vector s_material_identity_keys = { @@ -122,8 +149,10 @@ DynamicPrintConfig filter_published_config( "filament_self_index", "filament_extruder_variant" }; - for (const std::string &key : s_material_identity_keys) + for (const std::string &key : s_material_identity_keys) { base_keys_to_include.insert(key); + mask_exempt_keys.insert(key); + } // 2. Published plate / bed geometry keys (wipe tower positioning) static const std::vector s_plate_geometry_keys = { @@ -131,29 +160,69 @@ DynamicPrintConfig filter_published_config( "wipe_tower_y", "wipe_tower_rotation_angle" }; - for (const std::string &key : s_plate_geometry_keys) + for (const std::string &key : s_plate_geometry_keys) { base_keys_to_include.insert(key); + mask_exempt_keys.insert(key); + } // 3. Process and printer published keys for (const std::string &key : published_keys) { const std::string base_key = key.substr(0, key.find('#')); - if (!base_key.empty()) + if (!base_key.empty()) { base_keys_to_include.insert(base_key); + mask_exempt_keys.insert(base_key); + } } // 4. Material-specific published keys for (const PublishedMaterialEntry &entry : material_keys) { for (const std::string &key : entry.keys) { const std::string base_key = key.substr(0, key.find('#')); - if (!base_key.empty()) + if (!base_key.empty()) { base_keys_to_include.insert(base_key); + mask_exempt_keys.insert(base_key); + } + } + // 4b. "Full publish" entries carry the entire slot; the values of the covered keys are + // masked to the author's slot on export (see the copy loop below). + for (const std::string &key : entry.full_keys) { + const std::string base_key = key.substr(0, key.find('#')); + if (base_key.empty()) + continue; + base_keys_to_include.insert(base_key); + if (entry.slot >= 0) + full_slot_map[base_key].insert(entry.slot); } } + // Mask a vector option's slots that are not author-published: copy the option default over + // each non-published index. Keys without an option default are left unmasked (the file then + // carries the whole vector, matching the partial-publish behavior). + auto mask_slots = [](ConfigOption &opt, const ConfigOptionDef *def, const std::set &keep_slots) { + auto *vec = dynamic_cast(&opt); + if (vec == nullptr || vec->size() == 0 || def == nullptr || !def->default_value) + return; + if (def->default_value->type() != opt.type()) + return; + const auto *default_vec = dynamic_cast(def->default_value.get()); + if (default_vec == nullptr || default_vec->empty()) + return; + for (size_t idx = 0; idx < vec->size(); ++idx) + if (keep_slots.count(static_cast(idx)) == 0) + vec->set_at(def->default_value.get(), idx, 0); + }; + // Copy selected options from full_config into filtered config for (const std::string &key : base_keys_to_include) { - if (const ConfigOption *opt = full_config.option(key)) - filtered.set_key_value(key, opt->clone()); + if (const ConfigOption *opt = full_config.option(key)) { + ConfigOption *cloned = opt->clone(); + if (mask_exempt_keys.count(key) == 0) { + const auto it = full_slot_map.find(key); + if (it != full_slot_map.end() && !it->second.empty()) + mask_slots(*cloned, print_config_def.get(key), it->second); + } + filtered.set_key_value(key, cloned); + } } return filtered; diff --git a/src/libslic3r/PublishSettings.hpp b/src/libslic3r/PublishSettings.hpp index 6525fbb66e..aadf0d3234 100644 --- a/src/libslic3r/PublishSettings.hpp +++ b/src/libslic3r/PublishSettings.hpp @@ -52,8 +52,30 @@ struct PublishedMaterialEntry { // entries apply to every matching receiver preset. int slot{-1}; std::vector keys; + // "Full Publish": the entire filament preset of this slot is serialized (see full_keys), + // not just the individually selected keys. On load the type gate (publish_type_value) + // decides whether the receiver keeps its material (type match) or is replaced; a full + // entry carries no partial keys. + bool full{false}; + // All non-structural filament keys of the author's slot preset, present when full is true. + // Values travel in the file config, masked to the author's slot index. + std::vector full_keys; + // Vendor-agnostic, curated (MaterialType) filament type the author requires for this slot. + // On load the receiver's slot material is matched against it; on mismatch the slot is + // replaced with a same-type filament from the receiver's library. + bool publish_type{false}; + std::string publish_type_value; + // Required filament colour for this slot, applied on load regardless of the type match. + bool publish_color{false}; + std::string color; }; +// Normalizes a filament type string against the curated MaterialType list: an exact match +// wins, then the value is stripped after its first space ("PLA High Speed" -> "PLA"); a +// value still not recognized is returned unchanged. Shared by the Publish dialog's type row +// default and by the published-3MF loader's type matching. +std::string normalize_filament_type(const std::string& type); + // Constructs a minimal DynamicPrintConfig for a published 3MF export containing only the // author-selected published keys, material keys, material identity fields, and plate geometry keys. class DynamicPrintConfig; diff --git a/src/slic3r/GUI/KBShortcutsDialog.cpp b/src/slic3r/GUI/KBShortcutsDialog.cpp index ed513c425f..df69fa5434 100644 --- a/src/slic3r/GUI/KBShortcutsDialog.cpp +++ b/src/slic3r/GUI/KBShortcutsDialog.cpp @@ -174,6 +174,7 @@ void KBShortcutsDialog::fill_shortcuts() { ctrl + "O", L("Open Project") }, { ctrl + "S", L("Save Project") }, { ctrl + shift + "S", L("Save Project as")}, + { ctrl + shift + "E", L("Publish") }, // File>Import { ctrl + "I", L("Import geometry data from STL/STEP/3MF/OBJ/AMF files") }, // File>Export diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index abca800277..3cb246529f 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -741,6 +741,10 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_ if (m_plater) { m_plater->add_file(); } return; } + if (evt.CmdDown() && evt.ShiftDown() && evt.GetKeyCode() == 'E') { + if (can_export_model()) publish_project(); + return; + } evt.Skip(); }); @@ -1728,6 +1732,16 @@ bool MainFrame::save_project_as(const wxString& filename) return ret; } +void MainFrame::publish_project() +{ + if (m_plater == nullptr) + return; + PublishSettingsDialog dlg(this); + if (dlg.ShowModal() != wxID_OK) + return; + m_plater->export_published_3mf(dlg.GetPublishedKeys(), dlg.GetPublishedMaterialKeys()); +} + bool MainFrame::can_upload() const { return true; @@ -2820,19 +2834,14 @@ void MainFrame::init_menubar_as_editor() // BBS: publish fileMenu->AppendSeparator(); - auto publish_handler = [this](wxCommandEvent&) { - if (!m_plater) return; - PublishSettingsDialog dlg(this); - if (dlg.ShowModal() != wxID_OK) return; - m_plater->export_published_3mf(dlg.GetPublishedKeys(), dlg.GetPublishedMaterialKeys()); - }; + auto publish_handler = [this](wxCommandEvent&) { publish_project(); }; #ifndef __APPLE__ - append_menu_item(fileMenu, wxID_ANY, _L("Publish") + dots, _L("Export a 3MF file with the selected settings embedded"), + append_menu_item(fileMenu, wxID_ANY, _L("Publish") + dots + "\t" + ctrl + shift + "E", _L("Export a 3MF file with the selected settings embedded"), publish_handler, "menu_publish", nullptr, [this](){return can_export_model(); }, this); #else - append_menu_item(fileMenu, wxID_ANY, _L("Publish") + dots, _L("Export a 3MF file with the selected settings embedded"), + append_menu_item(fileMenu, wxID_ANY, _L("Publish") + dots + "\t" + ctrl + shift + "E", _L("Export a 3MF file with the selected settings embedded"), publish_handler, "", nullptr, [this](){return can_export_model(); }, this); #endif diff --git a/src/slic3r/GUI/MainFrame.hpp b/src/slic3r/GUI/MainFrame.hpp index a614783c31..040b9447e8 100644 --- a/src/slic3r/GUI/MainFrame.hpp +++ b/src/slic3r/GUI/MainFrame.hpp @@ -340,6 +340,8 @@ public: bool can_upload() const; void save_project(); bool save_project_as(const wxString& filename = wxString()); + // Open the Publish dialog and export the selected settings as a published 3MF. + void publish_project(); void add_to_recent_projects(const wxString& filename); void get_recent_projects(boost::property_tree::wptree &tree, int images); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 82dd38fe97..28ba184d1c 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -5449,7 +5449,7 @@ struct Plater::priv BoundingBox scaled_bed_shape_bb() const; // BBS: backup & restore - std::vector load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi = false); + std::vector load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi = false, bool* published_out = nullptr); std::vector load_model_objects(const ModelObjectPtrs& model_objects, bool allow_negative_z = false, bool split_object = false, bool auto_drop = true); fs::path get_export_file_path(GUI::FileType file_type); @@ -6759,7 +6759,7 @@ void read_binary_stl(const std::string& filename, std::string& model_id, std::st } // BBS: backup & restore -std::vector Plater::priv::load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi) +std::vector Plater::priv::load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi, bool* published_out) { std::vector empty_result; bool dlg_cont = true; @@ -7257,6 +7257,22 @@ std::vector Plater::priv::load_files(const std::vector& input_ for (const auto &k : *entry_keys_it) if (k.is_string()) entry.keys.emplace_back(k.get()); + // Filament-publishing-v2 fields; absent in legacy files. + if (m.contains("full") && m["full"].is_boolean()) + entry.full = m["full"].get(); + const auto entry_full_keys_it = m.find("full_keys"); + if (entry_full_keys_it != m.end() && entry_full_keys_it->is_array()) + for (const auto &k : *entry_full_keys_it) + if (k.is_string()) + entry.full_keys.emplace_back(k.get()); + if (m.contains("publish_type") && m["publish_type"].is_boolean()) + entry.publish_type = m["publish_type"].get(); + if (m.contains("type") && m["type"].is_string()) + entry.publish_type_value = m["type"].get(); + if (m.contains("publish_color") && m["publish_color"].is_boolean()) + entry.publish_color = m["publish_color"].get(); + if (m.contains("color") && m["color"].is_string()) + entry.color = m["color"].get(); published_config.material_keys.emplace_back(std::move(entry)); } } catch (...) { @@ -7266,6 +7282,18 @@ std::vector Plater::priv::load_files(const std::vector& input_ } } + // BBS: a "published" 3MF behaves like a new project once loaded: the file's path + // must not become the project filename (Save/Ctrl-S would otherwise overwrite the + // shared file), and the published metadata is consumed by the overlay above and + // stripped so a later save produces a normal, unpublished 3MF. + if (published_out != nullptr && published_config.published) + *published_out = true; + if (published_config.published && load_config && this->model.model_info != nullptr) { + this->model.model_info->metadata_items.erase("published"); + this->model.model_info->metadata_items.erase("published_keys"); + this->model.model_info->metadata_items.erase("published_material_keys"); + } + if (load_config) { if (!config.empty()) { Preset::normalize(config); @@ -7380,6 +7408,16 @@ std::vector Plater::priv::load_files(const std::vector& input_ notify_manager->bbl_show_3mf_warn_notification(message); } + // BBS: notify the user about slot materials that were replaced while + // loading a published project (type mismatch / no same-type match). + if (!published_config.material_replacements.empty()) { + NotificationManager *notify_manager = q->get_notification_manager(); + std::string message = _u8L("Some filament slots were changed to match the published materials:"); + for (const std::string &replacement : published_config.material_replacements) + message += "\n-" + replacement; + notify_manager->bbl_show_3mf_warn_notification(message); + } + ConfigOption* bed_type_opt = preset_bundle->project_config.option("curr_bed_type"); if (bed_type_opt != nullptr) { BedType bed_type = (BedType)bed_type_opt->getInt(); @@ -7519,7 +7557,13 @@ std::vector Plater::priv::load_files(const std::vector& input_ dynamic_map->value = false; } // Update filament combobox after loading config - wxGetApp().plater()->sidebar().update_presets(Preset::TYPE_FILAMENT); + if (published_config.published) { + q->update_filament_colors_in_full_config(); + wxGetApp().plater()->sidebar().update_all_preset_comboboxes(); + wxGetApp().plater()->sidebar().update_dynamic_filament_list(); + } else { + wxGetApp().plater()->sidebar().update_presets(Preset::TYPE_FILAMENT); + } // The loaded project supplies nozzle_volume_type; refresh the sidebar // nozzle-count badges against it. if (auto *nozzle_volumes = wxGetApp().preset_bundle->project_config.option("nozzle_volume_type")) { @@ -13244,14 +13288,15 @@ void Plater::load_project(wxString const& filename2, if (strategy & LoadStrategy::Restore) input_paths.push_back(into_u8(originfile)); - std::vector res = load_files(input_paths, strategy); + bool loaded_published = false; + std::vector res = load_files(input_paths, strategy, false, &loaded_published); reset_project_dirty_initial_presets(); update_project_dirty_from_presets(); wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); // if res is empty no data has been loaded - if (!res.empty() && (load_restore || !(strategy & LoadStrategy::Silence))) { + if (!res.empty() && !loaded_published && (load_restore || !(strategy & LoadStrategy::Silence))) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " call set_project_filename: " << (load_restore ? originfile : filename); p->set_project_filename(load_restore ? originfile : filename); if (load_restore && originfile.IsEmpty()) { @@ -13263,6 +13308,15 @@ void Plater::load_project(wxString const& filename2, BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " using ecported set project filename: " << filename; p->set_project_filename(filename); } + else if (loaded_published) { + // A "published" 3MF loads as a new project: the shared file's path must not become + // the project filename, so Save/Ctrl-S prompts for a destination instead of + // overwriting the published file. reset() above already cleared the project name + // and folder; restore the default new-project title and keep the file in recents. + p->set_project_name(_L("Untitled")); + if (!filename.IsEmpty()) + wxGetApp().mainframe->add_to_recent_projects(filename); + } } @@ -14918,12 +14972,12 @@ void Plater::force_update_all_plate_thumbnails() } // BBS: backup -std::vector Plater::load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi) { +std::vector Plater::load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi, bool* published_out) { //BBS: wish to reset state when load a new file p->m_slice_all_only_has_gcode = false; //BBS: wish to reset all plates stats item selected state when load a new file p->preview->get_canvas3d()->reset_select_plate_toolbar_selection(); - return p->load_files(input_files, strategy, ask_multi); + return p->load_files(input_files, strategy, ask_multi, published_out); } // To be called when providing a list of files to the GUI slic3r on command line. @@ -16186,7 +16240,10 @@ int Plater::export_published_3mf(const std::vector& published_keys, j.push_back(key); nlohmann::json jm = nlohmann::json::array(); for (const Slic3r::PublishedMaterialEntry& e : material_keys) - jm.push_back({ {"material", {{"filament_type", e.filament_type}, {"filament_vendor", e.filament_vendor}, {"filament_id", e.filament_id}}}, {"slot", e.slot}, {"keys", e.keys} }); + jm.push_back({ {"material", {{"filament_type", e.filament_type}, {"filament_vendor", e.filament_vendor}, {"filament_id", e.filament_id}}}, {"slot", e.slot}, {"keys", e.keys}, + {"full", e.full}, {"full_keys", e.full_keys}, + {"publish_type", e.publish_type}, {"type", e.publish_type_value}, + {"publish_color", e.publish_color}, {"color", e.color} }); Model& model = this->model(); // Remember the previous metadata state so it can be restored after the export, keeping the diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index d29e86a4fa..d6ec283409 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -383,7 +383,7 @@ public: bool preview_zip_archive(const boost::filesystem::path& archive_path); // BBS: restore - std::vector load_files(const std::vector& input_files, LoadStrategy strategy = LoadStrategy::LoadModel | LoadStrategy::LoadConfig, bool ask_multi = false); + std::vector load_files(const std::vector& input_files, LoadStrategy strategy = LoadStrategy::LoadModel | LoadStrategy::LoadConfig, bool ask_multi = false, bool* published_out = nullptr); // To be called when providing a list of files to the GUI slic3r on command line. std::vector load_files(const std::vector& input_files, LoadStrategy strategy = LoadStrategy::LoadModel | LoadStrategy::LoadConfig, bool ask_multi = false); // to be called on drag and drop diff --git a/src/slic3r/GUI/PublishSettingsDialog.cpp b/src/slic3r/GUI/PublishSettingsDialog.cpp index 63333ea451..5baff8282c 100644 --- a/src/slic3r/GUI/PublishSettingsDialog.cpp +++ b/src/slic3r/GUI/PublishSettingsDialog.cpp @@ -290,6 +290,25 @@ void PublishSettingsDialog::build_option_model() const PublishMaterialIdentity identity = material_identity(slot, full); const wxString title = material_title(slot, bundle, full); const size_t category_index = category_index_for(title, Section::Material, "custom-gcode_filament", g, slot, identity); + + // Filament-publishing-v2 rows: the author may require a filament colour and/or + // a vendor-agnostic material type for this slot. They live in their own + // optgroup so they stay visually separated from the setting rows. + { + const size_t req_sub = subcategory_index_for(category_index, _L("Material"), "custom-gcode_filament"); + std::string hex; + if (const auto* colours = full.opt("filament_colour")) + if (slot < colours->size()) + hex = colours->get_at(slot); + add_row_ui("filament_colour", _L("Color"), from_u8(hex), wxString(), category_index, req_sub, RowKind::Color); + std::string type; + if (const auto* types = full.opt("filament_type")) + if (slot < types->size()) + type = types->get_at(slot); + add_row_ui("filament_type", _L("Type"), from_u8(normalize_filament_type(type)), wxString(), category_index, req_sub, + RowKind::Type); + } + // A material section must not repeat a key; the same key may // appear in other material sections - that is intended. std::set material_added; @@ -371,6 +390,10 @@ void PublishSettingsDialog::build_option_model() dirty_base.insert(n == std::string::npos ? key : key.substr(0, n)); } for (Row& row : m_rows) { + // The Color/Type requirement rows are not "dirty overrides": they are never + // auto-checked by the dirty pre-check. + if (row.kind != RowKind::Setting) + continue; std::string base = row.key.substr(0, row.key.find('#')); row.dirty = dirty_base.count(base) > 0; if (row.dirty) { @@ -379,24 +402,11 @@ void PublishSettingsDialog::build_option_model() } } - // Wire the inner-page tri-state headers: clicking a header toggles all its children; - // toggling any child re-syncs its header. Bind by index so the lambdas stay - // valid even if the vectors are reallocated later. - for (size_t c = 0; c < m_categories.size(); ++c) { - if (m_categories[c].master_check != nullptr) - m_categories[c].master_check->Bind(wxEVT_CHECKBOX, [this, c](wxCommandEvent&) { on_master_toggle(c); }); - if (m_categories[c].header != nullptr) - m_categories[c].header->Bind(wxEVT_CHECKBOX, [this, c](wxCommandEvent&) { on_category_toggle(c); }); - for (size_t r : m_categories[c].rows) - m_rows[r].check->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent&) { update_all_headers(); }); - update_category_header(m_categories[c]); - } - - // Material pages start gated (master OFF): their rows and tri-state are - // disabled until the author opts the material in. + // Wire the "Full Publish" checkboxes: toggling one disables/enables the material's + // rows. Bind by index so the lambda stays valid even if the vector is reallocated later. for (size_t c = 0; c < m_categories.size(); ++c) - if (m_categories[c].section == Section::Material) - on_master_toggle(c); + if (m_categories[c].full_check != nullptr) + m_categories[c].full_check->Bind(wxEVT_CHECKBOX, [this, c](wxCommandEvent&) { on_full_toggle(c); }); // No filter is active at startup: every row matches until the user types. for (Row& row : m_rows) @@ -502,13 +512,13 @@ size_t PublishSettingsDialog::category_index_for(const wxString& title, category.filament_color_chip = new wxStaticBitmap(category.page, wxID_ANY, *chip); header_sizer->Add(category.filament_color_chip, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); } - category.master_check = new wxCheckBox(category.page, wxID_ANY, title); - category.master_check->SetFont(Label::Head_14); - category.master_check->SetToolTip(_L("Export this material")); - header_sizer->Add(category.master_check, 0, wxALIGN_CENTER_VERTICAL); - category.header = new wxCheckBox(category.page, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxCHK_3STATE); - category.header->SetToolTip(_L("Select/deselect all settings in this material")); - header_sizer->Add(category.header, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(6)); + category.title_label = new wxStaticText(category.page, wxID_ANY, title); + category.title_label->SetFont(Label::Head_14); + header_sizer->Add(category.title_label, 0, wxALIGN_CENTER_VERTICAL); + category.full_check = new wxCheckBox(category.page, wxID_ANY, _L("Full Publish")); + category.full_check->SetFont(Label::Body_13); + category.full_check->SetToolTip(_L("Embed the entire filament of this slot in the 3MF file")); + header_sizer->Add(category.full_check, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); page_sizer->Add(header_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); } @@ -575,7 +585,8 @@ void PublishSettingsDialog::add_row_ui(const std::string& key, const wxString& value, const wxString& unit, size_t category_index, - size_t subcategory_index) + size_t subcategory_index, + RowKind kind) { Category& category = m_categories[category_index]; Row row; @@ -583,6 +594,7 @@ void PublishSettingsDialog::add_row_ui(const std::string& key, row.label = label; row.value = value; row.unit = unit; + row.kind = kind; row.category = category.title; row.subcategory = category.subs[subcategory_index].title; row.section = category.section; @@ -595,82 +607,38 @@ void PublishSettingsDialog::add_row_ui(const std::string& key, Row& current = m_rows[row_index]; current.check = new wxCheckBox(category.scroll, wxID_ANY, label); current.check->SetFont(Label::Body_13); + auto* row_sizer = new wxBoxSizer(wxHORIZONTAL); + row_sizer->Add(current.check, 0, wxALIGN_CENTER_VERTICAL); + // The value is read-only text (incl. the Type row: the published type is the slot's + // normalized type, the author cannot pick a different one here). current.value_label = new wxStaticText(category.scroll, wxID_ANY, value, wxDefaultPosition, wxDefaultSize, wxST_ELLIPSIZE_END); current.value_label->SetFont(Label::Body_13); current.value_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#262E30"))); current.value_label->SetToolTip(unit.IsEmpty() ? value : value + " " + unit); + if (kind == RowKind::Color && !value.IsEmpty()) { + if (wxBitmap* chip = get_extruder_color_icon(value.ToStdString(), "", FromDIP(12), FromDIP(12))) { + current.color_chip = new wxStaticBitmap(category.scroll, wxID_ANY, *chip); + row_sizer->Add(current.color_chip, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + } + } + row_sizer->Add(current.value_label, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); if (!unit.IsEmpty()) { current.unit_label = new wxStaticText(category.scroll, wxID_ANY, unit); current.unit_label->SetFont(Label::Body_13); current.unit_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#6B6B6B"))); - } - auto* row_sizer = new wxBoxSizer(wxHORIZONTAL); - row_sizer->Add(current.check, 0, wxALIGN_CENTER_VERTICAL); - row_sizer->Add(current.value_label, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); - if (current.unit_label != nullptr) row_sizer->Add(current.unit_label, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(4)); + } current.item = category.list_sizer->Add(row_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(38)); category.rows.push_back(row_index); category.subs[subcategory_index].rows.push_back(row_index); } -void PublishSettingsDialog::on_category_toggle(size_t category_index) +void PublishSettingsDialog::on_full_toggle(size_t category_index) { Category& cat = m_categories[category_index]; - // Defensive: a gated material header is disabled and cannot fire. - if (cat.section == Section::Material && !cat.master) - return; - // A click on the header toggles between "all" and "none": if every child is - // checked, uncheck all; otherwise check all. - bool all_checked = true; + cat.full = cat.full_check->GetValue(); for (size_t r : cat.rows) - if (!m_rows[r].check->GetValue()) { - all_checked = false; - break; - } - bool value = !all_checked; - for (size_t r : cat.rows) - m_rows[r].check->SetValue(value); - update_all_headers(); -} - -void PublishSettingsDialog::on_master_toggle(size_t category_index) -{ - Category& cat = m_categories[category_index]; - cat.master = cat.master_check->GetValue(); - for (size_t r : cat.rows) - m_rows[r].check->Enable(cat.master); - cat.header->Enable(cat.master); - update_all_headers(); -} - -void PublishSettingsDialog::update_all_headers() -{ - for (Category& category : m_categories) - update_category_header(category); -} - -void PublishSettingsDialog::update_category_header(Category& category) -{ - if (category.header == nullptr) - return; - // A gated material section's tri-state must not reflect the preserved - // (greyed-out) row values. - if (category.section == Section::Material && !category.master) { - category.header->Set3StateValue(wxCHK_UNCHECKED); - return; - } - int checked = 0; - for (size_t r : category.rows) - if (m_rows[r].check->GetValue()) - ++checked; - - if (checked == 0) - category.header->Set3StateValue(wxCHK_UNCHECKED); - else if (checked == static_cast(category.rows.size())) - category.header->Set3StateValue(wxCHK_CHECKED); - else - category.header->Set3StateValue(wxCHK_UNDETERMINED); + m_rows[r].check->Enable(!cat.full); } void PublishSettingsDialog::set_row_bold(Row& row, bool bold) @@ -845,7 +813,6 @@ void PublishSettingsDialog::select_all(bool value) for (Row& row : m_rows) if (row.check->IsEnabled()) row.check->SetValue(value); - update_all_headers(); } bool PublishSettingsDialog::row_is_visible(const Row& row) const @@ -876,9 +843,8 @@ void PublishSettingsDialog::select_visible(bool value) // re-enters apply_filter() - that is fine, the rows above were already // toggled and the trailing call below is idempotent. m_filter_ctrl->ChangeValue(""); - apply_filter(""); // resync visibility, headers and the All/None bar + apply_filter(""); // resync visibility and the All/None bar } - update_all_headers(); } void PublishSettingsDialog::show_menu(wxMouseEvent& evt) @@ -942,24 +908,67 @@ std::vector PublishSettingsDialog::GetPublishedM { std::vector out; for (const Category& cat : m_categories) { - // Only opted-in materials export their keys. - if (cat.section != Section::Material || !cat.master) + if (cat.section != Section::Material) continue; Slic3r::PublishedMaterialEntry entry; entry.filament_type = cat.filament_type; entry.filament_vendor = cat.filament_vendor; entry.filament_id = cat.filament_id; entry.slot = static_cast(cat.filament_slot); - for (size_t r : cat.rows) - if (m_rows[r].check->GetValue()) - entry.keys.push_back(m_rows[r].key); - // A section without any checked key carries no information for the writer. - if (!entry.keys.empty()) + // "Full Publish": the entire filament preset of the slot is embedded; type and color + // are implicitly published, and the per-key rows are disabled and their state is ignored. + if (cat.full_check != nullptr && cat.full_check->GetValue()) { + entry.full = true; + entry.full_keys = full_keys_for_slot(); + entry.publish_type = true; + entry.publish_type_value = normalize_filament_type(cat.filament_type); + for (size_t r : cat.rows) { + const Row& row = m_rows[r]; + if (row.kind == RowKind::Color && !row.value.IsEmpty()) { + entry.publish_color = true; + entry.color = row.value.ToStdString(); + } + } + out.push_back(std::move(entry)); + continue; + } + for (size_t r : cat.rows) { + const Row& row = m_rows[r]; + if (!row.check->GetValue()) + continue; + if (row.kind == RowKind::Color) { + entry.publish_color = true; + entry.color = row.value.ToStdString(); + } else if (row.kind == RowKind::Type) { + entry.publish_type = true; + entry.publish_type_value = row.value.ToStdString(); + } else { + entry.keys.push_back(row.key); + } + } + // A material with only setting keys but none checked, or with nothing selected at all, + // carries no information for the writer. + if (!entry.keys.empty() || entry.publish_type || entry.publish_color) out.push_back(std::move(entry)); } return out; } +std::vector PublishSettingsDialog::full_keys_for_slot() const +{ + // The canonical filament preset keys, minus the structural keys the published overlay must + // never touch (inherits, compatibility, *_settings_id, ...), plus filament_colour (not a + // member of Preset::filament_options). The values travel in the exported config, masked to + // this slot, and are applied on load onto the receiver's slot. + const std::set& denylist = publish_structural_keys(); + std::vector keys; + for (const std::string& key : Preset::filament_options()) + if (denylist.count(key) == 0) + keys.emplace_back(key); + keys.emplace_back("filament_colour"); + return keys; +} + void PublishSettingsDialog::on_dpi_changed(const wxRect& suggested_rect) { // Rescale toolbar bitmaps and icons; collapse chevrons are vector-drawn and repaint themselves. @@ -974,10 +983,10 @@ void PublishSettingsDialog::on_dpi_changed(const wxRect& suggested_rect) cat.icon_bmp.msw_rescale(); cat.icon->SetBitmap(cat.icon_bmp.bmp()); } - if (cat.header != nullptr) - cat.header->Refresh(); - if (cat.master_check != nullptr) - cat.master_check->Refresh(); + if (cat.full_check != nullptr) + cat.full_check->Refresh(); + if (cat.title_label != nullptr) + cat.title_label->Refresh(); if (cat.filament_color_chip != nullptr) { std::string hex; const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); @@ -994,6 +1003,14 @@ void PublishSettingsDialog::on_dpi_changed(const wxRect& suggested_rect) for (SectionGroup& section : m_sections) section.tabs->Rescale(); + // Refresh the per-row Color chips at the new DPI. + for (Row& row : m_rows) { + if (row.color_chip != nullptr && !row.value.IsEmpty()) { + if (wxBitmap* chip = get_extruder_color_icon(row.value.ToStdString(), "", FromDIP(12), FromDIP(12))) + row.color_chip->SetBitmap(*chip); + } + } + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); for (size_t category_index = 0; category_index < m_categories.size(); ++category_index) { const Category& category = m_categories[category_index]; diff --git a/src/slic3r/GUI/PublishSettingsDialog.hpp b/src/slic3r/GUI/PublishSettingsDialog.hpp index 949c08a81b..70b1dc8a2d 100644 --- a/src/slic3r/GUI/PublishSettingsDialog.hpp +++ b/src/slic3r/GUI/PublishSettingsDialog.hpp @@ -59,6 +59,11 @@ private: // One selectable setting row: a checkbox (setting name) plus a value label // and a (optional) grey unit label. key is the full config key and may carry // a "#N" variant suffix (print/printer rows); material rows carry the base key. + enum class RowKind { + Setting, // a regular setting key + Color, // material colour requirement (filament_colour) + Type, // material type requirement (read-only text) + }; struct Row { std::string key; @@ -69,6 +74,7 @@ private: wxString unit; wxString section_title; // outer tab title, for filter matching Section section{Section::Print}; + RowKind kind{RowKind::Setting}; size_t outer_index{0}; size_t inner_index{0}; size_t subcategory_index{0}; @@ -77,6 +83,7 @@ private: wxCheckBox* check{nullptr}; wxStaticText* value_label{nullptr}; wxStaticText* unit_label{nullptr}; + wxStaticBitmap* color_chip{nullptr}; // Color rows only; swatch next to the value wxSizerItem* item{nullptr}; // sizer item of this row's h-sizer in its tab list sizer }; @@ -89,7 +96,7 @@ private: std::vector rows; }; - // An inner TabCtrl page with its select-all/material controls and content. + // An inner TabCtrl page with its material controls and content. struct Category { wxString title; @@ -106,11 +113,11 @@ private: ScalableBitmap icon_bmp; // scalable bitmap for DPI changes wxStaticBitmap* icon{nullptr}; wxStaticBitmap* filament_color_chip{nullptr}; - wxCheckBox* header{nullptr}; // material select-all tri-state; null for Printer/Process - // Material opt-in: the master checkbox carries the material title and - // gates whether this material's keys may be exported. - bool master{false}; - wxCheckBox* master_check{nullptr}; + wxStaticText* title_label{nullptr}; // material title (static text, Full Publish carries the label elsewhere) + // "Full Publish": serializing the entire filament preset of this slot. While checked, + // the slot's rows (incl. Color/Type) are disabled. + bool full{false}; + wxCheckBox* full_check{nullptr}; // Material identity, only for Section::Material categories. std::string filament_type; std::string filament_vendor; @@ -118,7 +125,7 @@ private: // The author's 0-based filament slot this material section represents. size_t filament_slot{0}; std::vector subs; - std::vector rows; // flattened rows, for the tri-state math + std::vector rows; // flattened rows of this category }; // One outer TabCtrl page. Category entries are its inner tabs. @@ -140,25 +147,23 @@ private: void select_all(bool value); void select_visible(bool value); void show_menu(wxMouseEvent& evt); - void update_category_header(Category& category); void set_row_bold(Row& row, bool bold); - void on_category_toggle(size_t category_index); - // Material opt-in toggled: enables/disables the material's rows + tri-state - // and resyncs the header. - void on_master_toggle(size_t category_index); + // "Full Publish" toggled: disables/enables the material's rows. + void on_full_toggle(size_t category_index); // Return/create the fixed outer page for a Section kind. size_t section_group_for(Section kind); size_t category_index_for(const wxString& title, Section section, const std::string& icon_name, size_t group, size_t source_index, const PublishMaterialIdentity& identity = PublishMaterialIdentity()); size_t subcategory_index_for(size_t category_index, const wxString& title, const wxString& icon); void add_row_ui(const std::string& key, const wxString& label, const wxString& value, const wxString& unit, - size_t category_index, size_t subcategory_index); + size_t category_index, size_t subcategory_index, RowKind kind = RowKind::Setting); + // The non-structural filament keys of a slot's preset, for a "Full Publish" entry. + std::vector full_keys_for_slot() const; void save_scroll_position(Category& category); void show_outer_page(size_t section_index); void show_inner_page(size_t section_index, int inner_index); void on_outer_tab_changed(wxCommandEvent& event); void on_inner_tab_changed(size_t section_index, wxCommandEvent& event); - void update_all_headers(); bool row_is_visible(const Row& row) const; void apply_visibility(); void bind_tab_events(); diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index daf46c3834..d9d6833a5a 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -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("filament_diameter")->values = { 1.75, 1.75 }; + full_cfg.opt("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("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("filament_flow_ratio")->values[1] == 0.98); + } + THEN("the non-published slot is masked to its default") { + REQUIRE(filtered_cfg.opt("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(); + 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 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() == true); + REQUIRE(entries[0]["full_keys"].is_array()); + REQUIRE(entries[0]["full_keys"].size() == 2); + REQUIRE(entries[0]["publish_type"].get() == true); + REQUIRE(entries[0]["type"] == "PLA"); + REQUIRE(entries[0]["publish_color"].get() == false); + } + release_PlateData_list(dst_plates); + } + } +} diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index fc61083896..f1d2c79f95 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -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("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 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("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("filament_retraction_length")->values == std::vector{ 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("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); + + 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("filament_retraction_length")->values == std::vector{ 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("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("filament_retraction_length")->values == std::vector{ 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("filament_retraction_length")->values == std::vector{ 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("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 applied, colour applied. + CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); + CHECK(bundle.filaments.find_preset("My PLA")->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); + + // 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("filament_retraction_length")->values == std::vector{ 0.5 }); + CHECK(bundle.filaments.find_preset("My PLA")->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, 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("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); + CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_colour")->values == std::vector{ "#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("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(); + CHECK(edited.config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + CHECK(edited.config.opt("filament_retraction_length")->values == std::vector{ 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("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(edited.config.opt("filament_retraction_length")->values == std::vector{ 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.