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/src/framework/mount.js
CHANGED
|
@@ -11,26 +11,39 @@ import { buildControls } from "./controls.js";
|
|
|
11
11
|
import { relevantParamKeys } from "./param-deps.js";
|
|
12
12
|
import { createMeshCache } from "./mesh-cache.js";
|
|
13
13
|
import { createGeometryService } from "./geometry-service.js";
|
|
14
|
-
import { viewSubParts } from "./
|
|
14
|
+
import { viewSubParts } from "./part-model.js";
|
|
15
15
|
import { resolveDerived } from "./derive.js";
|
|
16
|
-
import { detectBackend } from "./
|
|
16
|
+
import { detectBackend } from "./backend-select.js";
|
|
17
17
|
import { createDebugOverlay } from "./debug-overlay.js";
|
|
18
18
|
import { createRegenLoop } from "./regen-loop.js";
|
|
19
19
|
import { createPoseFastPath } from "./pose-fast-path.js";
|
|
20
20
|
import { createStatusUi } from "./status-ui.js";
|
|
21
21
|
import { createViewTabs } from "./view-tabs.js";
|
|
22
22
|
import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
|
|
23
|
-
import { createPickRequestClient } from "./pick-request/index.js";
|
|
23
|
+
import { createPickRequestClient, resolvePickServerUrl, PICK_SERVER_DEFAULT_URL } from "./pick-request/index.js";
|
|
24
24
|
import { exportablePartNames, partLabel } from "./export-select.js";
|
|
25
|
-
import { createExportController } from "./export-controller.js";
|
|
25
|
+
import { createExportController, backendForFormat } from "./export-controller.js";
|
|
26
|
+
import { attachAnimationControls } from "./animation-controls.js";
|
|
26
27
|
|
|
27
28
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
28
29
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
29
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane }) {
|
|
30
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation }) {
|
|
30
31
|
return {
|
|
31
32
|
ready, dispose, setParams,
|
|
33
|
+
// Part-declared animation playback (spec 2026-08-02): null when the part
|
|
34
|
+
// declares no animations. { play(name?), pause(), seek(t), stop(), state() }.
|
|
35
|
+
animation: animation ?? null,
|
|
32
36
|
captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
|
|
33
37
|
captureCurrent: (opts) => viewer.captureCurrent(opts),
|
|
38
|
+
// Park/unpark the viewer: stops the render loop and frees the drawing
|
|
39
|
+
// buffer and the cached capture target. For an embedder that hides the
|
|
40
|
+
// canvas without unmounting it — `visibility: hidden`, an off-screen tab —
|
|
41
|
+
// where nothing collapses the container and the loop would otherwise run
|
|
42
|
+
// forever. See the setActive comment in viewer.js for what it costs.
|
|
43
|
+
setActive: (active) => viewer.setActive(active),
|
|
44
|
+
// Subscribe to WebGL context loss (returns an unsubscribe), so a host can
|
|
45
|
+
// say "the 3D view ran out of memory" instead of showing a dead canvas.
|
|
46
|
+
onContextLost: (listener) => viewer.onContextLost(listener),
|
|
34
47
|
listExportableParts,
|
|
35
48
|
exportParts,
|
|
36
49
|
// Narrow-layout pane selection, for a host that draws its own tab bar
|
|
@@ -69,7 +82,7 @@ function createCleanupStack() {
|
|
|
69
82
|
// mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
|
|
70
83
|
// so Vite can bundle the worker (see geometry-service.js).
|
|
71
84
|
//
|
|
72
|
-
// Embedding contract (0.
|
|
85
|
+
// Embedding contract (0.44.0):
|
|
73
86
|
// const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
|
|
74
87
|
// await runtime.ready; // first successful build of the default view
|
|
75
88
|
// runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
|
|
@@ -86,6 +99,25 @@ function createCleanupStack() {
|
|
|
86
99
|
// runtime.setHostPane("rail"); // narrow layout only: show just the controls
|
|
87
100
|
// // rail ('stage' | 'rail'), suppressing the
|
|
88
101
|
// // built-in tab bar. null hands selection back.
|
|
102
|
+
// runtime.setActive(false); // park the viewer: stop the render loop and release
|
|
103
|
+
// // both large GPU allocations (the drawing buffer and
|
|
104
|
+
// // the cached capture target). For a host that hides the
|
|
105
|
+
// // canvas WITHOUT unmounting it (`visibility: hidden`, an
|
|
106
|
+
// // inactive tab) — nothing else can detect that, and the
|
|
107
|
+
// // loop would otherwise render a hidden pane forever.
|
|
108
|
+
// // Captures still work while parked (they re-allocate).
|
|
109
|
+
// // setActive(true) restores it. Safe after dispose().
|
|
110
|
+
// runtime.animation?.play("open"); // part-declared animation playback: null when the
|
|
111
|
+
// // part declares no animations, else
|
|
112
|
+
// // { play(name?), pause(), seek(t), stop(), state() }.
|
|
113
|
+
// // play() with an unknown name warns and does nothing;
|
|
114
|
+
// // any user/host param edit pauses playback. A part's
|
|
115
|
+
// // `autoplay: true` animation self-starts on first show
|
|
116
|
+
// // and each view switch until the user touches the
|
|
117
|
+
// // transport — no runtime call needed for that part.
|
|
118
|
+
// const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
|
|
119
|
+
// // OS gave up — surface it rather than showing a dead
|
|
120
|
+
// // canvas. Returns an unsubscribe.
|
|
89
121
|
// runtime.dispose(); // full teardown
|
|
90
122
|
// onBuild fires per completed build, so it does NOT fire for a pose-only edit —
|
|
91
123
|
// those are repaired in the viewer and produce no build at all.
|
|
@@ -119,7 +151,6 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
119
151
|
threeMf: elements.exports?.threeMf ?? byId("download-3mf"),
|
|
120
152
|
},
|
|
121
153
|
chrome: {
|
|
122
|
-
pause: elements.chrome?.pause ?? byId("pause"),
|
|
123
154
|
reframe: elements.chrome?.reframe ?? byId("reframe"),
|
|
124
155
|
theme: elements.chrome?.theme ?? byId("theme"),
|
|
125
156
|
cutaway: elements.chrome?.cutaway ?? byId("cutaway"),
|
|
@@ -183,7 +214,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
183
214
|
// View tabs (generated from part.views) + live params. A tab switch shows the
|
|
184
215
|
// cached assembly instantly if it's current, else auto-builds what's missing.
|
|
185
216
|
const tabsCtl = createViewTabs(els.tabs, part, {
|
|
186
|
-
onChange: () => { pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); },
|
|
217
|
+
onChange: () => { pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick(); },
|
|
187
218
|
});
|
|
188
219
|
cleanup.defer(() => tabsCtl.detach());
|
|
189
220
|
const view = () => tabsCtl.current();
|
|
@@ -220,10 +251,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
220
251
|
cleanup.defer(() => pickToggle.detach());
|
|
221
252
|
} else if (qs.has("pickserver")) {
|
|
222
253
|
// Agent-driven mode: arm the picker only when the local pick-server asks for a
|
|
223
|
-
// click. `?pickserver
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
254
|
+
// click. `?pickserver&picktoken=<token>` or `?pickserver=http://host:port&picktoken=…`.
|
|
255
|
+
// The URL is attacker-suppliable (anyone can hand the user a link), so a
|
|
256
|
+
// non-loopback target is refused rather than honoured — otherwise every click,
|
|
257
|
+
// with its live parameter values, would stream to a remote host.
|
|
258
|
+
const serverUrl = resolvePickServerUrl(qs.get("pickserver"), {
|
|
259
|
+
onReject: (raw) => console.warn(
|
|
260
|
+
`partforge: ignoring non-loopback ?pickserver=${raw} — using ${PICK_SERVER_DEFAULT_URL}`,
|
|
261
|
+
),
|
|
262
|
+
});
|
|
263
|
+
const token = qs.get("picktoken") || "";
|
|
264
|
+
pickClient = createPickRequestClient({ serverUrl, token, viewer, part, getContext });
|
|
227
265
|
cleanup.defer(() => pickClient.detach());
|
|
228
266
|
}
|
|
229
267
|
|
|
@@ -262,9 +300,18 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
262
300
|
params, getView: view, getParamsVersion: () => loop.version(),
|
|
263
301
|
});
|
|
264
302
|
|
|
303
|
+
// paramsVersion of the most recent animation-frame apply. It is what lets
|
|
304
|
+
// the meshes handler tell "stale because playback moved on" (show it — that
|
|
305
|
+
// IS best-effort playback) from "stale because the user edited" (discard).
|
|
306
|
+
let lastAnimApplyVersion = -1;
|
|
307
|
+
|
|
265
308
|
// First-build readiness: resolves on the first accepted meshes result, rejects on
|
|
266
309
|
// a first-build error. Guarded against unhandled rejection when never awaited.
|
|
267
310
|
let readySettled = false;
|
|
311
|
+
// First-show autoplay latch: separate from `readySettled`, which the error
|
|
312
|
+
// branch also settles — a part whose first build fails but whose retry
|
|
313
|
+
// succeeds still deserves its autoplay.
|
|
314
|
+
let autoplayKicked = false;
|
|
268
315
|
let resolveReady, rejectReady;
|
|
269
316
|
const ready = new Promise((res, rej) => { resolveReady = res; rejectReady = rej; });
|
|
270
317
|
ready.catch(() => {});
|
|
@@ -326,7 +373,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
326
373
|
ui.setStatus(`${data.phase}…`);
|
|
327
374
|
break;
|
|
328
375
|
case "meshes": {
|
|
329
|
-
|
|
376
|
+
const fresh = loop.buildDone();
|
|
377
|
+
if (fresh) { // stale results (params changed mid-build) are discarded
|
|
330
378
|
for (const m of data.meshes) {
|
|
331
379
|
viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
|
|
332
380
|
cache.record(m.name);
|
|
@@ -343,6 +391,26 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
343
391
|
dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed });
|
|
344
392
|
onBuild?.({ status: "success", ms: data.ms });
|
|
345
393
|
if (!readySettled) { readySettled = true; resolveReady(); }
|
|
394
|
+
// First-show autoplay: latched separately from `ready`, which the
|
|
395
|
+
// error branch also settles — a part whose first build fails but
|
|
396
|
+
// whose retry succeeds still deserves its autoplay.
|
|
397
|
+
if (!autoplayKicked) { autoplayKicked = true; animCtl?.autoplayKick(); }
|
|
398
|
+
} else if (lastAnimApplyVersion === loop.version()) {
|
|
399
|
+
// Stale ONLY because animation frames kept bumping the version:
|
|
400
|
+
// show the delivered meshes anyway — that IS best-effort playback —
|
|
401
|
+
// but record NOTHING. Cache and fast-path stamps must describe
|
|
402
|
+
// geometry built at the live params, and this delivery wasn't; the
|
|
403
|
+
// fast-path stamp is dropped too, so a later pose-only repair can
|
|
404
|
+
// never re-pose this newer geometry off an older delivery's stamp.
|
|
405
|
+
// A user edit mid-play pauses playback and bumps the version WITHOUT
|
|
406
|
+
// touching lastAnimApplyVersion, so a genuinely user-stale result
|
|
407
|
+
// fails this test and is discarded exactly as before.
|
|
408
|
+
for (const m of data.meshes) {
|
|
409
|
+
viewer.setSubGeometry(m.name, m);
|
|
410
|
+
fastPath.forget(m.name);
|
|
411
|
+
}
|
|
412
|
+
ui.hideBusy();
|
|
413
|
+
refreshView();
|
|
346
414
|
}
|
|
347
415
|
loop.kick(); // stale → rebuild; fresh → the view may still need parts (tab switched mid-build)
|
|
348
416
|
break;
|
|
@@ -385,7 +453,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
385
453
|
});
|
|
386
454
|
cleanup.defer(() => exportCtl.dispose("viewer disposed"));
|
|
387
455
|
|
|
388
|
-
|
|
456
|
+
let animCtl = null; // assigned below; panel edits must pause active playback
|
|
457
|
+
const panel = buildControls(els.controls, part.parameters, params, () => {
|
|
458
|
+
animCtl?.notifyUserEdit();
|
|
459
|
+
onParamChange();
|
|
460
|
+
});
|
|
389
461
|
cleanup.defer(() => panel.dispose());
|
|
390
462
|
const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
|
|
391
463
|
updateRelevance(); // initial view
|
|
@@ -394,8 +466,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
394
466
|
// forceRegen path: forceRegen() forgets cache stamps WITHOUT bumping the params
|
|
395
467
|
// version, so a repair there would re-stamp everything current off the memoized
|
|
396
468
|
// probe and the forced rebuild would silently no-op.
|
|
397
|
-
function onParamChange() {
|
|
398
|
-
loop.markDirty(); // bump the version first: refreshView below must see the parts as stale
|
|
469
|
+
function onParamChange({ debounce = true } = {}) {
|
|
470
|
+
loop.markDirty({ debounce }); // bump the version first: refreshView below must see the parts as stale
|
|
399
471
|
// Pose-only edits: re-posed + re-stamped current, no job. Skipped entirely
|
|
400
472
|
// when caching is off — ?debug&nocache is there to measure true uncached
|
|
401
473
|
// rebuilds, which the fast path would otherwise hide.
|
|
@@ -412,11 +484,33 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
412
484
|
// path as a slider edit: pose-only changes repair synchronously (no worker
|
|
413
485
|
// job, no debounce); geometry changes fall through to the regen loop.
|
|
414
486
|
function setParams(partial) {
|
|
487
|
+
animCtl?.notifyUserEdit();
|
|
415
488
|
Object.assign(params, partial);
|
|
416
489
|
panel.syncValues(Object.keys(partial));
|
|
417
490
|
onParamChange();
|
|
418
491
|
}
|
|
419
492
|
|
|
493
|
+
// Animation-frame param entry point: same change path as setParams, minus
|
|
494
|
+
// the regen debounce. The explicit kick after repair is what makes playback
|
|
495
|
+
// best-effort — a pose-only frame finds nothing missing (repair re-stamped
|
|
496
|
+
// it) and sends no job; a geometry frame dispatches immediately when the
|
|
497
|
+
// worker is idle and is otherwise absorbed until buildDone re-kicks.
|
|
498
|
+
function applyAnimationValues(values) {
|
|
499
|
+
Object.assign(params, values);
|
|
500
|
+
panel.syncValues(Object.keys(values));
|
|
501
|
+
onParamChange({ debounce: false });
|
|
502
|
+
lastAnimApplyVersion = loop.version(); // this version came from playback, not a user edit
|
|
503
|
+
loop.kick();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Animation transport + driver (no-op null when the part declares none).
|
|
507
|
+
animCtl = attachAnimationControls(viewer, part, {
|
|
508
|
+
container: els.viewer,
|
|
509
|
+
applyValues: applyAnimationValues,
|
|
510
|
+
getParamValues: (keys) => Object.fromEntries(keys.map((k) => [k, params[k]])),
|
|
511
|
+
});
|
|
512
|
+
if (animCtl) cleanup.defer(() => animCtl.detach());
|
|
513
|
+
|
|
420
514
|
// Re-run the active view under the current caching setting, so toggling the
|
|
421
515
|
// ?debug switch updates the readout for the same design without a param change.
|
|
422
516
|
function forceRegen() {
|
|
@@ -437,7 +531,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
437
531
|
|
|
438
532
|
const onStepClick = () => {
|
|
439
533
|
ui.showBusy("exporting STEP");
|
|
440
|
-
service.send({ type: "export-step", view: view(), params }, "
|
|
534
|
+
service.send({ type: "export-step", view: view(), params }, backendForFormat("step", backendFor));
|
|
441
535
|
};
|
|
442
536
|
if (els.exports.step) {
|
|
443
537
|
els.exports.step.addEventListener("click", onStepClick);
|
|
@@ -453,7 +547,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
453
547
|
cleanup.defer(() => els.exports.threeMf.removeEventListener("click", on3mfClick));
|
|
454
548
|
}
|
|
455
549
|
|
|
456
|
-
// Optional host-page viewer chrome (
|
|
550
|
+
// Optional host-page viewer chrome (reframe / theme) + camera persistence.
|
|
457
551
|
const chrome = attachViewerControls(viewer, els.chrome, { tooltip });
|
|
458
552
|
cleanup.defer(() => chrome.detach());
|
|
459
553
|
|
|
@@ -470,6 +564,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
470
564
|
listExportableParts: () =>
|
|
471
565
|
exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
|
|
472
566
|
exportParts: (opts) => exportCtl.exportParts(opts),
|
|
567
|
+
animation: animCtl?.runtime ?? null,
|
|
473
568
|
});
|
|
474
569
|
} catch (error) {
|
|
475
570
|
try {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { viewSubParts, resolveParams, buildPosed } from "../
|
|
1
|
+
import { viewSubParts, resolveParams, buildPosed } from "../part-model.js";
|
|
2
2
|
|
|
3
3
|
// Build every sub-part of a view in its display (assembly) pose with the given
|
|
4
4
|
// Manifold kernel, returning live solids + copied-out meshes. Mirrors the
|