partforge 0.41.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.
Files changed (70) hide show
  1. package/README.md +31 -10
  2. package/bin/cli.js +100 -27
  3. package/docs/AUTHORING-PARTS.md +126 -14
  4. package/docs/ERROR-PATTERNS.md +6 -0
  5. package/package.json +48 -7
  6. package/skills/partforge/SKILL.md +17 -3
  7. package/src/app-embed-test.js +1 -1
  8. package/src/app-hinged-box.js +12 -0
  9. package/src/framework/animation-controls.js +243 -0
  10. package/src/framework/animation.js +217 -0
  11. package/src/framework/app.css +32 -0
  12. package/src/framework/assembly.js +1 -1
  13. package/src/framework/backend-select.js +25 -0
  14. package/src/framework/camera-tween.js +58 -0
  15. package/src/framework/chrome.css +16 -0
  16. package/src/framework/controls.js +13 -3
  17. package/src/framework/cutaway-gizmo-scene.js +244 -0
  18. package/src/framework/cutaway-gizmo.js +80 -243
  19. package/src/framework/default-view.js +46 -0
  20. package/src/framework/download.js +7 -2
  21. package/src/framework/export-controller.js +13 -2
  22. package/src/framework/geometry/probe.js +3 -22
  23. package/src/framework/jobs.js +9 -40
  24. package/src/framework/lint/finding.js +4 -0
  25. package/src/framework/lint/index.js +7 -3
  26. package/src/framework/lint/rules-animations.js +404 -0
  27. package/src/framework/lint/rules-place.js +76 -0
  28. package/src/framework/lint/rules-shape.js +12 -0
  29. package/src/framework/lint/rules-verify.js +2 -2
  30. package/src/framework/mount.js +93 -18
  31. package/src/{testing → framework/oracle}/build.js +1 -1
  32. package/src/{testing → framework/oracle}/bvh.js +1 -1
  33. package/src/{testing → framework/oracle}/measure.js +1 -1
  34. package/src/{testing → framework/oracle}/min-wall.js +1 -1
  35. package/src/{testing → framework/oracle}/verify.js +3 -3
  36. package/src/framework/param-deps.js +1 -1
  37. package/src/framework/part-model.js +48 -0
  38. package/src/framework/pick-request/client.js +11 -3
  39. package/src/framework/pick-request/endpoint.js +60 -0
  40. package/src/framework/pick-request/index.js +6 -0
  41. package/src/framework/pick-request/server.js +222 -34
  42. package/src/framework/pick-request/token-store.js +31 -0
  43. package/src/framework/pose-fast-path.js +12 -1
  44. package/src/framework/pose-probe-core.js +129 -0
  45. package/src/framework/pose-probe.js +7 -123
  46. package/src/framework/regen-loop.js +10 -3
  47. package/src/framework/safe-name.js +26 -0
  48. package/src/framework/verify-metrics.js +4 -4
  49. package/src/framework/view-state.js +25 -21
  50. package/src/framework/view-tabs.js +22 -7
  51. package/src/framework/viewer-controls.js +5 -26
  52. package/src/framework/viewer.js +58 -17
  53. package/src/hinged-box-worker.js +3 -0
  54. package/src/index.js +1 -1
  55. package/src/parts/hinged-box.js +94 -0
  56. package/src/testing/render.js +19 -8
  57. package/src/testing.js +15 -8
  58. package/types/derive.d.ts +14 -0
  59. package/types/geometry.d.ts +117 -0
  60. package/types/index.d.ts +240 -0
  61. package/types/kernel.d.ts +409 -0
  62. package/types/lint.d.ts +85 -0
  63. package/types/part.d.ts +381 -0
  64. package/types/testing.d.ts +362 -0
  65. package/types/worker.d.ts +21 -0
  66. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  67. /package/src/{testing → framework/oracle}/cases.js +0 -0
  68. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  69. /package/src/{testing → framework/oracle}/gaps.js +0 -0
  70. /package/src/{testing → framework/oracle}/mesh.js +0 -0
