From 9a72dda79a6d00d9dee1a2f92f9386904f4ce352 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 25 Jul 2026 20:00:56 +0800 Subject: [PATCH] Fix plugin configuration not taking effect, and improve the plugin config UI (#14944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Changing a slicing plugin's configuration had no effect on the sliced result until you forced a re-slice some other way; it now applies immediately. Print, printer and filament presets also keep their plugin configuration separately, so configuring a plugin on one no longer wipes out what you set on another. A plugin's custom configuration page gets the same round of improvements in both the Plugins dialog and the per-preset dialog: it follows the app's light/dark theme, keeps its state while you edit instead of resetting under the cursor, and can tell whether it is being edited globally or for a preset, so "Restore defaults" can be labeled for what it will actually do. The two bundled examples show this off — Twistify now ships a custom configuration UI, and Inspector is themed, groups # Screenshots/Recordings/Graphs https://github.com/user-attachments/assets/02ca062a-5143-49a3-abe0-a2a040b3a928 ## Tests [How to Download Pull Requests Artifacts for Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts) --- .../web/dialog/PluginsConfigDialog/index.html | 1 + .../web/dialog/PluginsConfigDialog/index.js | 50 +- resources/web/dialog/PluginsDialog/index.html | 1 + resources/web/dialog/PluginsDialog/index.js | 36 +- resources/web/dialog/js/plugin-config-ui.js | 105 +++ ...le_any.py => orca_inspector_plugin_any.py} | 500 ++++++++++---- sandboxes/orca_twistify_plugin_any.py | 618 ++++++++++++++++++ sandboxes/orca_twistify_plugin_example_any.py | 142 ---- src/libslic3r/Preset.cpp | 15 +- src/libslic3r/Preset.hpp | 5 + src/libslic3r/Print.cpp | 1 + src/libslic3r/PrintConfig.cpp | 25 +- src/libslic3r/PrintConfig.hpp | 1 + src/slic3r/GUI/Tab.cpp | 15 +- src/slic3r/plugin/PluginConfig.cpp | 38 +- src/slic3r/plugin/PluginConfig.hpp | 17 +- .../fff_print/test_slicing_pipeline_hook.cpp | 16 +- .../libslic3r/test_preset_bundle_loading.cpp | 24 + 18 files changed, 1202 insertions(+), 408 deletions(-) create mode 100644 resources/web/dialog/js/plugin-config-ui.js rename sandboxes/{orca_inspector_plugin_example_any.py => orca_inspector_plugin_any.py} (74%) create mode 100644 sandboxes/orca_twistify_plugin_any.py delete mode 100644 sandboxes/orca_twistify_plugin_example_any.py diff --git a/resources/web/dialog/PluginsConfigDialog/index.html b/resources/web/dialog/PluginsConfigDialog/index.html index 8e44bac35d..6ce4d29dcc 100644 --- a/resources/web/dialog/PluginsConfigDialog/index.html +++ b/resources/web/dialog/PluginsConfigDialog/index.html @@ -48,6 +48,7 @@ + diff --git a/resources/web/dialog/PluginsConfigDialog/index.js b/resources/web/dialog/PluginsConfigDialog/index.js index 013ec7ac16..8d9b11011f 100644 --- a/resources/web/dialog/PluginsConfigDialog/index.js +++ b/resources/web/dialog/PluginsConfigDialog/index.js @@ -9,6 +9,12 @@ let selectedCapabilityType = ""; let selectedHasPresetOverride = false; let selectedReadOnly = false; +// Whether the frame already holds the selected capability's custom UI (payloads are gated by +// IsCurrentCapability and every selection change clears the view). Saving re-sends the whole +// capability_config payload, and rebuilding the frame from it would reload the plugin's page under +// the user's cursor, so a loaded frame gets the new values posted in instead. +let customFrameLoaded = false; + function SafeJsonParse(text) { try { return JSON.parse(text); @@ -193,6 +199,7 @@ function ClearCapabilityConfigView() { if (custom) { custom.hidden = true; custom.removeAttribute("srcdoc"); + customFrameLoaded = false; } if (text) text.value = ""; @@ -248,8 +255,14 @@ function ApplyCapabilityConfig(payload) { if (html) { if (custom) { + const context = OrcaConfigContext(payload, "preset"); custom.hidden = false; - custom.srcdoc = BuildCustomConfigDocument(html, config); + if (customFrameLoaded && custom.contentWindow) { + custom.contentWindow.postMessage({ __orca: "config", config: config, context: context }, "*"); + } else { + customFrameLoaded = true; + custom.srcdoc = BuildCustomConfigDocument(html, config, context); + } } if (editor) editor.hidden = true; @@ -260,6 +273,7 @@ function ApplyCapabilityConfig(payload) { if (custom) { custom.hidden = true; custom.removeAttribute("srcdoc"); + customFrameLoaded = false; } if (editor) editor.hidden = false; @@ -354,39 +368,6 @@ function ApplyCapabilityConfigSaved(payload) { SetConfigValidation(""); } -// The whole host surface a custom config UI gets: read the config, save one, drop the preset's -// override, and be told when either lands. The frame is sandboxed into an opaque origin, so this -// bridge is its only channel. -function BuildCustomConfigDocument(html, config) { - // Inlined into a " would close the tag early, so escape "<" — the - // literal stays valid JSON. - const seed = JSON.stringify(config).replace(/ -(function () { - var handlers = []; - var current = ${seed}; - window.orca = { - getConfig: function () { return current; }, - saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); }, - restoreDefaults: function () { parent.postMessage({ __orca: "restore" }, "*"); }, - onConfig: function (cb) { - if (typeof cb !== "function") return; - handlers.push(cb); - try { cb(current); } catch (e) {} - } - }; - window.addEventListener("message", function (event) { - if (!event.data || event.data.__orca !== "config") return; - current = event.data.config || {}; - handlers.forEach(function (handler) { - try { handler(current); } catch (e) {} - }); - }); -})(); -<\/script>`; - return bridge + html; -} - function OnCustomConfigMessage(event) { const custom = document.getElementById("configCustom"); // Only the frame we created, and only while it is actually showing. @@ -432,6 +413,7 @@ document.addEventListener("DOMContentLoaded", () => { // OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when // sandboxed), and ignores anything else. window.addEventListener("message", OnCustomConfigMessage); + OrcaWatchThemeForFrame("configCustom"); SendMessage("request_capabilities"); }); diff --git a/resources/web/dialog/PluginsDialog/index.html b/resources/web/dialog/PluginsDialog/index.html index 1e879339ed..3cee806786 100644 --- a/resources/web/dialog/PluginsDialog/index.html +++ b/resources/web/dialog/PluginsDialog/index.html @@ -15,6 +15,7 @@ + diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js index cd690ef8b1..ea081a23aa 100644 --- a/resources/web/dialog/PluginsDialog/index.js +++ b/resources/web/dialog/PluginsDialog/index.js @@ -90,6 +90,7 @@ function OnInit() { // OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when // sandboxed), and ignores anything else. window.addEventListener("message", OnCustomConfigMessage); + OrcaWatchThemeForFrame("configCustom"); document.addEventListener("click", (event) => { if (!event.target.closest(".ctx")) @@ -1077,7 +1078,7 @@ function ApplyCapabilityConfig(payload) { if (html) { if (custom) { custom.hidden = false; - custom.srcdoc = BuildCustomConfigDocument(html, config); + custom.srcdoc = BuildCustomConfigDocument(html, config, OrcaConfigContext(payload, "global")); } if (editor) editor.hidden = true; @@ -1178,39 +1179,6 @@ function ApplyCapabilityConfigSaved(payload) { SetConfigValidation(""); } -// The whole host surface a custom config UI gets: read the config, save one, restore the plugin's -// defaults, and be told when either lands. The frame is sandboxed into an opaque origin, so this -// bridge is its only channel. -function BuildCustomConfigDocument(html, config) { - // Inlined into a " would close the tag early, so escape "<" — the - // literal stays valid JSON. - const seed = JSON.stringify(config).replace(/ -(function () { - var handlers = []; - var current = ${seed}; - window.orca = { - getConfig: function () { return current; }, - saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); }, - restoreDefaults: function () { parent.postMessage({ __orca: "restore" }, "*"); }, - onConfig: function (cb) { - if (typeof cb !== "function") return; - handlers.push(cb); - try { cb(current); } catch (e) {} - } - }; - window.addEventListener("message", function (event) { - if (!event.data || event.data.__orca !== "config") return; - current = event.data.config || {}; - handlers.forEach(function (handler) { - try { handler(current); } catch (e) {} - }); - }); -})(); -<\/script>`; - return bridge + html; -} - function OnCustomConfigMessage(event) { const custom = document.getElementById("configCustom"); // Only the frame we created, and only while it is actually showing. diff --git a/resources/web/dialog/js/plugin-config-ui.js b/resources/web/dialog/js/plugin-config-ui.js new file mode 100644 index 0000000000..af009c3d3a --- /dev/null +++ b/resources/web/dialog/js/plugin-config-ui.js @@ -0,0 +1,105 @@ +// The host surface a plugin capability's custom configuration UI gets, shared by the Plugins dialog +// and by a preset's plugin configuration dialog. Both sandbox the page into an opaque origin, so this +// bridge is its only channel — and both must offer plugin authors the same one. + +// The host theme (WebViewHostDialog::host_theme_vars_css) is injected as a ":root{--orca-*}" rule in +// + + +""" + + +def config_page(collection, name, rows): + """CONFIG_PAGE with one preset's rows baked in. `` cannot break out of the script block.""" + payload = json.dumps({"name": name, "hue": HUES.get(collection, ""), "rows": rows}) + return CONFIG_PAGE.replace("__DATA__", payload.replace("=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Twistify" +# description = "Twists, tapers, and wobbles every layer's slice polygons as a function of Z (demo)." +# author = "SoftFever" +# version = "0.03" +# type = "slicing-pipeline" +# /// +"""Twistify -- twist/taper/wobble any model at slice time. + +At Step.posSlice, every layer's sliced surfaces are transformed by a similarity +about the object's bounding-box center as a function of Z -- edited IN PLACE +through the host geometry classes (ExPolygon.rotate/scale/translate). Each +surface is rotated about the center, then (if tapering) translated to the +origin, uniformly scaled, and translated back, so the taper stays centered on +the object instead of drifting toward the coordinate origin. An optional X +wobble is applied last. After the per-region edits, layer.make_slices() +re-derives the layer's merged islands so overhang/bridge/skirt/support stay +coherent. The split slice loop runs make_perimeters() right after the hook, so +the transform cascades into perimeters, infill, and the final G-code -- the +preview corkscrews and the print keeps correct walls/infill/flow. + +Because we edit geometry in place, surface types are preserved automatically +(no per-surface type carry needed), and no numpy is required -- +rotate/scale/translate are host methods. Parameters come from self.get_config() +(see _params below); the host seeds them from get_default_config() the first +time the plugin loads. The first object layer is untouched (z_rel = 0), so bed +adhesion is unaffected. + +The plugin also ships its own configuration UI (has_config_ui/get_config_ui): +a self-contained page the Plugins dialog renders instead of the JSON editor, +drawing the transform it is about to apply as an isometric stack of layer +outlines. It reaches the host only through the injected window.orca bridge -- +getConfig/saveConfig/restoreDefaults/getContext/onConfig/onTheme -- and styles +itself from the --orca-* theme variables the host hands the frame, so it +follows OrcaSlicer's light/dark theme. +""" +import math +import json +import orca + +_DEFAULTS = { + "twist_deg_per_mm": 1.0, + "taper_per_mm": 0.0, + "wobble_ampl_mm": 0.0, + "wobble_period_mm": 20.0, + "min_scale": 0.05, +} + + +def _params(self): + try: + src = json.loads(self.get_config()) + except (AttributeError, TypeError, ValueError): + src = {} + out = {} + for key, default in _DEFAULTS.items(): + try: + out[key] = float(src[key]) + except (KeyError, TypeError, ValueError): + out[key] = default + return out + + +def _is_identity(p): + return p["twist_deg_per_mm"] == 0.0 and p["taper_per_mm"] == 0.0 and p["wobble_ampl_mm"] == 0.0 + + +def _layer_params(z_rel, mm_to_scaled, p): + """(angle_rad, scale, x_offset_scaled) for one layer. Exact identity at z_rel == 0.""" + theta = math.radians(p["twist_deg_per_mm"] * z_rel) + s = max(p["min_scale"], 1.0 + p["taper_per_mm"] * z_rel) + ox = 0.0 + if p["wobble_ampl_mm"] != 0.0 and p["wobble_period_mm"] > 0.0: + ox = p["wobble_ampl_mm"] * math.sin(2.0 * math.pi * z_rel / p["wobble_period_mm"]) * mm_to_scaled + return theta, s, ox + + +# The configuration page. Self-contained on purpose: it runs in an iframe sandboxed into an opaque +# origin, so it has only the window.orca bridge and the --orca-* theme variables the host injects -- +# no network, no same-origin access, no shared stylesheet. get_config_ui() substitutes _DEFAULTS for +# __ORCA_DEFAULTS__, so Python owns the values and the page adds only presentation. +_CONFIG_UI = """ + + +
+
+ Twistify + Applied to every layer as it is sliced +
+ +
+
+
+

+
+ +
+ +
+
+
+ +
+ + + +
+
+ + +""" + + +class Twistify(orca.slicing.SlicingPipelineCapabilityBase): + def get_name(self): + return "Twistify" + + def get_default_config(self): + return _DEFAULTS + + def has_config_ui(self): + return True + + def get_config_ui(self): + return _CONFIG_UI.replace("__ORCA_DEFAULTS__", json.dumps(_DEFAULTS)) + + def execute(self, ctx): + if ctx.step != orca.slicing.Step.posSlice or ctx.object is None: + return orca.ExecutionResult.success() + + p = _params(self) + if _is_identity(p): + return orca.ExecutionResult.success("Twistify: identity parameters, nothing to do") + + mm_to_scaled = 1.0 / orca.slicing.unscale(1) + + layers = ctx.object.layers() + if not layers: + return orca.ExecutionResult.success("Twistify: object has no layers") + + # Twist/taper axis = the object's bounding-box center (scaled coords, same frame + # as the slice polygons), so each object on the plate transforms about its own + # center. Keep the float center for translate-to-origin/back around scale(), and + # a rounded-to-Point center for rotate() (which takes an integer Point). + min_x, min_y, max_x, max_y = ctx.object.bounding_box() + cx = (min_x + max_x) / 2.0 + cy = (min_y + max_y) / 2.0 + center = orca.host.Point(int(round(cx)), int(round(cy))) + z0 = float(layers[0].print_z) # z_rel = 0 on the first layer -> footprint untouched + + layers_touched = 0 + for layer in layers: + if ctx.cancelled(): + break + z_rel = float(layer.print_z) - z0 + theta, s, ox = _layer_params(z_rel, mm_to_scaled, p) + if theta == 0.0 and s == 1.0 and ox == 0.0: + continue # exact identity (always the first layer) + + edited = False + for region in layer.regions(): + for surface in region.slices.surfaces: + ex = surface.expolygon + ex.rotate(theta, center) # rotate about the object center (in place) + if s != 1.0: + # scale() scales about the coordinate ORIGIN, so re-center the + # geometry on the origin first and translate back after, making + # this a true similarity transform about the object's center. + ex.translate(-cx, -cy) + ex.scale(s) + ex.translate(cx, cy) + if ox != 0.0: + ex.translate(ox, 0.0) # wobble in X + edited = True + if edited: + # Re-derive the merged islands from the twisted region slices. + layer.make_slices() + layers_touched += 1 + + name = ctx.object.model_object().name or "object" + return orca.ExecutionResult.success( + f"Twistify: transformed {layers_touched} layer(s) of '{name}' " + f"(twist {p['twist_deg_per_mm']} deg/mm, taper {p['taper_per_mm']}/mm, " + f"wobble {p['wobble_ampl_mm']} mm)") + + +@orca.plugin +class TwistifyPackage(orca.base): + def register_capabilities(self): + orca.register_capability(Twistify) diff --git a/sandboxes/orca_twistify_plugin_example_any.py b/sandboxes/orca_twistify_plugin_example_any.py deleted file mode 100644 index 4714af536b..0000000000 --- a/sandboxes/orca_twistify_plugin_example_any.py +++ /dev/null @@ -1,142 +0,0 @@ -# /// script -# requires-python = ">=3.12" -# -# [tool.orcaslicer.plugin] -# name = "Twistify" -# description = "Twists, tapers, and wobbles every layer's slice polygons as a function of Z (demo)." -# author = "OrcaSlicer" -# version = "0.02" -# type = "slicing-pipeline" -# /// -"""Twistify -- twist/taper/wobble any model at slice time. - -At Step.posSlice, every layer's sliced surfaces are transformed by a similarity -about the object's bounding-box center as a function of Z -- edited IN PLACE -through the host geometry classes (ExPolygon.rotate/scale/translate). Each -surface is rotated about the center, then (if tapering) translated to the -origin, uniformly scaled, and translated back, so the taper stays centered on -the object instead of drifting toward the coordinate origin. An optional X -wobble is applied last. After the per-region edits, layer.make_slices() -re-derives the layer's merged islands so overhang/bridge/skirt/support stay -coherent. The split slice loop runs make_perimeters() right after the hook, so -the transform cascades into perimeters, infill, and the final G-code -- the -preview corkscrews and the print keeps correct walls/infill/flow. - -Because we edit geometry in place, surface types are preserved automatically -(no per-surface type carry needed), and no numpy is required -- -rotate/scale/translate are host methods. Parameters come from self.get_config() -(see _params below), which the host seeds from get_default_config(). The first -object layer is untouched (z_rel = 0), so bed adhesion is unaffected. -""" -import math -import json -import orca - -_DEFAULTS = { - "twist_deg_per_mm": 1.0, - "taper_per_mm": 0.0, - "wobble_ampl_mm": 0.0, - "wobble_period_mm": 20.0, - "min_scale": 0.05, -} - - -def _params(self): - try: - src = json.loads(self.get_config()) - except (AttributeError, TypeError): - src = {} - out = {} - for key, default in _DEFAULTS.items(): - try: - out[key] = float(src[key]) - except (KeyError, TypeError, ValueError): - out[key] = default - return out - - -def _is_identity(p): - return p["twist_deg_per_mm"] == 0.0 and p["taper_per_mm"] == 0.0 and p["wobble_ampl_mm"] == 0.0 - - -def _layer_params(z_rel, mm_to_scaled, p): - """(angle_rad, scale, x_offset_scaled) for one layer. Exact identity at z_rel == 0.""" - theta = math.radians(p["twist_deg_per_mm"] * z_rel) - s = max(p["min_scale"], 1.0 + p["taper_per_mm"] * z_rel) - ox = 0.0 - if p["wobble_ampl_mm"] != 0.0 and p["wobble_period_mm"] > 0.0: - ox = p["wobble_ampl_mm"] * math.sin(2.0 * math.pi * z_rel / p["wobble_period_mm"]) * mm_to_scaled - return theta, s, ox - - -class Twistify(orca.slicing.SlicingPipelineCapabilityBase): - def get_name(self): - return "Twistify" - - def get_default_config(self): - return _DEFAULTS - - def execute(self, ctx): - if ctx.step != orca.slicing.Step.posSlice or ctx.object is None: - return orca.ExecutionResult.success() - - p = _params(self) - if _is_identity(p): - return orca.ExecutionResult.success("Twistify: identity parameters, nothing to do") - - mm_to_scaled = 1.0 / orca.slicing.unscale(1) - - layers = ctx.object.layers() - if not layers: - return orca.ExecutionResult.success("Twistify: object has no layers") - - # Twist/taper axis = the object's bounding-box center (scaled coords, same frame - # as the slice polygons), so each object on the plate transforms about its own - # center. Keep the float center for translate-to-origin/back around scale(), and - # a rounded-to-Point center for rotate() (which takes an integer Point). - min_x, min_y, max_x, max_y = ctx.object.bounding_box() - cx = (min_x + max_x) / 2.0 - cy = (min_y + max_y) / 2.0 - center = orca.host.Point(int(round(cx)), int(round(cy))) - z0 = float(layers[0].print_z) # z_rel = 0 on the first layer -> footprint untouched - - layers_touched = 0 - for layer in layers: - if ctx.cancelled(): - break - z_rel = float(layer.print_z) - z0 - theta, s, ox = _layer_params(z_rel, mm_to_scaled, p) - if theta == 0.0 and s == 1.0 and ox == 0.0: - continue # exact identity (always the first layer) - - edited = False - for region in layer.regions(): - for surface in region.slices.surfaces: - ex = surface.expolygon - ex.rotate(theta, center) # rotate about the object center (in place) - if s != 1.0: - # scale() scales about the coordinate ORIGIN, so re-center the - # geometry on the origin first and translate back after, making - # this a true similarity transform about the object's center. - ex.translate(-cx, -cy) - ex.scale(s) - ex.translate(cx, cy) - if ox != 0.0: - ex.translate(ox, 0.0) # wobble in X - edited = True - if edited: - # Re-derive the merged islands from the twisted region slices. - layer.make_slices() - layers_touched += 1 - - name = ctx.object.model_object().name or "object" - return orca.ExecutionResult.success( - f"Twistify: transformed {layers_touched} layer(s) of '{name}' " - f"(twist {p['twist_deg_per_mm']} deg/mm, taper {p['taper_per_mm']}/mm, " - f"wobble {p['wobble_ampl_mm']} mm)") - - -@orca.plugin -class TwistifyPackage(orca.base): - def register_capabilities(self): - orca.register_capability(Twistify) diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index ed712ef6a9..775c31b563 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1203,7 +1203,7 @@ static std::vector s_Preset_print_options{ "post_process", "slicing_pipeline_plugin", "plugins", - "plugin_config_overrides", + "print_plugin_config_overrides", "process_change_extrusion_role_gcode", "min_length_factor", "wall_maximum_resolution", @@ -1378,7 +1378,7 @@ static std::vector s_Preset_filament_options {/*"filament_colour", "filament_preheat_temperature_delta", "filament_retract_length_nc", "filament_change_length_nc", "filament_prime_volume", "filament_prime_volume_nc", "long_retractions_when_ec", "retraction_distances_when_ec", - "plugin_config_overrides", + "filament_plugin_config_overrides", //ams chamber "filament_dev_ams_drying_ams_limitations", "filament_dev_ams_drying_temperature", "filament_dev_ams_drying_time", "filament_dev_ams_drying_heat_distortion_temperature", "filament_dev_chamber_drying_bed_temperature", "filament_dev_chamber_drying_time", @@ -1430,7 +1430,7 @@ static std::vector s_Preset_printer_options { // Fast-purge printer flag + device/firmware-facing per-variant extruder-change // deretraction speed (unconsumed by the slicer; carried by H2D/A2L/X2D/P2S machine profiles). "support_fast_purge_mode", "deretract_speed_extruder_change", - "plugin_config_overrides" + "printer_plugin_config_overrides" }; static std::vector s_Preset_sla_print_options { @@ -1542,6 +1542,15 @@ const std::vector& Preset::printer_options() return s_opts; } +const char* Preset::plugin_overrides_key(Type type) +{ + switch (type) { + case TYPE_PRINTER: return "printer_plugin_config_overrides"; + case TYPE_FILAMENT: return "filament_plugin_config_overrides"; + default: return "print_plugin_config_overrides"; + } +} + PresetCollection::PresetCollection(Preset::Type type, const std::vector &keys, const Slic3r::StaticPrintConfig &defaults, const std::string &default_name) : m_type(type), m_edited_preset(type, "", false), diff --git a/src/libslic3r/Preset.hpp b/src/libslic3r/Preset.hpp index 8863f04672..b88b5a5ed5 100644 --- a/src/libslic3r/Preset.hpp +++ b/src/libslic3r/Preset.hpp @@ -407,6 +407,11 @@ public: // Printer machine limits, those are contained in printer_options(). static const std::vector& machine_limits_options(); + // Option key holding this preset type's plugin capability overrides. Each type has its own key so + // the values survive the merge into a single full config; print is the fallback for the types with + // no plugin-backed options. + static const char* plugin_overrides_key(Type type); + static const std::vector& sla_printer_options(); static const std::vector& sla_material_options(); static const std::vector& sla_print_options(); diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 808bc590be..334bda84e9 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -285,6 +285,7 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n steps.emplace_back(psSkirtBrim); } else if ( opt_key == "slicing_pipeline_plugin" + || opt_key == "print_plugin_config_overrides" || opt_key == "initial_layer_print_height" || opt_key == "nozzle_diameter" || opt_key == "filament_shrink" diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index a9860cbbed..8129ff741e 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -1081,16 +1081,21 @@ void PrintConfigDef::init_common_params() def->set_default_value(new ConfigOptionString()); } - def = this->add("plugin_config_overrides", coString); - def->label = L("Capabilities"); - def->tooltip = L("Configuration for the plugin capabilities this preset uses, overriding the global " - "Capabilities configuration. Stored as a raw JSON array and edited through the dialog " - "behind the button, never typed in directly."); - // Never shown as a text field: GUIType::plugin_config renders a button that opens PluginsConfigDialog. - def->gui_type = ConfigOptionDef::GUIType::plugin_config; - def->mode = comAdvanced; - def->cli = ConfigOptionDef::nocli; - def->set_default_value(new ConfigOptionString("")); + // One key per preset type (Preset::plugin_overrides_key), so the print, printer and filament + // overrides don't clobber each other when the presets merge into one full config. No handle_legacy + // migration from the shared "plugin_config_overrides" they replace: it only ever shipped in + // nightlies. Never a text field — GUIType::plugin_config renders a button opening PluginsConfigDialog. + for (const char* key : {"print_plugin_config_overrides", "printer_plugin_config_overrides", "filament_plugin_config_overrides"}) { + def = this->add(key, coString); + def->label = L("Capabilities"); + def->tooltip = L("Configuration for the plugin capabilities this preset uses, overriding the global " + "Capabilities configuration. Stored as a raw JSON array and edited through the dialog " + "behind the button, never typed in directly."); + def->gui_type = ConfigOptionDef::GUIType::plugin_config; + def->mode = comAdvanced; + def->cli = ConfigOptionDef::nocli; + def->set_default_value(new ConfigOptionString("")); + } } void PrintConfigDef::init_fff_params() diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 73811e3ecb..c4006b6c35 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -1780,6 +1780,7 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE( ((ConfigOptionString, filename_format)) ((ConfigOptionStrings, post_process)) ((ConfigOptionStrings, slicing_pipeline_plugin)) + ((ConfigOptionString, print_plugin_config_overrides)) ((ConfigOptionString, printer_model)) ((ConfigOptionFloat, resolution)) ((ConfigOptionFloats, retraction_minimum_travel)) diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index fedd4dd38b..fa235f69dd 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -1796,7 +1796,7 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value) // Keep this preset's "plugins" manifest in sync when a plugin picker changes, so full_config() and // save_to_json() always find resolved "name;uuid;capability" references and rebuild it nowhere else. - // Also drop any plugin_config_overrides entries for a capability the change just stopped + // Also drop any plugin config override entries for a capability the change just stopped // referencing (e.g. a plugin removed from slicing_pipeline_plugin), so a saved preset never // carries configuration for a capability it no longer names. The Configure button is a separate // field holding its own cached copy of that value, so it needs to be told explicitly, or it @@ -1804,9 +1804,10 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value) if (const ConfigOptionDef* opt_def = m_config->def()->get(opt_key); opt_def && opt_def->is_plugin_backed()) { m_config->update_plugin_manifest(); - if (prune_stale_plugin_overrides(*m_config)) { - if (Field* overrides_field = get_field(PLUGIN_OVERRIDES_OPTION_KEY)) - overrides_field->set_value(boost::any(m_config->opt_string(PLUGIN_OVERRIDES_OPTION_KEY)), false); + const std::string overrides_key = Preset::plugin_overrides_key(m_type); + if (prune_stale_plugin_overrides(*m_config, overrides_key)) { + if (Field* overrides_field = get_field(overrides_key)) + overrides_field->set_value(boost::any(m_config->opt_string(overrides_key)), false); } } @@ -3136,7 +3137,7 @@ void TabPrint::build() // Its own group: the one above hides its labels, and this row needs its label — and the revert // arrow beside it — to show. No label-width override either, as a 0 there means "no label column". optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode"); - optgroup->append_single_option_line("plugin_config_overrides"); + optgroup->append_single_option_line("print_plugin_config_overrides"); optgroup = page->new_optgroup(L("Notes"), "note", 0); option = optgroup->get_option("notes"); @@ -4552,7 +4553,7 @@ void TabFilament::build() optgroup->append_single_option_line(option); optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode"); - optgroup->append_single_option_line("plugin_config_overrides"); + optgroup->append_single_option_line("filament_plugin_config_overrides"); page = add_options_page(L("Multimaterial"), "custom-gcode_multi_material"); // ORCA: icon only visible on placeholders optgroup = page->new_optgroup(L("Wipe tower parameters"), "param_tower"); @@ -5061,7 +5062,7 @@ void TabPrinter::build_fff() optgroup->append_single_option_line("time_cost", "printer_basic_information_advanced#time-cost"); optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode"); - optgroup->append_single_option_line("plugin_config_overrides"); + optgroup->append_single_option_line("printer_plugin_config_overrides"); optgroup = page->new_optgroup(L("Cooling Fan"), "param_cooling_fan"); Line line = Line{ L("Fan speed-up time"), optgroup->get_option("fan_speedup_time").opt.tooltip }; diff --git a/src/slic3r/plugin/PluginConfig.cpp b/src/slic3r/plugin/PluginConfig.cpp index 7b6baa1fd1..a6f0ead18b 100644 --- a/src/slic3r/plugin/PluginConfig.cpp +++ b/src/slic3r/plugin/PluginConfig.cpp @@ -324,12 +324,6 @@ bool PluginConfig::dirty() const return m_dirty; } -std::string plugin_overrides_of(const Preset& preset) -{ - const auto* opt = dynamic_cast(preset.config.option(PLUGIN_OVERRIDES_OPTION_KEY)); - return opt == nullptr ? std::string() : opt->value; -} - bool parse_plugin_overrides(const std::string& raw, CapabilityConfigDocument& document, std::string& error) { document = CapabilityConfigDocument(); @@ -357,9 +351,9 @@ std::string serialize_plugin_overrides(const CapabilityConfigDocument& document) return document.empty() ? std::string() : document.serialize_entries().dump(); } -bool prune_stale_plugin_overrides(DynamicConfig& config) +bool prune_stale_plugin_overrides(DynamicConfig& config, const std::string& overrides_key) { - const auto* overrides_opt = dynamic_cast(config.option(PLUGIN_OVERRIDES_OPTION_KEY)); + const auto* overrides_opt = dynamic_cast(config.option(overrides_key)); if (overrides_opt == nullptr || overrides_opt->value.empty()) return false; @@ -397,7 +391,7 @@ bool prune_stale_plugin_overrides(DynamicConfig& config) if (!overrides.prune_unreferenced(referenced)) return false; - config.set_key_value(PLUGIN_OVERRIDES_OPTION_KEY, new ConfigOptionString(serialize_plugin_overrides(overrides))); + config.set_key_value(overrides_key, new ConfigOptionString(serialize_plugin_overrides(overrides))); return true; } @@ -470,27 +464,29 @@ EffectiveCapabilityConfig active_capability_config(const PluginCapabilityId& id) if (bundle != nullptr) { const std::string type_key = plugin_capability_type_to_string(id.type); + // The edited preset of each type that can hold plugin-backed options, keyed by its option list. + const std::pair*, const Preset*> scopes[] = { + {&Preset::print_options(), &bundle->prints.get_edited_preset()}, + {&Preset::printer_options(), &bundle->printers.get_edited_preset()}, + {&Preset::filament_options(), &bundle->filaments.get_edited_preset()}, + }; for (const auto& [key, def] : print_config_def.options) { if (def.plugin_type != type_key) continue; - - const auto& print_options = Preset::print_options(); - if (std::find(print_options.begin(), print_options.end(), key) != print_options.end()) { - preset = &bundle->prints.get_edited_preset(); + for (const auto& [options, edited] : scopes) + if (contains(*options, key)) { + preset = edited; + break; + } + if (preset != nullptr) break; - } - - const auto& printer_options = Preset::printer_options(); - if (std::find(printer_options.begin(), printer_options.end(), key) != printer_options.end()) { - preset = &bundle->printers.get_edited_preset(); - break; - } } } if (preset != nullptr) { + const auto* stored = dynamic_cast(preset->config.option(Preset::plugin_overrides_key(preset->type))); std::string error; - if (!parse_plugin_overrides(plugin_overrides_of(*preset), overrides, error)) { + if (!parse_plugin_overrides(stored == nullptr ? std::string() : stored->value, overrides, error)) { // Text we cannot read is not an override: log it and resolve against the base config. BOOST_LOG_TRIVIAL(error) << "Preset \"" << preset->name << "\": " << error; overrides = CapabilityConfigDocument(); diff --git a/src/slic3r/plugin/PluginConfig.hpp b/src/slic3r/plugin/PluginConfig.hpp index 2d541a58f4..523173af68 100644 --- a/src/slic3r/plugin/PluginConfig.hpp +++ b/src/slic3r/plugin/PluginConfig.hpp @@ -17,7 +17,6 @@ namespace Slic3r { -class Preset; class DynamicConfig; struct CapabilityConfigEntry { @@ -50,19 +49,15 @@ private: std::vector m_opaque_entries; }; -inline constexpr const char* PLUGIN_OVERRIDES_OPTION_KEY = "plugin_config_overrides"; - -std::string plugin_overrides_of(const Preset& preset); bool parse_plugin_overrides(const std::string& raw, CapabilityConfigDocument& document, std::string& error); std::string serialize_plugin_overrides(const CapabilityConfigDocument& document); -// Drops plugin_config_overrides entries for capabilities no longer named by any plugin-backed -// option's current value in `config` (e.g. slicing_pipeline_plugin cleared or switched to a -// different capability), and writes the result back if anything changed. Called wherever a -// plugin-backed option's value changes, so a saved preset never carries configuration for a -// capability it no longer references. Returns true if `config` was modified, so a caller holding a -// GUI field over PLUGIN_OVERRIDES_OPTION_KEY knows it must refresh that field's displayed value. -bool prune_stale_plugin_overrides(DynamicConfig& config); +// Drops override entries for capabilities no longer named by any plugin-backed option in `config` +// (e.g. slicing_pipeline_plugin cleared or pointed at another capability) and writes the result back +// to `overrides_key`. Call it wherever such an option changes, so a saved preset never carries +// configuration for a capability it no longer references. Returns true if `config` was modified, so a +// caller holding a GUI field over `overrides_key` knows to refresh it. +bool prune_stale_plugin_overrides(DynamicConfig& config, const std::string& overrides_key); struct EffectiveCapabilityConfig { diff --git a/tests/fff_print/test_slicing_pipeline_hook.cpp b/tests/fff_print/test_slicing_pipeline_hook.cpp index 341bd73465..d7ebd1fd60 100644 --- a/tests/fff_print/test_slicing_pipeline_hook.cpp +++ b/tests/fff_print/test_slicing_pipeline_hook.cpp @@ -218,10 +218,24 @@ TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pip CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required } +// Editing a slicing plugin's config (print_plugin_config_overrides) must re-run posSlice, where the +// plugin transforms each layer's geometry; otherwise the cached slice keeps the old config's result. +TEST_CASE("Changing print_plugin_config_overrides invalidates posSlice", "[slicing_pipeline]") { + Slic3r::Print print; Slic3r::Model model; + auto config = Slic3r::DynamicPrintConfig::full_print_config(); + init_print({cube(20)}, print, model, config); + print.process(); + REQUIRE(print.objects().front()->is_step_done(posSlice)); + config.set_key_value("print_plugin_config_overrides", + new Slic3r::ConfigOptionString("[{\"type\":\"slicing-pipeline\",\"name\":\"Twistify\",\"config\":{\"twist_deg_per_mm\":2.0}}]")); + print.apply(model, config); + CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required +} + #include // A similarity transform (rotate + uniform scale) applied to slices at Step.posSlice, matching -// what the Twistify sample (sandboxes/orca_twistify_plugin_example_any.py) does. This C++ analogue +// what the Twistify plugin (sandboxes/orca_twistify_plugin_any.py) does. This C++ analogue // rotates every region's slices a fixed 45 deg about the object's base-footprint center -- the same // seam and cascade the sample drives through the slices.set() + Layer::make_slices() path. Two // end-to-end invariants after process() confirm the approach: diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index b3bf1c15d3..351535dd9d 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -464,3 +464,27 @@ TEST_CASE("Profile validator flags dangling and renamed preset references", "[Pr } } +// Under a shared override key, the last preset merged into the full config overwrote the others', so an +// edited slicing-pipeline override never reached Print::apply's diff and re-configuring a plugin never +// re-sliced. Per-type keys make that collision impossible; guard the scoping here. +TEST_CASE("Plugin capability override keys are scoped per preset type", "[Preset][Plugin]") +{ + // Pin the key names: presets and 3mf files store them verbatim, so a rename is a format change. + CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINT) == std::string("print_plugin_config_overrides")); + CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINTER) == std::string("printer_plugin_config_overrides")); + CHECK(Preset::plugin_overrides_key(Preset::TYPE_FILAMENT) == std::string("filament_plugin_config_overrides")); + + // ...and each key lives on exactly its own preset type's option list, so no two ever share a slot. + const std::pair*> scopes[] = { + {Preset::TYPE_PRINT, &Preset::print_options()}, + {Preset::TYPE_PRINTER, &Preset::printer_options()}, + {Preset::TYPE_FILAMENT, &Preset::filament_options()}, + }; + for (const auto &owner : scopes) + for (const auto &scoped : scopes) { + const std::string key = Preset::plugin_overrides_key(scoped.first); + CAPTURE(owner.first, key); + CHECK(contains(*owner.second, key) == (owner.first == scoped.first)); + } +} +