partforge 0.112.0 → 0.114.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.
@@ -436,6 +436,7 @@ offsetPolygon(outline, -wall, { corners: "sharp" }); // inse
436
436
  const tab = pathProfile([0, -w / 2])
437
437
  .lineTo([len, -w / 2])
438
438
  .arcTo([len, w / 2], [len + w / 2, 0]) // tip, via the apex
439
+ // same arc: .arcTo([len, w / 2], { r: w / 2 }) — radius form, no via to compute
439
440
  .lineTo([0, w / 2])
440
441
  .close();
441
442
  k.extrude({ profile: tab, h: 3 });
@@ -445,6 +446,7 @@ const lip = pathProfile([0, 0])
445
446
  .lineTo([20, 0]).lineTo([20, 8])
446
447
  .cubicTo([0, 8], [14, 16], [6, 16]) // curved top edge
447
448
  .close();
449
+ k.extrude({ profile: lip, h: 3 });
448
450
 
449
451
  // Rounded enclosure: soft vertical edges, a softer lid, a flat base.
450
452
  const shell = k.roundedBox({ size: [60, 40, 22], round: { side: 4, top: 2, bottom: 0 } });
@@ -470,7 +472,7 @@ dumbbell past its waist) **throws** a greppable error rather than returning dege
470
472
  geometry. Being pure, it works in `derive()` as well as `build()` — the natural home for
471
473
  clearance math.