@@ -0,0 +1,76 @@
1
+ // Group 6 — the two place() invariants (docs/AUTHORING-PARTS.md "Display vs
2
+ // export placement"), promoted from doc-only conventions to lint because the
3
+ // animation system leans on place() for every pose-only track:
4
+ // 1. Display placement must not depend on the active view (display meshes
5
+ // are cached across views; a view-dependent pose serves stale geometry).
6
+ // 2. Display vs export may differ only by a rigid motion (translate/rotate).
7
+ // Both checks run the geometry-free pose probe; an untrusted probe (query op /
8
+ // function selector in build or place) proves nothing and stays silent — the
9
+ // runtime declines the fast path for those sub-parts anyway.
10
+ import { err } from "./finding.js";
11
+ import { probeSubPartPose } from "../pose-probe-core.js";
12
+
13
+ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
14
+
15
+ // The sub-part names visible in a view at params p — restated locally (like
16
+ // rules-schema.js restates controls.js's visibility predicates) rather than
17
+ // imported from jobs.js, which would break the lint purity closure.
18
+ function viewNames(part, view, p) {
19
+ return Object.entries(isPlainObject(part?.parts) ? part.parts : {})
20
+ .filter(([, sp]) => Array.isArray(sp?.views) && sp.views.includes(view))
21
+ .filter(([, sp]) => { try { return sp.enabled ? !!sp.enabled(p) : true; } catch { return false; } })
22
+ .map(([name]) => name);
23
+ }
24
+
25
+ const poseKey = (pose) => JSON.stringify(pose);
26
+
27
+ export const PLACE_RULES = [
28
+ {
29
+ id: "view-dependent-display-place",
30
+ run: ({ part, p, d }) => {
31
+ const out = [];
32
+ const views = Object.keys(isPlainObject(part?.views) ? part.views : {});
33
+ if (views.length < 2) return out;
34
+ for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
35
+ const inViews = views.filter((v) => viewNames(part, v, p).includes(name));
36
+ if (inViews.length < 2) continue;
37
+ const probes = inViews.map((view) => probeSubPartPose(sp, { view, purpose: "display", p, d }));
38
+ if (probes.some((x) => !x.trusted)) continue;
39
+ const first = probes[0];
40
+ const differs = probes.some((x) => x.baseHash !== first.baseHash || poseKey(x.pose) !== poseKey(first.pose));
41
+ if (differs) {
42
+ out.push(err("view-dependent-display-place",
43
+ `sub-part "${name}" display placement differs between views (${inViews.join(", ")})`,
44
+ "Display meshes are built once per sub-part and cached across views, so a view-dependent display pose shows stale geometry after a tab switch. Only `place(..., { purpose: \"export\" })` may vary; keep the display branch view-independent.",
45
+ `parts.${name}.place`,
46
+ "view-dependent-display-place"));
47
+ }
48
+ }
49
+ return out;
50
+ },
51
+ },
52
+ {
53
+ id: "place-not-rigid",
54
+ run: ({ part, p, d }) => {
55
+ const out = [];
56
+ for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
57
+ if (!sp?.place) continue;
58
+ for (const view of Object.keys(isPlainObject(part?.views) ? part.views : {})) {
59
+ if (!viewNames(part, view, p).includes(name)) continue;
60
+ const display = probeSubPartPose(sp, { view, purpose: "display", p, d });
61
+ const exportP = probeSubPartPose(sp, { view, purpose: "export", p, d });
62
+ if (!display.trusted || !exportP.trusted) continue;
63
+ if (display.baseHash !== exportP.baseHash) {
64
+ out.push(err("place-not-rigid",
65
+ `sub-part "${name}" display and export placements differ by more than a rigid motion (view "${view}")`,
66
+ "place() may move a solid between purposes (translate/rotate) but never reshape it — a geometry op on one branch means the exported part is not the previewed part. Move the op into build().",
67
+ `parts.${name}.place`,
68
+ "place-not-rigid"));
69
+ break; // one finding per sub-part — further views add nothing
70
+ }
71
+ }
72
+ }
73
+ return out;
74
+ },
75
+ },
76
+ ];
@@ -83,4 +83,16 @@ export const SHAPE_RULES = [
83
83
  `views.${v}`));
