partforge 0.41.0 → 0.45.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 (74) hide show
  1. package/README.md +31 -10
  2. package/bin/cli.js +138 -27
  3. package/docs/AUTHORING-PARTS.md +164 -17
  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 +254 -0
  10. package/src/framework/animation.js +271 -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/capture-build.js +59 -0
  16. package/src/framework/chrome.css +16 -0
  17. package/src/framework/controls.js +13 -3
  18. package/src/framework/cutaway-gizmo-scene.js +244 -0
  19. package/src/framework/cutaway-gizmo.js +80 -243
  20. package/src/framework/default-view.js +46 -0
  21. package/src/framework/download.js +7 -2
  22. package/src/framework/export-controller.js +13 -2
  23. package/src/framework/geometry/probe.js +3 -22
  24. package/src/framework/jobs.js +30 -40
  25. package/src/framework/lint/finding.js +4 -0
  26. package/src/framework/lint/index.js +7 -3
  27. package/src/framework/lint/rules-animations.js +441 -0
  28. package/src/framework/lint/rules-place.js +76 -0
  29. package/src/framework/lint/rules-schema.js +22 -0
  30. package/src/framework/lint/rules-shape.js +12 -0
  31. package/src/framework/lint/rules-verify.js +2 -2
  32. package/src/framework/mount.js +147 -20
  33. package/src/{testing → framework/oracle}/build.js +1 -1
  34. package/src/{testing → framework/oracle}/bvh.js +1 -1
  35. package/src/{testing → framework/oracle}/measure.js +1 -1
  36. package/src/{testing → framework/oracle}/min-wall.js +1 -1
  37. package/src/{testing → framework/oracle}/verify.js +3 -3
  38. package/src/framework/param-deps.js +1 -1
  39. package/src/framework/part-model.js +48 -0
  40. package/src/framework/pick-request/client.js +11 -3
  41. package/src/framework/pick-request/endpoint.js +60 -0
  42. package/src/framework/pick-request/index.js +6 -0
  43. package/src/framework/pick-request/server.js +222 -34
  44. package/src/framework/pick-request/token-store.js +31 -0
  45. package/src/framework/pose-fast-path.js +12 -1
  46. package/src/framework/pose-probe-core.js +129 -0
  47. package/src/framework/pose-probe.js +7 -123
  48. package/src/framework/regen-loop.js +10 -3
  49. package/src/framework/safe-name.js +26 -0
  50. package/src/framework/verify-metrics.js +4 -4
  51. package/src/framework/view-state.js +25 -21
  52. package/src/framework/view-tabs.js +35 -7
  53. package/src/framework/viewer-controls.js +5 -26
  54. package/src/framework/viewer-lighting.js +8 -1
  55. package/src/framework/viewer.js +139 -20
  56. package/src/framework/worker.js +5 -1
  57. package/src/hinged-box-worker.js +3 -0
  58. package/src/index.js +1 -1
  59. package/src/parts/hinged-box.js +94 -0
  60. package/src/testing/render.js +19 -8
  61. package/src/testing.js +15 -8
  62. package/types/derive.d.ts +14 -0
  63. package/types/geometry.d.ts +117 -0
  64. package/types/index.d.ts +259 -0
  65. package/types/kernel.d.ts +409 -0
  66. package/types/lint.d.ts +85 -0
  67. package/types/part.d.ts +409 -0
  68. package/types/testing.d.ts +362 -0
  69. package/types/worker.d.ts +21 -0
  70. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  71. /package/src/{testing → framework/oracle}/cases.js +0 -0
  72. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  73. /package/src/{testing → framework/oracle}/gaps.js +0 -0
  74. /package/src/{testing → framework/oracle}/mesh.js +0 -0
@@ -11,24 +11,36 @@ 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 { createCaptureBuild } from "./capture-build.js";
27
+ import { attachAnimationControls } from "./animation-controls.js";
28
+ import { resolveDefaultView } from "./default-view.js";
26
29
 
