mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-08-01 15:22:21 +00:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
105
resources/web/dialog/js/plugin-config-ui.js
Normal file
105
resources/web/dialog/js/plugin-config-ui.js
Normal 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"]
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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":
|
||||||
618
sandboxes/orca_twistify_plugin_any.py
Normal file
618
sandboxes/orca_twistify_plugin_any.py
Normal 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)
|
||||||
@@ -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)
|
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user