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("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF" }; + config.opt("filament_type")->values = { "PLA", "PETG", "ABS" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFT99", "GFA99" }; + config.option("filament_retraction_length", true)->values = { 0.9, 1.2, 1.5 }; + return config; + }; + + PresetBundle bundle; + // Two receiver presets of the SAME type with no filament_id: the type fallback is ambiguous. + Preset &pla_a = add_inmemory_preset(bundle.filaments, "My PLA A"); + pla_a.config.opt_string("filament_type", 0u) = "PLA"; + pla_a.config.opt_string("filament_vendor", 0u) = "Generic"; + pla_a.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &pla_b = add_inmemory_preset(bundle.filaments, "My PLA B"); + pla_b.config.opt_string("filament_type", 0u) = "PLA"; + pla_b.config.opt_string("filament_vendor", 0u) = "Generic"; + pla_b.config.opt("filament_retraction_length", true)->values = { 0.5 }; + // A unique PETG receiver preset: the single type fallback is unambiguous. + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + 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 }; + bundle.filament_presets = { "My PLA A", "My PLA B", "My PETG" }; + + auto make_entry = [](const std::string &type, const std::string &key) { + PublishedMaterialEntry entry; + entry.filament_type = type; + entry.filament_vendor = "Generic"; + entry.keys = { key }; + return entry; + }; + + PublishedMaterialEntry pla_entry = make_entry("PLA", "filament_retraction_length"); + pla_entry.slot = 0; // the author's PLA slot + PublishedMaterialEntry petg_entry = make_entry("PETG", "filament_retraction_length"); + petg_entry.slot = 1; // the author's PETG slot + PublishedMaterialEntry abs_entry = make_entry("ABS", "filament_retraction_length"); // author slot exists, no receiver match + abs_entry.slot = 2; + + 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); + + // Ambiguous type fallback: neither PLA preset is touched, reported as skipped. + CHECK(bundle.filaments.find_preset("My PLA A")->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); + CHECK(bundle.filaments.find_preset("My PLA B")->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); + CHECK(contains_key(pub.skipped_keys, "material:PLA (filament_retraction_length)")); + // Unambiguous single fallback: applied. + CHECK(bundle.filaments.find_preset("My PETG")->config.opt("filament_retraction_length")->values == std::vector{ 1.2 }); + CHECK_FALSE(contains_key(pub.skipped_keys, "material:PETG (filament_retraction_length)")); + // Receiver-side miss: the author slot exists but no receiver preset matches. + CHECK(contains_key(pub.skipped_keys, "material:ABS (filament_retraction_length)")); +} + +// A slotted material entry carries the author's per-slot overrides: on load it applies to the +// receiver's matching preset at the author's slot ordinal (first matching author slot -> first +// matching receiver preset, second -> second, ...). Legacy entries without a slot keep applying +// to every matching receiver preset. +TEST_CASE("Published material keys apply to the receiver's matching filament preset by author slot ordinal", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // Three author slots of the SAME material (PETG) with distinct per-slot retraction. + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF" }; + config.opt("filament_type")->values = { "PETG", "PETG", "PETG" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFT99", "GFT99", "GFT99" }; + config.option("filament_retraction_length", true)->values = { 0.7, 0.8, 0.9 }; + return config; + }; + auto make_slotted_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.filament_type = "PETG"; + entry.filament_vendor = "Generic"; + entry.filament_id = "GFT99"; + entry.slot = slot; + entry.keys = { "filament_retraction_length" }; + return entry; + }; + auto add_petg_preset = [](PresetBundle &bundle, const std::string &name) { + Preset &preset = add_inmemory_preset(bundle.filaments, name); + preset.filament_id = "GFT99"; + preset.config.opt_string("filament_type", 0u) = "PETG"; + preset.config.opt_string("filament_vendor", 0u) = "Generic"; + preset.config.opt("filament_retraction_length", true)->values = { 0.5 }; + return &preset; + }; + + // Three receiver presets, one per author slot: each gets its ordinal's value. + { + PresetBundle bundle; + add_petg_preset(bundle, "My PETG 1"); + add_petg_preset(bundle, "My PETG 2"); + add_petg_preset(bundle, "My PETG 3"); + bundle.filament_presets = { "My PETG 1", "My PETG 2", "My PETG 3" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_slotted_entry(0), make_slotted_entry(1), make_slotted_entry(2) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Each author slot's value lands in the receiver preset at the same ordinal. + CHECK(bundle.filaments.find_preset("My PETG 1")->config.opt("filament_retraction_length")->values == std::vector{ 0.7 }); + CHECK(bundle.filaments.find_preset("My PETG 2")->config.opt("filament_retraction_length")->values == std::vector{ 0.8 }); + CHECK(bundle.filaments.find_preset("My PETG 3")->config.opt("filament_retraction_length")->values == std::vector{ 0.9 }); + CHECK(pub.skipped_keys.empty()); + } + + // A single receiver preset: only the first ordinal fits; the later slots are reported + // with a slot-qualified label. + { + PresetBundle bundle; + add_petg_preset(bundle, "My PETG 1"); + bundle.filament_presets = { "My PETG 1" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_slotted_entry(0), make_slotted_entry(1), make_slotted_entry(2) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filaments.find_preset("My PETG 1")->config.opt("filament_retraction_length")->values == std::vector{ 0.7 }); + CHECK(contains_key(pub.skipped_keys, "material:GFT99 slot 1 (filament_retraction_length)")); + CHECK(contains_key(pub.skipped_keys, "material:GFT99 slot 2 (filament_retraction_length)")); + CHECK_FALSE(contains_key(pub.skipped_keys, "material:GFT99 slot 0 (filament_retraction_length)")); + } + + // A legacy entry (no slot) applies to every matching receiver preset, from the first + // author slot. + { + PresetBundle bundle; + add_petg_preset(bundle, "My PETG 1"); + add_petg_preset(bundle, "My PETG 2"); + bundle.filament_presets = { "My PETG 1", "My PETG 2" }; + + PublishedMaterialEntry legacy = make_slotted_entry(0); + legacy.slot = -1; // legacy: no slot field + PublishedConfig pub; + pub.published = true; + pub.material_keys = { legacy }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filaments.find_preset("My PETG 1")->config.opt("filament_retraction_length")->values == std::vector{ 0.7 }); + CHECK(bundle.filaments.find_preset("My PETG 2")->config.opt("filament_retraction_length")->values == std::vector{ 0.7 }); + CHECK(pub.skipped_keys.empty()); + } + + // An out-of-range author slot is silently skipped: nothing applied, nothing reported. + { + PresetBundle bundle; + add_petg_preset(bundle, "My PETG 1"); + bundle.filament_presets = { "My PETG 1" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_slotted_entry(5) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filaments.find_preset("My PETG 1")->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); + CHECK(pub.skipped_keys.empty()); + } +} + +// The published overlay must validate '#' variant indices: an out-of-range index must be +// reported as skipped and must NOT resize/corrupt the receiver's vector, and a variant suffix +// on a scalar key must be rejected instead of silently no-op'd. +TEST_CASE("Published 3MF rejects out-of-range vector variants and variant-suffixed scalar keys", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + // Vector key, size 2 (matches the receiver's resized vector); distinct values so the + // applied element is observable. + config.opt("wiping_volumes_extruders")->values = { 140., 150. }; + config.opt_float("layer_height") = 0.28; + Preset::normalize(config); + + PresetBundle bundle; + bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values = { 10., 20. }; + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "wiping_volumes_extruders#5", "wiping_volumes_extruders#1", "layer_height#0" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // In-range variant applied element-wise; the out-of-range one did not resize the vector. + CHECK(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values == std::vector{ 10., 150. }); + CHECK(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values.size() == 2); + // Out-of-range variant and variant-suffixed scalar are reported as skipped. + CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders#5")); + CHECK(contains_key(pub.skipped_keys, "layer_height#0")); + CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders#1")); + // The scalar was never applied. + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.1, 0.000001)); +} + +// A receiver filament preset whose material identity fields are missing (hand-edited preset +// file) must not crash the material pass: the entry simply cannot match and is reported skipped. +TEST_CASE("Published 3MF survives a receiver filament preset missing its material identity", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9 }; + Preset::normalize(config); + + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + // Malformed receiver preset: the identity options are missing entirely. + pla.config.erase("filament_type"); + pla.config.erase("filament_vendor"); + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.filament_id = "GFL99"; + entry.slot = 0; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // No match possible without the identity fields: reported skipped, preset untouched. + CHECK(contains_key(pub.skipped_keys, "material:GFL99 (filament_retraction_length)")); + CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_retraction_length")->values == std::vector{ 0.5 }); +} + +// The printer publishable allowlist is the union of the printer tab's "Retraction" and +// "Z-Hop" optgroup option lists; lock the exact contents and order (Tab.cpp). +TEST_CASE("Printer publishable allowlist matches the printer tab's Retraction and Z-Hop optgroups", "[Preset][Bundle][Published]") +{ + auto keys_of = [](const std::vector& opts) { + std::vector keys; + keys.reserve(opts.size()); + for (const PublishablePrinterOption& opt : opts) + keys.emplace_back(opt.key); + return keys; + }; + + const std::vector expected_retraction = { + "retraction_length", "retract_restart_extra", "retraction_speed", "deretraction_speed", + "retraction_minimum_travel", "retract_when_changing_layer", "wipe", "wipe_distance", + "retract_before_wipe", "retract_after_wipe" + }; + const std::vector expected_z_hop = { + "retract_lift_enforce", "z_hop_types", "z_hop", "travel_slope", "retract_lift_above", + "retract_lift_below" + }; + + CHECK(keys_of(publishable_printer_retraction_options()) == expected_retraction); + CHECK(keys_of(publishable_printer_z_hop_options()) == expected_z_hop); + + std::set expected_union(expected_retraction.begin(), expected_retraction.end()); + expected_union.insert(expected_z_hop.begin(), expected_z_hop.end()); + CHECK(publishable_printer_keys() == expected_union); +} +