partforge 0.47.1 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +454 -80
- package/docs/ERROR-PATTERNS.md +24 -0
- package/package.json +1 -1
- package/src/framework/app.css +30 -2
- package/src/framework/lint/rules-schema.js +359 -21
- package/src/framework/mount.js +8 -1
- package/src/framework/oracle/cases.js +17 -6
- package/src/framework/panel/author.js +88 -0
- package/src/framework/panel/legacy.js +9 -1
- package/src/framework/panel/render.js +46 -6
- package/src/framework/panel/widget-specs.js +58 -0
- package/src/framework/panel/widgets/index.js +3 -0
- package/src/framework/panel/widgets/numeric.js +66 -7
- package/src/framework/panel/widgets/readout.js +31 -0
- package/src/framework/panel/widgets/select.js +71 -0
- package/src/parts/bracket.js +5 -5
- package/src/parts/planter.js +23 -17
- package/types/part.d.ts +96 -5
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -453,109 +453,356 @@ even at animation rates (see `runtime.setParams`).
|
|
|
453
453
|
|
|
454
454
|
## Parameters: the control-panel schema
|
|
455
455
|
|
|
456
|
-
`
|
|
457
|
-
|
|
456
|
+
`src/parts/planter.js`'s "Body" section is a live in-repo example of the full node
|
|
457
|
+
shape — a preset, headline sliders, and a nested `"Wall"` group holding a
|
|
458
|
+
`recommended` band and an `innerDia` readout. `src/parts/bracket.js`'s "Shape ops"
|
|
459
|
+
section shows a `"radio"` control, and mixes it with two sections left on the legacy
|
|
460
|
+
shape — proof the two coexist in one part.
|
|
458
461
|
|
|
459
|
-
**
|
|
462
|
+
`parameters` is an **array of sections**. Each section is a node with a `controls`
|
|
463
|
+
array, and **authored order is render order** — what you write top-to-bottom is what
|
|
464
|
+
the user reads top-to-bottom:
|
|
460
465
|
|
|
461
466
|
```js
|
|
462
467
|
{
|
|
463
|
-
id: "body",
|
|
468
|
+
id: "body", // optional; also the node id (see "Ids" below)
|
|
464
469
|
title: "Body",
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
{ key: "title", label: "Title", control: "text" },
|
|
470
|
-
{ key: "label", label: "Label", control: "textarea" },
|
|
471
|
-
],
|
|
470
|
+
description: "...", // CommonMark, behind the section's ⓘ glyph
|
|
471
|
+
collapsed: "auto", // true | false | "auto" (default)
|
|
472
|
+
when: { ... }, // optional condition — see "Conditions" below
|
|
473
|
+
controls: [ /* entries, in render order */ ],
|
|
472
474
|
}
|
|
473
475
|
```
|
|
474
476
|
|
|
475
|
-
|
|
476
|
-
slider or type an exact value (finer than `step` is allowed; typed values clamp to
|
|
477
|
-
`[min, max]`). Optional `control` per parameter chooses the input:
|
|
477
|
+
Every entry in `controls` is one of four things, told apart by its `type`:
|
|
478
478
|
|
|
479
|
-
-
|
|
480
|
-
|
|
481
|
-
-
|
|
482
|
-
-
|
|
479
|
+
- a **control** — bound to one key in `defaults` (`type` defaults to `"slider"`,
|
|
480
|
+
so a plain `{ key, label, min, max, step }` is a slider);
|
|
481
|
+
- **`type: "group"`** — a nested container with its own `controls` array;
|
|
482
|
+
- **`type: "preset"`** — a picker that writes a bundle of parameters at once;
|
|
483
|
+
- **`type: "readout"`** — a read-only display of a `derive()` output.
|
|
483
484
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
geometry supports them. Editing any control in a preset section selects `Custom`, and
|
|
488
|
-
choosing a preset updates both numeric and text fields.
|
|
485
|
+
Groups nest, but **two levels is the limit** — a section plus one fold inside it is
|
|
486
|
+
as deep as a 300 px rail stays readable, and `partforge lint` warns (`group-depth`)
|
|
487
|
+
past that. Flatten by promoting the inner group to its own section.
|
|
489
488
|
|
|
490
|
-
|
|
489
|
+
A complete section, exercising most of the model:
|
|
491
490
|
|
|
492
491
|
```js
|
|
493
|
-
{
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
492
|
+
defaults: { profile: "round", facets: 6, dia: 80, wall: 2, feet: 0 },
|
|
493
|
+
derive: (p) => ({ innerDia: p.dia - 2 * p.wall }),
|
|
494
|
+
|
|
495
|
+
parameters: [
|
|
496
|
+
{
|
|
497
|
+
id: "body",
|
|
498
|
+
title: "Body",
|
|
499
|
+
description: "Silhouette and size of the vessel.",
|
|
500
|
+
controls: [
|
|
501
|
+
{ type: "preset", presets: {
|
|
502
|
+
"Pen cup": { dia: 80, wall: 2, profile: "round" },
|
|
503
|
+
Vase: { dia: 120, wall: 2.4, profile: "faceted", facets: 8 },
|
|
504
|
+
} },
|
|
505
|
+
|
|
506
|
+
{ key: "profile", type: "radio", label: "Profile",
|
|
507
|
+
options: [{ value: "round", label: "Round" }, { value: "faceted", label: "Faceted" }],
|
|
508
|
+
description: "**Round** revolves the silhouette; **faceted** prisms it." },
|
|
509
|
+
|
|
510
|
+
{ key: "facets", type: "slider", label: "Facets", min: 3, max: 12, step: 1,
|
|
511
|
+
when: { profile: "faceted" }, // only shown on a faceted profile
|
|
512
|
+
description: "Sides of the prism. 6–8 reads as faceted without looking coarse." },
|
|
513
|
+
|
|
514
|
+
{ key: "dia", type: "slider", label: "Diameter", unit: "mm", min: 30, max: 150, step: 1,
|
|
515
|
+
description: "Outer diameter at the widest point; 60–100 mm suits a pen cup." },
|
|
516
|
+
|
|
517
|
+
{ type: "group", title: "Wall", collapsed: "auto", controls: [
|
|
518
|
+
{ key: "wall", type: "slider", label: "Thickness", unit: "mm",
|
|
519
|
+
min: 0.8, max: 4, step: 0.1, recommended: [1.2, 4],
|
|
520
|
+
description: "Wall thickness. Under 1.2 mm an FDM print gets fragile." },
|
|
521
|
+
|
|
522
|
+
{ type: "readout", label: "Inner diameter", derivedKey: "innerDia", unit: "mm",
|
|
523
|
+
description: "Diameter minus both walls — the space something actually has to fit into." },
|
|
524
|
+
|
|
525
|
+
{ key: "feet", type: "checkbox", label: "Raised feet", on: 3,
|
|
526
|
+
description: "Lift the base on four 3 mm feet so it drains and de-moulds cleanly." },
|
|
527
|
+
] },
|
|
528
|
+
],
|
|
529
|
+
},
|
|
530
|
+
]
|
|
501
531
|
```
|
|
502
532
|
|
|
503
|
-
Every `key`
|
|
504
|
-
|
|
533
|
+
Every control `key` must exist in `defaults`, or the control is silently dead —
|
|
534
|
+
`control-key-not-in-defaults` is an error for exactly that reason.
|
|
535
|
+
|
|
536
|
+
**Ids.** A section, a group and a preset may carry an `id`; the renderer keys its
|
|
537
|
+
element, state and disclosure maps on ids, so they must be unique across the whole
|
|
538
|
+
panel (`duplicate-node-id`). A **control** entry's `id` is ignored — controls get
|
|
539
|
+
positional ids — and lint reports it as an unknown field. Leave `id` off unless you
|
|
540
|
+
need a stable handle.
|
|
541
|
+
|
|
542
|
+
### Control types
|
|
543
|
+
|
|
544
|
+
Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` and
|
|
545
|
+
`whenFalse`. Beyond those:
|
|
546
|
+
|
|
547
|
+
| `type` | Renders as | Extra fields |
|
|
548
|
+
|---|---|---|
|
|
549
|
+
| `"slider"` (default) | a range track plus an editable number box | `unit`, `min`, `max`, `step`, and the refinements below |
|
|
550
|
+
| `"number"` | the number box alone — for precise or very wide ranges | `unit`, `min`, `max`, `step`, `recommended` (see below) |
|
|
551
|
+
| `"text"` | a single-line string field | — |
|
|
552
|
+
| `"textarea"` | a multiline string field; line breaks are preserved | — |
|
|
553
|
+
| `"checkbox"` | an on/off box: ticked writes `on`, cleared writes `0` | `on` (default `1`) |
|
|
554
|
+
| `"select"` | a dropdown | `options` |
|
|
555
|
+
| `"radio"` | a segmented button row | `options` |
|
|
556
|
+
|
|
557
|
+
Numeric controls always show the number box: drag the slider *or* type an exact
|
|
558
|
+
value. Typed values may be finer than `step` and clamp to `[min, max]` on commit.
|
|
559
|
+
Text fields write `params` on every keystroke, so the rebuild loop previews the new
|
|
560
|
+
string immediately; give every text key a string default (empty strings are valid,
|
|
561
|
+
and the build decides whether its geometry tolerates one).
|
|
562
|
+
|
|
563
|
+
**`options`** (select/radio) takes either the shorthand `["round", "faceted"]` —
|
|
564
|
+
each entry is both value and label — or the long form
|
|
565
|
+
`[{ value, label, description? }]`. Values may be strings or numbers, and
|
|
566
|
+
`defaults[key]` **must be one of them** (`select-default-not-in-options`; watch
|
|
567
|
+
types, `12` is not `"12"`). An option's `description` surfaces as a hover tooltip
|
|
568
|
+
on that one option, not as a ⓘ popover.
|
|
569
|
+
|
|
570
|
+
**`"readout"` is not a control.** It has no `key`, never writes `params`, and can
|
|
571
|
+
never be a preset target. It displays one output of `derive()`, named by
|
|
572
|
+
`derivedKey`, refreshed on every parameter change; `unit` is appended to numeric
|
|
573
|
+
values. A `derivedKey` no `derive` group returns shows an em-dash forever, which
|
|
574
|
+
`readout-unknown-derived-key` warns about. Readouts are how a panel closes the loop
|
|
575
|
+
on design intent — show the user the clearance, the inner diameter, the resulting
|
|
576
|
+
wall — without adding a parameter nobody should edit.
|
|
577
|
+
|
|
578
|
+
**A `group`** takes `type`, `id`, `title`, `collapsed`, `bare`, `controls`,
|
|
579
|
+
`hidden`, `when` and `whenFalse`. It deliberately takes **no `description`**: the
|
|
580
|
+
fold's title is itself a button, and there is nowhere to hang an ⓘ glyph beside it.
|
|
581
|
+
Put the explanation on the section or on the controls inside. `bare: true` drops the
|
|
582
|
+
title and the disclosure entirely, leaving an indented block — useful for a run of
|
|
583
|
+
controls that appear and disappear together under one `when`.
|
|
584
|
+
|
|
585
|
+
### Control metadata
|
|
505
586
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
587
|
+
- `description` — a CommonMark string shown in a click-open **ⓘ** popover beside the
|
|
588
|
+
label. Supports **bold/italic**, lists, `code`, links and images (for diagrams);
|
|
589
|
+
links open in a new tab and the rendered HTML is sanitized. Write one for every
|
|
590
|
+
control — see "A description for every control" below.
|
|
591
|
+
- `hidden: true` — omits the node from the panel. Its `key` must still exist in
|
|
592
|
+
`defaults` and still drives the geometry: this is *no UI*, not *no parameter*. Use
|
|
593
|
+
it for internal constants the end user shouldn't edit. A group left with no visible
|
|
594
|
+
children doesn't render at all, and neither does an empty section.
|
|
595
|
+
|
|
596
|
+
### Slider refinements
|
|
597
|
+
|
|
598
|
+
Three optional fields shape how a numeric track behaves. All three are worth reaching
|
|
599
|
+
for when a raw linear slider misrepresents the parameter.
|
|
600
|
+
|
|
601
|
+
- **`scale: "log"`** — the thumb travels geometrically, so a 0.1–100 range gives each
|
|
602
|
+
decade equal width instead of burying everything below 10 in the first pixel. The
|
|
603
|
+
number box stays linear and exact, so typing `0.5` still works. `min` **must be
|
|
604
|
+
greater than 0** (`log(0)` is `-Infinity` and the mapping breaks); lint reports
|
|
605
|
+
`log-scale-needs-positive-min`.
|
|
606
|
+
- **`ticks: [...]`** — marked values on the track (a native `datalist`). Every tick
|
|
607
|
+
must sit inside `[min, max]`. Add **`snap: true`** to quantize *slider drags* to the
|
|
608
|
+
nearest tick; the number box stays free, so an off-tick value is always still
|
|
609
|
+
typeable. Use it for stock sizes: M3/M4/M5, 3 mm / 6 mm plate.
|
|
610
|
+
- **`recommended: [lo, hi]`** — tints that span of the track and puts a warning border
|
|
611
|
+
on the number box when the value sits outside it. This is the **visual companion to
|
|
612
|
+
the DFM checks**: the band is where the process the part targets is comfortable
|
|
613
|
+
(minimum wall, nozzle multiples, sane clearances), and `verify`'s `minWall` /
|
|
614
|
+
process checks are the same judgement enforced at measure time. It is advisory —
|
|
615
|
+
outside values remain selectable, because a user who knows their printer should not
|
|
616
|
+
be blocked by a default profile.
|
|
617
|
+
|
|
618
|
+
`ticks`, `snap` and `recommended` render on a **linear track only**; combined with
|
|
619
|
+
`scale: "log"` they are ignored, and `slider-refinement-invalid` warns. On a
|
|
620
|
+
`"number"` control there is no track at all: `recommended` still tints the box on an
|
|
621
|
+
out-of-band value, while `scale`, `ticks` and `snap` do nothing.
|
|
622
|
+
|
|
623
|
+
### Conditions: `when` and `whenFalse`
|
|
624
|
+
|
|
625
|
+
`when` is valid on **any** node — a control, a group, a preset, a readout, or a
|
|
626
|
+
section itself. It is a plain data condition evaluated against raw parameters:
|
|
627
|
+
|
|
628
|
+
```js
|
|
629
|
+
when: { profile: "faceted" } // equality
|
|
630
|
+
when: { wall: { gte: 1.2 } } // gt | gte | lt | lte | ne
|
|
631
|
+
when: { style: { in: ["cup", "vase"] } } // membership
|
|
632
|
+
when: { drain: { gt: 0 }, mode: "planter" } // multiple keys are ANDed
|
|
633
|
+
when: { allOf: [{ drain: { gt: 0 } }, { mode: "planter" }] }
|
|
634
|
+
when: { anyOf: [{ style: "cup" }, { style: "vase" }] }
|
|
635
|
+
when: { not: { style: "plain" } }
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
The operators are `gt`, `gte`, `lt`, `lte`, `ne` and `in`; the combinators are
|
|
639
|
+
`allOf`, `anyOf` and `not`. Two rules make conditions statically checkable, and both
|
|
640
|
+
are enforced as **errors** because either failure is silent at runtime:
|
|
641
|
+
|
|
642
|
+
- **Raw parameter keys only.** A `when` reads keys from `defaults`, never derived
|
|
643
|
+
values — that is what lets lint check every referenced key against `defaults`
|
|
644
|
+
(`when-key-not-in-defaults`), which no predicate function could support. Readouts
|
|
645
|
+
reach derived values through their own `derivedKey`, so there is never any doubt
|
|
646
|
+
which namespace a name is in.
|
|
647
|
+
- **Known operators only.** `evalWhen` treats an unrecognised operator as false, so a
|
|
648
|
+
typo would hide the node forever; `when-unknown-operator` catches it first.
|
|
649
|
+
|
|
650
|
+
A malformed condition evaluates to `false` rather than throwing — a control that
|
|
651
|
+
hides is better than a panel that crashes.
|
|
652
|
+
|
|
653
|
+
When the condition is false the node is **removed from the layout**, taking its
|
|
654
|
+
subtree with it if it is a group. Set **`whenFalse: "disable"`** to grey it in place
|
|
655
|
+
instead, for the case where the user should see that an option exists but needs
|
|
656
|
+
something else switched on first. Disabling propagates through the whole subtree and
|
|
657
|
+
sets real `disabled` attributes, so a disabled control cannot be focused or dragged.
|
|
658
|
+
|
|
659
|
+
**`when` is not relevance dimming.** The panel also dims controls automatically, and
|
|
660
|
+
the two are different mechanisms that must stay visually distinct:
|
|
661
|
+
|
|
662
|
+
| | Relevance dimming | `when` |
|
|
663
|
+
|---|---|---|
|
|
664
|
+
| Answers | "does the geometry on screen actually read this parameter?" | "did the author say this applies right now?" |
|
|
665
|
+
| Comes from | probing the build — automatic, nothing to write | your `when` condition |
|
|
666
|
+
| Looks like | faded but fully usable, with a "doesn't affect the parts in the current view" tooltip | gone from the layout, or genuinely disabled |
|
|
667
|
+
|
|
668
|
+
A control can be relevant but conditioned away, or conditioned in but irrelevant.
|
|
669
|
+
Both recompute on the same tick as any parameter change. Don't reach for `when` to
|
|
670
|
+
reproduce dimming — you'd be hand-maintaining something the framework already knows.
|
|
671
|
+
|
|
672
|
+
### Collapsing
|
|
673
|
+
|
|
674
|
+
Every section and every titled group is a disclosure, controlled by `collapsed`:
|
|
675
|
+
`true` (start closed), `false` (start open), or `"auto"` (the default). `"auto"`
|
|
676
|
+
defers to one rule:
|
|
511
677
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
678
|
+
> A panel with **three or fewer visible top-level sections** opens every `"auto"`
|
|
679
|
+
> section and fold on load. Beyond that, they all start closed.
|
|
680
|
+
|
|
681
|
+
The rail is a fixed-height column, and a long part otherwise scrolls forever; three
|
|
682
|
+
sections is a panel the user can take in at a glance. The count is over the sections
|
|
683
|
+
in the built tree — `hidden: true` sections and sections left with nothing in them are
|
|
684
|
+
gone before counting, but a section that a false `when` or relevance merely hides from
|
|
685
|
+
view still counts, since it can come back without rebuilding the panel.
|
|
686
|
+
Only the **first** render applies it — after that the user's own clicks own the folds,
|
|
687
|
+
and a slider drag never snaps a section they opened back shut. A `bare: true` group
|
|
688
|
+
has no disclosure at all and is never collapsed.
|
|
689
|
+
|
|
690
|
+
### Presets
|
|
691
|
+
|
|
692
|
+
A preset picker is a node like any other:
|
|
693
|
+
|
|
694
|
+
```js
|
|
695
|
+
{ type: "preset", label: "Size", presets: {
|
|
696
|
+
M3: { od: 8, bore: 3.4, h: 10 },
|
|
697
|
+
M5: { od: 12, bore: 5.4, h: 16 },
|
|
698
|
+
} }
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
Each key of `presets` is a name, each value a bundle of parameter overrides (every
|
|
702
|
+
key of which must exist in `defaults` — `preset-key-not-in-defaults`). The picker
|
|
703
|
+
lists the names plus **Custom**, and opens on the first name. Choosing a preset
|
|
704
|
+
assigns its overrides over `params` and refreshes that section's controls; editing
|
|
705
|
+
any control in the section afterwards drops the picker to **Custom**.
|
|
706
|
+
|
|
707
|
+
Because it is a node, a picker can sit **anywhere** in `controls` — among the
|
|
708
|
+
controls it affects, not necessarily at the top — and a section may carry more than
|
|
709
|
+
one. Note that Custom-marking tracks the section's **first** picker only, so if two
|
|
710
|
+
pickers in one section both need to show divergence, give each its own section.
|
|
711
|
+
|
|
712
|
+
**Preset names are global to the part**, not to the section: `verify()` expands one
|
|
713
|
+
case per preset name (so every preset gets measured), and a repeated name throws
|
|
714
|
+
there. `duplicate-preset-name` catches it at lint time instead.
|
|
715
|
+
|
|
716
|
+
### Legacy section shapes (still supported)
|
|
717
|
+
|
|
718
|
+
Everything above is what a **new part should write**. The original array-based shapes
|
|
719
|
+
predate the node model, still work exactly as they always did, and are not going
|
|
720
|
+
away — most of the in-repo parts are deliberately left on them as live proof.
|
|
721
|
+
`desugar()` normalizes them into the very same nodes, so the **runtime** is uniform:
|
|
722
|
+
one renderer, one state pass, one set of lint walkers, whichever shape you wrote.
|
|
723
|
+
|
|
724
|
+
The **authorable surface is not** uniform, and deliberately so — the legacy
|
|
725
|
+
descriptors are frozen at the fields they always had. `when`, `whenFalse` and
|
|
726
|
+
`collapsed`, and the `"checkbox"`, `"select"`, `"radio"` and `"readout"` types, exist
|
|
727
|
+
in the `controls` shape **only**. Written on a legacy descriptor they are dropped and
|
|
728
|
+
reported as `unknown-control-field`; a legacy section's `collapsed` is ignored
|
|
729
|
+
silently. Reach for any of them and you are writing a `controls` section.
|
|
730
|
+
|
|
731
|
+
| Legacy | Normalizes to |
|
|
732
|
+
|---|---|
|
|
733
|
+
| `presets: {...}` | a `{ type: "preset" }` node, first child of the section |
|
|
734
|
+
| `toggles: [{ key, label, on }]` | `"checkbox"` controls placed directly in the section, after the picker and before the Advanced fold |
|
|
735
|
+
| `advanced: [...]` | a nested group titled **Advanced**, `collapsed: "auto"` |
|
|
736
|
+
| `features: [{ key, on, sliders }]` | per feature: a `"checkbox"`, followed by a `bare` group of its sliders carrying `when: { [key]: { gt: 0 } }` — both inside the Advanced group |
|
|
737
|
+
| `control: "number"` | `type: "number"` |
|
|
738
|
+
| `hidden: true` | kept through desugaring (lint needs it), dropped when the render tree is built |
|
|
739
|
+
|
|
740
|
+
**Preset + controls section** — a picker, standalone toggles, and an Advanced fold:
|
|
515
741
|
|
|
516
742
|
```js
|
|
517
743
|
{
|
|
518
|
-
id: "
|
|
519
|
-
title: "
|
|
744
|
+
id: "body",
|
|
745
|
+
title: "Body",
|
|
746
|
+
presets: { M3: { od: 8, bore: 3.4, h: 10 }, M5: { od: 12, bore: 5.4, h: 16 } },
|
|
520
747
|
toggles: [
|
|
521
748
|
{ key: "clip", label: "Clip arms to a disc (intersect)", on: 1,
|
|
522
|
-
description: "**Intersect** the cross with a circle so the
|
|
749
|
+
description: "**Intersect** the cross with a circle so the arm tips round off." },
|
|
750
|
+
],
|
|
751
|
+
advanced: [ // controls revealed under "Advanced"
|
|
752
|
+
{ key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5 },
|
|
753
|
+
{ key: "bore", label: "Bore", unit: "mm", min: 1, max: 30, step: 0.1, control: "number" },
|
|
754
|
+
{ key: "title", label: "Title", control: "text" },
|
|
523
755
|
],
|
|
524
756
|
}
|
|
525
757
|
```
|
|
526
758
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
example.
|
|
759
|
+
`control` is the legacy spelling of `type` and takes `"slider"` (the default),
|
|
760
|
+
`"number"`, `"text"` or `"textarea"` — the newer `"checkbox"`, `"select"`, `"radio"`
|
|
761
|
+
and `"readout"` types exist only in the `controls` shape. A `toggles` entry is
|
|
762
|
+
`{ key, label, on?, hidden?, description? }`: checked writes `on` (default `1`),
|
|
763
|
+
unchecked writes `0`. It is the right home for a bare boolean in this shape —
|
|
764
|
+
`src/parts/hull-sweep.js`'s `wrap` toggle is the in-repo example.
|
|
533
765
|
|
|
534
|
-
**
|
|
535
|
-
|
|
536
|
-
- `description` — a CommonMark string shown in a click-open **ⓘ** popover beside the
|
|
537
|
-
label. Supports **bold/italic**, lists, `code`, links, and images (for diagrams);
|
|
538
|
-
links open in a new tab and the rendered HTML is sanitized. Write one for every
|
|
539
|
-
control — see "A description for every control" below.
|
|
540
|
-
- `hidden: true` — omits the control/feature/section from the panel. Its `key` must still
|
|
541
|
-
exist in `defaults` and still drives the geometry: use it for internal constants the
|
|
542
|
-
end user shouldn't edit (it is *no UI*, not *no parameter*). A section left with no
|
|
543
|
-
presets and no visible controls doesn't render at all.
|
|
766
|
+
**Feature-toggle section** — a checkbox that enables a feature *and* reveals its own
|
|
767
|
+
sliders (`0` = off):
|
|
544
768
|
|
|
545
769
|
```js
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
],
|
|
770
|
+
{
|
|
771
|
+
id: "flange",
|
|
772
|
+
title: "Flange",
|
|
773
|
+
features: [
|
|
774
|
+
{ label: "Base flange", key: "flange_d", on: 16,
|
|
775
|
+
sliders: [{ key: "flange_d", label: "Flange diameter", unit: "mm", min: 8, max: 50, step: 1 }] },
|
|
776
|
+
],
|
|
777
|
+
}
|
|
552
778
|
```
|
|
553
779
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
780
|
+
A feature's `on` is **required and must be greater than 0** — it is the real value the
|
|
781
|
+
parameter takes when the box is ticked (a diameter, a count), and the panel reads
|
|
782
|
+
`> 0` as "enabled", so there is nothing sensible to fall back to
|
|
783
|
+
(`features-requires-on`). `sliders` is required too (`features-requires-sliders`) — it
|
|
784
|
+
is what the checkbox reveals, and a feature with nothing to reveal belongs in
|
|
785
|
+
`toggles` instead. A section carrying `features` renders *only* its features — its
|
|
786
|
+
`presets`, `toggles` and `advanced` are ignored. `src/parts/demo.js`'s `flange` is the
|
|
787
|
+
in-repo example (`planter.js` has a second one).
|
|
788
|
+
|
|
789
|
+
Three behaviours differ between the shapes, and they are frozen that way on purpose:
|
|
790
|
+
|
|
791
|
+
- A legacy **feature** checkbox restores the magnitude the user had dialled in when
|
|
792
|
+
re-ticked; an authored `"checkbox"` always writes `on`. The node-model way to get a
|
|
793
|
+
feature is a checkbox plus a group gated on `when: { key: { gt: 0 } }` — which is
|
|
794
|
+
exactly what `features` desugars to.
|
|
795
|
+
- **Every** control in a `controls` section marks the section's picker Custom when
|
|
796
|
+
edited. In the legacy shapes, feature sliders and toggles do not.
|
|
797
|
+
- Collapse state is not authorable here, per the surface note above: a legacy section
|
|
798
|
+
and the "Advanced" fold it desugars to are both always `"auto"`, so they follow the
|
|
799
|
+
three-section rule and nothing else.
|
|
800
|
+
|
|
801
|
+
**A section is one shape or the other.** Mixing `controls` with `advanced`,
|
|
802
|
+
`toggles`, `features` or `presets` is the error `mixed-section-shape` — the render
|
|
803
|
+
order of the mixture would be arbitrary. (`controls` wins if you do it anyway.) A
|
|
804
|
+
single *part* may mix freely, one shape per section, so migration can go section by
|
|
805
|
+
section.
|
|
559
806
|
|
|
560
807
|
---
|
|
561
808
|
|
|
@@ -611,12 +858,65 @@ coherently:
|
|
|
611
858
|
Tier the controls so the default view is uncluttered:
|
|
612
859
|
|
|
613
860
|
1. **Presets** for the common cases — the first thing most users pick.
|
|
614
|
-
2. A **few primary
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
861
|
+
2. A **few primary controls** for the dimensions users change most, sitting loose in
|
|
862
|
+
the section.
|
|
863
|
+
3. **A nested group** (`{ type: "group", title: "...", collapsed: "auto" }`) for the
|
|
864
|
+
rest — one per idea, titled for what it is (`Wall`, `Lid`, `Mounting`), not
|
|
865
|
+
"Advanced". Two levels is the ceiling.
|
|
866
|
+
4. **`hidden: true`** for internal constants the end user shouldn't edit.
|
|
867
|
+
|
|
868
|
+
Keep a section to **12 visible controls or fewer** — past that `section-too-many-controls`
|
|
869
|
+
warns, because more than a dozen in one column reads as a wall rather than a set of
|
|
870
|
+
choices. If a section is over budget, the fix is almost always that two ideas are
|
|
871
|
+
sharing it: split the section, or hide internals (`hidden: true`) — grouping
|
|
872
|
+
organizes but does not reduce the count.
|
|
873
|
+
|
|
874
|
+
Aim for a panel whose first screen is a handful of controls, and whose full design is
|
|
875
|
+
one click away in a fold.
|
|
876
|
+
|
|
877
|
+
### Choosing a control
|
|
878
|
+
|
|
879
|
+
The type carries meaning, so pick the one that matches the parameter rather than
|
|
880
|
+
defaulting everything to a slider:
|
|
881
|
+
|
|
882
|
+
- **A continuous dimension** → `"slider"`. Add `recommended` when there's a
|
|
883
|
+
manufacturable band, `ticks` + `snap` when real-world stock sizes exist, and
|
|
884
|
+
`scale: "log"` when the range spans decades.
|
|
885
|
+
- **A precise or very wide number** (a count, a tolerance, a coordinate) →
|
|
886
|
+
`"number"`, so the user types rather than hunts.
|
|
887
|
+
- **A discrete choice** → `"select"` when the values are a list, or `"radio"` when
|
|
888
|
+
there are **2–4** of them and seeing all the options at once is part of the
|
|
889
|
+
decision. Never fake either one with a slider over magic integers.
|
|
890
|
+
- **A boolean** → `"checkbox"`. Ticked writes `on`, cleared writes `0`; there is no
|
|
891
|
+
reason for a two-position slider to exist.
|
|
892
|
+
- **A computed value the user should see but not set** → `"readout"`. It costs no
|
|
893
|
+
parameter and answers the "so what did that do?" question in place.
|
|
894
|
+
|
|
895
|
+
Then gate what doesn't always apply. A control that is meaningless in the current mode
|
|
896
|
+
should carry a **`when`** rather than sit there inert — hide it by default, or use
|
|
897
|
+
`whenFalse: "disable"` when its existence is itself the information ("Lid hinge:
|
|
898
|
+
enable a lid first"). Conditions are also the cheapest way to keep a section under
|
|
899
|
+
budget: three mode-specific controls that are never all relevant at once cost the
|
|
900
|
+
reader one.
|
|
901
|
+
|
|
902
|
+
Finally, **every control gets a `description`** — see below.
|
|
903
|
+
|
|
904
|
+
### Ordering and naming
|
|
905
|
+
|
|
906
|
+
Authored order is render order, so spend it deliberately: put the control a user
|
|
907
|
+
reaches for first at the **top** — usually the primary dimension the presets don't
|
|
908
|
+
settle — and order the rest by how a user thinks about the part, not by the order
|
|
909
|
+
the build consumes them. A user scans the rail top-to-bottom once; the control they
|
|
910
|
+
need should sit where that scan expects it, with fine-tuning below it and
|
|
911
|
+
housekeeping last.
|
|
912
|
+
|
|
913
|
+
Labels are for reading, not for the build: a short noun phrase (**"Wall
|
|
914
|
+
thickness"**, **"Bolt hole ø"**), with units in `unit:` rather than in the label
|
|
915
|
+
text, and never a parameter key or build-internal jargon — `flange_d` is a key,
|
|
916
|
+
"Flange diameter" is a label. Every label must make sense on its own with its
|
|
917
|
+
neighbours folded away; if a label only reads correctly next to another control
|
|
918
|
+
("Diameter" … "Diameter" in two groups), rename until each stands alone or regroup
|
|
919
|
+
until they are one idea.
|
|
620
920
|
|
|
621
921
|
### A description for every control
|
|
622
922
|
|
|
@@ -644,6 +944,9 @@ parameters change. You don't wire this up; it's automatic. To get the most from
|
|
|
644
944
|
on-screen part shows dimmed, which is a useful signal that it's vestigial or
|
|
645
945
|
misplaced.
|
|
646
946
|
|
|
947
|
+
This is a separate mechanism from `when`, and stays visually distinct from it on
|
|
948
|
+
purpose — see "Conditions: `when` and `whenFalse`" above for the split.
|
|
949
|
+
|
|
647
950
|
---
|
|
648
951
|
|
|
649
952
|
## Profiles & patterns
|
|
@@ -1136,9 +1439,80 @@ previously didn't; that's the fix working as intended, not a regression.
|
|
|
1136
1439
|
`default-view-ambiguous` (warnings).
|
|
1137
1440
|
|
|
1138
1441
|
**Parameter schema** — `features-requires-sliders`, `features-requires-on`,
|
|
1139
|
-
`control-key-not-in-defaults`, `preset-key-not-in-defaults`
|
|
1442
|
+
`control-key-not-in-defaults`, `preset-key-not-in-defaults`, `mixed-section-shape`,
|
|
1443
|
+
`duplicate-preset-name`, `duplicate-node-id`, `select-options-missing`,
|
|
1444
|
+
`select-default-not-in-options`, `log-scale-needs-positive-min`,
|
|
1445
|
+
`when-key-not-in-defaults`, `when-unknown-operator`, `unknown-control-type` (errors);
|
|
1140
1446
|
`slider-range-excludes-default`, `unknown-control-field`, `duplicate-control-key`,
|
|
1141
|
-
`default-not-exposed`
|
|
1447
|
+
`default-not-exposed`, `readout-unknown-derived-key`, `slider-refinement-invalid`,
|
|
1448
|
+
`group-depth`, `section-too-many-controls` (warnings).
|
|
1449
|
+
|
|
1450
|
+
`mixed-section-shape` fires when a section mixes the new `controls` array with
|
|
1451
|
+
a legacy field (`advanced`, `toggles`, `features`, `presets`) — the two shapes
|
|
1452
|
+
can't coexist, since mixing them would make the render order arbitrary. Move
|
|
1453
|
+
the legacy entries into `controls` (a toggle becomes a checkbox control,
|
|
1454
|
+
`advanced` becomes a nested group, `presets` becomes `{ type: "preset" }`
|
|
1455
|
+
nodes), or drop `controls` and stay legacy.
|
|
1456
|
+
`duplicate-preset-name` fires when the same preset name is declared twice
|
|
1457
|
+
(legacy `presets` and/or `{ type: "preset" }` nodes both count) — preset names
|
|
1458
|
+
are global to the part, and `verify()` expands one case per name and throws on
|
|
1459
|
+
a repeat, a worse place to find out. Rename one of them.
|
|
1460
|
+
`duplicate-node-id` fires when two panel nodes (sections, groups, or controls)
|
|
1461
|
+
share an `id` — the renderer keys its element and state maps on ids, and a
|
|
1462
|
+
collision silently cross-wires the two nodes. Rename one `id`, or drop it to
|
|
1463
|
+
use the positional default.
|
|
1464
|
+
`select-options-missing` fires when a `select`/`radio` control has no
|
|
1465
|
+
`options` array — with none the control renders empty and its parameter can
|
|
1466
|
+
never change.
|
|
1467
|
+
`select-default-not-in-options` fires when `defaults[key]` is not one of a
|
|
1468
|
+
`select`/`radio`'s option values (watch value types — `12` is not `"12"`) —
|
|
1469
|
+
without this the panel opens showing a value the user can never get back to.
|
|
1470
|
+
Add the value to `options`, or change the default.
|
|
1471
|
+
`log-scale-needs-positive-min` fires when a slider/number sets `scale: "log"`
|
|
1472
|
+
without a positive `min` — `log(0)` is `-Infinity` and the thumb-to-value mapping
|
|
1473
|
+
breaks, so raise `min` above 0 or drop `scale`.
|
|
1474
|
+
`when-key-not-in-defaults` and `when-unknown-operator` walk every authored
|
|
1475
|
+
`when` (on a control, a group, a preset, a readout, or a section itself) —
|
|
1476
|
+
`allOf`/`anyOf`/`not` recurse — and check each condition against the two things
|
|
1477
|
+
that make it real: the param key must be one `defaults` actually declares, and
|
|
1478
|
+
each comparison operator (`{ gt: 0 }`, `{ in: [...] }`, …) must be one
|
|
1479
|
+
`evalWhen` recognises. Both are silent failure modes — an unknown key reads
|
|
1480
|
+
`undefined` and an unknown operator is treated as false, so either way the
|
|
1481
|
+
condition is always false and the node never shows — which is why both are
|
|
1482
|
+
errors rather than warnings.
|
|
1483
|
+
|
|
1484
|
+
`unknown-control-type` fires when an authored control's `type` (e.g. a typo
|
|
1485
|
+
like `"sldier"`) isn't one of the recognised widget types — the renderer skips
|
|
1486
|
+
a node with an unrecognised type entirely, so the control silently vanishes
|
|
1487
|
+
from the panel with no other sign anything is wrong. An unrecognised type's
|
|
1488
|
+
field list falls back to the common set (`key`, `type`, `label`, `description`,
|
|
1489
|
+
`hidden`, `when`, `whenFalse`) rather than an empty one, so this error carries
|
|
1490
|
+
the diagnosis instead of every field on the control — even ordinary ones like
|
|
1491
|
+
`label` — separately warning as `unknown-control-field`. This only applies to
|
|
1492
|
+
the authored `controls` shape — a legacy descriptor's `control:` value was
|
|
1493
|
+
never validated and still isn't.
|
|
1494
|
+
|
|
1495
|
+
`readout-unknown-derived-key` checks a `{ type: "readout" }` entry's `derivedKey`
|
|
1496
|
+
against the keys `derive()` actually produces (resolved once against `defaults`)
|
|
1497
|
+
— a readout naming a key no group returns shows an em-dash forever, so it warns
|
|
1498
|
+
rather than errors.
|
|
1499
|
+
`slider-refinement-invalid` covers a slider/number's optional `ticks` (native
|
|
1500
|
+
datalist marks; combine with `snap: true` to quantize slider drags to the
|
|
1501
|
+
nearest tick) and `recommended` (an `[lo, hi]` band tinted on the track, with
|
|
1502
|
+
the value box warning outside it): a tick outside `[min, max]`, a `recommended`
|
|
1503
|
+
that isn't exactly `[lo, hi]` with `lo < hi`, or either of them combined with
|
|
1504
|
+
`scale: "log"` (ticks and the band render on a linear track only) all warn.
|
|
1505
|
+
`group-depth` warns when authored groups nest more than two levels deep — a
|
|
1506
|
+
section plus one inner fold is as deep as a 300px rail can stay readable.
|
|
1507
|
+
Flatten by promoting the innermost group to its own section, or folding its
|
|
1508
|
+
controls into the parent.
|
|
1509
|
+
`section-too-many-controls` warns when a section (authored or legacy, desugared
|
|
1510
|
+
to a common format) shows more than 12 visible controls — the budget is
|
|
1511
|
+
deliberately conservative, revisable against real LLM-authored parts. More than a
|
|
1512
|
+
dozen in one section reads as a wall; split into multiple sections, or hide
|
|
1513
|
+
internals (`hidden: true`). Grouping controls organizes them but does not
|
|
1514
|
+
reduce the count — the check recurses into groups — so a group alone doesn't
|
|
1515
|
+
bring a section back under budget.
|
|
1142
1516
|
|
|
1143
1517
|
**Kernel API**, found by executing `build()` against a geometry-free probe —
|
|
1144
1518
|
`unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -337,6 +337,30 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
|
|
|
337
337
|
- **Fix:** pass `shading: "faceted"` to `k.loft` (or drop the smooth-implying
|
|
338
338
|
option) per [AUTHORING-PARTS.md](AUTHORING-PARTS.md) shading-intent note.
|
|
339
339
|
|
|
340
|
+
## duplicate-preset-name-throws
|
|
341
|
+
|
|
342
|
+
- **Symptom:** `duplicate preset name across sections:` thrown from verify/measure, naming the repeated preset (e.g. `duplicate preset name across sections: "Compact"`).
|
|
343
|
+
- **Cause:** The same preset name is declared twice — once via the legacy `presets` field, once as a `{ type: "preset" }` node, or twice within either.
|
|
344
|
+
- **Fix:** Rename one of them; `npx partforge lint` reports it statically as `duplicate-preset-name` before verify ever runs. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Rule catalog".
|
|
345
|
+
|
|
346
|
+
## when-condition-never-true
|
|
347
|
+
|
|
348
|
+
- **Symptom:** A control, group, preset, readout, or section with a `when` condition never appears, with no error anywhere.
|
|
349
|
+
- **Cause:** The condition references a key `defaults` doesn't declare (reads `undefined`, which every comparison treats as false) or a typo'd operator (`evalWhen` treats an unrecognized operator as false too).
|
|
350
|
+
- **Fix:** Run `npx partforge lint` — `when-key-not-in-defaults` or `when-unknown-operator` names the offending key or operator. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Rule catalog".
|
|
351
|
+
|
|
352
|
+
## readout-shows-em-dash
|
|
353
|
+
|
|
354
|
+
- **Symptom:** A `{ type: "readout" }` control renders "—" forever, no matter what the other controls are set to.
|
|
355
|
+
- **Cause:** The readout's `derivedKey` names a key that no `derive()` group actually produces.
|
|
356
|
+
- **Fix:** Name a key a `derive` group returns, or add that key to `derive`; `npx partforge lint` warns via `readout-unknown-derived-key`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Rule catalog".
|
|
357
|
+
|
|
358
|
+
## select-default-unreachable
|
|
359
|
+
|
|
360
|
+
- **Symptom:** The panel opens showing a `select`/`radio` value the control can never be set back to by interacting with it.
|
|
361
|
+
- **Cause:** `defaults[key]` is not among the control's `options` values — often a value-type mismatch (`12` is not `"12"`).
|
|
362
|
+
- **Fix:** Add the default to `options`, or change the default to one of the existing options; `npx partforge lint` errors via `select-default-not-in-options`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Rule catalog".
|
|
363
|
+
|
|
340
364
|
# Hardware library
|
|
341
365
|
|
|
342
366
|
Reserved for `hardware-*` patterns (issue #30). No entries yet.
|
package/package.json
CHANGED