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.
@@ -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 -> raw sync } for preset application
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
- for (const input of node.querySelectorAll("input, select, textarea")) {
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 [key, sync] of rawSyncs.get(sectionCtx.id)) if (key in params) sync();
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).set(node.key, widget.sync);
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, new Map());
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
- applyRelevance: (next) => { relevant = next; applyState(); },
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
- params[node.key] = +slider.value;
49
- box.value = numStr(+slider.value);
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
+ }
@@ -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
@@ -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 field list mirrors
75
- * `CONTROL_FIELDS` in src/framework/lint/rules-schema.js anything else is
76
- * 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`.
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