472
474
  `pathProfile(start)` is a fluent builder for a curve-native path contour (`lineTo` / `arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep spline edges on the OCCT/STEP backend and facet at the mesh LOD on Manifold — the same exact-vs-faceted split as `roundedProfile` arcs.
473
- `arcTo(to, via)` is a **three-point arc**: `via` is any point on the arc between the current point and `to` (its midpoint is the natural choice), and the sweep is whichever direction passes through it — so an arc's direction is a property of a point you can see, never of a sign. Build the symmetric half of a profile once and `mirrorProfile` it (see "Editing profiles") rather than writing the mirrored arcs by hand. `loft` accepts these contours as rings (every ring with the same segment signature lofts curve-to-curve).
475
+ `arcTo(to, via)` is a **three-point arc**: `via` is any point on the arc between the current point and `to` (its midpoint is the natural choice), and the sweep is whichever direction passes through it — so an arc's direction is a property of a point you can see, never of a sign. `arcTo(to, { r, sweep?, large? })` is the **radius form** for when you know the radius, not a point on the arc: it computes `via` from the current point, `to`, and `r`, emitting the exact same `{to, via}` segment the three-point form does. `sweep` names the direction the arc itself is traversed (default `"ccw"`), so on a counter-clockwise outline `"ccw"` bulges OUTWARD (a convex bump), and inward on a clockwise hole; `"cw"` is the reverse. `large` (default `false`) picks the major arc over the minor one when both are possible. A radius shorter than half the distance between the current point and `to` throws rather than being silently scaled up (the way SVG's arc command does) — the smallest circle joining the two points is a semicircle at `r = d/2`. Build the symmetric half of a profile once and `mirrorProfile` it (see "Editing profiles") rather than writing the mirrored arcs by hand. `loft` accepts these contours as rings (every ring with the same segment signature lofts curve-to-curve).
474
476
  **`pathProfile` or an authored vector file?** Reach for `pathProfile` (and the polygon helpers above) when the geometry is **computed from parameters** — a profile whose dimensions come from `p`/`d`, which a JSON file cannot see. Reach for an authored `partforge-vector` document (`k.vector2d`, see "Vector geometry" below) when the geometry is **drawn** — a logo, a faceplate outline, a decorative cutout, where each number means one thing and gets edited on its own. The two are freely composable: both produce ordinary 2-D geometry that the same booleans and editing ops accept.
475
477
  **Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
476
478
  entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
@@ -743,6 +745,7 @@ Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` an
743
745
  | `"font"` | a typeface picker with a catalog, else a drop target | `allow`, `preview`, `sourceField` |
744
746
  | `"image"` | an image picker with a catalog, else a drop target showing the artwork | `allow`, `sourceField` |
745
747
  | `"vector"` | a drop target showing the artwork — no catalog exists | `sourceField` |
748
+ | `"custom"` | a widget the part draws itself — see "Custom controls" below | `widget`, `keys` |
746
749
 
747
750
  Numeric controls always show the number box: drag the slider *or* type an exact
748
751
  value. Typed values may be finer than `step` and may sit **outside `[min, max]`**:
@@ -952,6 +955,132 @@ pickers in one section both need to show divergence, give each its own section.
952
955
  case per preset name (so every preset gets measured), and a repeated name throws
953
956
  there. `duplicate-preset-name` catches it at lint time instead.
954
957
 
958
+ ### Custom controls
959
+
960
+ A `type: "custom"` control renders a widget the part draws itself — a clickable hex
961
+ grid, an organizer whose walls toggle on click, a picture of the part's own outline
962
+ with hot regions. Reach for it when the user configures **many similar things
963
+ individually**, or makes a **spatial choice** no slider or select can express.
964
+ Never for a value an existing control already covers: a slider is still a slider.
965
+
966
+ ```js
967
+ // part.js
968
+ import { tilePicker } from "./tile-picker.js";
969
+
970
+ export default {
971
+ defaults: {
972
+ tileSize: 20,
973
+ tiles: [{ q: 0, r: 0, height: 10 }, { q: 1, r: 0, height: 14 }],
974
+ },
975
+ parameters: [{
976
+ title: "Tiles",
977
+ controls: [
978
+ { type: "slider", key: "tileSize", min: 10, max: 40 },
979
+ { type: "custom", key: "tiles", label: "Tile layout", widget: tilePicker,
980
+ description: "Click a tile to select it. Drag to move it." },
981
+ ],
982
+ }],
983
+ parts: { tiles: { build: (k, p) => /* p.tiles is the array */ } },
984
+ };
985
+ ```
986
+
987
+ - `key` names the param the widget owns. It may hold a **JSON value**: numbers,
988
+ strings, booleans, arrays and plain objects of those, at most 16 KB serialized and
989
+ 8 levels deep, never `null` (`custom-default-not-json`). `build()` reads it like any
990
+ other param. This is the one place the "a control writes one scalar" rule is relaxed.
991
+ - `keys` (optional) lists further scalar params the widget may also write.
992
+ - `widget` is a function `(host) => …`, normally imported from a sibling file so
993
+ `part.js` stays readable (`custom-control-widget-not-function`).
994
+ - `label`, `description`, `hidden`, `when` and `whenFalse` behave as on any control.
995
+
996
+ **The widget function.** It runs once per mount and draws into `host.el` with plain
997
+ DOM and SVG. It may return `{ update, dispose }`.
998
+
999
+ ```js
1000
+ // tile-picker.js
1001
+ export function tilePicker(host) {
1002
+ const svg = host.h("svg", { viewBox: "0 0 200 160", width: "100%" });
1003
+ const detail = host.h("div");
1004
+ host.el.append(svg, detail);
1005
+ let stopDetail = null;
1006
+
1007
+ function draw() {
1008
+ const tiles = host.get(); // a fresh clone of the owned value
1009
+ const sel = host.state.selected ?? null; // survives the remount an edit performs
1010
+ svg.replaceChildren(...tiles.map((t, i) => host.h("polygon", {
1011
+ points: hexPoints(t.q, t.r), class: i === sel ? "pf-hit selected" : "pf-hit",
1012
+ onpointerdown: () => { host.setState({ selected: i }); draw(); },
1013
+ })));
1014
+ stopDetail?.();
1015
+ stopDetail = sel === null ? null : host.controls(detail, [
1016
+ { type: "slider", key: "height", label: "Height", min: 4, max: 30 },
1017
+ ], { path: `${sel}` });
1018
+ }
1019
+ draw();
1020
+ return { update: draw, dispose: () => stopDetail?.() };
1021
+ }
1022
+ ```
1023
+
1024
+ **The host.**
1025
+
1026
+ | Member | Meaning |
1027
+ |---|---|
1028
+ | `host.el`, `host.doc` | The slot to draw into, and its document. |
1029
+ | `host.get(key?)` | A clone of the current value (default: the owned key). Any param is readable. |
1030
+ | `host.set(value, {key?, commit?})` | Replace a value with a **new** one — never mutate what `get` returned. Schedules the rebuild and commits, unless `commit: false`; then call `host.commit()` when the gesture ends (a drag). Throws for a key you do not own or a value outside the contract. On a **retired** widget (one whose code already threw) `set`, `commit` and `setState` are silent no-ops. |
1031
+ | `host.commit(keys?)` | Ends a deferred gesture. A no-op when nothing changed. |
1032
+ | `host.derived` | The latest `derive()` output, the same object readouts show. |
1033
+ | `host.state`, `host.setState(patch)` | Transient JSON (a selection, an open panel). Not a param, never persisted, but it **survives a remount**, which every edit performs. |
1034
+ | `host.controls(container, controls, {path})` | Mount ordinary built-in controls bound *inside* the owned value at a dotted `path` (`"3"`, `"walls.north"`). Their edits commit the owning key. Returns a disposer. One level: no custom control inside, and no `font`, `image` or `vector` sub-control at a path (their asset lookups are keyed on the part's real param names). |
1035
+ | `host.h(tag, attrs, ...children)` | Element builder. SVG tags get the SVG namespace; `on<event>` attrs become listeners; `class` and `style` pass through. |
1036
+ | `host.svg(text)` | Parse an SVG string to an element you can append and wire up. |
1037
+ | `host.svgFromVector(doc)` | A partforge-vector document → inline `<svg>` (the same renderer the `vector` control uses). |
1038
+ | `host.file(pathOrToken)` | Text of one of the part's own files, by path or a `pfc-tree://` token, or null. |
1039
+ | `host.disabled` | Whether a `when`/`whenFalse: "disable"` currently disables this control. |
1040
+
1041
+ `update({reason, disabled})` is called when your key changes from outside the widget
1042
+ (`reason: "sync"` — a preset, undo, `setParams`), when `derived` changes
1043
+ (`"derived"`), and once after `host.state` was restored on mount (`"restore"`). It is
1044
+ **not** called for your own `set`. `dispose()` runs on teardown.
1045
+
1046
+ **Rules that keep it working.**
1047
+
1048
+ 1. Size SVG with `viewBox` plus `width: 100%`. The rail is 288px wide by default and
1049
+ narrower on a phone, where it is the bottom sheet.
1050
+ 2. Use pointer events, not mouse events, and put `class="pf-drag"` (or
1051
+ `touch-action: none`) on anything dragged — otherwise a finger scrolls the sheet.
1052
+ 3. Keep selection and similar state in `host.state`, not in a closure: every edit
1053
+ remounts the part and your function runs again.
1054
+ 4. Hand `set` a new value. `get` returns a clone precisely so the stored value is never
1055
+ edited in place.
1056
+ 5. A throw in creation, `update`, or a listener installed through `host.h` replaces
1057
+ the widget with an error card and reports it (a hosting agent sees it in the apply
1058
+ result as `panelErrors`); other controls keep working. A throw in `dispose` is
1059
+ reported the same way but leaves no card — there is nothing left to show it on. A
1060
+ listener you attach yourself with `addEventListener`, rather than through `host.h`,
1061
+ is **not** guarded — wire listeners through `host.h`, or wrap your own in try/catch.
1062
+
1063
+ **Looking native.** The slot inherits the rail's font, colours and light/dark theme.
1064
+ Bare `<button>`, `<input>` and `<select>` elements pick up the built-in looks
1065
+ automatically; the built-in classes are available by name for the exact thing:
1066
+ `row`, `seg` (a segmented row of buttons), `action`, `ghost`, `num`, `text-input`,
1067
+ `select-input`; and for SVG, `pf-hit` (clickable, with a `selected` state) and
1068
+ `pf-drag`. Sub-controls mounted through `host.controls` *are* the built-in widgets.
1069
+
1070
+ **Reading the part's own files.** Artwork can live beside the code (the tree is text,
1071
+ so an SVG, a `partforge-vector` JSON document or a JSON data file — not a PNG).
1072
+ Two routes: store the SVG as a string in a JS module (`assets/emblem.svg.js` exporting
1073
+ a template literal) and import it like any sibling file — no framework support
1074
+ needed; or read it with `host.file("assets/emblem.svg")` and inline it with
1075
+ `host.svg(text)`. Give regions ids and wire `pointerdown` on `#wall-3` to toggle
1076
+ `walls[3]` in the owned value.
1077
+
1078
+ **What a widget cannot do.** Never `fetch` or otherwise reach the network — a widget
1079
+ is a pure function of the part and its params, and a hosted sandbox may refuse the
1080
+ request or have no credentials to make it with. Also: no imports beyond the part's
1081
+ own files, nothing outside `host.el`, and no reading of `params` except through
1082
+ `host`.
1083
+
955
1084
  ### Legacy section shapes (still supported)
956
1085
 
957
1086
  Everything above is what a **new part should write**. The original array-based shapes
@@ -1141,6 +1270,10 @@ defaulting everything to a slider:
1141
1270
  reason for a two-position slider to exist.
1142
1271
  - **A computed value the user should see but not set** → `"readout"`. It costs no
1143
1272
  parameter and answers the "so what did that do?" question in place.
1273
+ - **Many similar things configured individually, or a spatial choice** (which tiles
1274
+ exist and how tall each is; which walls of an organizer are present) →
1275
+ `"custom"`: a widget the part draws, holding one JSON value. See "Custom controls"
1276
+ above; use it only when no built-in control expresses the choice.
1144
1277
 
1145
1278
  Then gate what doesn't always apply. A control that is meaningless in the current mode
1146
1279
  should carry a **`when`** rather than sit there inert — hide it by default, or use
@@ -2789,7 +2922,9 @@ previously didn't; that's the fix working as intended, not a regression.
2789
2922
  `preset-key-not-in-defaults`, `mixed-section-shape`,
2790
2923
  `duplicate-preset-name`, `duplicate-node-id`, `select-options-missing`,
2791
2924
  `select-default-not-in-options`, `log-scale-needs-positive-min`,
2792
- `when-key-not-in-defaults`, `when-unknown-operator`, `unknown-control-type` (errors);
2925
+ `when-key-not-in-defaults`, `when-unknown-operator`, `unknown-control-type`,
2926
+ `custom-control-widget-not-function`, `custom-default-not-json`,
2927
+ `custom-keys-not-in-defaults` (errors);
2793
2928
  `slider-range-excludes-default`, `unknown-control-field`, `duplicate-control-key`,
2794
2929
  `default-not-exposed`, `readout-unknown-derived-key`, `slider-refinement-invalid`,
2795
2930
  `group-depth`, `section-too-many-controls` (warnings).
@@ -2800,6 +2935,10 @@ can't coexist, since mixing them would make the render order arbitrary. Move
2800
2935
  the legacy entries into `controls` (a toggle becomes a checkbox control,
2801
2936
  `advanced` becomes a nested group, `presets` becomes `{ type: "preset" }`
2802
2937
  nodes), or drop `controls` and stay legacy.
2938
+ `custom-default-not-json` is `control-default-not-primitive`'s counterpart for a
2939
+ `type: "custom"` control, whose key may hold a JSON value (see "Custom controls"):
2940
+ it names the first member that is not one — `null`, a function, a class instance,
2941
+ a forbidden key, or a value past the 16 KB / depth-8 caps.
2803
2942
  `duplicate-preset-name` fires when the same preset name is declared twice
2804
2943
  (legacy `presets` and/or `{ type: "preset" }` nodes both count) — preset names
2805
2944
  are global to the part, and `verify()` expands one case per name and throws on
@@ -316,6 +316,12 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
316
316
  - **Cause:** A cubic segment is missing `c1` or `c2`, or a control point is not a finite `[x,y]` (e.g. `NaN`, wrong length).
317
317
  - **Fix:** Provide both control points as finite `[x,y]`. A cubic Bézier needs two controls between the previous point and `to`.
318
318
 
319
+ ## arcto-radius-too-short
320
+
321
+ - **Symptom:** `pathProfile: arcTo r=<r> is shorter than half the chord (<half-chord>) from (<x0>, <y0>) to (<x1>, <y1>) — the smallest arc that can join these points has r=<half-chord> (a semicircle)`
322
+ - **Cause:** `pathProfile().arcTo(to, { r, sweep?, large? })`'s `r` is smaller than half the distance between the current point and `to` — no circle of that radius passes through both points.
323
+ - **Fix:** Raise `r` to at least half the chord (the message states the exact minimum), or move the endpoint closer. Unlike SVG's arc command, partforge refuses rather than silently scaling `r` up to fit — the model should learn the number it wrote was wrong rather than have it quietly corrected.
324
+
319
325
  ## shape2d-simple-not-single-region
320
326
 
321
327
  - **Symptom:** `Shape2D.simple: result has N regions, not 1 (use toRegions())`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.112.0",
3
+ "version": "0.114.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,6 +58,10 @@
58
58
  "types": "./types/ingest.d.ts",
59
59
  "default": "./src/ingest.js"
60
60
  },