84
84
  },
85
85
  },
86
+ {
87
+ id: "default-view-ambiguous",
88
+ run: ({ part }) => {
89
+ if (!isPlainObject(part?.views)) return [];
90
+ const flagged = Object.keys(part.views).filter((v) => part.views[v]?.default === true);
91
+ if (flagged.length < 2) return [];
92
+ return [warn("default-view-ambiguous",
93
+ `${flagged.length} views set \`default: true\`: ${flagged.map((v) => `"${v}"`).join(", ")}`,
94
+ `Only one view can open by default. The viewer takes the first one declared — "${flagged[0]}" — and ignores the rest; remove \`default: true\` from the others.`,
95
+ `views.${flagged[1]}.default`)];
96
+ },
97
+ },
86
98
  ];
@@ -4,8 +4,8 @@
4
4
  // Catching them statically removes both the wasted boot and the stdout caveat.
5
5
  import { err } from "./finding.js";
6
6
  import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
7
- import { PROFILES } from "../../testing/dfm-profiles.js";
8
- import { parseAssertion } from "../../testing/assert-dsl.js";
7
+ import { PROFILES } from "../oracle/dfm-profiles.js";
8
+ import { parseAssertion } from "../oracle/assert-dsl.js";
9
9
  import { suggest } from "../geometry/op-options.js";
10
10
 
11
11
  // Resolve `expect` to a plain object. The function form (p, d) => ({…}) is invoked
@@ -11,24 +11,28 @@ 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 "./jobs.js";
14
+ import { viewSubParts } from "./part-model.js";
15
15
  import { resolveDerived } from "./derive.js";
16
- import { detectBackend } from "./geometry/probe.js";
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),
34
38
  // Park/unpark the viewer: stops the render loop and frees the drawing
@@ -78,7 +82,7 @@ function createCleanupStack() {
78
82
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
79
83
  // so Vite can bundle the worker (see geometry-service.js).
80
84
  //
81
- // Embedding contract (0.41.0):
85
+ // Embedding contract (0.44.0):
82
86
  // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
83
87
  // await runtime.ready; // first successful build of the default view
84
88
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
@@ -103,6 +107,14 @@ function createCleanupStack() {
103
107
  // // loop would otherwise render a hidden pane forever.
104
108
  // // Captures still work while parked (they re-allocate).
105
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.
106
118
  // const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
107
119
  // // OS gave up — surface it rather than showing a dead
108
120
  // // canvas. Returns an unsubscribe.
@@ -139,7 +151,6 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
139
151
  threeMf: elements.exports?.threeMf ?? byId("download-3mf"),
140
152
  },
141
153
  chrome: {
142
- pause: elements.chrome?.pause ?? byId("pause"),
143
154
  reframe: elements.chrome?.reframe ?? byId("reframe"),
144
155
  theme: elements.chrome?.theme ?? byId("theme"),
145
156
  cutaway: elements.chrome?.cutaway ?? byId("cutaway"),
@@ -203,7 +214,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
203
214
  // View tabs (generated from part.views) + live params. A tab switch shows the
204
215
  // cached assembly instantly if it's current, else auto-builds what's missing.
205
216
  const tabsCtl = createViewTabs(els.tabs, part, {
206
- onChange: () => { pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); },
217
+ onChange: () => { pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick(); },
207
218
  });
208
219
  cleanup.defer(() => tabsCtl.detach());
209
220
  const view = () => tabsCtl.current();
@@ -240,10 +251,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
240
251
  cleanup.defer(() => pickToggle.detach());
241
252
  } else if (qs.has("pickserver")) {
242
253
  // Agent-driven mode: arm the picker only when the local pick-server asks for a
243
- // click. `?pickserver` or `?pickserver=http://host:port`.
244
- const serverUrl = typeof qs.get("pickserver") === "string" && qs.get("pickserver")
245
- ? qs.get("pickserver") : "http://127.0.0.1:4518";
246
- pickClient = createPickRequestClient({ serverUrl, viewer, part, getContext });
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 });
247
265
  cleanup.defer(() => pickClient.detach());
