diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt
index 1614bc453b..c36a3aba40 100644
--- a/localization/i18n/list.txt
+++ b/localization/i18n/list.txt
@@ -136,6 +136,7 @@ src/slic3r/GUI/BackgroundSlicingProcess.cpp
src/slic3r/GUI/BedShapeDialog.cpp
src/slic3r/GUI/BedShapeDialog.hpp
src/slic3r/GUI/ConfigManipulation.cpp
+src/slic3r/GUI/ConfigValueFormatter.cpp
src/slic3r/GUI/DeviceManager.cpp
src/slic3r/GUI/DeviceErrorDialog.cpp
src/slic3r/GUI/ExtraRenderers.cpp
@@ -175,6 +176,7 @@ src/slic3r/GUI/ProgressStatusBar.cpp
src/slic3r/GUI/PlateSettingsDialog.cpp
src/slic3r/GUI/PrivacyUpdateDialog.cpp
src/slic3r/GUI/PublishDialog.cpp
+src/slic3r/GUI/PublishSettingsDialog.cpp
src/slic3r/GUI/SavePresetDialog.cpp
src/slic3r/GUI/Search.cpp
src/slic3r/GUI/Selection.cpp
diff --git a/resources/images/menu_publish.svg b/resources/images/menu_publish.svg
new file mode 100644
index 0000000000..e444623e33
--- /dev/null
+++ b/resources/images/menu_publish.svg
@@ -0,0 +1 @@
+
diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt
index 812d28e088..16bee2d70c 100644
--- a/src/libslic3r/CMakeLists.txt
+++ b/src/libslic3r/CMakeLists.txt
@@ -350,6 +350,8 @@ set(lisbslic3r_sources
Preset.hpp
PrincipalComponents2D.cpp
PrincipalComponents2D.hpp
+ PublishSettings.cpp
+ PublishSettings.hpp
PrintApply.cpp
PrintBase.cpp
PrintBase.hpp
diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp
index 01cbc43bc2..9e0fc61323 100644
--- a/src/libslic3r/PresetBundle.cpp
+++ b/src/libslic3r/PresetBundle.cpp
@@ -3,6 +3,7 @@
#include "PresetBundle.hpp"
#include "PrintConfig.hpp"
+#include "PublishSettings.hpp"
#include "libslic3r.h"
#include "I18N.hpp"
#include "Utils.hpp"
@@ -11,7 +12,7 @@
#include "libslic3r_version.h"
#include
-#include
+#include
#include
#include
#include
@@ -40,6 +41,8 @@
namespace Slic3r {
+// Project-level options imported from a loaded 3MF into project_config. s_project_options_published
+// below is the reduced subset that crosses over in "published" 3MF mode; keep both in sync.
static std::vector s_project_options {
"flush_volumes_vector",
"flush_volumes_matrix",
@@ -71,6 +74,24 @@ static std::vector s_project_options {
"enable_filament_dynamic_map"
};
+// Project options applied when loading a "published" 3MF project: the full s_project_options
+// minus the filament/purge keys. A published file must not port the author's filament data
+// (colors, colour types, filament/map/AMS slot state, purge/prime/flush volumes, nozzle
+// volume types, filament switcher state) to the receiver's project_config, which feeds the
+// scene colors, AMS slot colors and purge data. Only the plate/bed geometry keys cross over;
+// normal (non-published) 3MF loads keep importing the author's flush data via s_project_options.
+//
+// KEEP IN SYNC with s_project_options above: when a new project option is added there, decide
+// here whether it is plate/bed geometry (add it to this list) or filament/purge/mapping/device
+// state (it must NOT be added). curr_bed_type is deliberately NOT in this list: the receiver
+// keeps its own bed type when loading a published project. The published-mode project_config
+// assertions in tests/libslic3r/test_preset_bundle_loading.cpp guard both directions.
+static std::vector s_project_options_published {
+ "wipe_tower_x",
+ "wipe_tower_y",
+ "wipe_tower_rotation_angle"
+};
+
//Orca: add custom as default
const char *PresetBundle::ORCA_DEFAULT_BUNDLE = "Custom";
const char *PresetBundle::ORCA_DEFAULT_PRINTER_MODEL = "MyKlipper 0.4 nozzle";
@@ -4426,10 +4447,14 @@ static void convert_filament_preset_name(std::string& machine_name, std::string&
}
// Load a config file from a boost property_tree. This is a private method called from load_config_file.
// is_external == false on if called from ConfigWizard
-void PresetBundle::load_config_file_config(const std::string &name_or_path, bool is_external, DynamicPrintConfig &&config, Semver file_version, bool selected)
+void PresetBundle::load_config_file_config(const std::string &name_or_path, bool is_external, DynamicPrintConfig &&config, Semver file_version, bool selected, PublishedConfig *published_config)
{
PrinterTechnology printer_technology = Preset::printer_technology(config);
+ // A "published" 3MF project keeps the user's currently-selected presets and overlays only
+ // the author-selected published keys onto the edited presets.
+ const bool is_published = published_config != nullptr && published_config->published;
+
auto clear_compatible_printers = [](DynamicPrintConfig& config){
ConfigOption *opt_compatible = config.optptr("compatible_printers");
if (opt_compatible != nullptr) {
@@ -4570,6 +4595,10 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool
switch (Preset::printer_technology(config)) {
case ptFFF:
{
+ // A "published" 3MF project keeps the user's currently-selected presets, so the
+ // print / printer / filament presets are NOT loaded from the file. Only the
+ // project config values and the published keys are applied below.
+ if (!is_published) {
//BBS: add different settings logic
BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load print preset from print_settings_id");
std::vector print_different_keys_vector;
@@ -4722,9 +4751,12 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool
this->filament_presets[i] = loaded->name;
}
}
+ } // !is_published
// 4) Load the project config values (the per extruder wipe matrix etc).
- this->project_config.apply_only(config, s_project_options);
+ // In published mode the receiver must not inherit the author's filament/purge data,
+ // so only the plate/bed geometry project keys are applied.
+ this->project_config.apply_only(config, is_published ? s_project_options_published : s_project_options);
break;
}
@@ -4743,9 +4775,258 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool
this->update_compatible(PresetSelectCompatibleType::Never);
this->update_multi_material_filament_presets();
+ // A "published" 3MF project overlays only the author-selected published keys onto the
+ // user's currently-selected (edited) process preset. Scalar keys are applied directly;
+ // vector (multi-extruder) keys are applied only when the edited preset has a matching
+ // vector size. Keys that cannot be applied are collected for notification; legacy
+ // filament/printer keys in a published file fall through into skipped_keys.
+ if (is_published) {
+ std::vector skipped_keys;
+ std::set applied_keys;
+ // 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
+ // PublishSettings.hpp). Defense-in-depth: a hand-crafted 3MF could set
+ // published_keys to these regardless of the dialog, so skip them here too.
+ const std::set &structural_keys = publish_structural_keys();
+ // The printer overlay is restricted to the publishable retraction/z-hop allowlist.
+ // Printer-class keys outside it are contract-excluded: never applied and never
+ // reported as skipped (a hand-crafted 3MF listing machine_start_gcode or
+ // nozzle_diameter must not apply them and must not spam the warning).
+ const std::set &printer_allowlist = publishable_printer_keys();
+ const std::vector &printer_options = Preset::printer_options();
+ const std::set printer_option_set(printer_options.begin(), printer_options.end());
+ std::set contract_excluded_keys;
+ auto apply_published = [&](DynamicPrintConfig &target, const std::set *allowlist) {
+ for (const std::string &key : published_config->published_keys) {
+ if (applied_keys.count(key) != 0)
+ continue; // already applied
+ // A '#' suffix denotes a variant (per-extruder/per-filament) key; resolve the base key.
+ const std::string base_key = key.substr(0, key.find('#'));
+ // Structural keys are intentionally never applied (not "skipped due to
+ // mismatch"), so bail out before the applied/skipped bookkeeping.
+ if (structural_keys.count(base_key) != 0)
+ continue;
+ if (allowlist != nullptr &&
+ printer_option_set.count(base_key) != 0 &&
+ allowlist->count(base_key) == 0) {
+ // Printer-class key outside the publishable allowlist: contract-excluded.
+ contract_excluded_keys.insert(base_key);
+ continue;
+ }
+ const ConfigOption *src_opt = config.option(base_key);
+ if (src_opt == nullptr)
+ continue; // key not present in the loaded config; record later
+ if (src_opt->is_vector()) {
+ // Vector key: apply only when the edited preset has a matching vector size.
+ const ConfigOption *dst_opt = target.option(base_key);
+ if (dst_opt == nullptr || !dst_opt->is_vector() ||
+ static_cast(src_opt)->size() != static_cast(dst_opt)->size())
+ continue; // cannot apply; will be reported as skipped
+ // A '#' variant index must be in range: ConfigOptionVector::set_at would
+ // otherwise resize the destination vector, corrupting the receiver's preset.
+ if (key.size() > base_key.size()) {
+ const size_t idx = static_cast(std::atoi(key.c_str() + base_key.size() + 1));
+ if (idx >= static_cast(src_opt)->size())
+ continue; // out-of-range variant: cannot apply; reported as skipped
+ }
+ target.apply_only(config, {key}, true);
+ applied_keys.insert(key);
+ } else {
+ // A scalar key cannot carry a '#N' variant suffix; a hand-crafted file
+ // listing one is reported as skipped instead of being silently marked applied.
+ if (key.find('#') != std::string::npos)
+ continue;
+ // Scalar key: apply only if present on the user's machine.
+ if (target.option(base_key) == nullptr)
+ continue; // not present on the edited preset; will be reported as skipped
+ target.apply_only(config, {key}, true);
+ applied_keys.insert(key);
+ }
+ }
+ };
+ apply_published(this->prints.get_edited_preset().config, nullptr);
+ apply_published(this->printers.get_edited_preset().config, &printer_allowlist);
+
+ // Material pass: apply the author's material-qualified keys onto the receiver's
+ // matching filament presets. The file config carries the author's per-slot identity
+ // (filament_type / filament_vendor remain in config; filament_ids was moved into a
+ // local earlier) and the per-slot material retraction values.
+ if (!published_config->material_keys.empty()) {
+ const ConfigOptionStrings *file_types = config.option("filament_type");
+ const ConfigOptionStrings *file_vendors = config.option("filament_vendor");
+ auto identity_matches = [](const std::string &id, const std::string &type, const std::string &vendor,
+ const std::string &slot_id, const std::string &slot_type, const std::string &slot_vendor) {
+ // When both sides carry a filament_id, equality is required; otherwise fall
+ // back to filament_type, with filament_vendor as an additional qualifier only
+ // when both sides have a non-empty vendor.
+ if (!id.empty() && !slot_id.empty())
+ return id == slot_id;
+ if (type.empty() || type != slot_type)
+ return false;
+ if (!vendor.empty() && !slot_vendor.empty())
+ return vendor == slot_vendor;
+ return true;
+ };
+ for (const PublishedMaterialEntry &entry : published_config->material_keys) {
+ // 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);
+ // a legacy entry (slot -1) uses the first matching author slot and applies to
+ // every matching receiver preset.
+ auto slot_identity = [&filament_ids, file_types, file_vendors](size_t slot, std::string &id, std::string &type, std::string &vendor) {
+ id = (slot < filament_ids.size()) ? filament_ids[slot] : std::string();
+ type = (file_types && slot < file_types->size()) ? file_types->get_at(slot) : std::string();
+ vendor = (file_vendors && slot < file_vendors->size()) ? file_vendors->get_at(slot) : std::string();
+ };
+ bool author_found = false;
+ size_t author_slot = 0;
+ size_t author_ordinal = 0;
+ if (entry.slot >= 0) {
+ // Collect every author slot carrying this identity, in slot order; the
+ // entry's slot must be among them, and its position is the ordinal used
+ // to pick the receiver's matching preset.
+ std::vector matching_author_slots;
+ for (size_t slot = 0; slot < filament_ids.size(); ++slot) {
+ std::string slot_id, slot_type, slot_vendor;
+ slot_identity(slot, slot_id, slot_type, slot_vendor);
+ if (identity_matches(entry.filament_id, entry.filament_type, entry.filament_vendor,
+ slot_id, slot_type, slot_vendor))
+ matching_author_slots.emplace_back(slot);
+ }
+ const auto ordinal_it = std::find(matching_author_slots.begin(), matching_author_slots.end(), size_t(entry.slot));
+ if (ordinal_it != matching_author_slots.end()) {
+ author_slot = size_t(entry.slot);
+ author_ordinal = size_t(ordinal_it - matching_author_slots.begin());
+ author_found = true;
+ }
+ // Out of range, or the slot does not carry this identity: silent skip below.
+ } else {
+ // Legacy: the first author slot whose identity matches.
+ for (size_t slot = 0; slot < filament_ids.size(); ++slot) {
+ std::string slot_id, slot_type, slot_vendor;
+ slot_identity(slot, slot_id, slot_type, slot_vendor);
+ if (identity_matches(entry.filament_id, entry.filament_type, entry.filament_vendor,
+ slot_id, slot_type, slot_vendor)) {
+ author_slot = slot;
+ author_found = true;
+ break;
+ }
+ }
+ }
+ if (!author_found)
+ // No author slot carries this material: nothing to apply, nothing to report.
+ continue;
+
+ const std::string material_label = entry.filament_id.empty() ? entry.filament_type : entry.filament_id;
+ auto report_skipped = [&skipped_keys, &material_label](const std::string &key, const std::string &slot_qualifier = std::string()) {
+ skipped_keys.emplace_back("material:" + material_label +
+ (slot_qualifier.empty() ? std::string() : " " + slot_qualifier) +
+ " (" + key + ")");
+ };
+
+ // Collect the receiver's matching filament presets (distinct by preset name).
+ std::vector matched_preset_names;
+ std::set fallback_matched_names;
+ for (const std::string &preset_name : this->filament_presets) {
+ Preset *preset = this->filaments.find_preset(preset_name);
+ if (preset == nullptr)
+ continue;
+ const std::string slot_id = preset->filament_id;
+ // Null-guard the identity reads: a malformed user preset may lack
+ // filament_type / filament_vendor entirely (hand-edited preset file).
+ const ConfigOptionStrings *slot_types = preset->config.option("filament_type");
+ const ConfigOptionStrings *slot_vendors = preset->config.option("filament_vendor");
+ const std::string slot_type = (slot_types && !slot_types->values.empty()) ? slot_types->get_at(0) : std::string();
+ const std::string slot_vendor = (slot_vendors && !slot_vendors->values.empty()) ? slot_vendors->get_at(0) : std::string();
+ if (!identity_matches(entry.filament_id, entry.filament_type, entry.filament_vendor,
+ slot_id, slot_type, slot_vendor))
+ continue;
+ if (std::find(matched_preset_names.begin(), matched_preset_names.end(), preset_name) == matched_preset_names.end())
+ matched_preset_names.emplace_back(preset_name);
+ if (entry.filament_id.empty() || slot_id.empty())
+ fallback_matched_names.insert(preset_name);
+ }
+ if (matched_preset_names.empty()) {
+ // No receiver material matches this entry: report each key as skipped.
+ for (const std::string &key : entry.keys)
+ report_skipped(key);
+ continue;
+ }
+ if (fallback_matched_names.size() > 1) {
+ // The type fallback matched more than one distinct receiver preset: never
+ // guess which one the author meant.
+ for (const std::string &key : entry.keys)
+ report_skipped(key);
+ continue;
+ }
+
+ // Slotted entries target the receiver's matching preset at the author's
+ // ordinal; legacy entries apply to every matching receiver preset.
+ std::vector apply_to_preset_names;
+ if (entry.slot >= 0) {
+ if (author_ordinal >= matched_preset_names.size()) {
+ // The receiver has fewer matching presets than the author's ordinal:
+ // this slot's values cannot be placed, report each key.
+ const std::string slot_qualifier = "slot " + std::to_string(entry.slot);
+ for (const std::string &key : entry.keys)
+ report_skipped(key, slot_qualifier);
+ continue;
+ }
+ apply_to_preset_names.emplace_back(matched_preset_names[author_ordinal]);
+ } else {
+ apply_to_preset_names = matched_preset_names;
+ }
+
+ for (const std::string &key : entry.keys) {
+ const std::string base_key = key.substr(0, key.find('#'));
+ if (structural_keys.count(base_key) != 0)
+ continue; // structural: silent
+ const ConfigOption *src_opt = config.option(base_key);
+ if (src_opt == nullptr || !src_opt->is_vector() ||
+ author_slot >= static_cast(src_opt)->size()) {
+ report_skipped(key);
+ continue;
+ }
+ for (const std::string &preset_name : apply_to_preset_names) {
+ Preset *preset = this->filaments.find_preset(preset_name);
+ if (preset == nullptr)
+ continue;
+ ConfigOption *dst_opt = preset->config.option(base_key);
+ // 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.
+ if (dst_opt == nullptr || !dst_opt->is_vector() ||
+ static_cast(dst_opt)->empty() ||
+ dst_opt->type() != src_opt->type()) {
+ report_skipped(key);
+ continue;
+ }
+ static_cast(dst_opt)->set_at(src_opt, 0, author_slot);
+ }
+ }
+ }
+ }
+
+ for (const std::string &key : published_config->published_keys) {
+ if (applied_keys.count(key) != 0)
+ continue;
+ const std::string base_key = key.substr(0, key.find('#'));
+ // Structural keys are silently ignored, never reported as skipped: a hand-crafted
+ // 3MF must not trigger the "could not be applied" warning for them.
+ if (structural_keys.count(base_key) != 0)
+ continue;
+ // Printer-class keys outside the publishable allowlist are contract-excluded too.
+ if (contract_excluded_keys.count(base_key) != 0)
+ continue;
+ skipped_keys.emplace_back(key);
+ }
+ published_config->skipped_keys = std::move(skipped_keys);
+ }
+
//BBS
//const std::string &physical_printer = config.option("physical_printer_settings_id", true)->value;
const std::string physical_printer;
+ if (!is_published) {
if (this->printers.get_edited_preset().is_external || physical_printer.empty()) {
this->physical_printers.unselect_printer();
} else {
@@ -4756,6 +5037,7 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool
else
this->physical_printers.unselect_printer();
}
+ }
//BBS: add config related logs
BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": finished");
}
diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp
index 685687975b..c8af2e4b71 100644
--- a/src/libslic3r/PresetBundle.hpp
+++ b/src/libslic3r/PresetBundle.hpp
@@ -3,6 +3,7 @@
#include "Preset.hpp"
#include "AppConfig.hpp"
+#include "PublishSettings.hpp"
#include "enum_bitmask.hpp"
#include
@@ -166,6 +167,22 @@ struct PresetBundleMetadata
}
};
+// Configuration describing a "published" 3MF project: the file carries a flag plus a list of
+// author-selected setting keys. When loading such a project the user's currently-selected
+// presets are kept and only the published keys are overlaid onto the edited presets.
+struct PublishedConfig
+{
+ bool published = false;
+ std::vector published_keys;
+ // Material-qualified published keys chosen by the author for the materials used in the
+ // project; applied on load only to the receiver's filament presets whose material
+ // identity matches (see PublishedMaterialEntry in PublishSettings.hpp).
+ std::vector material_keys;
+ // 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;
+};
+
// Bundle of Print + Filament + Printer presets.
class PresetBundle
{
@@ -414,8 +431,8 @@ public:
// Load configuration that comes from a model file containing configuration, such as 3MF et al.
// This method is called by the Plater.
- void load_config_model(const std::string &name, DynamicPrintConfig config, Semver file_version = Semver())
- { this->load_config_file_config(name, true, std::move(config), file_version); }
+ void load_config_model(const std::string &name, DynamicPrintConfig config, Semver file_version = Semver(), PublishedConfig *published_config = nullptr)
+ { this->load_config_file_config(name, true, std::move(config), file_version, false, published_config); }
// Load an external config file containing the print, filament and printer presets.
// Instead of a config file, a G-code may be loaded containing the full set of parameters.
@@ -544,7 +561,7 @@ private:
// Load print, filament & printer presets from a config. If it is an external config, then the name is extracted from the external path.
// and the external config is just referenced, not stored into user profile directory.
// If it is not an external config, then the config will be stored into the user profile directory.
- void load_config_file_config(const std::string &name_or_path, bool is_external, DynamicPrintConfig &&config, Semver file_version = Semver(), bool selected = false);
+ void load_config_file_config(const std::string &name_or_path, bool is_external, DynamicPrintConfig &&config, Semver file_version = Semver(), bool selected = false, PublishedConfig *published_config = nullptr);
/*ConfigSubstitutions load_config_file_config_bundle(
const std::string &path, const boost::property_tree::ptree &tree, ForwardCompatibilitySubstitutionRule compatibility_rule);*/
diff --git a/src/libslic3r/PublishSettings.cpp b/src/libslic3r/PublishSettings.cpp
new file mode 100644
index 0000000000..4c0a851696
--- /dev/null
+++ b/src/libslic3r/PublishSettings.cpp
@@ -0,0 +1,105 @@
+#include "PublishSettings.hpp"
+
+#include "PresetBundle.hpp"
+#include "Preset.hpp"
+
+#include
+
+namespace Slic3r {
+
+const std::set& publish_structural_keys()
+{
+ // Structural / non-publishable keys. The *_settings_id keys are also part of
+ // PresetCollection::skipped_in_dirty (Preset.cpp) and are excluded there too.
+ // This mirrors the structural keys stripped from configs in Preset.cpp
+ // (profile_print_params_same) plus other keys that must never be published
+ // because they would rewrite the user's preset inheritance/structure.
+ static const std::set structural_keys = {
+ "printer_settings_id", "filament_settings_id", "print_settings_id",
+ "sla_print_settings_id", "sla_material_settings_id",
+ "compatible_printers", "compatible_prints",
+ "compatible_printers_condition", "compatible_prints_condition",
+ "default_filament_profile", "default_print_profile",
+ "default_sla_print_profile", "default_sla_material_profile",
+ "extruder_count", "bed_shape",
+ "inherits", "inherits_group",
+ "printer_technology", "printer_model", "printer_variant",
+ "physical_printer_settings_id", "filament_ids",
+ "different_settings_to_system"
+ };
+ return structural_keys;
+}
+
+// The printer tab's "Retraction" optgroup (TabPrinter::build_fff, Tab.cpp), in tab order.
+// KEEP IN SYNC with that optgroup: the published-3MF printer allowlist is built from these
+// lists, so any key shown there must be publishable here (and vice versa).
+const std::vector& publishable_printer_retraction_options()
+{
+ static const std::vector options = {
+ { "retraction_length", "printer_extruder_retraction#length" },
+ { "retract_restart_extra", "printer_extruder_retraction#extra-length-on-restart" },
+ { "retraction_speed", "printer_extruder_retraction#retraction-speed" },
+ { "deretraction_speed", "printer_extruder_retraction#deretraction-speed" },
+ { "retraction_minimum_travel", "printer_extruder_retraction#travel-distance-threshold" },
+ { "retract_when_changing_layer", "printer_extruder_retraction#retract-on-layer-change" },
+ { "wipe", "printer_extruder_retraction#wipe-while-retracting" },
+ { "wipe_distance", "printer_extruder_retraction#wipe-distance" },
+ { "retract_before_wipe", "printer_extruder_retraction#retract-amount-before-wipe" },
+ { "retract_after_wipe", "printer_extruder_retraction#retract-amount-after-wipe" },
+ };
+ return options;
+}
+
+// The printer tab's "Z-Hop" optgroup (TabPrinter::build_fff, Tab.cpp), in tab order. KEEP IN
+// SYNC with that optgroup, same as publishable_printer_retraction_options().
+const std::vector& publishable_printer_z_hop_options()
+{
+ static const std::vector options = {
+ { "retract_lift_enforce", "printer_extruder_z_hop#on-surfaces" },
+ { "z_hop_types", "printer_extruder_z_hop#z-hop-type" },
+ { "z_hop", "printer_extruder_z_hop#z-hop-height" },
+ { "travel_slope", "printer_extruder_z_hop#traveling-angle" },
+ { "retract_lift_above", "printer_extruder_z_hop#only-lift-z-above" },
+ { "retract_lift_below", "printer_extruder_z_hop#only-lift-z-below" },
+ };
+ return options;
+}
+
+const std::set& publishable_printer_keys()
+{
+ // The union of the printer tab's "Retraction" and "Z-Hop" optgroups. The "Retraction when
+ // switching material" keys are intentionally excluded: toolchange retraction is
+ // device/profile territory, not a publishable behavior tweak.
+ static const std::set printer_keys = [] {
+ std::set keys;
+ for (const PublishablePrinterOption &opt : publishable_printer_retraction_options())
+ keys.insert(opt.key);
+ for (const PublishablePrinterOption &opt : publishable_printer_z_hop_options())
+ keys.insert(opt.key);
+ return keys;
+ }();
+ return printer_keys;
+}
+
+std::vector collect_dirty_settings_keys(const PresetBundle& bundle)
+{
+ std::vector keys;
+
+ auto append_dirty = [&keys](const std::vector& dirty) {
+ for (const std::string& key : dirty) {
+ if (std::find(keys.begin(), keys.end(), key) == keys.end())
+ keys.push_back(key);
+ }
+ };
+
+ // Print and printer presets each track a single edited preset; filaments may span
+ // multiple slots (multi-material). Union the dirty keys of each collection's edited
+ // preset; this feeds only the Publish dialog's pre-check.
+ append_dirty(bundle.prints.current_dirty_options(true));
+ append_dirty(bundle.printers.current_dirty_options(true));
+ append_dirty(bundle.filaments.current_dirty_options(true));
+
+ return keys;
+}
+
+} // namespace Slic3r
diff --git a/src/libslic3r/PublishSettings.hpp b/src/libslic3r/PublishSettings.hpp
new file mode 100644
index 0000000000..ecc939d883
--- /dev/null
+++ b/src/libslic3r/PublishSettings.hpp
@@ -0,0 +1,56 @@
+#pragma once
+#include
+#include
+#include
+
+namespace Slic3r {
+class PresetBundle;
+
+// Structural / non-publishable setting keys, shared by the Publish dialog and the published-3MF
+// overlay path in PresetBundle::load_config_file_config. These keys must never be published
+// because they would rewrite the user's preset inheritance/structure. This is the single
+// source of truth for the denylist.
+const std::set& publish_structural_keys();
+
+// One option row of the printer tab's "Retraction" / "Z-Hop" optgroups (TabPrinter::build_fff,
+// Tab.cpp). Key and icon id are kept together so the tab can later be migrated onto these
+// lists; publishable_printer_keys() is their union, and the published-3MF loader/dialog must
+// never accept printer keys outside it.
+struct PublishablePrinterOption {
+ const char *key; // config key, e.g. "retraction_length"
+ const char *icon; // tab icon id, e.g. "printer_extruder_retraction#length"
+};
+
+// The printer tab's "Retraction" optgroup options, in tab order.
+const std::vector& publishable_printer_retraction_options();
+// The printer tab's "Z-Hop" optgroup options, in tab order.
+const std::vector& publishable_printer_z_hop_options();
+
+// Printer-class retraction / z-hop keys that are publishable: the union of
+// publishable_printer_retraction_options() and publishable_printer_z_hop_options(). The
+// published-3MF overlay applies printer keys only when their base key is in this allowlist;
+// any other printer-class key in a published file is contract-excluded (never applied, never
+// reported as skipped).
+const std::set& publishable_printer_keys();
+
+// Returns the union of setting keys that differ from the base/system preset across the current
+// print, printer and filament presets (feeds the Publish dialog's pre-check).
+std::vector collect_dirty_settings_keys(const PresetBundle& bundle);
+
+// A material-qualified set of published setting keys, chosen by the author for one of the
+// materials used in the project. The identity fields let the receiver apply the keys only
+// when a matching material is selected: filament_id is the most precise (stable across
+// machines/vendors when present, empty for user presets); filament_type + filament_vendor
+// are the fallback. Keys are base keys (no "#N" variant suffix).
+struct PublishedMaterialEntry {
+ std::string filament_type; // material family, e.g. "PLA" (may be empty)
+ std::string filament_vendor; // e.g. "Generic", "Bambu" (may be empty)
+ std::string filament_id; // stable material id, e.g. "GFL99" (may be empty)
+ // 0-based author filament slot this entry's values came from; -1 = legacy/unspecified
+ // (files written before the slot field). Slotted entries apply to the receiver's Nth
+ // matching preset (N = the slot's ordinal among the author's matching slots); legacy
+ // entries apply to every matching receiver preset.
+ int slot{-1};
+ std::vector keys;
+};
+}
diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt
index b98396f943..4d103ff749 100644
--- a/src/slic3r/CMakeLists.txt
+++ b/src/slic3r/CMakeLists.txt
@@ -93,6 +93,8 @@ set(SLIC3R_GUI_SOURCES
GUI/CloneDialog.hpp
GUI/ConfigManipulation.cpp
GUI/ConfigManipulation.hpp
+ GUI/ConfigValueFormatter.cpp
+ GUI/ConfigValueFormatter.hpp
GUI/ConfigWizard.cpp
GUI/ConfigWizard.hpp
GUI/ConfigWizard_private.hpp
@@ -437,6 +439,8 @@ set(SLIC3R_GUI_SOURCES
GUI/Project.hpp
GUI/PublishDialog.cpp
GUI/PublishDialog.hpp
+ GUI/PublishSettingsDialog.cpp
+ GUI/PublishSettingsDialog.hpp
GUI/PurgeModeDialog.cpp
GUI/PurgeModeDialog.hpp
GUI/RammingChart.cpp
diff --git a/src/slic3r/GUI/ConfigValueFormatter.cpp b/src/slic3r/GUI/ConfigValueFormatter.cpp
new file mode 100644
index 0000000000..f3ea539d4f
--- /dev/null
+++ b/src/slic3r/GUI/ConfigValueFormatter.cpp
@@ -0,0 +1,245 @@
+#include "ConfigValueFormatter.hpp"
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#include "libslic3r/Config.hpp"
+#include "libslic3r/PrintConfig.hpp"
+
+#include "I18N.hpp"
+#include "GUI.hpp"
+#include "Field.hpp"
+
+namespace Slic3r {
+namespace GUI {
+
+std::string get_pure_opt_key(const std::string& opt_key)
+{
+ std::string pure_key = opt_key;
+ const int pos = pure_key.find("#");
+ if (pos > 0)
+ boost::erase_tail(pure_key, pure_key.size() - pos);
+ return pure_key;
+}
+
+wxString get_string_from_enum(const std::string& opt_key, const DynamicPrintConfig& config, bool is_infill, int idx)
+{
+ const ConfigOptionDef& def = config.def()->options.at(opt_key);
+ const std::vector& names = def.enum_labels;//ConfigOptionEnum::get_enum_names();
+ int val = 0;
+
+ if (idx >= 0)
+ val = dynamic_cast(config.option(opt_key))->get_at(idx);
+ else
+ val = config.option(opt_key)->getInt();
+
+ // Each infill doesn't use all list of infill declared in PrintConfig.hpp.
+ // So we should "convert" val to the correct one
+ if (is_infill) {
+ for (auto key_val : *def.enum_keys_map)
+ if (int(key_val.second) == val) {
+ auto it = std::find(def.enum_values.begin(), def.enum_values.end(), key_val.first);
+ if (it == def.enum_values.end())
+ return "";
+ return from_u8(_utf8(names[it - def.enum_values.begin()]));
+ }
+ return _L("Undefined");
+ }
+ return from_u8(_utf8(names[val]));
+}
+
+wxString get_full_label(const std::string& opt_key, const DynamicPrintConfig& config)
+{
+ const std::string pure_key = get_pure_opt_key(opt_key);
+ auto option = config.option(pure_key);
+
+ if (!option || option->is_nil())
+ return _L("N/A");
+
+ const ConfigOptionDef* opt = config.def()->get(pure_key);
+ return opt->full_label.empty() ? opt->label : opt->full_label;
+}
+
+wxString get_string_value(const std::string& opt_key, const DynamicPrintConfig& config)
+{
+ int orig_opt_idx = -1;
+ int opt_idx = -1;
+ int pos = opt_key.find("#");
+ std::string temp_str = opt_key;
+ if (pos > 0) {
+ boost::erase_head(temp_str, pos + 1);
+ orig_opt_idx = static_cast(atoi(temp_str.c_str()));
+ }
+ opt_idx = orig_opt_idx >= 0 ? orig_opt_idx : 0;
+ const std::string pure_key = get_pure_opt_key(opt_key);
+ auto option = config.option(pure_key);
+ if (!option) {
+ return _L("N/A");
+ }
+ auto opt_vector = dynamic_cast(option);
+
+ if (option->is_scalar() && config.option(pure_key)->is_nil() ||
+ option->is_vector() && opt_vector && opt_idx >= 0 && opt_idx < opt_vector->size() && opt_vector->is_nil(opt_idx))
+ return _L("N/A");
+
+ wxString out;
+
+ const ConfigOptionDef* opt = config.def()->get(pure_key);
+ bool is_nullable = opt->nullable;
+
+ switch (opt->type) {
+ case coInt:
+ return from_u8((boost::format("%1%") % config.opt_int(pure_key)).str());
+ case coInts: {
+ if (is_nullable) {
+ auto values = config.opt(pure_key);
+ if (opt_idx < values->size())
+ return from_u8((boost::format("%1%") % values->get_at(opt_idx)).str());
+ }
+ else {
+ auto values = config.opt(pure_key);
+ if (orig_opt_idx >= 0 && orig_opt_idx < values->size()) {
+ return from_u8((boost::format("%1%") % values->get_at(opt_idx)).str());
+ }
+ else {
+ std::string value_str;
+ for (int i = 0; i < values->size(); i++) {
+ value_str += std::to_string(values->get_at(i));
+ if (i != values->size() - 1) {
+ value_str += ",";
+ }
+ }
+ return from_u8(value_str);
+ }
+ }
+ return _L("Undefined");
+ }
+ case coBool:
+ return config.opt_bool(pure_key) ? "true" : "false";
+ case coBools: {
+ if (is_nullable) {
+ auto values = config.opt(pure_key);
+ if (opt_idx < values->size())
+ return values->get_at(opt_idx) ? "true" : "false";
+ }
+ else {
+ auto values = config.opt(pure_key);
+ if (opt_idx < values->size())
+ return values->get_at(opt_idx) ? "true" : "false";
+ }
+ return _L("Undefined");
+ }
+ case coPercent:
+ return from_u8((boost::format("%1%%%") % int(config.optptr(pure_key)->getFloat())).str());
+ case coPercents: {
+ if (is_nullable) {
+ auto values = config.opt(pure_key);
+ if (opt_idx < values->size())
+ return from_u8((boost::format("%1%%%") % values->get_at(opt_idx)).str());
+ }
+ else {
+ auto values = config.opt(pure_key);
+ if (opt_idx < values->size())
+ return from_u8((boost::format("%1%%%") % values->get_at(opt_idx)).str());
+ }
+ return _L("Undefined");
+ }
+ case coFloat:
+ return double_to_string(config.opt_float(pure_key));
+ case coFloats: {
+ if (is_nullable) {
+ auto values = config.opt(pure_key);
+ if (opt_idx < values->size())
+ return double_to_string(values->get_at(opt_idx));
+ }
+ else {
+ auto values = config.opt(pure_key);
+ if (values && opt_idx < values->size())
+ return double_to_string(values->get_at(opt_idx));
+ }
+ return _L("Undefined");
+ }
+ case coString:
+ return from_u8(config.opt_string(pure_key));
+ case coStrings: {
+ const ConfigOptionStrings* strings = config.opt(pure_key);
+ if (strings) {
+ if (pure_key == "compatible_printers" || pure_key == "compatible_prints") {
+ if (strings->empty())
+ return _L("All");
+ for (size_t id = 0; id < strings->size(); id++)
+ out += from_u8(strings->get_at(id)) + "\n";
+ out.RemoveLast(1);
+ return out;
+ }
+ if (!strings->empty() && opt_idx < strings->values.size())
+ return from_u8(strings->get_at(opt_idx));
+ }
+ break;
+ }
+ case coFloatOrPercent: {
+ const ConfigOptionFloatOrPercent* opt = config.opt(pure_key);
+ if (opt)
+ out = double_to_string(opt->value) + (opt->percent ? "%" : "");
+ return out;
+ }
+ case coEnum: {
+ return get_string_from_enum(pure_key, config,
+ pure_key == "top_surface_pattern" ||
+ pure_key == "bottom_surface_pattern" ||
+ pure_key == "internal_solid_infill_pattern" ||
+ pure_key == "sparse_infill_pattern" ||
+ pure_key == "ironing_pattern" ||
+ pure_key == "support_ironing_pattern" ||
+ pure_key == "support_pattern" ||
+ pure_key == "support_interface_pattern")
+ ;
+ }
+ case coEnums: {
+ return get_string_from_enum(pure_key, config,
+ pure_key == "top_surface_pattern" ||
+ pure_key == "bottom_surface_pattern" ||
+ pure_key == "internal_solid_infill_pattern" ||
+ pure_key == "sparse_infill_pattern" ||
+ pure_key == "ironing_pattern" ||
+ pure_key == "support_ironing_pattern" ||
+ pure_key == "support_pattern" ||
+ pure_key == "support_interface_pattern"
+ , opt_idx);
+ }
+ case coPoint: {
+ Vec2d val = config.opt(pure_key)->value;
+ return from_u8((boost::format("[%1%]") % ConfigOptionPoint(val).serialize()).str());
+ }
+ case coPoints: {
+ //BBS: add bed_exclude_area
+ if (pure_key == "printable_area" || pure_key == "thumbnails") {
+ ConfigOptionPoints points = *config.option(pure_key);
+ //BuildVolume build_volume = {points.values, 0.};
+ return get_thumbnails_string(points.values);
+ }
+ else if (pure_key == "bed_exclude_area") {
+ return get_thumbnails_string(config.option(pure_key)->values);
+ }
+ else if (pure_key == "head_wrap_detect_zone") {
+ return get_thumbnails_string(config.option(pure_key)->values);
+ }
+ else if (pure_key == "wrapping_exclude_area") {
+ return get_thumbnails_string(config.option(pure_key)->values);
+ }
+ Vec2d val = config.opt(pure_key)->get_at(opt_idx);
+ return from_u8((boost::format("[%1%]") % ConfigOptionPoint(val).serialize()).str());
+ }
+ default:
+ break;
+ }
+ return out;
+}
+
+} // namespace GUI
+} // namespace Slic3r
diff --git a/src/slic3r/GUI/ConfigValueFormatter.hpp b/src/slic3r/GUI/ConfigValueFormatter.hpp
new file mode 100644
index 0000000000..e7ed549930
--- /dev/null
+++ b/src/slic3r/GUI/ConfigValueFormatter.hpp
@@ -0,0 +1,31 @@
+#ifndef slic3r_ConfigValueFormatter_hpp_
+#define slic3r_ConfigValueFormatter_hpp_
+
+#include
+
+#include
+
+namespace Slic3r {
+
+class DynamicPrintConfig;
+
+namespace GUI {
+
+// Return the value of the given option (identified by opt_key, which may contain
+// a "#" suffix) formatted as a human readable string.
+wxString get_string_value(const std::string& opt_key, const DynamicPrintConfig& config);
+
+// Return the full label of the given option (identified by opt_key, which may contain
+// a "#" suffix). Returns "N/A" when the option is not set.
+wxString get_full_label(const std::string& opt_key, const DynamicPrintConfig& config);
+
+// Strip the "#" suffix (if any) from the given option key.
+std::string get_pure_opt_key(const std::string& opt_key);
+
+// Return the localized label of the currently selected value of an enum option.
+wxString get_string_from_enum(const std::string& opt_key, const DynamicPrintConfig& config, bool is_infill = false, int idx = -1);
+
+} // namespace GUI
+} // namespace Slic3r
+
+#endif // slic3r_ConfigValueFormatter_hpp_
diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp
index 5ef81a32e1..6aa7932c84 100644
--- a/src/slic3r/GUI/MainFrame.cpp
+++ b/src/slic3r/GUI/MainFrame.cpp
@@ -54,6 +54,7 @@
#include "GUI_App.hpp"
#include "UnsavedChangesDialog.hpp"
+#include "PublishSettingsDialog.hpp"
#include "MsgDialog.hpp"
#include "Notebook.hpp"
#include "GUI_Factories.hpp"
@@ -2821,6 +2822,25 @@ void MainFrame::init_menubar_as_editor()
[this](){return m_plater != nullptr && can_save_as(); }, this);
#endif
+ // BBS: publish settings
+ 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());
+ };
+
+#ifndef __APPLE__
+ append_menu_item(fileMenu, wxID_ANY, _L("Publish Settings") + dots, _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 Settings") + dots, _L("Export a 3MF file with the selected settings embedded"),
+ publish_handler, "", nullptr,
+ [this](){return can_export_model(); }, this);
+#endif
+
fileMenu->AppendSeparator();
diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp
index 8299125353..847d98f398 100644
--- a/src/slic3r/GUI/Plater.cpp
+++ b/src/slic3r/GUI/Plater.cpp
@@ -1,12 +1,10 @@
#include "Plater.hpp"
#include "../Utils/NetworkAgent.hpp"
-#include "../Utils/NetworkAgentFactory.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r_version.h"
#include
#include
-#include
#include
#include
#include
@@ -16,8 +14,6 @@
#include
#include
#include
-#include
-#include
#include
#include
#include
@@ -69,7 +65,6 @@
#include "libslic3r/Format/bbs_3mf.hpp"
#include "libslic3r/GCode/ThumbnailData.hpp"
#include "libslic3r/Model.hpp"
-#include "libslic3r/SLA/Hollowing.hpp"
#include "libslic3r/SLA/SupportPoint.hpp"
#include "libslic3r/SLA/ReprojectPointsOnMesh.hpp"
#include "libslic3r/Polygon.hpp"
@@ -78,16 +73,15 @@
#include "libslic3r/SLAPrint.hpp"
#include "libslic3r/Utils.hpp"
#include "libslic3r/PresetBundle.hpp"
+#include "libslic3r/PublishSettings.hpp"
#include "slic3r/Utils/CrealityPrint.hpp"
#include "libslic3r/ClipperUtils.hpp"
-#include "libslic3r/ObjColorUtils.hpp"
// For stl export
#include "libslic3r/CSGMesh/ModelToCSGMesh.hpp"
#include "libslic3r/CSGMesh/PerformCSGMeshBooleans.hpp"
#include "GUI.hpp"
#include "GUI_App.hpp"
-#include "GuiColor.hpp"
#include "GUI_ObjectList.hpp"
#ifdef __WXGTK__
#include "LinuxDisplayBackend.hpp"
@@ -123,7 +117,6 @@
#include "SendMultiMachinePage.hpp"
#include "SendToPrinter.hpp"
#include "PublishDialog.hpp"
-#include "ModelMall.hpp"
#include "ConfigWizard.hpp"
#include "SyncAmsInfoDialog.hpp"
#include "../Utils/ASCIIFolding.hpp"
@@ -149,7 +142,6 @@
#include "ParamsDialog.hpp"
#include "ImageDPIFrame.hpp"
#include "Widgets/Label.hpp"
-#include "Widgets/RoundedRectangle.hpp"
#include "Widgets/RadioGroup.hpp"
#include "Widgets/CheckBox.hpp"
#include "Widgets/Button.hpp"
@@ -5459,7 +5451,7 @@ struct Plater::priv
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);
- wxString get_export_file(GUI::FileType file_type);
+ wxString get_export_file(GUI::FileType file_type, const wxString& title = {});
// BBS
void load_auxiliary_files();
@@ -7214,6 +7206,64 @@ std::vector Plater::priv::load_files(const std::vector& input_
}
}
+ // BBS: a "published" 3MF project carries a flag plus a list of author-selected
+ // setting keys. When present, keep the user's currently-selected presets and
+ // overlay only the published keys onto the edited presets on load.
+ PublishedConfig published_config;
+ if (model.model_info != nullptr) {
+ auto published_it = model.model_info->metadata_items.find("published");
+ if (published_it != model.model_info->metadata_items.end() &&
+ (published_it->second == "true" || published_it->second == "1")) {
+ published_config.published = true;
+ auto keys_it = model.model_info->metadata_items.find("published_keys");
+ if (keys_it != model.model_info->metadata_items.end()) {
+ try {
+ auto j = nlohmann::json::parse(keys_it->second);
+ if (j.is_array())
+ for (const auto &k : j)
+ if (k.is_string())
+ published_config.published_keys.emplace_back(k.get());
+ } catch (...) {
+ // Ignore malformed published_keys; the project still loads normally.
+ }
+ }
+
+ auto material_keys_it = model.model_info->metadata_items.find("published_material_keys");
+ if (material_keys_it != model.model_info->metadata_items.end()) {
+ try {
+ auto jm = nlohmann::json::parse(material_keys_it->second);
+ if (jm.is_array())
+ for (const auto &m : jm) {
+ // Malformed entries are skipped individually.
+ if (!m.is_object())
+ continue;
+ PublishedMaterialEntry entry;
+ const auto mat_it = m.find("material");
+ if (mat_it != m.end() && mat_it->is_object()) {
+ const auto &mat = *mat_it;
+ if (mat.contains("filament_type") && mat["filament_type"].is_string())
+ entry.filament_type = mat["filament_type"].get();
+ if (mat.contains("filament_vendor") && mat["filament_vendor"].is_string())
+ entry.filament_vendor = mat["filament_vendor"].get();
+ if (mat.contains("filament_id") && mat["filament_id"].is_string())
+ entry.filament_id = mat["filament_id"].get();
+ }
+ if (m.contains("slot") && m["slot"].is_number_integer())
+ entry.slot = m["slot"].get();
+ const auto entry_keys_it = m.find("keys");
+ if (entry_keys_it != m.end() && entry_keys_it->is_array())
+ for (const auto &k : *entry_keys_it)
+ if (k.is_string())
+ entry.keys.emplace_back(k.get());
+ published_config.material_keys.emplace_back(std::move(entry));
+ }
+ } catch (...) {
+ // Ignore malformed published_material_keys; the project still loads normally.
+ }
+ }
+ }
+ }
+
if (load_config) {
if (!config.empty()) {
Preset::normalize(config);
@@ -7317,7 +7367,16 @@ std::vector Plater::priv::load_files(const std::vector& input_
if (wipe_tower_y_opt)
file_wipe_tower_y = *wipe_tower_y_opt;
- preset_bundle->load_config_model(filename.string(), std::move(config), file_version);
+ preset_bundle->load_config_model(filename.string(), std::move(config), file_version, &published_config);
+
+ // BBS: notify the user about published settings that could not be applied.
+ if (!published_config.skipped_keys.empty()) {
+ NotificationManager *notify_manager = q->get_notification_manager();
+ std::string message = _u8L("Some published settings could not be applied:");
+ for (const std::string &key : published_config.skipped_keys)
+ message += "\n-" + key;
+ 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) {
@@ -8179,7 +8238,7 @@ fs::path Plater::priv::get_export_file_path(GUI::FileType file_type)
return output_file;
}
-wxString Plater::priv::get_export_file(GUI::FileType file_type)
+wxString Plater::priv::get_export_file(GUI::FileType file_type, const wxString& title)
{
wxString wildcard;
switch (file_type) {
@@ -8222,7 +8281,7 @@ wxString Plater::priv::get_export_file(GUI::FileType file_type)
case FT_3MF:
{
output_file.replace_extension("3mf");
- dlg_title = _L("Save file as");
+ dlg_title = title.empty() ? _L("Save file as") : title;
break;
}
case FT_OBJ:
@@ -16105,6 +16164,76 @@ void Plater::export_core_3mf()
export_3mf(path_u8, SaveStrategy::Silence);
}
+// Export the current project as a "published" 3MF. This is a pure export: unlike save_project(),
+// it never touches the project's file name, dirty state, backup path or title, and the
+// published metadata is attached to the model only for the duration of the export so the
+// in-memory project stays exactly as it was (a later Save Project produces a normal 3MF).
+int Plater::export_published_3mf(const std::vector& published_keys, const std::vector& material_keys)
+{
+ wxString path = p->get_export_file(FT_3MF, _L("Publish 3MF file as:"));
+ if (path.empty() || path == "")
+ return wxID_CANCEL;
+
+ nlohmann::json j = nlohmann::json::array();
+ for (const std::string& key : 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} });
+
+ Model& model = this->model();
+ // Remember the previous metadata state so it can be restored after the export, keeping the
+ // in-memory project pristine (the published flag lives only in the exported file).
+ const bool had_model_info = (model.model_info != nullptr);
+ const bool had_published = had_model_info && (model.model_info->metadata_items.find("published") != model.model_info->metadata_items.end());
+ const bool had_published_keys = had_model_info && (model.model_info->metadata_items.find("published_keys") != model.model_info->metadata_items.end());
+ const bool had_material_keys = had_model_info && (model.model_info->metadata_items.find("published_material_keys") != model.model_info->metadata_items.end());
+ const std::string prev_published = had_published ? model.model_info->metadata_items.at("published") : std::string();
+ const std::string prev_published_keys = had_published_keys ? model.model_info->metadata_items.at("published_keys") : std::string();
+ const std::string prev_material_keys = had_material_keys ? model.model_info->metadata_items.at("published_material_keys") : std::string();
+ if (model.model_info == nullptr)
+ model.model_info = std::make_shared();
+ model.model_info->metadata_items["published"] = "1";
+ model.model_info->metadata_items["published_keys"] = j.dump();
+ model.model_info->metadata_items["published_material_keys"] = jm.dump();
+
+ // Same file layout save_project() uses for its project files, plus SaveStrategy::Silence:
+ // without it export_3mf() calls set_project_filename() on success, which would make this
+ // pure export the current project file. Silence keeps the project state untouched, exactly
+ // like export_core_3mf().
+ auto save_strategy = SaveStrategy::SplitModel | SaveStrategy::ShareMesh | SaveStrategy::Silence;
+ bool full_pathnames = wxGetApp().app_config->get_bool("export_sources_full_pathnames");
+ if (full_pathnames)
+ save_strategy = save_strategy | SaveStrategy::FullPathSources;
+
+ const int ret = export_3mf(into_path(path), save_strategy);
+
+ // Restore the previous metadata state (both on success and on failure).
+ if (!had_model_info) {
+ model.model_info = nullptr;
+ } else {
+ if (had_published)
+ model.model_info->metadata_items["published"] = prev_published;
+ else
+ model.model_info->metadata_items.erase("published");
+ if (had_published_keys)
+ model.model_info->metadata_items["published_keys"] = prev_published_keys;
+ else
+ model.model_info->metadata_items.erase("published_keys");
+ if (had_material_keys)
+ model.model_info->metadata_items["published_material_keys"] = prev_material_keys;
+ else
+ model.model_info->metadata_items.erase("published_material_keys");
+ }
+
+ if (ret < 0) {
+ MessageDialog(this, _L("Failed to export the published 3MF file.\nPlease check whether the folder exists online or if other programs have the file open."),
+ _L("Publish Settings"), wxOK | wxICON_WARNING).ShowModal();
+ return wxID_CANCEL;
+ }
+ return wxID_YES;
+}
+
Preset *get_printer_preset(const MachineObject *obj)
{
if (!obj)
diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp
index b60c0eb242..5a83e5e4e1 100644
--- a/src/slic3r/GUI/Plater.hpp
+++ b/src/slic3r/GUI/Plater.hpp
@@ -495,6 +495,10 @@ public:
void export_gcode_3mf(bool export_all = false);
void send_gcode_finish(wxString name);
void export_core_3mf();
+ // Export the current project as a "published" 3MF: embeds the author-selected settings
+ // (published_keys / published_material_keys) into the file's metadata. A pure export: the
+ // in-memory project (filename, dirty state, model_info metadata) is left untouched.
+ int export_published_3mf(const std::vector& published_keys, const std::vector& material_keys);
static TriangleMesh combine_mesh_fff(const ModelObject& mo, int instance_id, std::function notify_func = {});
void export_stl(bool extended = false, bool selection_only = false, bool multi_stls = false, FileType file_type = FT_STL);
//BBS: remove amf
diff --git a/src/slic3r/GUI/PublishSettingsDialog.cpp b/src/slic3r/GUI/PublishSettingsDialog.cpp
new file mode 100644
index 0000000000..36b7372d4a
--- /dev/null
+++ b/src/slic3r/GUI/PublishSettingsDialog.cpp
@@ -0,0 +1,1077 @@
+#include "PublishSettingsDialog.hpp"
+
+#include "GUI_App.hpp"
+#include "MainFrame.hpp"
+#include "MsgDialog.hpp"
+#include "I18N.hpp"
+#include "Tab.hpp"
+#include "ConfigValueFormatter.hpp"
+#include "Widgets/Label.hpp"
+#include "Widgets/TextInput.hpp"
+#include "Widgets/DialogButtons.hpp"
+#include "Widgets/StaticLine.hpp"
+#include "Widgets/StateColor.hpp"
+
+#include "libslic3r/PrintConfig.hpp"
+#include "libslic3r/Preset.hpp"
+#include "libslic3r/PublishSettings.hpp"
+
+#include
+#include
+#include
+#include
+#include
+
+// Custom-painted collapse chevron: a vector path (down when expanded, right
+// when collapsed) drawn in the dialog's secondary-text grey. Vector drawing
+// keeps it crisp at any DPI (the earlier 16px bitmap chevron looked
+// blurry/wide).
+class CollapseChevron : public wxWindow
+{
+public:
+ explicit CollapseChevron(wxWindow* parent) : wxWindow(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE)
+ {
+ SetBackgroundColour(parent->GetBackgroundColour());
+ DisableFocusFromKeyboard();
+ SetMinSize(FromDIP(wxSize(10, 10)));
+ Bind(wxEVT_PAINT, &CollapseChevron::on_paint, this);
+ }
+
+ void SetCollapsed(bool collapsed)
+ {
+ if (m_collapsed == collapsed)
+ return;
+ m_collapsed = collapsed;
+ Refresh();
+ }
+
+private:
+ void on_paint(wxPaintEvent&)
+ {
+ wxPaintDC dc(this);
+ wxGraphicsContext* ctx = wxGraphicsContext::Create(dc);
+ if (ctx == nullptr)
+ return;
+ ctx->SetAntialiasMode(wxANTIALIAS_DEFAULT);
+ // Same grey as the row value labels, dark-mode aware.
+ wxPen pen(StateColor::darkModeColorFor(wxColour("#6B6B6B")), FromDIP(1.5), wxPENSTYLE_SOLID);
+ pen.SetCap(wxCAP_ROUND);
+ pen.SetJoin(wxJOIN_ROUND);
+ ctx->SetPen(pen);
+
+ const wxSize sz = GetClientSize();
+ const double cx = sz.x / 2.0;
+ const double cy = sz.y / 2.0;
+ const double r = std::min(sz.x, sz.y) * 0.32;
+
+ wxGraphicsPath path = ctx->CreatePath();
+ if (m_collapsed) {
+ // Right-pointing chevron ">".
+ path.MoveToPoint(cx - r, cy - r);
+ path.AddLineToPoint(cx + r, cy);
+ path.AddLineToPoint(cx - r, cy + r);
+ } else {
+ // Down-pointing chevron "v".
+ path.MoveToPoint(cx - r, cy - r);
+ path.AddLineToPoint(cx, cy + r);
+ path.AddLineToPoint(cx + r, cy - r);
+ }
+ ctx->StrokePath(path);
+ delete ctx;
+ }
+
+ bool m_collapsed{false};
+};
+
+namespace Slic3r { namespace GUI {
+namespace {
+
+// Identity of a filament slot: the stable material id when present, else the
+// type+vendor pair. Used to emit the PublishedMaterialEntry identity fields.
+struct MaterialIdentity
+{
+ std::string type;
+ std::string vendor;
+ std::string id;
+};
+
+// Menu ids for show_menu(). Dedicated range above the standard ids so the popup cannot
+// collide with application-level bindings (e.g. MainFrame's recent-files wxID_FILE1.. range).
+enum {
+ kPublishSelectAll = wxID_HIGHEST + 1,
+ kPublishDeselectAll,
+ kPublishSelectVisible,
+ kPublishDeselectVisible,
+ kPublishFilterSelected,
+ kPublishFilterNonSelected
+};
+
+MaterialIdentity material_identity(size_t slot, const DynamicPrintConfig& full)
+{
+ MaterialIdentity identity;
+ if (const auto* types = full.opt("filament_type"))
+ if (slot < types->size())
+ identity.type = types->get_at(slot);
+ if (const auto* vendors = full.opt("filament_vendor"))
+ if (slot < vendors->size())
+ identity.vendor = vendors->get_at(slot);
+ if (const auto* ids = full.opt("filament_ids"))
+ if (slot < ids->size())
+ identity.id = ids->get_at(slot);
+ return identity;
+}
+
+// "Generic PLA @System" -> "Generic PLA"; mirrors the alias derivation in
+// PresetBundle::load_vendor_configs_from_json (PresetBundle.cpp) and
+// PresetCollection::set_custom_preset_alias (Preset.cpp).
+std::string material_display_name(const std::string& preset_name)
+{
+ const size_t at = preset_name.find_first_of('@');
+ if (at == std::string::npos)
+ return preset_name;
+ std::string bare = preset_name.substr(0, at);
+ boost::trim_right(bare);
+ return bare.empty() ? preset_name : bare;
+}
+
+// Human-readable section title for a filament slot: the resolved preset name,
+// falling back to the filament type, then to the generic "Material".
+wxString material_title(size_t slot, const PresetBundle* bundle, const DynamicPrintConfig& full)
+{
+ if (slot < bundle->filament_presets.size()) {
+ const Preset* preset = bundle->filaments.find_preset(bundle->filament_presets[slot]);
+ if (preset != nullptr && !preset->name.empty())
+ return from_u8(material_display_name(preset->name));
+ }
+ const MaterialIdentity identity = material_identity(slot, full);
+ if (!identity.type.empty())
+ return from_u8(identity.type);
+ return _L("Material");
+}
+
+} // namespace
+
+PublishSettingsDialog::PublishSettingsDialog(wxWindow* parent)
+ : DPIDialog(parent ? parent : static_cast(wxGetApp().mainframe),
+ wxID_ANY,
+ _L("Publish Settings"),
+ wxDefaultPosition,
+ wxDefaultSize,
+ wxCAPTION | wxCLOSE_BOX | wxRESIZE_BORDER)
+ , m_search(this, "search", 16)
+ , m_menu(this, "filter", 16)
+{
+ SetBackgroundColour(*wxWHITE);
+
+ build_option_model();
+
+ // --- filter bar: search box, All/None, menu button ---
+ wxPanel* f_bar = new wxPanel(this, wxID_ANY);
+ f_bar->SetBackgroundColour(GetBackgroundColour());
+ wxBoxSizer* f_sizer = new wxBoxSizer(wxHORIZONTAL);
+
+ m_filter_box = new TextInput(f_bar, "", "", "", wxDefaultPosition, wxDefaultSize, wxTE_PROCESS_ENTER);
+ m_filter_box->SetIcon(m_search.bmp());
+ m_filter_box->SetMinSize(FromDIP(wxSize(200, 24)));
+ m_filter_box->SetSize(FromDIP(wxSize(-1, 24)));
+ m_filter_box->SetFocus();
+ m_filter_ctrl = m_filter_box->GetTextCtrl();
+ m_filter_ctrl->SetFont(Label::Body_13);
+ m_filter_ctrl->SetSize(wxSize(-1, FromDIP(16))); // centers text vertically
+ m_filter_ctrl->SetHint(_L("Type to filter..."));
+ m_filter_ctrl->Bind(wxEVT_TEXT, [this](auto&) { apply_filter(m_filter_ctrl->GetValue()); });
+ m_filter_ctrl->Bind(wxEVT_TEXT_ENTER, [this](auto&) { apply_filter(m_filter_ctrl->GetValue()); });
+ m_filter_ctrl->Bind(wxEVT_SET_FOCUS, [this](auto& e) {
+ apply_filter(m_filter_ctrl->GetValue());
+ e.Skip();
+ });
+ m_filter_ctrl->Bind(wxEVT_KILL_FOCUS, [this](auto& e) {
+ apply_filter(m_filter_ctrl->GetValue());
+ e.Skip();
+ });
+ f_sizer->Add(m_filter_box, 1, wxEXPAND);
+ Bind(wxEVT_SET_FOCUS, [this](auto&) { m_filter_box->SetFocus(); });
+
+ m_fb_sizer = new wxBoxSizer(wxHORIZONTAL);
+ auto create_btn = [this, f_bar](wxString title, bool select) {
+ auto btn = new wxStaticText(f_bar, wxID_ANY, title);
+ btn->SetForegroundColour("#009687");
+ btn->SetCursor(wxCURSOR_HAND);
+ btn->SetFont(Label::Body_13);
+ btn->Bind(wxEVT_LEFT_DOWN, [this, select](wxMouseEvent&) { select_all(select); });
+ m_fb_sizer->Add(btn, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(10));
+ };
+ f_sizer->Add(m_fb_sizer, 0, wxALIGN_CENTER_VERTICAL);
+ create_btn(_L("All"), true);
+ create_btn(_L("None"), false);
+
+ m_menu_button = new wxStaticBitmap(f_bar, wxID_ANY, m_menu.bmp());
+ m_menu_button->SetCursor(wxCURSOR_HAND);
+ m_menu_button->Bind(wxEVT_LEFT_DOWN, &PublishSettingsDialog::show_menu, this);
+ f_sizer->Add(m_menu_button, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(10));
+
+ f_bar->SetSizerAndFit(f_sizer);
+
+ wxBoxSizer* w_sizer = new wxBoxSizer(wxVERTICAL);
+
+ wxStaticText* msg = new wxStaticText(this, wxID_ANY, _L("Select which settings to embed in the 3MF file"));
+ msg->SetFont(Label::Body_13);
+ msg->Wrap(-1);
+ w_sizer->Add(msg, 0, wxRIGHT | wxLEFT | wxTOP, FromDIP(10));
+
+ w_sizer->Add(f_bar, 0, wxRIGHT | wxLEFT | wxTOP | wxEXPAND, FromDIP(10));
+ w_sizer->Add(m_scroll, 1, wxRIGHT | wxLEFT | wxTOP | wxEXPAND, FromDIP(10));
+
+ auto dlg_btns = new DialogButtons(this, {"OK", "Cancel"});
+
+ dlg_btns->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
+ // At least one checked, enabled (publishable) key is required. A gated
+ // material row is disabled even if its value is pre-checked, so it must
+ // not count.
+ for (const Row& row : m_rows)
+ if (row.check->GetValue() && row.check->IsEnabled()) {
+ EndModal(wxID_OK);
+ return;
+ }
+ MessageDialog(this, _L("No settings selected. Please select at least one setting to publish."), _L("Publish Settings"),
+ wxOK | wxICON_WARNING)
+ .ShowModal();
+ });
+ dlg_btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); });
+
+ w_sizer->Add(dlg_btns, 0, wxEXPAND);
+
+ SetSizerAndFit(w_sizer);
+ SetMinSize(FromDIP(wxSize(600, 500)));
+ SetSize(FromDIP(wxSize(600, 500))); // initial size only; the dialog is resizable
+ wxGetApp().UpdateDlgDarkUI(this);
+}
+
+PublishSettingsDialog::~PublishSettingsDialog() {}
+
+void PublishSettingsDialog::build_option_model()
+{
+ // Structural / non-publishable keys, shared with the published-3MF overlay
+ // path (see libslic3r/PublishSettings.hpp).
+ const std::set& denylist = publish_structural_keys();
+ // Base keys already added in the print/printer sections. Printer rows share
+ // this set: a base key appears once (per-extruder "#N" variants collapse to
+ // the first occurrence - acceptable MVP; the per-extruder context is lost).
+ std::set added;
+
+ PresetBundle* bundle = wxGetApp().preset_bundle;
+ DynamicPrintConfig full = bundle->full_config();
+
+ m_scroll = new wxScrolledWindow(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxVSCROLL);
+ m_scroll->SetScrollRate(0, 10);
+ m_scroll->SetBackgroundColour(GetBackgroundColour());
+ m_list_sizer = new wxBoxSizer(wxVERTICAL);
+ m_scroll->SetSizer(m_list_sizer);
+ m_scroll->DisableFocusFromKeyboard();
+ m_scroll->Bind(wxEVT_RIGHT_DOWN, &PublishSettingsDialog::show_menu, this);
+
+ // "no matching rows" info label, shown by apply_filter().
+ m_info = new wxStaticText(m_scroll, wxID_ANY, "");
+ m_info->SetFont(Label::Body_13);
+ m_list_sizer->Add(m_info, 1, wxALIGN_CENTER_HORIZONTAL | wxALL, FromDIP(10));
+ m_info->Hide();
+ m_info_nonsel = _L("No selected items...");
+ m_info_allsel = _L("All items selected...");
+ m_info_empty = _L("No matching items...");
+
+ // Shared per-option label/value computation; returns false when the option
+ // must be skipped (denylisted / unknown / empty label). value is the pure
+ // stringified value; unit is the translated sidetext (may be empty).
+ auto option_text = [&denylist, &full](const std::string& opt_id, const std::string& pure_key, wxString& label, wxString& value,
+ wxString& unit) -> bool {
+ if (denylist.count(pure_key) > 0)
+ return false;
+ const ConfigOptionDef* def = print_config_def.get(pure_key);
+ if (def == nullptr)
+ return false;
+ label = _(def->full_label.empty() ? def->label : def->full_label);
+ if (label.IsEmpty())
+ return false;
+ value = get_string_value(opt_id, full);
+ unit = _(def->sidetext);
+ return true;
+ };
+
+ // Find-or-create a main category; builds its header row UI on first use.
+ // Material sections are additionally matched by identity and slot so two
+ // identities (or slots) that happen to share a title stay separate.
+ auto category_index_for = [this, &full](const wxString& title, Section section, const std::string& icon_name, size_t group,
+ const MaterialIdentity& identity = MaterialIdentity(), size_t slot = 0) -> size_t {
+ for (size_t i = 0; i < m_categories.size(); ++i) {
+ if (m_categories[i].title != title || m_categories[i].section != section)
+ continue;
+ if (section == Section::Material &&
+ (m_categories[i].filament_id != identity.id || m_categories[i].filament_type != identity.type ||
+ m_categories[i].filament_vendor != identity.vendor || m_categories[i].filament_slot != slot))
+ continue;
+ return i;
+ }
+ Category cat;
+ cat.title = title;
+ cat.section = section;
+ cat.group = group;
+ cat.icon_name = icon_name;
+ cat.filament_type = identity.type;
+ cat.filament_vendor = identity.vendor;
+ cat.filament_id = identity.id;
+ cat.filament_slot = slot;
+ if (!icon_name.empty()) {
+ ScalableBitmap icon_bmp(m_scroll, icon_name, 18);
+ cat.icon = new wxStaticBitmap(m_scroll, wxID_ANY, icon_bmp.bmp());
+ }
+ if (section == Section::Material) {
+ // Material header: [master (title)][slim tri-state select-all].
+ // The master is a 2-state opt-in that carries the material title;
+ // the tri-state is label-less and gates on the master.
+ cat.master_check = new wxCheckBox(m_scroll, wxID_ANY, title);
+ cat.master_check->SetFont(Label::Head_14);
+ cat.master_check->SetToolTip(_L("Export this material"));
+ cat.header = new wxCheckBox(m_scroll, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxCHK_3STATE);
+ cat.header->SetFont(Label::Head_14);
+ cat.header->SetToolTip(_L("Select/deselect all settings in this material"));
+ } else {
+ cat.header = new wxCheckBox(m_scroll, wxID_ANY, title, wxDefaultPosition, wxDefaultSize, wxCHK_3STATE);
+ cat.header->SetFont(Label::Head_14.Bold());
+ }
+ const size_t new_index = m_categories.size();
+ cat.chevron = create_chevron(m_scroll, wxEVT_LEFT_DOWN, [this, new_index] { toggle_category(new_index); });
+ // [icon][chevron][(chip)(master)(tri-state) | checkbox]: the chevron
+ // collapses/expands the category, the checkbox is the select-all.
+ auto header_sizer = new wxBoxSizer(wxHORIZONTAL);
+ if (cat.icon != nullptr)
+ header_sizer->Add(cat.icon, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(2));
+ header_sizer->Add(cat.chevron, 0, wxALIGN_CENTER_VERTICAL);
+ if (section == Section::Material) {
+ // Per-slot colour chip, before the master title.
+ std::string hex;
+ if (const auto* colours = full.opt("filament_colour"))
+ if (slot < colours->size())
+ hex = colours->get_at(slot);
+ wxBitmap* chip_bmp = get_extruder_color_icon(hex, "", FromDIP(12), FromDIP(12));
+ // chip_bmp points into get_extruder_color_icon's static BitmapCache
+ // and must NOT be deleted; the wxStaticBitmap takes its own copy.
+ header_sizer->Add(new wxStaticBitmap(m_scroll, wxID_ANY, *chip_bmp), 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(4));
+ header_sizer->Add(cat.master_check, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(4));
+ header_sizer->Add(cat.header, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(6));
+ } else {
+ header_sizer->Add(cat.header, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(4));
+ }
+ // A wrapper sizer splits the vertical separation (TOP 10) from the
+ // horizontal indent (LEFT|RIGHT 22), so the top gap collapses with the
+ // header when it is hidden.
+ auto wrap = new wxBoxSizer(wxVERTICAL);
+ wrap->Add(header_sizer, 0, wxTOP, FromDIP(10));
+ cat.item = m_list_sizer->Add(wrap, 0, wxLEFT | wxRIGHT, FromDIP(22));
+ // Register the new category with its section group: the group's visibility
+ // and select-all logic iterates section.categories.
+ m_sections[group].categories.push_back(new_index);
+ m_categories.push_back(std::move(cat));
+ return new_index;
+ };
+
+ // Find-or-create a subcategory (optgroup) heading within a category.
+ auto subcategory_index_for = [this](size_t cat_index, const wxString& title, const wxString& icon) -> size_t {
+ Category& cat = m_categories[cat_index];
+ for (size_t i = 0; i < cat.subs.size(); ++i)
+ if (cat.subs[i].title == title)
+ return i;
+ Subcategory sub;
+ sub.title = title;
+ if (!title.IsEmpty()) {
+ // Same look as the Tab's optgroup headers (incl. its icon), plus a
+ // collapse chevron. A click on the chevron bitmap does not reach the
+ // StaticLine, so both are bound to the same toggle (LEFT_UP so the
+ // StaticLine's label acts as the click target too).
+ sub.header = new ::StaticLine(m_scroll, false, title, icon);
+ sub.header->SetFont(Label::Head_14);
+ sub.header->SetForegroundColour("#363636");
+ sub.header->SetCursor(wxCURSOR_HAND);
+ const size_t new_index = cat.subs.size();
+ auto toggle = [this, cat_index, new_index] { toggle_subcategory(cat_index, new_index); };
+ sub.header->Bind(wxEVT_LEFT_UP, [toggle](wxMouseEvent&) { toggle(); });
+ sub.chevron = create_chevron(m_scroll, wxEVT_LEFT_UP, toggle);
+ auto header_sizer = new wxBoxSizer(wxHORIZONTAL);
+ header_sizer->Add(sub.chevron, 0, wxALIGN_CENTER_VERTICAL);
+ header_sizer->Add(sub.header, 1, wxEXPAND | wxLEFT, FromDIP(4));
+ // A wrapper sizer splits the vertical separation (TOP|BOTTOM 6) from
+ // the horizontal indent (LEFT|RIGHT 38), so the gaps collapse with
+ // the header when it is hidden.
+ auto wrap = new wxBoxSizer(wxVERTICAL);
+ wrap->Add(header_sizer, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(6));
+ sub.item = m_list_sizer->Add(wrap, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(38));
+ }
+ cat.subs.push_back(std::move(sub));
+ return cat.subs.size() - 1;
+ };
+
+ // Creates the row UI (checkbox + white ellipsized value + grey unit) and
+ // registers the row into the given category/subcategory.
+ auto add_row_ui = [this](const std::string& key, const wxString& label, const wxString& value, const wxString& unit, size_t cat_index,
+ size_t sub_index) {
+ Row row;
+ row.key = key;
+ row.label = label;
+ row.value = value;
+ row.unit = unit;
+ row.category = m_categories[cat_index].title;
+ row.subcategory = m_categories[cat_index].subs[sub_index].title;
+ row.section = m_categories[cat_index].section;
+ row.section_title = m_sections[m_categories[cat_index].group].title;
+ const size_t row_index = m_rows.size();
+ m_rows.push_back(std::move(row));
+
+ Row& r = m_rows[row_index];
+ r.check = new wxCheckBox(m_scroll, wxID_ANY, label, wxDefaultPosition, wxDefaultSize);
+ r.check->SetFont(Label::Body_13);
+ // Value in the Tab's text color (near-black light / #EFEFF0 dark); the
+ // unit is the grey secondary text.
+ r.value_label = new wxStaticText(m_scroll, wxID_ANY, value, wxDefaultPosition, wxDefaultSize, wxST_ELLIPSIZE_END);
+ r.value_label->SetFont(Label::Body_13);
+ r.value_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#262E30")));
+ r.value_label->SetToolTip(unit.IsEmpty() ? value : value + " " + unit);
+ if (!unit.IsEmpty()) {
+ r.unit_label = new wxStaticText(m_scroll, wxID_ANY, unit);
+ r.unit_label->SetFont(Label::Body_13);
+ r.unit_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#6B6B6B")));
+ }
+
+ auto row_sizer = new wxBoxSizer(wxHORIZONTAL);
+ row_sizer->Add(r.check, 0, wxALIGN_CENTER_VERTICAL);
+ row_sizer->Add(r.value_label, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8));
+ if (r.unit_label != nullptr)
+ row_sizer->Add(r.unit_label, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(4));
+ r.item = m_list_sizer->Add(row_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(54));
+
+ m_categories[cat_index].rows.push_back(row_index);
+ m_categories[cat_index].subs[sub_index].rows.push_back(row_index);
+ };
+
+ // --- Phase 1: printer per-extruder retraction settings (displayed first,
+ // mirroring the sidebar's Printer group). The printer tab's
+ // "Extruder"/"Extruder N" pages carry the per-extruder retraction options.
+ {
+ size_t g = section_group_for(Section::Printer);
+ for (Tab* tab : wxGetApp().tabs_list) {
+ if (tab->m_type != Preset::TYPE_PRINTER)
+ continue;
+ for (const PageShp& page : tab->m_pages) {
+ if (!page->title().StartsWith("Extruder"))
+ continue;
+ const wxString page_title = Tab::translate_category(page->title(), tab->m_type);
+ for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) {
+ // Allowlist on the untranslated optgroup title; the "Retraction
+ // when switching material" group is intentionally skipped.
+ if (optgroup->title != "Retraction" && optgroup->title != "Z-Hop")
+ continue;
+ const wxString subcategory = page_title + L" \u00B7 " + _(optgroup->title);
+ for (const auto& opt : optgroup->opt_map()) {
+ const std::string& opt_id = opt.first;
+ const std::string& pure_key = opt.second.first;
+ // Per-extruder "#N" variants collapse to the first base key. The row stores
+ // the BASE key (whole-vector semantics on load: the size-guarded apply
+ // copies the author's full vector), while the "#0" opt_id is only used to
+ // display the first extruder's value.
+ if (!added.insert(pure_key).second)
+ continue;
+ wxString label, value, unit;
+ if (!option_text(opt_id, pure_key, label, value, unit))
+ continue;
+ size_t cat_index = category_index_for(_L("Retraction & Z-hop"), Section::Printer, "custom-gcode_extruder", g);
+ size_t sub_index = subcategory_index_for(cat_index, subcategory, optgroup->icon);
+ add_row_ui(pure_key, label, value, unit, cat_index, sub_index);
+ }
+ }
+ }
+ }
+ }
+
+ // --- Phase 2: per-material sections synthesized from the filament tab's
+ // "Setting Overrides" page, under the Filament group.
+ {
+ size_t g = section_group_for(Section::Material);
+ Tab* filament_tab = nullptr;
+ for (Tab* tab : wxGetApp().tabs_list)
+ if (tab->m_type == Preset::TYPE_FILAMENT) {
+ filament_tab = tab;
+ break;
+ }
+ if (filament_tab != nullptr) {
+ const Page* overrides_page = nullptr;
+ for (const PageShp& page : filament_tab->m_pages)
+ if (page->title() == "Setting Overrides") {
+ overrides_page = page.get();
+ break;
+ }
+
+ if (overrides_page != nullptr) {
+ // One section per filament slot: a 4-slot printer (e.g. 1 PLA +
+ // 3 PETG) shows 4 sections, each disambiguated by its colour chip
+ // and slot number. The "· Slot N" title suffix keeps the category
+ // titles unique across slots.
+ for (size_t slot = 0; slot < bundle->filament_presets.size(); ++slot) {
+ const MaterialIdentity identity = material_identity(slot, full);
+ const wxString title = material_title(slot, bundle, full) + L" \u00B7 " +
+ wxString::Format(_L("Slot %d"), static_cast(slot) + 1);
+ // A material section must not repeat a key; the same key may
+ // appear in other material sections - that is intended.
+ std::set material_added;
+
+ for (const ConfigOptionsGroupShp& optgroup : overrides_page->m_optgroups) {
+ // Allowlist on the untranslated optgroup title; the
+ // "Ironing" group is intentionally skipped.
+ if (optgroup->title != "Retraction" && optgroup->title != "Retraction when switching material")
+ continue;
+ for (const auto& opt : optgroup->opt_map()) {
+ // Row keys are base keys (no "#N"): the load side
+ // matches the material and uses the author's slot.
+ const std::string& opt_id = opt.first;
+ std::string base = opt_id.substr(0, opt_id.find('#'));
+ if (!material_added.insert(base).second)
+ continue;
+ // Show the value of this slot; fall back to slot 0 if out of range.
+ std::string value_opt_id = base + "#" + std::to_string(slot);
+ if (const ConfigOption* opt_cfg = full.option(base))
+ if (const auto* vec = dynamic_cast(opt_cfg))
+ if (vec->size() > 0 && slot >= vec->size())
+ value_opt_id = base + "#0";
+ wxString label, value, unit;
+ if (!option_text(value_opt_id, base, label, value, unit))
+ continue;
+ size_t cat_index = category_index_for(title, Section::Material, "custom-gcode_filament", g, identity, slot);
+ size_t sub_index = subcategory_index_for(cat_index, _(optgroup->title), optgroup->icon);
+ add_row_ui(base, label, value, unit, cat_index, sub_index);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // --- Phase 3: process (print) settings; grouping matches the process tab.
+ {
+ size_t g = section_group_for(Section::Print);
+ for (Tab* tab : wxGetApp().tabs_list) {
+ if (tab->m_type != Preset::TYPE_PRINT)
+ continue;
+ const auto& icon_map = tab->get_category_icon_map();
+ for (const PageShp& page : tab->m_pages) {
+ wxString category = Tab::translate_category(page->title(), tab->m_type);
+ // Page icon, keyed by the untranslated page title (per-Tab map).
+ std::string icon_name;
+ auto icon_it = icon_map.find(page->title());
+ if (icon_it != icon_map.end())
+ icon_name = icon_it->second;
+
+ for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) {
+ for (const auto& opt : optgroup->opt_map()) {
+ // opt_map() key is the opt_id (may carry "#N"); the value is
+ // (pure_opt_key, opt_index).
+ const std::string& opt_id = opt.first;
+ const std::string& pure_key = opt.second.first;
+ // A key may appear in more than one page/group; keep the first.
+ if (!added.insert(pure_key).second)
+ continue;
+ wxString label, value, unit;
+ if (!option_text(opt_id, pure_key, label, value, unit))
+ continue;
+ size_t cat_index = category_index_for(category, Section::Print, icon_name, g);
+ size_t sub_index = subcategory_index_for(cat_index, _(optgroup->title), optgroup->icon);
+ add_row_ui(opt_id, label, value, unit, cat_index, sub_index);
+ }
+ }
+ }
+ }
+ }
+
+ // Pre-check the dirty (modified) settings and mark them bold. The base-key
+ // match covers all sections; collect_dirty_settings_keys already unions the
+ // prints, printers and filaments of the bundle.
+ std::set dirty_base;
+ for (const std::string& key : collect_dirty_settings_keys(*wxGetApp().preset_bundle)) {
+ auto n = key.find('#');
+ dirty_base.insert(n == std::string::npos ? key : key.substr(0, n));
+ }
+ for (Row& row : m_rows) {
+ std::string base = row.key.substr(0, row.key.find('#'));
+ row.dirty = dirty_base.count(base) > 0;
+ if (row.dirty) {
+ row.check->SetValue(true);
+ set_row_bold(row, true);
+ }
+ }
+
+ // Wire the section group tri-state headers (chevrons/StaticLine toggles were
+ // bound at creation in section_group_for).
+ for (size_t s = 0; s < m_sections.size(); ++s) {
+ if (m_sections[s].header != nullptr) {
+ m_sections[s].header->Bind(wxEVT_CHECKBOX, [this, s](wxCommandEvent&) { on_section_toggle(s); });
+ update_section_header(m_sections[s]);
+ }
+ }
+
+ // Wire the 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); });
+ 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, c](wxCommandEvent&) { update_category_header(m_categories[c]); });
+ update_category_header(m_categories[c]);
+ }
+
+ // Material sections start gated (master OFF): their rows and tri-state are
+ // disabled until the author opts the material in.
+ for (size_t c = 0; c < m_categories.size(); ++c)
+ if (m_categories[c].section == Section::Material)
+ on_master_toggle(c);
+
+ // No filter is active at startup: every row matches until the user types.
+ for (Row& row : m_rows)
+ row.matches_filter = true;
+ apply_visibility();
+
+ m_scroll->FitInside();
+ m_list_sizer->Layout();
+}
+
+size_t PublishSettingsDialog::section_group_for(Section kind)
+{
+ for (size_t i = 0; i < m_sections.size(); ++i)
+ if (m_sections[i].kind == kind)
+ return i;
+
+ SectionGroup section;
+ section.kind = kind;
+ const size_t new_index = m_sections.size();
+
+ switch (kind) {
+ case Section::Printer:
+ section.title = _L("Printer");
+ section.icon_name = "printer";
+ break;
+ case Section::Material:
+ section.title = _L("Filament");
+ section.icon_name = "filament";
+ break;
+ case Section::Print:
+ section.title = _L("Process");
+ section.icon_name = "process";
+ break;
+ }
+
+ if (!section.icon_name.empty()) {
+ ScalableBitmap icon_bmp(m_scroll, section.icon_name, 18);
+ section.icon = new wxStaticBitmap(m_scroll, wxID_ANY, icon_bmp.bmp());
+ }
+
+ section.chevron = create_chevron(m_scroll, wxEVT_LEFT_DOWN, [this, new_index] { toggle_section(new_index); });
+
+ auto header_sizer = new wxBoxSizer(wxHORIZONTAL);
+ if (section.icon != nullptr)
+ header_sizer->Add(section.icon, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(2));
+ header_sizer->Add(section.chevron, 0, wxALIGN_CENTER_VERTICAL);
+
+ if (kind == Section::Material) {
+ // Filament group: clickable StaticLine title, no tri-state (each
+ // material below opts in individually).
+ section.header_line = new ::StaticLine(m_scroll, false, section.title);
+ section.header_line->SetFont(Label::Head_14.Bold());
+ section.header_line->SetForegroundColour("#363636");
+ section.header_line->SetCursor(wxCURSOR_HAND);
+ section.header_line->SetToolTip(_L("Enable each material below to export its settings"));
+ auto toggle = [this, new_index] { toggle_section(new_index); };
+ section.header_line->Bind(wxEVT_LEFT_UP, [toggle](wxMouseEvent&) { toggle(); });
+ header_sizer->Add(section.header_line, 1, wxEXPAND | wxLEFT, FromDIP(4));
+ } else {
+ // Printer/Process: tri-state select-all carries the title.
+ section.header = new wxCheckBox(m_scroll, wxID_ANY, section.title, wxDefaultPosition, wxDefaultSize, wxCHK_3STATE);
+ section.header->SetFont(Label::Head_14.Bold());
+ header_sizer->Add(section.header, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(4));
+ }
+
+ // A wrapper sizer splits the larger vertical separation (TOP 14) from the
+ // shallow horizontal indent (LEFT|RIGHT 6), so the top gap collapses with
+ // the header when it is hidden. wxEXPAND lets the Filament StaticLine's
+ // separator span the width like the subcategory headers.
+ auto wrap = new wxBoxSizer(wxVERTICAL);
+ wrap->Add(header_sizer, 0, wxEXPAND | wxTOP, FromDIP(14));
+ section.item = m_list_sizer->Add(wrap, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(6));
+ m_sections.push_back(std::move(section));
+ return new_index;
+}
+
+void PublishSettingsDialog::on_category_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;
+ 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_category_header(cat);
+}
+
+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_category_header(cat);
+}
+
+void PublishSettingsDialog::on_section_toggle(size_t section_index)
+{
+ SectionGroup& section = m_sections[section_index];
+ if (section.header == nullptr)
+ return; // defensive: the Filament group has no select-all
+ // All-or-none over every enabled row in the group's categories.
+ bool all_checked = true;
+ for (size_t c : section.categories) {
+ for (size_t r : m_categories[c].rows)
+ if (m_rows[r].check->IsEnabled() && !m_rows[r].check->GetValue()) {
+ all_checked = false;
+ break;
+ }
+ if (!all_checked)
+ break;
+ }
+ const bool value = !all_checked;
+ for (size_t c : section.categories)
+ for (size_t r : m_categories[c].rows)
+ if (m_rows[r].check->IsEnabled())
+ m_rows[r].check->SetValue(value);
+ for (size_t c : section.categories)
+ update_category_header(m_categories[c]);
+ update_section_header(section);
+}
+
+void PublishSettingsDialog::update_section_header(SectionGroup& section)
+{
+ if (section.header == nullptr)
+ return; // Filament group has no tri-state.
+ int checked = 0;
+ int total = 0;
+ for (size_t c : section.categories) {
+ for (size_t r : m_categories[c].rows) {
+ if (!m_rows[r].check->IsEnabled())
+ continue; // gated material rows don't count (defensive)
+ ++total;
+ if (m_rows[r].check->GetValue())
+ ++checked;
+ }
+ }
+ if (total == 0 || checked == 0)
+ section.header->Set3StateValue(wxCHK_UNCHECKED);
+ else if (checked == total)
+ section.header->Set3StateValue(wxCHK_CHECKED);
+ else
+ section.header->Set3StateValue(wxCHK_UNDETERMINED);
+}
+
+void PublishSettingsDialog::update_category_header(Category& category)
+{
+ // 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);
+}
+
+void PublishSettingsDialog::set_row_bold(Row& row, bool bold)
+{
+ // Real set/clear: rebase on the dialog's body font so that clearing bold
+ // restores the exact original font (the old CheckList::SetBold was one-way).
+ row.check->SetFont(bold ? Label::Body_13.Bold() : Label::Body_13);
+}
+
+void PublishSettingsDialog::apply_filter(const wxString& filter_text)
+{
+ Freeze();
+ wxString filter = filter_text.Lower();
+
+ // Pseudo filters (menu only): show only checked ("::sel") or only
+ // unchecked ("::nonsel") rows.
+ const bool pseudo = (filter == "::sel" || filter == "::nonsel");
+ m_fb_sizer->Show(!pseudo);
+
+ // Update the per-row match flags only; actual visibility is computed by
+ // apply_visibility() (which also respects the collapse state).
+ if (pseudo) {
+ if (m_filter_ctrl->GetValue().Lower() != filter) {
+ m_filter_ctrl->SetValue(filter);
+ m_filter_ctrl->SetSelection(0, -1);
+ }
+ const bool want_checked = (filter == "::sel");
+ for (Row& row : m_rows)
+ row.matches_filter = row.check->IsEnabled() && row.check->GetValue() == want_checked;
+ } else {
+ const bool clear = filter.IsEmpty();
+ for (Row& row : m_rows) {
+ row.matches_filter = clear || (row.section_title + " " + row.category + " " + row.subcategory + " " + row.label + " " +
+ row.value + " " + row.unit)
+ .Lower()
+ .Contains(filter);
+ }
+ }
+
+ // The info label reflects the filter result only; a collapsed section
+ // hiding its matches is a user choice, not "no match".
+ m_info->Show();
+ for (const Row& row : m_rows) {
+ if (row.matches_filter) {
+ m_info->Hide();
+ break;
+ }
+ }
+ if (m_info->IsShown())
+ m_info->SetLabel(pseudo ? (filter == "::sel" ? m_info_nonsel : m_info_allsel) : m_info_empty);
+
+ apply_visibility();
+ m_scroll->FitInside();
+ Layout();
+ Thaw();
+}
+
+void PublishSettingsDialog::apply_visibility()
+{
+ Freeze();
+ for (SectionGroup& section : m_sections) {
+ // The section header stays visible whenever any row in the group matches
+ // (a section header never depends on its own collapsed state).
+ bool section_any = false;
+ for (size_t c : section.categories) {
+ for (size_t r : m_categories[c].rows)
+ if (m_rows[r].matches_filter) {
+ section_any = true;
+ break;
+ }
+ if (section_any)
+ break;
+ }
+ section.item->Show(section_any);
+ section.chevron->SetCollapsed(section.collapsed);
+
+ for (size_t c : section.categories) {
+ Category& cat = m_categories[c];
+
+ // The category header stays visible whenever it has any match and
+ // the section is not collapsed, so it can always be re-expanded.
+ bool cat_any = false;
+ for (size_t r : cat.rows)
+ if (m_rows[r].matches_filter) {
+ cat_any = true;
+ break;
+ }
+ cat.item->Show(cat_any && !section.collapsed);
+ cat.chevron->SetCollapsed(cat.collapsed);
+
+ for (Subcategory& sub : cat.subs) {
+ if (sub.header != nullptr) {
+ // The subcategory header visibility depends on its rows' matches
+ // and on its ancestors, but NOT on its own collapsed state.
+ bool sub_any = false;
+ for (size_t r : sub.rows)
+ if (m_rows[r].matches_filter) {
+ sub_any = true;
+ break;
+ }
+ sub.item->Show(sub_any && !section.collapsed && !cat.collapsed);
+ sub.chevron->SetCollapsed(sub.collapsed);
+ }
+ // Rows are hidden by the filter and by any collapsed ancestor.
+ for (size_t r : sub.rows)
+ m_rows[r].item->Show(m_rows[r].matches_filter && !section.collapsed && !cat.collapsed && !sub.collapsed);
+ }
+ }
+ }
+ Thaw();
+}
+
+void PublishSettingsDialog::toggle_section(size_t section_index)
+{
+ m_sections[section_index].collapsed = !m_sections[section_index].collapsed;
+ apply_visibility();
+ m_scroll->FitInside();
+ m_list_sizer->Layout();
+}
+
+void PublishSettingsDialog::toggle_category(size_t category_index)
+{
+ m_categories[category_index].collapsed = !m_categories[category_index].collapsed;
+ apply_visibility();
+ m_scroll->FitInside();
+ m_list_sizer->Layout();
+}
+
+void PublishSettingsDialog::toggle_subcategory(size_t category_index, size_t subcategory_index)
+{
+ m_categories[category_index].subs[subcategory_index].collapsed = !m_categories[category_index].subs[subcategory_index].collapsed;
+ apply_visibility();
+ m_scroll->FitInside();
+ m_list_sizer->Layout();
+}
+
+CollapseChevron* PublishSettingsDialog::create_chevron(wxWindow* parent,
+ const wxEventTypeTag& event_type,
+ std::function toggle)
+{
+ CollapseChevron* chevron = new CollapseChevron(parent);
+ chevron->SetCursor(wxCURSOR_HAND);
+ // The tag type (not wxEventType) keeps Bind's EventTag template deduced as
+ // wxEventTypeTag; wxEvent& is used so the helper works with
+ // any mouse event tag, and the toggle itself does not inspect the event.
+ chevron->Bind(event_type, [toggle](wxEvent&) { toggle(); });
+ return chevron;
+}
+
+void PublishSettingsDialog::select_all(bool value)
+{
+ // "All" does not auto-enable gated material sections; "None" leaves a gated
+ // row's preserved value untouched.
+ for (Row& row : m_rows)
+ if (row.check->IsEnabled())
+ row.check->SetValue(value);
+ for (Category& cat : m_categories)
+ update_category_header(cat);
+}
+
+void PublishSettingsDialog::select_visible(bool value)
+{
+ wxString filter = m_filter_ctrl->GetValue().Lower();
+ // In a pseudo-filter view the rows being toggled would all disappear;
+ // drop the filter afterwards so the result stays visible.
+ bool clear_pseudo = (!value && filter == "::nonsel") || (value && filter == "::sel");
+
+ // Toggle the rows that are visible under the *current* filter.
+ for (Row& row : m_rows)
+ if (row.check->IsShown() && row.check->IsEnabled())
+ row.check->SetValue(value);
+
+ if (clear_pseudo) {
+ // Note: SetValue() may fire wxEVT_TEXT on some platforms, which
+ // re-enters apply_filter() - that is fine, the rows above were already
+ // toggled and the trailing call below is idempotent.
+ m_filter_ctrl->SetValue("");
+ apply_filter(""); // resync visibility, headers and the All/None bar
+ }
+ for (Category& cat : m_categories)
+ update_category_header(cat);
+}
+
+void PublishSettingsDialog::show_menu(wxMouseEvent& evt)
+{
+ bool filtering = !m_filter_ctrl->GetValue().IsEmpty();
+ bool list_empty = m_info->IsShown();
+
+ wxMenu m;
+ m.Append(kPublishSelectAll, _L("Select All"))->Enable(!filtering);
+ m.Append(kPublishDeselectAll, _L("Deselect All"))->Enable(!filtering);
+ m.AppendSeparator();
+ m.Append(kPublishSelectVisible, _L("Select visible"))->Enable(!list_empty && filtering);
+ m.Append(kPublishDeselectVisible, _L("Deselect visible"))->Enable(!list_empty && filtering);
+ m.AppendSeparator();
+ m.Append(kPublishFilterSelected, _L("Filter selected"));
+ m.Append(kPublishFilterNonSelected, _L("Filter nonSelected"));
+
+ m.Bind(
+ wxEVT_MENU,
+ [this](wxCommandEvent& e) {
+ switch (e.GetId()) {
+ case kPublishSelectAll: select_all(true); break;
+ case kPublishDeselectAll: select_all(false); break;
+ case kPublishSelectVisible: select_visible(true); break;
+ case kPublishDeselectVisible: select_visible(false); break;
+ case kPublishFilterSelected: apply_filter("::sel"); break;
+ case kPublishFilterNonSelected: apply_filter("::nonsel"); break;
+ default: break;
+ }
+ },
+ kPublishSelectAll, kPublishFilterNonSelected);
+
+ wxWindow* src = dynamic_cast(evt.GetEventObject());
+ if (!src)
+ return;
+ wxPoint screen_pos = src->ClientToScreen(evt.GetPosition());
+ wxPoint local_pos = ScreenToClient(screen_pos);
+ PopupMenu(&m, local_pos);
+}
+
+std::vector PublishSettingsDialog::GetPublishedKeys() const
+{
+ std::vector out;
+ // Process and printer sections both travel through published_keys (the load-side
+ // overlay applies process keys to the prints edited preset and the allowlisted
+ // printer keys to the printers edited preset). Material keys use a separate API.
+ for (const Row& row : m_rows)
+ if ((row.section == Section::Print || row.section == Section::Printer) && row.check->GetValue())
+ out.push_back(row.key);
+ return out;
+}
+
+std::vector PublishSettingsDialog::GetPublishedMaterialKeys() const
+{
+ std::vector out;
+ for (const Category& cat : m_categories) {
+ // Only opted-in materials export their keys.
+ if (cat.section != Section::Material || !cat.master)
+ 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())
+ out.push_back(std::move(entry));
+ }
+ return out;
+}
+
+void PublishSettingsDialog::on_dpi_changed(const wxRect& suggested_rect)
+{
+ // The remaining bitmap icons are rescaled; the collapse chevrons are
+ // vector-drawn and repaint themselves.
+ m_search.msw_rescale();
+ m_menu.msw_rescale();
+ m_filter_box->SetIcon(m_search.bmp());
+ m_menu_button->SetBitmap(m_menu.bmp());
+ SetMinSize(FromDIP(wxSize(600, 500)));
+ m_scroll->FitInside();
+ m_list_sizer->Layout();
+ Refresh();
+}
+
+}} // namespace Slic3r::GUI
diff --git a/src/slic3r/GUI/PublishSettingsDialog.hpp b/src/slic3r/GUI/PublishSettingsDialog.hpp
new file mode 100644
index 0000000000..ff9cb88e4b
--- /dev/null
+++ b/src/slic3r/GUI/PublishSettingsDialog.hpp
@@ -0,0 +1,183 @@
+#ifndef slic3r_GUI_PublishSettingsDialog_hpp_
+#define slic3r_GUI_PublishSettingsDialog_hpp_
+
+#include "GUI_Utils.hpp"
+#include "wxExtensions.hpp"
+
+#include "libslic3r/PublishSettings.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Forward declarations (all are global classes, see Widgets/TextInput.hpp,
+// Widgets/StaticLine.hpp and the CollapseChevron definition in the .cpp).
+class TextInput;
+class StaticLine;
+class CollapseChevron;
+
+namespace Slic3r { namespace GUI {
+
+// Dialog that lets a model author select which settings get embedded in a 3MF.
+// Settings are grouped the way the tabs show them: the process (print) pages,
+// the printer's per-extruder retraction settings, and one section per material
+// used in the project (filament overrides). Each main category has a select-all
+// tri-state header, subcategory (optgroup) headings, one row per setting
+// (checkbox + grey value label), and both header levels are collapsible (chevron
+// toggle). A search filter and an All/None / select-visible menu are provided.
+// Modified (dirty) settings are pre-checked and shown bold. On OK, the print
+// rows become the "published_keys" list and the material rows become the
+// per-material "published_material_keys".
+class PublishSettingsDialog : public DPIDialog
+{
+public:
+ PublishSettingsDialog(wxWindow* parent = nullptr);
+ ~PublishSettingsDialog();
+
+ // The selected print-section setting keys (in display order). Keys may
+ // contain '#'.
+ std::vector GetPublishedKeys() const;
+
+ // The selected keys grouped per material: one entry per material section
+ // with at least one checked key. Keys are base keys (no "#N" suffix).
+ std::vector GetPublishedMaterialKeys() const;
+
+protected:
+ void on_dpi_changed(const wxRect& suggested_rect) override;
+
+private:
+ // Which part of the settings the row/category came from.
+ enum class Section { Print, Printer, Material };
+
+ // 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.
+ struct Row
+ {
+ std::string key;
+ wxString category;
+ wxString subcategory;
+ wxString label;
+ wxString value;
+ wxString unit;
+ wxString section_title; // top-level group title, for filter matching
+ Section section{Section::Print};
+ bool dirty{false}; // matches a dirty base key: pre-checked + bold
+ bool matches_filter{false}; // survives the active filter (computed by apply_filter)
+ wxCheckBox* check{nullptr};
+ wxStaticText* value_label{nullptr};
+ wxStaticText* unit_label{nullptr};
+ wxSizerItem* item{nullptr}; // sizer item of this row's h-sizer in m_list_sizer
+ };
+
+ // A subcategory (optgroup) heading. Rows store indices into m_rows.
+ struct Subcategory
+ {
+ wxString title;
+ ::StaticLine* header{nullptr}; // null when the title is empty
+ bool collapsed{false};
+ CollapseChevron* chevron{nullptr};
+ wxSizerItem* item{nullptr}; // sizer item of the header h-sizer in m_list_sizer
+ std::vector rows;
+ };
+
+ // A main category with its select-all tri-state header.
+ struct Category
+ {
+ wxString title;
+ Section section{Section::Print};
+ size_t group{0}; // index into m_sections
+ std::string icon_name; // bitmap name; empty = no icon
+ wxStaticBitmap* icon{nullptr}; // 18px category icon (null when icon_name empty)
+ wxCheckBox* header{nullptr}; // select-all tri-state
+ // 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};
+ bool collapsed{false};
+ CollapseChevron* chevron{nullptr};
+ wxSizerItem* item{nullptr}; // sizer item of the header h-sizer in m_list_sizer
+ // Material identity, only for Section::Material categories.
+ std::string filament_type;
+ std::string filament_vendor;
+ std::string filament_id;
+ // 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
+ };
+
+ // A top-level section group mirroring the editor sidebar. Categories are
+ // nested inside.
+ struct SectionGroup
+ {
+ wxString title; // _L("Printer") / _L("Filament") / _L("Process")
+ Section kind{Section::Print}; // maps 1:1 to the display group
+ std::string icon_name; // "printer" / "filament" / "process"
+ wxStaticBitmap* icon{nullptr}; // 18px, like Category::icon
+ wxCheckBox* header{nullptr}; // tri-state select-all; nullptr for the Filament group
+ ::StaticLine* header_line{nullptr}; // Filament group's clickable title
+ CollapseChevron* chevron{nullptr};
+ wxSizerItem* item{nullptr};
+ bool collapsed{false};
+ std::vector categories; // indices into m_categories
+ };
+
+ void build_option_model();
+ void apply_filter(const wxString& filter_text);
+ 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);
+ // Collapse/expand a category or subcategory; resyncs visibility + chevrons.
+ void toggle_category(size_t category_index);
+ void toggle_subcategory(size_t category_index, size_t subcategory_index);
+ // Find-or-create the top-level section group for a Section kind (builds its
+ // header row on first use).
+ size_t section_group_for(Section kind);
+ // Top-level section group: select-all tri-state toggled / collapse/expand /
+ // header resync.
+ void on_section_toggle(size_t section_index);
+ void toggle_section(size_t section_index);
+ void update_section_header(SectionGroup& section);
+ // Single pass over categories/subs/rows: shows an item iff it is not hidden
+ // by the filter and (for subs/rows) by a collapsed ancestor. Flips the
+ // header chevrons. Only reads matches_filter; never re-runs filter matching.
+ void apply_visibility();
+ // Creates a collapse chevron with a hand cursor; clicking it (with the given
+ // mouse event, LEFT_DOWN for categories / LEFT_UP for subcategories,
+ // matching the header's own binding) invokes the toggle.
+ CollapseChevron* create_chevron(wxWindow* parent, const wxEventTypeTag& event_type, std::function toggle);
+
+ wxScrolledWindow* m_scroll{nullptr};
+ wxBoxSizer* m_list_sizer{nullptr}; // vertical sizer of the scrolled window
+ wxBoxSizer* m_fb_sizer{nullptr}; // "All"/"None" buttons sizer
+ TextInput* m_filter_box{nullptr};
+ wxTextCtrl* m_filter_ctrl{nullptr};
+ wxStaticBitmap* m_menu_button{nullptr};
+ wxStaticText* m_info{nullptr};
+ wxString m_info_nonsel;
+ wxString m_info_allsel;
+ wxString m_info_empty;
+
+ ScalableBitmap m_search;
+ ScalableBitmap m_menu;
+
+ std::vector m_rows;
+ std::vector m_categories;
+ // Fixed display order enforced by phase order in build_option_model():
+ // Printer, then Filament, then Process.
+ std::vector m_sections;
+};
+
+}} // namespace Slic3r::GUI
+
+#endif // slic3r_GUI_PublishSettingsDialog_hpp_
diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp
index 1a31355d0e..935ab58592 100644
--- a/src/slic3r/GUI/Tab.cpp
+++ b/src/slic3r/GUI/Tab.cpp
@@ -5675,6 +5675,9 @@ if (is_marlin_flavor)
optgroup->append_single_option_line("extruder_offset", "printer_extruder_basic_information#extruder-offset-position", extruder_idx);
//BBS: don't show retract related config menu in machine page
+ // Keep this optgroup's options in sync with publishable_printer_retraction_options()
+ // in libslic3r/PublishSettings.hpp: the published-3MF printer allowlist is its union
+ // with the Z-Hop optgroup below.
optgroup = page->new_optgroup(L("Retraction"), L"param_retraction");
optgroup->append_single_option_line("retraction_length", "printer_extruder_retraction#length", extruder_idx);
optgroup->append_single_option_line("retract_restart_extra", "printer_extruder_retraction#extra-length-on-restart", extruder_idx);
@@ -5688,6 +5691,9 @@ if (is_marlin_flavor)
// Orca
optgroup->append_single_option_line("retract_after_wipe", "printer_extruder_retraction#retract-amount-after-wipe", extruder_idx);
+ // Keep this optgroup's options in sync with publishable_printer_z_hop_options()
+ // in libslic3r/PublishSettings.hpp: the published-3MF printer allowlist is its union
+ // with the Retraction optgroup above.
optgroup = page->new_optgroup(L("Z-Hop"), L"param_extruder_lift_enforcement");
optgroup->append_single_option_line("retract_lift_enforce", "printer_extruder_z_hop#on-surfaces", extruder_idx);
optgroup->append_single_option_line("z_hop_types", "printer_extruder_z_hop#z-hop-type", extruder_idx);
diff --git a/src/slic3r/GUI/Tab.hpp b/src/slic3r/GUI/Tab.hpp
index 19eb0b849d..1d3f8daf2a 100644
--- a/src/slic3r/GUI/Tab.hpp
+++ b/src/slic3r/GUI/Tab.hpp
@@ -30,7 +30,6 @@
#include
//#include "BedShapeDialog.hpp"
-#include "Event.hpp"
#include "wxExtensions.hpp"
#include "ConfigManipulation.hpp"
#include "OptionsGroup.hpp"
@@ -38,7 +37,6 @@
//BBS: GUI refactor
#include "Notebook.hpp"
#include "ParamsPanel.hpp"
-#include "Widgets/RoundedRectangle.hpp"
#include "Widgets/TextInput.hpp"
#include "Widgets/CheckBox.hpp" // ORCA
@@ -472,6 +470,7 @@ protected:
std::string m_last_sparse_infill_rotate_template_value;
ConfigManipulation get_config_manipulation();
friend class EditGCodeDialog;
+ friend class PublishSettingsDialog;
};
class TabPrint : public Tab
diff --git a/src/slic3r/GUI/UnsavedChangesDialog.cpp b/src/slic3r/GUI/UnsavedChangesDialog.cpp
index 5c69466cfa..dd1bf43253 100644
--- a/src/slic3r/GUI/UnsavedChangesDialog.cpp
+++ b/src/slic3r/GUI/UnsavedChangesDialog.cpp
@@ -12,6 +12,7 @@
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/Color.hpp"
#include "format.hpp"
+#include "ConfigValueFormatter.hpp"
#include "GUI_App.hpp"
#include "Plater.hpp"
#include "Tab.hpp"
@@ -571,14 +572,6 @@ void DiffModel::Clear()
}
-static std::string get_pure_opt_key(std::string opt_key)
-{
- const int pos = opt_key.find("#");
- if (pos > 0)
- boost::erase_tail(opt_key, opt_key.size() - pos);
- return opt_key;
-}
-
// ----------------------------------------------------------------------------
// DiffViewCtrl
// ----------------------------------------------------------------------------
@@ -1212,32 +1205,6 @@ bool UnsavedChangesDialog::save(PresetCollection* dependent_presets, bool show_s
return true;
}
-wxString get_string_from_enum(const std::string& opt_key, const DynamicPrintConfig& config, bool is_infill = false, int idx = -1)
-{
- const ConfigOptionDef& def = config.def()->options.at(opt_key);
- const std::vector& names = def.enum_labels;//ConfigOptionEnum::get_enum_names();
- int val = 0;
-
- if (idx >= 0)
- val = dynamic_cast(config.option(opt_key))->get_at(idx);
- else
- val = config.option(opt_key)->getInt();
-
- // Each infill doesn't use all list of infill declared in PrintConfig.hpp.
- // So we should "convert" val to the correct one
- if (is_infill) {
- for (auto key_val : *def.enum_keys_map)
- if (int(key_val.second) == val) {
- auto it = std::find(def.enum_values.begin(), def.enum_values.end(), key_val.first);
- if (it == def.enum_values.end())
- return "";
- return from_u8(_utf8(names[it - def.enum_values.begin()]));
- }
- return _L("Undefined");
- }
- return from_u8(_utf8(names[val]));
-}
-
// BBS
#if 0
static size_t get_id_from_opt_key(std::string opt_key)
@@ -1251,194 +1218,6 @@ static size_t get_id_from_opt_key(std::string opt_key)
}
#endif
-static wxString get_full_label(std::string opt_key, const DynamicPrintConfig& config)
-{
- opt_key = get_pure_opt_key(opt_key);
- auto option = config.option(opt_key);
-
- if (!option || option->is_nil())
- return _L("N/A");
-
- const ConfigOptionDef* opt = config.def()->get(opt_key);
- return opt->full_label.empty() ? opt->label : opt->full_label;
-}
-
-static wxString get_string_value(std::string opt_key, const DynamicPrintConfig& config)
-{
- int orig_opt_idx = -1;
- int opt_idx = -1;
- int pos = opt_key.find("#");
- std::string temp_str = opt_key;
- if (pos > 0) {
- boost::erase_head(temp_str, pos + 1);
- orig_opt_idx = static_cast(atoi(temp_str.c_str()));
- }
- opt_idx = orig_opt_idx >= 0 ? orig_opt_idx : 0;
- opt_key = get_pure_opt_key(opt_key);
- auto option = config.option(opt_key);
- if (!option) {
- return _L("N/A");
- }
- auto opt_vector = dynamic_cast(option);
-
- if (option->is_scalar() && config.option(opt_key)->is_nil() ||
- option->is_vector() && opt_vector && opt_idx >= 0 && opt_idx < opt_vector->size() && opt_vector->is_nil(opt_idx))
- return _L("N/A");
-
- wxString out;
-
- const ConfigOptionDef* opt = config.def()->get(opt_key);
- bool is_nullable = opt->nullable;
-
- switch (opt->type) {
- case coInt:
- return from_u8((boost::format("%1%") % config.opt_int(opt_key)).str());
- case coInts: {
- if (is_nullable) {
- auto values = config.opt(opt_key);
- if (opt_idx < values->size())
- return from_u8((boost::format("%1%") % values->get_at(opt_idx)).str());
- }
- else {
- auto values = config.opt(opt_key);
- if (orig_opt_idx >= 0 && orig_opt_idx < values->size()) {
- return from_u8((boost::format("%1%") % values->get_at(opt_idx)).str());
- }
- else {
- std::string value_str;
- for (int i = 0; i < values->size(); i++) {
- value_str += std::to_string(values->get_at(i));
- if (i != values->size() - 1) {
- value_str += ",";
- }
- }
- return from_u8(value_str);
- }
- }
- return _L("Undefined");
- }
- case coBool:
- return config.opt_bool(opt_key) ? "true" : "false";
- case coBools: {
- if (is_nullable) {
- auto values = config.opt(opt_key);
- if (opt_idx < values->size())
- return values->get_at(opt_idx) ? "true" : "false";
- }
- else {
- auto values = config.opt(opt_key);
- if (opt_idx < values->size())
- return values->get_at(opt_idx) ? "true" : "false";
- }
- return _L("Undefined");
- }
- case coPercent:
- return from_u8((boost::format("%1%%%") % int(config.optptr(opt_key)->getFloat())).str());
- case coPercents: {
- if (is_nullable) {
- auto values = config.opt(opt_key);
- if (opt_idx < values->size())
- return from_u8((boost::format("%1%%%") % values->get_at(opt_idx)).str());
- }
- else {
- auto values = config.opt(opt_key);
- if (opt_idx < values->size())
- return from_u8((boost::format("%1%%%") % values->get_at(opt_idx)).str());
- }
- return _L("Undefined");
- }
- case coFloat:
- return double_to_string(config.opt_float(opt_key));
- case coFloats: {
- if (is_nullable) {
- auto values = config.opt(opt_key);
- if (opt_idx < values->size())
- return double_to_string(values->get_at(opt_idx));
- }
- else {
- auto values = config.opt(opt_key);
- if (values && opt_idx < values->size())
- return double_to_string(values->get_at(opt_idx));
- }
- return _L("Undefined");
- }
- case coString:
- return from_u8(config.opt_string(opt_key));
- case coStrings: {
- const ConfigOptionStrings* strings = config.opt(opt_key);
- if (strings) {
- if (opt_key == "compatible_printers" || opt_key == "compatible_prints") {
- if (strings->empty())
- return _L("All");
- for (size_t id = 0; id < strings->size(); id++)
- out += from_u8(strings->get_at(id)) + "\n";
- out.RemoveLast(1);
- return out;
- }
- if (!strings->empty() && opt_idx < strings->values.size())
- return from_u8(strings->get_at(opt_idx));
- }
- break;
- }
- case coFloatOrPercent: {
- const ConfigOptionFloatOrPercent* opt = config.opt(opt_key);
- if (opt)
- out = double_to_string(opt->value) + (opt->percent ? "%" : "");
- return out;
- }
- case coEnum: {
- return get_string_from_enum(opt_key, config,
- opt_key == "top_surface_pattern" ||
- opt_key == "bottom_surface_pattern" ||
- opt_key == "internal_solid_infill_pattern" ||
- opt_key == "sparse_infill_pattern" ||
- opt_key == "ironing_pattern" ||
- opt_key == "support_ironing_pattern" ||
- opt_key == "support_pattern" ||
- opt_key == "support_interface_pattern")
- ;
- }
- case coEnums: {
- return get_string_from_enum(opt_key, config,
- opt_key == "top_surface_pattern" ||
- opt_key == "bottom_surface_pattern" ||
- opt_key == "internal_solid_infill_pattern" ||
- opt_key == "sparse_infill_pattern" ||
- opt_key == "ironing_pattern" ||
- opt_key == "support_ironing_pattern" ||
- opt_key == "support_pattern" ||
- opt_key == "support_interface_pattern"
- , opt_idx);
- }
- case coPoint: {
- Vec2d val = config.opt(opt_key)->value;
- return from_u8((boost::format("[%1%]") % ConfigOptionPoint(val).serialize()).str());
- }
- case coPoints: {
- //BBS: add bed_exclude_area
- if (opt_key == "printable_area" || opt_key == "thumbnails") {
- ConfigOptionPoints points = *config.option(opt_key);
- //BuildVolume build_volume = {points.values, 0.};
- return get_thumbnails_string(points.values);
- }
- else if (opt_key == "bed_exclude_area") {
- return get_thumbnails_string(config.option(opt_key)->values);
- }
- else if (opt_key == "head_wrap_detect_zone") {
- return get_thumbnails_string(config.option(opt_key)->values);
- }
- else if (opt_key == "wrapping_exclude_area") {
- return get_thumbnails_string(config.option(opt_key)->values);
- }
- Vec2d val = config.opt(opt_key)->get_at(opt_idx);
- return from_u8((boost::format("[%1%]") % ConfigOptionPoint(val).serialize()).str());
- }
- default:
- break;
- }
- return out;
-}
-
void UnsavedChangesDialog::update(Preset::Type type, PresetCollection* dependent_presets, const std::string& new_selected_preset, const wxString& header)
{
PresetCollection* presets = dependent_presets;
diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp
index c839149f5f..4eccf0e9fa 100644
--- a/tests/libslic3r/test_3mf.cpp
+++ b/tests/libslic3r/test_3mf.cpp
@@ -11,6 +11,8 @@
#include "test_utils.hpp"
+#include
+
#include
#include
@@ -497,3 +499,178 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
delete plate;
}
}
+
+// The "Publish" feature stores a published flag plus a JSON array of author-selected setting keys
+// in model.model_info->metadata_items. This locks the serialization contract: both keys must survive
+// a store_bbs_3mf -> load_bbs_3mf round-trip unchanged. (The full preset-preservation behavior —
+// keeping the user's currently-selected presets and overlaying only the published keys onto them —
+// is exercised headlessly in "Published 3MF overlays only the author-selected process keys onto the
+// edited preset" in test_preset_bundle_loading.cpp.)
+SCENARIO("Published 3MF round-trips the published flag and published_keys metadata", "[3mf]") {
+ GIVEN("a model carrying published 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();
+
+ model.model_info = std::make_shared();
+ model.model_info->metadata_items["published"] = "1";
+ model.model_info->metadata_items["published_keys"] = R"(["layer_height","wall_thickness"])";
+
+ // store_bbs_3mf stages Metadata/project_settings.config through the model's backup path;
+ // point it at a writable temp dir (the default lives under a read-only root in CI).
+ ScopedTemporaryDir backup_dir("orca_pub");
+ 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 published metadata round-trips unchanged") {
+ REQUIRE(loaded);
+ REQUIRE(dst_model.model_info != nullptr);
+ REQUIRE(dst_model.model_info->metadata_items["published"] == "1");
+ REQUIRE(dst_model.model_info->metadata_items["published_keys"] == R"(["layer_height","wall_thickness"])");
+
+ // The published_keys value is a JSON array of setting keys; it must parse back to
+ // the same keys that were selected.
+ nlohmann::json keys = nlohmann::json::parse(dst_model.model_info->metadata_items["published_keys"]);
+ REQUIRE(keys.is_array());
+ REQUIRE(keys.size() == 2);
+ REQUIRE(keys[0] == "layer_height");
+ REQUIRE(keys[1] == "wall_thickness");
+ }
+ release_PlateData_list(dst_plates);
+ }
+ }
+}
+
+// A project saved without the Publish metadata (i.e. a normal 3MF) must load identically: the
+// loader must not fabricate a "published" flag or published_keys for files that never carried them.
+SCENARIO("Legacy 3MF without published metadata loads unchanged", "[3mf]") {
+ GIVEN("a model without any published 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();
+
+ ScopedTemporaryDir backup_dir("orca_legacy");
+ 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("no published key is fabricated") {
+ REQUIRE(loaded);
+ if (dst_model.model_info != nullptr) {
+ REQUIRE(dst_model.model_info->metadata_items.count("published") == 0);
+ REQUIRE(dst_model.model_info->metadata_items.count("published_keys") == 0);
+ }
+ }
+ release_PlateData_list(dst_plates);
+ }
+ }
+}
+
+// The "Publish" feature can also store material-qualified setting keys, one entry per material
+// the author uses, in model.model_info->metadata_items. This locks the serialization contract
+// for that entry list: the JSON must survive a store_bbs_3mf -> load_bbs_3mf round-trip
+// verbatim, exactly like the plain published_keys array.
+SCENARIO("Published 3MF round-trips the published_material_keys metadata", "[3mf]") {
+ GIVEN("a model carrying 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":0,"keys":["filament_retraction_length","filament_z_hop"]}])";
+
+ model.model_info = std::make_shared();
+ model.model_info->metadata_items["published_material_keys"] = material_keys_json;
+
+ ScopedTemporaryDir backup_dir("orca_pub_mat");
+ 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 published material keys 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 to one material entry carrying the nested identity
+ // object, the author slot ordinal and the key list, so the loader can match it
+ // to the receiver's filaments.
+ nlohmann::json entries = nlohmann::json::parse(material_keys_json);
+ REQUIRE(entries.is_array());
+ REQUIRE(entries.size() == 1);
+ REQUIRE(entries[0]["material"]["filament_type"] == "PLA");
+ REQUIRE(entries[0]["material"]["filament_vendor"] == "Generic");
+ REQUIRE(entries[0]["material"]["filament_id"] == "GFL99");
+ REQUIRE(entries[0]["slot"] == 0);
+ REQUIRE(entries[0]["keys"].is_array());
+ REQUIRE(entries[0]["keys"].size() == 2);
+ REQUIRE(entries[0]["keys"][0] == "filament_retraction_length");
+ }
+ release_PlateData_list(dst_plates);
+ }
+ }
+}
diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp
index 844ccb6a8b..fc61083896 100644
--- a/tests/libslic3r/test_preset_bundle_loading.cpp
+++ b/tests/libslic3r/test_preset_bundle_loading.cpp
@@ -7,12 +7,20 @@
#include "test_utils.hpp"
+#include
+
using namespace Slic3r;
namespace {
namespace fs = boost::filesystem;
+// Whether a key is listed in a vector of keys (published_keys / skipped_keys).
+bool contains_key(const std::vector &keys, const std::string &key)
+{
+ return std::find(keys.begin(), keys.end(), key) != keys.end();
+}
+
void write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {})
{
DynamicPrintConfig config(default_config);
@@ -540,3 +548,530 @@ TEST_CASE("A printer specific filament supersedes the generic library filament w
CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr)));
}
+// A "published" 3MF keeps the user's currently-selected presets and overlays only the
+// author-selected process keys onto the edited preset. Mirrors the GUI load path
+// (src/slic3r/GUI/Plater.cpp): Preset::normalize before load_config_model, then the
+// published overlay in PresetBundle::load_config_file_config.
+TEST_CASE("Published 3MF overlays only the author-selected process keys onto the edited preset", "[Preset][Bundle][Published]")
+{
+ // The file config the GUI builds from a .3mf's project settings.
+ auto make_file_config = [] {
+ DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
+ // The loader derives the filament count from filament_colour and throws when it is
+ // empty ("Invalid configuration file"); a 3mf always carries it.
+ config.opt("filament_colour")->values = { "#FF0000" };
+ // Process scalar key.
+ config.opt_float("layer_height") = 0.28;
+ // Process vector key, size 2 to match the edited preset's resized vector.
+ config.opt("wiping_volumes_extruders")->values = { 140., 150. };
+ // Process vector key, size 2: deliberately mismatched against the edited preset.
+ config.opt("post_process")->values = { "script-a", "script-b" };
+ // A filament key: published files may still carry legacy filament keys.
+ config.opt("nozzle_temperature")->values = { 220 };
+ // A structural (denylisted) key: must be silently ignored even if a hand-crafted
+ // file lists it as published. full_print_config() omits the *_settings_id keys (they
+ // have no static counterpart), while a real 3mf project config carries it, so create
+ // it explicitly.
+ config.opt_string("print_settings_id", true) = "file process";
+ // Project-level filament/purge data: must NOT cross over in published mode.
+ config.opt("flush_multiplier")->values = { 2., 2. };
+ // A project-level option, to pin the project_config.apply_only() invariant.
+ config.opt("wipe_tower_x")->values = { 100. };
+ // The author's bed type must NOT cross over either: the receiver keeps its own.
+ config.option("curr_bed_type")->setInt(BedType::btPC);
+ return config;
+ };
+
+ const std::vector published_keys = {
+ "layer_height", "wiping_volumes_extruders", "post_process", "nozzle_temperature", "print_settings_id"
+ };
+
+ PresetBundle bundle;
+ const std::string pre_load_name = bundle.prints.get_edited_preset().name;
+ const size_t pre_load_size = bundle.prints.size();
+
+ // The edited presets are the overlay targets: recognizable pre-load values.
+ bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1;
+ bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values = { 10., 20. };
+ bundle.prints.get_edited_preset().config.opt("post_process")->values = { "existing-script" };
+ bundle.prints.get_edited_preset().config.opt_string("print_settings_id") = "user process";
+ // Capture the ctor-seeded project_config values; the assertions below check that the
+ // published load leaves them untouched rather than hardcoding the defaults.
+ const std::vector seed_filament_colour = bundle.project_config.opt("filament_colour")->values;
+ const std::vector seed_flush_multiplier = bundle.project_config.opt("flush_multiplier")->values;
+ const int seed_bed_type = bundle.project_config.option("curr_bed_type")->getInt();
+
+ DynamicPrintConfig config = make_file_config();
+ // The GUI normalizes the config before load; do the same so only the production path is exercised.
+ Preset::normalize(config);
+
+ PublishedConfig pub;
+ pub.published = true;
+ pub.published_keys = published_keys;
+ bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
+
+ // a) The process scalar is overlaid onto the edited preset.
+ CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001));
+
+ // b) A matching-size process vector is applied; a size-mismatched one is neither applied
+ // nor reported as skipped by accident — it lands in skipped_keys.
+ CHECK(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values == std::vector{ 140., 150. });
+ CHECK(bundle.prints.get_edited_preset().config.opt("post_process")->values == std::vector{ "existing-script" });
+ CHECK(contains_key(pub.skipped_keys, "post_process"));
+
+ // c) A filament key is never applied anywhere and is reported as skipped (warning).
+ CHECK(bundle.prints.get_edited_preset().config.option("nozzle_temperature") == nullptr);
+ CHECK(contains_key(pub.skipped_keys, "nozzle_temperature"));
+
+ // d) A structural key is silently ignored: neither applied nor reported as skipped.
+ CHECK(bundle.prints.get_edited_preset().config.opt_string("print_settings_id") == "user process");
+ CHECK_FALSE(contains_key(pub.skipped_keys, "print_settings_id"));
+
+ // Applied keys are not reported as skipped.
+ CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height"));
+ CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders"));
+
+ // e) project_config.apply_only() still runs, but in published mode only the plate/bed
+ // geometry crosses: the file's filament/purge data must NOT port to the receiver.
+ // Filament colors do not port; project_config keeps its ctor-seeded values.
+ CHECK(bundle.project_config.opt("filament_colour")->values != std::vector{ "#FF0000" });
+ CHECK(bundle.project_config.opt("filament_colour")->values == seed_filament_colour);
+ // Purge data does not port either, and update_multi_material_filament_presets() cannot
+ // resurrect it (flush_multiplier stays at the ctor seed).
+ CHECK(bundle.project_config.opt("flush_multiplier")->values == seed_flush_multiplier);
+ // The author's bed type does not cross over: the receiver keeps its own.
+ CHECK(bundle.project_config.option("curr_bed_type")->getInt() == seed_bed_type);
+ // Plate/bed geometry still crosses.
+ CHECK(bundle.project_config.opt("wipe_tower_x")->values == std::vector{ 100. });
+
+ // The published path keeps the user's currently-selected presets: the edited process
+ // preset is the same preset as before the load.
+ CHECK(bundle.prints.get_edited_preset().name == pre_load_name);
+ CHECK(bundle.prints.size() == pre_load_size);
+
+ // f) Non-published control: with published=false the overlay is disabled. The file's
+ // presets are loaded and selected instead (the user's preset is not kept) and no
+ // skipped_keys are produced.
+ PresetBundle control_bundle;
+ const size_t control_pre_size = control_bundle.prints.size();
+ PublishedConfig control_pub;
+ control_pub.published = false;
+ control_pub.published_keys = published_keys;
+ DynamicPrintConfig control_config = make_file_config();
+ Preset::normalize(control_config);
+ control_bundle.load_config_model("test.3mf", std::move(control_config), Semver(), &control_pub);
+
+ CHECK(control_pub.skipped_keys.empty());
+ CHECK(control_bundle.prints.size() > control_pre_size);
+ // The file's layer_height reached the edited preset through the normal preset import,
+ // not through the published overlay.
+ CHECK_THAT(control_bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001));
+}
+
+// The published printer overlay is restricted to the publishable retraction/z-hop allowlist
+// (publishable_printer_keys). Matching-size retraction vectors apply; mismatched vectors are
+// reported as skipped; any other printer-class key (e.g. machine_start_gcode) is
+// contract-excluded: never applied and never reported.
+TEST_CASE("Published 3MF overlays only the allowlisted retraction and z-hop keys onto the edited printer preset", "[Preset][Bundle][Published]")
+{
+ DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
+ config.opt("filament_colour")->values = { "#FF0000" };
+ Preset::normalize(config);
+ // Matching size (the receiver's default printer has one extruder).
+ config.opt("retraction_length")->values = { 1.4 };
+ // Size 2: mismatched against the single-extruder receiver.
+ config.opt("retraction_speed")->values = { 45., 55. };
+ // Printer-class but outside the allowlist: must be silently contract-excluded.
+ config.opt_string("machine_start_gcode") = "G28 ; from file";
+
+ PresetBundle bundle;
+ bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 };
+ bundle.printers.get_edited_preset().config.opt_string("machine_start_gcode") = "G28 ; user";
+
+ PublishedConfig pub;
+ pub.published = true;
+ pub.published_keys = { "retraction_length", "retraction_speed", "machine_start_gcode" };
+ bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
+
+ // Matching-size retraction vector applied to the edited printer preset.
+ CHECK(bundle.printers.get_edited_preset().config.opt("retraction_length")->values == std::vector{ 1.4 });
+ // Mismatched vector not applied and reported as skipped.
+ CHECK(bundle.printers.get_edited_preset().config.opt("retraction_speed")->values == std::vector{ 30. });
+ CHECK(contains_key(pub.skipped_keys, "retraction_speed"));
+ // Contract-excluded printer key: silently ignored, absent from skipped_keys.
+ CHECK(bundle.printers.get_edited_preset().config.opt_string("machine_start_gcode") == "G28 ; user");
+ CHECK_FALSE(contains_key(pub.skipped_keys, "machine_start_gcode"));
+}
+
+// A published 3MF can carry material-qualified keys; on load they are applied to the
+// receiver's filament presets whose material identity matches the author's (filament_id when
+// both sides have one, filament_type + vendor fallback otherwise).
+TEST_CASE("Published 3MF applies material retraction keys onto the receiver's matching filament presets", "[Preset][Bundle][Published]")
+{
+ auto make_file_config = [] {
+ DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
+ // Two filament slots; filament_diameter drives the normalized per-slot vector sizes.
+ config.opt("filament_diameter")->values = { 1.75, 1.75 };
+ // Keep the multi-extruder consistency validation happy for a 2-slot config.
+ config.opt("filament_self_index")->values = { 1, 2 };
+ config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
+ // Author per-slot material identity (filament_ids feeds the loader's local copy).
+ config.opt("filament_colour")->values = { "#FF0000", "#00FF00" };
+ config.opt("filament_type")->values = { "PLA", "PETG" };
+ config.opt("filament_vendor")->values = { "Generic", "Generic" };
+ config.opt("filament_ids")->values = { "GFL99", "GFT99" };
+ // Author per-slot retraction values. These are per-filament override keys that are not
+ // members of the static PrintRegionConfig, so full_print_config() omits them and they
+ // must be created explicitly (as nullable, matching the real 3MF project config).
+ config.option("filament_retraction_length", true)->values = { 0.9, 1.2 };
+ config.option("filament_z_hop", true)->values = { 0.2, 0.3 };
+ return config;
+ };
+
+ PresetBundle bundle;
+ // Receiver materials with matching stable ids.
+ Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
+ pla.filament_id = "GFL99";
+ pla.config.opt_string("filament_type", 0u) = "PLA";
+ pla.config.opt_string("filament_vendor", 0u) = "Generic";
+ // In-memory preset configs carry the per-filament retraction keys as nullable options
+ // (the type real filament presets hold), so access them through the nullable type.
+ pla.config.opt("filament_retraction_length", true)->values = { 0.5 };
+ pla.config.opt("filament_settings_id")->values = { "receiver-pla" };
+ Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
+ petg.filament_id = "GFT99";
+ petg.config.opt_string("filament_type", 0u) = "PETG";
+ petg.config.opt_string("filament_vendor", 0u) = "Generic";
+ petg.config.opt("filament_retraction_length", true)->values = { 0.6 };
+ // The z-hop key must exist on the receiver preset for the overlay to apply into it.
+ petg.config.opt("filament_z_hop", true)->values = { 0.1 };
+ bundle.filament_presets = { "My PLA", "My PETG" };
+
+ PublishedMaterialEntry pla_entry;
+ pla_entry.filament_type = "PLA";
+ pla_entry.filament_vendor = "Generic";
+ pla_entry.filament_id = "GFL99";
+ pla_entry.slot = 0; // the author's PLA slot
+ pla_entry.keys = { "filament_retraction_length", "filament_settings_id" };
+ PublishedMaterialEntry petg_entry;
+ petg_entry.filament_type = "PETG";
+ petg_entry.filament_vendor = "Generic";
+ petg_entry.filament_id = "GFT99";
+ petg_entry.slot = 1; // the author's PETG slot
+ petg_entry.keys = { "filament_retraction_length", "filament_z_hop" };
+ // A material that does not exist on the author's side: whole entry skipped, no reporting.
+ PublishedMaterialEntry abs_entry;
+ abs_entry.filament_type = "ABS";
+ abs_entry.filament_id = "GFX99";
+ abs_entry.keys = { "filament_retraction_length" };
+
+ PublishedConfig pub;
+ pub.published = true;
+ pub.material_keys = { pla_entry, petg_entry, abs_entry };
+ DynamicPrintConfig config = make_file_config();
+ Preset::normalize(config);
+ bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
+
+ // Per-slot scalar copy: the author's slot value lands in the matching receiver preset.
+ CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_retraction_length")->values == std::vector{ 0.9 });
+ CHECK(bundle.filaments.find_preset("My PETG")->config.opt("filament_retraction_length")->values == std::vector{ 1.2 });
+ CHECK(bundle.filaments.find_preset("My PETG")->config.opt("filament_z_hop")->values == std::vector{ 0.3 });
+ // Structural keys inside a material entry are silently ignored: the receiver's own
+ // filament_settings_id is left untouched and nothing is reported for it.
+ CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_settings_id")->values == std::vector{ "receiver-pla" });
+ CHECK_FALSE(contains_key(pub.skipped_keys, "material:GFL99 (filament_settings_id)"));
+ // Everything applied; the unknown material entry produced no skipped entry.
+ CHECK(pub.skipped_keys.empty());
+}
+
+// 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.
+TEST_CASE("Published 3MF reports material keys with no unique receiver match as skipped", "[Preset][Bundle][Published]")
+{
+ auto make_file_config = [] {
+ DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
+ // Three author slots: PLA, PETG, ABS.
+ config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 };
+ config.opt("filament_self_index")->values = { 1, 2, 3 };
+ config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" };
+ config.opt