diff --git a/sandboxes/orca_reload_local_bundle_demo.py b/sandboxes/orca_reload_local_bundle_demo.py
new file mode 100644
index 0000000000..5af520049e
--- /dev/null
+++ b/sandboxes/orca_reload_local_bundle_demo.py
@@ -0,0 +1,410 @@
+# /// script
+# requires-python = ">=3.12"
+#
+# [tool.orcaslicer.plugin]
+# id = "reload-local-bundle-demo"
+# name = "Reload Local Bundle Demo"
+# description = "Manages local filament, process, and printer profiles with live bundle reload."
+# author = "OrcaSlicer contributors"
+# version = "0.1.0"
+# ///
+"""A small interactive consumer of ``orca.host.preset.bundles.reload_local``."""
+
+import json
+import os
+import tempfile
+
+import orca
+
+
+BUNDLE_ID = "reload-local-bundle-demo"
+STATE_FILE = ".reload-local-bundle-demo-state.json"
+DEMO_PRESETS = (
+ {
+ "id": "filament",
+ "name": "Local Bundle Demo Filament",
+ "kind": "Filament",
+ "type": "filament",
+ "directory": "filament",
+ "collection": "filaments",
+ "setting_key": "filament_settings_id",
+ },
+ {
+ "id": "process",
+ "name": "Local Bundle Demo Process",
+ "kind": "Process",
+ "type": "process",
+ "directory": "process",
+ "collection": "prints",
+ "setting_key": "print_settings_id",
+ },
+ {
+ "id": "printer",
+ "name": "Local Bundle Demo Printer",
+ "kind": "Printer",
+ "type": "machine",
+ "directory": "machine",
+ "collection": "printers",
+ "setting_key": "printer_settings_id",
+ },
+)
+
+
+def _data_dir():
+ parts = os.path.abspath(__file__).replace("\\", "/").split("/")
+ if "orca_plugins" not in parts:
+ raise RuntimeError("Install this file as a local OrcaSlicer plugin")
+ return "/".join(parts[: parts.index("orca_plugins")])
+
+
+def _folder_from_preset_file(path):
+ parts = os.path.normpath(path or "").replace("\\", "/").split("/")
+ try:
+ return parts[parts.index("user") + 1]
+ except (ValueError, IndexError):
+ return None
+
+
+def _bundle_dir(bundle):
+ for collection in (bundle.filaments, bundle.prints, bundle.printers):
+ for index in range(collection.size()):
+ folder = _folder_from_preset_file(collection.preset(index).file)
+ if folder:
+ return os.path.join(_data_dir(), "user", folder, "_local", BUNDLE_ID)
+ return os.path.join(_data_dir(), "user", "default", "_local", BUNDLE_ID)
+
+
+def _source_path(bundle, item):
+ return os.path.join(_bundle_dir(bundle), item["directory"], item["name"] + ".json")
+
+
+def _state_path(bundle):
+ return os.path.join(_bundle_dir(bundle), STATE_FILE)
+
+
+def _write_json_atomic(path, value):
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ descriptor, temporary = tempfile.mkstemp(
+ prefix="." + os.path.basename(path) + ".", suffix=".tmp", dir=os.path.dirname(path)
+ )
+ try:
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
+ json.dump(value, stream, ensure_ascii=False, indent=2)
+ stream.write("\n")
+ os.replace(temporary, path)
+ except Exception:
+ try:
+ os.unlink(temporary)
+ except OSError:
+ pass
+ raise
+
+
+def _remove_file(path):
+ try:
+ os.unlink(path)
+ except FileNotFoundError:
+ pass
+
+
+def _read_state(bundle):
+ try:
+ with open(_state_path(bundle), "r", encoding="utf-8") as stream:
+ state = json.load(stream)
+ except FileNotFoundError:
+ return {"profiles": {}}
+ except (OSError, ValueError) as error:
+ raise RuntimeError("The demo state file cannot be read: %s" % error)
+ if not isinstance(state, dict) or not isinstance(state.get("profiles"), dict):
+ raise RuntimeError("The demo state file has an invalid shape")
+ return state
+
+
+def _profile_state(state, item):
+ profiles = state["profiles"]
+ profile = profiles.get(item["id"])
+ if profile is None:
+ profile = {}
+ profiles[item["id"]] = profile
+ if not isinstance(profile, dict):
+ raise RuntimeError("The demo state for %s has an invalid shape" % item["name"])
+ profile["enabled"] = profile.get("enabled") is True
+ profile["deleted"] = profile.get("deleted") is True
+ if profile["deleted"]:
+ profile["enabled"] = False
+ profile["parent_name"] = str(profile.get("parent_name") or "")
+ return profile
+
+
+def _collection(bundle, item):
+ return getattr(bundle, item["collection"])
+
+
+def _target_name(item):
+ return "_local/%s/%s" % (BUNDLE_ID, item["name"])
+
+
+def _parent_name(bundle, item):
+ collection = _collection(bundle, item)
+ own_names = {
+ _target_name(candidate)
+ for candidate in DEMO_PRESETS
+ if candidate["collection"] == item["collection"]
+ }
+ candidates = [collection.selected_preset()]
+ candidates.extend(collection.preset(index) for index in range(collection.size()))
+ for preset in candidates:
+ if preset.name not in own_names:
+ return preset.name
+ raise RuntimeError("A parent %s preset is required for this demo" % item["kind"].lower())
+
+
+def _parent_is_live(bundle, item, name):
+ return bool(name) and name not in {_target_name(item)} and _collection(bundle, item).find_preset(name) is not None
+
+
+def _profile(item, parent_name):
+ return {
+ "type": item["type"],
+ "name": item["name"],
+ "from": "User",
+ "instantiation": "true",
+ "version": "1.0.0",
+ "inherits": parent_name,
+ item["setting_key"]: item["name"],
+ }
+
+
+def _reconcile(bundle, state):
+ for item in DEMO_PRESETS:
+ profile = _profile_state(state, item)
+ if profile["enabled"] and not profile["deleted"] and not _parent_is_live(bundle, item, profile["parent_name"]):
+ profile["parent_name"] = _parent_name(bundle, item)
+
+ for item in DEMO_PRESETS:
+ profile = _profile_state(state, item)
+ path = _source_path(bundle, item)
+ if profile["enabled"] and not profile["deleted"]:
+ _write_json_atomic(path, _profile(item, profile["parent_name"]))
+ else:
+ _remove_file(path)
+ _write_json_atomic(
+ os.path.join(_bundle_dir(bundle), "bundle_metadata.json"),
+ {"id": BUNDLE_ID, "name": "Reload Local Bundle Demo", "version": "0.1.0"},
+ )
+ _write_json_atomic(_state_path(bundle), state)
+ orca.host.preset.bundles.reload_local(BUNDLE_ID)
+
+
+def _state_payload(bundle, state, message="", error=""):
+ items = []
+ for item in DEMO_PRESETS:
+ profile = _profile_state(state, item)
+ items.append(
+ {
+ "id": item["id"],
+ "name": item["name"],
+ "kind": item["kind"],
+ "enabled": profile["enabled"],
+ "deleted": profile["deleted"],
+ "loaded": _collection(bundle, item).find_preset(_target_name(item)) is not None,
+ }
+ )
+ return {
+ "command": "state",
+ "items": items,
+ "bundles": orca.host.preset.bundles.list_ids(),
+ "message": message,
+ "error": error,
+ }
+
+
+PAGE = r"""
+
+How it works Each row controls one profile in this local bundle. A switch adds or removes its source file; Delete removes the demo record and Restore returns it disabled. Every action reloads this bundle across filament, process, and machine. Local bundles lists every bundle id that orca.host.preset.bundles.list_ids() reports; Reload calls orca.host.preset.bundles.reload_local() for that id.
+
+Local bundles
+
+Close
+
+"""
+
+
+class ReloadLocalBundleDemo(orca.script.ScriptPluginCapabilityBase):
+ window = None
+
+ def get_name(self):
+ return "Reload local bundle demo"
+
+ def execute(self):
+ try:
+ if self.window is not None and self.window.is_open():
+ self._post_state()
+ return orca.ExecutionResult.success("Reload local bundle demo refreshed.")
+ self.window = orca.host.ui.create_window(
+ title="Reload local bundle demo", html=PAGE, width=500, height=420,
+ on_message=self.on_message, on_close=self.on_close,
+ )
+ return orca.ExecutionResult.success("Reload local bundle demo opened.")
+ except Exception as error:
+ message = "Reload local bundle demo failed: %s" % error
+ orca.host.ui.message(message, title="Reload local bundle demo", buttons="ok", icon="error")
+ return orca.ExecutionResult.failure(orca.PluginResult.RecoverableError, message)
+
+ def on_close(self):
+ self.window = None
+
+ def _post_state(self, message="", error=""):
+ try:
+ bundle = orca.host.preset_bundle()
+ payload = _state_payload(bundle, _read_state(bundle), message, error)
+ except Exception as state_error:
+ payload = {"command": "state", "items": [], "message": message, "error": str(state_error)}
+ if self.window is not None and self.window.is_open():
+ self.window.post(payload)
+
+ def on_message(self, message):
+ try:
+ message = message or {}
+ bundle = orca.host.preset_bundle()
+ state = _read_state(bundle)
+ if message.get("command") == "state":
+ self._post_state()
+ return
+ if message.get("command") == "reload_bundle":
+ bundle_id = message.get("bundle_id")
+ if not isinstance(bundle_id, str) or not bundle_id:
+ raise RuntimeError("A bundle id is required")
+ orca.host.preset.bundles.reload_local(bundle_id)
+ self._post_state(message="Reloaded local bundle %s." % bundle_id)
+ return
+ item = next((item for item in DEMO_PRESETS if item["id"] == message.get("id")), None)
+ if item is None:
+ raise RuntimeError("Unknown demo preset")
+ profile = _profile_state(state, item)
+ command = message.get("command")
+ if command == "set_enabled":
+ enabled = message.get("enabled")
+ if not isinstance(enabled, bool):
+ raise RuntimeError("Preset availability must be a boolean")
+ if profile["deleted"]:
+ raise RuntimeError("Restore the deleted demo preset before enabling it")
+ profile["enabled"] = enabled
+ elif command == "delete":
+ profile.update({"enabled": False, "deleted": True, "parent_name": ""})
+ elif command == "restore":
+ profile.update({"enabled": False, "deleted": False, "parent_name": ""})
+ else:
+ raise RuntimeError("Unknown demo action")
+ _reconcile(bundle, state)
+ self._post_state(message="The change was applied. Check the corresponding preset selector in Orca.")
+ except Exception as error:
+ self._post_state(error=str(error))
+
+
+@orca.plugin
+class ReloadLocalBundleDemoPackage(orca.base):
+ def register_capabilities(self):
+ orca.register_capability(ReloadLocalBundleDemo)
diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp
index 4108ee210d..f557348998 100644
--- a/src/libslic3r/Preset.cpp
+++ b/src/libslic3r/Preset.cpp
@@ -1822,7 +1822,7 @@ PresetCollection::UserPresetLoad PresetCollection::resolve_user_preset(
const boost::filesystem::path &file, const std::string &canonical_name,
const PresetOrigin &load_origin, ForwardCompatibilitySubstitutionRule substitution_rule,
const std::string &extruder_id_name, const std::string &extruder_variant_name,
- std::set *key_set1, std::set *key_set2) const
+ std::set *key_set1, std::set *key_set2, bool read_only) const
{
UserPresetLoad out;
out.preset = Preset(m_type, canonical_name, false);
@@ -1856,7 +1856,10 @@ PresetCollection::UserPresetLoad PresetCollection::resolve_user_preset(
std::string version_str = key_values[BBL_JSON_KEY_VERSION];
boost::optional version = Semver::parse(version_str);
- if (!version) return out;
+ if (!version) {
+ out.errors.push_back((boost::format("invalid version %1% in config %2%") % version_str % preset.file).str());
+ return out;
+ }
preset.version = *version;
if (key_values.find(BBL_JSON_KEY_FILAMENT_ID) != key_values.end())
@@ -1878,6 +1881,12 @@ PresetCollection::UserPresetLoad PresetCollection::resolve_user_preset(
inherit_preset = this->find_preset2(inherits_value);
Preset::normalize_inherits(config, inherit_preset);
}
+ // Local hot reload only supports FFF; no SLA default printer is installed.
+ if (read_only && load_origin.kind == PresetOrigin::Kind::LocalBundle && m_type == Preset::TYPE_PRINTER &&
+ Preset::printer_technology(config) != ptFFF) {
+ out.errors.push_back((boost::format("local bundle printer %1% is not FFF") % preset.file).str());
+ return out;
+ }
const Preset& default_preset = this->default_preset_for(config);
if (inherit_preset) {
preset.config = inherit_preset->config;
@@ -2042,7 +2051,7 @@ void PresetCollection::load_presets(
resolve_then_commit(files.size(),
[&](size_t i) {
return this->resolve_user_preset(files[i].path, files[i].canonical_name, resolved_origin, substitution_rule,
- extruder_id_name, extruder_variant_name, key_set1, key_set2);
+ extruder_id_name, extruder_variant_name, key_set1, key_set2, read_only);
},
[&](size_t i, UserPresetLoad &&loaded) {
// Resolve read the files without the lock, so another instance may have saved
@@ -2062,7 +2071,7 @@ void PresetCollection::load_presets(
if (! (on_disk == loaded.on_disk)) {
CNumericLocalesSetter locales_setter;
loaded = this->resolve_user_preset(files[i].path, files[i].canonical_name, resolved_origin, substitution_rule,
- extruder_id_name, extruder_variant_name, key_set1, key_set2);
+ extruder_id_name, extruder_variant_name, key_set1, key_set2, read_only);
}
}
const bool leave_files = read_only || (! lock_path.empty() && ! instance_lock.locked());
diff --git a/src/libslic3r/Preset.hpp b/src/libslic3r/Preset.hpp
index 98e7e62e90..b6dc3b37bb 100644
--- a/src/libslic3r/Preset.hpp
+++ b/src/libslic3r/Preset.hpp
@@ -991,7 +991,8 @@ private:
UserPresetLoad resolve_user_preset(const boost::filesystem::path &file, const std::string &canonical_name,
const PresetOrigin &load_origin, ForwardCompatibilitySubstitutionRule substitution_rule,
const std::string &extruder_id_name, const std::string &extruder_variant_name,
- std::set *key_set1, std::set *key_set2) const;
+ std::set *key_set1, std::set *key_set2,
+ bool read_only) const;
// Install one resolved preset. The collection, its alias maps, the error count
// and the preset files on disk are touched here and only here.
diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp
index 711501aecf..7d7855e624 100644
--- a/src/libslic3r/PresetBundle.cpp
+++ b/src/libslic3r/PresetBundle.cpp
@@ -1,3 +1,4 @@
+#include
#include
#include
#include
@@ -56,8 +57,10 @@
#include
#include
+#include
#include
#include
+#include
#include
#include
#include
@@ -1383,6 +1386,27 @@ std::string PresetBundle::get_hotend_model_for_printer_model(std::string model_n
return out;
}
+static void load_bundle_presets(PresetBundle& bundle, const std::string& bundle_dir, BundleMetadata& metadata,
+ const PresetOrigin& origin, PresetsConfigSubstitutions& substitutions,
+ ForwardCompatibilitySubstitutionRule substitution_rule, bool read_only = false)
+{
+ struct Collection {
+ PresetCollection& presets;
+ const char* directory;
+ std::vector& names;
+ };
+
+ metadata.print_presets.clear();
+ metadata.filament_presets.clear();
+ metadata.printer_presets.clear();
+ for (Collection collection : {
+ Collection{ bundle.prints, PRESET_PRINT_NAME, metadata.print_presets },
+ Collection{ bundle.filaments, PRESET_FILAMENT_NAME, metadata.filament_presets },
+ Collection{ bundle.printers, PRESET_PRINTER_NAME, metadata.printer_presets } })
+ collection.presets.load_presets(bundle_dir, collection.directory, substitutions, substitution_rule,
+ [&](Preset& preset) { collection.names.push_back(preset.name); }, origin, read_only);
+}
+
PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, ForwardCompatibilitySubstitutionRule substitution_rule, bool read_only)
{
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " entry and user is: " << user;
@@ -1431,19 +1455,8 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For
BundleMetadata metadata;
if (!load_bundle_metadata(metadata_file, metadata)) continue;
- metadata.print_presets.clear();
- metadata.filament_presets.clear();
- metadata.printer_presets.clear();
-
- this->prints.load_presets(bundle_dir, PRESET_PRINT_NAME, substitutions, substitution_rule, [&](Preset& preset) {
- metadata.print_presets.push_back(preset.name);
- }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), read_only);
- this->filaments.load_presets(bundle_dir, PRESET_FILAMENT_NAME, substitutions, substitution_rule, [&](Preset& preset) {
- metadata.filament_presets.push_back(preset.name);
- }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), read_only);
- this->printers.load_presets(bundle_dir, PRESET_PRINTER_NAME, substitutions, substitution_rule, [&](Preset& preset) {
- metadata.printer_presets.push_back(preset.name);
- }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), read_only);
+ load_bundle_presets(*this, bundle_dir, metadata, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id),
+ substitutions, substitution_rule, read_only);
metadata.bundle_type = BundleType::Local;
metadata.path = metadata_file.string();
@@ -1466,20 +1479,10 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For
BundleMetadata metadata;
if (!load_bundle_metadata(metadata_file, metadata)) continue;
- metadata.print_presets.clear();
- metadata.filament_presets.clear();
- metadata.printer_presets.clear();
metadata.is_subscribed = true;
- this->prints.load_presets(bundle_dir, PRESET_PRINT_NAME, substitutions, substitution_rule, [&](Preset& preset) {
- metadata.print_presets.push_back(preset.name);
- }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), read_only);
- this->filaments.load_presets(bundle_dir, PRESET_FILAMENT_NAME, substitutions, substitution_rule, [&](Preset& preset) {
- metadata.filament_presets.push_back(preset.name);
- }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), read_only);
- this->printers.load_presets(bundle_dir, PRESET_PRINTER_NAME, substitutions, substitution_rule, [&](Preset& preset) {
- metadata.printer_presets.push_back(preset.name);
- }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), read_only);
+ load_bundle_presets(*this, bundle_dir, metadata, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id),
+ substitutions, substitution_rule, read_only);
metadata.bundle_type = BundleType::Subscribed;
metadata.path = metadata_file.string();
@@ -1534,6 +1537,335 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For
return PresetsConfigSubstitutions();
}
+static bool is_single_path_component(const std::string& value)
+{
+ const fs::path path(value);
+ return !value.empty() && value != "." && value != ".." && value.find_first_of("/\\") == std::string::npos &&
+ !path.has_root_name() && !path.has_root_directory();
+}
+
+static bool check_local_bundle(PresetBundleMetadata& bundles, const fs::path& user_dir, const std::string& bundle_id,
+ BundleMetadata& metadata, std::string& error)
+{
+ if (!is_single_path_component(bundle_id)) {
+ error = "Local bundle id must be a single path component";
+ return false;
+ }
+ const fs::path bundle_dir = user_dir / PRESET_LOCAL_DIR / bundle_id;
+ if (!fs::is_directory(bundle_dir)) {
+ error = "Local bundle directory does not exist";
+ return false;
+ }
+ if (fs::exists(user_dir / PRESET_SUBSCRIBED_DIR / bundle_id)) {
+ error = "A subscribed bundle already uses this id";
+ return false;
+ }
+ {
+ std::shared_lock lock(bundles.RWMtx);
+ const auto it = bundles.m_bundles.find(bundle_id);
+ if (it != bundles.m_bundles.end() && (it->second.bundle_type == BundleType::Subscribed || it->second.is_subscribed)) {
+ error = "A subscribed bundle already uses this id";
+ return false;
+ }
+ }
+ if (!metadata.load_from_json((bundle_dir / PRESET_BUNDLE_METADATA).string()) || metadata.id != bundle_id) {
+ error = "Local bundle metadata is missing, malformed, or has a different id";
+ return false;
+ }
+ return true;
+}
+
+std::vector PresetBundle::list_local_bundle_ids(const std::string& preset_folder)
+{
+ std::vector ids;
+ const std::string user = preset_folder.empty() ? DEFAULT_USER_FOLDER_NAME : preset_folder;
+ if (!is_single_path_component(user))
+ return ids;
+
+ const fs::path user_dir = fs::path(data_dir()) / PRESET_USER_DIR / user;
+ const fs::path local_dir = user_dir / PRESET_LOCAL_DIR;
+ try {
+ if (!fs::is_directory(local_dir))
+ return ids;
+ for (const auto& entry : fs::directory_iterator(local_dir)) {
+ const std::string bundle_id = entry.path().filename().string();
+ try {
+ BundleMetadata metadata;
+ std::string error;
+ if (check_local_bundle(bundles, user_dir, bundle_id, metadata, error))
+ ids.push_back(bundle_id);
+ } catch (const std::exception& exception) {
+ BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": skipping " << bundle_id << ": " << exception.what();
+ }
+ }
+ } catch (const std::exception& exception) {
+ BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": " << exception.what();
+ }
+ std::sort(ids.begin(), ids.end());
+ return ids;
+}
+
+bool PresetBundle::reload_local_bundle(const std::string& preset_folder, const std::string& bundle_id, std::string* error)
+{
+ auto fail = [error](const std::string& message) {
+ if (error != nullptr)
+ *error = message;
+ return false;
+ };
+ if (!is_single_path_component(bundle_id))
+ return fail("Local bundle id must be a single path component");
+
+ const std::string user = preset_folder.empty() ? DEFAULT_USER_FOLDER_NAME : preset_folder;
+ if (!is_single_path_component(user))
+ return fail("Preset folder must be a single path component");
+
+ try {
+ const fs::path user_dir = fs::path(data_dir()) / PRESET_USER_DIR / user;
+ const fs::path bundle_dir = user_dir / PRESET_LOCAL_DIR / bundle_id;
+ const fs::path metadata_file = bundle_dir / PRESET_BUNDLE_METADATA;
+
+ BundleMetadata metadata;
+ std::string check_error;
+ if (!check_local_bundle(bundles, user_dir, bundle_id, metadata, check_error))
+ return fail(check_error);
+
+ // Keep background preset sync from changing the collections between copying and committing them.
+ std::scoped_lock collection_lock(prints.m_mutex, filaments.m_mutex, printers.m_mutex);
+ const std::string target_prefix = std::string(PRESET_LOCAL_DIR) + "/" + bundle_id + "/";
+ const auto is_target = [&](const Preset& preset) {
+ return preset.bundle_id == bundle_id && boost::starts_with(preset.name, target_prefix);
+ };
+ struct Selection {
+ std::string name;
+ Preset base{Preset::TYPE_INVALID, std::string()};
+ Preset edited{Preset::TYPE_INVALID, std::string()};
+ std::vector dirty_options;
+ };
+ auto snapshot = [&](const PresetCollection& collection, Selection& selection) {
+ selection.name = collection.get_selected_preset_name();
+ if (selection.name.empty())
+ return false;
+ for (const Preset& preset : collection.get_presets()) {
+ if (preset.name == selection.name) {
+ selection.base = preset;
+ selection.edited = collection.get_edited_preset();
+ selection.dirty_options = PresetCollection::dirty_options(&selection.edited, &selection.base);
+ return true;
+ }
+ }
+ return false;
+ };
+
+ Selection print_selection, filament_selection, printer_selection;
+ if (!snapshot(prints, print_selection) || !snapshot(filaments, filament_selection) || !snapshot(printers, printer_selection))
+ return fail("Preset selections are not initialized");
+
+ PresetBundle staged;
+ // Collection copies keep vendor pointers into the live vendor map, which is not replaced by this reload.
+ staged.prints = prints;
+ staged.filaments = filaments;
+ staged.printers = printers;
+ staged.filament_presets = filament_presets;
+ staged.project_config.apply_only(project_config, {"flush_volumes_matrix", "flush_volumes_vector", "flush_multiplier"});
+ auto remove_target = [&](PresetCollection& collection) {
+ auto begin = collection.m_presets.begin() + collection.m_num_default_presets;
+ collection.m_presets.erase(std::remove_if(begin, collection.m_presets.end(), is_target), collection.m_presets.end());
+ collection.sort_presets();
+ };
+ remove_target(staged.prints);
+ remove_target(staged.filaments);
+ remove_target(staged.printers);
+
+ const std::array errors_before = { staged.prints.error_count(), staged.filaments.error_count(), staged.printers.error_count() };
+ PresetsConfigSubstitutions substitutions;
+ load_bundle_presets(staged, bundle_dir.string(), metadata, PresetOrigin(PresetOrigin::Kind::LocalBundle, bundle_id),
+ substitutions, ForwardCompatibilitySubstitutionRule::Enable, true);
+ if (staged.prints.error_count() != errors_before[0] || staged.filaments.error_count() != errors_before[1] || staged.printers.error_count() != errors_before[2])
+ return fail("Local bundle contains an invalid preset");
+
+ auto dirty_target_was_removed = [&](const Selection& selection, const PresetCollection& collection) {
+ if (selection.dirty_options.empty() || !is_target(selection.base))
+ return false;
+ for (const Preset& preset : collection.get_presets())
+ if (preset.name == selection.name && is_target(preset))
+ return false;
+ return true;
+ };
+ if (dirty_target_was_removed(print_selection, staged.prints) ||
+ dirty_target_was_removed(filament_selection, staged.filaments) ||
+ dirty_target_was_removed(printer_selection, staged.printers))
+ return fail("A modified selected preset was removed or renamed");
+
+ auto external_child_name = [&](const PresetCollection& collection) {
+ auto depends_on_target = [&](const Preset& preset) {
+ if (is_target(preset))
+ return false;
+ const Preset* parent = &preset;
+ std::unordered_set visited;
+ while ((parent = collection.get_preset_parent(*parent)) != nullptr && visited.insert(parent).second)
+ if (is_target(*parent))
+ return true;
+ for (const char* key : {"compatible_prints", "compatible_printers"}) {
+ const auto* names = preset.config.option(key);
+ if (names == nullptr)
+ continue;
+ const PresetCollection& referenced = std::string(key) == "compatible_printers" ? printers : prints;
+ for (const std::string& name : names->values) {
+ const Preset* dependency = referenced.find_preset(name, false);
+ if (dependency != nullptr && is_target(*dependency))
+ return true;
+ }
+ }
+ return false;
+ };
+ for (const Preset& preset : collection.get_presets())
+ if (depends_on_target(preset))
+ return preset.name;
+ if (depends_on_target(collection.get_edited_preset()))
+ return collection.get_edited_preset().name;
+ return std::string();
+ };
+ for (const PresetCollection* collection : std::array{&prints, &filaments, &printers}) {
+ const std::string child_name = external_child_name(*collection);
+ if (!child_name.empty())
+ return fail("Preset outside this bundle depends on it: " + child_name);
+ }
+
+ auto restore_selection = [](PresetCollection& collection, const Selection& selection) {
+ if (collection.find_preset(selection.name, false, true) == nullptr) {
+ collection.select_preset(collection.first_visible_idx());
+ return false;
+ }
+ collection.select_preset_by_name(selection.name, true);
+ if (collection.get_selected_preset_name() != selection.name)
+ return false;
+ Preset& edited = collection.get_edited_preset();
+ for (const std::string& key : selection.dirty_options) {
+ if (const ConfigOption* option = selection.edited.config.option(key))
+ edited.config.set_key_value(key, option->clone());
+ else
+ edited.config.erase(key);
+ }
+ collection.update_dirty();
+ return true;
+ };
+
+ staged.prints.update_map_alias_to_profile_name();
+ staged.filaments.update_map_alias_to_profile_name();
+ staged.printers.update_map_alias_to_profile_name();
+ const bool printer_preserved = restore_selection(staged.printers, printer_selection);
+ const bool print_preserved = restore_selection(staged.prints, print_selection);
+ const bool filament_preserved = restore_selection(staged.filaments, filament_selection);
+ const auto selection_mode = [printer_preserved](bool selection_preserved) {
+ if (!selection_preserved)
+ return PresetSelectCompatibleType::Always;
+ return printer_preserved ? PresetSelectCompatibleType::Never : PresetSelectCompatibleType::OnlyIfWasCompatible;
+ };
+ if (staged.printers.get_edited_preset().printer_technology() != ptFFF)
+ return fail("Local bundle reload requires an FFF printer selection");
+ staged.update_compatible(selection_mode(print_preserved), selection_mode(filament_preserved));
+ for (std::string& name : staged.filament_presets)
+ if (boost::starts_with(name, target_prefix) && staged.filaments.find_preset(name, false) == nullptr)
+ name = staged.filaments.first_compatible().name;
+ staged.update_multi_material_filament_presets();
+
+ struct Collection
+ {
+ PresetCollection& live;
+ PresetCollection& candidate;
+ size_t original_size;
+ };
+ std::array collections{{{prints, staged.prints, prints.size()},
+ {filaments, staged.filaments, filaments.size()},
+ {printers, staged.printers, printers.size()}}};
+ for (size_t i = 0; i < collections.size(); ++i) {
+ const auto& collection = collections[i];
+ const auto dirty_options = collection.live.current_dirty_options();
+ if (dirty_options.empty())
+ continue;
+ if (collection.live.get_selected_preset_name() != collection.candidate.get_selected_preset_name())
+ return fail("Reload would discard a modified selected preset");
+ for (const std::string& key : dirty_options) {
+ const ConfigOption* before = collection.live.get_edited_preset().config.option(key);
+ const ConfigOption* after = collection.candidate.get_edited_preset().config.option(key);
+ if ((before == nullptr) != (after == nullptr) || (before != nullptr && *before != *after))
+ return fail("Reload would discard a modified preset option: " + key);
+ }
+ }
+
+ if (calibrate_printer != nullptr && staged.printers.find_preset(calibrate_printer->name, false, true) != nullptr)
+ staged.set_calibrate_printer(calibrate_printer->name);
+ std::array, 3> flush_options;
+ size_t i = 0;
+ for (const char* key : {"flush_volumes_matrix", "flush_volumes_vector", "flush_multiplier"})
+ flush_options[i++] = {project_config.option(key), staged.project_config.option(key)};
+
+ metadata.bundle_type = BundleType::Local;
+ metadata.is_subscribed = false;
+ metadata.update_available = false;
+ metadata.path = metadata_file.string();
+ fs::path local_dir = user_dir / PRESET_LOCAL_DIR;
+ std::unique_lock lock(bundles.RWMtx);
+ const auto existing = bundles.m_bundles.find(bundle_id);
+ if (existing != bundles.m_bundles.end() &&
+ (existing->second.bundle_type == BundleType::Subscribed || existing->second.is_subscribed))
+ return fail("A subscribed bundle already uses this id");
+
+ std::set calibration_filaments;
+ const Preset* calibration_printer = nullptr;
+ try {
+ // Growing a deque preserves existing objects. Roll back appended copies if preparation fails.
+ for (size_t i = 0; i < collections.size(); ++i) {
+ auto& collection = collections[i];
+ while (collection.live.size() < collection.candidate.size())
+ collection.live.m_presets.emplace_back(collection.candidate.m_presets[collection.live.size()]);
+ }
+ auto calibration_preset = [](const Preset* preset, PresetCollection& live, PresetCollection& candidate) -> const Preset* {
+ if (preset == nullptr)
+ return nullptr;
+ if (preset == &candidate.get_edited_preset())
+ return &live.get_edited_preset();
+ const auto it = candidate.find_preset_internal(preset->name);
+ return &live.m_presets[it - candidate.m_presets.begin()];
+ };
+ calibration_printer = calibration_preset(staged.calibrate_printer, printers, staged.printers);
+ for (const Preset* preset : staged.calibrate_filaments)
+ calibration_filaments.insert(calibration_preset(preset, filaments, staged.filaments));
+ // Insertion is the last allocating operation. Replacing metadata only moves its members.
+ bundles.m_bundles.insert_or_assign(bundle_id, std::move(metadata));
+ } catch (...) {
+ for (size_t i = 0; i < collections.size(); ++i)
+ while (collections[i].live.size() > collections[i].original_size)
+ collections[i].live.m_presets.pop_back();
+ throw;
+ }
+ auto install_target = [](PresetCollection& live, PresetCollection& candidate) noexcept {
+ // Keep stored Preset references alive when the collection's layout is unchanged.
+ // Adding/removing presets retains the collection's usual sorted-position semantics.
+ for (size_t i = 0; i < candidate.size(); ++i)
+ live.m_presets[i] = std::move(candidate.m_presets[i]);
+ while (live.size() > candidate.size())
+ live.m_presets.pop_back();
+ live.m_map_alias_to_profile_name.swap(candidate.m_map_alias_to_profile_name);
+ // Move assignment does not allocate (including Semver); swapping Presets would construct temporary containers.
+ live.m_edited_preset = std::move(candidate.m_edited_preset);
+ live.m_saved_preset = std::move(candidate.m_saved_preset);
+ live.m_idx_selected = candidate.m_idx_selected;
+ };
+ for (size_t i = 0; i < collections.size(); ++i)
+ install_target(collections[i].live, collections[i].candidate);
+ filament_presets.swap(staged.filament_presets);
+ for (const auto& options : flush_options)
+ options.first->values.swap(options.second->values);
+ calibrate_printer = calibration_printer;
+ calibrate_filaments.swap(calibration_filaments);
+ dir_user_presets_local.swap(local_dir);
+ return true;
+ } catch (const std::exception& exception) {
+ return fail(std::string("Failed to reload local bundle: ") + exception.what());
+ }
+}
+
PresetsConfigSubstitutions PresetBundle::load_user_presets(AppConfig & config,
std::map> &my_presets,
ForwardCompatibilitySubstitutionRule substitution_rule)
diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp
index 9d41746aee..30e2c66d67 100644
--- a/src/libslic3r/PresetBundle.hpp
+++ b/src/libslic3r/PresetBundle.hpp
@@ -302,6 +302,9 @@ public:
// BBS Load user presets
PresetsConfigSubstitutions load_user_presets(std::string user, ForwardCompatibilitySubstitutionRule rule, bool read_only = false);
PresetsConfigSubstitutions load_user_presets(AppConfig &config, std::map>& my_presets, ForwardCompatibilitySubstitutionRule rule);
+
+ bool reload_local_bundle(const std::string& preset_folder, const std::string& bundle_id, std::string* error = nullptr);
+ std::vector list_local_bundle_ids(const std::string& preset_folder);
// Orca: Import subscribed bundle presets (load and save to disk in one operation), handles one bundle at a time
PresetsConfigSubstitutions update_subscribed_presets(AppConfig& config,
const std::map>& bundle_presets,
diff --git a/src/slic3r/plugin/host/PluginHostPresets.cpp b/src/slic3r/plugin/host/PluginHostPresets.cpp
index 6e10e3f282..4f032e0808 100644
--- a/src/slic3r/plugin/host/PluginHostPresets.cpp
+++ b/src/slic3r/plugin/host/PluginHostPresets.cpp
@@ -1,16 +1,27 @@
#include "PluginHostBindings.hpp"
+#include "PluginHostUi.hpp"
+#include "slic3r/plugin/PluginBindingUtils.hpp"
+
#include
#include
#include
-#include "slic3r/plugin/PluginBindingUtils.hpp"
#include
+#include
#include
#include
#include
+
+#include
+#include
+#include
+#include
+#include
+
#include
+#include
#include
#include
@@ -37,10 +48,66 @@ PresetCollection& printer_presets(PresetBundle& bundle)
return static_cast(bundle.printers);
}
+void reload_local_bundle(const std::string& bundle_id)
+{
+ bool reloaded = false;
+ std::string error;
+ PluginHostUi::run_on_ui_thread([&] {
+ GUI::GUI_App& app = GUI::wxGetApp();
+ if (app.is_closing())
+ throw std::runtime_error("OrcaSlicer is closing");
+ if (app.preset_bundle == nullptr)
+ throw std::runtime_error("Preset bundle is not available");
+ if (app.app_config == nullptr)
+ throw std::runtime_error("Application configuration is not available");
+ if (app.mainframe == nullptr)
+ throw std::runtime_error("OrcaSlicer main window is not available");
+
+ reloaded = app.preset_bundle->reload_local_bundle(app.app_config->get("preset_folder"), bundle_id, &error);
+ if (!reloaded)
+ return;
+
+ app.mainframe->update_side_preset_ui();
+ for (GUI::Tab* tab : app.tabs_list) {
+ tab->reload_config();
+ tab->update_changed_ui();
+ }
+ if (GUI::Plater* plater = app.plater())
+ plater->sidebar().update_all_preset_comboboxes();
+ });
+ if (!reloaded)
+ throw std::runtime_error(error.empty() ? "Local bundle was not reloaded" : error);
+}
+
+std::vector list_bundle_ids()
+{
+ std::vector ids;
+ PluginHostUi::run_on_ui_thread([&] {
+ GUI::GUI_App& app = GUI::wxGetApp();
+ if (app.is_closing())
+ throw std::runtime_error("OrcaSlicer is closing");
+ if (app.preset_bundle == nullptr)
+ throw std::runtime_error("Preset bundle is not available");
+ if (app.app_config == nullptr)
+ throw std::runtime_error("Application configuration is not available");
+
+ ids = app.preset_bundle->list_local_bundle_ids(app.app_config->get("preset_folder"));
+ });
+ return ids;
+}
+
} // namespace
void host_bindings::register_presets(py::module_& host)
{
+ auto preset = host.def_submodule("preset", "Preset operations");
+ auto bundles = preset.def_submodule("bundles", "Local preset bundle operations");
+ bundles.def("reload_local", &reload_local_bundle, py::arg("bundle_id"),
+ "Reload one local preset bundle from the active user's _local directory.");
+ bundles.def("list_ids", &list_bundle_ids,
+ "IDs of local preset bundles in the active user's _local directory that reload_local() accepts, "
+ "including bundles added after startup.");
+
py::enum_(host, "PresetType")
.value("Invalid", Preset::TYPE_INVALID)
.value("Print", Preset::TYPE_PRINT)
diff --git a/src/slic3r/plugin/host/PluginHostUi.cpp b/src/slic3r/plugin/host/PluginHostUi.cpp
index 15fe24b2c5..4a87d5b26d 100644
--- a/src/slic3r/plugin/host/PluginHostUi.cpp
+++ b/src/slic3r/plugin/host/PluginHostUi.cpp
@@ -646,6 +646,11 @@ void plater_notification(GUI::NotificationManager::NotificationLevel notificatio
} // namespace
+void PluginHostUi::run_on_ui_thread(std::function fn)
+{
+ run_on_ui_blocking(std::move(fn));
+}
+
void PluginHostUi::RegisterBindings(pybind11::module_& host)
{
auto ui = host.def_submodule(
diff --git a/src/slic3r/plugin/host/PluginHostUi.hpp b/src/slic3r/plugin/host/PluginHostUi.hpp
index a8a5e64606..cb47416b71 100644
--- a/src/slic3r/plugin/host/PluginHostUi.hpp
+++ b/src/slic3r/plugin/host/PluginHostUi.hpp
@@ -2,6 +2,7 @@
#include
+#include
#include
namespace Slic3r {
@@ -19,6 +20,8 @@ class PluginHostUi
public:
static void RegisterBindings(pybind11::module_& host);
+ static void run_on_ui_thread(std::function fn);
+
// Lifecycle hook: close and tear down every UI window owned by a plugin. PluginManager invokes
// this after plugin teardown and also for bulk unload during application shutdown.
static void close_windows_for_plugin(const std::string& plugin_key);
diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp
index a8ff462d37..47f5c80cca 100644
--- a/tests/libslic3r/test_preset_bundle_loading.cpp
+++ b/tests/libslic3r/test_preset_bundle_loading.cpp
@@ -10,10 +10,17 @@
#include
#include
#include
+#include
+#include
+#include
+#include
+#include
+#include
#include
#include
#include
+#include
#include
#include
#include "libslic3r/PresetBundle.hpp"
@@ -148,6 +155,124 @@ struct RenameTestCollection : public PresetCollection
using PresetCollection::update_map_system_profile_renamed;
};
+struct ScopedPresetDataDir
+{
+ ScopedTemporaryDir tmp{"orca-local-bundle-reload"};
+ std::string previous{data_dir()};
+
+ ScopedPresetDataDir() { set_data_dir(tmp.path().string()); }
+ ~ScopedPresetDataDir() { set_data_dir(previous); }
+
+ fs::path bundle_dir(const std::string& id) const
+ {
+ return tmp.path() / PRESET_USER_DIR / DEFAULT_USER_FOLDER_NAME / PRESET_LOCAL_DIR / id;
+ }
+};
+
+void write_bundle_metadata(const fs::path& bundle_dir, const std::string& id)
+{
+ fs::create_directories(bundle_dir);
+ std::ofstream((bundle_dir / PRESET_BUNDLE_METADATA).string()) << "{\"id\":\"" << id << "\"}";
+}
+
+void write_print_preset_with_layer_height(const DynamicPrintConfig& default_config, const fs::path& file,
+ const std::string& name, double layer_height)
+{
+ DynamicPrintConfig config(default_config);
+ config.option("print_settings_id", true)->value = name;
+ config.option(BBL_JSON_KEY_INHERITS, true)->value.clear();
+ config.option("layer_height", true)->value = layer_height;
+ fs::create_directories(file.parent_path());
+ config.save_to_json(file.string(), name, "User", "1.0.0");
+}
+
+double layer_height(const Preset& preset)
+{
+ return preset.config.option("layer_height")->value;
+}
+
+// The first copy snapshots the dirty edit; cloning that snapshot replays the edit after source loading.
+// This exercises late failures and concurrent metadata updates without a production test hook.
+struct ReloadEdit : ConfigOptionString
+{
+ std::function on_restore;
+ bool copied = false;
+
+ explicit ReloadEdit(std::function on_restore) : ConfigOptionString("dirty start gcode"), on_restore(std::move(on_restore)) {}
+
+ ConfigOption* clone() const override
+ {
+ if (copied)
+ on_restore();
+ auto* copy = new ReloadEdit(*this);
+ copy->copied = true;
+ return copy;
+ }
+};
+
+void check_reload_state(const PresetBundle& bundle, const PresetBundle& before)
+{
+ const PresetCollection* actual[] = {&bundle.prints, &bundle.filaments, &bundle.printers};
+ const PresetCollection* expected[] = {&before.prints, &before.filaments, &before.printers};
+ for (size_t i = 0; i < 3; ++i) {
+ CAPTURE(i);
+ CHECK(actual[i]->get_selected_preset_name() == expected[i]->get_selected_preset_name());
+ CHECK(actual[i]->get_edited_preset().config == expected[i]->get_edited_preset().config);
+ CHECK(actual[i]->get_edited_preset().is_dirty == expected[i]->get_edited_preset().is_dirty);
+ REQUIRE(actual[i]->size() == expected[i]->size());
+ for (size_t j = 0; j < actual[i]->size(); ++j) {
+ const Preset& preset = actual[i]->get_presets()[j];
+ const Preset& old = expected[i]->get_presets()[j];
+ CHECK(preset.name == old.name);
+ CHECK(preset.config == old.config);
+ CHECK(preset.bundle_id == old.bundle_id);
+ CHECK(preset.is_compatible == old.is_compatible);
+ CHECK(preset.is_visible == old.is_visible);
+ CHECK(preset.is_dirty == old.is_dirty);
+ CHECK(actual[i]->get_preset_name_by_alias(preset.alias) == expected[i]->get_preset_name_by_alias(old.alias));
+ }
+ }
+ CHECK(bundle.filament_presets == before.filament_presets);
+ CHECK(bundle.project_config == before.project_config);
+}
+
+struct LocalReloadFixture
+{
+ ScopedPresetDataDir data_dir;
+ PresetBundle bundle;
+ const std::string id = "reload-target";
+ const fs::path directory = data_dir.bundle_dir(id);
+ const std::string prefix = std::string(PRESET_LOCAL_DIR) + "/" + id + "/";
+ std::string error;
+
+ LocalReloadFixture()
+ {
+ write_bundle_metadata(directory, id);
+ write_print_preset_with_layer_height(bundle.prints.default_preset().config, directory / PRESET_PRINT_NAME / "Print.json", "Print",
+ 0.20);
+ write_preset_with_inherits(bundle.filaments.default_preset().config, directory / PRESET_FILAMENT_NAME / "Filament.json", "Filament",
+ "");
+ write_preset_with_inherits(bundle.printers.default_preset().config, directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", "");
+ REQUIRE(bundle.reload_local_bundle("", id, &error));
+ bundle.prints.select_preset_by_name(prefix + "Print", true);
+ bundle.filaments.select_preset_by_name(prefix + "Filament", true);
+ bundle.printers.select_preset_by_name(prefix + "Printer", true);
+ bundle.filament_presets = {prefix + "Filament"};
+ bundle.update_compatible(PresetSelectCompatibleType::Never);
+ }
+
+ void change_sources()
+ {
+ write_print_preset_with_layer_height(bundle.prints.default_preset().config, directory / PRESET_PRINT_NAME / "Print.json", "Print",
+ 0.30);
+ write_preset_with_inherits(bundle.filaments.default_preset().config, directory / PRESET_FILAMENT_NAME / "Added Filament.json",
+ "Added Filament", "");
+ write_preset_with_inherits(bundle.printers.default_preset().config, directory / PRESET_PRINTER_NAME / "Added Printer.json",
+ "Added Printer", "");
+ std::ofstream((directory / PRESET_BUNDLE_METADATA).string()) << "{\"id\":\"" << id << "\",\"version\":\"2.0.0\"}";
+ }
+};
+
} // namespace
TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]")
@@ -267,6 +392,431 @@ TEST_CASE("Printer extruder count tolerates missing nozzle diameter", "[Preset][
CHECK(bundle.get_printer_extruder_count() == 2);
}
+TEST_CASE("Reloading one local bundle preserves unrelated and modified presets", "[Preset][Bundle]")
+{
+ using Catch::Matchers::WithinAbs;
+
+ ScopedPresetDataDir data_dir;
+ PresetBundle bundle;
+ const std::string id = "reload-target";
+ const fs::path bundle_dir = data_dir.bundle_dir(id);
+ const fs::path process_file = bundle_dir / PRESET_PRINT_NAME / "Managed Print.json";
+ const std::string target_name = std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Print";
+ std::string error;
+
+ write_bundle_metadata(bundle_dir, id);
+ write_print_preset_with_layer_height(bundle.prints.default_preset().config, process_file, "Managed Print", 0.20);
+ write_preset_with_inherits(bundle.filaments.default_preset().config,
+ bundle_dir / PRESET_FILAMENT_NAME / "Managed Filament.json", "Managed Filament", "");
+ write_preset_with_inherits(bundle.printers.default_preset().config,
+ bundle_dir / PRESET_PRINTER_NAME / "Managed Printer.json", "Managed Printer", "");
+
+ REQUIRE(bundle.reload_local_bundle("", id, &error));
+ const Preset* target = bundle.prints.find_preset(target_name, false, true);
+ REQUIRE(target != nullptr);
+ CHECK(target->bundle_id == id);
+ CHECK_FALSE(target->is_user());
+ REQUIRE(bundle.filaments.find_preset(std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Filament") != nullptr);
+ REQUIRE(bundle.printers.find_preset(std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Printer") != nullptr);
+ CHECK(bundle.prints.get_preset_name_by_alias("Managed Print") == target_name);
+ CHECK(bundle.filaments.get_preset_name_by_alias("Managed Filament") ==
+ std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Filament");
+ CHECK(bundle.printers.get_preset_name_by_alias("Managed Printer") ==
+ std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Printer");
+
+ const std::string unrelated_name = add_inmemory_preset(bundle.prints, "Unrelated Process").name;
+ bundle.prints.select_preset_by_name(unrelated_name, true);
+ write_print_preset_with_layer_height(bundle.prints.default_preset().config, process_file, "Managed Print", 0.25);
+ REQUIRE(bundle.reload_local_bundle("", id, &error));
+ CHECK(bundle.prints.get_selected_preset_name() == unrelated_name);
+ target = bundle.prints.find_preset(target_name, false, true);
+ REQUIRE(target != nullptr);
+ CHECK_THAT(layer_height(*target), WithinAbs(0.25, 1e-6));
+
+ bundle.prints.select_preset_by_name(target_name, true);
+ bundle.prints.get_edited_preset().config.option("layer_height", true)->value = 0.33;
+ REQUIRE(bundle.prints.update_dirty());
+ write_print_preset_with_layer_height(bundle.prints.default_preset().config, process_file, "Managed Print", 0.28);
+ REQUIRE(bundle.reload_local_bundle("", id, &error));
+ CHECK(bundle.prints.get_selected_preset_name() == target_name);
+ CHECK_THAT(layer_height(bundle.prints.get_selected_preset()), WithinAbs(0.28, 1e-6));
+ CHECK_THAT(layer_height(bundle.prints.get_edited_preset()), WithinAbs(0.33, 1e-6));
+ CHECK(bundle.prints.current_is_dirty());
+
+ std::ofstream(process_file.string()) << "not json";
+ CHECK_FALSE(bundle.reload_local_bundle("", id, &error));
+ CHECK(fs::exists(process_file));
+ CHECK_THAT(layer_height(bundle.prints.get_selected_preset()), WithinAbs(0.28, 1e-6));
+ CHECK_THAT(layer_height(bundle.prints.get_edited_preset()), WithinAbs(0.33, 1e-6));
+
+ bundle.prints.discard_current_changes();
+ REQUIRE(fs::remove_all(process_file.parent_path()) > 0);
+ REQUIRE(bundle.reload_local_bundle("", id, &error));
+ CHECK(bundle.prints.find_preset(target_name, false, true) == nullptr);
+ CHECK(bundle.prints.get_selected_preset_name() != target_name);
+ CHECK(bundle.filaments.find_preset(std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Filament") != nullptr);
+ CHECK(bundle.printers.find_preset(std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Printer") != nullptr);
+
+ const std::string filament_name = std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Filament";
+ const std::string printer_name = std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Printer";
+ bundle.filaments.select_preset_by_name(filament_name, true);
+ bundle.printers.select_preset_by_name(printer_name, true);
+ bundle.filament_presets = { filament_name };
+ REQUIRE(fs::remove_all(bundle_dir / PRESET_FILAMENT_NAME) > 0);
+ REQUIRE(fs::remove_all(bundle_dir / PRESET_PRINTER_NAME) > 0);
+ REQUIRE(bundle.reload_local_bundle("", id, &error));
+ CHECK(bundle.filaments.get_selected_preset_name() != filament_name);
+ CHECK(bundle.printers.get_selected_preset_name() != printer_name);
+ REQUIRE(bundle.filament_presets.size() == 1);
+ CHECK(bundle.filament_presets.front() != filament_name);
+ CHECK(bundle.filaments.find_preset(bundle.filament_presets.front(), false) != nullptr);
+}
+
+TEST_CASE("Reloading a local bundle resolves a loaded parent preset", "[Preset][Bundle]")
+{
+ ScopedPresetDataDir data_dir;
+ PresetBundle bundle;
+ const std::string id = "inherited-target";
+ const fs::path bundle_dir = data_dir.bundle_dir(id);
+ const std::string parent_name = "Generic ABS @System";
+ const std::string target_name = std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Filament";
+ std::string error;
+
+ add_inmemory_preset(bundle.filaments, parent_name);
+ write_bundle_metadata(bundle_dir, id);
+ write_preset_with_inherits(bundle.filaments.default_preset().config,
+ bundle_dir / PRESET_FILAMENT_NAME / "Managed Filament.json", "Managed Filament", parent_name);
+
+ REQUIRE(bundle.reload_local_bundle("", id, &error));
+ const Preset* target = bundle.filaments.find_preset(target_name, false, true);
+ REQUIRE(target != nullptr);
+ CHECK(target->inherits() == parent_name);
+}
+
+TEST_CASE("Reloading a local bundle rejects unsafe source and inheritance changes", "[Preset][Bundle]")
+{
+ ScopedPresetDataDir data_dir;
+ PresetBundle bundle;
+ const std::string id = "guarded-target";
+ const fs::path bundle_dir = data_dir.bundle_dir(id);
+ const std::string target_name = std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Print";
+ std::string error;
+
+ write_bundle_metadata(bundle_dir, id);
+ write_print_preset_with_layer_height(bundle.prints.default_preset().config,
+ bundle_dir / PRESET_PRINT_NAME / "Managed Print.json", "Managed Print", 0.20);
+ REQUIRE(bundle.reload_local_bundle("", id, &error));
+
+ add_inmemory_preset(bundle.prints, "External Child", target_name);
+ CHECK_FALSE(bundle.reload_local_bundle("", id, &error));
+ CHECK(error.find("External Child") != std::string::npos);
+ CHECK(bundle.prints.find_preset(target_name, false, true) != nullptr);
+
+ for (auto it = bundle.prints.begin(); it != bundle.prints.end(); ++it) {
+ if (it->name == "External Child") {
+ bundle.prints.erase(it);
+ break;
+ }
+ }
+ fs::remove_all(bundle_dir);
+ CHECK_FALSE(bundle.reload_local_bundle("", id, &error));
+ CHECK(bundle.prints.find_preset(target_name, false, true) != nullptr);
+ CHECK_FALSE(bundle.reload_local_bundle("", "../outside", &error));
+ CHECK(error == "Local bundle id must be a single path component");
+ CHECK_FALSE(bundle.reload_local_bundle("../outside", id, &error));
+ CHECK(error == "Preset folder must be a single path component");
+#ifdef _WIN32
+ CHECK_FALSE(bundle.reload_local_bundle("", "C:", &error));
+ CHECK(error == "Local bundle id must be a single path component");
+#endif
+}
+
+TEST_CASE("A failure while restoring dirty edits leaves the whole local bundle unchanged", "[Preset][Bundle][Regression]")
+{
+ LocalReloadFixture f;
+ bool restored = false;
+ f.bundle.printers.get_edited_preset().config.set_key_value("machine_start_gcode", new ReloadEdit([&] {
+ restored = true;
+ throw std::bad_alloc();
+ }));
+ f.bundle.printers.update_dirty();
+ const PresetBundle before(f.bundle);
+ const BundleMetadata metadata = f.bundle.bundles.m_bundles.at(f.id);
+ const fs::path local_dir = f.bundle.dir_user_presets_local;
+ f.change_sources();
+
+ CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ REQUIRE(restored);
+ CHECK_THAT(f.error, Catch::Matchers::ContainsSubstring("Failed to reload"));
+ check_reload_state(f.bundle, before);
+ CHECK(f.bundle.dir_user_presets_local == local_dir);
+ const auto& after = f.bundle.bundles.m_bundles.at(f.id);
+ CHECK(after.version == metadata.version);
+ CHECK(after.path == metadata.path);
+ CHECK(after.bundle_type == metadata.bundle_type);
+ CHECK(after.print_presets == metadata.print_presets);
+ CHECK(after.filament_presets == metadata.filament_presets);
+ CHECK(after.printer_presets == metadata.printer_presets);
+ CHECK(fs::exists(f.directory / PRESET_FILAMENT_NAME / "Added Filament.json"));
+
+ // The rejected staging state does not poison the next reload or leave locks held.
+ f.bundle.printers.discard_current_changes();
+ REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ CHECK_THAT(layer_height(f.bundle.prints.get_selected_preset()), Catch::Matchers::WithinAbs(0.30, 1e-6));
+}
+
+TEST_CASE("A subscribed bundle registered during staging wins over the local reload", "[Preset][Bundle][Regression]")
+{
+ const bool replace_local_entry = GENERATE(false, true);
+ LocalReloadFixture f;
+ if (!replace_local_entry)
+ f.bundle.bundles.m_bundles.erase(f.id);
+ bool registered = false;
+ f.bundle.printers.get_edited_preset().config.set_key_value("machine_start_gcode", new ReloadEdit([&] {
+ // Finish a background metadata write after the initial local-source
+ // check, before live commit.
+ std::async(std::launch::async, [&] {
+ BundleMetadata subscribed;
+ subscribed.id = f.id;
+ subscribed.bundle_type = BundleType::Subscribed;
+ subscribed.is_subscribed = true;
+ subscribed.version = "9.0.0";
+ std::unique_lock lock(f.bundle.bundles.RWMtx);
+ f.bundle.bundles.m_bundles.insert_or_assign(f.id,
+ std::move(subscribed));
+ }).get();
+ registered = true;
+ }));
+ f.bundle.printers.update_dirty();
+ const PresetBundle before(f.bundle);
+ f.change_sources();
+
+ CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ REQUIRE(registered);
+ CHECK_THAT(f.error, Catch::Matchers::ContainsSubstring("subscribed"));
+ check_reload_state(f.bundle, before);
+ const auto& metadata = f.bundle.bundles.m_bundles.at(f.id);
+ CHECK(metadata.bundle_type == BundleType::Subscribed);
+ CHECK(metadata.is_subscribed);
+ CHECK(metadata.version == "9.0.0");
+}
+
+TEST_CASE("External compatible process references prevent local bundle replacement and removal", "[Preset][Bundle][Regression]")
+{
+ const bool edited_only = GENERATE(false, true);
+ const bool remove = GENERATE(false, true);
+ LocalReloadFixture f;
+ const std::string external_name = "External Filament";
+ add_inmemory_preset(f.bundle.filaments, external_name);
+ f.bundle.filaments.select_preset_by_name(external_name, true);
+ Preset& dependent = edited_only ? f.bundle.filaments.get_edited_preset() : *f.bundle.filaments.find_preset(external_name, false, true);
+ dependent.config.set_key_value("compatible_prints", new ConfigOptionStrings({f.prefix + "Print"}));
+ if (!edited_only)
+ f.bundle.filaments.select_preset_by_name(external_name, true);
+ f.bundle.filaments.update_dirty();
+ f.bundle.filament_presets = {external_name};
+ f.bundle.update_compatible(PresetSelectCompatibleType::Never);
+ REQUIRE(f.bundle.filaments.get_edited_preset().is_compatible);
+ const PresetBundle before(f.bundle);
+ if (remove)
+ REQUIRE(fs::remove(f.directory / PRESET_PRINT_NAME / "Print.json"));
+ else
+ f.change_sources();
+
+ CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ CHECK_THAT(f.error, Catch::Matchers::ContainsSubstring(external_name));
+ check_reload_state(f.bundle, before);
+ CHECK(f.bundle.bundles.m_bundles.at(f.id).print_presets == std::vector{f.prefix + "Print"});
+}
+
+TEST_CASE("External compatible printer references prevent local bundle replacement and removal", "[Preset][Bundle][Regression]")
+{
+ const bool filament = GENERATE(false, true);
+ const bool edited_only = GENERATE(false, true);
+ const bool remove = GENERATE(false, true);
+ LocalReloadFixture f;
+ PresetCollection& collection = filament ? f.bundle.filaments : f.bundle.prints;
+ const std::string external_name = filament ? "External Filament" : "External Print";
+ add_inmemory_preset(collection, external_name);
+ collection.select_preset_by_name(external_name, true);
+ Preset& dependent = edited_only ? collection.get_edited_preset() : *collection.find_preset(external_name, false, true);
+ dependent.config.set_key_value("compatible_printers", new ConfigOptionStrings({f.prefix + "Printer"}));
+ if (!edited_only)
+ collection.select_preset_by_name(external_name, true);
+ collection.update_dirty();
+ if (filament)
+ f.bundle.filament_presets = {external_name};
+ f.bundle.update_compatible(PresetSelectCompatibleType::Never);
+ REQUIRE(collection.get_edited_preset().is_compatible);
+ const PresetBundle before(f.bundle);
+ if (remove)
+ REQUIRE(fs::remove(f.directory / PRESET_PRINTER_NAME / "Printer.json"));
+ else
+ f.change_sources();
+
+ CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ CHECK_THAT(f.error, Catch::Matchers::ContainsSubstring(external_name));
+ check_reload_state(f.bundle, before);
+}
+
+TEST_CASE("Local reload retains internal compatibility references and live vendor and calibration pointers", "[Preset][Bundle][Regression]")
+{
+ LocalReloadFixture f;
+ VendorProfile& vendor = f.bundle.vendors["TestVendor"];
+ vendor.id = "TestVendor";
+ Preset& external = add_inmemory_preset(f.bundle.filaments, "Vendor Filament");
+ external.is_system = true;
+ external.vendor = &vendor;
+ f.bundle.set_calibrate_printer(f.prefix + "Printer");
+
+ DynamicPrintConfig config(f.bundle.filaments.default_preset().config);
+ config.set_key_value("compatible_prints", new ConfigOptionStrings({f.prefix + "Print"}));
+ config.set_key_value("compatible_printers", new ConfigOptionStrings({f.prefix + "Printer"}));
+ write_preset_with_inherits(config, f.directory / PRESET_FILAMENT_NAME / "Filament.json", "Filament", "");
+ f.change_sources();
+ REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ CHECK(f.bundle.filaments.get_selected_preset_name() == f.prefix + "Filament");
+ CHECK(f.bundle.filaments.get_edited_preset().is_compatible);
+ CHECK(f.bundle.vendors.at("TestVendor").id == vendor.id);
+ CHECK(f.bundle.filaments.find_preset("Vendor Filament")->vendor == &vendor);
+ CHECK(f.bundle.calibrate_printer == f.bundle.printers.find_preset(f.prefix + "Printer"));
+ CHECK(f.bundle.calibrate_filaments.count(f.bundle.filaments.find_preset("Vendor Filament")) == 1);
+ CHECK(f.bundle.calibrate_filaments.count(f.bundle.filaments.find_preset(f.prefix + "Filament", false, true)) == 1);
+}
+
+TEST_CASE("Local reload preserves dirty filament and printer options over new source values", "[Preset][Bundle][Regression]")
+{
+ LocalReloadFixture f;
+ f.bundle.filaments.get_edited_preset().config.option("filament_flow_ratio")->values = {1.13};
+ f.bundle.printers.get_edited_preset().config.option("nozzle_diameter")->values = {0.6};
+ f.bundle.filaments.update_dirty();
+ f.bundle.printers.update_dirty();
+ DynamicPrintConfig filament_config(f.bundle.filaments.get_selected_preset().config);
+ filament_config.option("filament_flow_ratio")->values = {0.98};
+ write_preset_with_inherits(filament_config, f.directory / PRESET_FILAMENT_NAME / "Filament.json", "Filament", "");
+ DynamicPrintConfig printer_config(f.bundle.printers.get_selected_preset().config);
+ printer_config.option("nozzle_diameter")->values = {0.8};
+ write_preset_with_inherits(printer_config, f.directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", "");
+
+ REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ check_double_vector(f.bundle.filaments.get_selected_preset().config.option("filament_flow_ratio")->values, {0.98});
+ check_double_vector(f.bundle.filaments.get_edited_preset().config.option("filament_flow_ratio")->values, {1.13});
+ check_double_vector(f.bundle.printers.get_selected_preset().config.option("nozzle_diameter")->values, {0.8});
+ check_double_vector(f.bundle.printers.get_edited_preset().config.option("nozzle_diameter")->values, {0.6});
+ CHECK(f.bundle.filaments.current_is_dirty());
+ CHECK(f.bundle.printers.current_is_dirty());
+}
+
+TEST_CASE("Local reload rejects removal or rename of a dirty selected preset in every collection", "[Preset][Bundle][Regression]")
+{
+ const auto type = GENERATE(Preset::TYPE_PRINT, Preset::TYPE_FILAMENT, Preset::TYPE_PRINTER);
+ const bool rename = GENERATE(false, true);
+ LocalReloadFixture f;
+ PresetCollection& collection = type == Preset::TYPE_PRINT ? f.bundle.prints :
+ type == Preset::TYPE_FILAMENT ? f.bundle.filaments :
+ f.bundle.printers;
+ const char* directory = type == Preset::TYPE_PRINT ? PRESET_PRINT_NAME :
+ type == Preset::TYPE_FILAMENT ? PRESET_FILAMENT_NAME :
+ PRESET_PRINTER_NAME;
+ const std::string name = type == Preset::TYPE_PRINT ? "Print" : type == Preset::TYPE_FILAMENT ? "Filament" : "Printer";
+ const char* key = type == Preset::TYPE_PRINT ? "layer_height" :
+ type == Preset::TYPE_FILAMENT ? "filament_flow_ratio" :
+ "nozzle_diameter";
+ collection.get_edited_preset().config.set_deserialize_strict(key, "0.37");
+ collection.update_dirty();
+ REQUIRE(collection.current_is_dirty());
+ const PresetBundle before(f.bundle);
+ REQUIRE(fs::remove(f.directory / directory / (name + ".json")));
+ if (rename)
+ write_preset_with_inherits(collection.default_preset().config, f.directory / directory / "Renamed.json", "Renamed", "");
+
+ CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ CHECK_THAT(f.error, Catch::Matchers::ContainsSubstring("modified selected preset"));
+ check_reload_state(f.bundle, before);
+}
+
+TEST_CASE("Local reload reconciles filament slots and purge volumes while retaining unrelated project options",
+ "[Preset][Bundle][Regression]")
+{
+ LocalReloadFixture f;
+ DynamicPrintConfig printer_config(f.bundle.printers.get_selected_preset().config);
+ printer_config.option("nozzle_diameter")->values = {0.4, 0.4};
+ write_preset_with_inherits(printer_config, f.directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", "");
+ f.bundle.project_config.option("flush_volumes_matrix")->values = {0.};
+ f.bundle.project_config.option("flush_volumes_vector")->values = {111., 222.};
+ f.bundle.project_config.option("flush_multiplier")->values = {0.75};
+ f.bundle.project_config.option("filament_colour")->values = {"#123456"};
+ const ConfigOption* colour = f.bundle.project_config.option("filament_colour");
+
+ REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ CHECK(f.bundle.filament_presets == std::vector{f.prefix + "Filament", f.prefix + "Filament"});
+ check_double_vector(f.bundle.project_config.option("flush_multiplier")->values, {0.75, 1.});
+ check_double_vector(f.bundle.project_config.option("flush_volumes_vector")->values, {111., 222., 111., 222.});
+ check_double_vector(f.bundle.project_config.option("flush_volumes_matrix")->values,
+ {0., 333., 333., 0., 0., 333., 333., 0.});
+ CHECK(f.bundle.project_config.option("filament_colour") == colour);
+ CHECK(f.bundle.project_config.option("filament_colour")->values == std::vector{"#123456"});
+}
+
+TEST_CASE("Local reload rejects a non-FFF printer selection without changing live presets", "[Preset][Bundle][Regression]")
+{
+ const bool from_source = GENERATE(false, true);
+ LocalReloadFixture f;
+ DynamicPrintConfig printer_config(f.bundle.printers.get_selected_preset().config);
+ printer_config.set_key_value("printer_technology", new ConfigOptionEnum(ptSLA));
+ if (from_source)
+ write_preset_with_inherits(printer_config, f.directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", "");
+ else
+ f.bundle.printers.load_preset("", "Non-FFF Printer", printer_config, true);
+ const PresetBundle before(f.bundle);
+
+ CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ CHECK_THAT(f.error, Catch::Matchers::ContainsSubstring(from_source ? "invalid preset" : "FFF printer selection"));
+ check_reload_state(f.bundle, before);
+ CHECK(f.bundle.bundles.m_bundles.at(f.id).printer_presets == std::vector{f.prefix + "Printer"});
+}
+
+TEST_CASE("Listing local bundles returns only ids that reload accepts", "[Preset][Bundle]")
+{
+ ScopedPresetDataDir data_dir;
+ PresetBundle bundle;
+ const fs::path local_dir = data_dir.bundle_dir("unused").parent_path();
+ std::string error;
+
+ CHECK(bundle.list_local_bundle_ids("").empty());
+ CHECK_FALSE(fs::exists(local_dir));
+ CHECK(bundle.list_local_bundle_ids("../outside").empty());
+
+ write_bundle_metadata(data_dir.bundle_dir("valid-b"), "valid-b");
+ write_print_preset_with_layer_height(bundle.prints.default_preset().config,
+ data_dir.bundle_dir("valid-b") / PRESET_PRINT_NAME / "Listed Print.json",
+ "Listed Print", 0.20);
+ write_bundle_metadata(data_dir.bundle_dir("valid-a"), "valid-a");
+ fs::create_directories(data_dir.bundle_dir("no-metadata"));
+ write_bundle_metadata(data_dir.bundle_dir("wrong-id"), "other-id");
+ write_bundle_metadata(data_dir.bundle_dir("subscribed-folder"), "subscribed-folder");
+ fs::create_directories(data_dir.tmp.path() / PRESET_USER_DIR / DEFAULT_USER_FOLDER_NAME / PRESET_SUBSCRIBED_DIR /
+ "subscribed-folder");
+ write_bundle_metadata(data_dir.bundle_dir("subscribed-registry"), "subscribed-registry");
+ BundleMetadata subscribed;
+ subscribed.id = "subscribed-registry";
+ subscribed.bundle_type = BundleType::Subscribed;
+ bundle.bundles.m_bundles.emplace(subscribed.id, subscribed);
+ std::ofstream((local_dir / "stray-file.json").string()) << "{}";
+
+ const size_t print_count = bundle.prints.size();
+ const std::vector expected{"valid-a", "valid-b"};
+ CHECK(bundle.list_local_bundle_ids("") == expected);
+ CHECK(bundle.prints.size() == print_count);
+ CHECK_FALSE(fs::exists(data_dir.bundle_dir("no-metadata") / PRESET_BUNDLE_METADATA));
+
+ for (const std::string& rejected : {"no-metadata", "wrong-id", "subscribed-folder", "subscribed-registry"})
+ CHECK_FALSE(bundle.reload_local_bundle("", rejected, &error));
+
+ REQUIRE(bundle.reload_local_bundle("", "valid-b", &error));
+ CHECK(bundle.prints.find_preset(std::string(PRESET_LOCAL_DIR) + "/valid-b/Listed Print", false, true) != nullptr);
+}
+
TEST_CASE("Selected printer uses its default or saved bed type", "[Preset][Bundle]")
{
PresetBundle bundle;
@@ -5958,6 +6508,176 @@ TEST_CASE("Config import confines zip entries, preset names and bundle ids to th
}
}
+namespace {
+void change_json_value(const fs::path& path, const std::string& key, const std::string& value)
+{
+ nlohmann::json document;
+ {
+ std::ifstream input(path.string());
+ input >> document;
+ }
+ document[key] = value;
+ std::ofstream(path.string()) << document.dump(2);
+}
+} // namespace
+
+TEST_CASE("Shrinking an edited vector survives local reload", "[Preset][Bundle][Regression]")
+{
+ LocalReloadFixture f;
+ DynamicPrintConfig config(f.bundle.printers.get_selected_preset().config);
+ config.option("nozzle_diameter")->values = {0.4, 0.4};
+ write_preset_with_inherits(config, f.directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", "");
+ REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ f.bundle.printers.get_edited_preset().config.option("nozzle_diameter")->values = {0.4};
+ f.bundle.printers.update_dirty();
+ REQUIRE(f.bundle.printers.current_is_dirty());
+ REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ CHECK(f.bundle.printers.get_edited_preset().config.option("nozzle_diameter")->values.size() == 1);
+ CHECK(f.bundle.printers.current_is_dirty());
+}
+
+TEST_CASE("Removing a selected printer preserves external dirty process edits", "[Preset][Bundle][Regression]")
+{
+ LocalReloadFixture f;
+ DynamicPrintConfig printer_config(f.bundle.printers.get_selected_preset().config);
+ printer_config.option("nozzle_diameter")->values = {0.6};
+ write_preset_with_inherits(printer_config, f.directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", "");
+ REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ Preset& external = add_inmemory_preset(f.bundle.prints, "External Process");
+ external.config.set_key_value("compatible_printers_condition", new ConfigOptionString("nozzle_diameter[0] == 0.6"));
+ f.bundle.prints.select_preset_by_name("External Process", true);
+ f.bundle.update_compatible(PresetSelectCompatibleType::Never);
+ REQUIRE(f.bundle.prints.get_edited_preset().is_compatible);
+ f.bundle.prints.get_edited_preset().config.option("layer_height")->value = 0.27;
+ f.bundle.prints.update_dirty();
+ REQUIRE(f.bundle.prints.current_is_dirty());
+ REQUIRE(fs::remove(f.directory / PRESET_PRINTER_NAME / "Printer.json"));
+ const PresetBundle before(f.bundle);
+ const bool reloaded = f.bundle.reload_local_bundle("", f.id, &f.error);
+ CAPTURE(f.error);
+ CHECK_FALSE(reloaded);
+ check_reload_state(f.bundle, before);
+ CHECK(f.bundle.prints.get_selected_preset_name() == "External Process");
+ CHECK_THAT(layer_height(f.bundle.prints.get_edited_preset()), Catch::Matchers::WithinAbs(0.27, 1e-6));
+ CHECK(f.bundle.prints.current_is_dirty());
+}
+
+TEST_CASE("A preset with an invalid version does not silently disappear on reload", "[Preset][Bundle][Regression]")
+{
+ const auto type = GENERATE(Preset::TYPE_PRINT, Preset::TYPE_FILAMENT, Preset::TYPE_PRINTER);
+ LocalReloadFixture f;
+ const char* directory = type == Preset::TYPE_PRINT ? PRESET_PRINT_NAME :
+ type == Preset::TYPE_FILAMENT ? PRESET_FILAMENT_NAME :
+ PRESET_PRINTER_NAME;
+ const char* name = type == Preset::TYPE_PRINT ? "Print.json" : type == Preset::TYPE_FILAMENT ? "Filament.json" : "Printer.json";
+ change_json_value(f.directory / directory / name, "version", "broken-version");
+ const PresetBundle before(f.bundle);
+ const bool reloaded = f.bundle.reload_local_bundle("", f.id, &f.error);
+ CAPTURE(f.error);
+ CHECK_FALSE(reloaded);
+ check_reload_state(f.bundle, before);
+ CHECK(f.bundle.prints.find_preset(f.prefix + "Print", false, true) != nullptr);
+}
+
+TEST_CASE("Hiding a selected source preset does not replay its edits onto another preset", "[Preset][Bundle][Regression]")
+{
+ LocalReloadFixture f;
+ add_inmemory_preset(f.bundle.prints, "External Process");
+ f.bundle.prints.get_edited_preset().config.option("layer_height")->value = 0.27;
+ f.bundle.prints.update_dirty();
+ REQUIRE(f.bundle.prints.current_is_dirty());
+ change_json_value(f.directory / PRESET_PRINT_NAME / "Print.json", "instantiation", "false");
+ const PresetBundle before(f.bundle);
+ const bool reloaded = f.bundle.reload_local_bundle("", f.id, &f.error);
+ CAPTURE(f.error);
+ CHECK_FALSE(reloaded);
+ check_reload_state(f.bundle, before);
+ CHECK(f.bundle.prints.get_selected_preset_name() == f.prefix + "Print");
+ CHECK_THAT(layer_height(f.bundle.prints.get_edited_preset()), Catch::Matchers::WithinAbs(0.27, 1e-6));
+ CHECK(f.bundle.prints.current_is_dirty());
+}
+
+TEST_CASE("Background subscription flags are retained at the reload commit", "[Preset][Bundle][Regression]")
+{
+ LocalReloadFixture f;
+ bool registered = false;
+ f.bundle.printers.get_edited_preset().config.set_key_value("machine_start_gcode", new ReloadEdit([&] {
+ std::async(std::launch::async, [&] {
+ std::unique_lock lock(f.bundle.bundles.RWMtx);
+ auto& metadata = f.bundle.bundles.m_bundles.at(f.id);
+ metadata.is_subscribed = true;
+ metadata.update_available = true;
+ }).get();
+ registered = true;
+ }));
+ f.bundle.printers.update_dirty();
+ const PresetBundle before(f.bundle);
+ const bool reloaded = f.bundle.reload_local_bundle("", f.id, &f.error);
+ REQUIRE(registered);
+ CAPTURE(f.error);
+ CHECK_FALSE(reloaded);
+ check_reload_state(f.bundle, before);
+ CHECK(f.bundle.bundles.m_bundles.at(f.id).is_subscribed);
+ CHECK(f.bundle.bundles.m_bundles.at(f.id).update_available);
+}
+
+TEST_CASE("An unrelated stored preset remains at the address exposed to consumers", "[Preset][Bundle][Regression]")
+{
+ LocalReloadFixture f;
+ const std::string name = "External Process";
+ const Preset* external = &add_inmemory_preset(f.bundle.prints, name);
+ const Preset* selected = &f.bundle.prints.get_selected_preset();
+ const Preset* edited = &f.bundle.prints.get_edited_preset();
+ REQUIRE(f.bundle.prints.get_selected_preset_name() != name);
+ write_print_preset_with_layer_height(f.bundle.prints.default_preset().config, f.directory / PRESET_PRINT_NAME / "Print.json", "Print",
+ 0.30);
+ REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ REQUIRE(f.bundle.prints.find_preset(name, false, true) == external);
+ REQUIRE(&f.bundle.prints.get_selected_preset() == selected);
+ REQUIRE(&f.bundle.prints.get_edited_preset() == edited);
+ CHECK(external->name == name);
+ CHECK_THAT(layer_height(*selected), Catch::Matchers::WithinAbs(0.30, 1e-6));
+ CHECK_THAT(layer_height(*edited), Catch::Matchers::WithinAbs(0.30, 1e-6));
+}
+TEST_CASE("Calibration compatibility reflects the reloaded printer settings", "[Preset][Bundle][Regression]")
+{
+ LocalReloadFixture f;
+ Preset& external = add_inmemory_preset(f.bundle.filaments, "Calibration Filament");
+ external.config.set_key_value("compatible_printers_condition", new ConfigOptionString("nozzle_diameter[0] == 0.4"));
+ f.bundle.set_calibrate_printer(f.prefix + "Printer");
+ REQUIRE(f.bundle.calibrate_filaments.count(&external) == 1);
+ DynamicPrintConfig config(f.bundle.printers.get_selected_preset().config);
+ config.option("nozzle_diameter")->values = {0.6};
+ write_preset_with_inherits(config, f.directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", "");
+ REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ const Preset* filament = f.bundle.filaments.find_preset("Calibration Filament", false, true);
+ REQUIRE(filament != nullptr);
+ REQUIRE_FALSE(is_compatible_with_printer(f.bundle.filaments.get_preset_with_vendor_profile(*filament),
+ f.bundle.printers.get_preset_with_vendor_profile(*f.bundle.calibrate_printer)));
+ CHECK(f.bundle.calibrate_filaments.count(filament) == 0);
+}
+TEST_CASE("Local reload rolls back prepared collection growth on a late allocation failure", "[Preset][Bundle][Regression]")
+{
+ LocalReloadFixture f;
+ const size_t original_print_count = f.bundle.prints.size();
+ bool failed = false;
+ Preset& external = add_inmemory_preset(f.bundle.printers, "zzzz External Printer");
+ external.config.set_key_value("machine_start_gcode", new ReloadEdit([&] {
+ if (f.bundle.prints.size() > original_print_count) {
+ failed = true;
+ throw std::bad_alloc();
+ }
+ }));
+ const PresetBundle before(f.bundle);
+ f.change_sources();
+ write_print_preset_with_layer_height(f.bundle.prints.default_preset().config, f.directory / PRESET_PRINT_NAME / "Added Print.json",
+ "Added Print", 0.25);
+ CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error));
+ REQUIRE(failed);
+ check_reload_state(f.bundle, before);
+ CHECK(f.bundle.bundles.m_bundles.at(f.id).bundle_type == BundleType::Local);
+}
+
namespace {
const std::string kTextureBytes("\x89PNG\x00tex", 8);
diff --git a/tests/slic3rutils/test_plugin_host_api.cpp b/tests/slic3rutils/test_plugin_host_api.cpp
index 0841a130d7..301acbc7cc 100644
--- a/tests/slic3rutils/test_plugin_host_api.cpp
+++ b/tests/slic3rutils/test_plugin_host_api.cpp
@@ -56,6 +56,14 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho
REQUIRE(has_attr(host, "Model"));
REQUIRE(has_attr(host, "ModelObject"));
REQUIRE(has_attr(host, "Plater"));
+ REQUIRE(has_attr(host, "preset"));
+ py::object preset = host.attr("preset");
+ REQUIRE(has_attr(preset, "bundles"));
+ py::object bundles = preset.attr("bundles");
+ CHECK(has_attr(bundles, "list_ids"));
+ CHECK(has_attr(bundles, "reload_local"));
+ CHECK_FALSE(has_attr(host, "reload_local_bundle"));
+ CHECK_FALSE(has_attr(host, "list_bundle_ids"));
py::object preset_bundle_type = host.attr("PresetBundle");
CHECK(has_attr(preset_bundle_type, "prints"));
@@ -132,6 +140,7 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho
TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]")
{
py::object host = import_orca_module().attr("host");
+ py::object bundles = host.attr("preset").attr("bundles");
for (const char* function_name : { "preset_bundle", "plater", "model", "app_info", "selected_printer" }) {
CAPTURE(function_name);
@@ -143,6 +152,22 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
}
+
+ try {
+ bundles.attr("reload_local")("test-bundle");
+ FAIL("host.preset.bundles.reload_local unexpectedly succeeded without a wx application");
+ } catch (const py::error_already_set& error) {
+ CHECK(error.matches(PyExc_RuntimeError));
+ CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
+ }
+
+ try {
+ bundles.attr("list_ids")();
+ FAIL("host.preset.bundles.list_ids unexpectedly succeeded without a wx application");
+ } catch (const py::error_already_set& error) {
+ CHECK(error.matches(PyExc_RuntimeError));
+ CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
+ }
}
TEST_CASE("Plugin host API reports no GL details before the 3D view exists", "[PluginHost][Python]")