248
266
  }
249
267
 
@@ -282,9 +300,18 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
282
300
  params, getView: view, getParamsVersion: () => loop.version(),
283
301
  });
284
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
+
285
308
  // First-build readiness: resolves on the first accepted meshes result, rejects on
286
309
  // a first-build error. Guarded against unhandled rejection when never awaited.
287
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;
288
315
  let resolveReady, rejectReady;
289
316
  const ready = new Promise((res, rej) => { resolveReady = res; rejectReady = rej; });
290
317
  ready.catch(() => {});
@@ -346,7 +373,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
346
373
  ui.setStatus(`${data.phase}…`);
347
374
  break;
348
375
  case "meshes": {
349
- if (loop.buildDone()) { // stale results (params changed mid-build) are discarded
376
+ const fresh = loop.buildDone();
377
+ if (fresh) { // stale results (params changed mid-build) are discarded
350
378
  for (const m of data.meshes) {
351
379
  viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
352
380
  cache.record(m.name);
@@ -363,6 +391,26 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
363
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 });
364
392
  onBuild?.({ status: "success", ms: data.ms });
365
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();
366
414
  }
367
415
  loop.kick(); // stale → rebuild; fresh → the view may still need parts (tab switched mid-build)
368
416
  break;
@@ -405,7 +453,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
405
453
  });
406
454
  cleanup.defer(() => exportCtl.dispose("viewer disposed"));
407
455
 
408
- const panel = buildControls(els.controls, part.parameters, params, onParamChange);
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
+ });
409
461
  cleanup.defer(() => panel.dispose());
410
462
  const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
411
463
  updateRelevance(); // initial view
@@ -414,8 +466,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
414
466
  // forceRegen path: forceRegen() forgets cache stamps WITHOUT bumping the params
415
467
  // version, so a repair there would re-stamp everything current off the memoized
416
468
  // probe and the forced rebuild would silently no-op.
417
- function onParamChange() {
418
- 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
419
471
  // Pose-only edits: re-posed + re-stamped current, no job. Skipped entirely
420
472
  // when caching is off — ?debug&nocache is there to measure true uncached
421
473
  // rebuilds, which the fast path would otherwise hide.
@@ -432,11 +484,33 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
432
484
  // path as a slider edit: pose-only changes repair synchronously (no worker
433
485
  // job, no debounce); geometry changes fall through to the regen loop.
434
486
  function setParams(partial) {
487
+ animCtl?.notifyUserEdit();
435
488
  Object.assign(params, partial);
436
489
  panel.syncValues(Object.keys(partial));
437
490
  onParamChange();
438
491
  }
439
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
+
440
514
  // Re-run the active view under the current caching setting, so toggling the
441
515
  // ?debug switch updates the readout for the same design without a param change.
442
516
  function forceRegen() {
@@ -457,7 +531,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
457
531
 
458
532
  const onStepClick = () => {
459
533
  ui.showBusy("exporting STEP");
460
- service.send({ type: "export-step", view: view(), params }, "occt"); // STEP is always OCCT
534
+ service.send({ type: "export-step", view: view(), params }, backendForFormat("step", backendFor));
461
535
  };
462
536
  if (els.exports.step) {
463
537
  els.exports.step.addEventListener("click", onStepClick);
@@ -473,7 +547,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
473
547
  cleanup.defer(() => els.exports.threeMf.removeEventListener("click", on3mfClick));
474
548
  }
475
549
 
476
- // Optional host-page viewer chrome (pause / reframe / theme) + camera persistence.
550
+ // Optional host-page viewer chrome (reframe / theme) + camera persistence.
477
551
  const chrome = attachViewerControls(viewer, els.chrome, { tooltip });
478
552
  cleanup.defer(() => chrome.detach());
479
553
 
@@ -490,6 +564,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
490
564
  listExportableParts: () =>
491
565
  exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
492
566
  exportParts: (opts) => exportCtl.exportParts(opts),
567
+ animation: animCtl?.runtime ?? null,
493
568
  });
