partforge 0.40.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -6
- package/bin/cli.js +103 -27
- package/docs/AUTHORING-PARTS.md +143 -15
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +48 -7
- package/skills/partforge/SKILL.md +17 -3
- package/src/app-embed-test.js +1 -1
- package/src/app-hinged-box.js +12 -0
- package/src/framework/animation-controls.js +243 -0
- package/src/framework/animation.js +217 -0
- package/src/framework/app.css +32 -0
- package/src/framework/assembly.js +1 -1
- package/src/framework/backend-select.js +25 -0
- package/src/framework/camera-tween.js +58 -0
- package/src/framework/chrome.css +16 -0
- package/src/framework/controls.js +13 -3
- package/src/framework/cutaway-gizmo-scene.js +244 -0
- package/src/framework/cutaway-gizmo.js +80 -243
- package/src/framework/default-view.js +46 -0
- package/src/framework/download.js +7 -2
- package/src/framework/export-controller.js +13 -2
- package/src/framework/geometry/probe.js +3 -22
- package/src/framework/jobs.js +23 -42
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +404 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +113 -18
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/framework/oracle/bvh.js +463 -0
- package/src/{testing → framework/oracle}/gaps.js +6 -3
- package/src/{testing → framework/oracle}/measure.js +34 -4
- package/src/framework/oracle/min-wall.js +98 -0
- package/src/{testing → framework/oracle}/verify.js +61 -5
- package/src/framework/param-deps.js +1 -1
- package/src/framework/part-model.js +48 -0
- package/src/framework/pick-request/client.js +11 -3
- package/src/framework/pick-request/endpoint.js +60 -0
- package/src/framework/pick-request/index.js +6 -0
- package/src/framework/pick-request/server.js +222 -34
- package/src/framework/pick-request/token-store.js +31 -0
- package/src/framework/pose-fast-path.js +12 -1
- package/src/framework/pose-probe-core.js +129 -0
- package/src/framework/pose-probe.js +7 -123
- package/src/framework/regen-loop.js +10 -3
- package/src/framework/safe-name.js +26 -0
- package/src/framework/verify-metrics.js +19 -6
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +22 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer.js +126 -16
- package/src/hinged-box-worker.js +3 -0
- package/src/index.js +1 -1
- package/src/parts/hinged-box.js +94 -0
- package/src/testing/render.js +19 -8
- package/src/testing.js +15 -8
- package/types/derive.d.ts +14 -0
- package/types/geometry.d.ts +117 -0
- package/types/index.d.ts +240 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +381 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- package/src/testing/bvh.js +0 -273
- package/src/testing/min-wall.js +0 -38
- /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
- /package/src/{testing → framework/oracle}/cases.js +0 -0
- /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
- /package/src/{testing → framework/oracle}/mesh.js +0 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// unchanged gets its delivered mesh re-posed in the viewer (delta vs. the
|
|
5
5
|
// delivered pose) and is re-stamped current — no worker job. Everything else
|
|
6
6
|
// falls through to the normal regen loop.
|
|
7
|
-
import { viewSubParts } from "./
|
|
7
|
+
import { viewSubParts } from "./part-model.js";
|
|
8
8
|
import { probePoses } from "./pose-probe.js";
|
|
9
9
|
import { poseDelta } from "./geometry/pose.js";
|
|
10
10
|
|
|
@@ -28,6 +28,17 @@ export function createPoseFastPath(part, viewer, cache, { params, getView, getPa
|
|
|
28
28
|
stamps[name] = probeFor().get(name);
|
|
29
29
|
},
|
|
30
30
|
|
|
31
|
+
// Drop a subpart's stamp: the mesh in the viewer is no longer known to
|
|
32
|
+
// correspond to any probed pose. Used when meshes are SHOWN without being
|
|
33
|
+
// recorded — a build delivered stale because animation frames kept bumping
|
|
34
|
+
// the version is displayed best-effort, but its geometry was not built at
|
|
35
|
+
// the live params, so no stamp may describe it. Without this the next edit
|
|
36
|
+
// would re-pose that newer mesh off the PREVIOUS delivery's stamp, i.e.
|
|
37
|
+
// apply a delta measured against geometry that is no longer on screen.
|
|
38
|
+
forget(name) {
|
|
39
|
+
delete stamps[name];
|
|
40
|
+
},
|
|
41
|
+
|
|
31
42
|
// Re-pose every visible stale subpart whose base geometry is unchanged.
|
|
32
43
|
// Returns the NAMES repaired (empty = nothing pose-only to do). Names, not a
|
|
33
44
|
// count: a slider drag repairs the same subpart on every input event, so only
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Geometry-free pose probe: run a subpart's build()+place() against a stub kernel
|
|
2
|
+
// whose token solids carry (a) a content-hash chain built with the shared h() and
|
|
3
|
+
// (b) pending rigid pose steps, mirroring the backends' pose-lazy bookkeeping.
|
|
4
|
+
// The fast path compares probe results ACROSS PARAM CHANGES ONLY — probe hashes
|
|
5
|
+
// are never compared to backend hashes, so they only need to be stable and to
|
|
6
|
+
// fold every geometry-affecting argument.
|
|
7
|
+
//
|
|
8
|
+
// Trust model: any query op (boundingBox/volume/…) during a build marks that
|
|
9
|
+
// subpart untrusted — a query result could feed geometry OR pose, and the probe
|
|
10
|
+
// returns dummies, so neither hash stability nor pose values can be believed.
|
|
11
|
+
// A FUNCTION passed as (or nested inside) an op argument is untrusted for the
|
|
12
|
+
// same reason the OCCT backend refuses to hash function selectors (see `selKey`
|
|
13
|
+
// in occt-backend.js): a closure like `(e) => e.inDirection([0,0,p.z])` has the
|
|
14
|
+
// same source text at every value of `p.z`, so hashing it would hold baseHash
|
|
15
|
+
// stable while the real geometry changed — precisely the false-positive the fast
|
|
16
|
+
// path must never make. Untrusted subparts simply take the normal regen path.
|
|
17
|
+
import { h } from "./geometry/solid-hash.js";
|
|
18
|
+
import { addSugar } from "./geometry/solid-sugar.js";
|
|
19
|
+
import { SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS } from "./geometry/kernel.js";
|
|
20
|
+
import { MAX_PROBE_OPS, ProbeRunawayError } from "./geometry/probe.js";
|
|
21
|
+
|
|
22
|
+
const NAN3 = () => [NaN, NaN, NaN];
|
|
23
|
+
|
|
24
|
+
function makeProbeSession() {
|
|
25
|
+
const state = { count: 0, queried: false, unhashable: false };
|
|
26
|
+
const tick = () => { if (++state.count > MAX_PROBE_OPS) throw new ProbeRunawayError(`pose probe exceeded ${MAX_PROBE_OPS} ops`); };
|
|
27
|
+
|
|
28
|
+
// Queries return dummies AND poison trust (see module comment).
|
|
29
|
+
const QUERY_DUMMIES = {
|
|
30
|
+
boundingBox: () => ({ min: NAN3(), max: NAN3() }), // addSugar derives center/size
|
|
31
|
+
volume: () => NaN,
|
|
32
|
+
genus: () => NaN,
|
|
33
|
+
isEmpty: () => false,
|
|
34
|
+
area: () => NaN,
|
|
35
|
+
toRegions: () => [],
|
|
36
|
+
simple: () => ({ outer: [[NaN, NaN]], holes: [] }),
|
|
37
|
+
toMesh: () => ({ positions: new Float32Array(9), normals: new Float32Array(0), triangles: 1, edges: new Float32Array(0) }),
|
|
38
|
+
toSTL: () => new ArrayBuffer(0),
|
|
39
|
+
toIndexedMesh: () => ({ positions: new Float32Array(9), indices: new Uint32Array(3) }),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Operand tokens fold into a hash key by their own (pose-folded) hash; plain
|
|
43
|
+
// data canonicalizes via h(). Functions can't be hashed at all (see the module
|
|
44
|
+
// comment), so they poison trust AND get a per-call unique key — belt and
|
|
45
|
+
// braces, so the hash can't collide even before the trust check is consulted.
|
|
46
|
+
//
|
|
47
|
+
// The walk mirrors h()'s `canon` exactly (array → elements, other object → own
|
|
48
|
+
// enumerable values), because a function nested inside an options object —
|
|
49
|
+
// `fillet({ r, edges: (e) => … })`, the normal calling convention — is reached
|
|
50
|
+
// by canon, not by the top-level argument check.
|
|
51
|
+
let unhashable = 0;
|
|
52
|
+
const argKey = (a) => {
|
|
53
|
+
if (a && a.__poseToken) return a.__folded();
|
|
54
|
+
if (typeof a === "function") { state.unhashable = true; return `fn#${unhashable++}`; }
|
|
55
|
+
if (Array.isArray(a)) return a.map(argKey);
|
|
56
|
+
if (a && typeof a === "object") return Object.fromEntries(Object.keys(a).map((k) => [k, argKey(a[k])]));
|
|
57
|
+
return a;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function token(hash, pose) {
|
|
61
|
+
const folded = () => (pose.length ? h("posed", hash, pose) : hash);
|
|
62
|
+
const foldOp = (op) => (...args) => { tick(); return token(h(op, folded(), ...args.map(argKey)), []); };
|
|
63
|
+
const t = {
|
|
64
|
+
__poseToken: true,
|
|
65
|
+
__folded: folded,
|
|
66
|
+
_hash: hash,
|
|
67
|
+
_pose: pose,
|
|
68
|
+
// The rigid vocabulary stays out of the hash: recorded as pending steps,
|
|
69
|
+
// exactly like the OCCT backend's pose-lazy wrap. All transform sugar
|
|
70
|
+
// (rotateAbout/along/at/rotateX…) composes onto these via addSugar.
|
|
71
|
+
translate: (v) => { tick(); return token(hash, [...pose, { t: "translate", v }]); },
|
|
72
|
+
rotate: (deg, center, axis) => { tick(); return token(hash, [...pose, { t: "rotate", deg, center, axis }]); },
|
|
73
|
+
clone: () => t, // tokens are immutable — sharing is safe
|
|
74
|
+
// regions() is a data-returning query in disguise: on a real backend the
|
|
75
|
+
// scission ARRAY LENGTH is param-dependent data, so a build branching on
|
|
76
|
+
// `regions().length` could hold baseHash stable while geometry changed.
|
|
77
|
+
// It therefore poisons trust like any other query; the single token is
|
|
78
|
+
// still returned so op chains on regions()[0] don't crash mid-probe.
|
|
79
|
+
regions: () => { tick(); state.queried = true; return [token(h("regions", folded()), [])]; },
|
|
80
|
+
};
|
|
81
|
+
for (const [op, dummy] of Object.entries(QUERY_DUMMIES))
|
|
82
|
+
t[op] = (...a) => { tick(); state.queried = true; return dummy(...a); };
|
|
83
|
+
// Every other contract op folds pose + args into a fresh hash. Generated from
|
|
84
|
+
// the kernel-contract lists so new ops can never silently drift out of the probe.
|
|
85
|
+
for (const op of [...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_OPS, ...OCCT_ONLY_OPS])
|
|
86
|
+
t[op] ??= foldOp(op);
|
|
87
|
+
return addSugar(t);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Kernel: catch-all factory — any op makes a fresh token hashed from its args.
|
|
91
|
+
const kernelQueries = {
|
|
92
|
+
toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
|
|
93
|
+
cleanup: () => {}, beginSubPart: () => {}, endSubPart: () => {},
|
|
94
|
+
cacheStats: () => ({ hits: 0, misses: 0 }), resetCacheStats: () => {},
|
|
95
|
+
};
|
|
96
|
+
const ignore = (key) => typeof key !== "string" || key === "then" || key === "toJSON" || key[0] === "_";
|
|
97
|
+
const kernel = new Proxy({}, {
|
|
98
|
+
get(_t, key) {
|
|
99
|
+
if (ignore(key)) return undefined;
|
|
100
|
+
if (key in kernelQueries) return kernelQueries[key];
|
|
101
|
+
return (...args) => { tick(); return token(h(key, ...args.map(argKey)), []); };
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return { kernel, state };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const finiteVec = (v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
|
|
109
|
+
const stepsFinite = (steps) => steps.every((st) =>
|
|
110
|
+
st.t === "translate"
|
|
111
|
+
? finiteVec(st.v)
|
|
112
|
+
: Number.isFinite(st.deg) && finiteVec(st.center) && finiteVec(st.axis));
|
|
113
|
+
|
|
114
|
+
// Probe ONE sub-part's build+place at explicit params/purpose. The shared
|
|
115
|
+
// primitive under probePoses (pose-probe.js) and the lint place/animation
|
|
116
|
+
// rules — kept free of jobs.js imports so the lint import closure stays pure
|
|
117
|
+
// (test/lint-purity.test.js). Never throws; a failing/queried/weird sub-part
|
|
118
|
+
// yields { trusted: false }.
|
|
119
|
+
export function probeSubPartPose(sp, { view, purpose = "display", p, d }) {
|
|
120
|
+
try {
|
|
121
|
+
const { kernel, state } = makeProbeSession(); // fresh op budget + trust per subpart
|
|
122
|
+
let s = sp.build(kernel, p, d);
|
|
123
|
+
if (sp.place) s = sp.place(s, { view, purpose, p, d });
|
|
124
|
+
const ok = s && s.__poseToken && !state.queried && !state.unhashable && stepsFinite(s._pose);
|
|
125
|
+
return ok ? { trusted: true, baseHash: s._hash, pose: s._pose } : { trusted: false };
|
|
126
|
+
} catch {
|
|
127
|
+
return { trusted: false };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -1,116 +1,9 @@
|
|
|
1
|
-
// Geometry-free pose probe
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
// Trust model: any query op (boundingBox/volume/…) during a build marks that
|
|
9
|
-
// subpart untrusted — a query result could feed geometry OR pose, and the probe
|
|
10
|
-
// returns dummies, so neither hash stability nor pose values can be believed.
|
|
11
|
-
// A FUNCTION passed as (or nested inside) an op argument is untrusted for the
|
|
12
|
-
// same reason the OCCT backend refuses to hash function selectors (see `selKey`
|
|
13
|
-
// in occt-backend.js): a closure like `(e) => e.inDirection([0,0,p.z])` has the
|
|
14
|
-
// same source text at every value of `p.z`, so hashing it would hold baseHash
|
|
15
|
-
// stable while the real geometry changed — precisely the false-positive the fast
|
|
16
|
-
// path must never make. Untrusted subparts simply take the normal regen path.
|
|
17
|
-
import { h } from "./geometry/solid-hash.js";
|
|
18
|
-
import { addSugar } from "./geometry/solid-sugar.js";
|
|
19
|
-
import { SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS } from "./geometry/kernel.js";
|
|
20
|
-
import { MAX_PROBE_OPS, ProbeRunawayError } from "./geometry/probe.js";
|
|
21
|
-
import { viewSubParts, resolveParams } from "./jobs.js";
|
|
22
|
-
|
|
23
|
-
const NAN3 = () => [NaN, NaN, NaN];
|
|
24
|
-
|
|
25
|
-
function makeProbeSession() {
|
|
26
|
-
const state = { count: 0, queried: false, unhashable: false };
|
|
27
|
-
const tick = () => { if (++state.count > MAX_PROBE_OPS) throw new ProbeRunawayError(`pose probe exceeded ${MAX_PROBE_OPS} ops`); };
|
|
28
|
-
|
|
29
|
-
// Queries return dummies AND poison trust (see module comment).
|
|
30
|
-
const QUERY_DUMMIES = {
|
|
31
|
-
boundingBox: () => ({ min: NAN3(), max: NAN3() }), // addSugar derives center/size
|
|
32
|
-
volume: () => NaN,
|
|
33
|
-
genus: () => NaN,
|
|
34
|
-
isEmpty: () => false,
|
|
35
|
-
area: () => NaN,
|
|
36
|
-
toRegions: () => [],
|
|
37
|
-
simple: () => ({ outer: [[NaN, NaN]], holes: [] }),
|
|
38
|
-
toMesh: () => ({ positions: new Float32Array(9), normals: new Float32Array(0), triangles: 1, edges: new Float32Array(0) }),
|
|
39
|
-
toSTL: () => new ArrayBuffer(0),
|
|
40
|
-
toIndexedMesh: () => ({ positions: new Float32Array(9), indices: new Uint32Array(3) }),
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
// Operand tokens fold into a hash key by their own (pose-folded) hash; plain
|
|
44
|
-
// data canonicalizes via h(). Functions can't be hashed at all (see the module
|
|
45
|
-
// comment), so they poison trust AND get a per-call unique key — belt and
|
|
46
|
-
// braces, so the hash can't collide even before the trust check is consulted.
|
|
47
|
-
//
|
|
48
|
-
// The walk mirrors h()'s `canon` exactly (array → elements, other object → own
|
|
49
|
-
// enumerable values), because a function nested inside an options object —
|
|
50
|
-
// `fillet({ r, edges: (e) => … })`, the normal calling convention — is reached
|
|
51
|
-
// by canon, not by the top-level argument check.
|
|
52
|
-
let unhashable = 0;
|
|
53
|
-
const argKey = (a) => {
|
|
54
|
-
if (a && a.__poseToken) return a.__folded();
|
|
55
|
-
if (typeof a === "function") { state.unhashable = true; return `fn#${unhashable++}`; }
|
|
56
|
-
if (Array.isArray(a)) return a.map(argKey);
|
|
57
|
-
if (a && typeof a === "object") return Object.fromEntries(Object.keys(a).map((k) => [k, argKey(a[k])]));
|
|
58
|
-
return a;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
function token(hash, pose) {
|
|
62
|
-
const folded = () => (pose.length ? h("posed", hash, pose) : hash);
|
|
63
|
-
const foldOp = (op) => (...args) => { tick(); return token(h(op, folded(), ...args.map(argKey)), []); };
|
|
64
|
-
const t = {
|
|
65
|
-
__poseToken: true,
|
|
66
|
-
__folded: folded,
|
|
67
|
-
_hash: hash,
|
|
68
|
-
_pose: pose,
|
|
69
|
-
// The rigid vocabulary stays out of the hash: recorded as pending steps,
|
|
70
|
-
// exactly like the OCCT backend's pose-lazy wrap. All transform sugar
|
|
71
|
-
// (rotateAbout/along/at/rotateX…) composes onto these via addSugar.
|
|
72
|
-
translate: (v) => { tick(); return token(hash, [...pose, { t: "translate", v }]); },
|
|
73
|
-
rotate: (deg, center, axis) => { tick(); return token(hash, [...pose, { t: "rotate", deg, center, axis }]); },
|
|
74
|
-
clone: () => t, // tokens are immutable — sharing is safe
|
|
75
|
-
// regions() is a data-returning query in disguise: on a real backend the
|
|
76
|
-
// scission ARRAY LENGTH is param-dependent data, so a build branching on
|
|
77
|
-
// `regions().length` could hold baseHash stable while geometry changed.
|
|
78
|
-
// It therefore poisons trust like any other query; the single token is
|
|
79
|
-
// still returned so op chains on regions()[0] don't crash mid-probe.
|
|
80
|
-
regions: () => { tick(); state.queried = true; return [token(h("regions", folded()), [])]; },
|
|
81
|
-
};
|
|
82
|
-
for (const [op, dummy] of Object.entries(QUERY_DUMMIES))
|
|
83
|
-
t[op] = (...a) => { tick(); state.queried = true; return dummy(...a); };
|
|
84
|
-
// Every other contract op folds pose + args into a fresh hash. Generated from
|
|
85
|
-
// the kernel-contract lists so new ops can never silently drift out of the probe.
|
|
86
|
-
for (const op of [...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_OPS, ...OCCT_ONLY_OPS])
|
|
87
|
-
t[op] ??= foldOp(op);
|
|
88
|
-
return addSugar(t);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Kernel: catch-all factory — any op makes a fresh token hashed from its args.
|
|
92
|
-
const kernelQueries = {
|
|
93
|
-
toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
|
|
94
|
-
cleanup: () => {}, beginSubPart: () => {}, endSubPart: () => {},
|
|
95
|
-
cacheStats: () => ({ hits: 0, misses: 0 }), resetCacheStats: () => {},
|
|
96
|
-
};
|
|
97
|
-
const ignore = (key) => typeof key !== "string" || key === "then" || key === "toJSON" || key[0] === "_";
|
|
98
|
-
const kernel = new Proxy({}, {
|
|
99
|
-
get(_t, key) {
|
|
100
|
-
if (ignore(key)) return undefined;
|
|
101
|
-
if (key in kernelQueries) return kernelQueries[key];
|
|
102
|
-
return (...args) => { tick(); return token(h(key, ...args.map(argKey)), []); };
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
return { kernel, state };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const finiteVec = (v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
|
|
110
|
-
const stepsFinite = (steps) => steps.every((st) =>
|
|
111
|
-
st.t === "translate"
|
|
112
|
-
? finiteVec(st.v)
|
|
113
|
-
: Number.isFinite(st.deg) && finiteVec(st.center) && finiteVec(st.axis));
|
|
1
|
+
// Geometry-free pose probe over a view — see pose-probe-core.js for the probe
|
|
2
|
+
// session itself and the trust model. This wrapper resolves params/derive and
|
|
3
|
+
// walks the view's sub-parts; it stays separate so lint can import the core
|
|
4
|
+
// without dragging in the part-model/jobs layer (purity).
|
|
5
|
+
import { probeSubPartPose } from "./pose-probe-core.js";
|
|
6
|
+
import { viewSubParts, resolveParams } from "./part-model.js";
|
|
114
7
|
|
|
115
8
|
// Probe every subpart the view shows. Never throws; a failing/queried/weird
|
|
116
9
|
// subpart yields { trusted: false } and the others still probe.
|
|
@@ -124,16 +17,7 @@ export function probePoses(part, view, params) {
|
|
|
124
17
|
}
|
|
125
18
|
const { p, d } = resolved;
|
|
126
19
|
for (const name of viewSubParts(part, view, params)) {
|
|
127
|
-
|
|
128
|
-
const { kernel, state } = makeProbeSession(); // fresh op budget + trust per subpart
|
|
129
|
-
const sp = part.parts[name];
|
|
130
|
-
let s = sp.build(kernel, p, d);
|
|
131
|
-
if (sp.place) s = sp.place(s, { view, purpose: "display", p, d });
|
|
132
|
-
const ok = s && s.__poseToken && !state.queried && !state.unhashable && stepsFinite(s._pose);
|
|
133
|
-
out.set(name, ok ? { baseHash: s._hash, pose: s._pose, trusted: true } : { trusted: false });
|
|
134
|
-
} catch {
|
|
135
|
-
out.set(name, { trusted: false });
|
|
136
|
-
}
|
|
20
|
+
out.set(name, probeSubPartPose(part.parts[name], { view, purpose: "display", p, d }));
|
|
137
21
|
}
|
|
138
22
|
return out;
|
|
139
23
|
}
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// - markDirty() bumps the params version and debounces a kick, so dragging a
|
|
10
10
|
// slider queues one build per pause, not one per pixel;
|
|
11
11
|
// - a build that a mid-flight edit outdated is reported stale by buildDone()
|
|
12
|
-
// (return false → the caller discards the meshes and kicks a rebuild)
|
|
12
|
+
// (return false → the caller discards the meshes and kicks a rebuild);
|
|
13
|
+
// - markDirty({debounce:false}) bumps without arming the timer (the animation
|
|
14
|
+
// fast-apply path kicks explicitly).
|
|
13
15
|
export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
|
|
14
16
|
let kernelReady = false;
|
|
15
17
|
let generating = false;
|
|
@@ -30,10 +32,15 @@ export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
|
|
|
30
32
|
return {
|
|
31
33
|
kick,
|
|
32
34
|
ready() { if (disposed) return; kernelReady = true; kick(); },
|
|
33
|
-
|
|
35
|
+
// `debounce: false` is the animation driver's mode: the version still bumps
|
|
36
|
+
// (stale in-flight builds are still detected), but no timer is armed — the
|
|
37
|
+
// driver kicks explicitly after the pose fast path has repaired, so a
|
|
38
|
+
// pose-only frame sends no job and a geometry frame dispatches immediately
|
|
39
|
+
// whenever the worker is idle (best-effort at worker cadence, clock-free).
|
|
40
|
+
markDirty({ debounce = true } = {}) {
|
|
34
41
|
paramsVersion++;
|
|
35
42
|
clearTimeout(timer);
|
|
36
|
-
timer = setTimeout(kick, debounceMs);
|
|
43
|
+
if (debounce) timer = setTimeout(kick, debounceMs);
|
|
37
44
|
},
|
|
38
45
|
// The build finished (meshes / needs-occt / error). Returns whether its result
|
|
39
46
|
// is still current; the caller applies the meshes only on true, then kicks.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// One hardened slug for every part-derived string that ends up as a filename, a
|
|
2
|
+
// path segment, or a zip entry name.
|
|
3
|
+
//
|
|
4
|
+
// A PartDefinition is DATA, not trusted developer source: downstream hosts
|
|
5
|
+
// (partforge-cloud) run LLM-generated and user-supplied parts, so `meta.title`,
|
|
6
|
+
// `views[k].label` and `parts[x].export.name` are untrusted input. A title of
|
|
7
|
+
// "../../.ssh/authorized" must not steer a render write out of its output
|
|
8
|
+
// directory, and a zip entry must not carry a separator that a naive extractor
|
|
9
|
+
// would honour (zip-slip).
|
|
10
|
+
//
|
|
11
|
+
// Deliberately DOM-free and free of `node:` builtins: the geometry worker, the
|
|
12
|
+
// browser shell and the headless testing tree all import this.
|
|
13
|
+
|
|
14
|
+
// Lowercase, reduce to [a-z0-9._-], collapse runs, and refuse to start with a
|
|
15
|
+
// dot or dash — that is what kills ".." and dotfiles at the same time. Anything
|
|
16
|
+
// that sanitizes away to nothing (a title that is entirely CJK or emoji, or the
|
|
17
|
+
// empty string) yields `fallback`, so callers never build a name-less path.
|
|
18
|
+
export function safeName(s, fallback = "part") {
|
|
19
|
+
const out = String(s ?? "")
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
22
|
+
.replace(/-{2,}/g, "-")
|
|
23
|
+
.replace(/^[.-]+/, "")
|
|
24
|
+
.replace(/[.-]+$/, "");
|
|
25
|
+
return out || fallback;
|
|
26
|
+
}
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
// is a hard gate or a warning, and the diagnostics attached to a non-pass check:
|
|
3
3
|
// `hint` (required — the report contract promises one on every fail/warn),
|
|
4
4
|
// `pattern` (optional stable ERROR-PATTERNS.md#<id>), `locate` (optional
|
|
5
|
-
// [x,y,z] source)
|
|
5
|
+
// [x,y,z] source), `note` (optional caveat about HOW the value was measured,
|
|
6
|
+
// attached whatever the status — a passing-but-sampled reading is exactly the
|
|
7
|
+
// case a reader needs told about). `manifoldOnly` facts are null on OCCT parts.
|
|
6
8
|
//
|
|
7
|
-
// This lives
|
|
9
|
+
// This lives one level above framework/oracle/ deliberately: the set of legal
|
|
8
10
|
// `verify.expect` metrics is part of the PartDefinition CONTRACT, which both the
|
|
9
|
-
// verify runner (
|
|
10
|
-
// it here lets the linter import the vocabulary without reaching
|
|
11
|
-
//
|
|
11
|
+
// verify runner (oracle/verify.js) and the linter (partforge/lint) must agree on.
|
|
12
|
+
// Keeping it here lets the linter import the vocabulary without reaching
|
|
13
|
+
// measure.js, which pulls in the geometry kernels. Must stay import-free.
|
|
12
14
|
export const SUBPART_METRICS = {
|
|
13
15
|
holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
|
|
14
16
|
hint: "genus is wrong — an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
|
|
@@ -32,7 +34,18 @@ export const SUBPART_METRICS = {
|
|
|
32
34
|
minWall: { kind: "warn", extract: (s) => s.minWall,
|
|
33
35
|
hint: "thinnest wall is at the reported location — increase the governing wall/thickness parameter or reduce the intersecting feature's depth",
|
|
34
36
|
pattern: "minwall-sliver-triangles",
|
|
35
|
-
locate: (s) => s.minWallAt
|
|
37
|
+
locate: (s) => s.minWallAt,
|
|
38
|
+
// Two sampled outcomes, and the second is the one that most needs saying: a
|
|
39
|
+
// sampled run that found NO wall reports minWall null, which without this note
|
|
40
|
+
// is indistinguishable from a part nobody measured. `sampled` counts triangles
|
|
41
|
+
// the walk selected, not rays cast — degenerate triangles are skipped.
|
|
42
|
+
note: (s) => {
|
|
43
|
+
if (!s.minWallSampled || !s.minWallSamples) return null;
|
|
44
|
+
const { sampled, total } = s.minWallSamples;
|
|
45
|
+
return s.minWall == null
|
|
46
|
+
? `no reading from the ${sampled} of ${total} triangles sampled — not a clean bill of health; a thin spot may exist between samples`
|
|
47
|
+
: `sampled ${sampled} of ${total} triangles — an upper bound; a thinner spot may exist between samples`;
|
|
48
|
+
} },
|
|
36
49
|
};
|
|
37
50
|
export const VIEW_METRICS = {
|
|
38
51
|
bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
|
|
@@ -1,36 +1,35 @@
|
|
|
1
1
|
// Persist a little viewer UI state across browser reloads (notably Vite dev
|
|
2
|
-
// auto-refresh)
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// auto-refresh). `camera` and `theme` live in localStorage under global
|
|
3
|
+
// keys — they're viewer preferences, not part state. The active view is different:
|
|
4
|
+
// it's scoped to one part and stored in sessionStorage, so a hot reload keeps your
|
|
5
|
+
// tab but the name can't bleed into another part that happens to share a view name,
|
|
6
|
+
// and a fresh session opens on the part's own default (see default-view.js).
|
|
7
|
+
// Reads/writes are guarded: if storage is unavailable (private mode, disabled) or a
|
|
8
|
+
// value is corrupt, reads return the documented default and writes are no-ops —
|
|
9
|
+
// persistence never throws.
|
|
6
10
|
|
|
7
11
|
const KEY = {
|
|
8
|
-
rotating: "partforge:rotating",
|
|
9
12
|
camera: "partforge:camera",
|
|
10
|
-
view: "partforge:view",
|
|
11
13
|
theme: "partforge:theme",
|
|
12
14
|
};
|
|
13
15
|
|
|
16
|
+
const viewKey = (partKey) => `partforge:view:${partKey}`;
|
|
17
|
+
|
|
14
18
|
function read(key) {
|
|
15
19
|
try { return localStorage.getItem(key); } catch { return null; }
|
|
16
20
|
}
|
|
17
21
|
function write(key, value) {
|
|
18
22
|
try { localStorage.setItem(key, value); } catch { /* storage unavailable — no-op */ }
|
|
19
23
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export function loadRotating() {
|
|
24
|
-
const raw = read(KEY.rotating);
|
|
25
|
-
if (raw === "false") return false;
|
|
26
|
-
if (raw === "true") return true;
|
|
27
|
-
return true; // default: auto-rotate on (matches the viewer's default)
|
|
24
|
+
function readSession(key) {
|
|
25
|
+
try { return sessionStorage.getItem(key); } catch { return null; }
|
|
28
26
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
write(KEY.rotating, on ? "true" : "false");
|
|
27
|
+
function writeSession(key, value) {
|
|
28
|
+
try { sessionStorage.setItem(key, value); } catch { /* storage unavailable — no-op */ }
|
|
32
29
|
}
|
|
33
30
|
|
|
31
|
+
const isVec3 = (v) => Array.isArray(v) && v.length === 3 && v.every((n) => Number.isFinite(n));
|
|
32
|
+
|
|
34
33
|
export function loadCamera() {
|
|
35
34
|
const raw = read(KEY.camera);
|
|
36
35
|
if (!raw) return null;
|
|
@@ -56,10 +55,15 @@ export function saveTheme(mode) {
|
|
|
56
55
|
if (mode === "light" || mode === "dark") write(KEY.theme, mode);
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
// `partKey` identifies the part — createViewTabs passes `meta.title`. Without one
|
|
59
|
+
// there is nothing safe to key on, so both calls no-op rather than falling back to a
|
|
60
|
+
// shared key (the cross-part bleed this scoping exists to remove).
|
|
61
|
+
export function loadView(partKey) {
|
|
62
|
+
if (typeof partKey !== "string" || !partKey) return null;
|
|
63
|
+
return readSession(viewKey(partKey)); // raw string or null; caller validates against available tabs
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
export function saveView(name) {
|
|
64
|
-
if (typeof
|
|
66
|
+
export function saveView(partKey, name) {
|
|
67
|
+
if (typeof partKey !== "string" || !partKey) return;
|
|
68
|
+
if (typeof name === "string" && name) writeSession(viewKey(partKey), name);
|
|
65
69
|
}
|
|
@@ -1,22 +1,37 @@
|
|
|
1
|
+
import { resolveDefaultView } from "./default-view.js";
|
|
1
2
|
import { loadView, saveView } from "./view-state.js";
|
|
2
3
|
|
|
3
4
|
// The view-tab segmented control. When the part declares `views`, the buttons are
|
|
4
5
|
// generated from it (part.views is the single source of truth — host pages leave
|
|
5
6
|
// the #part div empty); a part without `views` keeps whatever buttons the page
|
|
6
|
-
// hand-wrote.
|
|
7
|
+
// hand-wrote. Which tab opens is resolveDefaultView's call, not key order. The
|
|
8
|
+
// active tab then persists per part for the rest of the browser session, so a
|
|
9
|
+
// Vite dev reload doesn't throw you back mid-edit.
|
|
7
10
|
export function createViewTabs(el, part, { onChange }) {
|
|
8
11
|
const generated = !!(el && part.views);
|
|
12
|
+
const partKey = part?.meta?.title ?? "";
|
|
13
|
+
const resolved = resolveDefaultView(part);
|
|
9
14
|
if (generated) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
// Built node-by-node with textContent/dataset rather than an innerHTML
|
|
16
|
+
// template — view keys and labels come from the part, which is untrusted
|
|
17
|
+
// data (hosts run LLM-generated and user-supplied parts), so a label of
|
|
18
|
+
// `<img src=x onerror=...>` must land as text, not as markup.
|
|
19
|
+
el.replaceChildren(...Object.entries(part.views).map(([key, v]) => {
|
|
20
|
+
const btn = document.createElement("button");
|
|
21
|
+
btn.dataset.part = key;
|
|
22
|
+
btn.textContent = v?.label ?? key;
|
|
23
|
+
if (key === resolved) btn.classList.add("on");
|
|
24
|
+
return btn;
|
|
25
|
+
}));
|
|
13
26
|
}
|
|
14
27
|
|
|
15
28
|
const setActive = (btn) => { for (const b of el.children) b.classList.toggle("on", b === btn); };
|
|
16
29
|
|
|
17
|
-
// Initial view: the saved one if it still matches a tab, else the active
|
|
30
|
+
// Initial view: the session-saved one if it still matches a tab, else the active
|
|
31
|
+
// button — the resolved default for a generated bar, or whatever the page's own
|
|
32
|
+
// markup marked `on` for a hand-written one.
|
|
18
33
|
const defaultView = el.querySelector("button.on")?.dataset.part ?? el.querySelector("button")?.dataset.part;
|
|
19
|
-
const saved = loadView();
|
|
34
|
+
const saved = loadView(partKey);
|
|
20
35
|
const savedBtn = saved ? [...el.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === saved) : null;
|
|
21
36
|
let view = savedBtn ? saved : defaultView;
|
|
22
37
|
if (savedBtn) setActive(savedBtn);
|
|
@@ -25,7 +40,7 @@ export function createViewTabs(el, part, { onChange }) {
|
|
|
25
40
|
const btn = e.target.closest("button[data-part]");
|
|
26
41
|
if (!btn) return;
|
|
27
42
|
view = btn.dataset.part;
|
|
28
|
-
saveView(view);
|
|
43
|
+
saveView(partKey, view);
|
|
29
44
|
setActive(btn);
|
|
30
45
|
onChange(view);
|
|
31
46
|
};
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { saveCamera, loadTheme, saveTheme } from "./view-state.js";
|
|
2
2
|
import { attachButtonTooltips } from "./tooltip.js";
|
|
3
3
|
|
|
4
|
-
// Wire the optional viewer-chrome buttons (
|
|
4
|
+
// Wire the optional viewer-chrome buttons (reframe / theme) to the viewer,
|
|
5
5
|
// plus persist the camera pose. Element refs in (mount resolves defaults); each
|
|
6
6
|
// button is optional — pass nothing and its behavior is simply absent. Returns
|
|
7
7
|
// { detach } removing every listener this attached.
|
|
8
8
|
export function attachViewerControls(
|
|
9
9
|
viewer,
|
|
10
|
-
{
|
|
10
|
+
{ reframe: reframeBtn, theme: themeBtn } = {},
|
|
11
11
|
{ tooltip } = {},
|
|
12
12
|
) {
|
|
13
13
|
const tooltipBinding = tooltip
|
|
14
|
-
? attachButtonTooltips(tooltip, [
|
|
14
|
+
? attachButtonTooltips(tooltip, [reframeBtn, themeBtn].map((element) => ({ element })))
|
|
15
15
|
: null;
|
|
16
16
|
|
|
17
17
|
// Theme: toggle the page chrome (CSS vars keyed off <html data-theme>) and the
|
|
@@ -34,26 +34,6 @@ export function attachViewerControls(
|
|
|
34
34
|
const onThemeClick = () => applyTheme(theme === "light" ? "dark" : "light");
|
|
35
35
|
themeBtn?.addEventListener("click", onThemeClick);
|
|
36
36
|
|
|
37
|
-
// Pause/resume the idle auto-rotation.
|
|
38
|
-
let rotating = loadRotating();
|
|
39
|
-
viewer.setAutoRotate(rotating);
|
|
40
|
-
const syncPause = () => {
|
|
41
|
-
if (!pauseBtn) return;
|
|
42
|
-
pauseBtn.textContent = rotating ? "⏸" : "▶";
|
|
43
|
-
const label = rotating ? "Pause rotation" : "Resume rotation";
|
|
44
|
-
pauseBtn.setAttribute("aria-label", label);
|
|
45
|
-
if (!tooltip) pauseBtn.title = label;
|
|
46
|
-
tooltipBinding?.sync();
|
|
47
|
-
};
|
|
48
|
-
syncPause();
|
|
49
|
-
const onPauseClick = () => {
|
|
50
|
-
rotating = !rotating;
|
|
51
|
-
viewer.setAutoRotate(rotating);
|
|
52
|
-
syncPause();
|
|
53
|
-
saveRotating(rotating);
|
|
54
|
-
};
|
|
55
|
-
pauseBtn?.addEventListener("click", onPauseClick);
|
|
56
|
-
|
|
57
37
|
// Re-fit the camera to the current view.
|
|
58
38
|
if (reframeBtn) {
|
|
59
39
|
reframeBtn.setAttribute("aria-label", "Re-frame model");
|
|
@@ -63,7 +43,7 @@ export function attachViewerControls(
|
|
|
63
43
|
reframeBtn?.addEventListener("click", onReframeClick);
|
|
64
44
|
|
|
65
45
|
// Persist the camera when the user finishes an orbit/zoom, and right before a
|
|
66
|
-
// reload (captures the latest pose
|
|
46
|
+
// reload (captures the latest pose).
|
|
67
47
|
viewer.onCameraEnd(() => saveCamera(viewer.getCameraState()));
|
|
68
48
|
const onPageHide = () => saveCamera(viewer.getCameraState());
|
|
69
49
|
window.addEventListener("pagehide", onPageHide);
|
|
@@ -71,7 +51,6 @@ export function attachViewerControls(
|
|
|
71
51
|
return {
|
|
72
52
|
detach: () => {
|
|
73
53
|
themeBtn?.removeEventListener("click", onThemeClick);
|
|
74
|
-
pauseBtn?.removeEventListener("click", onPauseClick);
|
|
75
54
|
reframeBtn?.removeEventListener("click", onReframeClick);
|
|
76
55
|
window.removeEventListener("pagehide", onPageHide);
|
|
77
56
|
tooltipBinding?.detach();
|