partforge 0.146.4 → 0.149.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.
@@ -0,0 +1,89 @@
1
+ // The cached-preview reader (main thread): a GLB written by encode-preview.js,
2
+ // decoded with three's GLTFLoader + MeshoptDecoder and turned back into the
3
+ // viewer's own mesh payloads — { name, positions, normals, indices, triangles,
4
+ // edges } in the part's Z-up MODEL frame — so a preview is drawn through
5
+ // viewer.setSubGeometry exactly like a worker delivery. Everything after that
6
+ // (the pivot, the per-sub-part CAD material, framing, the cutaway, captures)
7
+ // is the viewer's existing code.
8
+ //
9
+ // Why payloads and not the loaded scene graph: the file's root node carries the
10
+ // −90° about X that stands Z-up on end, and the viewer's pivot carries the same
11
+ // rotation; parenting the glTF root under the pivot would turn the part twice.
12
+ // Undoing the root here hands the viewer geometry in the frame it already owns.
13
+ //
14
+ // Rejects on anything that is not a decodable GLB; the callers (mount's
15
+ // `preview`, mountPreview) decide what a failure means for them.
16
+ import { Matrix3, Matrix4, Vector3 } from "three";
17
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
18
+ import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";
19
+
20
+ // The writer's root rotation (−90° about X), inverted: glTF Y-up → model Z-up.
21
+ const Y_UP_TO_Z_UP = new Matrix4().makeRotationX(Math.PI / 2);
22
+
23
+ function toArrayBuffer(bytes) {
24
+ if (bytes instanceof ArrayBuffer) return bytes;
25
+ if (ArrayBuffer.isView(bytes)) return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
26
+ throw new TypeError("decodePreviewGlb: the preview must be a Uint8Array or an ArrayBuffer");
27
+ }
28
+
29
+ // Read a (possibly quantized, normalized) vec3 attribute out as Float32, transformed.
30
+ function readVec3(attr, matrix, isNormal) {
31
+ const out = new Float32Array(attr.count * 3);
32
+ const v = new Vector3();
33
+ for (let i = 0; i < attr.count; i++) {
34
+ v.fromBufferAttribute(attr, i); // denormalizes a KHR_mesh_quantization attribute
35
+ if (isNormal) v.applyMatrix3(matrix).normalize();
36
+ else v.applyMatrix4(matrix);
37
+ out[i * 3] = v.x;
38
+ out[i * 3 + 1] = v.y;
39
+ out[i * 3 + 2] = v.z;
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function metaOf(extras) {
45
+ const m = extras?.partforgeMeta;
46
+ if (!m || typeof m !== "object") return null;
47
+ const out = {};
48
+ if (Number.isInteger(m.background)) out.background = m.background;
49
+ if (typeof m.environment === "string") out.environment = m.environment;
50
+ return Object.keys(out).length ? out : null;
51
+ }
52
+
53
+ export async function decodePreviewGlb(bytes) {
54
+ const data = toArrayBuffer(bytes);
55
+ const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
56
+ const gltf = await loader.parseAsync(data, "");
57
+ gltf.scene.updateMatrixWorld(true);
58
+ const meshes = [];
59
+ const toModel = new Matrix4();
60
+ const normalMatrix = new Matrix3();
61
+ gltf.scene.traverse((obj) => {
62
+ if (!obj.isMesh) return;
63
+ const geometry = obj.geometry;
64
+ const position = geometry.getAttribute("position");
65
+ if (!position || position.count === 0) return;
66
+ toModel.multiplyMatrices(Y_UP_TO_Z_UP, obj.matrixWorld);
67
+ normalMatrix.getNormalMatrix(toModel);
68
+ const normal = geometry.getAttribute("normal");
69
+ const indices = geometry.index ? Uint32Array.from(geometry.index.array) : null;
70
+ const { partforgeSubPart, partforgeDisplay, name: originalName } = obj.userData;
71
+ meshes.push({
72
+ // The writer's own record of the key, then the unsanitized glTF node name.
73
+ name: typeof partforgeSubPart === "string" ? partforgeSubPart : (originalName ?? obj.name),
74
+ positions: readVec3(position, toModel, false),
75
+ normals: normal ? readVec3(normal, normalMatrix, true) : null,
76
+ indices,
77
+ triangles: (indices ? indices.length : position.count) / 3,
78
+ edges: new Float32Array(0), // previews carry no feature edges (v1)
79
+ display: partforgeDisplay && typeof partforgeDisplay === "object" ? partforgeDisplay : null,
80
+ });
81
+ geometry.dispose();
82
+ });
83
+ const extras = gltf.userData ?? {};
84
+ return {
85
+ view: typeof extras.partforgeView === "string" ? extras.partforgeView : null,
86
+ meta: metaOf(extras),
87
+ meshes,
88
+ };
89
+ }
@@ -0,0 +1,142 @@
1
+ // The cached-preview writer behind `partforge/gltf`: one view's POSED display
2
+ // meshes (the payloads the viewer, the worker's `meshes` reply and the oracle's
3
+ // buildView all hold) as a small GLB a viewer can show before the kernel boots.
4
+ //
5
+ // The file, which a reader may rely on:
6
+ // - one node + mesh + primitive per sub-part, all three named by the sub-part
7
+ // key; POSITION + NORMAL (+ indices when the kernel sent them)
8
+ // - every sub-part node under ONE root node, "partforge", rotated −90° about X:
9
+ // partforge models Z-up in millimetres and glTF is Y-up. Units stay mm — the
10
+ // file is for partforge's own viewer; metres would only matter for AR.
11
+ // - each material from cadAppearance — the CAD look the viewer itself draws —
12
+ // and each node's extras carrying the sub-part's own `display` block, so a
13
+ // part-less viewer (mountPreview) can rebuild the exact look, realistic too
14
+ // - top-level extras { partforgeView, partforgeEncoder, partforgeMeta? }
15
+ // - weld() → quantize() → meshopt({ level: "medium" }): 5–8 bytes a triangle,
16
+ // the recipe measured in partforge-cloud's local_glb_benchmark.md
17
+ // Not encoded (v1): feature edges, textures, patterns.
18
+ //
19
+ // Runs in Node, a page and a Web Worker: no three.js, no DOM, no Blob
20
+ // (test/gltf-layering.test.js). Deterministic: the same input writes the same
21
+ // bytes, and nothing here mutates the input (exportPreview passes the viewer's
22
+ // live buffers). Bump PREVIEW_ENCODER_VERSION whenever the bytes for the same
23
+ // input change — downstream caches key on it.
24
+ import { Document, Logger, WebIO } from "@gltf-transform/core";
25
+ import { EXTMeshoptCompression, KHRMeshQuantization } from "@gltf-transform/extensions";
26
+ import { meshopt, quantize, weld } from "@gltf-transform/functions";
27
+ import { MeshoptEncoder } from "meshoptimizer/encoder";
28
+ import { cadAppearance, DISPLAY_KEYS } from "../materials/resolve.js";
29
+
30
+ export const PREVIEW_ENCODER_VERSION = 1;
31
+
32
+ // −90° about X as [x, y, z, w]: model (x, y, z) → glTF (x, z, −y), the viewer's pivot.
33
+ const Z_UP_TO_Y_UP = [-Math.SQRT1_2, 0, 0, Math.SQRT1_2];
34
+
35
+ let encoderReady = null; // MeshoptEncoder's wasm, awaited once per process
36
+
37
+ const srgbToLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
38
+ function baseColorFactor(hex, opacity) {
39
+ return [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255].map((v) => srgbToLinear(v / 255)).concat(opacity);
40
+ }
41
+
42
+ // The display block as plain data (resolve.js's own keys, primitive values only).
43
+ function displayExtras(display) {
44
+ if (!display || typeof display !== "object") return null;
45
+ const out = {};
46
+ for (const key of DISPLAY_KEYS) {
47
+ const v = display[key];
48
+ if (typeof v === "string" || (typeof v === "number" && Number.isFinite(v))) out[key] = v;
49
+ }
50
+ return Object.keys(out).length ? out : null;
51
+ }
52
+
53
+ // The two meta fields createViewer reads (background, environment).
54
+ function metaExtras(meta) {
55
+ if (!meta || typeof meta !== "object") return null;
56
+ const out = {};
57
+ if (Number.isInteger(meta.background)) out.background = meta.background;
58
+ if (typeof meta.environment === "string") out.environment = meta.environment;
59
+ return Object.keys(out).length ? out : null;
60
+ }
61
+
62
+ function checkMesh(m, seen) {
63
+ if (!m || typeof m.name !== "string" || !m.name) throw new TypeError("encodePreviewGlb: every mesh needs a non-empty string name");
64
+ if (seen.has(m.name)) throw new Error(`encodePreviewGlb: two meshes are named "${m.name}"`);
65
+ seen.add(m.name);
66
+ if (!ArrayBuffer.isView(m.positions)) throw new TypeError(`encodePreviewGlb: "${m.name}" positions must be a typed array`);
67
+ const indexed = m.indices != null && m.indices.length > 0;
68
+ if (m.positions.length % (indexed ? 3 : 9) !== 0) {
69
+ throw new Error(`encodePreviewGlb: "${m.name}" positions are not whole ${indexed ? "vertices" : "triangles"}`);
70
+ }
71
+ if (indexed && m.indices.length % 3 !== 0) throw new Error(`encodePreviewGlb: "${m.name}" indices are not whole triangles`);
72
+ if (m.normals != null && m.normals.length > 0 && m.normals.length !== m.positions.length) {
73
+ throw new Error(`encodePreviewGlb: "${m.name}" normals do not match its positions`);
74
+ }
75
+ if (indexed) {
76
+ const vertexCount = m.positions.length / 3;
77
+ for (let i = 0; i < m.indices.length; i++) {
78
+ if (!(m.indices[i] < vertexCount)) {
79
+ throw new Error(`encodePreviewGlb: "${m.name}" indices reference a vertex that does not exist`);
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ export async function encodePreviewGlb({ meshes, part = null, view = null } = {}) {
86
+ if (!Array.isArray(meshes) || meshes.length === 0) throw new TypeError("encodePreviewGlb: meshes must be a non-empty array");
87
+ const seen = new Set();
88
+ for (const m of meshes) checkMesh(m, seen);
89
+ try {
90
+ await (encoderReady ??= MeshoptEncoder.ready);
91
+ } catch (error) {
92
+ encoderReady = null; // a failed wasm load must not be cached: a later call retries
93
+ throw error;
94
+ }
95
+
96
+ const viewName = typeof view === "string" && view ? view : null;
97
+ const doc = new Document();
98
+ doc.setLogger(new Logger(Logger.Verbosity.SILENT));
99
+ const buffer = doc.createBuffer();
100
+ const scene = doc.createScene(viewName ?? "preview");
101
+ const root = doc.createNode("partforge").setRotation(Z_UP_TO_Y_UP);
102
+ scene.addChild(root);
103
+ const meta = metaExtras(part?.meta);
104
+ doc.getRoot().setExtras({
105
+ partforgeView: viewName,
106
+ partforgeEncoder: PREVIEW_ENCODER_VERSION,
107
+ ...(meta ? { partforgeMeta: meta } : {}),
108
+ });
109
+
110
+ for (const m of meshes) {
111
+ if (m.positions.length === 0) continue; // glTF forbids a zero-count accessor
112
+ // Copies throughout: setArray keeps the array it is handed, and the
113
+ // transforms below rewrite it.
114
+ const prim = doc.createPrimitive().setAttribute(
115
+ "POSITION", doc.createAccessor().setType("VEC3").setArray(new Float32Array(m.positions)).setBuffer(buffer));
116
+ if (m.normals?.length) {
117
+ prim.setAttribute("NORMAL", doc.createAccessor().setType("VEC3").setArray(new Float32Array(m.normals)).setBuffer(buffer));
118
+ }
119
+ if (m.indices?.length) {
120
+ prim.setIndices(doc.createAccessor().setType("SCALAR").setArray(new Uint32Array(m.indices)).setBuffer(buffer));
121
+ }
122
+ const display = part?.parts?.[m.name]?.display;
123
+ const look = cadAppearance(display);
124
+ prim.setMaterial(doc.createMaterial(m.name)
125
+ .setBaseColorFactor(baseColorFactor(look.color, look.opacity))
126
+ .setMetallicFactor(look.metalness)
127
+ .setRoughnessFactor(look.roughness)
128
+ .setAlphaMode(look.opacity < 1 ? "BLEND" : "OPAQUE"));
129
+ const extras = displayExtras(display);
130
+ root.addChild(doc.createNode(m.name)
131
+ .setMesh(doc.createMesh(m.name).addPrimitive(prim))
132
+ .setExtras(extras ? { partforgeSubPart: m.name, partforgeDisplay: extras } : { partforgeSubPart: m.name }));
133
+ }
134
+ if (root.listChildren().length === 0) throw new Error("encodePreviewGlb: every mesh was empty");
135
+
136
+ await doc.transform(weld(), quantize(), meshopt({ encoder: MeshoptEncoder, level: "medium" }));
137
+ const io = new WebIO()
138
+ .registerExtensions([EXTMeshoptCompression, KHRMeshQuantization])
139
+ .registerDependencies({ "meshopt.encoder": MeshoptEncoder });
140
+ io.setLogger(new Logger(Logger.Verbosity.SILENT));
141
+ return io.writeBinary(doc);
142
+ }
@@ -0,0 +1,120 @@
1
+ // src/framework/materials/overrides.js
2
+ // The viewer-side override of a sub-part's appearance, and the viewing state
3
+ // beside it. Pure: no three, no DOM (lint purity scans this folder).
4
+ //
5
+ // Two kinds of key, deliberately separate (spec A.1):
6
+ // - APPEARANCE keys merge over the authored `display` into effective(), which
7
+ // every material read uses — CAD, realistic, the throwaway thumbnail scene.
8
+ // A host may persist these into source.
9
+ // - VIEWING keys (`hidden`, `opacity`) are how the user is LOOKING. They never
10
+ // reach effective(), exports, lint or declaresMaterials, and no host saves
11
+ // them. `opacity` MULTIPLIES an authored display.opacity (the arithmetic
12
+ // animation opacity already uses), so a ghost can be faded further but not
13
+ // made solid from here.
14
+ import { DISPLAY_KEYS, OVERRIDE_RANGES } from "./resolve.js";
15
+
16
+ export const APPEARANCE_OVERRIDE_KEYS = DISPLAY_KEYS.filter((k) => k !== "opacity");
17
+ export const VIEWING_KEYS = ["hidden", "opacity"];
18
+ export const MIN_USER_OPACITY = 0.05;
19
+
20
+ const isObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
21
+ const isColor = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffff;
22
+ const finite = (v) => typeof v === "number" && Number.isFinite(v);
23
+
24
+ // Only known keys survive; `null` is the "clear this key" value and survives
25
+ // as-is. Bad values are DROPPED (not clamped) where clamping would invent a
26
+ // choice: a non-string material, a colour out of range. Numeric overrides clamp
27
+ // to OVERRIDE_RANGES exactly as resolve.js clamps authored ones. `opacity`
28
+ // floors at MIN_USER_OPACITY and reads >= 1 as a clear; `hidden` must be a real
29
+ // boolean (a host's "false" string would otherwise read as truthy and hide the
30
+ // part), anything else is dropped.
31
+ export function sanitizeDisplayPatch(patch) {
32
+ if (!isObject(patch)) return {};
33
+ const out = {};
34
+ for (const [key, v] of Object.entries(patch)) {
35
+ if (v === null) {
36
+ if (APPEARANCE_OVERRIDE_KEYS.includes(key) || VIEWING_KEYS.includes(key)) out[key] = null;
37
+ continue;
38
+ }
39
+ if (key === "material") { if (typeof v === "string" && v.length > 0) out.material = v; continue; }
40
+ if (key === "color") { if (isColor(v)) out.color = v; continue; }
41
+ if (key === "hidden") { if (typeof v === "boolean") out.hidden = v; continue; }
42
+ if (key === "opacity") {
43
+ if (!finite(v)) continue;
44
+ out.opacity = v >= 1 ? null : Math.max(MIN_USER_OPACITY, v);
45
+ continue;
46
+ }
47
+ if (key in OVERRIDE_RANGES) {
48
+ if (!finite(v)) continue;
49
+ const [lo, hi] = OVERRIDE_RANGES[key];
50
+ out[key] = Math.min(hi, Math.max(lo, v));
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ // Merge a SANITIZED patch over the current override. `hidden: false` and
57
+ // `opacity: null` (or >= 1) are clears like any other null. Returns null when nothing is
58
+ // left, so a Map entry can be deleted rather than kept empty.
59
+ export function mergeOverride(current, patch) {
60
+ const next = { ...(isObject(current) ? current : {}) };
61
+ for (const [key, v] of Object.entries(patch ?? {})) {
62
+ if (v === null || (key === "hidden" && v === false) || (key === "opacity" && v >= 1)) delete next[key];
63
+ else next[key] = v;
64
+ }
65
+ return Object.keys(next).length ? next : null;
66
+ }
67
+
68
+ // The display every material read sees: authored, with the override's
69
+ // APPEARANCE keys on top. Viewing keys are never in it.
70
+ export function effectiveDisplay(authored, override) {
71
+ const out = isObject(authored) ? { ...authored } : {};
72
+ if (!isObject(override)) return out;
73
+ for (const key of APPEARANCE_OVERRIDE_KEYS) if (key in override) out[key] = override[key];
74
+ return out;
75
+ }
76
+
77
+ export function isAppearanceNoop(authored, override) {
78
+ if (!isObject(override)) return true;
79
+ const a = isObject(authored) ? authored : {};
80
+ return APPEARANCE_OVERRIDE_KEYS.every((key) => !(key in override) || override[key] === a[key]);
81
+ }
82
+
83
+ // A carried map, made safe for THIS part (spec A.4): names the part no longer
84
+ // declares go; appearance keys that now equal the authored value go (an owner's
85
+ // saved change self-cleans this way); every kept entry is re-sanitized, since
86
+ // the token came from a host; empties go.
87
+ export function pruneOverrides(map, part) {
88
+ const out = {};
89
+ if (!isObject(map)) return out;
90
+ for (const [name, raw] of Object.entries(map)) {
91
+ // Own names only: `parts` is a plain object, so "toString" or "constructor"
92
+ // would otherwise read as declared sub-parts and be replayed into the viewer.
93
+ const parts = part?.parts;
94
+ if (!isObject(parts) || !Object.hasOwn(parts, name)) continue;
95
+ const sp = parts[name];
96
+ if (!sp) continue;
97
+ const clean = mergeOverride(null, sanitizeDisplayPatch(raw));
98
+ if (!clean) continue;
99
+ const authored = isObject(sp.display) ? sp.display : {};
100
+ for (const key of APPEARANCE_OVERRIDE_KEYS) if (key in clean && clean[key] === authored[key]) delete clean[key];
101
+ if (Object.keys(clean).length) out[name] = clean;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ // The one visibility decision viewer.js's applySubOpacity makes, as a pure
107
+ // function of its inputs. `anim` is the animation driver's opacity (undefined =
108
+ // none), `user` the viewing override's (undefined = none). `fade` is null when
109
+ // the mesh should sit on its base material, else the fade clone's opacity.
110
+ export function subPartVisibility({ shown, anim, user, hidden, renderMode, staticOpacity = 1 }) {
111
+ const off = { meshVisible: false, linesVisible: false, fade: null };
112
+ if (!shown || hidden) return off;
113
+ const a = anim === undefined ? 1 : anim;
114
+ const u = user === undefined ? 1 : user;
115
+ const v = a * u;
116
+ if (v <= 0) return off;
117
+ const linesVisible = renderMode === "cad";
118
+ if (v >= 1) return { meshVisible: true, linesVisible, fade: null };
119
+ return { meshVisible: true, linesVisible, fade: staticOpacity * v };
120
+ }
@@ -0,0 +1,33 @@
1
+ // Keys and bounds for the appearance card's sphere thumbnails. A tile depicts
2
+ // the PRESET, tinted: finish overrides (and see-through) are neither in the key
3
+ // nor in the render — re-rendering 25 spheres on every slider tick would make
4
+ // the card unusable, and a render that read them would cache one part's finish
5
+ // under a key every other part shares. thumbDisplay is what the render builds
6
+ // from, so it can depend on exactly what the key holds.
7
+ //
8
+ // An UNTINTABLE preset (brass, oak, ...) draws its own colour whatever the
9
+ // part's colour is, so its colour is dropped from both the render and the key:
10
+ // every part shares one sphere for it, and a colour pick re-renders only the
11
+ // tintable tiles. No material (and an unknown id) counts as tintable.
12
+ import { PRESETS } from "./presets.js";
13
+
14
+ const CAP = 120;
15
+ const tintable = (material) =>
16
+ typeof material !== "string" || !Object.hasOwn(PRESETS, material) || PRESETS[material].tintable === true;
17
+ export function thumbDisplay(display) {
18
+ const out = { material: display?.material };
19
+ if (display?.color !== undefined && tintable(display?.material)) out.color = display.color;
20
+ return out;
21
+ }
22
+ export function createMaterialThumbCache() {
23
+ const map = new Map();
24
+ return {
25
+ key: (display, env, theme, mode) => {
26
+ const look = thumbDisplay(display);
27
+ return `${look.material ?? ""}|${look.color ?? ""}|${env}|${theme}|${mode}`;
28
+ },
29
+ get(k) { const v = map.get(k); if (v !== undefined) { map.delete(k); map.set(k, v); } return v; },
30
+ set(k, url) { map.delete(k); map.set(k, url); if (map.size > CAP) map.delete(map.keys().next().value); },
31
+ clear() { map.clear(); },
32
+ };
33
+ }
@@ -0,0 +1,157 @@
1
+ // mountPreview: the kernel-free viewer. A cached preview GLB (partforge/gltf) in
2
+ // the SAME viewer mount() uses — scene, camera, lights, materials, the view cube,
3
+ // the view style and the theme — with no part source, no worker, no rail, no
4
+ // export and no picking. For a page that only shows a model (partforge-cloud's
5
+ // public forge page).
6
+ //
7
+ // createViewer needs a part's sub-part names at construction, so the viewer is
8
+ // built once the GLB has decoded, from a synthetic part: one sub-part per glTF
9
+ // mesh, carrying the `display` block the writer stored beside it, and the file's
10
+ // background/environment meta. The CAD look is exact; realistic mode uses each
11
+ // sub-part's display only — no sheet stock, no layer-line print frames (a v1
12
+ // residual alongside "no edges"). The decoder loads on demand (it carries the
13
+ // GLTFLoader and meshopt), so a bundle that never mounts a preview never ships it.
14
+ // Hence `ready`. Every handle method is safe before it resolves.
15
+ import "./app.css";
16
+ import { createViewer } from "./viewer.js";
17
+ import { attachViewerControls } from "./viewer-controls.js";
18
+ import { attachViewcubeControls } from "./viewcube/viewcube-controls.js";
19
+ import { attachViewStyleControls } from "./view-style-controls.js";
20
+ import { observeViewbarClear } from "./viewbar-clear.js";
21
+ import { createTooltipPresenter } from "./tooltip.js";
22
+ import { isFaceAlignedState } from "./projection.js";
23
+ import { ENVIRONMENTS } from "./materials/environments.js";
24
+
25
+ function previewPart({ meshes, view, meta }) {
26
+ return {
27
+ meta: { title: "Preview", ...(meta ?? {}) },
28
+ defaults: {},
29
+ parameters: [],
30
+ views: { [view]: { label: view } },
31
+ parts: Object.fromEntries(meshes.map((m) => [m.name, {
32
+ label: m.name,
33
+ views: [view],
34
+ ...(m.display ? { display: m.display } : {}),
35
+ }])),
36
+ };
37
+ }
38
+
39
+ const relay = (listeners) => (event) => {
40
+ for (const cb of [...listeners]) {
41
+ try { cb(event); } catch (error) { console.warn("partforge: preview listener failed", error); }
42
+ }
43
+ };
44
+
45
+ export function mountPreview(container, { glb, elements = {}, viewerState = null } = {}) {
46
+ if (!container) throw new TypeError("mountPreview: a container element is required");
47
+ let disposed = false;
48
+ let active = true;
49
+ let carried = viewerState ?? null; // applied once the viewer exists, and on every setViewerState
50
+ let live = null; // { viewer, view } once the GLB has decoded
51
+ const cleanups = [];
52
+ const modeListeners = new Set();
53
+ const envListeners = new Set();
54
+ const lostListeners = new Set();
55
+
56
+ // Same precedence mount() uses: projection first (orthographic only for a
57
+ // face-view camera), then the camera, then environment and render mode.
58
+ function applyState(s) {
59
+ if (!s || !live) return;
60
+ const { viewer } = live;
61
+ viewer.setProjection(s.projection === "orthographic" && isFaceAlignedState(s.camera) ? "orthographic" : "perspective");
62
+ if (s.camera) viewer.setCameraState(s.camera);
63
+ if (typeof s.environment === "string") viewer.setEnvironment(s.environment);
64
+ if ((s.renderMode === "cad" || s.renderMode === "realistic") && s.renderMode !== viewer.getRenderMode()) {
65
+ viewer.setRenderMode(s.renderMode);
66
+ }
67
+ }
68
+
69
+ const ready = import("./gltf/decode-preview.js").then(({ decodePreviewGlb }) => decodePreviewGlb(glb)).then((decoded) => {
70
+ if (disposed) return;
71
+ if (decoded.meshes.length === 0) throw new Error("mountPreview: the preview holds no meshes");
72
+ const view = decoded.view ?? "preview";
73
+ const viewer = createViewer(container, previewPart({ ...decoded, view }));
74
+ cleanups.push(() => viewer.dispose());
75
+ const tooltip = createTooltipPresenter({ id: null });
76
+ cleanups.push(() => tooltip.dispose());
77
+ const viewcube = attachViewcubeControls(viewer, { stage: container });
78
+ cleanups.push(() => viewcube.detach());
79
+ const toolbar = container.querySelector("#viewbar");
80
+ const viewStyle = attachViewStyleControls(viewer, { stage: container, toolbar, anchor: viewcube.element }, { tooltip });
81
+ cleanups.push(() => viewStyle.detach());
82
+ cleanups.push(observeViewbarClear(container, toolbar));
83
+ const chrome = attachViewerControls(viewer, elements.chrome ?? {}, { tooltip });
84
+ cleanups.push(() => chrome.detach());
85
+ cleanups.push(viewer.onRenderModeChange(relay(modeListeners)));
86
+ cleanups.push(viewer.onEnvironmentChange(relay(envListeners)));
87
+ cleanups.push(viewer.onContextLost(relay(lostListeners)));
88
+ for (const m of decoded.meshes) viewer.setSubGeometry(m.name, m);
89
+ viewer.showAssembly(decoded.meshes.map((m) => m.name), { frame: true });
90
+ live = { viewer, view };
91
+ applyState(carried);
92
+ if (!active) viewer.setActive(false);
93
+ });
94
+ ready.catch(() => {}); // the host awaits it; never an unhandled rejection
95
+
96
+ function getViewerState() {
97
+ if (!live) return { camera: null, projection: "perspective", renderMode: "cad", ...carried, cutaway: null };
98
+ const v = live.viewer;
99
+ return {
100
+ camera: v.getCameraState(),
101
+ projection: v.getProjection?.() ?? "perspective",
102
+ cutaway: null, // a preview has no live solid to section
103
+ renderMode: v.isRealisticPending?.() ? "realistic" : v.getRenderMode?.() ?? "cad",
104
+ ...(v.isEnvironmentChosen?.() ? { environment: v.getEnvironment() } : {}),
105
+ };
106
+ }
107
+
108
+ // A method that needs the viewer waits for it; a failed decode answers the default.
109
+ const whenLive = async (fn, fallback) => {
110
+ try { await ready; } catch { return fallback; }
111
+ return live ? fn(live.viewer) : fallback;
112
+ };
113
+
114
+ return {
115
+ ready,
116
+ dispose() {
117
+ if (disposed) return;
118
+ disposed = true;
119
+ live = null;
120
+ const errors = [];
121
+ while (cleanups.length) {
122
+ try { cleanups.pop()(); } catch (error) { errors.push(error); }
123
+ }
124
+ if (errors.length === 1) throw errors[0];
125
+ if (errors.length > 1) throw new AggregateError(errors, "partforge mountPreview cleanup failed");
126
+ },
127
+ getView: () => live?.view ?? null,
128
+ getViewerState,
129
+ setViewerState(next) {
130
+ carried = next ?? null;
131
+ applyState(carried);
132
+ },
133
+ setActive(next) {
134
+ active = next !== false;
135
+ live?.viewer.setActive(active);
136
+ },
137
+ onContextLost(listener) {
138
+ lostListeners.add(listener);
139
+ return () => lostListeners.delete(listener);
140
+ },
141
+ // No rail to place or pick: accepted so a host relaying mount()'s sticky state
142
+ // can send it here unchanged.
143
+ setHostPane: () => {},
144
+ setRailLayout: () => {},
145
+ renderMode: {
146
+ get: () => live?.viewer.getRenderMode() ?? carried?.renderMode ?? "cad",
147
+ set: (mode) => whenLive((v) => v.setRenderMode(mode), "cad"),
148
+ onChange: (cb) => { modeListeners.add(cb); return () => modeListeners.delete(cb); },
149
+ },
150
+ environment: {
151
+ get: () => live?.viewer.getEnvironment() ?? carried?.environment ?? "studio",
152
+ set: (id) => whenLive((v) => v.setEnvironment(id), "studio"),
153
+ onChange: (cb) => { envListeners.add(cb); return () => envListeners.delete(cb); },
154
+ list: () => Object.values(ENVIRONMENTS).map(({ id, label }) => ({ id, label })),
155
+ },
156
+ };
157
+ }