61
+ "./panel-values": {
62
+ "types": "./types/panel-values.d.ts",
63
+ "default": "./src/panel-values.js"
64
+ },
61
65
  "./tokens.css": "./src/framework/tokens.css",
62
66
  "./chrome.css": "./src/framework/chrome.css"
63
67
  },
@@ -83,6 +87,9 @@
83
87
  ],
84
88
  "ingest": [
85
89
  "./types/ingest.d.ts"
90
+ ],
91
+ "panel-values": [
92
+ "./types/panel-values.d.ts"
86
93
  ]
87
94
  }
88
95
  },
@@ -64,8 +64,10 @@ canvas { display: block; }
64
64
  Condition-hidden sections (.hidden, a `when` that evaluated false) are excluded
65
65
  for the same reason. Do not simplify this to `.section:first-child { border-top: 0 }` — that
66
66
  matches DOM position rather than visibility, and leaves a stray divider
67
- floating at the top whenever applyRelevance hides the first section. */
68
- .section:not(.section-hidden):not(.hidden) ~ .section:not(.section-hidden):not(.hidden) {
67
+ floating at the top whenever applyRelevance hides the first section. A bare
68
+ sub-panel section (host.controls inside a custom control) never gets the divider
69
+ because the widget owns its framing. */
70
+ .section:not(.section-hidden):not(.hidden) ~ .section:not(.section-hidden):not(.hidden):not(.bare) {
69
71
  border-top: 1px solid var(--pf-border);
70
72
  }
71
73
  /* Section disclosure header: the whole row is the click target (render.js
@@ -382,6 +384,52 @@ button.ghost { background: transparent; border: 1px solid var(--pf-border); colo
382
384
  button.ghost:hover:not(:disabled) { background: var(--pf-surface-2); }
383
385
  button.action:disabled { opacity: .5; cursor: default; }
384
386
 
387
+ /* Custom controls (type: "custom"): a part-authored widget in the rail. The
388
+ slot inherits the rail's cascade (font, colours, --pf-* tokens); these rules
389
+ give BARE elements the built-in controls' look so a widget written with
390
+ plain <button>/<input>/<select> still reads as native, and provide two
391
+ opt-in classes for SVG: .pf-hit (a clickable region) and .pf-drag (a
392
+ dragged one — touch-action: none is what lets a finger drag it on a phone
393
+ instead of scrolling the sheet). */
394
+ .pf-custom { margin: 9px 0; }
395
+ .pf-custom.hidden { display: none; }
396
+ .pf-custom-slot { display: flow-root; }
397
+ /* Every selector in this block is wrapped so the WHOLE thing resolves to
398
+ (0,0,0) — including the :not([type=...]) chain, which is otherwise an
399
+ attribute selector per :not() and would still out-specify a class rule
400
+ like .text-input (0,1,0) or .row .num (0,2,0). At true zero specificity,
401
+ any class rule wins, so a named built-in class (.seg button, .text-input,
402
+ select.select-input, …) always beats these bare-element defaults. */
403
+ :where(.pf-custom) :where(svg) { display: block; max-width: 100%; height: auto; }
404
+ :where(.pf-custom) :where(button) {
405
+ font: inherit; font-size: 12px; padding: 5px 9px; cursor: pointer;
406
+ background: transparent; border: 1px solid var(--pf-border); color: var(--pf-text-2);
407
+ border-radius: var(--pf-radius-control);
408
+ }
409
+ :where(.pf-custom) :where(button:hover:not(:disabled)) { background: var(--pf-surface-2); }
410
+ :where(.pf-custom) :where(button:disabled) { opacity: .5; cursor: default; }
411
+ :where(.pf-custom) :where(input:not([type="range"]):not([type="checkbox"]):not([type="radio"]), select) {
412
+ font: 12px/1.4 var(--pf-mono);
413
+ background: var(--pf-input-bg); color: var(--pf-text-strong);
414
+ border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 6px 8px;
415
+ }
416
+ :where(.pf-custom) :where(input:focus, select:focus, button:focus-visible) {
417
+ outline: none; border-color: var(--pf-accent);
418
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
419
+ }
420
+ .pf-custom .pf-hit { cursor: pointer; }
421
+ .pf-custom .pf-hit:hover { fill: color-mix(in oklab, var(--pf-accent) 25%, transparent); }
422
+ .pf-custom .pf-hit.selected { fill: color-mix(in oklab, var(--pf-accent) 45%, transparent); stroke: var(--pf-accent); }
423
+ .pf-custom .pf-drag { touch-action: none; }
424
+ .pf-custom-error {
425
+ margin: 4px 0; padding: 6px 8px; border: 1px solid var(--pf-err); border-radius: var(--pf-radius-control);
426
+ color: var(--pf-err); font-family: var(--pf-mono); font-size: 11px; line-height: 1.4; word-break: break-word;
427
+ }
428
+ .pf-custom-error-title { font-weight: 600; margin-bottom: 2px; }
429
+ /* A sub-panel inside a custom control (host.controls): no header, no indent. */
430
+ .section.bare > .sec-body { padding: 0; }
431
+ .section.bare { padding: 0; border: 0; }
432
+
385
433
  .dl { margin-top: 14px; }
386
434
  .dl-head {
387
435
  font-family: var(--pf-mono); font-size: 10px; font-weight: 600;
@@ -157,8 +157,9 @@ export function filletPolygon(points, r, { segs = 8 } = {}) {
157
157
  }
158
158
 
159
159
  // Fluent builder for a curve-native path contour { start, segments }. Segment kinds:
160
- // lineTo → {to}, arcTo → {to,via} (three-point arc), cubicTo {to,c1,c2} (cubic Bézier).
161
- // close() returns the plain contour object (feeds extrude/revolve/prism), not a Solid.
160
+ // lineTo → {to}, arcTo → {to,via} (three-point arc) or {to,via} computed from a radius
161
+ // spec, cubicTo → {to,c1,c2} (cubic Bézier). close() returns the plain contour object
162
+ // (feeds extrude/revolve/prism), not a Solid.
162
163
  export function pathProfile(start) {
163
164
  const fin2 = (p, what) => {
164
165
  if (!Array.isArray(p) || p.length < 2 || !Number.isFinite(p[0]) || !Number.isFinite(p[1]))
@@ -166,12 +167,73 @@ export function pathProfile(start) {
166
167
  return [p[0], p[1]];
167
168
  };
168
169
  const s = fin2(start, "start");
170
+ let cur = s;
169
171
  const segments = [];
170
172
  const api = {
171
- lineTo(to) { segments.push({ to: fin2(to, "lineTo point") }); return api; },
172
- arcTo(to, via) { segments.push({ to: fin2(to, "arcTo point"), via: fin2(via, "arcTo via") }); return api; },
173
+ lineTo(to) {
174
+ const p = fin2(to, "lineTo point");
175
+ segments.push({ to: p });
176
+ cur = p;
177
+ return api;
178
+ },
179
+ // arcTo(to, via) is the three-point form: `via` is any point on the arc.
180
+ // arcTo(to, { r, sweep?, large? }) is the radius form: `via` is computed here
181
+ // from the current point, `to`, and the radius spec so the emitted segment is
182
+ // byte-for-byte what the three-point form emits (see docs/AUTHORING-PARTS.md).
183
+ arcTo(to, second) {
184
+ const p1 = fin2(to, "arcTo point");
185
+ if (Array.isArray(second)) {
186
+ const via = fin2(second, "arcTo via");
187
+ segments.push({ to: p1, via });
188
+ cur = p1;
189
+ return api;
190
+ }
191
+ if (second !== null && typeof second === "object") {
192
+ const ARC_SPEC_KEYS = ["r", "sweep", "large"];
193
+ const unknownKeys = Object.keys(second).filter((k) => !ARC_SPEC_KEYS.includes(k));
194
+ if (unknownKeys.length > 0)
195
+ throw new Error(
196
+ `pathProfile: arcTo arc spec has unknown ${unknownKeys.length > 1 ? "keys" : "key"} ${unknownKeys.map((k) => JSON.stringify(k)).join(", ")} — the keys are r, sweep, large`,
197
+ );
198
+ const { r, sweep = "ccw", large = false } = second;
199
+ // Cheap key/enum/boolean checks run BEFORE the numeric ones below, so a
200
+ // typo'd sweep/large is reported on its own rather than being masked by
201
+ // an unrelated radius complaint on the same call.
202
+ if (sweep !== "ccw" && sweep !== "cw")
203
+ throw new Error(`pathProfile: arcTo sweep must be "ccw" or "cw", got ${JSON.stringify(sweep)}`);
204
+ if (typeof large !== "boolean")
205
+ throw new Error("pathProfile: arcTo large must be a boolean");
206
+ const [x0, y0] = cur;
207
+ const [x1, y1] = p1;
208
+ const dx = x1 - x0, dy = y1 - y0;
209
+ const d = Math.hypot(dx, dy);
210
+ if (d < 1e-9)
211
+ throw new Error(`pathProfile: arcTo to (${x1}, ${y1}) coincides with the current point`);
212
+ if (!(r > 0) || !Number.isFinite(r))
213
+ throw new Error(`pathProfile: arcTo r must be > 0 and finite, got ${JSON.stringify(r)}`);
214
+ if (r < d / 2 - 1e-9)
215
+ throw new Error(
216
+ `pathProfile: arcTo r=${r} is shorter than half the chord (${(d / 2).toFixed(4)}) from (${x0}, ${y0}) to (${x1}, ${y1}) — the smallest arc that can join these points has r=${(d / 2).toFixed(4)} (a semicircle)`,
217
+ );
218
+ const rr = Math.max(r, d / 2); // absorb the 1e-9 tolerance so h is never NaN
219
+ const h = Math.sqrt(rr * rr - (d / 2) * (d / 2)); // centre's distance from the chord midpoint
220
+ const ux = dx / d, uy = dy / d; // unit chord direction
221
+ const nx = -uy, ny = ux; // unit LEFT normal of the direction of travel
222
+ const mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
223
+ const side = sweep === "ccw" ? -1 : 1; // which side of the chord the arc bulges to
224
+ const sgn = large ? side : -side;
225
+ const cx = mx + nx * h * sgn, cy = my + ny * h * sgn;
226
+ const via = [cx + nx * rr * side, cy + ny * rr * side]; // the arc's midpoint
227
+ segments.push({ to: p1, via });
228
+ cur = p1;
229
+ return api;
230
+ }
231
+ throw new Error("pathProfile: arcTo needs a via [x,y] or an { r, sweep?, large? } arc spec");
232
+ },
173
233
  cubicTo(to, c1, c2) {
174
- segments.push({ to: fin2(to, "cubicTo point"), c1: fin2(c1, "cubicTo c1"), c2: fin2(c2, "cubicTo c2") });
234
+ const p = fin2(to, "cubicTo point");
235
+ segments.push({ to: p, c1: fin2(c1, "cubicTo c1"), c2: fin2(c2, "cubicTo c2") });
236
+ cur = p;
175
237
  return api;
176
238
  },
177
239
  close() {
@@ -4,6 +4,7 @@
4
4
  // resolve against `defaults`, which produce a control that silently does nothing.
5
5
  import { err, warn } from "./finding.js";
6
6
  import { suggest } from "../geometry/op-options.js";
7
+ import { jsonValueProblem } from "../panel/json-value.js";
7
8
  import { fieldsFor, authorFieldsFor, WIDGET_TYPES, GROUP_FIELDS, PRESET_FIELDS, SECTION_FIELDS, normalizeOptions } from "../panel/widget-specs.js";
8
9
  import { sectionRenders, desugar } from "../panel/legacy.js";
9
10
  import { buildTree, WHEN_OPS } from "../panel/model.js";
@@ -253,6 +254,9 @@ export const SCHEMA_RULES = [
253
254
  const defaults = part.defaults;
254
255
  return collectDescriptors(part)
255
256
  .filter(({ container }) => !container)
257
+ // A custom control's OWN key may hold a JSON value; custom-default-not-json
258
+ // (below) is its rule. Its `keys` stay under this one via their own controls.
259
+ .filter(({ d }) => d.type !== "custom")
256
260
  .filter(({ d }) => typeof d.key === "string" && Object.hasOwn(defaults, d.key))
257
261
  .filter(({ d }) => !isEditableValue(defaults[d.key]))
258
262
  .map(({ d, path }) => err("control-default-not-primitive",
@@ -625,4 +629,56 @@ export const SCHEMA_RULES = [
625
629
  return out;
626
630
  },
627
631
  },
632
+ {
633
+ // The whole feature is the function: a missing or non-function `widget`
634
+ // renders the error card in the rail and nothing else, every time.
635
+ id: "custom-control-widget-not-function",
636
+ run: ({ part }) => collectDescriptors(part)
637
+ .filter(({ container, authored, d }) => authored && !container && d.type === "custom" && typeof d.widget !== "function")
638
+ .map(({ d, path }) => err("custom-control-widget-not-function",
639
+ `custom control "${d.key}" has no \`widget\` function`,
640
+ "Set `widget` to a function `(host) => …` — usually imported from a sibling file of the part — that draws into `host.el`. See \"Custom controls\" in AUTHORING-PARTS.md.",
641
+ `${path}.widget`)),
642
+ },
643
+ {
644
+ // The one relaxation of control-default-not-primitive: a custom control's
645
+ // key may hold a JSON value (json-value.js), and nothing else — a host that
646
+ // persists panel settings writes it back as a JSON literal.
647
+ id: "custom-default-not-json",
648
+ run: ({ part }) => {
649
+ if (!isPlainObject(part?.defaults)) return [];
650
+ const defaults = part.defaults;
651
+ return collectDescriptors(part)
652
+ .filter(({ container, authored, d }) => authored && !container && d.type === "custom")
653
+ .filter(({ d }) => typeof d.key === "string" && Object.hasOwn(defaults, d.key))
654
+ .map(({ d }) => ({ d, problem: jsonValueProblem(defaults[d.key]) }))
655
+ .filter(({ problem }) => problem !== null)
656
+ .map(({ d, problem }) => err("custom-default-not-json",
657
+ `\`defaults.${d.key}\` ${problem}`,
658
+ `Give "${d.key}" a JSON value: numbers, strings, booleans, arrays and plain objects of those, at most 16 KB and 8 levels deep, with no null. A custom control stores and persists its value as JSON, so anything else is silently lost on reload.`,
659
+ `defaults.${d.key}`));
660
+ },
661
+ },
662
+ {
663
+ // `keys` are params the widget writes besides its own; each needs a
664
+ // default or the write lands on a key the build never reads.
665
+ id: "custom-keys-not-in-defaults",
666
+ run: ({ part }) => {
667
+ if (!isPlainObject(part?.defaults)) return [];
668
+ const known = defaultKeys(part);
669
+ const out = [];
670
+ for (const { d, path, container, authored } of collectDescriptors(part)) {
671
+ if (!authored || container || d.type !== "custom" || !Array.isArray(d.keys)) continue;
672
+ d.keys.forEach((k, i) => {
673
+ if (typeof k === "string" && known.has(k)) return;
674
+ const hint = typeof k === "string" ? suggest(k, [...known]) : null;
675
+ out.push(err("custom-keys-not-in-defaults",
676
+ `custom control "${d.key}" lists key "${k}", which is not in \`defaults\``,
677
+ `Add "${k}" to \`defaults\`${hint ? `, or correct it to "${hint}"` : ""} — a key the widget writes must exist for the build to read it.`,
678
+ `${path}.keys[${i}]`));
679
+ });
680
+ }
681
+ return out;
682
+ },
683
+ },
628
684
  ];
@@ -12,6 +12,9 @@
12
12
  // closure (test/lint-purity.test.js) so lintPart keeps running in Node, the
13
13
  // browser sandbox iframe, and Deno.
14
14
  //
15
+ // The one exception is json-value.js, which itself imports nothing — the
16
+ // shared predicate for a `type: "custom"` control's value.
17
+ //
15
18
  // KNOWN BLIND SPOT — regex literals are not tokenized: a `/[/*]/` or `/x\/y/`
16
19
  // reads as a comment opener and can blank the rest of the file, and a regex
17
20
  // containing a quote can likewise derail string skipping.
@@ -19,6 +22,8 @@
19
22
  // FALSE NEGATIVES — a derailed scan finds no defaults literal and no tokens,
20
23
  // so a rule says nothing rather than something wrong.
21
24
 
25
+ import { isJsonValue, CUSTOM_VALUE_MAX_DEPTH } from "../panel/json-value.js";
26
+
22
27
  // Span of the object literal after the first `defaults:` key (indices into
23
28
  // `source`, end exclusive, covering `{...}`). String- and comment-aware so a
24
29
  // "defaults: {" inside a string or comment can't fool the scan.
@@ -191,8 +196,9 @@ function decodeStringLiteral(raw) {
191
196
  // into a different spelling of themselves.
192
197
  const NUMBER_RE = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
193
198
 
194
- // One value's source text → { value }, or null when it is not a primitive
195
- // literal this module can read AND write back.
199
+ // One value's source text → { value }, or null when it is not a literal this
200
+ // module can read AND write back. Primitives first; then the JSON-literal
201
+ // grammar a `type: "custom"` control's value is written in.
196
202
  function readValue(raw) {
197
203
  if (raw === "true") return { value: true };
198
204
  if (raw === "false") return { value: false };
@@ -205,6 +211,7 @@ function readValue(raw) {
205
211
  const s = decodeStringLiteral(raw);
206
212
  return s === null ? null : { value: s };
207
213
  }
214
+ if (q === "[" || q === "{") return readJsonLiteral(raw);
208
215
  return null;
209
216
  }
210
217
 
@@ -220,6 +227,113 @@ function readKey(text, i, end) {
220
227
  return m ? { key: m[0], next: i + m[0].length } : null;
221
228
  }
222
229
 
230
+ // --- JSON literals ----------------------------------------------------------
231
+ //
232
+ // The grammar a custom control's owned value is spelled in inside `defaults`:
233
+ // object and array literals of primitives, nested; bare or quoted keys; JS
234
+ // string escapes (the same decoder as above); decimal numbers; true/false;
235
+ // trailing commas; whitespace. NOTHING else — no comments inside the span, no
236
+ // expressions, identifiers, templates, `null`, computed keys, spreads — so
237
+ // every value this reads, writeJsonLiteral can write back and this can read
238
+ // again. A parse failure is null, never a throw: the entry simply stays
239
+ // unreadable, exactly as an expression does.
240
+ //
241
+ // `depth` is threaded through value/array/object and checked BEFORE
242
+ // recursing into a container, so pathological nesting (thousands of `[`)
243
+ // fails fast during parsing instead of blowing the JS call stack — a
244
+ // RangeError would escape readJsonLiteral's try/catch (it only catches
245
+ // JsonLiteralError) and reach a host outside runRules' own try/catch. The
246
+ // threshold matches json-value.js's own depth check exactly (same "depth at
247
+ // which this container sits" numbering), so this is a fast-fail that agrees
248
+ // with the post-parse isJsonValue call below, never a stricter or looser one.
249
+
250
+ class JsonLiteralError extends Error {}
251
+
252
+ function jsonParser(text) {
253
+ let i = 0;
254
+ const fail = () => { throw new JsonLiteralError(); };
255
+ const ws = () => { while (i < text.length && /\s/.test(text[i])) i++; };
256
+ const value = (depth = 0) => {
257
+ ws();
258
+ const c = text[i];
259
+ if (c === "{") return object(depth);
260
+ if (c === "[") return array(depth);
261
+ if (c === '"' || c === "'") {
262
+ const end = skipQuoted(text, i, text.length);
263
+ const s = decodeStringLiteral(text.slice(i, end));
264
+ if (s === null) fail();
265
+ i = end;
266
+ return s;
267
+ }
268
+ const m = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/.exec(text.slice(i));
269
+ if (m) { i += m[0].length; const n = Number(m[0]); if (!Number.isFinite(n)) fail(); return n; }
270
+ if (text.startsWith("true", i)) { i += 4; return true; }
271
+ if (text.startsWith("false", i)) { i += 5; return false; }
272
+ return fail();
273
+ };
274
+ const array = (depth) => {
275
+ if (depth >= CUSTOM_VALUE_MAX_DEPTH) fail();
276
+ i++; // [
277
+ const out = [];
278
+ for (;;) {
279
+ ws();
280
+ if (text[i] === "]") { i++; return out; }
281
+ if (out.length) { if (text[i] !== ",") fail(); i++; ws(); if (text[i] === "]") { i++; return out; } }
282
+ out.push(value(depth + 1));
283
+ }
284
+ };
285
+ const object = (depth) => {
286
+ if (depth >= CUSTOM_VALUE_MAX_DEPTH) fail();
287
+ i++; // {
288
+ const out = {};
289
+ let n = 0;
290
+ for (;;) {
291
+ ws();
292
+ if (text[i] === "}") { i++; return out; }
293
+ if (n) { if (text[i] !== ",") fail(); i++; ws(); if (text[i] === "}") { i++; return out; } }
294
+ const k = readKey(text, i, text.length);
295
+ if (!k) fail();
296
+ i = k.next;
297
+ ws();
298
+ if (text[i] !== ":") fail();
299
+ i++;
300
+ const v = value(depth + 1);
301
+ Object.defineProperty(out, k.key, { value: v, enumerable: true, writable: true, configurable: true });
302
+ n++;
303
+ }
304
+ };
305
+ return { value, done: () => { ws(); return i === text.length; } };
306
+ }
307
+
308
+ // An array or object literal's source text → { value }, or null. Applies the
309
+ // custom-value caps (json-value.js) so a literal lint accepts is one the panel
310
+ // would accept too.
311
+ export function readJsonLiteral(text) {
312
+ if (typeof text !== "string") return null;
313
+ const first = text.trimStart()[0];
314
+ if (first !== "[" && first !== "{") return null;
315
+ try {
316
+ const p = jsonParser(text);
317
+ const v = p.value();
318
+ if (!p.done()) return null;
319
+ return isJsonValue(v) ? { value: v } : null;
320
+ } catch (e) {
321
+ if (e instanceof JsonLiteralError) return null;
322
+ throw e;
323
+ }
324
+ }
325
+
326
+ // The source text for a JSON value being written into `defaults`: compact
327
+ // when it fits on a line, else indented two spaces deeper than the entry's
328
+ // own indentation (`indent`, the whitespace before the entry's key) so a large
329
+ // layout diffs line by line. Always plain JSON — quoted keys — so the reader
330
+ // above accepts it unchanged.
331
+ export function writeJsonLiteral(value, { indent = "" } = {}) {
332
+ const compact = JSON.stringify(value);
333
+ if (compact.length <= 80) return compact;
334
+ return JSON.stringify(value, null, 2).replace(/\n/g, `\n${indent}`);
335
+ }
336
+
223
337
  // Split `{ … }` into entries in source order. `readable` says whether the
224
338
  // value was interpreted; `raw` is its exact source text either way, and
225
339
  // valueStart/valueEnd are its span (indices into `text`), which is what a