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.
@@ -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
+ }
@@ -42,12 +42,12 @@ export default {
42
42
  {
43
43
  id: "shape",
44
44
  title: "Shape ops",
45
- toggles: [
46
- { key: "clip", label: "Clip arms to a disc (intersect)", on: 1,
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
- advanced: [
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
  },
@@ -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 open **Advanced** for exact dimensions. " +
34
- "**Facets** and **Twist** are pure styling; **Wall** is the one that decides whether it prints cleanly.",
35
- presets: {
36
- "Pen cup": { facets: 6, dia: 80, height: 100, taper: 1.0, twist: 0, drain: 0 },
37
- Planter: { facets: 8, dia: 90, height: 80, taper: 0.9, twist: 0, drain: 8 },
38
- Vase: { facets: 5, dia: 70, height: 150, taper: 1.12, twist: 40, drain: 0 },
39
- },
40
- advanced: [
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: "wall", label: "Wall thickness", unit: "mm", min: 0.8, max: 4, step: 0.1,
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
- { key: "floor", label: "Floor thickness", unit: "mm", min: 1, max: 6, step: 0.5, hidden: true,
54
- description: "Internal: solid base thickness, fixed by the design. Hidden from the end user but still drives the geometry." },
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 field list mirrors
62
- * `CONTROL_FIELDS` in src/framework/lint/rules-schema.js anything else is
63
- * ignored by the panel and warned about by `partforge lint`.
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