27
30
  // The mount handle, factored out so its shape is unit-testable without booting
28
31
  // the full mount() pipeline (WASM + workers + DOM).
29
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane }) {
32
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView }) {
30
33
  return {
31
34
  ready, dispose, setParams,
35
+ // Part-declared animation playback (spec 2026-08-02): null when the part
36
+ // declares no animations. { play(name?), pause(), seek(t), stop(), state() }.
37
+ animation: animation ?? null,
38
+ // Active view name (never null once mounted). See onViewChange for the push side.
39
+ getView,
40
+ // Programmatic tab switch; false for a name the part doesn't declare.
41
+ setView,
42
+ // Offscreen render of a named view (default when omitted, or on an unknown name).
43
+ captureView,
32
44
  captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
33
45
  captureCurrent: (opts) => viewer.captureCurrent(opts),
34
46
  // Park/unpark the viewer: stops the render loop and frees the drawing
@@ -78,10 +90,17 @@ function createCleanupStack() {
78
90
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
79
91
  // so Vite can bundle the worker (see geometry-service.js).
80
92
  //
81
- // Embedding contract (0.41.0):
82
- // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
93
+ // Embedding contract (0.45.0):
94
+ // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload, onViewChange });
83
95
  // await runtime.ready; // first successful build of the default view
84
96
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
97
+ // runtime.getView(); // active view name (string), never null once mounted
98
+ // runtime.setView("lid"); // switch tab programmatically; returns false (and leaves the
99
+ // // active tab untouched) for a name the part doesn't declare
100
+ // await runtime.captureView(); // JPEG data URL of the DEFAULT view rendered offscreen (pass
101
+ // // a name for a specific view, falling back to the default for
102
+ // // an unknown one), never disturbing the active tab or the live
103
+ // // scene; null on failure (never throws)
85
104
  // runtime.captureCurrent({ size: 2048 }); // one offscreen render of the user's current
86
105
  // // framing (live camera pose + viewport aspect) at the
87
106
  // // given long-edge resolution → JPEG data URL, or null
@@ -103,16 +122,27 @@ function createCleanupStack() {
103
122
  // // loop would otherwise render a hidden pane forever.
104
123
  // // Captures still work while parked (they re-allocate).
105
124
  // // setActive(true) restores it. Safe after dispose().
125
+ // runtime.animation?.play("open"); // part-declared animation playback: null when the
126
+ // // part declares no animations, else
127
+ // // { play(name?), pause(), seek(t), stop(), state() }.
128
+ // // play() with an unknown name warns and does nothing;
129
+ // // any user/host param edit pauses playback. A part's
130
+ // // `autoplay: true` animation self-starts on first show
131
+ // // and each view switch until the user touches the
132
+ // // transport — no runtime call needed for that part.
106
133
  // const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
107
134
  // // OS gave up — surface it rather than showing a dead
108
135
  // // canvas. Returns an unsubscribe.
109
136
  // runtime.dispose(); // full teardown
110
137
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
111
138
  // those are repaired in the viewer and produce no build at all.
139
+ // onViewChange fires once synchronously during mount with the initial resolved
140
+ // view (before ready), then again on every subsequent view change (user click
141
+ // or a programmatic setView) — always the new view name.
112
142
  // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
113
143
  // exactly once here — submodules take element refs and never query the document.
114
144
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
115
- export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload,
145
+ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange,
116
146
  container: legacyContainer, controls: legacyControls } = {}) {
117
147
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
118
148
  const byId = (id) => document.getElementById(id);
@@ -139,7 +169,6 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
139
169
  threeMf: elements.exports?.threeMf ?? byId("download-3mf"),
140
170
  },
141
171
  chrome: {
142
- pause: elements.chrome?.pause ?? byId("pause"),
143
172
  reframe: elements.chrome?.reframe ?? byId("reframe"),
144
173
  theme: elements.chrome?.theme ?? byId("theme"),
145
174
  cutaway: elements.chrome?.cutaway ?? byId("cutaway"),
@@ -203,10 +232,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
203
232
  // View tabs (generated from part.views) + live params. A tab switch shows the
204
233
  // cached assembly instantly if it's current, else auto-builds what's missing.
205
234
  const tabsCtl = createViewTabs(els.tabs, part, {
206
- onChange: () => { pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); },
235
+ onChange: (name) => {
236
+ pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick();
237
+ onViewChange?.(name);
238
+ },
207
239
  });
208
240
  cleanup.defer(() => tabsCtl.detach());
209
241
  const view = () => tabsCtl.current();
242
+ // Tell the embedder the starting tab exactly once, synchronously, so a host
243
+ // (partforge-cloud) never has to poll getView() to learn where we opened.
244
+ onViewChange?.(tabsCtl.current());
210
245
  const params = { ...part.defaults };
211
246
 
212
247
  // Current selection context for the pickers: the active view + live params +
@@ -240,10 +275,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
240
275
  cleanup.defer(() => pickToggle.detach());
241
276
  } else if (qs.has("pickserver")) {
242
277
  // 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 });
278
+ // click. `?pickserver&picktoken=<token>` or `?pickserver=http://host:port&picktoken=…`.
279
+ // The URL is attacker-suppliable (anyone can hand the user a link), so a
280
+ // non-loopback target is refused rather than honoured — otherwise every click,
281
+ // with its live parameter values, would stream to a remote host.
282
+ const serverUrl = resolvePickServerUrl(qs.get("pickserver"), {
283
+ onReject: (raw) => console.warn(
284
+ `partforge: ignoring non-loopback ?pickserver=${raw} — using ${PICK_SERVER_DEFAULT_URL}`,
285
+ ),
286
+ });
287
+ const token = qs.get("picktoken") || "";
288
+ pickClient = createPickRequestClient({ serverUrl, token, viewer, part, getContext });
247
289
  cleanup.defer(() => pickClient.detach());
248
290
  }
249
291
 
@@ -282,9 +324,18 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
282
324
  params, getView: view, getParamsVersion: () => loop.version(),
283
325
  });