494
569
  } catch (error) {
495
570
  try {
@@ -1,4 +1,4 @@
1
- import { viewSubParts, resolveParams, buildPosed } from "../framework/jobs.js";
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
@@ -1,4 +1,4 @@
1
- // src/testing/bvh.js
1
+ // src/framework/oracle/bvh.js
2
2
  // Triangle BVH over a mesh in either Manifold non-indexed soup form (9 floats per
3
3
  // triangle, no `indices`) or OCCT indexed form (`positions` = 3 floats/vertex +
4
4
  // `indices` = 3 vertex-indices/triangle). A reusable spatial index: nearest ray hit
@@ -1,6 +1,6 @@
1
1
  import { buildView } from "./build.js";
2
2
  import { cachedBVH } from "./bvh.js";
3
- import { assemblyOverlaps } from "../framework/assembly.js";
3
+ import { assemblyOverlaps } from "../assembly.js";
4
4
  import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
5
5
  import { bounds, meshArea, meshCentroid } from "./mesh.js";
6
6
  import { minWall } from "./min-wall.js";
@@ -1,4 +1,4 @@
1
- // src/testing/min-wall.js
1
+ // src/framework/oracle/min-wall.js
2
2
  // Min wall thickness by ray/shot on a triangle BVH (see the spec's spike: this beat the
3
3
  // voxel/SDF approach on both accuracy and speed). For each surface triangle, cast a ray
4
4
  // inward (reverse of its outward normal) from the centroid; the nearest hit is the local
@@ -3,9 +3,9 @@ import { measure as defaultMeasure } from "./measure.js";
3
3
  import { pairKey, CONTACT_EPS } from "./gaps.js";
4
4
  import { resolveProfile } from "./dfm-profiles.js";
5
5
  import { expandCases } from "./cases.js";
6
- import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../framework/param-deps.js";
7
- import { resolveParams } from "../framework/jobs.js";
8
- import { SUBPART_METRICS, VIEW_METRICS } from "../framework/verify-metrics.js";
6
+ import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../param-deps.js";
7
+ import { resolveParams } from "../part-model.js";
8
+ import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
9
9
 
10
10
  // Re-exported for backwards compatibility: the registries moved to framework/ so
11
11
  // the linter can read the metric vocabulary without importing a geometry kernel.
@@ -3,7 +3,7 @@
3
3
  // affect what's visible. Pure — no DOM, no real geometry (reuses the geometry-free
4
4
  // probe kernel). Errs toward RELEVANT_ALL whenever it can't analyze a build.
5
5
  import { createProbeKernel } from "./geometry/probe.js";
6
- import { viewSubParts } from "./jobs.js";
6
+ import { viewSubParts } from "./part-model.js";
7
7
  import { resolveDerived } from "./derive.js";
8
8
 
9
9
  export const RELEVANT_ALL = Symbol("relevant-all");
@@ -0,0 +1,48 @@
1
+ // The pure part model: which sub-parts a view shows, which of those are exportable,
2
+ // how a part's params resolve, and how one posed sub-part solid is built. No async,
3
+ // no worker protocol, no kernel boot — just synchronous functions over a
4
+ // PartDefinition, a kernel handle, and params.
5
+ //
6
+ // Deliberately a LEAF of the framework graph. buildPosed is the single definition of
7
+ // "a posed sub-part solid", so the worker job loop (jobs.js), the collision check
8
+ // (assembly.js), the relevance probe (param-deps.js), and the headless oracle
9
+ // (oracle/) all call it. Keeping those four functions out of jobs.js — which is
10
+ // async, imports the kernels, and pulls in the whole export stack — is what lets the
11
+ // oracle depend on the part model without an import cycle back through the job loop.
12
+ import { resolveDerived } from "./derive.js";
13
+
14
+ // Names of the sub-parts a view shows: declared in the view and enabled for these
15
+ // params. Order follows Object.keys(part.parts) (definition order).
16
+ export function viewSubParts(part, view, params) {
17
+ return Object.keys(part.parts).filter((name) => {
18
+ const sp = part.parts[name];
19
+ const inView = sp.views.includes(view);
20
+ const on = sp.enabled ? !!sp.enabled(params) : true;
21
+ return inView && on;
22
+ });
23
+ }
24
+
25
+ // Sub-parts to include in an EXPORT of this view: the visible sub-parts, minus any
26
+ // flagged `exportable: false` (reference/preview-only parts — motor ghosts, bearing
27
+ // placeholders, etc.). They still show in the viewer; they're just never written to
28
+ // an STL/STEP/3MF file, so the user never has to toggle them off before exporting.
29
+ export function exportSubParts(part, view, params) {
30
+ return viewSubParts(part, view, params).filter((name) => part.parts[name].exportable !== false);
31
+ }
32
+
33
+ // Resolve a part's effective params + derived values for a build: the user's params
34
+ // layered over the part defaults, and derive() run once over the result.
35
+ export function resolveParams(part, params) {
36
+ const p = { ...part.defaults, ...params };
37
+ return { p, d: resolveDerived(part, p) };
38
+ }
39
+
40
+ // Build one sub-part and apply its optional place() for the given purpose/view.
41
+ // `p`/`d` come from resolveParams(). This is the SINGLE definition of "a posed
42
+ // sub-part solid" — the worker, the collision check, and the test harness all call
43
+ // it, so display/export poses can never drift between the app and its tests.
44
+ export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress } = {}) {
45
+ const sp = part.parts[name];
46
+ const solid = sp.build(kernel, p, d, onProgress);
47
+ return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
48
+ }
@@ -4,18 +4,26 @@
4
4
  // user is working with an agent.
