partforge 0.47.0 → 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 +455 -75
- package/docs/ERROR-PATTERNS.md +24 -0
- package/package.json +1 -1
- package/src/framework/animation-controls.js +6 -2
- package/src/framework/app.css +103 -4
- package/src/framework/chrome.css +20 -0
- package/src/framework/controls.js +14 -363
- package/src/framework/lint/rules-animations.js +13 -12
- package/src/framework/lint/rules-schema.js +366 -39
- 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/info.js +76 -0
- package/src/framework/panel/legacy.js +146 -0
- package/src/framework/panel/model.js +84 -0
- package/src/framework/panel/panel-state.js +70 -0
- package/src/framework/panel/render.js +289 -0
- package/src/framework/panel/widget-specs.js +94 -0
- package/src/framework/panel/widgets/checkbox.js +37 -0
- package/src/framework/panel/widgets/index.js +16 -0
- package/src/framework/panel/widgets/numeric.js +136 -0
- package/src/framework/panel/widgets/readout.js +31 -0
- package/src/framework/panel/widgets/select.js +71 -0
- package/src/framework/panel/widgets/text.js +33 -0
- package/src/parts/bracket.js +5 -5
- package/src/parts/planter.js +23 -17
- package/types/part.d.ts +114 -4
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The control-type registry. Declaring a type here is what makes it real: the
|
|
2
|
+
// renderer looks up its DOM factory by type, and partforge/lint derives its
|
|
3
|
+
// accepted-field list from `fields` instead of hardcoding one.
|
|
4
|
+
//
|
|
5
|
+
// Before this existed, rules-schema.js carried three hand-maintained allow-lists
|
|
6
|
+
// (CONTROL_FIELDS / FEATURE_FIELDS / TOGGLE_FIELDS) that had to be edited in
|
|
7
|
+
// lockstep with the renderer — and when they weren't, `unknown-control-field`
|
|
8
|
+
// warned on legitimate fields. Adding a type or a field is now one edit here.
|
|
9
|
+
//
|
|
10
|
+
// The lists deliberately mirror the legacy lint allow-lists EXACTLY: `when`,
|
|
11
|
+
// `whenFalse` and `type` are NOT accepted yet — they join when the phases that
|
|
12
|
+
// make them functional land (type: phase 4; when/whenFalse: phase 6). Accepting
|
|
13
|
+
// a field the panel ignores would trade one silent-dead-field bug for another.
|
|
14
|
+
//
|
|
15
|
+
// Imports nothing: lint consumes this and test/lint-purity.test.js requires a
|
|
16
|
+
// dependency-free closure.
|
|
17
|
+
|
|
18
|
+
// What legacy rules-schema.js called CONTROL_FIELDS: every descriptor that can
|
|
19
|
+
// appear in `advanced` or a feature's `sliders`, whatever its `control` value.
|
|
20
|
+
const LEGACY_CONTROL = ["key", "label", "unit", "min", "max", "step", "control", "hidden", "description"];
|
|
21
|
+
// What it called TOGGLE_FIELDS.
|
|
22
|
+
const LEGACY_TOGGLE = ["key", "label", "on", "hidden", "description"];
|
|
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
|
+
|
|
31
|
+
export const WIDGET_SPECS = [
|
|
32
|
+
{ type: "slider", kind: "control", fields: LEGACY_CONTROL },
|
|
33
|
+
{ type: "number", kind: "control", fields: LEGACY_CONTROL },
|
|
34
|
+
{ type: "text", kind: "control", fields: LEGACY_CONTROL },
|
|
35
|
+
{ type: "textarea", kind: "control", fields: LEGACY_CONTROL },
|
|
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"] },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const BY_TYPE = new Map(WIDGET_SPECS.map((s) => [s.type, s]));
|
|
43
|
+
|
|
44
|
+
export const WIDGET_TYPES = WIDGET_SPECS.map((s) => s.type);
|
|
45
|
+
export const specFor = (type) => BY_TYPE.get(type);
|
|
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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// A bare on/off checkbox, writing `on` when ticked and 0 when cleared.
|
|
2
|
+
//
|
|
3
|
+
// `preserveOn` is the one behavioral difference between the two legacy shapes it
|
|
4
|
+
// replaces. A `features` checkbox only wrote `on` when the value wasn't already
|
|
5
|
+
// positive (controls.js:352), so re-ticking a feature restored the magnitude the
|
|
6
|
+
// user had dialled in. A `toggles` checkbox always wrote it (controls.js:286),
|
|
7
|
+
// because its `on` is a flag, not a magnitude.
|
|
8
|
+
import { attachInfo } from "../info.js";
|
|
9
|
+
|
|
10
|
+
function el(tag, className, text) {
|
|
11
|
+
const node = document.createElement(tag);
|
|
12
|
+
if (className) node.className = className;
|
|
13
|
+
if (text != null) node.textContent = text;
|
|
14
|
+
return node;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function makeCheckbox(node, params, { onChange, info }) {
|
|
18
|
+
const row = el("label", "feat");
|
|
19
|
+
const box = document.createElement("input");
|
|
20
|
+
box.type = "checkbox";
|
|
21
|
+
box.checked = params[node.key] > 0;
|
|
22
|
+
const lbl = el("span", "", node.label);
|
|
23
|
+
attachInfo(lbl, node.description, info);
|
|
24
|
+
row.append(box, lbl);
|
|
25
|
+
|
|
26
|
+
box.addEventListener("change", () => {
|
|
27
|
+
if (box.checked) {
|
|
28
|
+
if (!node.preserveOn || !(params[node.key] > 0)) params[node.key] = node.on ?? 1;
|
|
29
|
+
} else {
|
|
30
|
+
params[node.key] = 0;
|
|
31
|
+
}
|
|
32
|
+
onChange?.();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const sync = () => { box.checked = params[node.key] > 0; };
|
|
36
|
+
return { el: row, sync };
|
|
37
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The DOM half of the widget registry. Its keys must match widget-specs.js
|
|
2
|
+
// exactly — test/framework/panel/registry.test.js proves they do.
|
|
3
|
+
import { makeNumeric } from "./numeric.js";
|
|
4
|
+
import { makeText } from "./text.js";
|
|
5
|
+
import { makeCheckbox } from "./checkbox.js";
|
|
6
|
+
import { makeSelect, makeRadio } from "./select.js";
|
|
7
|
+
|
|
8
|
+
export const WIDGET_FACTORIES = {
|
|
9
|
+
slider: makeNumeric,
|
|
10
|
+
number: makeNumeric,
|
|
11
|
+
text: makeText,
|
|
12
|
+
textarea: makeText,
|
|
13
|
+
checkbox: makeCheckbox,
|
|
14
|
+
select: makeSelect,
|
|
15
|
+
radio: makeRadio,
|
|
16
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// slider + number: a range input (omitted for `number`) beside an editable value
|
|
2
|
+
// box. The box accepts exact values finer than `step`; typed values clamp to
|
|
3
|
+
// [min, max] on commit (blur/Enter).
|
|
4
|
+
import { attachInfo } from "../info.js";
|
|
5
|
+
|
|
6
|
+
// Short numeric string without float noise (4 dp max) for the value box.
|
|
7
|
+
const numStr = (v) => String(Math.round(v * 1e4) / 1e4);
|
|
8
|
+
|
|
9
|
+
export function clampToRange(raw, min, max) {
|
|
10
|
+
const v = parseFloat(raw);
|
|
11
|
+
if (!Number.isFinite(v)) return null;
|
|
12
|
+
return Math.min(max, Math.max(min, v));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function el(tag, className, text) {
|
|
16
|
+
const node = document.createElement(tag);
|
|
17
|
+
if (className) node.className = className;
|
|
18
|
+
if (text != null) node.textContent = text;
|
|
19
|
+
return node;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function makeNumeric(node, params, { onChange, info }) {
|
|
23
|
+
const numeric = node.type === "number";
|
|
24
|
+
const wrap = el("div", "slider");
|
|
25
|
+
const row = el("div", "row");
|
|
26
|
+
const label = el("label", "", node.label);
|
|
27
|
+
attachInfo(label, node.description, info);
|
|
28
|
+
row.append(label);
|
|
29
|
+
|
|
30
|
+
const val = el("div", "val");
|
|
31
|
+
const box = document.createElement("input");
|
|
32
|
+
box.type = "number";
|
|
33
|
+
box.className = "num";
|
|
34
|
+
box.min = node.min; box.max = node.max; box.step = node.step;
|
|
35
|
+
box.value = numStr(params[node.key]);
|
|
36
|
+
val.append(box);
|
|
37
|
+
if (node.unit) val.append(el("span", "unit", node.unit));
|
|
38
|
+
row.append(val);
|
|
39
|
+
wrap.append(row);
|
|
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
|
+
|
|
58
|
+
let slider = null;
|
|
59
|
+
if (!numeric) {
|
|
60
|
+
slider = document.createElement("input");
|
|
61
|
+
slider.type = "range";
|
|
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];
|
|
64
|
+
slider.addEventListener("input", () => {
|
|
65
|
+
const v = log ? toValue(+slider.value) : snapTo(+slider.value);
|
|
66
|
+
params[node.key] = v;
|
|
67
|
+
box.value = numStr(v);
|
|
68
|
+
paintWarn();
|
|
69
|
+
onChange?.();
|
|
70
|
+
});
|
|
71
|
+
wrap.append(slider);
|
|
72
|
+
}
|
|
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
|
+
|
|
110
|
+
// live preview while typing (unclamped); clamp + reformat on commit
|
|
111
|
+
box.addEventListener("input", () => {
|
|
112
|
+
const v = parseFloat(box.value);
|
|
113
|
+
if (!Number.isFinite(v)) return;
|
|
114
|
+
params[node.key] = v;
|
|
115
|
+
if (slider) slider.value = log ? toPosSafe(v) : v;
|
|
116
|
+
paintWarn();
|
|
117
|
+
onChange?.();
|
|
118
|
+
});
|
|
119
|
+
box.addEventListener("change", () => {
|
|
120
|
+
const v = clampToRange(box.value, node.min, node.max);
|
|
121
|
+
if (v == null) { box.value = numStr(params[node.key]); return; } // revert invalid input
|
|
122
|
+
params[node.key] = v;
|
|
123
|
+
box.value = numStr(v);
|
|
124
|
+
if (slider) slider.value = log ? toPosSafe(v) : v;
|
|
125
|
+
paintWarn();
|
|
126
|
+
onChange?.();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const sync = () => {
|
|
130
|
+
box.value = numStr(params[node.key]);
|
|
131
|
+
if (slider) slider.value = log ? toPosSafe(params[node.key]) : params[node.key];
|
|
132
|
+
paintWarn();
|
|
133
|
+
};
|
|
134
|
+
paintWarn();
|
|
135
|
+
return { el: wrap, sync };
|
|
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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// text / textarea: a live-updating string field. Every edit writes params
|
|
2
|
+
// immediately so the existing rebuild loop previews the new string.
|
|
3
|
+
import { attachInfo } from "../info.js";
|
|
4
|
+
|
|
5
|
+
function el(tag, className, text) {
|
|
6
|
+
const node = document.createElement(tag);
|
|
7
|
+
if (className) node.className = className;
|
|
8
|
+
if (text != null) node.textContent = text;
|
|
9
|
+
return node;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function makeText(node, params, { onChange, info }) {
|
|
13
|
+
const multiline = node.type === "textarea";
|
|
14
|
+
const wrap = el("div", "slider");
|
|
15
|
+
const row = el("div", "row");
|
|
16
|
+
const label = el("label", "", node.label);
|
|
17
|
+
attachInfo(label, node.description, info);
|
|
18
|
+
row.append(label);
|
|
19
|
+
wrap.append(row);
|
|
20
|
+
|
|
21
|
+
const field = document.createElement(multiline ? "textarea" : "input");
|
|
22
|
+
if (!multiline) field.type = "text";
|
|
23
|
+
field.className = "text-input";
|
|
24
|
+
field.value = String(params[node.key] ?? "");
|
|
25
|
+
field.addEventListener("input", () => {
|
|
26
|
+
params[node.key] = field.value;
|
|
27
|
+
onChange?.();
|
|
28
|
+
});
|
|
29
|
+
wrap.append(field);
|
|
30
|
+
|
|
31
|
+
const sync = () => { field.value = String(params[node.key] ?? ""); };
|
|
32
|
+
return { el: wrap, sync };
|
|
33
|
+
}
|
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
|
@@ -57,10 +57,99 @@ export interface PartMeta {
|
|
|
57
57
|
/** Which input a control renders as. Omit for a slider + number box. */
|
|
58
58
|
export type ControlKind = "slider" | "number" | "text" | "textarea";
|
|
59
59
|
|
|
60
|
+
/** Every control type the panel can render. */
|
|
61
|
+
export type ControlType = "slider" | "number" | "text" | "textarea" | "checkbox" | "select" | "radio";
|
|
62
|
+
|
|
63
|
+
/** A declarative visibility condition, evaluated against raw parameters. */
|
|
64
|
+
export type WhenCondition =
|
|
65
|
+
| { allOf: WhenCondition[] }
|
|
66
|
+
| { anyOf: WhenCondition[] }
|
|
67
|
+
| { not: WhenCondition }
|
|
68
|
+
| Record<string, ParamValue | {
|
|
69
|
+
gt?: number; gte?: number; lt?: number; lte?: number;
|
|
70
|
+
ne?: ParamValue; in?: ParamValue[];
|
|
71
|
+
}>;
|
|
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
|
+
|
|
60
146
|
/**
|
|
61
|
-
* One parameter control. The recognised
|
|
62
|
-
*
|
|
63
|
-
* 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`.
|
|
151
|
+
*
|
|
152
|
+
* @deprecated Prefer a `controls` array of control nodes. Still fully supported.
|
|
64
153
|
*/
|
|
65
154
|
export interface ControlDef {
|
|
66
155
|
/** Must exist in `defaults`, or the control is silently dead. */
|
|
@@ -82,6 +171,8 @@ export interface ControlDef {
|
|
|
82
171
|
* A feature: a checkbox that sets `key` to `on` (or `0`) and reveals its own
|
|
83
172
|
* controls. `sliders` is REQUIRED — the panel reads `feat.sliders.filter(...)`
|
|
84
173
|
* unguarded. A bare on/off control belongs in `toggles` instead.
|
|
174
|
+
*
|
|
175
|
+
* @deprecated Prefer a `controls` array of control nodes. Still fully supported.
|
|
85
176
|
*/
|
|
86
177
|
export interface FeatureDef {
|
|
87
178
|
key: string;
|
|
@@ -96,6 +187,8 @@ export interface FeatureDef {
|
|
|
96
187
|
/**
|
|
97
188
|
* A standalone on/off checkbox shown below the preset picker, outside the
|
|
98
189
|
* Advanced fold. Checked writes `on` (default `1`); unchecked writes `0`.
|
|
190
|
+
*
|
|
191
|
+
* @deprecated Prefer a `controls` array of control nodes. Still fully supported.
|
|
99
192
|
*/
|
|
100
193
|
export interface ToggleDef {
|
|
101
194
|
key: string;
|
|
@@ -123,14 +216,31 @@ export interface PresetSection extends SectionBase {
|
|
|
123
216
|
advanced?: ControlDef[];
|
|
124
217
|
/** A section with `features` is a feature section; `advanced` is ignored there. */
|
|
125
218
|
features?: undefined;
|
|
219
|
+
/** Discriminator: a PresetSection carries no `controls` array. */
|
|
220
|
+
controls?: undefined;
|
|
126
221
|
}
|
|
127
222
|
|
|
128
223
|
/** A feature-toggle section: each feature is a checkbox plus its own controls. */
|
|
129
224
|
export interface FeatureSection extends SectionBase {
|
|
130
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;
|
|
131
241
|
}
|
|
132
242
|
|
|
133
|
-
export type ParameterSection = PresetSection | FeatureSection;
|
|
243
|
+
export type ParameterSection = PresetSection | FeatureSection | NodeSection;
|
|
134
244
|
|
|
135
245
|
// --- fonts ------------------------------------------------------------------
|
|
136
246
|
|