284
326
 
327
+ // paramsVersion of the most recent animation-frame apply. It is what lets
328
+ // the meshes handler tell "stale because playback moved on" (show it — that
329
+ // IS best-effort playback) from "stale because the user edited" (discard).
330
+ let lastAnimApplyVersion = -1;
331
+
285
332
  // First-build readiness: resolves on the first accepted meshes result, rejects on
286
333
  // a first-build error. Guarded against unhandled rejection when never awaited.
287
334
  let readySettled = false;
335
+ // First-show autoplay latch: separate from `readySettled`, which the error
336
+ // branch also settles — a part whose first build fails but whose retry
337
+ // succeeds still deserves its autoplay.
338
+ let autoplayKicked = false;
288
339
  let resolveReady, rejectReady;
289
340
  const ready = new Promise((res, rej) => { resolveReady = res; rejectReady = rej; });
290
341
  ready.catch(() => {});
@@ -337,6 +388,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
337
388
  function onWorkerMessage({ data }) {
338
389
  // Headless exportParts() correlation: consume its own replies first.
339
390
  if (exportCtl.handleMessage(data, onDownload)) return;
391
+ // captureView's off-loop build channel: consume its replies before the
392
+ // live `meshes` case — capture-meshes must never touch live cache/display.
393
+ if (captureBuild.handleMessage(data)) return;
340
394
  switch (data.type) {
341
395
  case "ready":
342
396
  loop.ready(); // auto-build the default view (keeps the busy spinner up)
@@ -346,7 +400,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
346
400
  ui.setStatus(`${data.phase}…`);
347
401
  break;
348
402
  case "meshes": {
349
- if (loop.buildDone()) { // stale results (params changed mid-build) are discarded
403
+ const fresh = loop.buildDone();
404
+ if (fresh) { // stale results (params changed mid-build) are discarded
350
405
  for (const m of data.meshes) {
351
406
  viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
352
407
  cache.record(m.name);
@@ -363,6 +418,26 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
363
418
  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
419
  onBuild?.({ status: "success", ms: data.ms });
365
420
  if (!readySettled) { readySettled = true; resolveReady(); }
421
+ // First-show autoplay: latched separately from `ready`, which the
422
+ // error branch also settles — a part whose first build fails but
423
+ // whose retry succeeds still deserves its autoplay.
424
+ if (!autoplayKicked) { autoplayKicked = true; animCtl?.autoplayKick(); }
425
+ } else if (lastAnimApplyVersion === loop.version()) {
426
+ // Stale ONLY because animation frames kept bumping the version:
427
+ // show the delivered meshes anyway — that IS best-effort playback —
428
+ // but record NOTHING. Cache and fast-path stamps must describe
429
+ // geometry built at the live params, and this delivery wasn't; the
430
+ // fast-path stamp is dropped too, so a later pose-only repair can
431
+ // never re-pose this newer geometry off an older delivery's stamp.
432
+ // A user edit mid-play pauses playback and bumps the version WITHOUT
433
+ // touching lastAnimApplyVersion, so a genuinely user-stale result
434
+ // fails this test and is discarded exactly as before.
435
+ for (const m of data.meshes) {
436
+ viewer.setSubGeometry(m.name, m);
437
+ fastPath.forget(m.name);
438
+ }
439
+ ui.hideBusy();
440
+ refreshView();
366
441
  }
367
442
  loop.kick(); // stale → rebuild; fresh → the view may still need parts (tab switched mid-build)
368
443
  break;
@@ -396,6 +471,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
396
471
  const service = createGeometryService({ createWorker, onMessage: onWorkerMessage });
397
472
  cleanup.defer(() => service.terminate());
398
473
 
474
+ const captureBuild = createCaptureBuild({ send: (msg, backend) => service.send(msg, backend) });
475
+ cleanup.defer(() => captureBuild.dispose());
476
+
399
477
  const exportCtl = createExportController({
400
478
  send: (msg, backend) => service.send(msg, backend),
401
479
  currentView: () => view(),
@@ -405,7 +483,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
405
483
  });
406
484
  cleanup.defer(() => exportCtl.dispose("viewer disposed"));
407
485
 
408
- const panel = buildControls(els.controls, part.parameters, params, onParamChange);
486
+ let animCtl = null; // assigned below; panel edits must pause active playback
487
+ const panel = buildControls(els.controls, part.parameters, params, () => {
488
+ animCtl?.notifyUserEdit();
489
+ onParamChange();
490
+ });
409
491
  cleanup.defer(() => panel.dispose());
410
492
  const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
411
493
  updateRelevance(); // initial view
@@ -414,8 +496,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
414
496
  // forceRegen path: forceRegen() forgets cache stamps WITHOUT bumping the params
415
497
  // version, so a repair there would re-stamp everything current off the memoized
416
498
  // 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
499
+ function onParamChange({ debounce = true } = {}) {
500
+ loop.markDirty({ debounce }); // bump the version first: refreshView below must see the parts as stale
419
501
  // Pose-only edits: re-posed + re-stamped current, no job. Skipped entirely
420
502
  // when caching is off — ?debug&nocache is there to measure true uncached
421
503
  // rebuilds, which the fast path would otherwise hide.
@@ -432,11 +514,33 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
432
514
  // path as a slider edit: pose-only changes repair synchronously (no worker
433
515
  // job, no debounce); geometry changes fall through to the regen loop.
434
516
  function setParams(partial) {
517
+ animCtl?.notifyUserEdit();
435
518
  Object.assign(params, partial);
436
519
  panel.syncValues(Object.keys(partial));
437
520
  onParamChange();
438
521
  }
439
522
 
523
+ // Animation-frame param entry point: same change path as setParams, minus
524
+ // the regen debounce. The explicit kick after repair is what makes playback
525
+ // best-effort — a pose-only frame finds nothing missing (repair re-stamped
526
+ // it) and sends no job; a geometry frame dispatches immediately when the
527
+ // worker is idle and is otherwise absorbed until buildDone re-kicks.
528
+ function applyAnimationValues(values) {
529
+ Object.assign(params, values);
530
+ panel.syncValues(Object.keys(values));
531
+ onParamChange({ debounce: false });
532
+ lastAnimApplyVersion = loop.version(); // this version came from playback, not a user edit
533
+ loop.kick();
534
+ }
535
+
536
+ // Animation transport + driver (no-op null when the part declares none).
537
+ animCtl = attachAnimationControls(viewer, part, {
538
+ container: els.viewer,
539
+ applyValues: applyAnimationValues,
540
+ getParamValues: (keys) => Object.fromEntries(keys.map((k) => [k, params[k]])),
541
+ });
542
+ if (animCtl) cleanup.defer(() => animCtl.detach());
543
+
440
544
  // Re-run the active view under the current caching setting, so toggling the
441
545
  // ?debug switch updates the readout for the same design without a param change.
442
546
  function forceRegen() {
@@ -457,7 +561,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
457
561
 
458
562
  const onStepClick = () => {
459
563
  ui.showBusy("exporting STEP");
460
- service.send({ type: "export-step", view: view(), params }, "occt"); // STEP is always OCCT
564
+ service.send({ type: "export-step", view: view(), params }, backendForFormat("step", backendFor));
461
565
  };
462
566
  if (els.exports.step) {
463
567
  els.exports.step.addEventListener("click", onStepClick);
@@ -473,7 +577,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
473
577
  cleanup.defer(() => els.exports.threeMf.removeEventListener("click", on3mfClick));
474
578
  }
475
579
 
476
- // Optional host-page viewer chrome (pause / reframe / theme) + camera persistence.
580
+ // Optional host-page viewer chrome (reframe / theme) + camera persistence.
477
581
  const chrome = attachViewerControls(viewer, els.chrome, { tooltip });
478
582
  cleanup.defer(() => chrome.detach());
479
583
 
@@ -484,12 +588,35 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
484
588
  cleanup.dispose();
485
589
  }
486
590
 
591
+ // Off-loop offscreen thumbnail: builds `viewName` (or the part's resolved
592
+ // default when omitted/unknown) via captureBuild's correlated channel, then
593
+ // renders it in a throwaway scene via viewer.renderMeshPayloads. Never
594
+ // touches the active tab, getView(), or the live scene — best-effort: any
595
+ // failure, including a resolved-null from a worker build failure (4A
596
+ // settles rather than throwing), returns null.
597
+ const captureView = async (viewName, opts = {}) => {
598
+ try {
599
+ const target = (viewName && part.views?.[viewName]) ? viewName : resolveDefaultView(part);
600
+ const subparts = viewSubParts(part, target, params);
601
+ if (!subparts.length) return null;
602
+ const meshes = await captureBuild.request({ subparts, view: target, params, backend: backendFor() });
603
+ if (!meshes || !meshes.length) return null; // 4A resolves null on a worker build failure
604
+ return viewer.renderMeshPayloads(meshes, { size: 640, quality: 0.8, angle: "iso", ...opts });
605
+ } catch {
606
+ return null; // best-effort: a failed thumbnail never breaks the caller
607
+ }
608
+ };
609
+
487
610
  return makeHandle({
488
611
  ready, dispose, viewer, setParams,
489
612
  setHostPane: paneTabs.setHostPane,
613
+ getView: view, // () => tabsCtl.current()
614
+ setView: (name) => tabsCtl.select(name),
615
+ captureView,
490
616
  listExportableParts: () =>
491
617
  exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
492
618
  exportParts: (opts) => exportCtl.exportParts(opts),
619
+ animation: animCtl?.runtime ?? null,
493
620
  });
494
621
  } catch (error) {
495
622
  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";