partforge 0.26.1 → 0.28.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/README.md +16 -4
- package/bin/cli.js +57 -2
- package/docs/AUTHORING-PARTS.md +205 -14
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +6 -2
- package/skills/partforge/SKILL.md +7 -1
- package/src/app-bracket.js +5 -0
- package/src/app-demo.js +5 -0
- package/src/app-faceted-vase.js +5 -0
- package/src/app-filleted-box.js +5 -0
- package/src/app-hull-sweep.js +5 -0
- package/src/app-nameplate.js +5 -0
- package/src/app-planter.js +5 -0
- package/src/app-text-smoke.js +5 -0
- package/src/framework/app.css +85 -39
- package/src/framework/chrome.css +180 -0
- package/src/framework/debug-overlay.js +16 -1
- package/src/framework/geometry/op-options.js +3 -1
- package/src/framework/geometry/probe.js +122 -23
- package/src/framework/lint/finding.js +20 -0
- package/src/framework/lint/index.js +114 -0
- package/src/framework/lint/rules-build.js +84 -0
- package/src/framework/lint/rules-schema.js +209 -0
- package/src/framework/lint/rules-shape.js +86 -0
- package/src/framework/lint/rules-verify.js +288 -0
- package/src/framework/mount.js +14 -0
- package/src/framework/rail-state.js +73 -0
- package/src/framework/rail.js +321 -0
- package/src/framework/tokens.css +14 -1
- package/src/framework/verify-metrics.js +51 -0
- package/src/framework/worker.js +9 -0
- package/src/lint.js +3 -0
- package/src/testing/verify.js +4 -45
package/README.md
CHANGED
|
@@ -99,10 +99,11 @@ const runtime = mount(part, {
|
|
|
99
99
|
createWorker,
|
|
100
100
|
elements: {
|
|
101
101
|
viewer, controls, // canvas host + param-panel host
|
|
102
|
+
rail, // full-height resizable/collapsible controls rail
|
|
102
103
|
status: { status, busy, phase }, // status chrome
|
|
103
104
|
tabs, // view-tab segmented control
|
|
104
105
|
exports: { stl, step, threeMf }, // export buttons
|
|
105
|
-
chrome: { pause, reframe, theme },
|
|
106
|
+
chrome: { pause, reframe, theme, railToggle }, // viewer buttons + rail collapse/restore
|
|
106
107
|
},
|
|
107
108
|
onBuild: ({ status, ms, error }) => {}, // per accepted build: "success" | "error"
|
|
108
109
|
onPick: ({ selection, label, prompt, token }) => {}, // programmatic click-to-select
|
|
@@ -112,9 +113,20 @@ runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU
|
|
|
112
113
|
```
|
|
113
114
|
|
|
114
115
|
Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
|
|
115
|
-
`#status`/`#busy`/`#phase`, `#part`,
|
|
116
|
-
`#
|
|
117
|
-
|
|
116
|
+
`#panel` for `rail`, `#status`/`#busy`/`#phase`, `#part`,
|
|
117
|
+
`#download`/`#download-step`/`#download-3mf`,
|
|
118
|
+
`#pause`/`#reframe`/`#theme`/`#rail-toggle`), so a classic host page needs no
|
|
119
|
+
changes. The viewer sizes from its container via ResizeObserver — no window
|
|
120
|
+
coupling.
|
|
121
|
+
|
|
122
|
+
`rail` is the full-height controls rail introduced by the resizable-panel
|
|
123
|
+
layout (`docs/superpowers/specs/2026-07-26-controls-rail-layout-design.md`);
|
|
124
|
+
`chrome.railToggle` is its optional collapse/restore button. Both are optional
|
|
125
|
+
— a host that lays out the framework itself (no rail markup) gets a no-op.
|
|
126
|
+
The rail's resize seam is positioned against `rail.parentElement` by default,
|
|
127
|
+
so **the rail must be a direct child of the positioned `.pf-shell`** unless
|
|
128
|
+
the host also supplies `elements.shell` to point at the real containing block
|
|
129
|
+
(e.g. when a wrapper div sits between them, as is common in a React layout).
|
|
118
130
|
|
|
119
131
|
`onPick` arms click-to-select permanently: `label` is the feature label (falling
|
|
120
132
|
back to the sub-part label/name) for compact UI, `prompt` is the LLM-ready
|
package/bin/cli.js
CHANGED
|
@@ -15,9 +15,10 @@ import { verify } from "../src/testing/verify.js";
|
|
|
15
15
|
import { renderViews } from "../src/testing/render.js";
|
|
16
16
|
import { createPickServer, requestPicks, formatPickResult } from "../src/framework/pick-request/server.js";
|
|
17
17
|
import { matchPattern } from "../src/testing/error-patterns.js";
|
|
18
|
+
import { lintPart } from "../src/lint.js";
|
|
18
19
|
|
|
19
20
|
const die = (msg) => { console.error(msg); process.exit(1); };
|
|
20
|
-
const USAGE = "usage: partforge <measure|render|pick-serve|pick> …";
|
|
21
|
+
const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick> …";
|
|
21
22
|
|
|
22
23
|
// Crash contract (issue #27): with --json, a thrown error becomes structured
|
|
23
24
|
// stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
|
|
@@ -59,16 +60,57 @@ async function loadPart(partPath, usage) {
|
|
|
59
60
|
const bootKernel = (part) => (detectBackend(part) === "occt" ? bootOcctKernel() : bootManifoldKernel());
|
|
60
61
|
|
|
61
62
|
const commands = {
|
|
63
|
+
async lint(args) {
|
|
64
|
+
const usage = "usage: partforge lint <part-module> [--params <json>] [--json] [--out <file>] [--strict]";
|
|
65
|
+
const { values: flags, positionals: [partPath] } = parse(args, {
|
|
66
|
+
params: { type: "string" },
|
|
67
|
+
json: { type: "boolean" },
|
|
68
|
+
out: { type: "string" },
|
|
69
|
+
strict: { type: "boolean" },
|
|
70
|
+
}, usage);
|
|
71
|
+
try {
|
|
72
|
+
const part = await loadPart(partPath, usage);
|
|
73
|
+
const params = flags.params ? JSON.parse(flags.params) : undefined;
|
|
74
|
+
const report = lintPart(part, { params });
|
|
75
|
+
if (!flags.json) printLint(report);
|
|
76
|
+
if (flags.out) {
|
|
77
|
+
mkdirSync(dirname(resolve(flags.out)), { recursive: true });
|
|
78
|
+
writeFileSync(flags.out, JSON.stringify(report, null, 2));
|
|
79
|
+
console.log(`\nwrote ${flags.out}`);
|
|
80
|
+
}
|
|
81
|
+
if (flags.json) console.log(JSON.stringify(report, null, 2));
|
|
82
|
+
process.exit(report.ok && (!flags.strict || report.warnings.length === 0) ? 0 : 1);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
crash("lint", e, !!flags.json);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
62
88
|
async measure(args) {
|
|
63
|
-
const usage = "usage: partforge measure <part-module> [view] [--process <profile>] [--no-verify] [--json] [--out <file>]";
|
|
89
|
+
const usage = "usage: partforge measure <part-module> [view] [--process <profile>] [--no-verify] [--no-lint] [--json] [--out <file>]";
|
|
64
90
|
const { values: flags, positionals: [partPath, view] } = parse(args, {
|
|
65
91
|
process: { type: "string" },
|
|
66
92
|
"no-verify": { type: "boolean" },
|
|
93
|
+
"no-lint": { type: "boolean" },
|
|
67
94
|
json: { type: "boolean" },
|
|
68
95
|
out: { type: "string" },
|
|
69
96
|
}, usage);
|
|
70
97
|
try {
|
|
71
98
|
const part = await loadPart(partPath, usage);
|
|
99
|
+
// Error-tier lint before the kernel boots: a statically broken part fails in
|
|
100
|
+
// milliseconds with a precise message rather than after a WASM boot and a
|
|
101
|
+
// downstream error that doesn't name the cause. Warnings never gate measure.
|
|
102
|
+
if (!flags["no-lint"]) {
|
|
103
|
+
const lint = lintPart(part);
|
|
104
|
+
if (!lint.ok) {
|
|
105
|
+
if (flags.json) console.log(JSON.stringify({ ok: false, lint }, null, 2));
|
|
106
|
+
else printLint(lint);
|
|
107
|
+
if (flags.out) {
|
|
108
|
+
mkdirSync(dirname(resolve(flags.out)), { recursive: true });
|
|
109
|
+
writeFileSync(flags.out, JSON.stringify({ ok: false, lint }, null, 2));
|
|
110
|
+
}
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
72
114
|
const kernel = await bootKernel(part);
|
|
73
115
|
const report = measure(kernel, part, view);
|
|
74
116
|
printMeasure(report);
|
|
@@ -172,6 +214,19 @@ function printVerify(v) {
|
|
|
172
214
|
console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
|
|
173
215
|
}
|
|
174
216
|
|
|
217
|
+
function printLint(r) {
|
|
218
|
+
const all = [...r.errors, ...r.warnings];
|
|
219
|
+
if (all.length === 0) { console.log("lint: clean"); return; }
|
|
220
|
+
console.log("lint:");
|
|
221
|
+
for (const f of all) {
|
|
222
|
+
console.log(` ${f.severity === "error" ? "✗" : "⚠"} ${f.rule}${f.path ? ` ${f.path}` : ""}`);
|
|
223
|
+
console.log(` ${f.message}`);
|
|
224
|
+
console.log(` hint: ${f.hint}${f.pattern ? ` (ERROR-PATTERNS.md#${f.pattern})` : ""}`);
|
|
225
|
+
}
|
|
226
|
+
const e = r.errors.length, w = r.warnings.length;
|
|
227
|
+
console.log(` result: ${e ? `${e} error(s)` : "no errors"}${w ? `, ${w} warning(s)` : ""}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
175
230
|
const [, , cmd, ...args] = process.argv;
|
|
176
231
|
if (!commands[cmd]) die(USAGE);
|
|
177
232
|
await commands[cmd](args);
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -30,8 +30,9 @@ OCCT-only fillet/chamfer/shell ops.
|
|
|
30
30
|
`http://localhost:5173/<your-part>.html`.
|
|
31
31
|
|
|
32
32
|
That's the whole loop. The chrome (panel, tabs, viewer, export buttons) is shared —
|
|
33
|
-
your HTML is
|
|
34
|
-
|
|
33
|
+
your HTML is structural markup only and carries no CSS (the framework supplies it via
|
|
34
|
+
`framework/app.css`, imported by `mount`). See "Wiring a part into a runnable app"
|
|
35
|
+
below for what that markup must contain.
|
|
35
36
|
|
|
36
37
|
---
|
|
37
38
|
|
|
@@ -390,6 +391,28 @@ choosing a preset updates both numeric and text fields.
|
|
|
390
391
|
Every `key` used must exist in `defaults`. `src/parts/demo.js` is the worked example for
|
|
391
392
|
everything below.
|
|
392
393
|
|
|
394
|
+
**Standalone toggles** (a plain on/off checkbox, no accompanying sliders): add a
|
|
395
|
+
`toggles` array to a preset section — shown below the preset picker, outside the
|
|
396
|
+
Advanced fold, so it stays visible:
|
|
397
|
+
|
|
398
|
+
```js
|
|
399
|
+
{
|
|
400
|
+
id: "shape",
|
|
401
|
+
title: "Shape ops",
|
|
402
|
+
toggles: [
|
|
403
|
+
{ key: "clip", label: "Clip arms to a disc (intersect)", on: 1,
|
|
404
|
+
description: "**Intersect** the cross with a circle so the four arm tips are rounded off to a common radius." },
|
|
405
|
+
],
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Each entry is `{ key, label, on?, hidden?, description? }`: checked sets `key` to `on`
|
|
410
|
+
(default `1`); unchecked sets it to `0`. This is the correct home for a bare boolean —
|
|
411
|
+
a `features` entry *requires* a `sliders` array (the panel reads `feat.sliders.filter(...)`
|
|
412
|
+
unguarded and throws if it's missing), so a feature with nothing to reveal belongs in
|
|
413
|
+
`toggles` instead. `src/parts/bracket.js`'s `clip` toggle (shown above) is the worked
|
|
414
|
+
example.
|
|
415
|
+
|
|
393
416
|
**Control metadata (optional — on any control def, feature, or section):**
|
|
394
417
|
|
|
395
418
|
- `description` — a CommonMark string shown in a click-open **ⓘ** popover beside the
|
|
@@ -666,37 +689,121 @@ stylesheet). `mount` looks up these element IDs:
|
|
|
666
689
|
| `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
|
|
667
690
|
| `#status`, `#busy`, `#phase` | status line + busy overlay |
|
|
668
691
|
| `#viewbar` with `#pause` / `#reframe` / `#cutaway` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
692
|
+
| `#panel` | the full-height controls rail (`class="pf-rail"`); programmatic hosts pass `elements.rail` instead |
|
|
693
|
+
| `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#pause`/`#theme` |
|
|
694
|
+
|
|
695
|
+
Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two
|
|
696
|
+
workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
|
|
697
|
+
`"occt"` for STEP — handled for you).
|
|
698
|
+
|
|
699
|
+
**The markup convention (`demo.html` is the canonical copy-me page):** `<body>` carries
|
|
700
|
+
`class="pf-shell"`, the flex row that lays the viewer column next to the rail. `#app`
|
|
701
|
+
(`class="pf-stage"`) *is* that viewer column, and now contains the floating chrome
|
|
702
|
+
(`#topbar`, `#viewbar`, `#busy`) as absolutely-positioned siblings of the canvas, not
|
|
703
|
+
page-level overlays. `#panel` (`class="pf-rail"`) is a full-height rail docked to the
|
|
704
|
+
right edge, split into three children — `.pf-rail-head` / `.pf-rail-body` /
|
|
705
|
+
`.pf-rail-foot` — of which head and foot are flex-fixed and only the body scrolls: put
|
|
706
|
+
your heading in the head and the download row in the foot so the export buttons never
|
|
707
|
+
scroll out of reach. The rail's drag/collapse seam is created by `rail.js` itself; don't
|
|
708
|
+
add markup for it. This isn't decorative — get the head/body/foot split wrong and either
|
|
709
|
+
the export buttons scroll away or a tall parameter list pushes them off-screen. See
|
|
710
|
+
`docs/superpowers/specs/2026-07-26-controls-rail-layout-design.md` for why the rail is
|
|
711
|
+
shaped this way (resize/collapse behavior, breakpoints, the design rationale).
|
|
712
|
+
|
|
713
|
+
**Keyboard (the seam is `role="separator"`, focusable, `tabIndex=0`):**
|
|
714
|
+
|
|
715
|
+
| Key | Action |
|
|
716
|
+
|---|---|
|
|
717
|
+
| ← | widen the rail 16px (64px with Shift). No-op while collapsed. |
|
|
718
|
+
| → | narrow the rail 16px (64px with Shift), clamped at the 240px minimum — never collapses. No-op while collapsed. |
|
|
719
|
+
| Home | jump to the 240px minimum, animated. Reopens even while collapsed. |
|
|
720
|
+
| End | jump to the clamped maximum (half the shell, capped at 560px), animated. Reopens even while collapsed. |
|
|
721
|
+
| Enter / Space | toggle collapse — collapses if open; reopens at the remembered width if collapsed. |
|
|
722
|
+
| double-click (on the seam) | reset to the 288px default, animated, and opens if collapsed. |
|
|
723
|
+
|
|
724
|
+
Arrow keys move the **separator**, not the pane — standard `role="separator"`
|
|
725
|
+
semantics, and why ← *widens* a right-hand rail. `Cmd`/`Ctrl`/`Alt` held with an
|
|
726
|
+
arrow key passes through untouched (those are OS/browser-reserved combos, e.g.
|
|
727
|
+
back navigation or window-switching); `Shift` alone still applies the larger
|
|
728
|
+
step. Collapsed, the two arrow keys are deliberate no-ops rather than a reopen
|
|
729
|
+
gesture — reopening would otherwise silently discard the remembered width and
|
|
730
|
+
clamp to the minimum, and "press an arrow, get narrower" reads backwards for a
|
|
731
|
+
rail that's already shut. Home/End and Enter/Space/double-click are exempt from
|
|
732
|
+
that rule and always reopen, since jumping to an explicit width or toggling is
|
|
733
|
+
an unambiguous, deliberate gesture either way. A held arrow-key repeat
|
|
734
|
+
suppresses the 150ms width transition for the whole repeat window (not just one
|
|
735
|
+
keydown), matching what happens during a drag.
|
|
736
|
+
|
|
737
|
+
Legacy id-only markup (predating this class scheme) still renders: `app.css` keeps
|
|
738
|
+
`:not(.pf-*)` fallbacks (`#app:not(.pf-stage)`, `#panel:not(.pf-rail)`, and
|
|
739
|
+
placement-only ones for `#topbar`/`#viewbar`) that reproduce the old floating-card
|
|
740
|
+
look — `:not()` rather than a plain id rule because an id selector outranks a class. New
|
|
741
|
+
apps should still use the classed markup above; the fallback exists for pages that
|
|
742
|
+
predate it, not as a second supported style.
|
|
743
|
+
|
|
744
|
+
A host that builds its own DOM instead of using `mount`'s markup (e.g. an editor
|
|
745
|
+
embedding the viewer/rail inside a larger UI) can adopt the same layout by importing
|
|
746
|
+
**`partforge/chrome.css`** directly — it's deliberately class-based and id-free for that
|
|
747
|
+
reason. It expects `partforge/tokens.css` to already be loaded for its `--pf-*` custom
|
|
748
|
+
properties, and expects the host to size `.pf-shell` itself (`mount`'s own `app.css`,
|
|
749
|
+
which `@import`s both, does both of these for you already).
|
|
672
750
|
|
|
673
751
|
`#cutaway` is optional viewer chrome. When present, it toggles an interactive
|
|
674
752
|
section plane whose exposed faces are hatched; changing views resets it. Cutaway
|
|
675
753
|
is viewer-only and never changes STL, STEP, or 3MF exports. Hosts that omit the
|
|
676
754
|
button get no cutaway UI.
|
|
677
755
|
|
|
678
|
-
Programmatic hosts can provide the same optional
|
|
679
|
-
ID by passing
|
|
756
|
+
Programmatic hosts can provide the same optional controls, including the rail toggle,
|
|
757
|
+
without relying on an ID by passing them beside the other chrome references — and can
|
|
758
|
+
pass the rail itself as `elements.rail` instead of relying on `#panel`:
|
|
680
759
|
|
|
681
760
|
```js
|
|
682
761
|
mount(part, {
|
|
683
762
|
createWorker,
|
|
684
763
|
elements: {
|
|
764
|
+
rail,
|
|
685
765
|
chrome: {
|
|
686
766
|
pause,
|
|
687
767
|
reframe,
|
|
688
768
|
cutaway,
|
|
689
769
|
theme,
|
|
770
|
+
railToggle,
|
|
690
771
|
},
|
|
691
772
|
},
|
|
692
773
|
});
|
|
693
774
|
```
|
|
694
775
|
|
|
776
|
+
`rail`/`chrome.railToggle` are both optional; a host with no rail markup gets a
|
|
777
|
+
no-op (the resize/collapse behavior below simply doesn't attach). **Constraint:**
|
|
778
|
+
the rail element must be a direct child of the positioned `.pf-shell` — the
|
|
779
|
+
resize seam is created and positioned against `rail.parentElement` by default,
|
|
780
|
+
so an extra wrapper div between them (common in a React layout) puts the seam
|
|
781
|
+
against the wrong ancestor and silently breaks `[data-pf-dragging] .pf-stage`.
|
|
782
|
+
A host that can't make the rail a direct child of `.pf-shell` must also pass
|
|
783
|
+
`elements.shell` pointing at the real positioned ancestor:
|
|
784
|
+
|
|
785
|
+
```js
|
|
786
|
+
mount(part, {
|
|
787
|
+
createWorker,
|
|
788
|
+
elements: { rail, shell, chrome: { railToggle } },
|
|
789
|
+
});
|
|
790
|
+
```
|
|
791
|
+
|
|
695
792
|
> Production deploy compiles only the pages listed in `build.rollupOptions.input`
|
|
696
793
|
> (currently the landing gallery + the demo part pages). Other root `*.html` files are
|
|
697
794
|
> **dev-only** (Vite serves any root HTML in `npm run dev`) unless added there. To also
|
|
698
795
|
> ship one, add it to `build.rollupOptions.input` in `vite.config.js`.
|
|
699
796
|
|
|
797
|
+
**Styling hooks:** the rail/stage layout and palette are both plain `--pf-*` custom
|
|
798
|
+
properties from `partforge/tokens.css`, overridable on `:root` (or
|
|
799
|
+
`:root[data-theme="light"]`) without touching `chrome.css`. Layout/shape tokens added
|
|
800
|
+
alongside the rail: `--pf-sans`, `--pf-rail-w`, `--pf-rail-pad`, `--pf-radius-control`,
|
|
801
|
+
`--pf-radius-pill`, `--pf-shadow-float`, `--pf-shadow-rail`. The dev demos self-host
|
|
802
|
+
Geist and Geist Mono (`@fontsource-variable/geist(-mono)`, a `devDependency`, imported
|
|
803
|
+
from each `app-<part>.js` — see `src/app-demo.js`) so a standalone forge looks like the
|
|
804
|
+
finished product; the published library ships no font files, and a consumer that loads
|
|
805
|
+
none falls through `--pf-sans`/`--pf-mono` to system stacks by design.
|
|
806
|
+
|
|
700
807
|
### Developing against a local (linked) partforge
|
|
701
808
|
|
|
702
809
|
A normal `npm install partforge` needs no extra config. But if you `npm link` a local
|
|
@@ -785,6 +892,83 @@ The `measure` function is also exported for vitest (boot a Manifold kernel as in
|
|
|
785
892
|
expect(r.subparts[0].holes).toBe(1); // e.g. expects one bore
|
|
786
893
|
});
|
|
787
894
|
|
|
895
|
+
## Linting
|
|
896
|
+
|
|
897
|
+
`partforge lint` statically validates a PartDefinition without booting a geometry
|
|
898
|
+
kernel. It runs in milliseconds and catches the authoring mistakes that otherwise
|
|
899
|
+
surface only at runtime — or, worse, not at all.
|
|
900
|
+
|
|
901
|
+
```bash
|
|
902
|
+
npx partforge lint src/parts/<part>.js [--params '{"h":40}'] [--json] [--out f] [--strict]
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
Exit 0 when clean, 1 when any **error** finding is present; `--strict` also fails on
|
|
906
|
+
warnings. `partforge measure` runs the error tier automatically before booting a
|
|
907
|
+
kernel — pass `--no-lint` to skip it.
|
|
908
|
+
|
|
909
|
+
The same check is available programmatically and in the browser:
|
|
910
|
+
|
|
911
|
+
```js
|
|
912
|
+
import { lintPart } from "partforge/lint";
|
|
913
|
+
const { ok, errors, warnings } = lintPart(part, { params });
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
`partforge/lint` has **zero runtime dependencies** and never imports a geometry
|
|
917
|
+
kernel or the DOM viewer, so it runs unchanged in Node, a Web Worker, a sandboxed
|
|
918
|
+
iframe, and Deno. A worker also answers `{ type: "lint", params }` with
|
|
919
|
+
`{ type: "lint-report", report }` without booting its kernel.
|
|
920
|
+
|
|
921
|
+
**Findings** carry the same guarantees as verify's checks — a self-contained `hint`
|
|
922
|
+
on every one, and a stable `pattern` id where an ERROR-PATTERNS.md entry applies:
|
|
923
|
+
|
|
924
|
+
```js
|
|
925
|
+
{ rule: "features-requires-sliders", severity: "error",
|
|
926
|
+
message: "section \"flange\" feature 0 has no `sliders` array",
|
|
927
|
+
hint: "A `features` entry must carry a `sliders` array …",
|
|
928
|
+
path: "parameters[1].features[0]", pattern: "features-missing-sliders" }
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
`path` is a JS accessor path rooted at the PartDefinition — `parameters[1].features[0]`,
|
|
932
|
+
`defaults.bore`, `parts.spacer.views[0]`, `parameters[0].presets["M3"].od`. Findings
|
|
933
|
+
about the definition as a whole use `""`.
|
|
934
|
+
|
|
935
|
+
**Severity.** A finding is an `error` when the part is *provably broken* — it cannot
|
|
936
|
+
behave as authored — whether or not that shows up as a thrown exception. Some error
|
|
937
|
+
findings do correspond to a runtime throw (`build-throws`, `verify-expect-throws`),
|
|
938
|
+
but others catch **silent** wrongness: `missing-meta-title`, `part-view-unknown`,
|
|
939
|
+
`control-key-not-in-defaults`, `preset-key-not-in-defaults`, and
|
|
940
|
+
`verify-unknown-subpart` all fire on parts that build, measure, and verify cleanly —
|
|
941
|
+
a dead control that's silently unreachable, a view that renders nothing, or a
|
|
942
|
+
`verify` expectation that's silently dropped so its gate never runs. That's still an
|
|
943
|
+
error: the part doesn't do what its author wrote, the failure is just quiet instead
|
|
944
|
+
of loud. Everything speculative or stylistic — lossy but not broken — is a `warning`
|
|
945
|
+
and never blocks anything. Because `measure` runs the error tier as a gate (see
|
|
946
|
+
below), a part with one of these silent defects now exits non-zero where it
|
|
947
|
+
previously didn't; that's the fix working as intended, not a regression.
|
|
948
|
+
|
|
949
|
+
### Rule catalog
|
|
950
|
+
|
|
951
|
+
**Definition shape** — `missing-meta-title`, `missing-defaults`, `no-buildable-parts`,
|
|
952
|
+
`missing-views`, `part-view-unknown` (all errors); `view-unused` (warning).
|
|
953
|
+
|
|
954
|
+
**Parameter schema** — `features-requires-sliders`, `control-key-not-in-defaults`,
|
|
955
|
+
`preset-key-not-in-defaults` (errors); `slider-range-excludes-default`,
|
|
956
|
+
`unknown-control-field`, `duplicate-control-key`, `default-not-exposed` (warnings).
|
|
957
|
+
|
|
958
|
+
**Kernel API**, found by executing `build()` against a geometry-free probe —
|
|
959
|
+
`unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
|
|
960
|
+
`derive-throws`, `manifold-backend-uses-occt-op`, `build-runaway` (errors);
|
|
961
|
+
`nondeterministic-build` (warning, from diffing two probe runs).
|
|
962
|
+
|
|
963
|
+
**Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
|
|
964
|
+
`verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
|
|
965
|
+
`verify-expect-throws` (all errors). Note `_view` also accepts the pair-wise
|
|
966
|
+
`contacts` / `clearance` keys, which are not scalar view metrics; they are
|
|
967
|
+
validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
|
|
968
|
+
|
|
969
|
+
A rule that itself throws yields an `internal-rule-error` **warning** and the run
|
|
970
|
+
continues: `lintPart` never throws and never blocks a part because of a linter bug.
|
|
971
|
+
|
|
788
972
|
### The diagnostics contract (for agents)
|
|
789
973
|
|
|
790
974
|
`partforge measure <part> --json` / `--out <file>` emits the machine-readable
|
|
@@ -817,13 +1001,20 @@ JSON to stdout and exits 1:
|
|
|
817
1001
|
```
|
|
818
1002
|
|
|
819
1003
|
`pattern`/`hint` appear when the message matches an ERROR-PATTERNS.md symptom
|
|
820
|
-
string. Exit codes: 0 pass, 1 gate failure or crash — unchanged.
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1004
|
+
string. Exit codes: 0 pass, 1 gate failure or crash — unchanged. `measure`'s
|
|
1005
|
+
automatic lint pass (see "Linting" above) now catches most of the defects that
|
|
1006
|
+
used to surface this way statically, before the kernel boots, so they fail with
|
|
1007
|
+
pure JSON up front instead. The caveat narrows but doesn't disappear: lint
|
|
1008
|
+
resolves `verify.expect` once against the part's *defaults*, while `verify()`
|
|
1009
|
+
itself expands every `verify.cases` entry and re-resolves `expect(p, d)` per
|
|
1010
|
+
case — so an expectation that only names a bad metric/subpart for a non-default
|
|
1011
|
+
case (see `test/fixtures/unknown-metric-in-case-part.js`) still passes lint
|
|
1012
|
+
clean and then throws at runtime, after measure output has printed. That throw
|
|
1013
|
+
appends crash JSON after the human lines, so stdout is no longer pure JSON;
|
|
1014
|
+
prefer `--out` (or parse the trailing JSON object — the crash JSON is
|
|
1015
|
+
pretty-printed across multiple lines) for robust machine parsing. With `--out`
|
|
1016
|
+
the measure report is written to the file as soon as `measure` succeeds, so
|
|
1017
|
+
even if a later `verify` throw crashes the run the file is there — it just
|
|
827
1018
|
lacks the `verify` key.
|
|
828
1019
|
|
|
829
1020
|
**Fresh-evidence rule.** A passing report is evidence only for the source, parameters,
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -115,6 +115,12 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
|
|
|
115
115
|
- **Cause:** A `key` used in the `parameters` schema (slider, feature, or preset override) doesn't exist in `defaults` — every key must, including `hidden` ones.
|
|
116
116
|
- **Fix:** Add the key to `defaults` with a sensible starting value. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Parameters: the control-panel schema".
|
|
117
117
|
|
|
118
|
+
## features-missing-sliders
|
|
119
|
+
|
|
120
|
+
- **Symptom:** `Cannot read properties of undefined (reading 'filter')` thrown from the control panel while the app boots, with no geometry ever rendering.
|
|
121
|
+
- **Cause:** A `features` entry in the parameter schema has no `sliders` array — `controls.js` reads `feat.sliders.filter(...)` unguarded. A bare on/off control was put in `features` instead of `toggles`.
|
|
122
|
+
- **Fix:** Move a bare boolean to the section's `toggles` array (`{ key, label, on }`), or give the `features` entry the `sliders` array it requires. `npx partforge lint <part>` catches this statically as `features-requires-sliders`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Parameters: the control-panel schema".
|
|
123
|
+
|
|
118
124
|
## dimmed-control-vestigial-param
|
|
119
125
|
|
|
120
126
|
- **Symptom:** A control renders dimmed (but still editable) and changing it does nothing on screen.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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",
|
|
@@ -25,9 +25,11 @@
|
|
|
25
25
|
".": "./src/index.js",
|
|
26
26
|
"./worker": "./src/framework/worker.js",
|
|
27
27
|
"./geometry": "./src/framework/geometry/polygon.js",
|
|
28
|
+
"./lint": "./src/lint.js",
|
|
28
29
|
"./derive": "./src/framework/derive.js",
|
|
29
30
|
"./testing": "./src/testing.js",
|
|
30
|
-
"./tokens.css": "./src/framework/tokens.css"
|
|
31
|
+
"./tokens.css": "./src/framework/tokens.css",
|
|
32
|
+
"./chrome.css": "./src/framework/chrome.css"
|
|
31
33
|
},
|
|
32
34
|
"bin": {
|
|
33
35
|
"partforge": "./bin/cli.js"
|
|
@@ -53,6 +55,8 @@
|
|
|
53
55
|
"three": "^0.184.0"
|
|
54
56
|
},
|
|
55
57
|
"devDependencies": {
|
|
58
|
+
"@fontsource-variable/geist": "^5.3.0",
|
|
59
|
+
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
56
60
|
"happy-dom": "^20.10.6",
|
|
57
61
|
"playwright": "^1.49.0",
|
|
58
62
|
"vite": "^8.0.16",
|
|
@@ -60,4 +60,10 @@ Picks come back **in request order**, each echoing its prompt, so you can map th
|
|
|
60
60
|
## Related: debugging failures
|
|
61
61
|
|
|
62
62
|
If anything fails while you're editing a part, grep `docs/ERROR-PATTERNS.md` for the
|
|
63
|
-
symptom first — its preamble states the full grep-first rule.
|
|
63
|
+
symptom first — its preamble states the full grep-first rule. Before assuming a user's
|
|
64
|
+
click is needed at all, run the static linter — it's instant, needs no live app, and
|
|
65
|
+
catches schema/build mistakes no pick session would explain:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
partforge lint src/parts/<part>.js
|
|
69
|
+
```
|
package/src/app-bracket.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import part from "./parts/bracket.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-demo.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import demoPart from "./parts/demo.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-faceted-vase.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import vasePart from "./parts/faceted-vase.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-filleted-box.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import part from "./parts/filleted-box.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-hull-sweep.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import part from "./parts/hull-sweep.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-nameplate.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import part from "./parts/nameplate.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-planter.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import planterPart from "./parts/planter.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|
package/src/app-text-smoke.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
|
|
2
|
+
// like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
|
|
3
|
+
// any consumer that doesn't load them (spec §2.2).
|
|
4
|
+
import "@fontsource-variable/geist";
|
|
5
|
+
import "@fontsource-variable/geist-mono";
|
|
1
6
|
import part from "./parts/text-smoke.js";
|
|
2
7
|
import { mount } from "./framework/index.js";
|
|
3
8
|
|