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
|
@@ -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
|
+
}
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// attached whatever the status — a passing-but-sampled reading is exactly the
|
|
7
7
|
// case a reader needs told about). `manifoldOnly` facts are null on OCCT parts.
|
|
8
8
|
//
|
|
9
|
-
// This lives
|
|
9
|
+
// This lives one level above framework/oracle/ deliberately: the set of legal
|
|
10
10
|
// `verify.expect` metrics is part of the PartDefinition CONTRACT, which both the
|
|
11
|
-
// verify runner (
|
|
12
|
-
// it here lets the linter import the vocabulary without reaching
|
|
13
|
-
//
|
|
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.
|
|
14
14
|
export const SUBPART_METRICS = {
|
|
15
15
|
holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
|
|
16
16
|
hint: "genus is wrong — an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
|
|
@@ -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();
|
package/src/framework/viewer.js
CHANGED
|
@@ -5,6 +5,7 @@ import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
|
|
|
5
5
|
import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
|
|
6
6
|
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
|
7
7
|
import { createCutaway } from "./cutaway.js";
|
|
8
|
+
import { createCameraTween } from "./camera-tween.js";
|
|
8
9
|
import { addViewerLights, captureLightPoses, createCaptureLights } from "./viewer-lighting.js";
|
|
9
10
|
import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
|
|
10
11
|
|
|
@@ -113,8 +114,6 @@ export function createViewer(container, part) {
|
|
|
113
114
|
|
|
114
115
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
115
116
|
controls.enableDamping = true;
|
|
116
|
-
controls.autoRotate = true;
|
|
117
|
-
controls.autoRotateSpeed = 1.6;
|
|
118
117
|
|
|
119
118
|
// --- lights + grid --------------------------------------------------------
|
|
120
119
|
const liveLights = addViewerLights(scene);
|
|
@@ -214,6 +213,39 @@ export function createViewer(container, part) {
|
|
|
214
213
|
cutaway.setSubpart(name, subMesh[name], subLines[name]);
|
|
215
214
|
}
|
|
216
215
|
|
|
216
|
+
// --- animation hooks --------------------------------------------------------
|
|
217
|
+
// Frame listeners get dt (seconds, clamped so a background-tab return doesn't
|
|
218
|
+
// fast-forward playback) inside the render loop — so a parked viewer
|
|
219
|
+
// (setActive(false)) automatically halts playback too: no loop, no ticks.
|
|
220
|
+
const frameListeners = new Set();
|
|
221
|
+
function onFrame(cb) { frameListeners.add(cb); return () => frameListeners.delete(cb); }
|
|
222
|
+
|
|
223
|
+
const camTween = createCameraTween();
|
|
224
|
+
// Tween the orbit camera to a canonical angle, framed on what's visible now.
|
|
225
|
+
// Presentational only; a caller passing duration 0 gets a jump cut.
|
|
226
|
+
function tweenCameraTo(viewName, { duration = 0.6, onComplete } = {}) {
|
|
227
|
+
const box = getVisibleWorldBounds();
|
|
228
|
+
if (!box || box.isEmpty()) { onComplete?.(); return; }
|
|
229
|
+
const center = box.getCenter(new THREE.Vector3()).toArray();
|
|
230
|
+
const size = box.getSize(new THREE.Vector3());
|
|
231
|
+
// radius = full max extent (not half), matching frameTo's framing distance so a
|
|
232
|
+
// live camera cue doesn't land twice as close as the reframe button and crop the part.
|
|
233
|
+
const pose = cameraPoseForView(viewName, { center, radius: Math.max(size.x, size.y, size.z) || 12 });
|
|
234
|
+
camTween.start(
|
|
235
|
+
{ position: camera.position.toArray(), target: controls.target.toArray() },
|
|
236
|
+
{ position: pose.position, target: pose.target },
|
|
237
|
+
{ duration, onComplete },
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
const cancelCameraTween = () => camTween.cancel();
|
|
241
|
+
|
|
242
|
+
// User grabbing the orbit cancels any cue tween (the user owns the camera) and
|
|
243
|
+
// tells subscribers (the animation driver disarms remaining cues).
|
|
244
|
+
const cameraStartListeners = new Set();
|
|
245
|
+
const onControlsStart = () => { camTween.cancel(); for (const cb of [...cameraStartListeners]) cb(); };
|
|
246
|
+
controls.addEventListener("start", onControlsStart);
|
|
247
|
+
function onCameraStart(cb) { cameraStartListeners.add(cb); return () => cameraStartListeners.delete(cb); }
|
|
248
|
+
|
|
217
249
|
// Smooth shading within CREASE_ANGLE of a shared edge, hard edge past it — so the
|
|
218
250
|
// round body and helical groove read smooth while bore rims, drum faces, and
|
|
219
251
|
// groove walls stay crisp. Lower = more hard edges; raise toward Math.PI/3 for
|
|
@@ -330,18 +362,8 @@ export function createViewer(container, part) {
|
|
|
330
362
|
frameTo(names.filter((n) => subMesh[n].visible && subCache[n]));
|
|
331
363
|
}
|
|
332
364
|
|
|
333
|
-
let autoRotateRequested = true;
|
|
334
|
-
function syncAutoRotate() {
|
|
335
|
-
controls.autoRotate = autoRotateRequested && !cutaway.isEnabled;
|
|
336
|
-
}
|
|
337
|
-
function setAutoRotate(on) {
|
|
338
|
-
autoRotateRequested = !!on;
|
|
339
|
-
syncAutoRotate();
|
|
340
|
-
}
|
|
341
365
|
function setCutawayEnabled(on) {
|
|
342
|
-
|
|
343
|
-
syncAutoRotate();
|
|
344
|
-
return changed;
|
|
366
|
+
return cutaway.setEnabled(on);
|
|
345
367
|
}
|
|
346
368
|
|
|
347
369
|
// Swap the scene background, grid, and edge-line colors for the given theme.
|
|
@@ -489,8 +511,19 @@ export function createViewer(container, part) {
|
|
|
489
511
|
}
|
|
490
512
|
|
|
491
513
|
// --- render loop ----------------------------------------------------------
|
|
492
|
-
|
|
514
|
+
// The tween is applied after controls.update() so the cue wins the frame, and
|
|
515
|
+
// the frame listeners run before render so a playback frame draws its own pose.
|
|
516
|
+
let lastFrameTime = null;
|
|
517
|
+
function renderFrame(time) {
|
|
518
|
+
const dt = lastFrameTime == null ? 0 : Math.min(0.1, (time - lastFrameTime) / 1000);
|
|
519
|
+
lastFrameTime = time;
|
|
493
520
|
controls.update();
|
|
521
|
+
const tw = camTween.update(dt);
|
|
522
|
+
if (tw) {
|
|
523
|
+
camera.position.fromArray(tw.position);
|
|
524
|
+
controls.target.fromArray(tw.target);
|
|
525
|
+
}
|
|
526
|
+
for (const cb of [...frameListeners]) cb(dt);
|
|
494
527
|
if (cutaway.isEnabled) cutaway.updateForCamera();
|
|
495
528
|
renderer.render(scene, camera);
|
|
496
529
|
cutaway.renderOverlay(renderer, camera);
|
|
@@ -504,8 +537,8 @@ export function createViewer(container, part) {
|
|
|
504
537
|
// that cannot do that — partforge-cloud's phone tab bar uses
|
|
505
538
|
// `visibility: hidden`, because the canvas has to keep its size for build
|
|
506
539
|
// screenshots — gets no such signal: the full-resolution MSAA drawing buffer
|
|
507
|
-
// stays resident and this loop keeps rendering
|
|
508
|
-
//
|
|
540
|
+
// stays resident and this loop keeps rendering the scene at 60fps behind an
|
|
541
|
+
// invisible pane. On an iPhone that is tens of megabytes and
|
|
509
542
|
// continuous GPU work nobody can see, so the host has to say so explicitly.
|
|
510
543
|
//
|
|
511
544
|
// Parking stops the loop and releases the drawing buffer. `setSize(1, 1,
|
|
@@ -530,6 +563,7 @@ export function createViewer(container, part) {
|
|
|
530
563
|
return;
|
|
531
564
|
}
|
|
532
565
|
resize(); // rebuild the buffer at whatever size the container is now
|
|
566
|
+
lastFrameTime = null; // parked time is not elapsed time — no dt jump on unpark
|
|
533
567
|
renderer.setAnimationLoop(renderFrame);
|
|
534
568
|
}
|
|
535
569
|
|
|
@@ -595,6 +629,10 @@ export function createViewer(container, part) {
|
|
|
595
629
|
// closure (and whatever it captured) alive.
|
|
596
630
|
renderer.domElement.removeEventListener("webglcontextlost", onContextLostEvent);
|
|
597
631
|
contextLostListeners.clear();
|
|
632
|
+
controls.removeEventListener("start", onControlsStart);
|
|
633
|
+
cameraStartListeners.clear();
|
|
634
|
+
frameListeners.clear();
|
|
635
|
+
camTween.cancel();
|
|
598
636
|
controls.dispose();
|
|
599
637
|
for (const t of flashTimers) clearTimeout(t);
|
|
600
638
|
flashTimers.clear();
|
|
@@ -624,7 +662,10 @@ export function createViewer(container, part) {
|
|
|
624
662
|
frame,
|
|
625
663
|
captureCanonicalViews,
|
|
626
664
|
captureCurrent,
|
|
627
|
-
|
|
665
|
+
onFrame,
|
|
666
|
+
tweenCameraTo,
|
|
667
|
+
cancelCameraTween,
|
|
668
|
+
onCameraStart,
|
|
628
669
|
setActive,
|
|
629
670
|
onContextLost,
|
|
630
671
|
setTheme,
|
package/src/index.js
CHANGED
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
// must NOT be imported from a part's build functions — those run in a Web Worker.
|
|
4
4
|
// Part build functions import geometry helpers from "partforge/geometry" instead.
|
|
5
5
|
export { mount } from "./framework/index.js";
|
|
6
|
-
export { viewSubParts } from "./framework/
|
|
6
|
+
export { viewSubParts } from "./framework/part-model.js";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Animation reference part — a box with a hinged lid. Worked example for
|
|
2
|
+
// docs/AUTHORING-PARTS.md "Animations": pose-only animated params (lidAngle,
|
|
3
|
+
// lidLift) driven through place(), an intro camera + markdown description on
|
|
4
|
+
// `open`, a looping `cycle`, and a stepped `assemble` with per-step cameras.
|
|
5
|
+
export default {
|
|
6
|
+
meta: { title: "Hinged Box", units: "mm" },
|
|
7
|
+
parameters: [
|
|
8
|
+
{
|
|
9
|
+
id: "box",
|
|
10
|
+
title: "Box",
|
|
11
|
+
description: "Outer dimensions of the base. The lid is a flat plate of the same wall thickness.",
|
|
12
|
+
advanced: [
|
|
13
|
+
{ key: "width", label: "Width", unit: "mm", min: 20, max: 120, step: 1,
|
|
14
|
+
description: "Outer width (X)." },
|
|
15
|
+
{ key: "depth", label: "Depth", unit: "mm", min: 20, max: 120, step: 1,
|
|
16
|
+
description: "Outer depth (Y). The hinge runs along the rear edge." },
|
|
17
|
+
{ key: "height", label: "Height", unit: "mm", min: 10, max: 80, step: 1,
|
|
18
|
+
description: "Outer height of the base (Z)." },
|
|
19
|
+
{ key: "wall", label: "Wall", unit: "mm", min: 1.2, max: 5, step: 0.2,
|
|
20
|
+
description: "Wall and lid thickness." },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "pose",
|
|
25
|
+
title: "Pose",
|
|
26
|
+
description: "Presentation pose. The **Open lid** and **Assemble** animations drive these — both are pose-only, so animating them never rebuilds geometry.",
|
|
27
|
+
advanced: [
|
|
28
|
+
{ key: "lidAngle", label: "Lid angle", unit: "°", min: 0, max: 110, step: 1,
|
|
29
|
+
description: "Hinge opening angle about the rear top edge." },
|
|
30
|
+
{ key: "lidLift", label: "Lid lift", unit: "mm", min: 0, max: 60, step: 1,
|
|
31
|
+
description: "Assembly explode offset: raises the lid straight up off the hinge." },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
defaults: { width: 60, depth: 40, height: 24, wall: 2, lidAngle: 0, lidLift: 0 },
|
|
36
|
+
parts: {
|
|
37
|
+
base: {
|
|
38
|
+
label: "Base",
|
|
39
|
+
views: ["box"],
|
|
40
|
+
export: { name: "base" },
|
|
41
|
+
build: (k, p) =>
|
|
42
|
+
k.box({ min: [0, 0, 0], max: [p.width, p.depth, p.height] })
|
|
43
|
+
.cut(k.box({ min: [p.wall, p.wall, p.wall], max: [p.width - p.wall, p.depth - p.wall, p.height + 1] })),
|
|
44
|
+
},
|
|
45
|
+
lid: {
|
|
46
|
+
label: "Lid",
|
|
47
|
+
views: ["box"],
|
|
48
|
+
export: { name: "lid" },
|
|
49
|
+
build: (k, p) => k.box({ min: [0, 0, p.height], max: [p.width, p.depth, p.height + p.wall] }),
|
|
50
|
+
// Display: swing about the hinge line (rear top edge, axis +X through
|
|
51
|
+
// [0, depth, height]; negative angle opens upward), then the assembly
|
|
52
|
+
// lift. Export: the lid prints flat beside the base. Both poses are
|
|
53
|
+
// rigid motions of the same solid, and neither reads `view` — the two
|
|
54
|
+
// invariants lint's place rules hold every part to.
|
|
55
|
+
place: (s, { purpose, p }) =>
|
|
56
|
+
purpose === "export"
|
|
57
|
+
? s.translate([p.width + 10, 0, -p.height])
|
|
58
|
+
: s.rotate(-p.lidAngle, [0, p.depth, p.height], [1, 0, 0]).translate([0, 0, p.lidLift]),
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
views: { box: { label: "Box" } },
|
|
62
|
+
animations: {
|
|
63
|
+
open: {
|
|
64
|
+
label: "Open lid",
|
|
65
|
+
description: "Swings the lid to **110°** about the rear hinge line.\n\nPose-only: playback runs at frame rate with no geometry rebuild.",
|
|
66
|
+
camera: "front",
|
|
67
|
+
duration: 1.2,
|
|
68
|
+
tracks: { lidAngle: [[0, 0], [1, 110]] },
|
|
69
|
+
},
|
|
70
|
+
cycle: {
|
|
71
|
+
label: "Open / close",
|
|
72
|
+
duration: 2.4,
|
|
73
|
+
loop: true,
|
|
74
|
+
easing: "linear",
|
|
75
|
+
autoplay: true,
|
|
76
|
+
tracks: { lidAngle: [[0, 0], [0.5, 110], [1, 0]] },
|
|
77
|
+
},
|
|
78
|
+
assemble: {
|
|
79
|
+
label: "Assemble",
|
|
80
|
+
description: "How the parts come together: the lid drops onto the base, then swings open to check hinge clearance.",
|
|
81
|
+
steps: [
|
|
82
|
+
{ label: "Lower the lid", camera: "left", duration: 1.0, tracks: { lidLift: [[0, 40], [1, 0]] } },
|
|
83
|
+
{ label: "Open to check clearance", camera: "iso", duration: 1.0, tracks: { lidAngle: [[0, 0], [1, 110]] } },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
verify: {
|
|
88
|
+
process: "fdm-pla",
|
|
89
|
+
expect: {
|
|
90
|
+
base: { bbox: "<=[200,200,200]" },
|
|
91
|
+
_view: { overlaps: 0 },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|