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.
- package/README.md +31 -10
- package/bin/cli.js +100 -27
- package/docs/AUTHORING-PARTS.md +126 -14
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +48 -7
- package/skills/partforge/SKILL.md +17 -3
- package/src/app-embed-test.js +1 -1
- package/src/app-hinged-box.js +12 -0
- package/src/framework/animation-controls.js +243 -0
- package/src/framework/animation.js +217 -0
- package/src/framework/app.css +32 -0
- package/src/framework/assembly.js +1 -1
- package/src/framework/backend-select.js +25 -0
- package/src/framework/camera-tween.js +58 -0
- package/src/framework/chrome.css +16 -0
- package/src/framework/controls.js +13 -3
- package/src/framework/cutaway-gizmo-scene.js +244 -0
- package/src/framework/cutaway-gizmo.js +80 -243
- package/src/framework/default-view.js +46 -0
- package/src/framework/download.js +7 -2
- package/src/framework/export-controller.js +13 -2
- package/src/framework/geometry/probe.js +3 -22
- package/src/framework/jobs.js +9 -40
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +404 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +93 -18
- 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 +22 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer.js +58 -17
- package/src/hinged-box-worker.js +3 -0
- package/src/index.js +1 -1
- package/src/parts/hinged-box.js +94 -0
- package/src/testing/render.js +19 -8
- package/src/testing.js +15 -8
- package/types/derive.d.ts +14 -0
- package/types/geometry.d.ts +117 -0
- package/types/index.d.ts +240 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +381 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- /package/src/{testing → 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
|
@@ -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 "
|
|
8
|
-
import { parseAssertion } from "
|
|
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
|
package/src/framework/mount.js
CHANGED
|
@@ -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 "./
|
|
14
|
+
import { viewSubParts } from "./part-model.js";
|
|
15
15
|
import { resolveDerived } from "./derive.js";
|
|
16
|
-
import { detectBackend } from "./
|
|
16
|
+
import { detectBackend } from "./backend-select.js";
|
|
17
17
|
import { createDebugOverlay } from "./debug-overlay.js";
|
|
18
18
|
import { createRegenLoop } from "./regen-loop.js";
|
|
19
19
|
import { createPoseFastPath } from "./pose-fast-path.js";
|
|
20
20
|
import { createStatusUi } from "./status-ui.js";
|
|
21
21
|
import { createViewTabs } from "./view-tabs.js";
|
|
22
22
|
import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
|
|
23
|
-
import { createPickRequestClient } from "./pick-request/index.js";
|
|
23
|
+
import { createPickRequestClient, resolvePickServerUrl, PICK_SERVER_DEFAULT_URL } from "./pick-request/index.js";
|
|
24
24
|
import { exportablePartNames, partLabel } from "./export-select.js";
|
|
25
|
-
import { createExportController } from "./export-controller.js";
|
|
25
|
+
import { createExportController, backendForFormat } from "./export-controller.js";
|
|
26
|
+
import { attachAnimationControls } from "./animation-controls.js";
|
|
26
27
|
|
|
27
28
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
28
29
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
29
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane }) {
|
|
30
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation }) {
|
|
30
31
|
return {
|
|
31
32
|
ready, dispose, setParams,
|
|
33
|
+
// Part-declared animation playback (spec 2026-08-02): null when the part
|
|
34
|
+
// declares no animations. { play(name?), pause(), seek(t), stop(), state() }.
|
|
35
|
+
animation: animation ?? null,
|
|
32
36
|
captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
|
|
33
37
|
captureCurrent: (opts) => viewer.captureCurrent(opts),
|
|
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.
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }, "
|
|
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 (
|
|
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 "../
|
|
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";
|