mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-24 19:32:18 +00:00
feature/improve_plugin_config_for_slicing_plugin
This commit is contained in:
@@ -48,6 +48,7 @@
|
||||
<span id="statusText" class="status-text"></span>
|
||||
</footer>
|
||||
</main>
|
||||
<script src="../js/plugin-config-ui.js"></script>
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,6 +9,11 @@ let selectedCapabilityType = "";
|
||||
let selectedHasPresetOverride = false;
|
||||
let selectedReadOnly = false;
|
||||
|
||||
// Identity of the capability whose custom UI is currently loaded in the frame. 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; when this still matches, the new values are posted in instead.
|
||||
let customFrameKey = "";
|
||||
|
||||
function SafeJsonParse(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
@@ -168,6 +173,10 @@ function IsCurrentCapability(payload) {
|
||||
&& String(payload?.capability_type || "") === selectedCapabilityType;
|
||||
}
|
||||
|
||||
function CapabilityKey(payload) {
|
||||
return JSON.stringify([payload?.plugin_key, payload?.capability_name, payload?.capability_type]);
|
||||
}
|
||||
|
||||
function RequestCapabilityConfig() {
|
||||
if (!selectedPluginKey || !selectedCapabilityName)
|
||||
return;
|
||||
@@ -193,6 +202,7 @@ function ClearCapabilityConfigView() {
|
||||
if (custom) {
|
||||
custom.hidden = true;
|
||||
custom.removeAttribute("srcdoc");
|
||||
customFrameKey = "";
|
||||
}
|
||||
if (text)
|
||||
text.value = "";
|
||||
@@ -248,8 +258,14 @@ function ApplyCapabilityConfig(payload) {
|
||||
|
||||
if (html) {
|
||||
if (custom) {
|
||||
const context = OrcaConfigContext(payload, "preset");
|
||||
custom.hidden = false;
|
||||
custom.srcdoc = BuildCustomConfigDocument(html, config);
|
||||
if (customFrameKey === CapabilityKey(payload) && custom.contentWindow) {
|
||||
custom.contentWindow.postMessage({ __orca: "config", config: config, context: context }, "*");
|
||||
} else {
|
||||
customFrameKey = CapabilityKey(payload);
|
||||
custom.srcdoc = BuildCustomConfigDocument(html, config, context);
|
||||
}
|
||||
}
|
||||
if (editor)
|
||||
editor.hidden = true;
|
||||
@@ -260,6 +276,7 @@ function ApplyCapabilityConfig(payload) {
|
||||
if (custom) {
|
||||
custom.hidden = true;
|
||||
custom.removeAttribute("srcdoc");
|
||||
customFrameKey = "";
|
||||
}
|
||||
if (editor)
|
||||
editor.hidden = false;
|
||||
@@ -354,39 +371,6 @@ function ApplyCapabilityConfigSaved(payload) {
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
// The whole host surface a custom config UI gets: read the config, save one, drop the preset's
|
||||
// override, and be told when either lands. The frame is sandboxed into an opaque origin, so this
|
||||
// bridge is its only channel.
|
||||
function BuildCustomConfigDocument(html, config) {
|
||||
// Inlined into a <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) {
|
||||
const custom = document.getElementById("configCustom");
|
||||
// Only the frame we created, and only while it is actually showing.
|
||||
@@ -432,6 +416,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when
|
||||
// sandboxed), and ignores anything else.
|
||||
window.addEventListener("message", OnCustomConfigMessage);
|
||||
OrcaWatchThemeForFrame(() => document.getElementById("configCustom"));
|
||||
|
||||
SendMessage("request_capabilities");
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<script type="text/javascript" src="../../data/text.js"></script>
|
||||
<script type="text/javascript" src="../js/globalapi.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="./plugin-sort.js"></script>
|
||||
<script src="../js/fuzzy-search.js"></script>
|
||||
|
||||
@@ -90,6 +90,7 @@ function OnInit() {
|
||||
// OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when
|
||||
// sandboxed), and ignores anything else.
|
||||
window.addEventListener("message", OnCustomConfigMessage);
|
||||
OrcaWatchThemeForFrame(() => document.getElementById("configCustom"));
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest(".ctx"))
|
||||
@@ -1077,7 +1078,7 @@ function ApplyCapabilityConfig(payload) {
|
||||
if (html) {
|
||||
if (custom) {
|
||||
custom.hidden = false;
|
||||
custom.srcdoc = BuildCustomConfigDocument(html, config);
|
||||
custom.srcdoc = BuildCustomConfigDocument(html, config, OrcaConfigContext(payload, "global"));
|
||||
}
|
||||
if (editor)
|
||||
editor.hidden = true;
|
||||
@@ -1178,39 +1179,6 @@ function ApplyCapabilityConfigSaved(payload) {
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
// The whole host surface a custom config UI gets: read the config, save one, restore the plugin's
|
||||
// defaults, and be told when either lands. The frame is sandboxed into an opaque origin, so this
|
||||
// bridge is its only channel.
|
||||
function BuildCustomConfigDocument(html, config) {
|
||||
// Inlined into a <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) {
|
||||
const custom = document.getElementById("configCustom");
|
||||
// Only the frame we created, and only while it is actually showing.
|
||||
|
||||
130
resources/web/dialog/js/plugin-config-ui.js
Normal file
130
resources/web/dialog/js/plugin-config-ui.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// 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 host the page in an iframe sandboxed into an
|
||||
// opaque origin, so this bridge is its only channel, and both must offer plugin authors exactly the
|
||||
// same one — hence a single module rather than a copy per dialog.
|
||||
|
||||
// The host theme "contract" (WebViewHostDialog::host_theme_vars_css). The document-start injector
|
||||
// stamps it on the top-level page only — it returns early in child frames — so a sandboxed config UI
|
||||
// never sees it unless we hand it over.
|
||||
const ORCA_THEME_VARS = [
|
||||
"--orca-bg",
|
||||
"--orca-fg",
|
||||
"--orca-muted",
|
||||
"--orca-border",
|
||||
"--orca-accent",
|
||||
"--orca-accent-fg",
|
||||
"--orca-font"
|
||||
];
|
||||
|
||||
// Read the contract off this page as it is rendering right now, so the frame always opens in the
|
||||
// live theme rather than whatever the app started in.
|
||||
function OrcaThemeSnapshot() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const vars = {};
|
||||
ORCA_THEME_VARS.forEach((name) => {
|
||||
// Values are host-produced colors and a pre-sanitized font stack; strip anything that could end
|
||||
// the declaration or the <style> block it gets inlined into.
|
||||
const value = String(style.getPropertyValue(name) || "").trim().replace(/[<>{};]/g, "");
|
||||
if (value)
|
||||
vars[name] = value;
|
||||
});
|
||||
return {
|
||||
theme: document.documentElement.getAttribute("data-orca-theme") === "dark" ? "dark" : "light",
|
||||
vars: vars
|
||||
};
|
||||
}
|
||||
|
||||
// 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 === undefined ? null : value).replace(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
// What a custom UI can learn about the surface it is being 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 cannot label its own button
|
||||
// without knowing the scope.
|
||||
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 && next.vars) theme = next;
|
||||
var css = ":root{";
|
||||
for (var name in theme.vars)
|
||||
if (Object.prototype.hasOwnProperty.call(theme.vars, name)) css += name + ":" + theme.vars[name] + ";";
|
||||
css += "color-scheme:" + theme.theme + ";}";
|
||||
var style = document.getElementById("orca-host-theme-vars");
|
||||
if (style) style.textContent = 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 rewrites the
|
||||
// injected variables and re-stamps data-orca-theme), which a sandboxed child frame never sees. Relay
|
||||
// it so a custom UI follows a light/dark switch without being reopened.
|
||||
function OrcaWatchThemeForFrame(getFrame) {
|
||||
const relay = () => {
|
||||
const frame = getFrame();
|
||||
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"]
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
# name = "Twistify"
|
||||
# description = "Twists, tapers, and wobbles every layer's slice polygons as a function of Z (demo)."
|
||||
# author = "OrcaSlicer"
|
||||
# version = "0.02"
|
||||
# version = "0.03"
|
||||
# type = "slicing-pipeline"
|
||||
# ///
|
||||
"""Twistify -- twist/taper/wobble any model at slice time.
|
||||
@@ -25,8 +25,17 @@ 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.
|
||||
(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
|
||||
@@ -44,7 +53,7 @@ _DEFAULTS = {
|
||||
def _params(self):
|
||||
try:
|
||||
src = json.loads(self.get_config())
|
||||
except (AttributeError, TypeError):
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
src = {}
|
||||
out = {}
|
||||
for key, default in _DEFAULTS.items():
|
||||
@@ -69,13 +78,485 @@ def _layer_params(z_rel, mm_to_scaled, p):
|
||||
return theta, s, ox
|
||||
|
||||
|
||||
# The configuration page. Self-contained on purpose: it runs in an iframe sandboxed into an opaque
|
||||
# origin, so there is no network, no same-origin access to the host, and no shared stylesheet --
|
||||
# only the window.orca bridge and the --orca-* theme variables the host injects. __ORCA_DEFAULTS__
|
||||
# is substituted with _DEFAULTS in get_config_ui(), so Python stays the single source of truth for
|
||||
# the values; the page adds only presentation (ranges, labels, wording).
|
||||
_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. Nothing reads the real model, so say so in the caption
|
||||
// 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; the drawing scales down to fit rather
|
||||
// than clipping. The Z axis 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 reaching 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 this is 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 is drawn 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, the line reports what
|
||||
// the current values add up to.
|
||||
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 things to have typed so far, and replacing them mid-keystroke
|
||||
// would fight the person doing it. 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 and reports while you change it: once a value
|
||||
// moves, what matters is the total it adds up to, not what the control does.
|
||||
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 out of 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();
|
||||
}
|
||||
|
||||
// "Restore defaults" writes the plugin's own defaults globally, but in a preset it discards that
|
||||
// preset's override and falls back to the global configuration. Two different actions, so the
|
||||
// button says which one it is.
|
||||
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() : {});
|
||||
labelRestore();
|
||||
setInputsEnabled(!context.readOnly);
|
||||
|
||||
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 whenever the host persists
|
||||
// something: 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);
|
||||
labelRestore();
|
||||
setInputsEnabled(!context.readOnly);
|
||||
announce("Saved");
|
||||
});
|
||||
}
|
||||
// Nothing to do for a theme change: 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()
|
||||
|
||||
@@ -1254,7 +1254,8 @@ bool PrintObject::invalidate_state_by_config_options(
|
||||
|| opt_key == "interlocking_beam_layer_count"
|
||||
|| opt_key == "interlocking_depth"
|
||||
|| opt_key == "interlocking_boundary_avoidance"
|
||||
|| opt_key == "interlocking_beam_width") {
|
||||
|| opt_key == "interlocking_beam_width"
|
||||
|| opt_key == "plugin_config_overrides") {
|
||||
steps.emplace_back(posSlice);
|
||||
} else if (
|
||||
opt_key == "elefant_foot_compensation"
|
||||
|
||||
Reference in New Issue
Block a user