partforge 0.47.1 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +454 -80
- package/docs/ERROR-PATTERNS.md +24 -0
- package/package.json +1 -1
- package/src/framework/app.css +30 -2
- package/src/framework/lint/rules-schema.js +359 -21
- package/src/framework/mount.js +8 -1
- package/src/framework/oracle/cases.js +17 -6
- package/src/framework/panel/author.js +88 -0
- package/src/framework/panel/legacy.js +9 -1
- package/src/framework/panel/render.js +46 -6
- package/src/framework/panel/widget-specs.js +58 -0
- package/src/framework/panel/widgets/index.js +3 -0
- package/src/framework/panel/widgets/numeric.js +66 -7
- package/src/framework/panel/widgets/readout.js +31 -0
- package/src/framework/panel/widgets/select.js +71 -0
- package/src/parts/bracket.js +5 -5
- package/src/parts/planter.js +23 -17
- package/types/part.d.ts +96 -5
|
@@ -5,6 +5,7 @@ import { desugar } from "./legacy.js";
|
|
|
5
5
|
import { buildTree, controlNodes } from "./model.js";
|
|
6
6
|
import { computeState } from "./panel-state.js";
|
|
7
7
|
import { WIDGET_FACTORIES } from "./widgets/index.js";
|
|
8
|
+
import { makeReadout } from "./widgets/readout.js";
|
|
8
9
|
import { createInfoPopover, attachInfo } from "./info.js";
|
|
9
10
|
|
|
10
11
|
function el(tag, className, text) {
|
|
@@ -28,9 +29,10 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
28
29
|
const tree = buildTree(desugar(parameters));
|
|
29
30
|
|
|
30
31
|
const nodeEls = new Map(); // id -> the element whose visibility we toggle
|
|
32
|
+
const displayUpdates = new Map(); // id -> a display widget's update(derived)
|
|
31
33
|
const groupIds = new Set(); // ids that are group/section wrappers, never controls
|
|
32
34
|
const syncFns = []; // { key, sync } for every widget
|
|
33
|
-
const rawSyncs = new Map(); // sectionId -> { key
|
|
35
|
+
const rawSyncs = new Map(); // sectionId -> [{ key, sync }] for preset application
|
|
34
36
|
const widgetSyncs = new Map(); // id -> the RAW widget sync (no markCustom)
|
|
35
37
|
const nodeById = new Map(); // id -> node, for the reveal re-sync
|
|
36
38
|
const lastVisible = new Map(); // id -> previous `visible`, to detect a reveal
|
|
@@ -84,7 +86,15 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
84
86
|
// entirely when `disabled` hasn't changed also keeps this off the hot
|
|
85
87
|
// path (applyState runs on every slider drag and relevance update).
|
|
86
88
|
if (!isGroup && lastDisabled.get(id) !== s.disabled) {
|
|
87
|
-
|
|
89
|
+
if (node.matches?.("input, select, textarea")) node.disabled = s.disabled;
|
|
90
|
+
// `.seg button` is in the list for the radio widget, whose options are
|
|
91
|
+
// <button>s: without it a disabled radio stayed keyboard-focusable and
|
|
92
|
+
// only LOOKED disabled. Scoped to `.seg` rather than every button so a
|
|
93
|
+
// disabled control's ⓘ glyph stays reachable — the popover is where the
|
|
94
|
+
// author explains what has to be enabled first, which is exactly what
|
|
95
|
+
// the reader wants here. (Group nodes skip this branch entirely, so the
|
|
96
|
+
// section and fold disclosure buttons are never touched either way.)
|
|
97
|
+
for (const input of node.querySelectorAll("input, select, textarea, .seg button")) {
|
|
88
98
|
input.disabled = s.disabled;
|
|
89
99
|
}
|
|
90
100
|
lastDisabled.set(id, s.disabled);
|
|
@@ -125,7 +135,9 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
125
135
|
|
|
126
136
|
const wrap = el("div", "adv-wrap");
|
|
127
137
|
const body = el("div", "adv hidden"); // starts closed — legacy parity
|
|
138
|
+
body.id = `pf-fold-${node.id.replaceAll("/", "-")}`;
|
|
128
139
|
const toggle = el("button", "adv-toggle", `${node.title} ▾`);
|
|
140
|
+
toggle.setAttribute("aria-controls", body.id);
|
|
129
141
|
toggle.addEventListener("click", () => {
|
|
130
142
|
const nowHidden = body.classList.toggle("hidden");
|
|
131
143
|
toggle.textContent = nowHidden ? `${node.title} ▾` : `${node.title} ▴`;
|
|
@@ -142,6 +154,11 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
142
154
|
// section's controls through their RAW syncs — a preset application must not
|
|
143
155
|
// mark itself Custom (controls.test.js:366).
|
|
144
156
|
function renderPreset(node, container, sectionCtx) {
|
|
157
|
+
if (node.label) {
|
|
158
|
+
const row = el("div", "row");
|
|
159
|
+
row.append(el("label", "", node.label));
|
|
160
|
+
container.append(row);
|
|
161
|
+
}
|
|
145
162
|
const names = Object.keys(node.presets);
|
|
146
163
|
const select = document.createElement("select");
|
|
147
164
|
select.className = "preset";
|
|
@@ -154,7 +171,7 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
154
171
|
const bundle = node.presets[select.value];
|
|
155
172
|
if (!bundle) return; // "Custom"
|
|
156
173
|
Object.assign(params, bundle);
|
|
157
|
-
for (const
|
|
174
|
+
for (const { key, sync } of rawSyncs.get(sectionCtx.id)) if (key in params) sync();
|
|
158
175
|
onEdit();
|
|
159
176
|
});
|
|
160
177
|
// The section's controls need a handle on the picker to drop it to Custom
|
|
@@ -167,6 +184,13 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
167
184
|
function renderNode(node, container, sectionCtx) {
|
|
168
185
|
if (node.kind === "group") { renderGroup(node, container, sectionCtx); return; }
|
|
169
186
|
if (node.kind === "preset") { renderPreset(node, container, sectionCtx); return; }
|
|
187
|
+
if (node.kind === "display") {
|
|
188
|
+
const widget = makeReadout(node, { info });
|
|
189
|
+
nodeEls.set(node.id, widget.el);
|
|
190
|
+
displayUpdates.set(node.id, widget.update);
|
|
191
|
+
container.append(widget.el);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
170
194
|
|
|
171
195
|
const factory = WIDGET_FACTORIES[node.type];
|
|
172
196
|
if (!factory) return; // unknown type: lint reports it; the panel skips it
|
|
@@ -190,7 +214,7 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
190
214
|
// syncValues() uses, and for a preset-section control it does drop the
|
|
191
215
|
// picker to Custom (controls.test.js:350), because a programmatic edit
|
|
192
216
|
// diverges from the preset exactly as a user edit does.
|
|
193
|
-
if (sectionCtx) rawSyncs.get(sectionCtx.id).
|
|
217
|
+
if (sectionCtx) rawSyncs.get(sectionCtx.id).push({ key: node.key, sync: widget.sync });
|
|
194
218
|
syncFns.push({
|
|
195
219
|
key: node.key,
|
|
196
220
|
sync: () => { widget.sync(); markCustom(); },
|
|
@@ -218,6 +242,8 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
218
242
|
secEl.append(header);
|
|
219
243
|
|
|
220
244
|
const body = el("div", "sec-body");
|
|
245
|
+
body.id = `pf-sec-${section.id.replaceAll("/", "-")}`;
|
|
246
|
+
title.setAttribute("aria-controls", body.id);
|
|
221
247
|
secEl.append(body);
|
|
222
248
|
|
|
223
249
|
title.addEventListener("click", () => {
|
|
@@ -229,7 +255,7 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
229
255
|
// `preset` is filled in when a preset node renders. Controls read it late, so
|
|
230
256
|
// one appearing after them in the children array still works.
|
|
231
257
|
const ctx = { id: section.id, preset: null };
|
|
232
|
-
rawSyncs.set(section.id,
|
|
258
|
+
rawSyncs.set(section.id, []);
|
|
233
259
|
|
|
234
260
|
for (const child of section.children) renderNode(child, body, ctx);
|
|
235
261
|
root.append(secEl);
|
|
@@ -237,8 +263,22 @@ export function buildControls(root, parameters, params, onDirty) {
|
|
|
237
263
|
|
|
238
264
|
applyState();
|
|
239
265
|
|
|
266
|
+
// The single entry point for a param change: relevance dims/undims controls,
|
|
267
|
+
// derived pushes fresh values into every readout. Either argument may be
|
|
268
|
+
// omitted (mount.js's initial call, or a syncValues-only path) — only what's
|
|
269
|
+
// passed updates. `applyRelevance` is the old name, kept as a thin delegate
|
|
270
|
+
// so existing callers (and mount.test.js) don't have to change.
|
|
271
|
+
const refresh = ({ relevant: nextRelevant, derived } = {}) => {
|
|
272
|
+
if (nextRelevant !== undefined) relevant = nextRelevant;
|
|
273
|
+
if (derived !== undefined) {
|
|
274
|
+
for (const update of displayUpdates.values()) update(derived);
|
|
275
|
+
}
|
|
276
|
+
applyState();
|
|
277
|
+
};
|
|
278
|
+
|
|
240
279
|
return {
|
|
241
|
-
|
|
280
|
+
refresh,
|
|
281
|
+
applyRelevance: (next) => refresh({ relevant: next }),
|
|
242
282
|
syncValues: (keys) => {
|
|
243
283
|
const only = keys && new Set(keys);
|
|
244
284
|
for (const { key, sync } of syncFns) if (!only || only.has(key)) sync();
|
|
@@ -21,12 +21,22 @@ const LEGACY_CONTROL = ["key", "label", "unit", "min", "max", "step", "control",
|
|
|
21
21
|
// What it called TOGGLE_FIELDS.
|
|
22
22
|
const LEGACY_TOGGLE = ["key", "label", "on", "hidden", "description"];
|
|
23
23
|
|
|
24
|
+
// The authored shape (author.js's normalized node tree): every control carries
|
|
25
|
+
// `type`, and `when`/`whenFalse` are real fields there (phase 6 landed them for
|
|
26
|
+
// this shape only — the legacy lists above stay frozen so a legacy `when` still
|
|
27
|
+
// warns). select/radio are new-shape-only types (no legacy equivalent), so
|
|
28
|
+
// their WIDGET_SPECS `fields` use this list directly rather than a legacy one.
|
|
29
|
+
const AUTHOR_COMMON = ["key", "type", "label", "description", "hidden", "when", "whenFalse"];
|
|
30
|
+
|
|
24
31
|
export const WIDGET_SPECS = [
|
|
25
32
|
{ type: "slider", kind: "control", fields: LEGACY_CONTROL },
|
|
26
33
|
{ type: "number", kind: "control", fields: LEGACY_CONTROL },
|
|
27
34
|
{ type: "text", kind: "control", fields: LEGACY_CONTROL },
|
|
28
35
|
{ type: "textarea", kind: "control", fields: LEGACY_CONTROL },
|
|
29
36
|
{ type: "checkbox", kind: "control", fields: LEGACY_TOGGLE },
|
|
37
|
+
{ type: "select", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
|
|
38
|
+
{ type: "radio", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
|
|
39
|
+
{ type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
|
|
30
40
|
];
|
|
31
41
|
|
|
32
42
|
const BY_TYPE = new Map(WIDGET_SPECS.map((s) => [s.type, s]));
|
|
@@ -34,3 +44,51 @@ const BY_TYPE = new Map(WIDGET_SPECS.map((s) => [s.type, s]));
|
|
|
34
44
|
export const WIDGET_TYPES = WIDGET_SPECS.map((s) => s.type);
|
|
35
45
|
export const specFor = (type) => BY_TYPE.get(type);
|
|
36
46
|
export const fieldsFor = (type) => BY_TYPE.get(type)?.fields ?? [];
|
|
47
|
+
|
|
48
|
+
// Per-type extras beyond AUTHOR_COMMON.
|
|
49
|
+
const AUTHOR_EXTRAS = {
|
|
50
|
+
slider: ["unit", "min", "max", "step", "scale", "ticks", "snap", "recommended"],
|
|
51
|
+
number: ["unit", "min", "max", "step", "scale", "ticks", "snap", "recommended"],
|
|
52
|
+
text: [],
|
|
53
|
+
textarea: [],
|
|
54
|
+
checkbox: ["on"],
|
|
55
|
+
select: ["options"],
|
|
56
|
+
radio: ["options"],
|
|
57
|
+
};
|
|
58
|
+
const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
|
|
59
|
+
([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));
|
|
60
|
+
// readout is a display, not a control — it has no `key`, so it doesn't compose
|
|
61
|
+
// with AUTHOR_COMMON like the control types above. Its author-facing fields
|
|
62
|
+
// are exactly its WIDGET_SPECS fields.
|
|
63
|
+
AUTHOR_FIELDS.set("readout", specFor("readout").fields);
|
|
64
|
+
// An unrecognised type (a typo like "sldier") falls back to AUTHOR_COMMON
|
|
65
|
+
// rather than []: with [], every field on the descriptor — including "key"
|
|
66
|
+
// and "label" — reads as unrecognised, so a single typo cascades into a wall
|
|
67
|
+
// of unknown-control-field warnings with nothing pointing at the real cause.
|
|
68
|
+
// lint's unknown-control-type rule (rules-schema.js) is what actually names
|
|
69
|
+
// the typo; this fallback just keeps the field-level noise from drowning it.
|
|
70
|
+
export const authorFieldsFor = (type) => AUTHOR_FIELDS.get(type) ?? AUTHOR_COMMON;
|
|
71
|
+
|
|
72
|
+
// Container node types in the authored tree. Not widget types — no DOM factory
|
|
73
|
+
// looks them up — so, like the legacy FEATURE_FIELDS/TOGGLE_FIELDS, they keep
|
|
74
|
+
// explicit field lists here rather than living in WIDGET_SPECS.
|
|
75
|
+
export const GROUP_FIELDS = ["type", "id", "title", "collapsed", "bare", "when", "whenFalse", "hidden", "controls"];
|
|
76
|
+
// NB: no "description" — renderGroup has nowhere to hang an info glyph (the
|
|
77
|
+
// toggle is itself a button). Sections keep descriptions (SECTION_FIELDS).
|
|
78
|
+
export const PRESET_FIELDS = ["type", "id", "label", "presets", "when", "whenFalse", "hidden"];
|
|
79
|
+
// A section itself, in the authored shape — collectDescriptors pushes it as a
|
|
80
|
+
// descriptor only when it carries a `when`, so `when-key-not-in-defaults` and
|
|
81
|
+
// `when-unknown-operator` cover section-level conditions too.
|
|
82
|
+
export const SECTION_FIELDS = ["id", "title", "description", "hidden", "collapsed", "when", "whenFalse", "controls"];
|
|
83
|
+
|
|
84
|
+
// select/radio option normalization: long form [{ value, label?, description? }]
|
|
85
|
+
// or shorthand ["round", 8, ...] where each entry is both value and label. Lives
|
|
86
|
+
// here rather than in the select widget because lint's validators consume it
|
|
87
|
+
// and must stay DOM-free (widgets/select.js imports info.js -> markdown.js).
|
|
88
|
+
export function normalizeOptions(options) {
|
|
89
|
+
return (Array.isArray(options) ? options : [])
|
|
90
|
+
.filter((o) => o != null)
|
|
91
|
+
.map((o) => (typeof o === "object"
|
|
92
|
+
? { value: o.value, label: o.label ?? String(o.value), description: o.description }
|
|
93
|
+
: { value: o, label: String(o) }));
|
|
94
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { makeNumeric } from "./numeric.js";
|
|
4
4
|
import { makeText } from "./text.js";
|
|
5
5
|
import { makeCheckbox } from "./checkbox.js";
|
|
6
|
+
import { makeSelect, makeRadio } from "./select.js";
|
|
6
7
|
|
|
7
8
|
export const WIDGET_FACTORIES = {
|
|
8
9
|
slider: makeNumeric,
|
|
@@ -10,4 +11,6 @@ export const WIDGET_FACTORIES = {
|
|
|
10
11
|
text: makeText,
|
|
11
12
|
textarea: makeText,
|
|
12
13
|
checkbox: makeCheckbox,
|
|
14
|
+
select: makeSelect,
|
|
15
|
+
radio: makeRadio,
|
|
13
16
|
};
|
|
@@ -38,26 +38,82 @@ export function makeNumeric(node, params, { onChange, info }) {
|
|
|
38
38
|
row.append(val);
|
|
39
39
|
wrap.append(row);
|
|
40
40
|
|
|
41
|
+
// A log track maps thumb position 0..LOG_STEPS onto [min, max] geometrically —
|
|
42
|
+
// the value box stays linear and exact (see AUTHORING-PARTS.md's "Slider
|
|
43
|
+
// refinements" section). Only valid when min > 0; lint's log-scale-needs-positive-min
|
|
44
|
+
// catches an authored part that violates that before it ever reaches here.
|
|
45
|
+
const LOG_STEPS = 1000;
|
|
46
|
+
const log = node.scale === "log" && node.min > 0;
|
|
47
|
+
const toValue = (t) => Math.exp(Math.log(node.min) + (t / LOG_STEPS) * (Math.log(node.max) - Math.log(node.min)));
|
|
48
|
+
const toPos = (v) => Math.round(LOG_STEPS * (Math.log(v) - Math.log(node.min)) / (Math.log(node.max) - Math.log(node.min)));
|
|
49
|
+
// toPos(0) is -Infinity and a non-finite assignment to slider.value snaps the
|
|
50
|
+
// thumb to mid-track instead of an end — guard the live-typed, unclamped box
|
|
51
|
+
// value before it reaches the slider.
|
|
52
|
+
const toPosSafe = (v) => {
|
|
53
|
+
if (!(v > 0)) return 0;
|
|
54
|
+
const t = toPos(v);
|
|
55
|
+
return Math.max(0, Math.min(LOG_STEPS, Number.isFinite(t) ? t : 0));
|
|
56
|
+
};
|
|
57
|
+
|
|
41
58
|
let slider = null;
|
|
42
59
|
if (!numeric) {
|
|
43
60
|
slider = document.createElement("input");
|
|
44
61
|
slider.type = "range";
|
|
45
|
-
slider.min = node.min; slider.max = node.max; slider.step = node.step;
|
|
46
|
-
slider.value = params[node.key];
|
|
62
|
+
slider.min = log ? 0 : node.min; slider.max = log ? LOG_STEPS : node.max; slider.step = log ? 1 : node.step;
|
|
63
|
+
slider.value = log ? toPosSafe(params[node.key]) : params[node.key];
|
|
47
64
|
slider.addEventListener("input", () => {
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
const v = log ? toValue(+slider.value) : snapTo(+slider.value);
|
|
66
|
+
params[node.key] = v;
|
|
67
|
+
box.value = numStr(v);
|
|
68
|
+
paintWarn();
|
|
50
69
|
onChange?.();
|
|
51
70
|
});
|
|
52
71
|
wrap.append(slider);
|
|
53
72
|
}
|
|
54
73
|
|
|
74
|
+
// ticks: native datalist marks; snap quantizes input to the nearest tick.
|
|
75
|
+
// The datalist id derives from the node id (assigned by buildTree before
|
|
76
|
+
// factories run) — stable across re-renders, no randomness.
|
|
77
|
+
if (slider && !log && Array.isArray(node.ticks) && node.ticks.length) {
|
|
78
|
+
const dl = document.createElement("datalist");
|
|
79
|
+
dl.id = `pf-ticks-${node.id.replaceAll("/", "-")}`;
|
|
80
|
+
for (const t of node.ticks) {
|
|
81
|
+
const o = document.createElement("option");
|
|
82
|
+
o.value = String(t);
|
|
83
|
+
dl.append(o);
|
|
84
|
+
}
|
|
85
|
+
wrap.append(dl);
|
|
86
|
+
slider.setAttribute("list", dl.id);
|
|
87
|
+
}
|
|
88
|
+
const snapTo = (v) => {
|
|
89
|
+
if (!node.snap || !Array.isArray(node.ticks) || !node.ticks.length) return v;
|
|
90
|
+
return node.ticks.reduce((best, t) => Math.abs(t - v) < Math.abs(best - v) ? t : best);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// recommended: a tinted band of the track, and a warning on the value box
|
|
94
|
+
// when the current value sits outside it. Linear tracks only (like ticks).
|
|
95
|
+
const band = !log && Array.isArray(node.recommended) && node.recommended.length === 2
|
|
96
|
+
? node.recommended : null;
|
|
97
|
+
if (band) {
|
|
98
|
+
wrap.classList.add("has-band");
|
|
99
|
+
const pct = (v) => {
|
|
100
|
+
const raw = Math.max(0, Math.min(100, ((v - node.min) / (node.max - node.min)) * 100));
|
|
101
|
+
return `${Math.round(raw * 1e4) / 1e4}%`; // trim float noise (e.g. 12.499999999999996)
|
|
102
|
+
};
|
|
103
|
+
wrap.style.setProperty("--band-lo", pct(band[0]));
|
|
104
|
+
wrap.style.setProperty("--band-hi", pct(band[1]));
|
|
105
|
+
}
|
|
106
|
+
const paintWarn = () => {
|
|
107
|
+
if (band) box.classList.toggle("warn", params[node.key] < band[0] || params[node.key] > band[1]);
|
|
108
|
+
};
|
|
109
|
+
|
|
55
110
|
// live preview while typing (unclamped); clamp + reformat on commit
|
|
56
111
|
box.addEventListener("input", () => {
|
|
57
112
|
const v = parseFloat(box.value);
|
|
58
113
|
if (!Number.isFinite(v)) return;
|
|
59
114
|
params[node.key] = v;
|
|
60
|
-
if (slider) slider.value = v;
|
|
115
|
+
if (slider) slider.value = log ? toPosSafe(v) : v;
|
|
116
|
+
paintWarn();
|
|
61
117
|
onChange?.();
|
|
62
118
|
});
|
|
63
119
|
box.addEventListener("change", () => {
|
|
@@ -65,13 +121,16 @@ export function makeNumeric(node, params, { onChange, info }) {
|
|
|
65
121
|
if (v == null) { box.value = numStr(params[node.key]); return; } // revert invalid input
|
|
66
122
|
params[node.key] = v;
|
|
67
123
|
box.value = numStr(v);
|
|
68
|
-
if (slider) slider.value = v;
|
|
124
|
+
if (slider) slider.value = log ? toPosSafe(v) : v;
|
|
125
|
+
paintWarn();
|
|
69
126
|
onChange?.();
|
|
70
127
|
});
|
|
71
128
|
|
|
72
129
|
const sync = () => {
|
|
73
130
|
box.value = numStr(params[node.key]);
|
|
74
|
-
if (slider) slider.value = params[node.key];
|
|
131
|
+
if (slider) slider.value = log ? toPosSafe(params[node.key]) : params[node.key];
|
|
132
|
+
paintWarn();
|
|
75
133
|
};
|
|
134
|
+
paintWarn();
|
|
76
135
|
return { el: wrap, sync };
|
|
77
136
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// readout: a read-only display of one derive() output, named by `derivedKey`.
|
|
2
|
+
// A display node, not a control — it has no key, never writes params, and gets
|
|
3
|
+
// its value pushed via panel.refresh({ derived }), not pulled from params.
|
|
4
|
+
import { attachInfo } from "../info.js";
|
|
5
|
+
|
|
6
|
+
function el(tag, className, text) {
|
|
7
|
+
const node = document.createElement(tag);
|
|
8
|
+
if (className) node.className = className;
|
|
9
|
+
if (text != null) node.textContent = text;
|
|
10
|
+
return node;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Same float-noise trim the numeric widgets use (4 dp max).
|
|
14
|
+
const numStr = (v) => String(Math.round(v * 1e4) / 1e4);
|
|
15
|
+
|
|
16
|
+
export function makeReadout(node, { info }) {
|
|
17
|
+
const wrap = el("div", "slider readout");
|
|
18
|
+
const row = el("div", "row");
|
|
19
|
+
const label = el("label", "", node.label);
|
|
20
|
+
attachInfo(label, node.description, info);
|
|
21
|
+
const val = el("div", "val", "—");
|
|
22
|
+
row.append(label, val);
|
|
23
|
+
wrap.append(row);
|
|
24
|
+
const update = (derived) => {
|
|
25
|
+
const v = derived?.[node.derivedKey];
|
|
26
|
+
val.textContent = v == null ? "—"
|
|
27
|
+
: typeof v === "number" ? numStr(v) + (node.unit ? ` ${node.unit}` : "")
|
|
28
|
+
: String(v);
|
|
29
|
+
};
|
|
30
|
+
return { el: wrap, update };
|
|
31
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// select: a dropdown over `options`. radio: the same data as a segmented
|
|
2
|
+
// control (reuses the app's existing `.seg` styling), for 2–4 options where
|
|
3
|
+
// seeing all of them matters. Option values may be strings or numbers; the DOM
|
|
4
|
+
// only speaks strings, so both widgets map String(value) back to the real
|
|
5
|
+
// value on the way out.
|
|
6
|
+
import { attachInfo } from "../info.js";
|
|
7
|
+
import { normalizeOptions } from "../widget-specs.js";
|
|
8
|
+
|
|
9
|
+
function el(tag, className, text) {
|
|
10
|
+
const node = document.createElement(tag);
|
|
11
|
+
if (className) node.className = className;
|
|
12
|
+
if (text != null) node.textContent = text;
|
|
13
|
+
return node;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function labeledRow(node, info) {
|
|
17
|
+
const wrap = el("div", "slider");
|
|
18
|
+
const row = el("div", "row");
|
|
19
|
+
const label = el("label", "", node.label);
|
|
20
|
+
attachInfo(label, node.description, info);
|
|
21
|
+
row.append(label);
|
|
22
|
+
wrap.append(row);
|
|
23
|
+
return wrap;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function makeSelect(node, params, { onChange, info }) {
|
|
27
|
+
const wrap = labeledRow(node, info);
|
|
28
|
+
const opts = normalizeOptions(node.options);
|
|
29
|
+
const byString = new Map(opts.map((o) => [String(o.value), o.value]));
|
|
30
|
+
const select = document.createElement("select");
|
|
31
|
+
select.className = "select-input";
|
|
32
|
+
for (const o of opts) {
|
|
33
|
+
const opt = document.createElement("option");
|
|
34
|
+
opt.value = String(o.value);
|
|
35
|
+
opt.textContent = o.label;
|
|
36
|
+
if (o.description) opt.title = o.description; // long-form option descriptions surface as tooltips
|
|
37
|
+
select.append(opt);
|
|
38
|
+
}
|
|
39
|
+
select.value = String(params[node.key]);
|
|
40
|
+
select.addEventListener("change", () => {
|
|
41
|
+
params[node.key] = byString.get(select.value);
|
|
42
|
+
onChange?.();
|
|
43
|
+
});
|
|
44
|
+
wrap.append(select);
|
|
45
|
+
const sync = () => { select.value = String(params[node.key]); };
|
|
46
|
+
return { el: wrap, sync };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function makeRadio(node, params, { onChange, info }) {
|
|
50
|
+
const wrap = labeledRow(node, info);
|
|
51
|
+
const opts = normalizeOptions(node.options);
|
|
52
|
+
const seg = el("div", "seg");
|
|
53
|
+
const buttons = opts.map((o) => {
|
|
54
|
+
const b = el("button", "", o.label);
|
|
55
|
+
b.type = "button";
|
|
56
|
+
if (o.description) b.title = o.description;
|
|
57
|
+
b.addEventListener("click", () => {
|
|
58
|
+
params[node.key] = o.value;
|
|
59
|
+
paint();
|
|
60
|
+
onChange?.();
|
|
61
|
+
});
|
|
62
|
+
seg.append(b);
|
|
63
|
+
return { b, value: o.value };
|
|
64
|
+
});
|
|
65
|
+
const paint = () => {
|
|
66
|
+
for (const { b, value } of buttons) b.classList.toggle("on", params[node.key] === value);
|
|
67
|
+
};
|
|
68
|
+
paint();
|
|
69
|
+
wrap.append(seg);
|
|
70
|
+
return { el: wrap, sync: paint };
|
|
71
|
+
}
|
package/src/parts/bracket.js
CHANGED
|
@@ -42,12 +42,12 @@ export default {
|
|
|
42
42
|
{
|
|
43
43
|
id: "shape",
|
|
44
44
|
title: "Shape ops",
|
|
45
|
-
|
|
46
|
-
{ key: "clip",
|
|
45
|
+
controls: [
|
|
46
|
+
{ key: "clip", type: "radio", label: "Arm tips",
|
|
47
|
+
options: [{ value: 0, label: "Square" }, { value: 1, label: "Clipped" }],
|
|
47
48
|
description: "**Intersect** the cross with a circle so the four arm tips are rounded off to a common radius." },
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
{ key: "clearance", label: "Print-clearance offset", unit: "mm", min: 0, max: 1, step: 0.1,
|
|
49
|
+
{ key: "clearance", type: "slider", label: "Print-clearance offset", unit: "mm",
|
|
50
|
+
min: 0, max: 1, step: 0.1,
|
|
51
51
|
description: "**Offset** the whole outline outward (round corners) for a looser slip fit. 0 = none." },
|
|
52
52
|
],
|
|
53
53
|
},
|
package/src/parts/planter.js
CHANGED
|
@@ -30,28 +30,33 @@ export default {
|
|
|
30
30
|
id: "body",
|
|
31
31
|
title: "Body",
|
|
32
32
|
description:
|
|
33
|
-
"The faceted vessel. Pick a preset to start, or
|
|
34
|
-
"**Facets** and **Twist** are pure styling; **Wall**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
{ key: "facets", label: "Facets", min: 3, max: 12, step: 1,
|
|
33
|
+
"The faceted vessel. Pick a preset to start, or dial exact dimensions below — " +
|
|
34
|
+
"**Facets** and **Twist** are pure styling; open **Wall** for the one that decides whether it prints cleanly.",
|
|
35
|
+
controls: [
|
|
36
|
+
{ type: "preset", presets: {
|
|
37
|
+
"Pen cup": { facets: 6, dia: 80, height: 100, taper: 1.0, twist: 0, drain: 0 },
|
|
38
|
+
Planter: { facets: 8, dia: 90, height: 80, taper: 0.9, twist: 0, drain: 8 },
|
|
39
|
+
Vase: { facets: 5, dia: 70, height: 150, taper: 1.12, twist: 40, drain: 0 },
|
|
40
|
+
} },
|
|
41
|
+
{ key: "facets", type: "slider", label: "Facets", min: 3, max: 12, step: 1,
|
|
42
42
|
description: "Number of flat sides around the body. Low counts read as crystalline; high counts approach a smooth cylinder." },
|
|
43
|
-
{ key: "dia", label: "Diameter", unit: "mm", min: 30, max: 150, step: 1,
|
|
43
|
+
{ key: "dia", type: "slider", label: "Diameter", unit: "mm", min: 30, max: 150, step: 1,
|
|
44
44
|
description: "Across-corners diameter at the base. Size it to the plant, pens, or shelf it has to fit." },
|
|
45
|
-
{ key: "height", label: "Height", unit: "mm", min: 20, max: 200, step: 1,
|
|
45
|
+
{ key: "height", type: "slider", label: "Height", unit: "mm", min: 20, max: 200, step: 1,
|
|
46
46
|
description: "Overall height along the axis." },
|
|
47
|
-
{ key: "taper", label: "Top taper", min: 0.6, max: 1.4, step: 0.02,
|
|
47
|
+
{ key: "taper", type: "slider", label: "Top taper", min: 0.6, max: 1.4, step: 0.02,
|
|
48
48
|
description: "Rim size relative to the base: below 1 tapers inward (planter), 1 is straight (cup), above 1 flares out (vase)." },
|
|
49
|
-
{ key: "
|
|
50
|
-
description: "Side-wall thickness. The fdm-pla profile wants **≥ 1.2 mm** — go thinner and partforge flags a min-wall warning." },
|
|
51
|
-
{ key: "twist", label: "Twist", unit: "°", min: 0, max: 180, step: 5,
|
|
49
|
+
{ key: "twist", type: "slider", label: "Twist", unit: "°", min: 0, max: 180, step: 5,
|
|
52
50
|
description: "Rotates the facets from base to rim for a spiral look. 0 keeps the facets vertical." },
|
|
53
|
-
{
|
|
54
|
-
|
|
51
|
+
{ type: "group", title: "Wall", collapsed: "auto", controls: [
|
|
52
|
+
{ key: "wall", type: "slider", label: "Wall thickness", unit: "mm", min: 0.8, max: 4, step: 0.1,
|
|
53
|
+
recommended: [1.2, 4],
|
|
54
|
+
description: "Side-wall thickness. The fdm-pla profile wants **≥ 1.2 mm** — go thinner and partforge flags a min-wall warning." },
|
|
55
|
+
{ type: "readout", label: "Inner diameter", derivedKey: "innerDia", unit: "mm",
|
|
56
|
+
description: "Clear inside width at the base, after the walls." },
|
|
57
|
+
{ key: "floor", type: "slider", label: "Floor thickness", unit: "mm", min: 1, max: 6, step: 0.5, hidden: true,
|
|
58
|
+
description: "Internal: solid base thickness, fixed by the design. Hidden from the end user but still drives the geometry." },
|
|
59
|
+
] },
|
|
55
60
|
],
|
|
56
61
|
},
|
|
57
62
|
{
|
|
@@ -87,6 +92,7 @@ export default {
|
|
|
87
92
|
// pick it so inner_radius(top) = outer_radius(top) − wall.
|
|
88
93
|
innerTaper: 1 + (Rout * (p.taper - 1)) / Rin,
|
|
89
94
|
drainR: (p.drain + 0.2) / 2, // nominal hole + 0.2 mm print clearance, as a radius
|
|
95
|
+
innerDia: 2 * Rin, // across-corners inner diameter at the base, after the wall inset
|
|
90
96
|
};
|
|
91
97
|
},
|
|
92
98
|
parts: {
|
package/types/part.d.ts
CHANGED
|
@@ -58,7 +58,7 @@ export interface PartMeta {
|
|
|
58
58
|
export type ControlKind = "slider" | "number" | "text" | "textarea";
|
|
59
59
|
|
|
60
60
|
/** Every control type the panel can render. */
|
|
61
|
-
export type ControlType = "slider" | "number" | "text" | "textarea" | "checkbox";
|
|
61
|
+
export type ControlType = "slider" | "number" | "text" | "textarea" | "checkbox" | "select" | "radio";
|
|
62
62
|
|
|
63
63
|
/** A declarative visibility condition, evaluated against raw parameters. */
|
|
64
64
|
export type WhenCondition =
|
|
@@ -70,10 +70,84 @@ export type WhenCondition =
|
|
|
70
70
|
ne?: ParamValue; in?: ParamValue[];
|
|
71
71
|
}>;
|
|
72
72
|
|
|
73
|
+
/** One entry in a `controls` array: a control, a nested group, a preset picker, or a readout. */
|
|
74
|
+
export type PanelEntry = PanelControlEntry | PanelGroupEntry | PanelPresetEntry | PanelReadoutEntry;
|
|
75
|
+
|
|
76
|
+
/** A control bound to one key in `defaults`. `type` defaults to `"slider"`. */
|
|
77
|
+
export interface PanelControlEntry {
|
|
78
|
+
key: string;
|
|
79
|
+
type?: ControlType;
|
|
80
|
+
label?: string;
|
|
81
|
+
description?: string;
|
|
82
|
+
unit?: string;
|
|
83
|
+
min?: number;
|
|
84
|
+
max?: number;
|
|
85
|
+
step?: number;
|
|
86
|
+
/** checkbox: the value written when ticked (default 1). */
|
|
87
|
+
on?: number;
|
|
88
|
+
/** select / radio: the choices. Strings are both value and label. */
|
|
89
|
+
options?: Array<ParamValue | { value: ParamValue; label?: string; description?: string }>;
|
|
90
|
+
/** slider: logarithmic response. Requires min > 0. */
|
|
91
|
+
scale?: "log";
|
|
92
|
+
/** slider: marked values on the track; `snap: true` makes the thumb prefer them. */
|
|
93
|
+
ticks?: number[];
|
|
94
|
+
snap?: boolean;
|
|
95
|
+
/** slider: [lo, hi] band drawn on the track; outside it the value box takes a warning tint. */
|
|
96
|
+
recommended?: [number, number];
|
|
97
|
+
hidden?: boolean;
|
|
98
|
+
when?: WhenCondition;
|
|
99
|
+
whenFalse?: "disable";
|
|
100
|
+
/** These two discriminate against the container entries. */
|
|
101
|
+
controls?: undefined;
|
|
102
|
+
presets?: undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A nested group. `collapsed` defaults to `"auto"` (the small-panel auto-open rule). */
|
|
106
|
+
export interface PanelGroupEntry {
|
|
107
|
+
type: "group";
|
|
108
|
+
id?: string;
|
|
109
|
+
title?: string;
|
|
110
|
+
collapsed?: boolean | "auto";
|
|
111
|
+
/** No title, no disclosure — just an indented block. */
|
|
112
|
+
bare?: boolean;
|
|
113
|
+
controls: PanelEntry[];
|
|
114
|
+
hidden?: boolean;
|
|
115
|
+
when?: WhenCondition;
|
|
116
|
+
whenFalse?: "disable";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A preset picker, positionable anywhere among the controls. */
|
|
120
|
+
export interface PanelPresetEntry {
|
|
121
|
+
type: "preset";
|
|
122
|
+
id?: string;
|
|
123
|
+
label?: string;
|
|
124
|
+
/** Preset name -> the param overrides it applies. */
|
|
125
|
+
presets: Record<string, Record<string, ParamValue>>;
|
|
126
|
+
hidden?: boolean;
|
|
127
|
+
when?: WhenCondition;
|
|
128
|
+
whenFalse?: "disable";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A read-only display of one `derive()` output. Not bound to `defaults`. */
|
|
132
|
+
export interface PanelReadoutEntry {
|
|
133
|
+
type: "readout";
|
|
134
|
+
label?: string;
|
|
135
|
+
description?: string;
|
|
136
|
+
unit?: string;
|
|
137
|
+
derivedKey: string;
|
|
138
|
+
hidden?: boolean;
|
|
139
|
+
when?: WhenCondition;
|
|
140
|
+
whenFalse?: "disable";
|
|
141
|
+
key?: undefined;
|
|
142
|
+
controls?: undefined;
|
|
143
|
+
presets?: undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
73
146
|
/**
|
|
74
|
-
* One parameter control. The recognised
|
|
75
|
-
*
|
|
76
|
-
* ignored by the panel and
|
|
147
|
+
* One parameter control in a legacy `advanced` / `sliders` array. The recognised
|
|
148
|
+
* field list is the registry's, `fieldsFor("slider")` in
|
|
149
|
+
* src/framework/panel/widget-specs.js — anything else is ignored by the panel and
|
|
150
|
+
* warned about by `partforge lint`.
|
|
77
151
|
*
|
|
78
152
|
* @deprecated Prefer a `controls` array of control nodes. Still fully supported.
|
|
79
153
|
*/
|
|
@@ -142,14 +216,31 @@ export interface PresetSection extends SectionBase {
|
|
|
142
216
|
advanced?: ControlDef[];
|
|
143
217
|
/** A section with `features` is a feature section; `advanced` is ignored there. */
|
|
144
218
|
features?: undefined;
|
|
219
|
+
/** Discriminator: a PresetSection carries no `controls` array. */
|
|
220
|
+
controls?: undefined;
|
|
145
221
|
}
|
|
146
222
|
|
|
147
223
|
/** A feature-toggle section: each feature is a checkbox plus its own controls. */
|
|
148
224
|
export interface FeatureSection extends SectionBase {
|
|
149
225
|
features: FeatureDef[];
|
|
226
|
+
/** Discriminator: a FeatureSection carries no `controls` array. */
|
|
227
|
+
controls?: undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The new section shape: everything in `controls`, in render order. */
|
|
231
|
+
export interface NodeSection extends SectionBase {
|
|
232
|
+
controls: PanelEntry[];
|
|
233
|
+
collapsed?: boolean | "auto";
|
|
234
|
+
when?: WhenCondition;
|
|
235
|
+
whenFalse?: "disable";
|
|
236
|
+
/** Discriminators: a NodeSection carries none of the legacy arrays. */
|
|
237
|
+
features?: undefined;
|
|
238
|
+
advanced?: undefined;
|
|
239
|
+
toggles?: undefined;
|
|
240
|
+
presets?: undefined;
|
|
150
241
|
}
|
|
151
242
|
|
|
152
|
-
export type ParameterSection = PresetSection | FeatureSection;
|
|
243
|
+
export type ParameterSection = PresetSection | FeatureSection | NodeSection;
|
|
153
244
|
|
|
154
245
|
// --- fonts ------------------------------------------------------------------
|
|
155
246
|
|