Published 3MF: import Full Publish materials as standalone detached presets inside the project

This commit is contained in:
Lam Wei Lun
2026-08-25 12:27:45 +08:00
parent 3ed1f64f2e
commit 52aa5a52e9
5 changed files with 259 additions and 8 deletions

View File

@@ -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 "<base>" -> "<base> (Published)" ->
// "<base> (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<ConfigOptionString>("print_settings_id", true)->value = final_name;
else if (m_type == Preset::TYPE_FILAMENT)
preset.config.option<ConfigOptionStrings>("filament_settings_id", true)->values[0] = final_name;
else if (m_type == Preset::TYPE_PRINTER)
preset.config.option<ConfigOptionString>("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();

View File

@@ -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:
// "<base>" -> "<base> (Published)" -> "<base> (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.

View File

@@ -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<std::string, std::string> 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<int>(static_cast<const ConfigOptionVectorBase*>(src_opt)->size()))
continue;
ConfigOption *dst_opt = new_cfg.option(base_key);
if (dst_opt == nullptr || !dst_opt->is_vector() ||
static_cast<const ConfigOptionVectorBase*>(dst_opt)->empty() ||
dst_opt->type() != src_opt->type())
continue;
static_cast<ConfigOptionVectorBase*>(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<ConfigOptionStrings>("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<ConfigOptionStrings>("filament_colour")) {
if (slot < proj_colour->values.size())
proj_colour->values[slot] = entry.color;
}
if (ConfigOptionStrings *proj_multi_colour = this->project_config.opt<ConfigOptionStrings>("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<ConfigOptionStrings>("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<const ConfigOptionStrings*>(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

View File

@@ -6,12 +6,19 @@
#include "MaterialType.hpp"
#include <boost/log/trivial.hpp>
#include <boost/algorithm/string/trim.hpp>
#include <map>
#include <set>
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<ConfigOptionStrings>("compatible_printers", false))
opt->values.clear();
if (auto *opt = config.opt<ConfigOptionStrings>("compatible_prints", false))
opt->values.clear();
if (auto *opt = config.opt<ConfigOptionString>("compatible_printers_condition", false))
opt->value.clear();
if (auto *opt = config.opt<ConfigOptionString>("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<std::string>& publish_structural_keys()
{
// Non-publishable keys: the *_settings_id keys are also in PresetCollection::skipped_in_dirty

View File

@@ -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<std::string> 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<std::string> 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<std::string> &published_keys,