Fix plugin configuration not taking effect, and improve the plugin config UI (#14944)

# 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

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
This commit is contained in:
SoftFever
2026-07-25 20:00:56 +08:00
committed by GitHub
parent d6cb667b89
commit 9a72dda79a
18 changed files with 1202 additions and 408 deletions

View File

@@ -48,6 +48,7 @@
<span id="statusText" class="status-text"></span> <span id="statusText" class="status-text"></span>
</footer> </footer>
</main> </main>
<script src="../js/plugin-config-ui.js"></script>
<script src="index.js"></script> <script src="index.js"></script>
</body> </body>
</html> </html>

View File

@@ -9,6 +9,12 @@ let selectedCapabilityType = "";
let selectedHasPresetOverride = false; let selectedHasPresetOverride = false;
let selectedReadOnly = 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) { function SafeJsonParse(text) {
try { try {
return JSON.parse(text); return JSON.parse(text);
@@ -193,6 +199,7 @@ function ClearCapabilityConfigView() {
if (custom) { if (custom) {
custom.hidden = true; custom.hidden = true;
custom.removeAttribute("srcdoc"); custom.removeAttribute("srcdoc");
customFrameLoaded = false;
} }
if (text) if (text)
text.value = ""; text.value = "";
@@ -248,8 +255,14 @@ function ApplyCapabilityConfig(payload) {
if (html) { if (html) {
if (custom) { if (custom) {
const context = OrcaConfigContext(payload, "preset");
custom.hidden = false; 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) if (editor)
editor.hidden = true; editor.hidden = true;
@@ -260,6 +273,7 @@ function ApplyCapabilityConfig(payload) {
if (custom) { if (custom) {
custom.hidden = true; custom.hidden = true;
custom.removeAttribute("srcdoc"); custom.removeAttribute("srcdoc");
customFrameLoaded = false;
} }
if (editor) if (editor)
editor.hidden = false; editor.hidden = false;
@@ -354,39 +368,6 @@ function ApplyCapabilityConfigSaved(payload) {
SetConfigValidation(""); 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 <script>: a stored "</script>" would close the tag early, so escape "<" — the
// literal stays valid JSON.
const seed = JSON.stringify(config).replace(/</g, "\\u003c");
const bridge = `<script>
(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) { function OnCustomConfigMessage(event) {
const custom = document.getElementById("configCustom"); const custom = document.getElementById("configCustom");
// Only the frame we created, and only while it is actually showing. // 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 // OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when
// sandboxed), and ignores anything else. // sandboxed), and ignores anything else.
window.addEventListener("message", OnCustomConfigMessage); window.addEventListener("message", OnCustomConfigMessage);
OrcaWatchThemeForFrame("configCustom");
SendMessage("request_capabilities"); SendMessage("request_capabilities");
}); });

View File

@@ -15,6 +15,7 @@
<script type="text/javascript" src="../../data/text.js"></script> <script type="text/javascript" src="../../data/text.js"></script>
<script type="text/javascript" src="../js/globalapi.js"></script> <script type="text/javascript" src="../js/globalapi.js"></script>
<script type="text/javascript" src="../js/common.js"></script> <script type="text/javascript" src="../js/common.js"></script>
<script src="../js/plugin-config-ui.js"></script>
<script src="./index.js"></script> <script src="./index.js"></script>
<script src="./plugin-sort.js"></script> <script src="./plugin-sort.js"></script>
<script src="../js/fuzzy-search.js"></script> <script src="../js/fuzzy-search.js"></script>

View File

@@ -90,6 +90,7 @@ function OnInit() {
// OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when // OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when
// sandboxed), and ignores anything else. // sandboxed), and ignores anything else.
window.addEventListener("message", OnCustomConfigMessage); window.addEventListener("message", OnCustomConfigMessage);
OrcaWatchThemeForFrame("configCustom");
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!event.target.closest(".ctx")) if (!event.target.closest(".ctx"))
@@ -1077,7 +1078,7 @@ function ApplyCapabilityConfig(payload) {
if (html) { if (html) {
if (custom) { if (custom) {
custom.hidden = false; custom.hidden = false;
custom.srcdoc = BuildCustomConfigDocument(html, config); custom.srcdoc = BuildCustomConfigDocument(html, config, OrcaConfigContext(payload, "global"));
} }
if (editor) if (editor)
editor.hidden = true; editor.hidden = true;
@@ -1178,39 +1179,6 @@ function ApplyCapabilityConfigSaved(payload) {
SetConfigValidation(""); 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 <script>: a stored "</script>" would close the tag early, so escape "<" — the
// literal stays valid JSON.
const seed = JSON.stringify(config).replace(/</g, "\\u003c");
const bridge = `<script>
(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) { function OnCustomConfigMessage(event) {
const custom = document.getElementById("configCustom"); const custom = document.getElementById("configCustom");
// Only the frame we created, and only while it is actually showing. // Only the frame we created, and only while it is actually showing.

View File

@@ -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
// <style id="orca-host-theme-vars">, but only on the top-level page, so a sandboxed config UI never
// sees it unless we hand it over. Relay that CSS verbatim instead of re-deriving it: C++ stays the
// only place the contract is spelled out, and the host re-themes the style in place, so reading it
// always yields the live theme.
function OrcaThemeSnapshot() {
const style = document.getElementById("orca-host-theme-vars");
return {
theme: document.documentElement.getAttribute("data-orca-theme") === "dark" ? "dark" : "light",
css: style ? style.textContent : ""
};
}
// Inlined into a <script> or a JSON payload: a stored "</script>" would close the tag early, so
// escape "<" — the literal stays valid JSON.
function OrcaInlineJson(value) {
return JSON.stringify(value).replace(/</g, "\\u003c");
}
// What a custom UI can learn about the surface it is edited on, kept to what changes the page's own
// behavior: "Restore defaults" writes the plugin's get_default_config() in the Plugins dialog but
// drops the preset's override in a preset dialog, so a page needs the scope to label its own button.
function OrcaConfigContext(payload, scope) {
return {
scope: scope,
readOnly: payload ? payload.read_only === true : false,
hasPresetOverride: payload ? payload.has_preset_override === true : false
};
}
// The whole host surface: read the config, save one, restore defaults, follow the theme, and be told
// when any of it lands.
function BuildCustomConfigDocument(html, config, context) {
const theme = OrcaThemeSnapshot();
const bridge = `<style id="orca-host-theme-vars"></style>
<script>
(function () {
var handlers = [];
var themeHandlers = [];
var current = ${OrcaInlineJson(config === undefined ? {} : config)};
var context = ${OrcaInlineJson(context || {})};
var theme = ${OrcaInlineJson(theme)};
function applyTheme(next) {
if (next && typeof next.css === "string") theme = next;
var style = document.getElementById("orca-host-theme-vars");
if (style) style.textContent = theme.css;
if (document.documentElement) document.documentElement.setAttribute("data-orca-theme", theme.theme);
themeHandlers.forEach(function (handler) {
try { handler(theme.theme); } catch (e) {}
});
}
window.orca = {
getConfig: function () { return current; },
saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); },
restoreDefaults: function () { parent.postMessage({ __orca: "restore" }, "*"); },
getContext: function () { return context; },
onConfig: function (cb) {
if (typeof cb !== "function") return;
handlers.push(cb);
try { cb(current); } catch (e) {}
},
onTheme: function (cb) {
if (typeof cb !== "function") return;
themeHandlers.push(cb);
try { cb(theme.theme); } catch (e) {}
}
};
window.addEventListener("message", function (event) {
if (!event.data) return;
if (event.data.__orca === "theme") { applyTheme(event.data.theme); return; }
if (event.data.__orca !== "config") return;
current = event.data.config || {};
if (event.data.context) context = event.data.context;
handlers.forEach(function (handler) {
try { handler(current); } catch (e) {}
});
});
applyTheme();
})();
<\/script>`;
return bridge + html;
}
// The host re-themes an open dialog in place (WebViewHostDialog::host_theme_apply_js), which a
// sandboxed child frame never sees. Relay it so a custom UI follows a light/dark switch live.
function OrcaWatchThemeForFrame(frameId) {
const relay = () => {
const frame = document.getElementById(frameId);
if (!frame || frame.hidden || !frame.contentWindow)
return;
frame.contentWindow.postMessage({ __orca: "theme", theme: OrcaThemeSnapshot() }, "*");
};
new MutationObserver(relay).observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-orca-theme"]
});
}

View File

@@ -3,18 +3,18 @@
# dependencies = ["numpy"] # dependencies = ["numpy"]
# #
# [tool.orcaslicer.plugin] # [tool.orcaslicer.plugin]
# name = "Orca Inspector Example" # name = "Orca Inspector"
# description = "An interactive panel that browses the whole orca.host read-only API and demos every orca.host.ui facility." # description = "An interactive panel that browses the whole orca.host read-only API and demos every orca.host.ui facility."
# author = "OrcaSlicer" # author = "SoftFever"
# version = "0.0.1" # version = "0.0.3"
# /// # ///
"""Orca Inspector — the worked example for `orca.host` and `orca.host.ui`. """Orca Inspector — a guided tour of `orca.host` and `orca.host.ui`.
Run it from the Plugins dialog. It opens a NON-MODAL window (OrcaSlicer stays Run it from the Plugins dialog. It opens a NON-MODAL window (OrcaSlicer stays
usable) with a sidebar of sections, each exercising one part of the API: usable) with a sidebar of sections, each exercising one part of the API:
Overview plater state, scene statistics, current printer/process/filaments Overview plater state, scene statistics, current printer/process/filaments
Presets every PresetCollection, preset flags, any preset's full config Presets every PresetCollection, filament slots, config viewer windows
Config the complete merged full_config as a searchable table Config the complete merged full_config as a searchable table
Model Model -> Object -> Volume/Instance tree with lazy mesh geometry Model Model -> Object -> Volume/Instance tree with lazy mesh geometry
Assembly objects grouped by module_name, per-instance assemble transforms Assembly objects grouped by module_name, per-instance assemble transforms
@@ -32,9 +32,10 @@ and heavy mesh geometry (zero-copy numpy arrays, world-space math, the little
point-cloud previews) only when you ask for it. "Refresh" drops the cache and point-cloud previews) only when you ask for it. "Refresh" drops the cache and
refetches the visible section. refetches the visible section.
Style rules the page follows (see the plugin docs): Style rules the pages follow (see the plugin docs):
- no hardcoded colors only the host-injected --orca-* theme variables, so - no hardcoded theme: BASE_CSS aliases the host-injected --orca-* variables
the panel matches light and dark mode automatically; (with fallbacks so the raw HTML previews in a plain browser) and styles the
body and native controls from them the host injects variables only;
- self-contained HTML (inline CSS/JS, no external resources); - self-contained HTML (inline CSS/JS, no external resources);
- on_message runs on the UI thread, so long work (the progress demos) is - on_message runs on the UI thread, so long work (the progress demos) is
offloaded to a thread and results are pushed back with win.post(). offloaded to a thread and results are pushed back with win.post().
@@ -45,6 +46,7 @@ transforms need bindings added in July 2026 — on older builds the panel shows
what is missing instead of failing. what is missing instead of failing.
""" """
import json
import re import re
import threading import threading
import time import time
@@ -125,6 +127,19 @@ def split_config_list(value):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# section builders # section builders
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def filament_slots(bundle):
"""One entry per filament slot: the mounted preset (current_filament_presets)
plus its color and material from the merged config."""
cfg = bundle.full_config_value
colors = split_config_list(cfg("filament_colour"))
types = split_config_list(cfg("filament_type"))
return [{"slot": i + 1,
"color": colors[i] if i < len(colors) else "",
"material": types[i] if i < len(types) else "",
"preset": preset_dict(preset) if preset else None}
for i, preset in enumerate(bundle.current_filament_presets())]
def build_overview(): def build_overview():
plater = orca.host.plater() plater = orca.host.plater()
model = orca.host.model() model = orca.host.model()
@@ -132,17 +147,7 @@ def build_overview():
objects = model.objects() objects = model.objects()
cfg = bundle.full_config_value cfg = bundle.full_config_value
colors = split_config_list(cfg("filament_colour")) filaments = filament_slots(bundle)
types = split_config_list(cfg("filament_type"))
filaments = []
for i, preset in enumerate(bundle.current_filament_presets()):
filaments.append({
"name": preset.name if preset else "<missing preset>",
"color": colors[i] if i < len(colors) else "",
"type": types[i] if i < len(types) else "",
"is_system": bool(preset.is_system) if preset else False,
"is_dirty": bool(preset.is_dirty) if preset else False,
})
printer = bundle.current_printer_preset() printer = bundle.current_printer_preset()
process = bundle.current_process_preset() process = bundle.current_process_preset()
@@ -189,13 +194,12 @@ def build_overview():
} }
# label -> how to reach the PresetCollection on the bundle # PresetCollection attribute -> label, in the order the Presets tab stacks its groups.
# The bundle also exposes sla_prints/sla_materials, omitted as OrcaSlicer has no SLA.
COLLECTIONS = [ COLLECTIONS = [
("prints", "Process"),
("printers", "Printer"), ("printers", "Printer"),
("filaments", "Filament"), ("filaments", "Filament"),
("sla_prints", "SLA print"), ("prints", "Process"),
("sla_materials", "SLA material"),
] ]
@@ -206,21 +210,25 @@ def build_presets():
coll = getattr(bundle, attr) coll = getattr(bundle, attr)
names = coll.preset_names() names = coll.preset_names()
selected = coll.selected_preset() selected = coll.selected_preset()
selected_name = coll.selected_preset_name()
shown = names[:MAX_PRESET_NAMES]
if selected_name and selected_name not in shown:
shown.insert(0, selected_name) # the active preset stays pickable past the cap
collections.append({ collections.append({
"key": attr, "key": attr,
"label": label, "label": label,
"size": coll.size(), "size": coll.size(),
"selected_name": coll.selected_preset_name(), "selected_name": selected_name,
# selected = as saved on disk; edited = selected + unsaved modifications # selected = as saved on disk; edited = selected + unsaved modifications
"selected": {"name": selected.name, "is_dirty": selected.is_dirty, "selected": {"name": selected.name, "is_dirty": selected.is_dirty,
"config_key_count": len(selected.config_keys())}, "config_key_count": len(selected.config_keys())},
"edited": preset_dict(coll.edited_preset()), "edited": preset_dict(coll.edited_preset()),
"names": names[:MAX_PRESET_NAMES], "names": shown,
"truncated": max(0, len(names) - MAX_PRESET_NAMES), "truncated": max(0, len(names) - MAX_PRESET_NAMES),
}) })
return { return {
"collections": collections, "collections": collections,
"filament_names": bundle.current_filament_preset_names(), "filament_slots": filament_slots(bundle),
} }
@@ -231,8 +239,7 @@ def build_preset_config(collection_key, name):
preset = getattr(bundle, collection_key).find_preset(name) preset = getattr(bundle, collection_key).find_preset(name)
if preset is None: if preset is None:
raise ValueError(f"preset {name!r} not found") raise ValueError(f"preset {name!r} not found")
return {"preset": preset_dict(preset), return config_rows(preset.config_keys(), preset.config_value)
"rows": config_rows(preset.config_keys(), preset.config_value)}
def build_config(): def build_config():
@@ -467,102 +474,149 @@ SECTION_BUILDERS = {
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# the page — sidebar app, themed exclusively via the injected --orca-* vars # the pages — themed via BASE_CSS, which aliases the injected --orca-* vars
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# The host injects theme *variables* only (never element styles), so every page owns
# its base look. The fallbacks keep the raw HTML previewable in a plain browser.
BASE_CSS = r"""
:root {
--bg: var(--orca-bg, #ffffff);
--fg: var(--orca-fg, #1f2429);
--muted: var(--orca-muted, #6b7580);
--border: var(--orca-border, #d9dee3);
--accent: var(--orca-accent, #009688);
--accent-fg: var(--orca-accent-fg, #ffffff);
--ui: var(--orca-font, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: var(--orca-bg, #2b2d30);
--fg: var(--orca-fg, #e4e6e8);
--muted: var(--orca-muted, #9aa0a6);
--border: var(--orca-border, #3d4043);
}
}
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:13px/1.45 var(--ui); }
button { font:inherit; padding:4px 12px; cursor:pointer; border-radius:6px;
background:var(--accent); color:var(--accent-fg); border:1px solid var(--accent); }
button.secondary { background:transparent; color:var(--fg); border-color:var(--border); }
button:disabled { opacity:.55; cursor:default; }
input, select { font:inherit; color:var(--fg); background:var(--bg);
border:1px solid var(--border); border-radius:6px; padding:4px 8px; }
:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
"""
# Identity hues for the preset collections, shared by the main page's group styling and
# the config viewer window. Deliberately the same in light and dark mode: they say what a
# card is, not what theme is on.
HUES = {"printers": "#9a6ee8", "filaments": "#ee8f41", "prints": "#4e8df6"}
HUE_CSS = "".join(f" .hue-{key} {{ --hue:{color}; }}\n" for key, color in HUES.items())
# The filterable key/value config table, shared by the Config tab and the viewer window.
CFG_TABLE_CSS = r"""
table.cfg { width:100%; border-collapse:collapse; font-size:12px; }
table.cfg th { text-align:left; font-size:11px; letter-spacing:.07em;
text-transform:uppercase; color:var(--muted); padding-bottom:4px; }
table.cfg td:first-child { font-family:var(--mono); white-space:nowrap;
vertical-align:top; }
table.cfg td.val { font-family:var(--mono); white-space:pre-wrap; word-break:break-word; }
td.val .more { color:var(--accent); cursor:pointer; }
"""
PAGE = r"""<!DOCTYPE html> PAGE = r"""<!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<style> <style>""" + BASE_CSS + r"""
:root { --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } body { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
* { box-sizing: border-box; }
body { margin:0; height:100vh; display:flex; flex-direction:column; overflow:hidden; }
header { flex:none; display:flex; align-items:center; gap:10px; header { flex:none; display:flex; align-items:center; gap:10px;
padding:10px 16px; border-bottom:1px solid var(--orca-border); } padding:10px 16px; border-bottom:1px solid var(--border); }
header h1 { font-size:15px; margin:0; } header h1 { font-size:15px; margin:0; }
#stamp { flex:1; color:var(--orca-muted); font-size:12px; } #stamp { flex:1; color:var(--muted); font-size:12px; }
button.secondary { background:transparent; color:var(--orca-fg);
border-color:var(--orca-border); }
button.small { padding:2px 10px; font-size:12px; } button.small { padding:2px 10px; font-size:12px; }
main { flex:1; display:flex; min-height:0; } main { flex:1; display:flex; min-height:0; }
nav { flex:none; width:150px; padding:8px 0; overflow-y:auto; nav { flex:none; width:150px; padding:8px 0; overflow-y:auto;
border-right:1px solid var(--orca-border); } border-right:1px solid var(--border); }
nav .item { padding:8px 14px; cursor:pointer; user-select:none; nav .item { padding:8px 14px; cursor:pointer; user-select:none;
color:var(--orca-muted); border-left:3px solid transparent; } color:var(--muted); border-left:3px solid transparent; }
nav .item:hover { color:var(--orca-fg); } nav .item:hover { color:var(--fg); }
nav .item.active { color:var(--orca-fg); font-weight:600; nav .item.active { color:var(--fg); font-weight:600;
border-left-color:var(--orca-accent); } border-left-color:var(--accent); }
nav .glyph { display:inline-block; width:1.5em; } nav .glyph { display:inline-block; width:1.5em; }
#content { flex:1; overflow:auto; padding:14px 16px 28px; } #content { flex:1; overflow:auto; padding:14px 16px 28px; }
.tiles { display:grid; grid-template-columns:repeat(auto-fill,minmax(108px,1fr)); .tiles { display:grid; grid-template-columns:repeat(auto-fill,minmax(108px,1fr));
gap:10px; margin-bottom:12px; } gap:10px; margin-bottom:12px; }
.tile { border:1px solid var(--orca-border); border-radius:8px; padding:10px 12px; } .tile { border:1px solid var(--border); border-radius:8px; padding:10px 12px; }
.tile .v { font-size:20px; font-weight:600; font-variant-numeric:tabular-nums; } .tile .v { font-size:20px; font-weight:600; font-variant-numeric:tabular-nums; }
.tile .l { font-size:11px; color:var(--orca-muted); margin-top:2px; } .tile .l { font-size:11px; color:var(--muted); margin-top:2px; }
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:12px; } .cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:12px; }
.card { border:1px solid var(--orca-border); border-radius:8px; .card { border:1px solid var(--border); border-radius:8px;
padding:12px 14px; min-width:0; } padding:12px 14px; min-width:0; }
.card h3 { margin:0 0 8px; font-size:11px; letter-spacing:.07em; .card h3 { margin:0 0 8px; font-size:11px; letter-spacing:.07em;
text-transform:uppercase; color:var(--orca-muted); } text-transform:uppercase; color:var(--muted); }
.card.wide { grid-column:1 / -1; } .card.wide { grid-column:1 / -1; }
""" + HUE_CSS + r""" .card.hued { border-top:3px solid var(--hue); }
.dot { display:inline-block; width:9px; height:9px; border-radius:50%;
background:var(--hue); margin-right:6px; }
.pgroup { margin-bottom:18px; }
.pgroup-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:8px; }
.pgroup-head h2 { margin:0; font-size:12px; letter-spacing:.07em; text-transform:uppercase; }
.pgroup-head select { flex:0 1 260px; min-width:120px; }
.spacer { flex:1; }
.kv { display:grid; grid-template-columns:minmax(110px,max-content) 1fr; .kv { display:grid; grid-template-columns:minmax(110px,max-content) 1fr;
gap:3px 14px; font-size:12px; } gap:3px 14px; font-size:12px; }
.kv .k { color:var(--orca-muted); } .kv .k { color:var(--muted); }
.kv .v { font-family:var(--mono); word-break:break-word; } .kv .v { font-family:var(--mono); word-break:break-word; }
.badge { display:inline-block; border:1px solid var(--orca-border); .badge { display:inline-block; border:1px solid var(--border);
color:var(--orca-muted); border-radius:999px; padding:1px 8px; color:var(--muted); border-radius:999px; padding:1px 8px;
font-size:11px; margin:1px 3px 1px 0; white-space:nowrap; } font-size:11px; margin:1px 3px 1px 0; white-space:nowrap; }
.badge.on { background:var(--orca-accent); border-color:var(--orca-accent); .badge.on { background:var(--accent); border-color:var(--accent);
color:var(--orca-accent-fg); } color:var(--accent-fg); }
.swatch { display:inline-block; width:12px; height:12px; border-radius:3px; .swatch { display:inline-block; width:12px; height:12px; border-radius:3px;
border:1px solid var(--orca-border); vertical-align:-2px; margin-right:5px; } border:1px solid var(--border); vertical-align:-2px; margin-right:5px; }
.toolbar { display:flex; gap:8px; align-items:center; margin-bottom:10px; } .toolbar { display:flex; gap:8px; align-items:center; margin-bottom:10px; }
.toolbar input { flex:1; max-width:340px; } .toolbar input { flex:1; max-width:340px; }
.count { color:var(--orca-muted); font-size:12px; } .count { color:var(--muted); font-size:12px; }
""" + CFG_TABLE_CSS + r"""
table.cfg { width:100%; border-collapse:collapse; font-size:12px; }
table.cfg td:first-child { font-family:var(--mono); white-space:nowrap;
vertical-align:top; }
table.cfg td.val { font-family:var(--mono); white-space:pre-wrap; word-break:break-word; }
td.val .more { color:var(--orca-accent); cursor:pointer; }
.node { margin:1px 0; } .node { margin:1px 0; }
.nhead { display:flex; align-items:center; gap:7px; padding:4px 8px; .nhead { display:flex; align-items:center; gap:7px; padding:4px 8px;
border-radius:6px; cursor:pointer; user-select:none; flex-wrap:wrap; } border-radius:6px; cursor:pointer; user-select:none; flex-wrap:wrap; }
.nhead:hover { background:var(--orca-border); } .nhead:hover { background:var(--border); }
.twist { width:1em; flex:none; color:var(--orca-muted); font-size:10px; } .twist { width:1em; flex:none; color:var(--muted); font-size:10px; }
.nname { font-weight:600; } .nname { font-weight:600; }
.nbody { display:none; margin-left:14px; padding:4px 0 4px 12px; .nbody { display:none; margin-left:14px; padding:4px 0 4px 12px;
border-left:1px dotted var(--orca-border); } border-left:1px dotted var(--border); }
.node.open > .nhead .twist { transform:rotate(90deg); } .node.open > .nhead .twist { transform:rotate(90deg); }
.node.open > .nbody { display:block; } .node.open > .nbody { display:block; }
.subhead { margin:8px 0 4px; font-size:11px; letter-spacing:.07em; .subhead { margin:8px 0 4px; font-size:11px; letter-spacing:.07em;
text-transform:uppercase; color:var(--orca-muted); } text-transform:uppercase; color:var(--muted); }
.banner { border:1px solid var(--orca-border); border-left:3px solid var(--orca-accent); .banner { border:1px solid var(--border); border-left:3px solid var(--accent);
border-radius:6px; padding:9px 12px; margin-bottom:12px; font-size:12px; } border-radius:6px; padding:9px 12px; margin-bottom:12px; font-size:12px; }
.muted { color:var(--orca-muted); } .muted { color:var(--muted); }
.mono { font-family:var(--mono); } .mono { font-family:var(--mono); }
canvas.preview { width:100%; height:160px; border:1px solid var(--orca-border); canvas.preview { width:100%; height:160px; border:1px solid var(--border);
border-radius:6px; } border-radius:6px; }
.previews { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:8px; } .previews { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:8px; }
.previews .cap { font-size:11px; color:var(--orca-muted); text-align:center; margin-top:2px; } .previews .cap { font-size:11px; color:var(--muted); text-align:center; margin-top:2px; }
.log { font-family:var(--mono); font-size:12px; border:1px solid var(--orca-border); .log { font-family:var(--mono); font-size:12px; border:1px solid var(--border);
border-radius:6px; padding:8px 10px; max-height:190px; overflow:auto; } border-radius:6px; padding:8px 10px; max-height:190px; overflow:auto; }
.log div { padding:1px 0; } .log div { padding:1px 0; }
fieldset { border:1px solid var(--orca-border); border-radius:6px; margin:0 0 10px; } fieldset { border:1px solid var(--border); border-radius:6px; margin:0 0 10px; }
legend { color:var(--orca-muted); font-size:11px; padding:0 6px; } legend { color:var(--muted); font-size:11px; padding:0 6px; }
.frow { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:6px 0; } .frow { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:6px 0; }
label { font-size:12px; color:var(--orca-muted); } label { font-size:12px; color:var(--muted); }
</style> </style>
</head> </head>
<body> <body>
@@ -588,7 +642,7 @@ const SECTIONS = [
['assembly', '', 'Assembly'], ['assembly', '', 'Assembly'],
['uikit', '', 'UI Toolkit'], ['uikit', '', 'UI Toolkit'],
]; ];
const S = { current:'overview', cache:{}, busy:{}, colors:[], uiLog:[] }; const S = { current:'overview', cache:{}, busy:{}, colors:[], uiLog:[], gen:0 };
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const esc = s => String(s == null ? '' : s) const esc = s => String(s == null ? '' : s)
@@ -598,6 +652,9 @@ const deg = r => num(r * 180 / Math.PI, 1) + '°';
const vec3 = (v, d=2) => v ? '(' + v.map(c => num(c, d)).join(', ') + ')' : ''; const vec3 = (v, d=2) => v ? '(' + v.map(c => num(c, d)).join(', ') + ')' : '';
const yn = b => b ? 'yes' : 'no'; const yn = b => b ? 'yes' : 'no';
const badge = (label, on) => '<span class="badge' + (on ? ' on' : '') + '">' + esc(label) + '</span>'; const badge = (label, on) => '<span class="badge' + (on ? ' on' : '') + '">' + esc(label) + '</span>';
// Colors land in a style attribute, so only literal hex colors pass through.
const swatch = c => /^#[0-9a-f]{3,8}$/i.test(c || '')
? '<span class="swatch" style="background:' + c + '"></span>' : '';
const kv = rows => '<div class="kv">' + rows.map(([k, v]) => const kv = rows => '<div class="kv">' + rows.map(([k, v]) =>
'<div class="k">' + esc(k) + '</div><div class="v">' + v + '</div>').join('') + '</div>'; '<div class="k">' + esc(k) + '</div><div class="v">' + v + '</div>').join('') + '</div>';
const bboxRows = (label, bb) => bb ? [ const bboxRows = (label, bb) => bb ? [
@@ -608,7 +665,8 @@ const bboxRows = (label, bb) => bb ? [
/* ------------------------------------------------------- nav + fetching */ /* ------------------------------------------------------- nav + fetching */
function buildNav() { function buildNav() {
$('nav').innerHTML = SECTIONS.map(([key, glyph, label]) => $('nav').innerHTML = SECTIONS.map(([key, glyph, label]) =>
'<div class="item" id="nav-' + key + '" onclick="show(\'' + key + '\')">' + '<div class="item" id="nav-' + key + '" role="button" tabindex="0" ' +
'onclick="show(\'' + key + '\')">' +
'<span class="glyph">' + glyph + '</span>' + label + '</div>').join(''); '<span class="glyph">' + glyph + '</span>' + label + '</div>').join('');
} }
function show(key) { function show(key) {
@@ -622,9 +680,10 @@ function show(key) {
function fetchSection(key) { function fetchSection(key) {
S.busy[key] = true; S.busy[key] = true;
$('content').innerHTML = '<p class="muted">Loading ' + esc(key) + '…</p>'; $('content').innerHTML = '<p class="muted">Loading ' + esc(key) + '…</p>';
orca.postMessage({ command:'fetch', section:key }); orca.postMessage({ command:'fetch', section:key, gen:S.gen });
} }
function refresh() { function refresh() {
S.gen++; // replies to fetches from before this point are dropped
S.cache = {}; S.cache = {};
S.busy = {}; S.busy = {};
show(S.current); show(S.current);
@@ -660,13 +719,12 @@ function renderOverview(d) {
[t.config_keys, 'config keys'], [t.config_keys, 'config keys'],
].map(([v, l]) => '<div class="tile"><div class="v">' + v + '</div><div class="l">' + l + '</div></div>').join(''); ].map(([v, l]) => '<div class="tile"><div class="v">' + v + '</div><div class="l">' + l + '</div></div>').join('');
const filaments = d.setup.filaments.map((f, i) => const filaments = d.setup.filaments.map(f =>
'<div style="margin:3px 0">' + '<div style="margin:3px 0">' + swatch(f.color) +
(f.color ? '<span class="swatch" style="background:' + esc(f.color) + '"></span>' : '') + '<b>' + f.slot + '</b> ' + esc(f.preset ? f.preset.name : '<missing preset>') +
'<b>' + (i + 1) + '</b> ' + esc(f.name) + (f.material ? ' <span class="muted">' + esc(f.material) + '</span>' : '') +
(f.type ? ' <span class="muted">' + esc(f.type) + '</span>' : '') + (f.preset ? badge(f.preset.is_system ? 'system' : 'user', f.preset.is_system) +
badge(f.is_system ? 'system' : 'user', f.is_system) + (f.preset.is_dirty ? badge('modified', true) : '') : '') +
(f.is_dirty ? badge('modified', true) : '') +
'</div>').join('') || '<span class="muted">none</span>'; '</div>').join('') || '<span class="muted">none</span>';
return '<div class="tiles">' + tiles + '</div><div class="cards">' + return '<div class="tiles">' + tiles + '</div><div class="cards">' +
@@ -709,35 +767,65 @@ function presetBadges(p) {
(p.is_visible ? '' : badge('hidden', false)); (p.is_visible ? '' : badge('hidden', false));
} }
function renderPresets(d) { function renderPresets(d) {
return '<div class="banner">Each card is one <span class="mono">host.PresetCollection</span>. ' + const detailCard = g => {
'“Edited” is the selected preset plus any unsaved changes. Pick any preset ' + const p = g.edited;
'and load its complete config via <span class="mono">find_preset().config_value()</span>.</div>' + return '<div class="cards"><div class="card hued">' +
'<div class="cards">' + d.collections.map(c => { '<div style="margin-bottom:6px"><b>' + esc(p.name) + '</b> ' + presetBadges(p) + '</div>' +
const p = c.edited; kv([['label()', esc(p.label)], ['alias', esc(p.alias) || ''],
const options = c.names.map(n => // explicit value= so odd whitespace in names survives ['type', esc(p.type)], ['bundle', esc(p.bundle_id) || ''],
'<option value="' + esc(n) + '"' + (n === c.selected_name ? ' selected' : '') + '>' + ['edited_preset()', p.config_key_count + ' keys' +
(p.is_dirty ? ' · has unsaved changes' : ' · same as saved')],
['selected_preset()', esc(g.selected.name) + ' · ' + g.selected.config_key_count + ' keys'],
['file', '<span class="muted">' + esc(p.file) + '</span>']]) +
'</div></div>';
};
const slotCard = (s, g) => {
const p = s.preset;
if (!p) return '<div class="card hued"><h3>Slot ' + s.slot + '</h3>' +
'<p class="muted">no preset mounted</p></div>';
return '<div class="card hued"><h3>Slot ' + s.slot + '</h3>' +
'<div style="margin-bottom:6px">' + swatch(s.color) + '<b>' + esc(p.name) + '</b> ' +
badge(p.is_system ? 'system' : 'user', p.is_system) +
(p.is_dirty ? badge('modified', true) : '') +
(p.name === g.selected_name ? badge('editing', true) : '') + '</div>' +
kv([['material', esc(s.material) || ''],
['alias', esc(p.alias) || ''],
['config keys', p.config_key_count],
['file', '<span class="muted">' + esc(p.file) + '</span>']]) +
'<div class="frow" style="margin-top:8px"><button class="small secondary" ' +
'data-act="pcfg" data-coll="filaments" data-name="' + esc(p.name) + '">View config</button></div>' +
'</div>';
};
return '<div class="banner">Each group is one <span class="mono">host.PresetCollection</span>; ' +
'the Filament group shows every slot from <span class="mono">current_filament_presets()</span>, ' +
'and “editing” marks the preset open in its settings tab. Pick any preset in a group ' +
'header — “View config” opens its complete config (via ' +
'<span class="mono">find_preset().config_value()</span>) in its own window.</div>' +
d.collections.map(g => {
const options = g.names.map(n => // explicit value= so odd whitespace in names survives
'<option value="' + esc(n) + '"' + (n === g.selected_name ? ' selected' : '') + '>' +
esc(n) + '</option>').join(''); esc(n) + '</option>').join('');
return '<div class="card"><h3>' + esc(c.label) + ' · ' + c.size + ' presets</h3>' + const cards = g.key === 'filaments'
'<div style="margin-bottom:6px"><b>' + esc(p.name) + '</b> ' + presetBadges(p) + '</div>' + ? '<div class="cards">' + (d.filament_slots.map(s => slotCard(s, g)).join('') ||
kv([['label()', esc(p.label)], ['alias', esc(p.alias) || ''], '<p class="muted">no filament slots</p>') + '</div>'
['type', esc(p.type)], ['bundle', esc(p.bundle_id) || ''], : detailCard(g);
['edited_preset()', p.config_key_count + ' keys' + return '<div class="pgroup hue-' + g.key + '">' +
(p.is_dirty ? ' · has unsaved changes' : ' · same as saved')], '<div class="pgroup-head"><span class="dot"></span><h2>' + esc(g.label) + '</h2>' +
['selected_preset()', esc(c.selected.name) + ' · ' + c.selected.config_key_count + ' keys'], '<span class="count">' + g.size + ' presets</span><span class="spacer"></span>' +
['file', '<span class="muted">' + esc(p.file) + '</span>']]) + '<select id="sel-' + g.key + '">' + options + '</select>' +
'<div class="frow" style="margin-top:8px">' + '<button class="small" data-act="pcfg" data-coll="' + g.key + '">View config</button></div>' +
'<select id="sel-' + c.key + '" style="flex:1;min-width:0">' + options + '</select>' + cards +
'<button class="small" data-act="pcfg" data-coll="' + c.key + '">View config</button>' + (g.truncated ? '<p class="muted">select capped, ' + g.truncated + ' more not listed</p>' : '') +
'</div>' + // the config itself opens in a viewer window; this slot only shows failures
(c.truncated ? '<p class="muted">list capped, ' + c.truncated + ' more not shown</p>' : '') + '<div id="pcfg-' + g.key + '"></div></div>';
'<div id="pcfg-' + c.key + '"></div></div>'; }).join('');
}).join('') + '</div>';
} }
function configTable(rows, id) { function configTable(rows, id) {
const body = rows.map(r => { const body = rows.map(r => {
const long = r.v.length > 160; const long = r.v.length > 160;
const shown = long ? esc(r.v.slice(0, 160)) + '<span class="more" data-act="expand"> … show all</span>' const shown = long ? esc(r.v.slice(0, 160)) +
'<span class="more" data-act="expand" role="button" tabindex="0"> … show all</span>'
: esc(r.v); : esc(r.v);
return '<tr data-k="' + esc(r.k.toLowerCase()) + '"><td>' + esc(r.k) + '</td>' + return '<tr data-k="' + esc(r.k.toLowerCase()) + '"><td>' + esc(r.k) + '</td>' +
'<td class="val" data-full="' + esc(r.v) + '">' + shown + '</td></tr>'; '<td class="val" data-full="' + esc(r.v) + '">' + shown + '</td></tr>';
@@ -746,37 +834,36 @@ function configTable(rows, id) {
} }
function renderConfig(d) { function renderConfig(d) {
return '<div class="toolbar"><input id="cfg-search" placeholder="Filter ' + d.count + return '<div class="toolbar"><input id="cfg-search" placeholder="Filter ' + d.count +
' keys…" oninput="filterConfig(this.value)">' + ' keys…" oninput="filterTable(\'cfg-table\', this.value, \'cfg-count\')">' +
'<span class="count" id="cfg-count">' + d.count + ' keys</span></div>' + '<span class="count" id="cfg-count">' + d.count + ' keys</span></div>' +
'<div class="banner">The merged result of printer + process + filament presets — ' + '<div class="banner">The merged result of printer + process + filament presets — ' +
'exactly what <span class="mono">preset_bundle().full_config_value(key)</span> returns for ' + 'exactly what <span class="mono">preset_bundle().full_config_value(key)</span> returns for ' +
'every key in <span class="mono">full_config_keys()</span>.</div>' + 'every key in <span class="mono">full_config_keys()</span>.</div>' +
configTable(d.rows, 'cfg-table'); configTable(d.rows, 'cfg-table');
} }
function filterConfig(q) { function filterTable(tableId, q, countId) {
q = q.trim().toLowerCase(); q = q.trim().toLowerCase();
let visible = 0; let visible = 0;
document.querySelectorAll('#cfg-table tr[data-k]').forEach(tr => { document.querySelectorAll('#' + tableId + ' tr[data-k]').forEach(tr => {
const hit = !q || tr.dataset.k.includes(q) || const hit = !q || tr.dataset.k.includes(q) ||
tr.querySelector('.val').dataset.full.toLowerCase().includes(q); tr.querySelector('.val').dataset.full.toLowerCase().includes(q);
tr.style.display = hit ? '' : 'none'; tr.style.display = hit ? '' : 'none';
if (hit) visible++; if (hit) visible++;
}); });
$('cfg-count').textContent = visible + ' keys'; if (countId) $(countId).textContent = visible + ' keys';
} }
function extruderChip(id) { function extruderChip(id) {
if (id == null || id < 1) return '<span class="badge">extruder —</span>'; if (id == null || id < 1) return '<span class="badge">extruder —</span>';
const color = S.colors[id - 1]; return '<span class="badge">' + swatch(S.colors[id - 1]) + 'extruder ' + id + '</span>';
return '<span class="badge">' + (color ? '<span class="swatch" style="background:' +
esc(color) + '"></span>' : '') + 'extruder ' + id + '</span>';
} }
const VOL_GLYPH = { ModelPart:'', NegativeVolume:'', ParameterModifier:'', const VOL_GLYPH = { ModelPart:'', NegativeVolume:'', ParameterModifier:'',
SupportEnforcer:'', SupportBlocker:'' }; SupportEnforcer:'', SupportBlocker:'' };
function node(head, body, open) { function node(head, body, open) {
return '<div class="node' + (open ? ' open' : '') + '">' + return '<div class="node' + (open ? ' open' : '') + '">' +
'<div class="nhead" data-act="toggle"><span class="twist">▶</span>' + head + '</div>' + '<div class="nhead" data-act="toggle" role="button" tabindex="0" aria-expanded="' +
!!open + '"><span class="twist">▶</span>' + head + '</div>' +
'<div class="nbody">' + body + '</div></div>'; '<div class="nbody">' + body + '</div></div>';
} }
function paintedBadges(p) { function paintedBadges(p) {
@@ -969,7 +1056,7 @@ function drawPoints(canvas, pts, ax, ay) {
PREVIEWS[canvas.id] = { pts, ax, ay }; PREVIEWS[canvas.id] = { pts, ax, ay };
const scaleDpr = window.devicePixelRatio || 1; const scaleDpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth * scaleDpr, h = canvas.clientHeight * scaleDpr; const w = canvas.clientWidth * scaleDpr, h = canvas.clientHeight * scaleDpr;
if (!w || !h) return; // inside a collapsed node; repainted on toggle if (!w || !h) { delete canvas.dataset.painted; return; } // collapsed; repainted on toggle
canvas.dataset.painted = '1'; canvas.dataset.painted = '1';
canvas.width = w; canvas.height = h; canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@@ -983,7 +1070,7 @@ function drawPoints(canvas, pts, ax, ay) {
(h - 2 * pad) / Math.max(maxY - minY, 1e-6)); (h - 2 * pad) / Math.max(maxY - minY, 1e-6));
const ox = (w - (maxX - minX) * scale) / 2, oy = (h - (maxY - minY) * scale) / 2; const ox = (w - (maxX - minX) * scale) / 2, oy = (h - (maxY - minY) * scale) / 2;
const style = getComputedStyle(document.body); const style = getComputedStyle(document.body);
ctx.strokeStyle = style.getPropertyValue('--orca-accent'); ctx.strokeStyle = style.getPropertyValue('--accent');
ctx.strokeRect(ox, oy, (maxX - minX) * scale, (maxY - minY) * scale); ctx.strokeRect(ox, oy, (maxX - minX) * scale, (maxY - minY) * scale);
ctx.fillStyle = style.color; ctx.fillStyle = style.color;
ctx.globalAlpha = 0.55; ctx.globalAlpha = 0.55;
@@ -992,6 +1079,21 @@ function drawPoints(canvas, pts, ax, ay) {
ctx.fillRect(ox + (p[ax] - minX) * scale - r / 2, ctx.fillRect(ox + (p[ax] - minX) * scale - r / 2,
h - oy - (p[ay] - minY) * scale - r / 2, r, r); h - oy - (p[ay] - minY) * scale - r / 2, r, r);
} }
// A live theme switch re-stamps data-orca-theme and a resize leaves the bitmap at its
// old size both need every known preview repainted.
function repaintPreviews() {
document.querySelectorAll('canvas.preview').forEach(c => {
const p = PREVIEWS[c.id];
if (p) drawPoints(c, p.pts, p.ax, p.ay);
});
}
new MutationObserver(repaintPreviews)
.observe(document.documentElement, { attributes:true, attributeFilter:['data-orca-theme'] });
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(repaintPreviews, 150);
});
/* ------------------------------------------------------------ UI toolkit */ /* ------------------------------------------------------------ UI toolkit */
function renderUikit() { function renderUikit() {
@@ -1052,6 +1154,7 @@ $('content').addEventListener('click', e => {
if (act === 'toggle') { if (act === 'toggle') {
const node = t.parentElement; const node = t.parentElement;
node.classList.toggle('open'); node.classList.toggle('open');
t.setAttribute('aria-expanded', node.classList.contains('open'));
// Repaint any preview whose canvas was hidden (zero-size) when its data arrived. // Repaint any preview whose canvas was hidden (zero-size) when its data arrived.
node.querySelectorAll('canvas.preview').forEach(c => { node.querySelectorAll('canvas.preview').forEach(c => {
const p = PREVIEWS[c.id]; const p = PREVIEWS[c.id];
@@ -1062,9 +1165,10 @@ $('content').addEventListener('click', e => {
t.textContent = 'Loading…'; t.textContent = 'Loading…';
orca.postMessage({ command:'mesh', object:+t.dataset.o, volume:+t.dataset.v }); orca.postMessage({ command:'mesh', object:+t.dataset.o, volume:+t.dataset.v });
} else if (act === 'pcfg') { } else if (act === 'pcfg') {
const coll = t.dataset.coll; const coll = t.dataset.coll; // slot buttons carry their preset in data-name
orca.postMessage({ command:'preset_config', collection:coll, orca.postMessage({ command:'preset_config', collection:coll,
name:$('sel-' + coll).value }); name:t.dataset.name !== undefined ? t.dataset.name
: $('sel-' + coll).value });
} else if (act === 'expand') { } else if (act === 'expand') {
const td = t.closest('td'); const td = t.closest('td');
td.textContent = td.dataset.full; td.textContent = td.dataset.full;
@@ -1072,11 +1176,22 @@ $('content').addEventListener('click', e => {
uiAction(t.dataset.ui); uiAction(t.dataset.ui);
} }
}); });
// Keyboard parity for the click-only elements (nav items, tree toggles, "show all").
// Real form controls are skipped so they keep their native keys.
document.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
if (/^(BUTTON|INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return;
const t = e.target.closest('.item, [data-act]');
if (!t) return;
e.preventDefault();
t.click();
});
/* ------------------------------------------------------------- messages */ /* ------------------------------------------------------------- messages */
orca.onMessage(msg => { orca.onMessage(msg => {
if (!msg || !msg.command) return; if (!msg || !msg.command) return;
if (msg.command === 'section') { if (msg.command === 'section') {
if (msg.gen !== S.gen) return; // raced a Refresh; the refetch is in flight
S.cache[msg.section] = msg; S.cache[msg.section] = msg;
S.busy[msg.section] = false; S.busy[msg.section] = false;
stamp(); stamp();
@@ -1085,14 +1200,15 @@ orca.onMessage(msg => {
const container = $('mesh-' + msg.object + '-' + msg.volume); const container = $('mesh-' + msg.object + '-' + msg.volume);
if (!container) return; if (!container) return;
if (msg.ok) renderMeshDetail(container, msg.data); if (msg.ok) renderMeshDetail(container, msg.data);
else container.innerHTML = '<span class="muted">⚠ ' + esc(msg.error) + '</span>'; else container.innerHTML =
'<button class="small secondary" data-act="mesh" data-o="' + msg.object +
'" data-v="' + msg.volume + '">Retry</button> <span class="muted">⚠ ' +
esc(msg.error) + '</span>';
} else if (msg.command === 'preset_config') { } else if (msg.command === 'preset_config') {
// Success opened a viewer window; this slot only shows (or clears) failures.
const container = $('pcfg-' + msg.collection); const container = $('pcfg-' + msg.collection);
if (!container) return; if (!container) return;
container.innerHTML = msg.ok container.innerHTML = msg.ok ? '' : '<p class="muted">⚠ ' + esc(msg.error) + '</p>';
? '<div class="subhead">' + esc(msg.name) + ' · ' + msg.data.rows.length +
' keys</div>' + configTable(msg.data.rows, 'pcfg-table-' + msg.collection)
: '<span class="muted">⚠ ' + esc(msg.error) + '</span>';
} else if (msg.command === 'ui_result') { } else if (msg.command === 'ui_result') {
addLog(msg.ok ? '' + msg.action + ': ' + msg.result addLog(msg.ok ? '' + msg.action + ': ' + msg.result
: '' + msg.action + ' failed: ' + msg.error); : '' + msg.action + ' failed: ' + msg.error);
@@ -1110,28 +1226,33 @@ show('overview');
# The modal page demoed from the UI Toolkit tab: create_window(style=WINDOW_MODAL) # The modal page demoed from the UI Toolkit tab: create_window(style=WINDOW_MODAL)
# keeps the handle alive for callbacks; orca.submit(payload) invokes on_submit. # keeps the handle alive for callbacks; orca.submit(payload) invokes on_submit.
MODAL_PAGE = r"""<!DOCTYPE html> MODAL_PAGE = r"""<!DOCTYPE html>
<html><head><meta charset="utf-8"></head> <html lang="en"><head><meta charset="utf-8"><style>""" + BASE_CSS + r"""
<body style="padding:18px"> body { padding:18px; }
<h3 style="margin-top:0">Modal dialog</h3> h3 { margin-top:0; }
input { width:100%; }
</style></head>
<body>
<h3>Modal dialog</h3>
<p>create_window(style=WINDOW_MODAL) keeps this page interactive until it submits or closes.</p> <p>create_window(style=WINDOW_MODAL) keeps this page interactive until it submits or closes.</p>
<p><input id="note" style="width:100%" value="hello from the modal"></p> <p><input id="note" value="hello from the modal"></p>
<p style="text-align:right"> <p style="text-align:right">
<button style="background:transparent;color:var(--orca-fg);border-color:var(--orca-border)" <button class="secondary" onclick="orca.postMessage({live: document.getElementById('note').value})">Post while open</button>
onclick="orca.postMessage({live: document.getElementById('note').value})">Post while open</button> <button class="secondary" onclick="orca.close()">Cancel</button>
<button style="background:transparent;color:var(--orca-fg);border-color:var(--orca-border)"
onclick="orca.close()">Cancel</button>
<button onclick="orca.submit({note: document.getElementById('note').value})">Submit</button> <button onclick="orca.submit({note: document.getElementById('note').value})">Submit</button>
</p> </p>
</body></html> </body></html>
""" """
CHILD_PAGE = r"""<!DOCTYPE html> CHILD_PAGE = r"""<!DOCTYPE html>
<html><head><meta charset="utf-8"></head> <html lang="en"><head><meta charset="utf-8"><style>""" + BASE_CSS + r"""
<body style="padding:18px"> body { padding:18px; }
<h3 style="margin-top:0">Child window</h3> h3 { margin-top:0; }
</style></head>
<body>
<h3>Child window</h3>
<p>Same create_window() API one plugin, several windows.</p> <p>Same create_window() API one plugin, several windows.</p>
<p><button onclick="orca.postMessage({command:'ping'})">Ping the plugin</button></p> <p><button onclick="orca.postMessage({command:'ping'})">Ping the plugin</button></p>
<div id="log" style="color:var(--orca-muted)"></div> <div id="log" style="color:var(--muted)"></div>
<script> <script>
var n = 0; var n = 0;
orca.onMessage(function (msg) { orca.onMessage(function (msg) {
@@ -1142,6 +1263,75 @@ CHILD_PAGE = r"""<!DOCTYPE html>
</body></html> </body></html>
""" """
# The per-preset config viewer, opened by "View config" on the Presets tab. Its rows are
# baked in as JSON at build time, so the window needs no bridge traffic and stays usable
# side by side with the panel until closed.
CONFIG_PAGE = r"""<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><style>""" + BASE_CSS + CFG_TABLE_CSS + r"""
body { padding:12px 16px 20px; border-top:3px solid var(--hue, var(--accent)); }
.toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
.toolbar h3 { margin:0; font-size:13px; }
.toolbar input { flex:1; min-width:140px; }
.dot { display:inline-block; width:9px; height:9px; border-radius:50%; flex:none;
background:var(--hue, var(--accent)); }
.count { color:var(--muted); font-size:12px; white-space:nowrap; }
</style></head>
<body>
<div class="toolbar"><span class="dot"></span><h3 id="name"></h3>
<span class="count" id="count"></span>
<input id="q" placeholder="Filter keys…">
<button class="secondary" onclick="orca.close()">Close</button></div>
<div id="table"></div>
<script>
'use strict';
const DATA = __DATA__;
const $ = id => document.getElementById(id);
const esc = s => String(s == null ? '' : s)
.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
if (DATA.hue) document.body.style.setProperty('--hue', DATA.hue);
$('name').textContent = DATA.name;
$('table').innerHTML = '<table class="cfg" id="t"><tr><th>key</th><th>value</th></tr>' +
DATA.rows.map(r => {
const long = r.v.length > 160;
const shown = long ? esc(r.v.slice(0, 160)) +
'<span class="more" role="button" tabindex="0"> … show all</span>' : esc(r.v);
return '<tr data-k="' + esc(r.k.toLowerCase()) + '"><td>' + esc(r.k) + '</td>' +
'<td class="val" data-full="' + esc(r.v) + '">' + shown + '</td></tr>';
}).join('') + '</table>';
function filter(q) {
q = q.trim().toLowerCase();
let visible = 0;
document.querySelectorAll('#t tr[data-k]').forEach(tr => {
const hit = !q || tr.dataset.k.includes(q) ||
tr.querySelector('.val').dataset.full.toLowerCase().includes(q);
tr.style.display = hit ? '' : 'none';
if (hit) visible++;
});
$('count').textContent = visible + ' keys';
}
$('q').addEventListener('input', e => filter(e.target.value));
$('table').addEventListener('click', e => {
const t = e.target.closest('.more');
if (t) { const td = t.closest('td'); td.textContent = td.dataset.full; }
});
document.addEventListener('keydown', e => {
if ((e.key === 'Enter' || e.key === ' ') && e.target.classList.contains('more')) {
e.preventDefault();
e.target.click();
}
});
filter('');
</script>
</body></html>
"""
def config_page(collection, name, rows):
"""CONFIG_PAGE with one preset's rows baked in. `</` is escaped so a config
value containing e.g. `</script>` 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("</", "<\\/"))
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# the plugin # the plugin
@@ -1150,6 +1340,7 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
win = None win = None
child = None child = None
modal = None modal = None
cfgwin = None
def get_name(self): def get_name(self):
return "Orca Inspector" return "Orca Inspector"
@@ -1166,6 +1357,9 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
if self.modal is not None: if self.modal is not None:
self.modal.close() self.modal.close()
self.modal = None self.modal = None
if self.cfgwin is not None:
self.cfgwin.close()
self.cfgwin = None
# Non-modal: returns immediately. The window is host-owned and lives on # Non-modal: returns immediately. The window is host-owned and lives on
# after execute() returns; on_message keeps firing when the page posts. # after execute() returns; on_message keeps firing when the page posts.
self.win = orca.host.ui.create_window( self.win = orca.host.ui.create_window(
@@ -1184,11 +1378,11 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
msg = msg or {} msg = msg or {}
command = msg.get("command") command = msg.get("command")
if command == "fetch": if command == "fetch":
self.send_section(msg.get("section", "")) self.send_section(msg.get("section", ""), msg.get("gen"))
elif command == "mesh": elif command == "mesh":
self.send_mesh(int(msg.get("object", -1)), int(msg.get("volume", -1))) self.send_mesh(msg.get("object", -1), msg.get("volume", -1))
elif command == "preset_config": elif command == "preset_config":
self.send_preset_config(msg.get("collection", ""), msg.get("name", "")) self.open_preset_config(msg.get("collection", ""), msg.get("name", ""))
elif command == "ui": elif command == "ui":
self.run_ui_action(msg) self.run_ui_action(msg)
@@ -1196,11 +1390,15 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
if self.child is not None: if self.child is not None:
self.child.close() self.child.close()
self.child = None self.child = None
if self.cfgwin is not None:
self.cfgwin.close()
self.cfgwin = None
print("Orca Inspector closed") print("Orca Inspector closed")
def send_section(self, section): def send_section(self, section, gen=None):
builder = SECTION_BUILDERS.get(section) builder = SECTION_BUILDERS.get(section)
reply = {"command": "section", "section": section} # gen echoes the page's refresh counter so a reply that raced a Refresh is dropped there.
reply = {"command": "section", "section": section, "gen": gen}
if builder is None: if builder is None:
self.win.post({**reply, "ok": False, "error": f"unknown section {section!r}"}) self.win.post({**reply, "ok": False, "error": f"unknown section {section!r}"})
return return
@@ -1213,15 +1411,23 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
reply = {"command": "mesh", "object": object_index, "volume": volume_index} reply = {"command": "mesh", "object": object_index, "volume": volume_index}
try: try:
self.win.post({**reply, "ok": True, self.win.post({**reply, "ok": True,
"data": build_mesh(object_index, volume_index)}) "data": build_mesh(int(object_index), int(volume_index))})
except Exception as exc: except Exception as exc:
self.win.post({**reply, "ok": False, "error": str(exc)}) self.win.post({**reply, "ok": False, "error": str(exc)})
def send_preset_config(self, collection, name): def open_preset_config(self, collection, name):
# One viewer at a time: a new pick replaces the previous window. The main page
# only hears back about failures.
reply = {"command": "preset_config", "collection": collection, "name": name} reply = {"command": "preset_config", "collection": collection, "name": name}
try: try:
self.win.post({**reply, "ok": True, rows = build_preset_config(collection, name)
"data": build_preset_config(collection, name)}) if self.cfgwin is not None:
self.cfgwin.close()
self.cfgwin = orca.host.ui.create_window(
title=f"Orca Inspector — {name}",
html=config_page(collection, name, rows),
width=520, height=640)
self.win.post({**reply, "ok": True})
except Exception as exc: except Exception as exc:
self.win.post({**reply, "ok": False, "error": str(exc)}) self.win.post({**reply, "ok": False, "error": str(exc)})
@@ -1267,15 +1473,19 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
if self.child is not None and self.child.is_open(): if self.child is not None and self.child.is_open():
report("child already open") report("child already open")
else: else:
# Not `reply`: the close can also come from "child_close" later, so the
# on_close payload is labelled neutrally.
self.child = orca.host.ui.create_window( self.child = orca.host.ui.create_window(
title="Orca Inspector — child", html=CHILD_PAGE, title="Orca Inspector — child", html=CHILD_PAGE,
width=380, height=240, on_message=self.on_child_message, width=380, height=240, on_message=self.on_child_message,
on_close=lambda: self.win.post( on_close=lambda: self.win.post(
{**reply, "ok": True, "result": "child window closed"})) {"command": "ui_result", "action": "child",
"ok": True, "result": "child window closed"}))
report("child opened") report("child opened")
elif action == "child_close": elif action == "child_close":
if self.child is not None: if self.child is not None:
self.child.close() self.child.close()
report("close requested") # the actual close is logged by on_close
else: else:
report("no child window") report("no child window")
elif action == "child_state": elif action == "child_state":

View File

@@ -0,0 +1,618 @@
# /// 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 = "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 = """
<style>
:root {
--ink: var(--orca-fg, #1f2429);
--paper: var(--orca-bg, #ffffff);
--rule: var(--orca-border, #d9dee3);
--quiet: var(--orca-muted, #6b7580);
--live: var(--orca-accent, #009688);
--live-ink: var(--orca-accent-fg, #ffffff);
--ui: var(--orca-font, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
--data: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--ink: var(--orca-fg, #e4e6e8);
--paper: var(--orca-bg, #2b2d30);
--rule: var(--orca-border, #3d4043);
--quiet: var(--orca-muted, #9aa0a6);
}
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 13px/1.45 var(--ui);
}
.panel { display: flex; flex-direction: column; height: 100%; }
.head {
display: flex; align-items: baseline; gap: 10px;
padding: 12px 16px 10px;
border-bottom: 1px solid var(--rule);
}
.mark {
font: 10px/1 var(--data); letter-spacing: .14em; text-transform: uppercase;
color: var(--live);
}
.head-note { font-size: 11px; color: var(--quiet); }
/* The frame is as narrow as ~400px in a resized dialog and as wide as ~600px; below the
breakpoint the drawing moves under the controls rather than squeezing both. */
.work {
flex: 1; min-height: 0; overflow: auto;
display: grid; grid-template-columns: minmax(0, 1fr) 180px; gap: 16px;
align-content: start; padding: 14px 16px;
}
@media (max-width: 470px) {
.work { grid-template-columns: minmax(0, 1fr); }
.plot { max-width: 210px; }
}
.rows { display: flex; flex-direction: column; gap: 12px; }
.row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 4px 8px; }
.row-label {
grid-column: 1;
font: 10px/1 var(--data); letter-spacing: .12em; text-transform: uppercase;
color: var(--quiet);
}
.row-figure { grid-column: 2; display: flex; align-items: baseline; gap: 4px; }
.row-figure input {
/* No spinners: the rail is the coarse control, this is the exact one, and the arrows would
steal the width the value needs. */
-webkit-appearance: textfield; appearance: textfield;
width: 76px; padding: 3px 6px; text-align: right;
font: 13px/1.2 var(--data); font-variant-numeric: tabular-nums;
color: var(--ink); background: transparent;
border: 1px solid transparent; border-radius: 3px;
}
.row-figure input::-webkit-outer-spin-button,
.row-figure input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.row-figure input:hover { border-color: var(--rule); }
.row-figure input:focus { outline: none; border-color: var(--live); }
.unit { font: 10px/1 var(--data); color: var(--quiet); min-width: 26px; }
/* The rail carries a detent at the value that means "no deformation", so neutral is a position
you can feel for rather than a number you have to read. */
.rail { grid-column: 1 / -1; position: relative; height: 16px; }
.rail[data-detent]::before {
content: ""; position: absolute; left: var(--detent); top: 3px;
width: 1px; height: 10px; background: var(--rule);
}
.rail input {
-webkit-appearance: none; appearance: none;
position: relative; width: 100%; height: 16px; margin: 0; background: transparent;
}
.rail input::-webkit-slider-runnable-track { height: 2px; background: var(--rule); border-radius: 1px; }
.rail input::-webkit-slider-thumb {
-webkit-appearance: none; width: 11px; height: 11px; margin-top: -4.5px;
border: none; border-radius: 50%; background: var(--live); cursor: grab;
}
.rail input:focus { outline: none; }
.rail input:focus-visible { outline: 2px solid var(--live); outline-offset: 1px; border-radius: 3px; }
.rail input:disabled::-webkit-slider-thumb { background: var(--quiet); cursor: default; }
.readout {
margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--rule);
font-size: 11px; color: var(--quiet); min-height: 32px;
}
.readout b { color: var(--ink); font-weight: 600; font-family: var(--data); font-variant-numeric: tabular-nums; }
.plot { margin: 0; display: flex; flex-direction: column; gap: 6px; }
.plot svg { width: 100%; height: auto; display: block; }
.plot figcaption { font: 10px/1.4 var(--data); color: var(--quiet); }
/* SVG colours live here rather than in stroke="" attributes: var() is not reliable in a
presentation attribute, and this way the drawing re-themes with the rest of the page. */
.ring, .helix, .datum { fill: none; }
.ring { stroke: var(--live); stroke-width: 1; }
.helix { stroke: var(--live); stroke-width: 1; stroke-opacity: .5; stroke-dasharray: 2 2; }
.datum { stroke: var(--quiet); stroke-width: 1; stroke-opacity: .7; }
.tick { stroke: var(--rule); stroke-width: 1; }
.plot text { fill: var(--quiet); font: 8px var(--data); }
.foot {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px; border-top: 1px solid var(--rule);
}
.state { flex: 1; font-size: 11px; color: var(--quiet); }
.state.dirty { color: var(--ink); }
button {
font: 12px/1 var(--ui); padding: 6px 14px; border-radius: 4px; cursor: pointer;
border: 1px solid var(--rule); background: transparent; color: var(--ink);
}
button.go { border-color: var(--live); background: var(--live); color: var(--live-ink); }
button:disabled { opacity: .45; cursor: default; }
button:focus-visible { outline: 2px solid var(--live); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
<div class="panel">
<header class="head">
<span class="mark">Twistify</span>
<span class="head-note">Applied to every layer as it is sliced</span>
</header>
<div class="work">
<div>
<div class="rows" id="rows"></div>
<p class="readout" id="readout"></p>
</div>
<figure class="plot">
<svg id="plot" viewBox="0 0 180 208" aria-hidden="true"></svg>
<figcaption id="plotNote"></figcaption>
</figure>
</div>
<footer class="foot">
<span class="state" id="state"></span>
<button type="button" id="restore">Restore defaults</button>
<button type="button" id="save" class="go">Save</button>
</footer>
</div>
<script>
(function () {
var DEFAULTS = __ORCA_DEFAULTS__;
// Presentation only. `neutral` marks the value at which the control contributes no deformation;
// rows without one (period, floor) are shape parameters, not amounts.
var FIELDS = [
{ key: "twist_deg_per_mm", label: "Twist", unit: "\\u00b0/mm", min: -20, max: 20, step: 0.1, dp: 1, neutral: 0,
hint: "Rotation added per millimetre of height." },
{ key: "taper_per_mm", label: "Taper", unit: "/mm", min: -0.05, max: 0.05, step: 0.001, dp: 3, neutral: 0,
hint: "Scale added per millimetre. Negative narrows the model as it rises." },
{ key: "wobble_ampl_mm", label: "Wobble", unit: "mm", min: 0, max: 10, step: 0.1, dp: 1, neutral: 0,
hint: "How far each layer slides in X, from centre to peak." },
{ key: "wobble_period_mm", label: "Wobble period", unit: "mm", min: 1, max: 120, step: 1, dp: 0,
hint: "Height of one full wobble cycle." },
{ key: "min_scale", label: "Scale floor", unit: "\\u00d7", min: 0.01, max: 1, step: 0.01, dp: 2,
hint: "Taper never shrinks a layer past this, so a tall model cannot collapse." }
];
// The preview simulates this much height. It never reads the real model, so the caption says so
// rather than implying the drawing is to scale with the plate.
var Z_SPAN = 40;
var Z_STEP = 2;
var form = {};
var stored = {};
var context = (window.orca && window.orca.getContext) ? window.orca.getContext() : { scope: "global" };
var savedNoteTimer = 0;
function num(value, fallback) {
var n = parseFloat(value);
return isFinite(n) ? n : fallback;
}
function merged(config) {
var out = {};
Object.keys(DEFAULTS).forEach(function (key) {
out[key] = num(config ? config[key] : undefined, DEFAULTS[key]);
});
return out;
}
function isDirty() {
return Object.keys(DEFAULTS).some(function (key) { return form[key] !== stored[key]; });
}
// --- the transform, exactly as _layer_params() applies it -------------------------------------
function layerAt(z) {
var theta = form.twist_deg_per_mm * z * Math.PI / 180;
var scale = Math.max(form.min_scale, 1 + form.taper_per_mm * z);
var shift = 0;
if (form.wobble_ampl_mm !== 0 && form.wobble_period_mm > 0)
shift = form.wobble_ampl_mm * Math.sin(2 * Math.PI * z / form.wobble_period_mm);
return { theta: theta, scale: scale, shift: shift };
}
function deforms() {
return form.twist_deg_per_mm !== 0 || form.taper_per_mm !== 0 || form.wobble_ampl_mm !== 0;
}
// --- drawing ----------------------------------------------------------------------------------
var ISO_X = 0.866, ISO_Y = 0.5; // isometric: plan outlines stacked by height
var ORIGIN_X = 108, ORIGIN_Y = 182; // the z = 0 outline's centre, in viewBox units
var PLAN = 20; // half-width of the sample contour
var Z_UNITS = 3.3; // viewBox units per millimetre of height
var MM = 1.5; // viewBox units per millimetre of wobble travel
var REACH = 62; // how far sideways the drawing may run before it is scaled down
// Plan-view corners of the layer at `z`, before projection.
function planOutline(z) {
var layer = layerAt(z);
var half = PLAN * layer.scale;
var cos = Math.cos(layer.theta), sin = Math.sin(layer.theta);
return [[-half, -half], [half, -half], [half, half], [-half, half]].map(function (corner) {
return [corner[0] * cos - corner[1] * sin + layer.shift * MM,
corner[0] * sin + corner[1] * cos];
});
}
function points(plan, z, fit) {
return plan.map(function (p) {
var x = ORIGIN_X + (p[0] - p[1]) * ISO_X * fit;
var y = ORIGIN_Y + (p[0] + p[1]) * ISO_Y * fit - z * Z_UNITS;
return x.toFixed(1) + "," + y.toFixed(1);
}).join(" ");
}
function draw() {
var svg = [];
var layers = [];
var reach = 0;
for (var z = 0; z <= Z_SPAN + 0.001; z += Z_STEP) {
var plan = planOutline(z);
layers.push({ z: z, plan: plan });
plan.forEach(function (p) { reach = Math.max(reach, Math.abs(p[0] - p[1]) * ISO_X); });
}
// A wide taper or a big wobble would run out of the frame, so scale down to fit rather than
// clipping. Z keeps its scale, so the ruler stays true.
var fit = reach > REACH ? REACH / reach : 1;
// Z ruler: the drawing is a height plot, so it gets an axis.
for (var mark = 0; mark <= Z_SPAN; mark += 10) {
var y = ORIGIN_Y - mark * Z_UNITS;
svg.push('<line class="tick" x1="24" y1="' + y.toFixed(1) + '" x2="30" y2="' + y.toFixed(1) + '"/>');
svg.push('<text x="20" y="' + (y + 3).toFixed(1) + '" text-anchor="end">' + mark + '</text>');
}
svg.push('<text x="30" y="' + (ORIGIN_Y - Z_SPAN * Z_UNITS - 11).toFixed(1) +
'" text-anchor="end">mm</text>');
// The stack, faint at the bed and full strength at the top: the deformation grows with Z.
var trace = [];
layers.forEach(function (layer) {
var ring = points(layer.plan, layer.z, fit);
svg.push('<polygon class="ring" points="' + ring + '" stroke-opacity="' +
(0.18 + 0.82 * layer.z / Z_SPAN).toFixed(2) + '"/>');
trace.push(ring.split(" ")[1]);
});
// One corner followed all the way up: on a twisted model, the helix the walls will run in.
svg.push('<polyline class="helix" points="' + trace.join(" ") + '"/>');
// The datum: the first layer is never transformed, so it doubles as the reference outline.
svg.push('<polygon class="datum" points="' + points(layers[0].plan, 0, fit) + '"/>');
document.getElementById("plot").innerHTML = svg.join("");
document.getElementById("plotNote").textContent =
deforms() ? "Sample contour, " + Z_SPAN + " mm of Z" : "Sample contour, undeformed";
}
// --- readout ----------------------------------------------------------------------------------
function describe() {
if (!deforms())
return "No deformation. Twistify passes every layer through unchanged.";
var top = layerAt(Z_SPAN);
var parts = [];
if (form.twist_deg_per_mm !== 0)
parts.push("<b>" + (form.twist_deg_per_mm * Z_SPAN).toFixed(0) + "\\u00b0</b> of rotation");
if (form.taper_per_mm !== 0)
parts.push("<b>" + top.scale.toFixed(2) + "\\u00d7</b> scale" +
(top.scale === form.min_scale ? " (at the floor)" : ""));
if (form.wobble_ampl_mm !== 0)
parts.push("<b>\\u00b1" + form.wobble_ampl_mm.toFixed(1) + "</b> mm of wobble");
return "At " + Z_SPAN + " mm above the first layer: " + parts.join(", ") + ".";
}
function setReadout(html) { document.getElementById("readout").innerHTML = html; }
// Whatever the pointer or the keyboard is on explains itself; with neither, report the totals.
function restoreReadout() {
var id = document.activeElement ? document.activeElement.id : "";
for (var i = 0; i < FIELDS.length; i++)
if (id === "n-" + FIELDS[i].key || id === "r-" + FIELDS[i].key)
return setReadout(FIELDS[i].hint);
setReadout(describe());
}
function refreshState() {
var dirty = isDirty();
document.getElementById("save").disabled = context.readOnly || !dirty;
if (savedNoteTimer)
return; // the "Saved" note owns the line until it expires
var state = document.getElementById("state");
state.textContent = context.readOnly ? "This configuration is read-only."
: dirty ? "Unsaved changes" : "";
state.classList.toggle("dirty", dirty && !context.readOnly);
}
function announce(message) {
var state = document.getElementById("state");
state.textContent = message;
state.classList.remove("dirty");
clearTimeout(savedNoteTimer);
savedNoteTimer = setTimeout(function () { savedNoteTimer = 0; refreshState(); }, 2200);
}
// --- controls ---------------------------------------------------------------------------------
function detent(field) {
if (field.neutral === undefined) return "";
return ((field.neutral - field.min) / (field.max - field.min) * 100).toFixed(2) + "%";
}
function clamp(field, value) { return Math.min(field.max, Math.max(field.min, value)); }
// `echo` rewrites the number field with the clamped, formatted value. Off while the user is still
// typing: "-" and "0.0" are valid partial input, and rewriting them mid-keystroke fights the
// typist. The drawing still follows every keystroke.
function apply(field, value, echo) {
form[field.key] = clamp(field, value);
if (echo)
document.getElementById("n-" + field.key).value = form[field.key].toFixed(field.dp);
document.getElementById("r-" + field.key).value = form[field.key];
draw();
// A control explains itself while you approach it, but once a value moves what matters is the
// total it adds up to.
setReadout(describe());
refreshState();
}
function buildRows() {
var host = document.getElementById("rows");
FIELDS.forEach(function (field) {
var row = document.createElement("div");
row.className = "row";
// The number input is the labelled, keyboard-reachable control; the rail is the same value by
// pointer, so it stays out of the tab order and the accessibility tree.
row.innerHTML =
'<label class="row-label" for="n-' + field.key + '">' + field.label + '</label>' +
'<span class="row-figure">' +
'<input id="n-' + field.key + '" type="number" step="' + field.step + '" ' +
'min="' + field.min + '" max="' + field.max + '">' +
'<span class="unit">' + field.unit + '</span>' +
'</span>' +
'<span class="rail"' + (field.neutral === undefined ? "" : ' data-detent style="--detent:' + detent(field) + '"') + '>' +
'<input id="r-' + field.key + '" type="range" step="' + field.step + '" ' +
'min="' + field.min + '" max="' + field.max + '" tabindex="-1" aria-hidden="true">' +
'</span>';
host.appendChild(row);
var number = row.querySelector("#n-" + field.key);
var range = row.querySelector("#r-" + field.key);
number.addEventListener("input", function () { apply(field, num(number.value, form[field.key]), false); });
number.addEventListener("change", function () { apply(field, num(number.value, DEFAULTS[field.key]), true); });
range.addEventListener("input", function () { apply(field, num(range.value, form[field.key]), true); });
[number, range].forEach(function (input) {
input.addEventListener("focus", function () { setReadout(field.hint); });
input.addEventListener("mouseenter", function () { setReadout(field.hint); });
input.addEventListener("blur", restoreReadout);
input.addEventListener("mouseleave", restoreReadout);
});
});
}
function load(config) {
stored = merged(config);
form = merged(config);
FIELDS.forEach(function (field) {
document.getElementById("n-" + field.key).value = form[field.key].toFixed(field.dp);
document.getElementById("r-" + field.key).value = form[field.key];
});
draw();
restoreReadout();
refreshState();
labelRestore();
setInputsEnabled(!context.readOnly);
}
// "Restore defaults" writes the plugin's own defaults globally, but in a preset it drops that
// preset's override and falls back to the global configuration. Say which one the button does.
function labelRestore() {
var restore = document.getElementById("restore");
var preset = context.scope === "preset";
restore.textContent = preset ? "Use global settings" : "Restore defaults";
restore.title = preset ? "Discard this preset's override and use the global configuration"
: "Replace the saved configuration with Twistify's defaults";
restore.disabled = context.readOnly || (preset && !context.hasPresetOverride);
}
function setInputsEnabled(enabled) {
FIELDS.forEach(function (field) {
document.getElementById("n-" + field.key).disabled = !enabled;
document.getElementById("r-" + field.key).disabled = !enabled;
});
}
buildRows();
load(window.orca ? window.orca.getConfig() : {});
document.getElementById("save").addEventListener("click", function () {
if (!window.orca) return;
window.orca.saveConfig(form);
});
document.getElementById("restore").addEventListener("click", function () {
if (window.orca) window.orca.restoreDefaults();
});
if (window.orca && window.orca.onConfig) {
var first = true;
window.orca.onConfig(function (config) {
// Fires once immediately (already handled above) and then on every host save: reload from
// what was stored, never from what was typed.
if (first) { first = false; return; }
if (window.orca.getContext) context = window.orca.getContext();
load(config);
announce("Saved");
});
}
// No theme handler needed: every colour on the page, the drawing included, resolves through the
// --orca-* variables the host rewrites in place.
})();
</script>
"""
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)

View File

@@ -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)

View File

@@ -1203,7 +1203,7 @@ static std::vector<std::string> s_Preset_print_options{
"post_process", "post_process",
"slicing_pipeline_plugin", "slicing_pipeline_plugin",
"plugins", "plugins",
"plugin_config_overrides", "print_plugin_config_overrides",
"process_change_extrusion_role_gcode", "process_change_extrusion_role_gcode",
"min_length_factor", "min_length_factor",
"wall_maximum_resolution", "wall_maximum_resolution",
@@ -1378,7 +1378,7 @@ static std::vector<std::string> s_Preset_filament_options {/*"filament_colour",
"filament_preheat_temperature_delta", "filament_retract_length_nc", "filament_preheat_temperature_delta", "filament_retract_length_nc",
"filament_change_length_nc", "filament_prime_volume", "filament_prime_volume_nc", "filament_change_length_nc", "filament_prime_volume", "filament_prime_volume_nc",
"long_retractions_when_ec", "retraction_distances_when_ec", "long_retractions_when_ec", "retraction_distances_when_ec",
"plugin_config_overrides", "filament_plugin_config_overrides",
//ams chamber //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_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", "filament_dev_chamber_drying_bed_temperature", "filament_dev_chamber_drying_time",
@@ -1430,7 +1430,7 @@ static std::vector<std::string> s_Preset_printer_options {
// Fast-purge printer flag + device/firmware-facing per-variant extruder-change // 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). // deretraction speed (unconsumed by the slicer; carried by H2D/A2L/X2D/P2S machine profiles).
"support_fast_purge_mode", "deretract_speed_extruder_change", "support_fast_purge_mode", "deretract_speed_extruder_change",
"plugin_config_overrides" "printer_plugin_config_overrides"
}; };
static std::vector<std::string> s_Preset_sla_print_options { static std::vector<std::string> s_Preset_sla_print_options {
@@ -1542,6 +1542,15 @@ const std::vector<std::string>& Preset::printer_options()
return s_opts; 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<std::string> &keys, const Slic3r::StaticPrintConfig &defaults, const std::string &default_name) : PresetCollection::PresetCollection(Preset::Type type, const std::vector<std::string> &keys, const Slic3r::StaticPrintConfig &defaults, const std::string &default_name) :
m_type(type), m_type(type),
m_edited_preset(type, "", false), m_edited_preset(type, "", false),

View File

@@ -407,6 +407,11 @@ public:
// Printer machine limits, those are contained in printer_options(). // Printer machine limits, those are contained in printer_options().
static const std::vector<std::string>& machine_limits_options(); static const std::vector<std::string>& 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<std::string>& sla_printer_options(); static const std::vector<std::string>& sla_printer_options();
static const std::vector<std::string>& sla_material_options(); static const std::vector<std::string>& sla_material_options();
static const std::vector<std::string>& sla_print_options(); static const std::vector<std::string>& sla_print_options();

View File

@@ -285,6 +285,7 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
steps.emplace_back(psSkirtBrim); steps.emplace_back(psSkirtBrim);
} else if ( } else if (
opt_key == "slicing_pipeline_plugin" opt_key == "slicing_pipeline_plugin"
|| opt_key == "print_plugin_config_overrides"
|| opt_key == "initial_layer_print_height" || opt_key == "initial_layer_print_height"
|| opt_key == "nozzle_diameter" || opt_key == "nozzle_diameter"
|| opt_key == "filament_shrink" || opt_key == "filament_shrink"

View File

@@ -1081,16 +1081,21 @@ void PrintConfigDef::init_common_params()
def->set_default_value(new ConfigOptionString()); def->set_default_value(new ConfigOptionString());
} }
def = this->add("plugin_config_overrides", coString); // One key per preset type (Preset::plugin_overrides_key), so the print, printer and filament
def->label = L("Capabilities"); // overrides don't clobber each other when the presets merge into one full config. No handle_legacy
def->tooltip = L("Configuration for the plugin capabilities this preset uses, overriding the global " // migration from the shared "plugin_config_overrides" they replace: it only ever shipped in
"Capabilities configuration. Stored as a raw JSON array and edited through the dialog " // nightlies. Never a text field — GUIType::plugin_config renders a button opening PluginsConfigDialog.
"behind the button, never typed in directly."); for (const char* key : {"print_plugin_config_overrides", "printer_plugin_config_overrides", "filament_plugin_config_overrides"}) {
// Never shown as a text field: GUIType::plugin_config renders a button that opens PluginsConfigDialog. def = this->add(key, coString);
def->gui_type = ConfigOptionDef::GUIType::plugin_config; def->label = L("Capabilities");
def->mode = comAdvanced; def->tooltip = L("Configuration for the plugin capabilities this preset uses, overriding the global "
def->cli = ConfigOptionDef::nocli; "Capabilities configuration. Stored as a raw JSON array and edited through the dialog "
def->set_default_value(new ConfigOptionString("")); "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() void PrintConfigDef::init_fff_params()

View File

@@ -1780,6 +1780,7 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE(
((ConfigOptionString, filename_format)) ((ConfigOptionString, filename_format))
((ConfigOptionStrings, post_process)) ((ConfigOptionStrings, post_process))
((ConfigOptionStrings, slicing_pipeline_plugin)) ((ConfigOptionStrings, slicing_pipeline_plugin))
((ConfigOptionString, print_plugin_config_overrides))
((ConfigOptionString, printer_model)) ((ConfigOptionString, printer_model))
((ConfigOptionFloat, resolution)) ((ConfigOptionFloat, resolution))
((ConfigOptionFloats, retraction_minimum_travel)) ((ConfigOptionFloats, retraction_minimum_travel))

View File

@@ -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 // 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. // 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 // 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 // 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 // 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); if (const ConfigOptionDef* opt_def = m_config->def()->get(opt_key);
opt_def && opt_def->is_plugin_backed()) { opt_def && opt_def->is_plugin_backed()) {
m_config->update_plugin_manifest(); m_config->update_plugin_manifest();
if (prune_stale_plugin_overrides(*m_config)) { const std::string overrides_key = Preset::plugin_overrides_key(m_type);
if (Field* overrides_field = get_field(PLUGIN_OVERRIDES_OPTION_KEY)) if (prune_stale_plugin_overrides(*m_config, overrides_key)) {
overrides_field->set_value(boost::any(m_config->opt_string(PLUGIN_OVERRIDES_OPTION_KEY)), false); 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 // 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". // 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 = 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); optgroup = page->new_optgroup(L("Notes"), "note", 0);
option = optgroup->get_option("notes"); option = optgroup->get_option("notes");
@@ -4552,7 +4553,7 @@ void TabFilament::build()
optgroup->append_single_option_line(option); optgroup->append_single_option_line(option);
optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode"); 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 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"); 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->append_single_option_line("time_cost", "printer_basic_information_advanced#time-cost");
optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode"); 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"); 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 }; Line line = Line{ L("Fan speed-up time"), optgroup->get_option("fan_speedup_time").opt.tooltip };

View File

@@ -324,12 +324,6 @@ bool PluginConfig::dirty() const
return m_dirty; return m_dirty;
} }
std::string plugin_overrides_of(const Preset& preset)
{
const auto* opt = dynamic_cast<const ConfigOptionString*>(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) bool parse_plugin_overrides(const std::string& raw, CapabilityConfigDocument& document, std::string& error)
{ {
document = CapabilityConfigDocument(); document = CapabilityConfigDocument();
@@ -357,9 +351,9 @@ std::string serialize_plugin_overrides(const CapabilityConfigDocument& document)
return document.empty() ? std::string() : document.serialize_entries().dump(); 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<const ConfigOptionString*>(config.option(PLUGIN_OVERRIDES_OPTION_KEY)); const auto* overrides_opt = dynamic_cast<const ConfigOptionString*>(config.option(overrides_key));
if (overrides_opt == nullptr || overrides_opt->value.empty()) if (overrides_opt == nullptr || overrides_opt->value.empty())
return false; return false;
@@ -397,7 +391,7 @@ bool prune_stale_plugin_overrides(DynamicConfig& config)
if (!overrides.prune_unreferenced(referenced)) if (!overrides.prune_unreferenced(referenced))
return false; 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; return true;
} }
@@ -470,27 +464,29 @@ EffectiveCapabilityConfig active_capability_config(const PluginCapabilityId& id)
if (bundle != nullptr) { if (bundle != nullptr) {
const std::string type_key = plugin_capability_type_to_string(id.type); 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 std::vector<std::string>*, 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) { for (const auto& [key, def] : print_config_def.options) {
if (def.plugin_type != type_key) if (def.plugin_type != type_key)
continue; continue;
for (const auto& [options, edited] : scopes)
const auto& print_options = Preset::print_options(); if (contains(*options, key)) {
if (std::find(print_options.begin(), print_options.end(), key) != print_options.end()) { preset = edited;
preset = &bundle->prints.get_edited_preset(); break;
}
if (preset != nullptr)
break; 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) { if (preset != nullptr) {
const auto* stored = dynamic_cast<const ConfigOptionString*>(preset->config.option(Preset::plugin_overrides_key(preset->type)));
std::string error; 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. // Text we cannot read is not an override: log it and resolve against the base config.
BOOST_LOG_TRIVIAL(error) << "Preset \"" << preset->name << "\": " << error; BOOST_LOG_TRIVIAL(error) << "Preset \"" << preset->name << "\": " << error;
overrides = CapabilityConfigDocument(); overrides = CapabilityConfigDocument();

View File

@@ -17,7 +17,6 @@
namespace Slic3r { namespace Slic3r {
class Preset;
class DynamicConfig; class DynamicConfig;
struct CapabilityConfigEntry struct CapabilityConfigEntry
{ {
@@ -50,19 +49,15 @@ private:
std::vector<nlohmann::json> m_opaque_entries; std::vector<nlohmann::json> 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); bool parse_plugin_overrides(const std::string& raw, CapabilityConfigDocument& document, std::string& error);
std::string serialize_plugin_overrides(const CapabilityConfigDocument& document); std::string serialize_plugin_overrides(const CapabilityConfigDocument& document);
// Drops plugin_config_overrides entries for capabilities no longer named by any plugin-backed // Drops override entries for capabilities no longer named by any plugin-backed option in `config`
// option's current value in `config` (e.g. slicing_pipeline_plugin cleared or switched to a // (e.g. slicing_pipeline_plugin cleared or pointed at another capability) and writes the result back
// different capability), and writes the result back if anything changed. Called wherever a // to `overrides_key`. Call it wherever such an option changes, so a saved preset never carries
// plugin-backed option's value changes, so a saved preset never carries configuration for a // configuration for a capability it no longer references. Returns true if `config` was modified, so a
// capability it no longer references. Returns true if `config` was modified, so a caller holding a // caller holding a GUI field over `overrides_key` knows to refresh it.
// GUI field over PLUGIN_OVERRIDES_OPTION_KEY knows it must refresh that field's displayed value. bool prune_stale_plugin_overrides(DynamicConfig& config, const std::string& overrides_key);
bool prune_stale_plugin_overrides(DynamicConfig& config);
struct EffectiveCapabilityConfig struct EffectiveCapabilityConfig
{ {

View File

@@ -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 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 <catch2/matchers/catch_matchers_floating_point.hpp> #include <catch2/matchers/catch_matchers_floating_point.hpp>
// A similarity transform (rotate + uniform scale) applied to slices at Step.posSlice, matching // 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 // 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 // seam and cascade the sample drives through the slices.set() + Layer::make_slices() path. Two
// end-to-end invariants after process() confirm the approach: // end-to-end invariants after process() confirm the approach:

View File

@@ -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<Preset::Type, const std::vector<std::string>*> 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));
}
}