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.
- package/README.md +31 -10
- package/bin/cli.js +138 -27
- package/docs/AUTHORING-PARTS.md +164 -17
- 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 +254 -0
- package/src/framework/animation.js +271 -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/capture-build.js +59 -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 +30 -40
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +441 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-schema.js +22 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +147 -20
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/{testing → framework/oracle}/bvh.js +1 -1
- package/src/{testing → framework/oracle}/measure.js +1 -1
- package/src/{testing → framework/oracle}/min-wall.js +1 -1
- package/src/{testing → framework/oracle}/verify.js +3 -3
- 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 +4 -4
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +35 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer-lighting.js +8 -1
- package/src/framework/viewer.js +139 -20
- package/src/framework/worker.js +5 -1
- 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 +259 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +409 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- /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}/gaps.js +0 -0
- /package/src/{testing → framework/oracle}/mesh.js +0 -0
package/src/framework/mount.js
CHANGED
|
@@ -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 "./
|
|
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 { 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.
|
|
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: () => {
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }, "
|
|
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 (
|
|
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 "../
|
|
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/
|
|
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 "../
|
|
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/
|
|
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 "../
|
|
7
|
-
import { resolveParams } from "../
|
|
8
|
-
import { SUBPART_METRICS, VIEW_METRICS } from "../
|
|
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 "./
|
|
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 = "
|
|
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",
|
|
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
|
|
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";
|