mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-08-03 08:12:07 +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:
@@ -3,18 +3,18 @@
|
||||
# dependencies = ["numpy"]
|
||||
#
|
||||
# [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."
|
||||
# author = "OrcaSlicer"
|
||||
# version = "0.0.1"
|
||||
# author = "SoftFever"
|
||||
# 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
|
||||
usable) with a sidebar of sections, each exercising one part of the API:
|
||||
|
||||
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
|
||||
Model Model -> Object -> Volume/Instance tree with lazy mesh geometry
|
||||
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
|
||||
refetches the visible section.
|
||||
|
||||
Style rules the page follows (see the plugin docs):
|
||||
- no hardcoded colors — only the host-injected --orca-* theme variables, so
|
||||
the panel matches light and dark mode automatically;
|
||||
Style rules the pages follow (see the plugin docs):
|
||||
- no hardcoded theme: BASE_CSS aliases the host-injected --orca-* variables
|
||||
(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);
|
||||
- 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().
|
||||
@@ -45,6 +46,7 @@ transforms need bindings added in July 2026 — on older builds the panel shows
|
||||
what is missing instead of failing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
@@ -125,6 +127,19 @@ def split_config_list(value):
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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():
|
||||
plater = orca.host.plater()
|
||||
model = orca.host.model()
|
||||
@@ -132,17 +147,7 @@ def build_overview():
|
||||
objects = model.objects()
|
||||
|
||||
cfg = bundle.full_config_value
|
||||
colors = split_config_list(cfg("filament_colour"))
|
||||
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,
|
||||
})
|
||||
filaments = filament_slots(bundle)
|
||||
|
||||
printer = bundle.current_printer_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 = [
|
||||
("prints", "Process"),
|
||||
("printers", "Printer"),
|
||||
("filaments", "Filament"),
|
||||
("sla_prints", "SLA print"),
|
||||
("sla_materials", "SLA material"),
|
||||
("prints", "Process"),
|
||||
]
|
||||
|
||||
|
||||
@@ -206,21 +210,25 @@ def build_presets():
|
||||
coll = getattr(bundle, attr)
|
||||
names = coll.preset_names()
|
||||
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({
|
||||
"key": attr,
|
||||
"label": label,
|
||||
"size": coll.size(),
|
||||
"selected_name": coll.selected_preset_name(),
|
||||
"selected_name": selected_name,
|
||||
# selected = as saved on disk; edited = selected + unsaved modifications
|
||||
"selected": {"name": selected.name, "is_dirty": selected.is_dirty,
|
||||
"config_key_count": len(selected.config_keys())},
|
||||
"edited": preset_dict(coll.edited_preset()),
|
||||
"names": names[:MAX_PRESET_NAMES],
|
||||
"names": shown,
|
||||
"truncated": max(0, len(names) - MAX_PRESET_NAMES),
|
||||
})
|
||||
return {
|
||||
"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)
|
||||
if preset is None:
|
||||
raise ValueError(f"preset {name!r} not found")
|
||||
return {"preset": preset_dict(preset),
|
||||
"rows": config_rows(preset.config_keys(), preset.config_value)}
|
||||
return config_rows(preset.config_keys(), preset.config_value)
|
||||
|
||||
|
||||
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>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
:root { --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||
<style>""" + BASE_CSS + r"""
|
||||
body { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||
|
||||
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; }
|
||||
#stamp { flex:1; color:var(--orca-muted); font-size:12px; }
|
||||
button.secondary { background:transparent; color:var(--orca-fg);
|
||||
border-color:var(--orca-border); }
|
||||
#stamp { flex:1; color:var(--muted); font-size:12px; }
|
||||
button.small { padding:2px 10px; font-size:12px; }
|
||||
|
||||
main { flex:1; display:flex; min-height:0; }
|
||||
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;
|
||||
color:var(--orca-muted); border-left:3px solid transparent; }
|
||||
nav .item:hover { color:var(--orca-fg); }
|
||||
nav .item.active { color:var(--orca-fg); font-weight:600;
|
||||
border-left-color:var(--orca-accent); }
|
||||
color:var(--muted); border-left:3px solid transparent; }
|
||||
nav .item:hover { color:var(--fg); }
|
||||
nav .item.active { color:var(--fg); font-weight:600;
|
||||
border-left-color:var(--accent); }
|
||||
nav .glyph { display:inline-block; width:1.5em; }
|
||||
#content { flex:1; overflow:auto; padding:14px 16px 28px; }
|
||||
|
||||
.tiles { display:grid; grid-template-columns:repeat(auto-fill,minmax(108px,1fr));
|
||||
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 .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; }
|
||||
.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; }
|
||||
.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; }
|
||||
""" + 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;
|
||||
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; }
|
||||
|
||||
.badge { display:inline-block; border:1px solid var(--orca-border);
|
||||
color:var(--orca-muted); border-radius:999px; padding:1px 8px;
|
||||
.badge { display:inline-block; border:1px solid var(--border);
|
||||
color:var(--muted); border-radius:999px; padding:1px 8px;
|
||||
font-size:11px; margin:1px 3px 1px 0; white-space:nowrap; }
|
||||
.badge.on { background:var(--orca-accent); border-color:var(--orca-accent);
|
||||
color:var(--orca-accent-fg); }
|
||||
.badge.on { background:var(--accent); border-color:var(--accent);
|
||||
color:var(--accent-fg); }
|
||||
.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 input { flex:1; max-width:340px; }
|
||||
.count { color:var(--orca-muted); font-size:12px; }
|
||||
|
||||
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; }
|
||||
|
||||
.count { color:var(--muted); font-size:12px; }
|
||||
""" + CFG_TABLE_CSS + r"""
|
||||
.node { margin:1px 0; }
|
||||
.nhead { display:flex; align-items:center; gap:7px; padding:4px 8px;
|
||||
border-radius:6px; cursor:pointer; user-select:none; flex-wrap:wrap; }
|
||||
.nhead:hover { background:var(--orca-border); }
|
||||
.twist { width:1em; flex:none; color:var(--orca-muted); font-size:10px; }
|
||||
.nhead:hover { background:var(--border); }
|
||||
.twist { width:1em; flex:none; color:var(--muted); font-size:10px; }
|
||||
.nname { font-weight:600; }
|
||||
.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 > .nbody { display:block; }
|
||||
.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; }
|
||||
.muted { color:var(--orca-muted); }
|
||||
.muted { color:var(--muted); }
|
||||
.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; }
|
||||
.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; }
|
||||
.log div { padding:1px 0; }
|
||||
fieldset { border:1px solid var(--orca-border); border-radius:6px; margin:0 0 10px; }
|
||||
legend { color:var(--orca-muted); font-size:11px; padding:0 6px; }
|
||||
fieldset { border:1px solid var(--border); border-radius:6px; margin:0 0 10px; }
|
||||
legend { color:var(--muted); font-size:11px; padding:0 6px; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -588,7 +642,7 @@ const SECTIONS = [
|
||||
['assembly', '⬡', 'Assembly'],
|
||||
['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 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 yn = b => b ? 'yes' : 'no';
|
||||
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]) =>
|
||||
'<div class="k">' + esc(k) + '</div><div class="v">' + v + '</div>').join('') + '</div>';
|
||||
const bboxRows = (label, bb) => bb ? [
|
||||
@@ -608,7 +665,8 @@ const bboxRows = (label, bb) => bb ? [
|
||||
/* ------------------------------------------------------- nav + fetching */
|
||||
function buildNav() {
|
||||
$('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('');
|
||||
}
|
||||
function show(key) {
|
||||
@@ -622,9 +680,10 @@ function show(key) {
|
||||
function fetchSection(key) {
|
||||
S.busy[key] = true;
|
||||
$('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() {
|
||||
S.gen++; // replies to fetches from before this point are dropped
|
||||
S.cache = {};
|
||||
S.busy = {};
|
||||
show(S.current);
|
||||
@@ -660,13 +719,12 @@ function renderOverview(d) {
|
||||
[t.config_keys, 'config keys'],
|
||||
].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) =>
|
||||
'<div style="margin:3px 0">' +
|
||||
(f.color ? '<span class="swatch" style="background:' + esc(f.color) + '"></span>' : '') +
|
||||
'<b>' + (i + 1) + '</b> ' + esc(f.name) +
|
||||
(f.type ? ' <span class="muted">' + esc(f.type) + '</span>' : '') +
|
||||
badge(f.is_system ? 'system' : 'user', f.is_system) +
|
||||
(f.is_dirty ? badge('modified', true) : '') +
|
||||
const filaments = d.setup.filaments.map(f =>
|
||||
'<div style="margin:3px 0">' + swatch(f.color) +
|
||||
'<b>' + f.slot + '</b> ' + esc(f.preset ? f.preset.name : '<missing preset>') +
|
||||
(f.material ? ' <span class="muted">' + esc(f.material) + '</span>' : '') +
|
||||
(f.preset ? badge(f.preset.is_system ? 'system' : 'user', f.preset.is_system) +
|
||||
(f.preset.is_dirty ? badge('modified', true) : '') : '') +
|
||||
'</div>').join('') || '<span class="muted">none</span>';
|
||||
|
||||
return '<div class="tiles">' + tiles + '</div><div class="cards">' +
|
||||
@@ -709,35 +767,65 @@ function presetBadges(p) {
|
||||
(p.is_visible ? '' : badge('hidden', false));
|
||||
}
|
||||
function renderPresets(d) {
|
||||
return '<div class="banner">Each card is one <span class="mono">host.PresetCollection</span>. ' +
|
||||
'“Edited” is the selected preset plus any unsaved changes. Pick any preset ' +
|
||||
'and load its complete config via <span class="mono">find_preset().config_value()</span>.</div>' +
|
||||
'<div class="cards">' + d.collections.map(c => {
|
||||
const p = c.edited;
|
||||
const options = c.names.map(n => // explicit value= so odd whitespace in names survives
|
||||
'<option value="' + esc(n) + '"' + (n === c.selected_name ? ' selected' : '') + '>' +
|
||||
const detailCard = g => {
|
||||
const p = g.edited;
|
||||
return '<div class="cards"><div class="card hued">' +
|
||||
'<div style="margin-bottom:6px"><b>' + esc(p.name) + '</b> ' + presetBadges(p) + '</div>' +
|
||||
kv([['label()', esc(p.label)], ['alias', esc(p.alias) || '—'],
|
||||
['type', esc(p.type)], ['bundle', esc(p.bundle_id) || '—'],
|
||||
['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('');
|
||||
return '<div class="card"><h3>' + esc(c.label) + ' · ' + c.size + ' presets</h3>' +
|
||||
'<div style="margin-bottom:6px"><b>' + esc(p.name) + '</b> ' + presetBadges(p) + '</div>' +
|
||||
kv([['label()', esc(p.label)], ['alias', esc(p.alias) || '—'],
|
||||
['type', esc(p.type)], ['bundle', esc(p.bundle_id) || '—'],
|
||||
['edited_preset()', p.config_key_count + ' keys' +
|
||||
(p.is_dirty ? ' · has unsaved changes' : ' · same as saved')],
|
||||
['selected_preset()', esc(c.selected.name) + ' · ' + c.selected.config_key_count + ' keys'],
|
||||
['file', '<span class="muted">' + esc(p.file) + '</span>']]) +
|
||||
'<div class="frow" style="margin-top:8px">' +
|
||||
'<select id="sel-' + c.key + '" style="flex:1;min-width:0">' + options + '</select>' +
|
||||
'<button class="small" data-act="pcfg" data-coll="' + c.key + '">View config</button>' +
|
||||
'</div>' +
|
||||
(c.truncated ? '<p class="muted">list capped, ' + c.truncated + ' more not shown</p>' : '') +
|
||||
'<div id="pcfg-' + c.key + '"></div></div>';
|
||||
}).join('') + '</div>';
|
||||
const cards = g.key === 'filaments'
|
||||
? '<div class="cards">' + (d.filament_slots.map(s => slotCard(s, g)).join('') ||
|
||||
'<p class="muted">no filament slots</p>') + '</div>'
|
||||
: detailCard(g);
|
||||
return '<div class="pgroup hue-' + g.key + '">' +
|
||||
'<div class="pgroup-head"><span class="dot"></span><h2>' + esc(g.label) + '</h2>' +
|
||||
'<span class="count">' + g.size + ' presets</span><span class="spacer"></span>' +
|
||||
'<select id="sel-' + g.key + '">' + options + '</select>' +
|
||||
'<button class="small" data-act="pcfg" data-coll="' + g.key + '">View config</button></div>' +
|
||||
cards +
|
||||
(g.truncated ? '<p class="muted">select capped, ' + g.truncated + ' more not listed</p>' : '') +
|
||||
// the config itself opens in a viewer window; this slot only shows failures
|
||||
'<div id="pcfg-' + g.key + '"></div></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function configTable(rows, id) {
|
||||
const body = rows.map(r => {
|
||||
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);
|
||||
return '<tr data-k="' + esc(r.k.toLowerCase()) + '"><td>' + esc(r.k) + '</td>' +
|
||||
'<td class="val" data-full="' + esc(r.v) + '">' + shown + '</td></tr>';
|
||||
@@ -746,37 +834,36 @@ function configTable(rows, id) {
|
||||
}
|
||||
function renderConfig(d) {
|
||||
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>' +
|
||||
'<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 ' +
|
||||
'every key in <span class="mono">full_config_keys()</span>.</div>' +
|
||||
configTable(d.rows, 'cfg-table');
|
||||
}
|
||||
function filterConfig(q) {
|
||||
function filterTable(tableId, q, countId) {
|
||||
q = q.trim().toLowerCase();
|
||||
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) ||
|
||||
tr.querySelector('.val').dataset.full.toLowerCase().includes(q);
|
||||
tr.style.display = hit ? '' : 'none';
|
||||
if (hit) visible++;
|
||||
});
|
||||
$('cfg-count').textContent = visible + ' keys';
|
||||
if (countId) $(countId).textContent = visible + ' keys';
|
||||
}
|
||||
|
||||
function extruderChip(id) {
|
||||
if (id == null || id < 1) return '<span class="badge">extruder —</span>';
|
||||
const color = S.colors[id - 1];
|
||||
return '<span class="badge">' + (color ? '<span class="swatch" style="background:' +
|
||||
esc(color) + '"></span>' : '') + 'extruder ' + id + '</span>';
|
||||
return '<span class="badge">' + swatch(S.colors[id - 1]) + 'extruder ' + id + '</span>';
|
||||
}
|
||||
const VOL_GLYPH = { ModelPart:'◆', NegativeVolume:'◇', ParameterModifier:'▨',
|
||||
SupportEnforcer:'▲', SupportBlocker:'▽' };
|
||||
|
||||
function node(head, body, 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>';
|
||||
}
|
||||
function paintedBadges(p) {
|
||||
@@ -969,7 +1056,7 @@ function drawPoints(canvas, pts, ax, ay) {
|
||||
PREVIEWS[canvas.id] = { pts, ax, ay };
|
||||
const scaleDpr = window.devicePixelRatio || 1;
|
||||
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.width = w; canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -983,7 +1070,7 @@ function drawPoints(canvas, pts, ax, ay) {
|
||||
(h - 2 * pad) / Math.max(maxY - minY, 1e-6));
|
||||
const ox = (w - (maxX - minX) * scale) / 2, oy = (h - (maxY - minY) * scale) / 2;
|
||||
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.fillStyle = style.color;
|
||||
ctx.globalAlpha = 0.55;
|
||||
@@ -992,6 +1079,21 @@ function drawPoints(canvas, pts, ax, ay) {
|
||||
ctx.fillRect(ox + (p[ax] - minX) * scale - r / 2,
|
||||
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 */
|
||||
function renderUikit() {
|
||||
@@ -1052,6 +1154,7 @@ $('content').addEventListener('click', e => {
|
||||
if (act === 'toggle') {
|
||||
const node = t.parentElement;
|
||||
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.
|
||||
node.querySelectorAll('canvas.preview').forEach(c => {
|
||||
const p = PREVIEWS[c.id];
|
||||
@@ -1062,9 +1165,10 @@ $('content').addEventListener('click', e => {
|
||||
t.textContent = 'Loading…';
|
||||
orca.postMessage({ command:'mesh', object:+t.dataset.o, volume:+t.dataset.v });
|
||||
} 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,
|
||||
name:$('sel-' + coll).value });
|
||||
name:t.dataset.name !== undefined ? t.dataset.name
|
||||
: $('sel-' + coll).value });
|
||||
} else if (act === 'expand') {
|
||||
const td = t.closest('td');
|
||||
td.textContent = td.dataset.full;
|
||||
@@ -1072,11 +1176,22 @@ $('content').addEventListener('click', e => {
|
||||
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 */
|
||||
orca.onMessage(msg => {
|
||||
if (!msg || !msg.command) return;
|
||||
if (msg.command === 'section') {
|
||||
if (msg.gen !== S.gen) return; // raced a Refresh; the refetch is in flight
|
||||
S.cache[msg.section] = msg;
|
||||
S.busy[msg.section] = false;
|
||||
stamp();
|
||||
@@ -1085,14 +1200,15 @@ orca.onMessage(msg => {
|
||||
const container = $('mesh-' + msg.object + '-' + msg.volume);
|
||||
if (!container) return;
|
||||
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') {
|
||||
// Success opened a viewer window; this slot only shows (or clears) failures.
|
||||
const container = $('pcfg-' + msg.collection);
|
||||
if (!container) return;
|
||||
container.innerHTML = msg.ok
|
||||
? '<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>';
|
||||
container.innerHTML = msg.ok ? '' : '<p class="muted">⚠ ' + esc(msg.error) + '</p>';
|
||||
} else if (msg.command === 'ui_result') {
|
||||
addLog(msg.ok ? '← ' + msg.action + ': ' + msg.result
|
||||
: '← ' + 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)
|
||||
# keeps the handle alive for callbacks; orca.submit(payload) invokes on_submit.
|
||||
MODAL_PAGE = r"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"></head>
|
||||
<body style="padding:18px">
|
||||
<h3 style="margin-top:0">Modal dialog</h3>
|
||||
<html lang="en"><head><meta charset="utf-8"><style>""" + BASE_CSS + r"""
|
||||
body { padding:18px; }
|
||||
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><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">
|
||||
<button style="background:transparent;color:var(--orca-fg);border-color:var(--orca-border)"
|
||||
onclick="orca.postMessage({live: document.getElementById('note').value})">Post while open</button>
|
||||
<button style="background:transparent;color:var(--orca-fg);border-color:var(--orca-border)"
|
||||
onclick="orca.close()">Cancel</button>
|
||||
<button class="secondary" onclick="orca.postMessage({live: document.getElementById('note').value})">Post while open</button>
|
||||
<button class="secondary" onclick="orca.close()">Cancel</button>
|
||||
<button onclick="orca.submit({note: document.getElementById('note').value})">Submit</button>
|
||||
</p>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
CHILD_PAGE = r"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"></head>
|
||||
<body style="padding:18px">
|
||||
<h3 style="margin-top:0">Child window</h3>
|
||||
<html lang="en"><head><meta charset="utf-8"><style>""" + BASE_CSS + r"""
|
||||
body { padding:18px; }
|
||||
h3 { margin-top:0; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h3>Child window</h3>
|
||||
<p>Same create_window() API — one plugin, several windows.</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>
|
||||
var n = 0;
|
||||
orca.onMessage(function (msg) {
|
||||
@@ -1142,6 +1263,75 @@ CHILD_PAGE = r"""<!DOCTYPE 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
|
||||
@@ -1150,6 +1340,7 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
|
||||
win = None
|
||||
child = None
|
||||
modal = None
|
||||
cfgwin = None
|
||||
|
||||
def get_name(self):
|
||||
return "Orca Inspector"
|
||||
@@ -1166,6 +1357,9 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
|
||||
if self.modal is not None:
|
||||
self.modal.close()
|
||||
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
|
||||
# after execute() returns; on_message keeps firing when the page posts.
|
||||
self.win = orca.host.ui.create_window(
|
||||
@@ -1184,11 +1378,11 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
|
||||
msg = msg or {}
|
||||
command = msg.get("command")
|
||||
if command == "fetch":
|
||||
self.send_section(msg.get("section", ""))
|
||||
self.send_section(msg.get("section", ""), msg.get("gen"))
|
||||
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":
|
||||
self.send_preset_config(msg.get("collection", ""), msg.get("name", ""))
|
||||
self.open_preset_config(msg.get("collection", ""), msg.get("name", ""))
|
||||
elif command == "ui":
|
||||
self.run_ui_action(msg)
|
||||
|
||||
@@ -1196,11 +1390,15 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
|
||||
if self.child is not None:
|
||||
self.child.close()
|
||||
self.child = None
|
||||
if self.cfgwin is not None:
|
||||
self.cfgwin.close()
|
||||
self.cfgwin = None
|
||||
print("Orca Inspector closed")
|
||||
|
||||
def send_section(self, section):
|
||||
def send_section(self, section, gen=None):
|
||||
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:
|
||||
self.win.post({**reply, "ok": False, "error": f"unknown section {section!r}"})
|
||||
return
|
||||
@@ -1213,15 +1411,23 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
|
||||
reply = {"command": "mesh", "object": object_index, "volume": volume_index}
|
||||
try:
|
||||
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:
|
||||
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}
|
||||
try:
|
||||
self.win.post({**reply, "ok": True,
|
||||
"data": build_preset_config(collection, name)})
|
||||
rows = 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:
|
||||
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():
|
||||
report("child already open")
|
||||
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(
|
||||
title="Orca Inspector — child", html=CHILD_PAGE,
|
||||
width=380, height=240, on_message=self.on_child_message,
|
||||
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")
|
||||
elif action == "child_close":
|
||||
if self.child is not None:
|
||||
self.child.close()
|
||||
report("close requested") # the actual close is logged by on_close
|
||||
else:
|
||||
report("no child window")
|
||||
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)
|
||||
Reference in New Issue
Block a user