5
5
  import { createPromptBanner } from "./prompt-banner.js";
6
6
  import { formatSelection } from "../selection/format.js";
7
+ import { PICK_SERVER_DEFAULT_URL } from "./endpoint.js";
7
8
 
8
- export function createPickRequestClient({ serverUrl = "http://127.0.0.1:4518", viewer, part, getContext }) {
9
+ export function createPickRequestClient({ serverUrl = PICK_SERVER_DEFAULT_URL, token = "", viewer, part, getContext }) {
9
10
  let active = null; // { id, index } of the agent prompt we're waiting on
10
11
  const banner = createPromptBanner({ viewer, part, getContext });
11
12
 
13
+ // Every route on the pick-server is token-gated. POSTs carry it as a header;
14
+ // EventSource cannot set headers, so the stream carries it in the query string.
12
15
  const postJson = (path, body) =>
13
16
  fetch(`${serverUrl}${path}`, {
14
- method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
17
+ method: "POST",
18
+ headers: { "content-type": "application/json", ...(token ? { "x-pick-token": token } : {}) },
19
+ body: JSON.stringify(body),
15
20
  }).catch(() => banner.message("⚠ couldn't reach pick-server — click not sent"));
16
21
 
17
22
  // --- agent prompts over SSE -------------------------------------------------
18
- const es = new globalThis.EventSource(`${serverUrl}/events`);
23
+ const eventsUrl = token
24
+ ? `${serverUrl}/events?token=${encodeURIComponent(token)}`
25
+ : `${serverUrl}/events`;
26
+ const es = new globalThis.EventSource(eventsUrl);
19
27
  es.addEventListener("prompt", (e) => {
20
28
  const v = JSON.parse(e.data);
21
29
  active = { id: v.id, index: v.index };
@@ -0,0 +1,60 @@
1
+ // Shared constants + loopback checks for request-a-pick. Deliberately free of both
2
+ // node: imports and DOM access: the browser client, mount.js, the Node server, and
3
+ // the CLI all need these, and the browser side must never pull in node:http.
4
+ //
5
+ // Why loopback matters: the pick-server streams the agent's prompts and receives the
6
+ // user's selection (including live parameter values). Both ends must be pinned to the
7
+ // local machine — an arbitrary `?pickserver=https://evil.example` would ship every
8
+ // click off-box, and an arbitrary reflected CORS origin would let any page the user
9
+ // visits read the stream.
10
+ export const PICK_SERVER_DEFAULT_PORT = 4518;
11
+ export const PICK_SERVER_DEFAULT_TIMEOUT_MS = 120000;
12
+ export const PICK_SERVER_DEFAULT_HOST = "127.0.0.1";
13
+ export const PICK_SERVER_DEFAULT_URL = `http://${PICK_SERVER_DEFAULT_HOST}:${PICK_SERVER_DEFAULT_PORT}`;
14
+
15
+ // The whole 127/8 block plus the IPv6 loopback and the `localhost` name. Anything
16
+ // else (including 0.0.0.0 and names that merely resolve to 127.0.0.1) is rejected —
17
+ // a DNS name is exactly the DNS-rebinding vector we are guarding against.
18
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "::1", "[::1]"]);
19
+ const isLoopbackHostname = (h) =>
20
+ LOOPBACK_HOSTNAMES.has(h) || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
21
+
22
+ // `origin` is an HTTP Origin header value: scheme://host[:port], no path.
23
+ export function isLoopbackOrigin(origin) {
24
+ if (typeof origin !== "string" || origin === "") return false;
25
+ let u;
26
+ try { u = new URL(origin); } catch { return false; }
27
+ if (u.protocol !== "http:" && u.protocol !== "https:") return false;
28
+ return isLoopbackHostname(u.hostname);
29
+ }
30
+
31
+ // `host` is an HTTP Host header value: host[:port], no scheme. A Host that names
32
+ // anything but loopback means the request arrived through a rebound DNS name.
33
+ export function isLoopbackHost(host, port) {
34
+ if (typeof host !== "string" || host === "") return false;
35
+ let u;
36
+ try { u = new URL(`http://${host}`); } catch { return false; }
37
+ if (!isLoopbackHostname(u.hostname)) return false;
38
+ // An explicit port must be ours; a bare host means port 80, which we never bind.
39
+ return u.port !== "" && Number(u.port) === Number(port);
40
+ }
41
+
42
+ export function isLoopbackUrl(url) {
43
+ if (typeof url !== "string" || url === "") return false;
44
+ let u;
45
+ try { u = new URL(url); } catch { return false; }
46
+ if (u.protocol !== "http:" && u.protocol !== "https:") return false;
47
+ return isLoopbackHostname(u.hostname);
48
+ }
49
+
50
+ // Normalise a user-supplied `?pickserver=` value to an origin we are willing to talk
51
+ // to. Anything non-loopback falls back to the default and reports why, so a tampered
52
+ // URL degrades to "talks to the local server" rather than "exfiltrates every click".
53
+ export function resolvePickServerUrl(raw, { onReject } = {}) {
54
+ if (typeof raw !== "string" || raw === "") return PICK_SERVER_DEFAULT_URL;
55
+ if (!isLoopbackUrl(raw)) {
56
+ onReject?.(raw);
57
+ return PICK_SERVER_DEFAULT_URL;
58
+ }
59
+ return raw.replace(/\/+$/, ""); // paths are appended verbatim; no double slash
60
+ }
@@ -1 +1,7 @@
1
1
  export { createPickRequestClient } from "./client.js";
2
+ // Browser-safe half of the endpoint contract (no node: imports) — mount.js needs the
3
+ // loopback check and the default URL, and must not reach into the Node server module.
4
+ export {
5
+ resolvePickServerUrl, isLoopbackUrl,
6
+ PICK_SERVER_DEFAULT_PORT, PICK_SERVER_DEFAULT_URL,
7
+ } from "./endpoint.js";