partforge 0.41.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -10
- package/bin/cli.js +138 -27
- package/docs/AUTHORING-PARTS.md +164 -17
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +48 -7
- package/skills/partforge/SKILL.md +17 -3
- package/src/app-embed-test.js +1 -1
- package/src/app-hinged-box.js +12 -0
- package/src/framework/animation-controls.js +254 -0
- package/src/framework/animation.js +271 -0
- package/src/framework/app.css +32 -0
- package/src/framework/assembly.js +1 -1
- package/src/framework/backend-select.js +25 -0
- package/src/framework/camera-tween.js +58 -0
- package/src/framework/capture-build.js +59 -0
- package/src/framework/chrome.css +16 -0
- package/src/framework/controls.js +13 -3
- package/src/framework/cutaway-gizmo-scene.js +244 -0
- package/src/framework/cutaway-gizmo.js +80 -243
- package/src/framework/default-view.js +46 -0
- package/src/framework/download.js +7 -2
- package/src/framework/export-controller.js +13 -2
- package/src/framework/geometry/probe.js +3 -22
- package/src/framework/jobs.js +30 -40
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +441 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-schema.js +22 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +147 -20
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/{testing → framework/oracle}/bvh.js +1 -1
- package/src/{testing → framework/oracle}/measure.js +1 -1
- package/src/{testing → framework/oracle}/min-wall.js +1 -1
- package/src/{testing → framework/oracle}/verify.js +3 -3
- package/src/framework/param-deps.js +1 -1
- package/src/framework/part-model.js +48 -0
- package/src/framework/pick-request/client.js +11 -3
- package/src/framework/pick-request/endpoint.js +60 -0
- package/src/framework/pick-request/index.js +6 -0
- package/src/framework/pick-request/server.js +222 -34
- package/src/framework/pick-request/token-store.js +31 -0
- package/src/framework/pose-fast-path.js +12 -1
- package/src/framework/pose-probe-core.js +129 -0
- package/src/framework/pose-probe.js +7 -123
- package/src/framework/regen-loop.js +10 -3
- package/src/framework/safe-name.js +26 -0
- package/src/framework/verify-metrics.js +4 -4
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +35 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer-lighting.js +8 -1
- package/src/framework/viewer.js +139 -20
- package/src/framework/worker.js +5 -1
- package/src/hinged-box-worker.js +3 -0
- package/src/index.js +1 -1
- package/src/parts/hinged-box.js +94 -0
- package/src/testing/render.js +19 -8
- package/src/testing.js +15 -8
- package/types/derive.d.ts +14 -0
- package/types/geometry.d.ts +117 -0
- package/types/index.d.ts +259 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +409 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
- /package/src/{testing → framework/oracle}/cases.js +0 -0
- /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
- /package/src/{testing → framework/oracle}/gaps.js +0 -0
- /package/src/{testing → framework/oracle}/mesh.js +0 -0
|
@@ -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
|
};
|
|
@@ -33,6 +48,19 @@ export function createViewTabs(el, part, { onChange }) {
|
|
|
33
48
|
|
|
34
49
|
return {
|
|
35
50
|
current: () => view,
|
|
51
|
+
// Programmatic switch — the click path without the click. Used by an
|
|
52
|
+
// embedder (mount's handle.setView) to change tabs from outside the DOM.
|
|
53
|
+
// Returns false for a name that isn't a tab so callers can validate.
|
|
54
|
+
select: (name) => {
|
|
55
|
+
if (name === view) return true; // already active — nothing to do
|
|
56
|
+
const btn = [...el.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === name);
|
|
57
|
+
if (!btn) return false;
|
|
58
|
+
view = name;
|
|
59
|
+
saveView(partKey, view);
|
|
60
|
+
setActive(btn);
|
|
61
|
+
onChange(view);
|
|
62
|
+
return true;
|
|
63
|
+
},
|
|
36
64
|
detach: () => {
|
|
37
65
|
el.removeEventListener("click", onClick);
|
|
38
66
|
if (generated) el.innerHTML = ""; // we generated these buttons; hand-written markup stays
|
|
@@ -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();
|
|
@@ -3,8 +3,15 @@ import * as THREE from "three";
|
|
|
3
3
|
const KEY_COLOR = 0xffffff, KEY_INTENSITY = 1.45;
|
|
4
4
|
const FILL_COLOR = 0xe5efff, FILL_INTENSITY = 0.65;
|
|
5
5
|
|
|
6
|
+
// The persistent sky/ground ambient. Shared with the offscreen thumbnail path
|
|
7
|
+
// (viewer.js renderMeshPayloads), which builds its own throwaway scene and so needs
|
|
8
|
+
// the same hemisphere fill to avoid rendering faces outside the key/fill cones near-black.
|
|
9
|
+
export function createHemisphereLight() {
|
|
10
|
+
return new THREE.HemisphereLight(0xdce9ff, 0x687586, 1.35);
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export function addViewerLights(scene) {
|
|
7
|
-
const hemisphere =
|
|
14
|
+
const hemisphere = createHemisphereLight();
|
|
8
15
|
const key = new THREE.DirectionalLight(KEY_COLOR, KEY_INTENSITY);
|
|
9
16
|
key.position.set(8, 14, 10);
|
|
10
17
|
const fill = new THREE.DirectionalLight(FILL_COLOR, FILL_INTENSITY);
|