From 52aa5a52e9e2943485195d3408793cf2220e4a58 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Tue, 25 Aug 2026 12:27:45 +0800 Subject: [PATCH] Published 3MF: import Full Publish materials as standalone detached presets inside the project --- src/libslic3r/Preset.cpp | 75 ++++++++++++++++++++ src/libslic3r/Preset.hpp | 18 +++++ src/libslic3r/PresetBundle.cpp | 111 ++++++++++++++++++++++++++++++ src/libslic3r/PublishSettings.cpp | 33 +++++++++ src/libslic3r/PublishSettings.hpp | 30 +++++--- 5 files changed, 259 insertions(+), 8 deletions(-) diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 1334bd4e7a..c422253dcd 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -3053,6 +3053,81 @@ void PresetCollection::save_current_preset(const std::string &new_name, bool det this->get_selected_preset().save(nullptr); } +// A detached standalone preset for the Full Publish receiver: create a user preset holding +// the full resolved filament config (no inheritance, no vendor/alias links), parentless and +// universally compatible. Mirrors save_current_preset(detach=true)'s creation branch but +// does not force-select or diff against a parent; the caller decides whether to select it. +// The published entry's filament_id is forwarded so user bases keep their stable +// material grouping (get_filament_presets() groups user bases by filament_id). +// save_to_project=true (the Full Publish default) creates a project-embedded preset: +// it lives inside the loaded project only (serialized into the saved .3mf, restored by +// load_project_embedded_presets) and never touches the user's library directory; +// Preset::save() early-returns for embedded presets, so persistence is skipped here too. +// Returns the final (uniquified) name; on collision "" -> " (Published)" -> +// " (Published 2)" ... +std::string PresetCollection::add_detached_preset(const std::string &name_base, DynamicPrintConfig config, + const std::string &filament_id, bool save_to_project) +{ + if (name_base.empty()) + return std::string(); + Preset stored(m_type, name_base); + stored.config = std::move(config); + stored.filament_id = filament_id; + + // Uniquify verbatim; only on collision append " (Published)" then " (Published 2)". + const std::string base_name = name_base; + std::string final_name = base_name; + auto exists = [this](const std::string &candidate) -> bool { + const auto it = this->find_preset_internal(candidate); + return it != m_presets.end() && it->name == candidate; + }; + if (exists(final_name)) { + final_name = base_name + " (Published)"; + for (int i = 2; exists(final_name); ++i) + final_name = base_name + " (Published " + std::to_string(i) + ")"; + } + + // Creation branch of save_current_preset(detach=true), without its selection side + // effects or project-embedded path. + lock(); + const auto it = this->find_preset_internal(final_name); + Preset &preset = *m_presets.insert(it, stored); + stored.name.clear(); // avoid stale copied name being used below + stored.config.clear(); + preset.name = final_name; + preset.vendor = nullptr; + preset.alias.clear(); + preset.renamed_from.clear(); + preset.m_excluded_from.clear(); + preset.setting_id.clear(); + preset.inherits().clear(); + preset.version = Semver::parse(SoftFever_VERSION) ? *Semver::parse(SoftFever_VERSION) : Semver(); + preset.is_default = false; + preset.is_system = false; + preset.is_external = false; + preset.bundle_id.clear(); + preset.file = this->path_for_preset(preset); + preset.is_visible = true; + preset.is_project_embedded = save_to_project; + if (m_type == Preset::TYPE_PRINT) + preset.config.option("print_settings_id", true)->value = final_name; + else if (m_type == Preset::TYPE_FILAMENT) + preset.config.option("filament_settings_id", true)->values[0] = final_name; + else if (m_type == Preset::TYPE_PRINTER) + preset.config.option("printer_settings_id", true)->value = final_name; + unlock(); + + if (!save_to_project) { + // Persist the full resolved config (no parent). Project-embedded presets are + // serialized into the .3mf instead; Preset::save() would early-return anyway. + // find by final_name — m_presets may have reallocated, so don't keep a raw ref. + auto persist_it = this->find_preset_internal(final_name); + if (persist_it != m_presets.end() && persist_it->name == final_name) + persist_it->save(nullptr); + } + return final_name; +} + bool PresetCollection::delete_current_preset() { Preset &selected = this->get_selected_preset(); diff --git a/src/libslic3r/Preset.hpp b/src/libslic3r/Preset.hpp index c9b3197a6f..e904851a1d 100644 --- a/src/libslic3r/Preset.hpp +++ b/src/libslic3r/Preset.hpp @@ -631,6 +631,24 @@ public: // All presets are marked as not modified and the new preset is activated. //BBS: add project embedded preset logic void save_current_preset(const std::string &new_name, bool detach = false, bool save_to_project = false, Preset* _curr_preset = nullptr); + // Insert a standalone user preset holding the full resolved config (no inheritance, + // no vendor links): the libslic3r equivalent of "Detach from parent". Takes a + // resolved config, clears parent/vendor/alias metadata, stamps filament_settings_id. + // Unlike save_current_preset it does not force-select or diff against a parent. + // Used by the published-3MF Full Publish path. The optional filament_id seeds the + // preset's stable material grouping (get_filament_presets groups user bases by + // filament_id); the published entry's filament_id is forwarded so the copy keeps + // the author's grouping. + // With save_to_project=true (default) the copy is a project-embedded preset + // ("Preset Inside Project"): it lives inside the loaded project only, is serialized + // into the saved .3mf via get_current_project_embedded_presets(), and is never + // written to the user's library directory. With false it persists as a normal + // user preset file. + // Returns the final (uniquified) name; on collision the suffix rule is: + // "" -> " (Published)" -> " (Published 2)" ... + std::string add_detached_preset(const std::string &name_base, DynamicPrintConfig config, + const std::string &filament_id = std::string(), + bool save_to_project = true); // Delete the current preset, activate the first visible preset. // returns true if the preset was deleted successfully. diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index ce88c7a030..96373eae7c 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -5312,6 +5312,10 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool // slots wrote to it; that compound case is not chased.) const bool edited_survives_load = this->filament_presets.empty() || this->filament_presets.front() == this->filaments.get_edited_preset().name; + // Full Publish within-load dedup: identical Full materials (same setting_id + // + preset_name identity) share one created instance, so an author who + // pointed two slots at one preset yields one standalone copy here. + std::map published_full_dedup; for (const PublishedMaterialEntry &entry : published_config->material_keys) { if (entry.slot < 0 || size_t(entry.slot) >= this->filament_presets.size()) continue; // out of range: nothing to do for this slot @@ -5327,6 +5331,113 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool ? (entry.publish_type_value.empty() ? entry.filament_type : entry.publish_type_value) : entry.filament_id; + // Full Publish: always create a standalone detached copy (even on exact + // identity match) as a "Preset Inside Project" (project-embedded: lives + // in this project only, never written to the library), universally + // compatible, named after the author's preset with its variant tail + // stripped ("(Published)" uniquification on collision). No existing + // preset is ever mutated. + if (entry.full) { + std::string dedup_key = entry.setting_id + std::string("\x1f") + entry.preset_name; + // Identity-less hand-crafted files (empty setting_id+name) must not + // collide: fall back to slot-scoped key so each slot gets its own copy + // unless the dedup above is meaningful. + if (dedup_key == std::string("\x1f")) + dedup_key = dedup_key + std::to_string(slot); + else if (!entry.filament_id.empty()) + dedup_key += std::string("\x1f") + entry.filament_id; + std::string new_name; + const auto dedup_it = published_full_dedup.find(dedup_key); + if (dedup_it != published_full_dedup.end()) { + new_name = dedup_it->second; + } else { + // Baseline: clone the receiver slot's stored preset config (schema- + // complete across versions), then overlay the published full_keys. + DynamicPrintConfig new_cfg = recv != nullptr + ? recv->config : this->filaments.default_preset().config; + for (const std::string &key : entry.full_keys) { + const std::string base_key = publish_base_key(key); + if (structural_keys.count(base_key) != 0) + continue; + const ConfigOption *src_opt = config.option(base_key); + if (src_opt == nullptr || !src_opt->is_vector() || + entry.slot < 0 || + entry.slot >= static_cast(static_cast(src_opt)->size())) + continue; + ConfigOption *dst_opt = new_cfg.option(base_key); + if (dst_opt == nullptr || !dst_opt->is_vector() || + static_cast(dst_opt)->empty() || + dst_opt->type() != src_opt->type()) + continue; + static_cast(dst_opt)->set_at(src_opt, 0, entry.slot); + } + // The published colour is authoritative even for Full (the payload's + // filament_colour plus the explicit publish_color field). + if (entry.publish_color && !entry.color.empty()) { + if (ConfigOptionStrings *col = new_cfg.opt("filament_colour", true)) { + if (col->values.empty()) + col->values.emplace_back(); + col->values[0] = entry.color; + } + } + make_publish_universal(new_cfg); + // Naming: stripped variant tail ("Generic PLA @System" -> "Generic + // PLA"), then identity fallbacks; collisions uniquify with + // "(Published)" / "(Published N)" inside add_detached_preset. + std::string base_name = entry.preset_name.empty() ? std::string() : publish_material_base_name(entry.preset_name); + if (base_name.empty()) { + base_name = !entry.filament_id.empty() ? entry.filament_id + : (!entry.publish_type_value.empty() ? entry.publish_type_value : entry.filament_type); + if (base_name.empty()) + base_name = "Published Filament"; + } + new_name = this->filaments.add_detached_preset(base_name, std::move(new_cfg), entry.filament_id); + published_full_dedup.emplace(dedup_key, new_name); + } + const std::string old_name = this->filament_presets[slot]; + this->filament_presets[slot] = new_name; + material_applied = true; + published_config->material_replacements.emplace_back( + "slot " + std::to_string(slot) + ": " + old_name + " -> " + new_name + + " (published material imported)"); + // Colour is slot-scoped and project-visible: sync into project_config + // (the copy already baked it, this makes the chips render). + if (entry.publish_color && !entry.color.empty()) { + if (ConfigOptionStrings *proj_colour = this->project_config.opt("filament_colour")) { + if (slot < proj_colour->values.size()) + proj_colour->values[slot] = entry.color; + } + if (ConfigOptionStrings *proj_multi_colour = this->project_config.opt("filament_multi_colour")) { + if (slot < proj_multi_colour->values.size()) + proj_multi_colour->values[slot] = entry.color; + } + } else if (proj_colour && new_name != old_name) { + // Fall back to the copy's colour so the chip is never blank: try + // the newly created preset's filament_colour, then the option default. + std::string seed; + if (const Preset *created = this->filaments.find_preset(new_name, false, true)) { + if (const ConfigOptionStrings *cols = created->config.opt("filament_colour")) + if (!cols->values.empty()) + seed = cols->values.front(); + } + if (seed.empty()) { + if (const ConfigOptionDef *colour_def = print_config_def.get("filament_colour")) + if (const auto *defaults = dynamic_cast(colour_def->default_value.get())) + if (!defaults->values.empty()) + seed = defaults->values.front(); + } + if (!seed.empty()) { + if (proj_colour && slot < proj_colour->values.size()) + proj_colour->values[slot] = seed; + if (proj_multi_colour && slot < proj_multi_colour->values.size()) + proj_multi_colour->values[slot] = seed; + } + } + if (proj_colour_type && slot < proj_colour_type->values.size()) + proj_colour_type->values[slot] = "1"; + continue; + } + bool apply_slot = true; // The gate compares against the slot's effective material type: the edited // layer when the slot references the collection's edited preset and that diff --git a/src/libslic3r/PublishSettings.cpp b/src/libslic3r/PublishSettings.cpp index ac25d815f1..3a3d241e81 100644 --- a/src/libslic3r/PublishSettings.cpp +++ b/src/libslic3r/PublishSettings.cpp @@ -6,12 +6,19 @@ #include "MaterialType.hpp" #include +#include #include #include namespace Slic3r { +std::string publish_base_key(const std::string &key) +{ + const size_t pos = key.find('#'); + return pos == std::string::npos ? key : key.substr(0, pos); +} + std::string normalize_filament_type(const std::string& type) { if (type.empty()) @@ -29,6 +36,32 @@ std::string normalize_filament_type(const std::string& type) return type; } +void make_publish_universal(DynamicPrintConfig &config) +{ + // Lists: empty => compatible with every printer / every print preset. Conditions: + // empty so a leftover expression left behind by the baseline clone can never + // re-narrow the match (see is_compatible_with_printer, Preset.cpp:840). All four + // keys exist on filament presets; nil-guard for hand-crafted future schemas. + if (auto *opt = config.opt("compatible_printers", false)) + opt->values.clear(); + if (auto *opt = config.opt("compatible_prints", false)) + opt->values.clear(); + if (auto *opt = config.opt("compatible_printers_condition", false)) + opt->value.clear(); + if (auto *opt = config.opt("compatible_prints_condition", false)) + opt->value.clear(); +} + +std::string publish_material_base_name(const std::string &preset_name) +{ + if (preset_name.empty()) + return preset_name; + const size_t at = preset_name.find('@'); + std::string base = (at == std::string::npos) ? preset_name : preset_name.substr(0, at); + boost::trim_right(base); + return base; +} + const std::set& publish_structural_keys() { // Non-publishable keys: the *_settings_id keys are also in PresetCollection::skipped_in_dirty diff --git a/src/libslic3r/PublishSettings.hpp b/src/libslic3r/PublishSettings.hpp index 929c08dd51..bcb3397dcc 100644 --- a/src/libslic3r/PublishSettings.hpp +++ b/src/libslic3r/PublishSettings.hpp @@ -7,11 +7,7 @@ namespace Slic3r { class PresetBundle; // Strip a trailing "#N" variant suffix ("retraction_length#2" -> "retraction_length"). -inline std::string publish_base_key(const std::string &key) -{ - const size_t pos = key.find('#'); - return pos == std::string::npos ? key : key.substr(0, pos); -} +std::string publish_base_key(const std::string &key); // Structural keys that are never applied onto the receiver's presets when loading a published // 3MF (single source of truth for the denylist): applying them would rewrite the user's preset @@ -56,14 +52,20 @@ struct PublishedMaterialEntry { // 0-based author filament slot; -1 (hand-crafted files) is skipped. int slot{-1}; std::vector keys; - // "Full Publish": serialize the whole filament preset (full_keys); the type gate then - // decides whether the receiver keeps its material (type match) or is replaced. + // "Full Publish": the whole filament preset (full_keys) is published. On the receiver + // Full Publish always creates a standalone parentless copy (libslic3r's "Detach from + // parent"): a new project-embedded preset ("Preset Inside Project") with the full + // resolved config, universally compatible (compatible_printers/condition cleared). + // It lives inside the loaded project only - never written to the user's library, + // no existing preset is ever selected-by-reference or mutated. Identical Full + // entries inside one load share one created instance (within-load dedup). bool full{false}; // All non-structural filament keys of the author's slot preset; values travel in the file // config, masked to the author's slot index. std::vector full_keys; // Vendor-agnostic (MaterialType) filament type the author requires for this slot; on // mismatch the slot is replaced with a same-type filament from the receiver's library. + // For Full entries the baked filament_type on the created copy satisfies the type gate. bool publish_type{false}; std::string publish_type_value; // Required filament colour, applied on load regardless of the type match. @@ -74,9 +76,21 @@ struct PublishedMaterialEntry { // "PLA High Speed" -> "PLA" (strip a space modifier); dash types like "PA-CF" are kept intact. std::string normalize_filament_type(const std::string& type); +class DynamicPrintConfig; +// Clear the compatibility lists/conditions on a filament config so it is compatible +// with every printer and every print profile. A detached published material is +// universally compatible by construction: the baseline clone may carry machine-specific +// restrictions. Empty lists + empty conditions => compatible with everything +// (see is_compatible_with_printer, Preset.cpp:840). +void make_publish_universal(DynamicPrintConfig &config); + +// Naming base for a detached published-material copy: "Generic PLA @System" -> +// "Generic PLA" (truncate at the first '@' variant tail, right-trimmed). Unchanged +// when the name carries no '@'. Empty result means "fall back to identity fields". +std::string publish_material_base_name(const std::string &preset_name); + // Minimal DynamicPrintConfig for a published 3MF export: only the selected published keys, // material keys, identity fields and plate geometry keys. -class DynamicPrintConfig; DynamicPrintConfig filter_published_config( const DynamicPrintConfig &full_config, const std::vector &published_keys,