partforge 0.40.0 → 0.44.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 +46 -6
- package/bin/cli.js +103 -27
- package/docs/AUTHORING-PARTS.md +143 -15
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +48 -7
- package/skills/partforge/SKILL.md +17 -3
- package/src/app-embed-test.js +1 -1
- package/src/app-hinged-box.js +12 -0
- package/src/framework/animation-controls.js +243 -0
- package/src/framework/animation.js +217 -0
- package/src/framework/app.css +32 -0
- package/src/framework/assembly.js +1 -1
- package/src/framework/backend-select.js +25 -0
- package/src/framework/camera-tween.js +58 -0
- package/src/framework/chrome.css +16 -0
- package/src/framework/controls.js +13 -3
- package/src/framework/cutaway-gizmo-scene.js +244 -0
- package/src/framework/cutaway-gizmo.js +80 -243
- package/src/framework/default-view.js +46 -0
- package/src/framework/download.js +7 -2
- package/src/framework/export-controller.js +13 -2
- package/src/framework/geometry/probe.js +3 -22
- package/src/framework/jobs.js +23 -42
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +404 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +113 -18
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/framework/oracle/bvh.js +463 -0
- package/src/{testing → framework/oracle}/gaps.js +6 -3
- package/src/{testing → framework/oracle}/measure.js +34 -4
- package/src/framework/oracle/min-wall.js +98 -0
- package/src/{testing → framework/oracle}/verify.js +61 -5
- package/src/framework/param-deps.js +1 -1
- package/src/framework/part-model.js +48 -0
- package/src/framework/pick-request/client.js +11 -3
- package/src/framework/pick-request/endpoint.js +60 -0
- package/src/framework/pick-request/index.js +6 -0
- package/src/framework/pick-request/server.js +222 -34
- package/src/framework/pick-request/token-store.js +31 -0
- package/src/framework/pose-fast-path.js +12 -1
- package/src/framework/pose-probe-core.js +129 -0
- package/src/framework/pose-probe.js +7 -123
- package/src/framework/regen-loop.js +10 -3
- package/src/framework/safe-name.js +26 -0
- package/src/framework/verify-metrics.js +19 -6
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +22 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer.js +126 -16
- package/src/hinged-box-worker.js +3 -0
- package/src/index.js +1 -1
- package/src/parts/hinged-box.js +94 -0
- package/src/testing/render.js +19 -8
- package/src/testing.js +15 -8
- package/types/derive.d.ts +14 -0
- package/types/geometry.d.ts +117 -0
- package/types/index.d.ts +240 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +381 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- package/src/testing/bvh.js +0 -273
- package/src/testing/min-wall.js +0 -38
- /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
- /package/src/{testing → framework/oracle}/cases.js +0 -0
- /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
- /package/src/{testing → framework/oracle}/mesh.js +0 -0
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ const runtime = mount(part, {
|
|
|
103
103
|
status: { status, busy, phase }, // status chrome
|
|
104
104
|
tabs, // view-tab segmented control
|
|
105
105
|
exports: { stl, step, threeMf }, // export buttons
|
|
106
|
-
chrome: {
|
|
106
|
+
chrome: { reframe, theme, railToggle }, // viewer buttons + rail collapse/restore
|
|
107
107
|
},
|
|
108
108
|
onBuild: ({ status, ms, error }) => {}, // per accepted build: "success" | "error"
|
|
109
109
|
onPick: ({ selection, label, prompt, token }) => {}, // programmatic click-to-select
|
|
@@ -112,15 +112,35 @@ await runtime.ready; // first successful build (rejects on a first-build error
|
|
|
112
112
|
runtime.setHostPane("rail"); // narrow layout only: show just the controls
|
|
113
113
|
// rail ('stage' | 'rail'), suppressing the
|
|
114
114
|
// built-in tab bar. null hands selection back.
|
|
115
|
+
runtime.setActive(false); // park the viewer: stop the render loop, release the
|
|
116
|
+
// drawing buffer. setActive(true) restores both.
|
|
117
|
+
const off = runtime.onContextLost(() => {}); // WebGL context loss; returns an unsubscribe
|
|
115
118
|
runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU resources
|
|
116
119
|
```
|
|
117
120
|
|
|
121
|
+
**Park the viewer when you hide it.** A host that hides the canvas with
|
|
122
|
+
`display: none` needs nothing — the container collapses and the ResizeObserver
|
|
123
|
+
shrinks the drawing buffer for free. A host that hides it any other way
|
|
124
|
+
(`visibility: hidden`, an inactive tab, an off-screen pane) gets no such signal:
|
|
125
|
+
the full-resolution MSAA buffer stays resident and the render loop keeps
|
|
126
|
+
redrawing an unchanging scene at 60fps that nobody can see (a part with an
|
|
127
|
+
`autoplay` animation running is the same story, just with something actually
|
|
128
|
+
moving off-screen). On a phone that is tens of megabytes plus continuous GPU
|
|
129
|
+
work, and it has been enough on its own to get a tab killed. Call
|
|
130
|
+
`runtime.setActive(false)` when the viewer goes off-screen and
|
|
131
|
+
`setActive(true)` when it comes back.
|
|
132
|
+
|
|
133
|
+
Parking releases both large GPU allocations — the drawing buffer and the cached
|
|
134
|
+
1024² capture target — and stops the render loop. Offscreen captures
|
|
135
|
+
(`captureCurrent`, `captureViews`) keep working while parked and framing is
|
|
136
|
+
unchanged, so a host can still take a build screenshot of a hidden viewer; the
|
|
137
|
+
first capture after parking just re-allocates its target.
|
|
138
|
+
|
|
118
139
|
Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
|
|
119
140
|
`#panel` for `rail`, `#status`/`#busy`/`#phase`, `#part`,
|
|
120
141
|
`#download`/`#download-step`/`#download-3mf`,
|
|
121
|
-
`#
|
|
122
|
-
|
|
123
|
-
coupling.
|
|
142
|
+
`#reframe`/`#theme`/`#rail-toggle`), so a classic host page needs no changes.
|
|
143
|
+
The viewer sizes from its container via ResizeObserver — no window coupling.
|
|
124
144
|
|
|
125
145
|
`rail` is the full-height controls rail introduced by the resizable-panel
|
|
126
146
|
layout (`docs/superpowers/specs/2026-07-26-controls-rail-layout-design.md`);
|
|
@@ -145,6 +165,25 @@ viewer re-poses the meshes it already has — instantly, with no worker rebuild;
|
|
|
145
165
|
`onBuild` does not fire for those pose-only edits. Anything that changes the
|
|
146
166
|
geometry itself rebuilds as usual.
|
|
147
167
|
|
|
168
|
+
For parts that declare `animations`, `runtime.animation` exposes the viewer's
|
|
169
|
+
playback engine (`null` otherwise):
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
runtime.animation.play("open"); // switch + play (camera cue and all)
|
|
173
|
+
runtime.animation.seek(0.5); // scrub, normalized 0..1 (pauses)
|
|
174
|
+
runtime.animation.pause();
|
|
175
|
+
runtime.animation.stop(); // reset + restore pre-animation params
|
|
176
|
+
runtime.animation.state(); // { animation, status, t, stepIndex }
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
A part can mark at most one animation `autoplay: true` to start it
|
|
180
|
+
automatically on first show and again on every view switch — until the user
|
|
181
|
+
touches the transport — or anything writes params (`runtime.setParams`
|
|
182
|
+
included) or calls a `runtime.animation` method; any of those disarms
|
|
183
|
+
auto-start for the session. This replaces the old idle turntable as the
|
|
184
|
+
"something is moving" cue for a part-app; see the authoring guide for the
|
|
185
|
+
full contract.
|
|
186
|
+
|
|
148
187
|
`onPick` arms click-to-select permanently: `label` is the feature label (falling
|
|
149
188
|
back to the sub-part label/name) for compact UI, `prompt` is the LLM-ready
|
|
150
189
|
sentence, `token` the compact form, `selection` the raw object. When `onPick` is
|
|
@@ -163,8 +202,9 @@ internal params, and keep the interface simple while staying deeply adjustable.
|
|
|
163
202
|
Locally, `npm run dev` then open `/demo.html`, `/planter.html`, or `/filleted-box.html`.
|
|
164
203
|
|
|
165
204
|
- **Agent clarification (`request-a-pick`):** an external tool can ask the user to click
|
|
166
|
-
geometry and get the `Selection` back — serve with `?pickserver
|
|
167
|
-
`partforge pick-serve` + `partforge pick "<prompt>" …`.
|
|
205
|
+
geometry and get the `Selection` back — serve with `?pickserver&picktoken=<token>`,
|
|
206
|
+
drive with `partforge pick-serve` (it prints the token) + `partforge pick "<prompt>" …`.
|
|
207
|
+
The server is loopback-only and token-gated on every route. See
|
|
168
208
|
`skills/partforge/SKILL.md` and the authoring guide.
|
|
169
209
|
|
|
170
210
|
## License
|
package/bin/cli.js
CHANGED
|
@@ -7,13 +7,18 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import { resolve, dirname } from "node:path";
|
|
9
9
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
-
import { detectBackend } from "../src/framework/
|
|
10
|
+
import { detectBackend } from "../src/framework/backend-select.js";
|
|
11
|
+
import { normalizeAnimation, evaluate, cueAt } from "../src/framework/animation.js";
|
|
11
12
|
import { bootOcctKernel } from "../src/testing/occt.js";
|
|
12
13
|
import { bootManifoldKernel } from "../src/testing/manifold.js";
|
|
13
|
-
import { measure } from "../src/
|
|
14
|
-
import { verify } from "../src/
|
|
14
|
+
import { measure } from "../src/framework/oracle/measure.js";
|
|
15
|
+
import { verify } from "../src/framework/oracle/verify.js";
|
|
15
16
|
import { renderViews } from "../src/testing/render.js";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createPickServer, requestPicks, formatPickResult,
|
|
19
|
+
PICK_SERVER_DEFAULT_PORT, PICK_SERVER_DEFAULT_TIMEOUT_MS,
|
|
20
|
+
} from "../src/framework/pick-request/server.js";
|
|
21
|
+
import { savePickToken, loadPickToken, clearPickToken, pickTokenPath } from "../src/framework/pick-request/token-store.js";
|
|
17
22
|
import { matchPattern } from "../src/testing/error-patterns.js";
|
|
18
23
|
import { lintPart } from "../src/lint.js";
|
|
19
24
|
|
|
@@ -22,12 +27,13 @@ const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick> …";
|
|
|
22
27
|
|
|
23
28
|
// Crash contract (issue #27): with --json, a thrown error becomes structured
|
|
24
29
|
// stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
|
|
25
|
-
// the pattern's fix is surfaced. Exit 1 always. NOTE on stdout purity:
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// unknown metric in verify.expect,
|
|
29
|
-
//
|
|
30
|
-
//
|
|
30
|
+
// the pattern's fix is surfaced. Exit 1 always. NOTE on stdout purity: in --json
|
|
31
|
+
// mode every human-readable printer (printLint/printMeasure/printVerify) is
|
|
32
|
+
// gated behind `!flags.json`, so crash JSON is the only thing that ever reaches
|
|
33
|
+
// stdout — including when verify() throws (an unknown metric in verify.expect,
|
|
34
|
+
// or a per-case build crash) after measure's report has already been computed.
|
|
35
|
+
// Without --json there is no purity contract: human lines print as each stage
|
|
36
|
+
// completes, so a later crash's message lands after them, not instead of them.
|
|
31
37
|
function crash(cmd, e, jsonMode) {
|
|
32
38
|
const message = e?.message || String(e);
|
|
33
39
|
const m = matchPattern(message);
|
|
@@ -113,7 +119,7 @@ const commands = {
|
|
|
113
119
|
}
|
|
114
120
|
const kernel = await bootKernel(part);
|
|
115
121
|
const report = measure(kernel, part, view);
|
|
116
|
-
printMeasure(report);
|
|
122
|
+
if (!flags.json) printMeasure(report);
|
|
117
123
|
// Write --out right after measure succeeds, then re-write once verify has
|
|
118
124
|
// attached report.verify. If verify throws (unknown metric, per-case build
|
|
119
125
|
// crash) the file already holds the measure half (no `verify` key) — matching
|
|
@@ -126,7 +132,7 @@ const commands = {
|
|
|
126
132
|
let vok = true;
|
|
127
133
|
if ((part.verify || flags.process) && !flags["no-verify"]) {
|
|
128
134
|
const v = verify(kernel, part, { process: flags.process, view });
|
|
129
|
-
printVerify(v);
|
|
135
|
+
if (!flags.json) printVerify(v);
|
|
130
136
|
report.verify = v;
|
|
131
137
|
vok = v.ok;
|
|
132
138
|
if (flags.out) writeOut();
|
|
@@ -140,17 +146,71 @@ const commands = {
|
|
|
140
146
|
},
|
|
141
147
|
|
|
142
148
|
async render(args) {
|
|
143
|
-
const usage = "usage: partforge render <part-module> [view] [--views iso,front] [--out <dir>]"
|
|
149
|
+
const usage = "usage: partforge render <part-module> [view] [--views iso,front] [--out <dir>] " +
|
|
150
|
+
"[--params <json>] [--animation <name>] [--at <t[,t…]>] [--step <index|label>]";
|
|
144
151
|
const { values: flags, positionals: [partPath, view] } = parse(args, {
|
|
145
152
|
views: { type: "string" },
|
|
146
153
|
out: { type: "string" },
|
|
154
|
+
params: { type: "string" },
|
|
155
|
+
animation: { type: "string" },
|
|
156
|
+
at: { type: "string" },
|
|
157
|
+
step: { type: "string" },
|
|
147
158
|
}, usage);
|
|
148
159
|
try {
|
|
149
160
|
const part = await loadPart(partPath, usage);
|
|
150
|
-
const
|
|
161
|
+
const baseParams = flags.params ? JSON.parse(flags.params) : {};
|
|
162
|
+
const outDir = flags.out || "render";
|
|
151
163
|
const views = flags.views ? flags.views.split(",") : undefined;
|
|
152
|
-
|
|
153
|
-
|
|
164
|
+
// Usage check BEFORE the kernel: a flag typo shouldn't pay a WASM boot.
|
|
165
|
+
if (!flags.animation && (flags.at || flags.step)) {
|
|
166
|
+
die(`--at/--step require --animation\n${usage}`);
|
|
167
|
+
}
|
|
168
|
+
const kernel = await bootKernel(part);
|
|
169
|
+
|
|
170
|
+
if (!flags.animation) {
|
|
171
|
+
const files = await renderViews(kernel, part, view, { views, out: outDir, params: baseParams });
|
|
172
|
+
for (const f of files) console.log(`wrote ${f}`);
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const spec = part.animations?.[flags.animation];
|
|
177
|
+
if (!spec) {
|
|
178
|
+
throw new Error(`unknown animation "${flags.animation}" (have: ${Object.keys(part.animations ?? {}).join(", ") || "none"})`);
|
|
179
|
+
}
|
|
180
|
+
const anim = normalizeAnimation(flags.animation, spec);
|
|
181
|
+
// Frames: --step renders one still at the END of that step (its fully
|
|
182
|
+
// applied state); --at takes positions normalized over the animation's
|
|
183
|
+
// TOTAL duration (same t as the viewer scrubber / runtime seek).
|
|
184
|
+
let frames;
|
|
185
|
+
if (flags.step != null) {
|
|
186
|
+
const byLabel = anim.steps.findIndex((s) => s.label === flags.step);
|
|
187
|
+
const idx = byLabel >= 0 ? byLabel : Number(flags.step) - 1;
|
|
188
|
+
if (!(idx >= 0 && idx < anim.steps.length)) {
|
|
189
|
+
throw new Error(`unknown step "${flags.step}" (use 1..${anim.steps.length} or a label: ${anim.steps.map((s) => JSON.stringify(s.label)).join(", ")})`);
|
|
190
|
+
}
|
|
191
|
+
const end = idx + 1 < anim.steps.length ? anim.stepStarts[idx + 1] : 1;
|
|
192
|
+
// Values come from the END of the step (fully applied), but the cue
|
|
193
|
+
// lookup uses the step's own START: cue boundaries belong to the LATER
|
|
194
|
+
// step (see animation.js stepIndexAt/cueAt), and `end` here IS the next
|
|
195
|
+
// step's start, so querying the cue at `end` would resolve to the next
|
|
196
|
+
// step's camera instead of this step's own.
|
|
197
|
+
frames = [{ t: end, cueT: anim.stepStarts[idx], tag: `${flags.animation}-step${idx + 1}` }];
|
|
198
|
+
} else {
|
|
199
|
+
const ts = (flags.at ?? "1").split(",").map(Number);
|
|
200
|
+
if (!ts.length || ts.some((t) => !Number.isFinite(t) || t < 0 || t > 1)) {
|
|
201
|
+
die(`--at takes comma-separated positions in 0..1\n${usage}`);
|
|
202
|
+
}
|
|
203
|
+
frames = ts.map((t) => ({ t, tag: `${flags.animation}-t${String(Math.round(t * 100)).padStart(3, "0")}` }));
|
|
204
|
+
}
|
|
205
|
+
for (const frame of frames) {
|
|
206
|
+
const { values } = evaluate(anim, frame.t);
|
|
207
|
+
const cue = cueAt(anim, frame.cueT ?? frame.t);
|
|
208
|
+
const frameViews = views ?? (cue ? [cue.view] : undefined);
|
|
209
|
+
const files = await renderViews(kernel, part, view, {
|
|
210
|
+
views: frameViews, out: outDir, params: { ...baseParams, ...values }, tag: frame.tag,
|
|
211
|
+
});
|
|
212
|
+
for (const f of files) console.log(`wrote ${f}`);
|
|
213
|
+
}
|
|
154
214
|
process.exit(0);
|
|
155
215
|
} catch (e) {
|
|
156
216
|
crash("render", e, false);
|
|
@@ -160,19 +220,31 @@ const commands = {
|
|
|
160
220
|
async "pick-serve"(args) {
|
|
161
221
|
const usage = "usage: partforge pick-serve [--port N] [--timeout <seconds>]";
|
|
162
222
|
const { values: flags } = parse(args, { port: { type: "string" }, timeout: { type: "string" } }, usage);
|
|
163
|
-
const port = Number(flags.port) ||
|
|
164
|
-
const timeoutMs = (Number(flags.timeout) ||
|
|
165
|
-
const
|
|
223
|
+
const port = Number(flags.port) || PICK_SERVER_DEFAULT_PORT;
|
|
224
|
+
const timeoutMs = (Number(flags.timeout) || PICK_SERVER_DEFAULT_TIMEOUT_MS / 1000) * 1000;
|
|
225
|
+
const srv = createPickServer({ port, timeoutMs });
|
|
226
|
+
const { port: bound } = await srv.start();
|
|
227
|
+
// The token is what keeps every other page on the machine out of this server.
|
|
228
|
+
// `partforge pick` runs in a different process, so drop it in a 0600 file for
|
|
229
|
+
// that process to find; the browser gets it through the app URL below.
|
|
230
|
+
savePickToken(bound, srv.token);
|
|
231
|
+
const stop = () => { clearPickToken(bound); process.exit(0); };
|
|
232
|
+
process.on("SIGINT", stop);
|
|
233
|
+
process.on("SIGTERM", stop);
|
|
166
234
|
console.log(`partforge pick-server listening on http://127.0.0.1:${bound}`);
|
|
235
|
+
console.log(`token: ${srv.token} (also at ${pickTokenPath(bound)})`);
|
|
236
|
+
console.log(`open the app with: ?pickserver=http://127.0.0.1:${bound}&picktoken=${srv.token}`);
|
|
167
237
|
// no exit — the process stays alive serving requests
|
|
168
238
|
},
|
|
169
239
|
|
|
170
240
|
async pick(args) {
|
|
171
|
-
const usage = 'usage: partforge pick "<prompt>" ["<prompt>" …] [--port N]';
|
|
172
|
-
const { values: flags, positionals: prompts } = parse(args, { port: { type: "string" } }, usage);
|
|
241
|
+
const usage = 'usage: partforge pick "<prompt>" ["<prompt>" …] [--port N] [--token T]';
|
|
242
|
+
const { values: flags, positionals: prompts } = parse(args, { port: { type: "string" }, token: { type: "string" } }, usage);
|
|
173
243
|
if (prompts.length === 0) die(usage);
|
|
174
|
-
const port = Number(flags.port) ||
|
|
175
|
-
const
|
|
244
|
+
const port = Number(flags.port) || PICK_SERVER_DEFAULT_PORT;
|
|
245
|
+
const token = flags.token || process.env.PARTFORGE_PICK_TOKEN || loadPickToken(port);
|
|
246
|
+
if (!token) die(`no pick-server token for port ${port} — start one with \`partforge pick-serve\`, or pass --token`);
|
|
247
|
+
const out = await requestPicks({ port, prompts, token }).catch((e) => die(e.message));
|
|
176
248
|
console.log(formatPickResult(out));
|
|
177
249
|
process.exit(out.status === "done" ? 0 : 1);
|
|
178
250
|
},
|
|
@@ -204,6 +276,9 @@ function printVerify(v) {
|
|
|
204
276
|
for (const ch of c.checks) {
|
|
205
277
|
const icon = ch.status === "pass" ? "✓" : ch.status === "fail" ? "✗" : ch.status === "warn" ? "⚠" : "·";
|
|
206
278
|
console.log(` ${icon} ${ch.subpart ?? "_view"} ${ch.metric} ${ch.expr} (${ch.message})`);
|
|
279
|
+
// A measurement caveat prints whatever the verdict — "passed, but sampled"
|
|
280
|
+
// is precisely the line a reader must not miss.
|
|
281
|
+
if (ch.note) console.log(` note: ${ch.note}`);
|
|
207
282
|
if (ch.status === "fail" || ch.status === "warn") {
|
|
208
283
|
if (ch.location) console.log(` at [${ch.location.map((n) => n.toFixed(1)).join(", ")}]`);
|
|
209
284
|
if (ch.hint) console.log(` hint: ${ch.hint}${ch.pattern ? ` (ERROR-PATTERNS.md#${ch.pattern})` : ""}`);
|
|
@@ -215,16 +290,17 @@ function printVerify(v) {
|
|
|
215
290
|
}
|
|
216
291
|
|
|
217
292
|
function printLint(r) {
|
|
218
|
-
const all = [...r.errors, ...r.warnings];
|
|
293
|
+
const all = [...r.errors, ...r.warnings, ...(r.notes ?? [])];
|
|
219
294
|
if (all.length === 0) { console.log("lint: clean"); return; }
|
|
220
295
|
console.log("lint:");
|
|
221
296
|
for (const f of all) {
|
|
222
|
-
|
|
297
|
+
const icon = f.severity === "error" ? "✗" : f.severity === "warning" ? "⚠" : "·";
|
|
298
|
+
console.log(` ${icon} ${f.rule}${f.path ? ` ${f.path}` : ""}`);
|
|
223
299
|
console.log(` ${f.message}`);
|
|
224
300
|
console.log(` hint: ${f.hint}${f.pattern ? ` (ERROR-PATTERNS.md#${f.pattern})` : ""}`);
|
|
225
301
|
}
|
|
226
|
-
const e = r.errors.length, w = r.warnings.length;
|
|
227
|
-
console.log(` result: ${e ? `${e} error(s)` : "no errors"}${w ? `, ${w} warning(s)` : ""}`);
|
|
302
|
+
const e = r.errors.length, w = r.warnings.length, n = (r.notes ?? []).length;
|
|
303
|
+
console.log(` result: ${e ? `${e} error(s)` : "no errors"}${w ? `, ${w} warning(s)` : ""}${n ? `, ${n} note(s)` : ""}`);
|
|
228
304
|
}
|
|
229
305
|
|
|
230
306
|
const [, , cmd, ...args] = process.argv;
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -85,7 +85,7 @@ export default {
|
|
|
85
85
|
export?: { name }, // filename/object name on export; defaults to the key
|
|
86
86
|
},
|
|
87
87
|
},
|
|
88
|
-
views: { <name>: { label } },
|
|
88
|
+
views: { <name>: { label, default? } }, // the view tabs (a view = a set of sub-parts)
|
|
89
89
|
};
|
|
90
90
|
```
|
|
91
91
|
|
|
@@ -111,6 +111,14 @@ export default {
|
|
|
111
111
|
- `enabled(p)` gates a conditional sub-part (e.g. only present when a feature is on).
|
|
112
112
|
- A view's sub-parts are derived, never hard-coded: those whose `views` include the view
|
|
113
113
|
and whose `enabled(p)` is true.
|
|
114
|
+
- **Which view the viewer opens on** is resolved in this order: the first view flagged
|
|
115
|
+
`default: true`; else the view placing the most sub-parts at `defaults` (counting
|
|
116
|
+
`enabled(defaults)`), which for a multi-view part is normally the assembly; else the
|
|
117
|
+
first key in `views`. So flag the assembly view `default: true` when you want it to
|
|
118
|
+
open but sit last in the tab bar. The chosen tab then persists per part for the rest
|
|
119
|
+
of the browser session. The headless tools are deliberately different: `measure`,
|
|
120
|
+
`verify` and `render` all default to the **first key** in `views`, ignoring
|
|
121
|
+
`default: true`, so a CI gate can't move because a sub-part was added to a view.
|
|
114
122
|
- `fonts` declares the outline fonts a part's `k.text2d()` calls need, as `{ name: source
|
|
115
123
|
}` — a source is inline bytes, a URL string, or a thunk (e.g. a Vite `import('./x.ttf')`,
|
|
116
124
|
which resolves to `{ default: url }`). The framework resolves and parses these into
|
|
@@ -121,6 +129,76 @@ export default {
|
|
|
121
129
|
|
|
122
130
|
---
|
|
123
131
|
|
|
132
|
+
## Animations
|
|
133
|
+
|
|
134
|
+
A part may declare named animations — pure keyframe data that drives **existing
|
|
135
|
+
params** over time. The viewer shows a transport bar (play/scrub/step); hosts
|
|
136
|
+
drive the same engine via `runtime.animation`; `partforge render` can render
|
|
137
|
+
stills at any position. The reference part is `src/parts/hinged-box.js`.
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
animations: {
|
|
141
|
+
open: {
|
|
142
|
+
label: "Open lid",
|
|
143
|
+
description: "Optional **CommonMark**, shown behind the ⓘ glyph.",
|
|
144
|
+
camera: "front", // optional: intro angle, cue list, or per-step (below)
|
|
145
|
+
duration: 1.2, // seconds
|
|
146
|
+
loop: false, // true = wraps continuously (single-step only)
|
|
147
|
+
easing: "ease-in-out", // linear | ease-in | ease-out | ease-in-out
|
|
148
|
+
tracks: { lidAngle: [[0, 0], [1, 110]] }, // param -> [t, value] keyframes
|
|
149
|
+
},
|
|
150
|
+
assemble: {
|
|
151
|
+
label: "Assemble",
|
|
152
|
+
steps: [ // steps play in order; prev/next navigate them
|
|
153
|
+
{ label: "Lower the lid", camera: "left", duration: 1.0,
|
|
154
|
+
tracks: { lidLift: [[0, 40], [1, 0]] } },
|
|
155
|
+
{ label: "Open", camera: "iso", duration: 1.0,
|
|
156
|
+
tracks: { lidAngle: [[0, 0], [1, 110]] } },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Rules (all lint-enforced):
|
|
163
|
+
|
|
164
|
+
- An animation has **either** `tracks` (a single anonymous step) **or** `steps`.
|
|
165
|
+
- Tracks reference numeric params from `defaults`. Keyframe `t` is normalized
|
|
166
|
+
per step, strictly ascending from exactly 0 to exactly 1; values must sit
|
|
167
|
+
inside the owning control's min/max (the engine applies them unclamped).
|
|
168
|
+
- Params not tracked anywhere keep their current values; a param tracked in
|
|
169
|
+
one step holds its nearest keyframe value while other steps play.
|
|
170
|
+
- Couple motions through `derive` (animate one master param; derive the rest),
|
|
171
|
+
not by tracking dependent params separately.
|
|
172
|
+
- `camera` cues use the seven canonical angles (`iso front back top bottom
|
|
173
|
+
left right`). One mechanism per animation: an animation-level name (an intro
|
|
174
|
+
cue at t=0), an animation-level `[[t, angle], …]` list, or per-step names.
|
|
175
|
+
Cues fire during play only — scrubbing never moves the camera — and a user
|
|
176
|
+
orbit disarms the remaining cues for that run.
|
|
177
|
+
- Playback drives params through the real param pipeline: a **pose-only**
|
|
178
|
+
param (feeds only rigid placement — see "Caching & determinism" below) plays
|
|
179
|
+
at frame rate; anything else rebuilds best-effort at worker cadence. `lint`
|
|
180
|
+
prints a note per track that can't take the fast path.
|
|
181
|
+
- Playback pauses when the user edits any control; Reset restores the values
|
|
182
|
+
the animation found. Because animated values are real params, exporting
|
|
183
|
+
while paused exports the posed state — by design.
|
|
184
|
+
- `autoplay: true` (optional, one animation at most) starts that animation on
|
|
185
|
+
first show and again on each view switch, until the user touches the
|
|
186
|
+
transport — or anything writes params (`runtime.setParams` included) or
|
|
187
|
+
calls a `runtime.animation` method; any of those disarms auto-start for the
|
|
188
|
+
session. Lint: `animation-autoplay-invalid`. It is not armed when the
|
|
189
|
+
browser reports `prefers-reduced-motion: reduce` — self-starting motion is
|
|
190
|
+
exactly what that setting asks a page not to do. An autoplay animation that
|
|
191
|
+
declares a `camera` cue will sweep the camera away from the user's
|
|
192
|
+
persisted framing on every page load, so choose cues for autoplay
|
|
193
|
+
deliberately — the shipped example's `cycle` animation has none.
|
|
194
|
+
|
|
195
|
+
Headless: `partforge render <part> --animation open --at 0,0.5,1` renders
|
|
196
|
+
tagged stills (`--at` is normalized over the animation's total duration, like
|
|
197
|
+
the scrubber); `--step <index|label>` renders a step's end state; stills
|
|
198
|
+
default to the governing camera cue's angle.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
124
202
|
## Geometry: the kernel / `Solid` API
|
|
125
203
|
|
|
126
204
|
`build` receives a backend-agnostic `kernel` (`k`). It returns and combines `Solid`
|
|
@@ -702,12 +780,12 @@ stylesheet). `mount` looks up these element IDs:
|
|
|
702
780
|
|---|---|
|
|
703
781
|
| `#app` | viewer canvas mounts here |
|
|
704
782
|
| `#controls` | control panel is built into this |
|
|
705
|
-
| `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` |
|
|
783
|
+
| `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` and opens the resolved default (see the "Which view the viewer opens on" rule above) |
|
|
706
784
|
| `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
|
|
707
785
|
| `#status`, `#busy`, `#phase` | status line + busy overlay |
|
|
708
|
-
| `#viewbar` with `#
|
|
786
|
+
| `#viewbar` with `#reframe` / `#cutaway` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
709
787
|
| `#panel` | the full-height controls rail (`class="pf-rail"`); programmatic hosts pass `elements.rail` instead |
|
|
710
|
-
| `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#
|
|
788
|
+
| `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#reframe`/`#theme` |
|
|
711
789
|
|
|
712
790
|
Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two
|
|
713
791
|
workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
|
|
@@ -817,7 +895,6 @@ mount(part, {
|
|
|
817
895
|
elements: {
|
|
818
896
|
rail,
|
|
819
897
|
chrome: {
|
|
820
|
-
pause,
|
|
821
898
|
reframe,
|
|
822
899
|
cutaway,
|
|
823
900
|
theme,
|
|
@@ -1003,7 +1080,8 @@ previously didn't; that's the fix working as intended, not a regression.
|
|
|
1003
1080
|
### Rule catalog
|
|
1004
1081
|
|
|
1005
1082
|
**Definition shape** — `missing-meta-title`, `missing-defaults`, `no-buildable-parts`,
|
|
1006
|
-
`missing-views`, `part-view-unknown` (all errors); `view-unused
|
|
1083
|
+
`missing-views`, `part-view-unknown` (all errors); `view-unused`,
|
|
1084
|
+
`default-view-ambiguous` (warnings).
|
|
1007
1085
|
|
|
1008
1086
|
**Parameter schema** — `features-requires-sliders`, `control-key-not-in-defaults`,
|
|
1009
1087
|
`preset-key-not-in-defaults` (errors); `slider-range-excludes-default`,
|
|
@@ -1020,6 +1098,30 @@ previously didn't; that's the fix working as intended, not a regression.
|
|
|
1020
1098
|
`contacts` / `clearance` keys, which are not scalar view metrics; they are
|
|
1021
1099
|
validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
|
|
1022
1100
|
|
|
1101
|
+
**Animations block** — static validation of `animations`, without executing
|
|
1102
|
+
`build`: `animations-not-object`, `animation-tracks-or-steps`,
|
|
1103
|
+
`animation-unknown-param`, `animation-param-not-numeric`,
|
|
1104
|
+
`animation-keyframes-invalid`, `animation-value-out-of-range`,
|
|
1105
|
+
`animation-duration-invalid`, `animation-loop-invalid`,
|
|
1106
|
+
`animation-step-label-duplicate`, `animation-easing-unknown`,
|
|
1107
|
+
`animation-camera-invalid`, `animation-description-invalid`,
|
|
1108
|
+
`animation-autoplay-invalid` (all errors). One
|
|
1109
|
+
more rule does execute `build`, geometry-free: `animation-track-rebuilds` probes
|
|
1110
|
+
each track's endpoint values and emits a **note** when the animated param feeds
|
|
1111
|
+
real geometry (or the probe can't be trusted), because such a track plays
|
|
1112
|
+
best-effort rather than at frame rate. Notes are informational — they never
|
|
1113
|
+
affect `ok`, `measure`, or `--strict`.
|
|
1114
|
+
|
|
1115
|
+
**Place invariants**, found by running the geometry-free pose probe (the same
|
|
1116
|
+
one animation's `animation-track-rebuilds` uses) against each sub-part's
|
|
1117
|
+
`place()` — `view-dependent-display-place` (display placement must not depend
|
|
1118
|
+
on the active view, since display meshes are cached across views) and
|
|
1119
|
+
`place-not-rigid` (display vs. export placement may differ only by a rigid
|
|
1120
|
+
motion — translate/rotate — never a reshape) (both errors). An untrusted probe
|
|
1121
|
+
(a query op or function selector reached during `build`/`place`) proves
|
|
1122
|
+
nothing either way and stays silent, matching `animation-track-rebuilds`'s own
|
|
1123
|
+
trust handling.
|
|
1124
|
+
|
|
1023
1125
|
A rule that itself throws yields an `internal-rule-error` **warning** and the run
|
|
1024
1126
|
continues: `lintPart` never throws and never blocks a part because of a linter bug.
|
|
1025
1127
|
|
|
@@ -1032,6 +1134,9 @@ carries:
|
|
|
1032
1134
|
- `hint` — one self-contained corrective sentence (always present),
|
|
1033
1135
|
- `pattern` — a stable [ERROR-PATTERNS.md](ERROR-PATTERNS.md) entry ID when one
|
|
1034
1136
|
applies (follow it with `ERROR-PATTERNS.md#<id>`),
|
|
1137
|
+
- `note` — an optional caveat about *how* the value was measured, attached
|
|
1138
|
+
whatever the verdict. Today only `minWall` sets one, when the reading came
|
|
1139
|
+
from a sample rather than every triangle (see below),
|
|
1035
1140
|
- `location` — `[x, y, z]` in mm where the metric has one: `minWall` (thinnest
|
|
1036
1141
|
sample point) and `overlaps` (the center of the first offending intersection's
|
|
1037
1142
|
*bounding box* — a nearby indicator, not an exact point: when a pair overlaps in
|
|
@@ -1042,7 +1147,20 @@ carries:
|
|
|
1042
1147
|
|
|
1043
1148
|
Subpart facts include `minWall` (number or `null` — null exactly when no reading
|
|
1044
1149
|
exists, e.g. the OCCT backend or min-wall measurement turned off, matching
|
|
1045
|
-
`minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`)
|
|
1150
|
+
`minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`). Min wall casts one ray
|
|
1151
|
+
per triangle, which is unbounded work on a dense mesh, so past 50,000 triangles
|
|
1152
|
+
it casts from a spread, deterministic subset instead — `minWallSampled` (boolean)
|
|
1153
|
+
and `minWallSamples` (`{ sampled, total }` or `null`) say whether that happened.
|
|
1154
|
+
`sampled` is how many triangles the walk *selected*, not how many rays were
|
|
1155
|
+
cast: a degenerate (zero-area) triangle has no normal to cast along and is
|
|
1156
|
+
skipped. A sampled reading is an **upper bound**: it can miss a thin spot, never
|
|
1157
|
+
invent one — and a sampled run that found no wall at all still reports its
|
|
1158
|
+
`minWallSamples`, so a null `minWall` there is "we looked and found nothing",
|
|
1159
|
+
not "nobody looked". Everything in `src/parts/` is far below the budget and
|
|
1160
|
+
reads exactly. The report's top-level `measuredMinWall` says whether this run
|
|
1161
|
+
cast min-wall rays at all — the difference between a null `minWall` that means
|
|
1162
|
+
"no wall found" and one that means "not measured".
|
|
1163
|
+
Overlap entries are
|
|
1046
1164
|
`{ a, b, volume, location }`. Pair-distance facts are `gaps` (every sub-part
|
|
1047
1165
|
pair: `{ a, b, distance, at }`, distance 0 = touching or overlapping) and
|
|
1048
1166
|
`nearMisses` (the pairs with an unintended-looking gap under 0.5 mm).
|
|
@@ -1113,8 +1231,8 @@ verify: {
|
|
|
1113
1231
|
**What the profile gives you:** a hard **bed-fit** gate (the view bbox must fit `bed`)
|
|
1114
1232
|
and a **min-wall** warning. **What `expect` gives you:** per-sub-part assertions on the
|
|
1115
1233
|
facts `measure` already reports — `holes` (through-bores / genus), `volume`,
|
|
1116
|
-
`surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`, `
|
|
1117
|
-
|
|
1234
|
+
`surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`, `boundsMin` / `boundsMax`
|
|
1235
|
+
(the axis-aligned `{min,max}` corner positions — where the geometry sits, vs
|
|
1118
1236
|
`bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
|
|
1119
1237
|
centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
|
|
1120
1238
|
`volume`, `overlaps`, `centerOfMass`, `boundsMin`, `boundsMax`, plus the pair-wise
|
|
@@ -1351,12 +1469,22 @@ An external tool (e.g. an AI agent editing your part) can ask the *user* to clic
|
|
|
1351
1469
|
geometry and receive the `Selection` back, closing the loop in the other direction
|
|
1352
1470
|
from `?pick`.
|
|
1353
1471
|
|
|
1354
|
-
- Serve your app with **`?pickserver
|
|
1355
|
-
it. While idle
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1472
|
+
- Serve your app with **`?pickserver&picktoken=<token>`** (or
|
|
1473
|
+
`?pickserver=http://127.0.0.1:4518&picktoken=<token>`) to enable it. While idle
|
|
1474
|
+
nothing changes; when the local pick-server requests a click, a banner appears
|
|
1475
|
+
("🤖 Claude needs you to click …") and the picker arms for one click.
|
|
1476
|
+
- The agent side runs `partforge pick-serve` once — it prints the token and the exact
|
|
1477
|
+
URL to open — then `partforge pick "<prompt>" …` for one or more clicks (collected in
|
|
1478
|
+
order, returned together). The CLI blocks until the user clicks, then prints the
|
|
1479
|
+
`Selection`(s) as JSON.
|
|
1480
|
+
- **The token is required.** Every route on the pick-server (including the SSE stream)
|
|
1481
|
+
is gated by a random per-process token, requests from non-loopback origins are
|
|
1482
|
+
refused, and the server never reflects an arbitrary `Origin`. Without that, any site
|
|
1483
|
+
the user browsed to while the server was running could read the agent's prompts,
|
|
1484
|
+
inject text into the agent's output, or harvest the user's live parameter values.
|
|
1485
|
+
A `?pickserver=` pointing anywhere but loopback is ignored with a console warning.
|
|
1486
|
+
`partforge pick` finds the token automatically via `~/.partforge/pick-<port>.token`;
|
|
1487
|
+
`--token` and `PARTFORGE_PICK_TOKEN` override it.
|
|
1360
1488
|
|
|
1361
1489
|
See the bundled skill `skills/partforge/SKILL.md` for the agent workflow. This is plain
|
|
1362
1490
|
click-routing — no LLM logic lives in partforge.
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -312,6 +312,12 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
|
|
|
312
312
|
- **Cause:** paper.js returned an unexpected or numerically degenerate path topology for the supplied font outline; the resolver refuses to attach the hole to an arbitrary outer.
|
|
313
313
|
- **Fix:** reduce or normalize degenerate font contours, confirm the correct CFF/TrueType fill rule was selected, and add the glyph as a focused `curve-fill.test.js` regression before changing resolver tolerances.
|
|
314
314
|
|
|
315
|
+
## animation-plays-choppy
|
|
316
|
+
|
|
317
|
+
- **Symptom:** An animation stutters or updates a few times a second instead of smoothly; `?debug` shows `rebuilt` counts climbing during playback.
|
|
318
|
+
- **Cause:** A track drives a param that feeds real geometry (or a build the pose probe can't trust — a query op or function selector), so every frame is a worker rebuild instead of a pose repair.
|
|
319
|
+
- **Fix:** Run `npx partforge lint <part>` — the `animation-track-rebuilds` note names the track. Restructure so the param only feeds rigid placement (`place()` or a trailing translate/rotate in `build`), or accept best-effort playback if geometry morphing is the intent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Animations".
|
|
320
|
+
|
|
315
321
|
# Hardware library
|
|
316
322
|
|
|
317
323
|
Reserved for `hardware-*` patterns (issue #30). No entries yet.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.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",
|
|
@@ -16,22 +16,61 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"src",
|
|
18
18
|
"bin",
|
|
19
|
+
"types",
|
|
19
20
|
"skills/partforge/SKILL.md",
|
|
20
21
|
"docs/AUTHORING-PARTS.md",
|
|
21
22
|
"docs/ERROR-PATTERNS.md",
|
|
22
23
|
"docs/KERNEL-CONTRACT.md",
|
|
23
24
|
"README.md"
|
|
24
25
|
],
|
|
26
|
+
"types": "./types/index.d.ts",
|
|
25
27
|
"exports": {
|
|
26
|
-
".":
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"./
|
|
31
|
-
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./types/index.d.ts",
|
|
30
|
+
"default": "./src/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./worker": {
|
|
33
|
+
"types": "./types/worker.d.ts",
|
|
34
|
+
"default": "./src/framework/worker.js"
|
|
35
|
+
},
|
|
36
|
+
"./geometry": {
|
|
37
|
+
"types": "./types/geometry.d.ts",
|
|
38
|
+
"default": "./src/framework/geometry/polygon.js"
|
|
39
|
+
},
|
|
40
|
+
"./lint": {
|
|
41
|
+
"types": "./types/lint.d.ts",
|
|
42
|
+
"default": "./src/lint.js"
|
|
43
|
+
},
|
|
44
|
+
"./derive": {
|
|
45
|
+
"types": "./types/derive.d.ts",
|
|
46
|
+
"default": "./src/framework/derive.js"
|
|
47
|
+
},
|
|
48
|
+
"./testing": {
|
|
49
|
+
"types": "./types/testing.d.ts",
|
|
50
|
+
"default": "./src/testing.js"
|
|
51
|
+
},
|
|
32
52
|
"./tokens.css": "./src/framework/tokens.css",
|
|
33
53
|
"./chrome.css": "./src/framework/chrome.css"
|
|
34
54
|
},
|
|
55
|
+
"typesVersions": {
|
|
56
|
+
"*": {
|
|
57
|
+
"worker": [
|
|
58
|
+
"./types/worker.d.ts"
|
|
59
|
+
],
|
|
60
|
+
"geometry": [
|
|
61
|
+
"./types/geometry.d.ts"
|
|
62
|
+
],
|
|
63
|
+
"lint": [
|
|
64
|
+
"./types/lint.d.ts"
|
|
65
|
+
],
|
|
66
|
+
"derive": [
|
|
67
|
+
"./types/derive.d.ts"
|
|
68
|
+
],
|
|
69
|
+
"testing": [
|
|
70
|
+
"./types/testing.d.ts"
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
},
|
|
35
74
|
"bin": {
|
|
36
75
|
"partforge": "./bin/cli.js"
|
|
37
76
|
},
|
|
@@ -41,6 +80,7 @@
|
|
|
41
80
|
"preview": "vite preview",
|
|
42
81
|
"test": "vitest run",
|
|
43
82
|
"test:watch": "vitest",
|
|
83
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
44
84
|
"check": "node scripts/check-app.mjs"
|
|
45
85
|
},
|
|
46
86
|
"dependencies": {
|
|
@@ -60,6 +100,7 @@
|
|
|
60
100
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
61
101
|
"happy-dom": "^20.10.6",
|
|
62
102
|
"playwright": "^1.49.0",
|
|
103
|
+
"typescript": "^7.0.2",
|
|
63
104
|
"vite": "^8.0.16",
|
|
64
105
|
"vitest": "^4.1.9"
|
|
65
106
|
}
|