partforge 0.146.4 → 0.147.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 CHANGED
@@ -169,6 +169,31 @@ Parking releases both large GPU allocations — the drawing buffer and the cache
169
169
  unchanged, so a host can still take a build screenshot of a hidden viewer; the
170
170
  first capture after parking just re-allocates its target.
171
171
 
172
+ **Show a cached preview while the kernel boots (0.147.0+).** `partforge/gltf`'s
173
+ `encodePreviewGlb({ meshes, part, view })` writes one view's posed display
174
+ meshes as a small GLB — welded, quantized and meshopt-compressed, one node per
175
+ sub-part, Y-up, millimetres — in Node, a page or a worker, and
176
+ `runtime.exportPreview()` encodes the view on screen with it. Hand the file back
177
+ as `mount()`'s `preview` and the viewer draws it before the worker is ready, then
178
+ swaps each sub-part for the live build in place without re-framing;
179
+ `runtime.previewActive` is true in between, while picking, measure, sketch and
180
+ cutaway wait for the live build. A file for another view, or one that will not
181
+ decode, is logged and ignored (a file written without a view is drawn on
182
+ sub-part name match alone). `mountPreview(container, { glb, elements,
183
+ viewerState })` is the kernel-free form — the same viewer, view cube, view style
184
+ and theme, with no part, no worker and no rail — for a page that only shows a
185
+ model (CAD look exact; realistic mode uses each sub-part's display only — no sheet
186
+ stock, no layer-line frames). Previews carry no edge lines, textures or patterns.
187
+ `resolveDefaultView(part)` (main entry and `partforge/testing`) names the view
188
+ `mount()` opens on, so a preview built headlessly matches it.
189
+
190
+ ```js
191
+ const glb = await runtime.exportPreview(); // Uint8Array: the view on screen
192
+ mount(part, { createWorker, elements, preview: glb }); // drawn at once, replaced by the build
193
+ const shown = mountPreview(stage, { glb }); // no kernel at all
194
+ await shown.ready;
195
+ ```
196
+
172
197
  Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
173
198
  `#panel` for `rail`, `#status`/`#busy`/`#phase`, `#part`,
174
199
  `#download`/`#download-step`/`#download-3mf`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.146.4",
3
+ "version": "0.147.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -62,6 +62,10 @@
62
62
  "types": "./types/panel-values.d.ts",
63
63
  "default": "./src/panel-values.js"
64
64
  },
65
+ "./gltf": {
66
+ "types": "./types/gltf.d.ts",
67
+ "default": "./src/gltf.js"
68
+ },
65
69
  "./tokens.css": "./src/framework/tokens.css",
66
70
  "./chrome.css": "./src/framework/chrome.css"
67
71
  },
@@ -90,6 +94,9 @@
90
94
  ],
91
95
  "panel-values": [
92
96
  "./types/panel-values.d.ts"
97
+ ],
98
+ "gltf": [
99
+ "./types/gltf.d.ts"
93
100
  ]
94
101
  }
95
102
  },
@@ -120,10 +127,14 @@
120
127
  }
121
128
  },
122
129
  "dependencies": {
130
+ "@gltf-transform/core": "^4.5.1",
131
+ "@gltf-transform/extensions": "^4.5.1",
132
+ "@gltf-transform/functions": "^4.5.1",
123
133
  "dompurify": "^3.4.11",
124
134
  "fflate": "^0.8.3",
125
135
  "manifold-3d": "^3.5.1",
126
136
  "marked": "^18.0.5",
137
+ "meshoptimizer": "^1.3.0",
127
138
  "opentype.js": "^2.0.0",
128
139
  "paper": "^0.12.18",
129
140
  "pngjs": "^7.0.0",
@@ -5,10 +5,13 @@
5
5
  // declaration order, so every part written before this existed resolves to the view
6
6
  // it has always opened on.
7
7
  //
8
- // Pure: no DOM, no storage, no kernel, no build. The headless surfaces (measure /
9
- // verify / renderViews) deliberately do NOT use this — they stay on
8
+ // Pure: no DOM, no storage, no kernel, no build. partforge's own headless surfaces
9
+ // (measure / verify / renderViews) deliberately do NOT use this — they stay on
10
10
  // Object.keys(part.views)[0], where a mechanically obvious rule matters more than a
11
- // convenient one.
11
+ // convenient one. It is exported (main entry and partforge/testing, 0.147.0) for a
12
+ // host that must open the SAME view mount() does — a cached preview built off the
13
+ // viewer has to match the live build it is swapped for. The signature is (part): it
14
+ // reads part.defaults, never caller params.
12
15
  import { inView } from "./sub-part-views.js";
13
16
 
14
17
  const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
@@ -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,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
+ }
@@ -39,7 +39,8 @@ import { createAnnotateMode } from "./annotate/annotate-mode.js";
39
39
  import { attachAnnotateControls } from "./annotate/annotate-controls.js";
40
40
  import { attachSketchToolbar } from "./annotate/sketch-toolbar.js";
41
41
  import { attachViewcubeControls } from "./viewcube/viewcube-controls.js";
42
- import { stageClearFor } from "./stage-clear.js";
42
+ import { createPreviewGate } from "./preview-gate.js";
43
+ import { observeViewbarClear } from "./viewbar-clear.js";
43
44
 
44
45
  // The mount handle, factored out so its shape is unit-testable without booting
45
46
  // the full mount() pipeline (WASM + workers + DOM).
@@ -69,9 +70,15 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
69
70
  // carries the worker's own error text. See the correlated "error" case below.
70
71
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
71
72
 
72
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, listExportFormats, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, controlsFor, renderMode, environment, declaresMaterials, renderViews }) {
73
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, listExportFormats, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, controlsFor, renderMode, environment, declaresMaterials, renderViews, isPreviewActive, exportPreview, pendingCutaway }) {
73
74
  return {
74
75
  ready, dispose, setParams,
76
+ // Whether the `preview` GLB handed to mount() is on screen (see mount's
77
+ // preview block). A GETTER, read live: a host that copies the handle object
78
+ // freezes the value it had at that moment.
79
+ get previewActive() { return isPreviewActive?.() ?? false; },
80
+ // The view on screen as a preview GLB (partforge/gltf), poses baked in.
81
+ exportPreview: exportPreview ?? (() => Promise.reject(new Error("exportPreview: this mount cannot encode a preview"))),
75
82
  // Part-declared animation playback (spec 2026-08-02): animations are
76
83
  // VIEW-owned, so this is null only when NO view declares any.
77
84
  // { play(name?), pause(), seek(t), stop(), state() } — play(name) resolves
@@ -113,7 +120,9 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
113
120
  getViewerState: () => ({
114
121
  camera: viewer.getCameraState(),
115
122
  projection: viewer.getProjection?.() ?? "perspective",
116
- cutaway: viewer.getCutawayState?.() ?? null,
123
+ // A carried cutaway not yet restored (the preview holds it back) is still
124
+ // what the user last had: echo it, or a remount in that window drops it.
125
+ cutaway: pendingCutaway?.() ?? viewer.getCutawayState?.() ?? null,
117
126
  // The mode the user is headed for, not only the one on screen: a
118
127
  // realistic restore or switch still loading reads "realistic", so a
119
128
  // remount mid-load (the cloud remounts on every edit) doesn't drop it.
@@ -242,8 +251,10 @@ function createCleanupStack() {
242
251
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
243
252
  // so Vite can bundle the worker (see geometry-service.js).
244
253
  //
245
- // Embedding contract (0.141.0: a place()-able sub-part is delivered in its
246
- // canonical frame and posed by the viewer; pose-only edits never rebuild):
254
+ // Embedding contract (0.147.0: a cached `preview` GLB is drawn before the kernel
255
+ // boots and swapped in place by the first build; 0.141.0: a place()-able sub-part
256
+ // is delivered in its canonical frame and posed by the viewer; pose-only edits
257
+ // never rebuild):
247
258
  // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload, onViewChange });
248
259
  // await runtime.ready; // first successful build of the default view
249
260
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
@@ -358,6 +369,15 @@ function createCleanupStack() {
358
369
  // // switching the live view; "cad" is exactly captureViews(),
359
370
  // // CAD in either live mode
360
371
  // runtime.dispose(); // full teardown
372
+ // runtime.previewActive // true while the `preview` GLB is on screen (decoded, not yet
373
+ // // replaced by a whole live build): picking, hover labels,
374
+ // // measure, sketch and cutaway wait; orbit, the view cube,
375
+ // // the view style and the rail work
376
+ // await runtime.exportPreview(); // the view on screen as a preview GLB (Uint8Array,
377
+ // // partforge/gltf), fast-path poses baked in. Rejects while
378
+ // // previewActive, before the view is fully built (a pending
379
+ // // rebuild counts), for a view other than the one on screen,
380
+ // // and after dispose
361
381
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
362
382
  // those are repaired in the viewer and produce no build at all.
363
383
  // onViewChange fires once synchronously during mount with the initial resolved
@@ -387,6 +407,12 @@ function createCleanupStack() {
387
407
  // // first mount — the viewer then restores its own persisted
388
408
  // // camera as before. Restore is best-effort per field: a pose
389
409
  // // this part cannot support is dropped, never fatal.
410
+ // preview: Uint8Array | ArrayBuffer // a GLB from encodePreviewGlb / exportPreview, drawn before
411
+ // // the worker is ready and replaced sub-part by sub-part by
412
+ // // the first live build, camera kept. Only a file for the view
413
+ // // on screen is drawn, and only its sub-parts of that view; a
414
+ // // file for another view, or one that will not decode, is
415
+ // // logged once and ignored.
390
416
  // panelState: PanelState // a previous mount's runtime.getPanelState(): the transient
391
417
  // // state of the part's custom controls (a selected tile), keyed
392
418
  // // by param. Same remount story as viewerState; omit on a first
@@ -433,7 +459,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
433
459
  fontCatalog,
434
460
  imageCatalog,
435
461
  onAssetUpload,
436
- viewerState,
462
+ viewerState, preview,
437
463
  files, panelState,
438
464
  annotateSend = "viewbar",
439
465
  container: legacyContainer, controls: legacyControls } = {}) {
@@ -516,6 +542,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
516
542
  cleanup.defer(() => ui.setStatus(""));
517
543
  cleanup.defer(() => ui.hideBusy());
518
544
 
545
+ // A cached preview (the `preview` option) is drawn before the kernel boots and
546
+ // held to the camera only — no feature ids, no live solids behind it — until
547
+ // the first whole live build replaces it. The gate is the flag, plus the
548
+ // chrome it disables meanwhile; picking and hover consult it per event. See
549
+ // the preview block just above `return makeHandle`.
550
+ const previewGate = createPreviewGate({
551
+ buttons: [els.chrome.cutaway, els.chrome.measure, els.chrome.annotate],
552
+ });
553
+
519
554
  // ?backend=occt|manifold forces the backend; otherwise it's re-detected per
520
555
  // regen with the live params (so a fillet dialed to 0 reverts to Manifold),
521
556
  // with a params-keyed latch for runtime needs-occt reroutes — see
@@ -755,21 +790,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
755
790
  // action rows can change. A hidden bar (ours or a host stylesheet's)
756
791
  // claims 0 — stage-clear.js has the why; the observer fires on the hide
757
792
  // itself, since the bar's box goes to zero.
758
- const viewbarEl = els.viewer.querySelector("#viewbar");
759
- if (viewbarEl && typeof ResizeObserver === "function") {
760
- const publishViewbarClear = () => {
761
- const clear = stageClearFor(els.viewer.getBoundingClientRect(), viewbarEl.getBoundingClientRect());
762
- els.viewer.style.setProperty("--pf-viewbar-clear", `${clear}px`);
763
- };
764
- const viewbarObserver = new ResizeObserver(publishViewbarClear);
765
- viewbarObserver.observe(viewbarEl);
766
- viewbarObserver.observe(els.viewer);
767
- publishViewbarClear();
768
- cleanup.defer(() => {
769
- viewbarObserver.disconnect();
770
- els.viewer.style.removeProperty("--pf-viewbar-clear");
771
- });
772
- }
793
+ cleanup.defer(observeViewbarClear(els.viewer, els.viewer.querySelector("#viewbar")));
773
794
  // escapeScope: cutaway's Flip/Reset buttons are canvas SIBLINGS inside
774
795
  // #viewbar, not descendants of the canvas — attaching Escape to
775
796
  // viewer.domElement alone would leave a guarded Escape from those buttons
@@ -787,9 +808,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
787
808
  // active — measure's highlight + dims take the pointer; annotate's overlay
788
809
  // canvas takes it entirely.
789
810
  const syncHoverSuppression = () =>
790
- hover.setSuppressed(measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false));
811
+ hover.setSuppressed(previewGate.isActive() || measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false));
791
812
  cleanup.defer(measureMode.onModeChange(syncHoverSuppression));
792
813
  if (annotateMode) cleanup.defer(annotateMode.onModeChange(syncHoverSuppression));
814
+ cleanup.defer(previewGate.onChange(syncHoverSuppression));
815
+ // Deferred AFTER the cutaway/measure/annotate chrome attached, so at dispose
816
+ // (LIFO) the gate hands the buttons back before those controls restore the
817
+ // host's own attributes.
818
+ cleanup.defer(() => previewGate.end());
793
819
 
794
820
  // Current selection context for the pickers: the active view + live params +
795
821
  // derived values. Shared by every pick mode below.
@@ -815,7 +841,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
815
841
  // resync on mode changes. The ?pick/?pickserver harnesses below are
816
842
  // deliberately not guarded — one is armed by an explicit dev toggle,
817
843
  // the other per agent request.
818
- suppressed: () => measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false),
844
+ suppressed: () => previewGate.isActive() || measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false),
819
845
  onPick: (selection, anchor) => onPick({
820
846
  selection,
821
847
  label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
@@ -850,6 +876,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
850
876
 
851
877
  let framedView = null; // the view the camera was last framed to (null until first show)
852
878
  let cameraRestored = false; // saved camera applied once, on the first frame after load
879
+ let cutawayRestored = false; // a carried cutaway, applied once — after any preview, never on one
853
880
 
854
881
  // Per-sub-part cache-validity tracker (Layer 1): view/version/caching change over
855
882
  // time, so they're passed as getters; params is a stable in-place-mutated object.
@@ -946,6 +973,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
946
973
  // view doesn't flicker. If nothing's built yet, show nothing.
947
974
  // Show the assembly, framing the camera only the first time we show a given view
948
975
  // (initial load / tab switch) — never on a regenerate, so zoom/orbit are kept.
976
+ // A carried cutaway goes back on once, and never onto a cached preview: the
977
+ // section needs live solids to cut. `cam` is the pose to place again after
978
+ // it (an ortho face view's size is matched at the surface the cut decides).
979
+ function restoreCarriedCutaway(cam) {
980
+ cutawayRestored = true;
981
+ if (viewerState?.cutaway?.enabled && viewer.setCutawayState?.(viewerState.cutaway)) {
982
+ cutawayChrome.sync(); // the button was not what turned it on
983
+ if (cam) viewer.setCameraState(cam);
984
+ }
985
+ }
986
+
949
987
  function showView(needed) {
950
988
  const frame = view() !== framedView;
951
989
  viewer.showAssembly(needed, { frame });
@@ -959,26 +997,23 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
959
997
  // only for a face-view camera, and this is the call that re-arms it.
960
998
  const cam = viewerState?.camera ?? loadCamera();
961
999
  if (cam) viewer.setCameraState(cam);
1000
+ cameraRestored = true;
962
1001
  // The cutaway goes back on AFTER the camera and only here, on the
963
1002
  // first accepted build: enabling it needs the sub-parts registered
964
1003
  // and real bounds to size the plane against, neither of which exists
965
1004
  // until showAssembly above has run. (Its initial pose also reads the
966
1005
  // camera direction, so a restore before the camera would seed the
967
- // fallback pose from the wrong view.)
968
- if (viewerState?.cutaway?.enabled && viewer.setCutawayState?.(viewerState.cutaway)) {
969
- cutawayChrome.sync(); // the button was not what turned it on
970
- // …and place the camera again now the section is back. An ortho
971
- // face view's size is matched at the surface on screen, which the
972
- // cut decides (the cap, or whatever survives it): the carried pose
973
- // was taken WITH the cut, and the first placement above measured
974
- // against the uncut part, so without this a sectioned face view
975
- // changes zoom on every remount. Same pose, so a perspective
976
- // camera does not move.
977
- if (cam) viewer.setCameraState(cam);
978
- }
979
- cameraRestored = true;
1006
+ // fallback pose from the wrong view.) A cached preview defers it to
1007
+ // the live build below.
1008
+ if (!previewGate.isActive()) restoreCarriedCutaway(cam);
980
1009
  }
981
1010
  }
1011
+ if (cameraRestored && !cutawayRestored && !previewGate.isActive()) {
1012
+ // The preview held the cut back and the live build is in. Re-place the
1013
+ // camera the user is looking at NOW — they may have orbited the preview
1014
+ // — not the carried one.
1015
+ restoreCarriedCutaway(viewer.getCameraState());
1016
+ }
982
1017
  }
983
1018
 
984
1019
  // Frames the realistic look reads, for the geometry just delivered:
@@ -1042,6 +1077,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1042
1077
 
1043
1078
  // Sub-parts whose latest fresh delivery had zero triangles (see the `meshes` case).
1044
1079
  const emptySubParts = new Set();
1080
+ // Any live delivery has landed. A preview decoded after this is dropped unseen.
1081
+ let liveDelivered = false;
1045
1082
 
1046
1083
  function refreshView() {
1047
1084
  const needed = viewSubParts(part, view(), params);
@@ -1055,6 +1092,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1055
1092
  } else if (needed.every((n) => viewer.hasSubMesh(n))) {
1056
1093
  showView(needed); // stale but present — keep it visible during regenerate
1057
1094
  ui.setExportEnabled(false);
1095
+ } else if (previewGate.isActive()) {
1096
+ // A cached preview that covers only part of the view: show what it has.
1097
+ showView(needed.filter((n) => viewer.hasSubMesh(n)));
1098
+ ui.setExportEnabled(false);
1058
1099
  } else {
1059
1100
  viewer.hideAssembly();
1060
1101
  ui.setExportEnabled(false);
@@ -1086,6 +1127,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1086
1127
  case "meshes": {
1087
1128
  const fresh = loop.buildDone();
1088
1129
  if (fresh) { // stale results (params changed mid-build) are discarded
1130
+ liveDelivered = true; // only a delivery that is applied beats a preview still decoding
1089
1131
  for (const m of data.meshes) {
1090
1132
  viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
1091
1133
  // The view its job was SENT for, and the frame the worker delivered
@@ -1112,6 +1154,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1112
1154
  // forgetting on a disagreement would rebuild at the same params forever).
1113
1155
  fastPath.apply(data.meshes.map((m) => m.name), undefined, { fresh: true });
1114
1156
  recordFrames(data.meshes);
1157
+ // The whole view is live: every sub-part the preview drew has just been
1158
+ // replaced in place, so the tools it held back come back. Before
1159
+ // refreshView, so showView restores a carried cutaway on this build.
1160
+ if (previewGate.isActive() && missingParts().length === 0) previewGate.end();
1115
1161
  // A split dispatch answers in two meshes replies; the busy spinner
1116
1162
  // stays up until the view has everything (the other worker's job may
1117
1163
  // still be running — often OCCT, the slow one).
@@ -1459,8 +1505,73 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1459
1505
  return binding;
1460
1506
  };
1461
1507
 
1508
+ // Set by the first cleanup to run at dispose (LIFO): late async work checks it.
1509
+ let disposed = false;
1510
+ cleanup.defer(() => { disposed = true; });
1511
+
1512
+ // The cached preview: a GLB from partforge/gltf, decoded off the worker path
1513
+ // and drawn through setSubGeometry like any delivery — so the first live
1514
+ // `meshes` reply replaces it in place, sub-part by sub-part, and showView
1515
+ // keeps the camera (the view was already framed). Only a file for the view on
1516
+ // screen is drawn, and only its sub-parts of that view; anything else — or a
1517
+ // file that will not decode — is logged once and ignored.
1518
+ if (preview != null) {
1519
+ // Loaded on demand: the GLTFLoader and meshopt decoder behind it stay out
1520
+ // of every bundle that never passes a preview.
1521
+ import("./gltf/decode-preview.js").then(({ decodePreviewGlb }) => (
1522
+ disposed ? null : decodePreviewGlb(preview)
1523
+ )).then((decoded) => {
1524
+ if (decoded == null) return;
1525
+ const { view: previewView, meshes } = decoded;
1526
+ if (disposed || liveDelivered) return; // live geometry always wins
1527
+ const current = view();
1528
+ if (previewView != null && previewView !== current) {
1529
+ console.warn(`partforge: preview ignored — it shows view "${previewView}", the viewer opened on "${current}"`);
1530
+ return;
1531
+ }
1532
+ const needed = viewSubParts(part, current, params);
1533
+ const shown = meshes.filter((m) => needed.includes(m.name));
1534
+ if (shown.length === 0) {
1535
+ console.warn("partforge: preview ignored — none of its meshes is a sub-part of the view on screen");
1536
+ return;
1537
+ }
1538
+ measureMode.setEnabled(false);
1539
+ annotateMode?.setEnabled(false);
1540
+ previewGate.start();
1541
+ for (const m of shown) viewer.setSubGeometry(m.name, m);
1542
+ refreshView();
1543
+ }).catch((error) => {
1544
+ if (!disposed) console.warn(`partforge: preview ignored — ${error?.message ?? error}`);
1545
+ });
1546
+ }
1547
+
1548
+ // runtime.exportPreview: the view on screen as a preview GLB — every sub-part's
1549
+ // drawn mesh with its fast-path pose baked in, the posed meshes a headless
1550
+ // buildView returns. Only the view on screen (no other view's meshes are held),
1551
+ // only when it is fully built at the live params (missingParts covers both a
1552
+ // first build and an edit still rebuilding), never a cached preview. The
1553
+ // encoder loads on first use: a host that never exports never pays for it.
1554
+ async function exportPreview({ view: wanted } = {}) {
1555
+ if (disposed) throw new Error("exportPreview: this mount is disposed");
1556
+ if (previewGate.isActive()) throw new Error("exportPreview: a cached preview is showing — wait for the first build");
1557
+ const current = view();
1558
+ if (wanted != null && wanted !== current) {
1559
+ throw new Error(`exportPreview: "${wanted}" is not the view on screen ("${current}")`);
1560
+ }
1561
+ const needed = viewSubParts(part, current, params);
1562
+ if (needed.length === 0 || missingParts().length > 0) {
1563
+ throw new Error("exportPreview: the view on screen is not fully built");
1564
+ }
1565
+ const meshes = needed.map((name) => viewer.getSubMeshPayload(name)).filter(Boolean);
1566
+ const { encodePreviewGlb } = await import("./gltf/encode-preview.js");
1567
+ return encodePreviewGlb({ meshes, part, view: current });
1568
+ }
1569
+
1462
1570
  return makeHandle({
1463
1571
  ready, dispose, viewer, setParams,
1572
+ isPreviewActive: () => previewGate.isActive(),
1573
+ pendingCutaway: () => (!cutawayRestored ? viewerState?.cutaway ?? null : null),
1574
+ exportPreview,
1464
1575
  attachTooltips: attachHostTooltips,
1465
1576
  setHostPane: paneTabs.setHostPane,
1466
1577
  setRailLayout: paneTabs.setRailLayout,
@@ -1481,13 +1592,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1481
1592
  animation: animCtl?.runtime ?? null,
1482
1593
  measure: {
1483
1594
  isEnabled: measureMode.isEnabled,
1484
- setEnabled: measureMode.setEnabled,
1595
+ // Held off while a cached preview shows (no feature ids to dimension).
1596
+ setEnabled: (on) => { if (on && previewGate.isActive()) return; measureMode.setEnabled(on); },
1485
1597
  clearPins: measureMode.clearPins,
1486
1598
  pinCount: measureMode.pinCount,
1487
1599
  },
1488
1600
  annotate: annotateMode ? {
1489
1601
  isEnabled: annotateMode.isEnabled,
1490
- setEnabled: annotateMode.setEnabled,
1602
+ setEnabled: (on) => { if (on && previewGate.isActive()) return; annotateMode.setEnabled(on); },
1491
1603
  undo: annotateMode.undo,
1492
1604
  clear: annotateMode.clear,
1493
1605
  strokeCount: annotateMode.strokeCount,
@@ -0,0 +1,37 @@
1
+ // Whether a cached preview (mount's `preview` option) is on screen, and the
2
+ // chrome it holds off meanwhile. A preview mesh has no feature ids and no live
3
+ // solid behind it, so the modes that read those — cutaway, measure, sketch —
4
+ // wait for the live build; picking and hover ask isActive() per event. Each
5
+ // button gets back exactly the `disabled` it had (a host may disable one for its
6
+ // own reasons, and that must survive the round trip).
7
+ export function createPreviewGate({ buttons = [] } = {}) {
8
+ let active = false;
9
+ const held = []; // { el, wasDisabled }
10
+ const listeners = new Set();
11
+ const announce = () => {
12
+ for (const cb of [...listeners]) cb(active);
13
+ };
14
+ return {
15
+ isActive: () => active,
16
+ start() {
17
+ if (active) return;
18
+ active = true;
19
+ for (const el of buttons) {
20
+ if (!el) continue;
21
+ held.push({ el, wasDisabled: el.disabled });
22
+ el.disabled = true;
23
+ }
24
+ announce();
25
+ },
26
+ end() {
27
+ if (!active) return;
28
+ active = false;
29
+ for (const { el, wasDisabled } of held.splice(0)) el.disabled = wasDisabled;
30
+ announce();
31
+ },
32
+ onChange(cb) {
33
+ listeners.add(cb);
34
+ return () => listeners.delete(cb);
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,23 @@
1
+ // Publish the viewbar's vertical claim as --pf-viewbar-clear on the stage, so
2
+ // chrome.css can stack the view cube on top of it without hardcoding a height
3
+ // that cutaway/measure/annotate action rows can change. A hidden bar (ours or a
4
+ // host stylesheet's) claims 0 — stage-clear.js has the why; the observer fires
5
+ // on the hide itself, since the bar's box goes to zero. Shared by mount() and
6
+ // mountPreview(). Returns a dispose; a no-op without a bar or a ResizeObserver.
7
+ import { stageClearFor } from "./stage-clear.js";
8
+
9
+ export function observeViewbarClear(stage, viewbar) {
10
+ if (!viewbar || typeof ResizeObserver !== "function") return () => {};
11
+ const publish = () => {
12
+ const clear = stageClearFor(stage.getBoundingClientRect(), viewbar.getBoundingClientRect());
13
+ stage.style.setProperty("--pf-viewbar-clear", `${clear}px`);
14
+ };
15
+ const observer = new ResizeObserver(publish);
16
+ observer.observe(viewbar);
17
+ observer.observe(stage);
18
+ publish();
19
+ return () => {
20
+ observer.disconnect();
21
+ stage.style.removeProperty("--pf-viewbar-clear");
22
+ };
23
+ }
@@ -1642,6 +1642,39 @@ export function createViewer(container, part) {
1642
1642
  const hasSubMesh = (name) => !!subCache[name];
1643
1643
  const subTriangles = (name) => subCache[name]?.userData.triangles ?? 0;
1644
1644
 
1645
+ // The geometry drawn for one sub-part, as a fresh worker-style payload with its
1646
+ // fast-path pose (setSubPose) baked in — the posed mesh a headless buildView
1647
+ // would return. Copies: the caller (exportPreview → partforge/gltf) may keep or
1648
+ // transfer them. null when nothing has been delivered under that name.
1649
+ const _payloadV = new THREE.Vector3();
1650
+ const _payloadNormal = new THREE.Matrix3();
1651
+ function getSubMeshPayload(name) {
1652
+ const geo = subCache[name];
1653
+ if (!geo) return null;
1654
+ const pose = subMesh[name].matrix; // identity unless the fast path posed it
1655
+ _payloadNormal.getNormalMatrix(pose);
1656
+ const pos = geo.getAttribute("position");
1657
+ const positions = new Float32Array(pos.count * 3);
1658
+ for (let i = 0; i < pos.count; i++) {
1659
+ _payloadV.fromBufferAttribute(pos, i).applyMatrix4(pose).toArray(positions, i * 3);
1660
+ }
1661
+ const nor = geo.getAttribute("normal");
1662
+ let normals = null;
1663
+ if (nor) {
1664
+ normals = new Float32Array(nor.count * 3);
1665
+ for (let i = 0; i < nor.count; i++) {
1666
+ _payloadV.fromBufferAttribute(nor, i).applyMatrix3(_payloadNormal).normalize().toArray(normals, i * 3);
1667
+ }
1668
+ }
1669
+ return {
1670
+ name,
1671
+ positions,
1672
+ normals,
1673
+ indices: geo.index ? Uint32Array.from(geo.index.array) : null,
1674
+ triangles: geo.userData.triangles ?? (geo.index ? geo.index.count : pos.count) / 3,
1675
+ };
1676
+ }
1677
+
1645
1678
  // --- show / hide assembly -------------------------------------------------
1646
1679
  const _box = new THREE.Box3();
1647
1680
  const _posedBox = new THREE.Box3();
@@ -2727,6 +2760,7 @@ export function createViewer(container, part) {
2727
2760
  clearSubPartOpacities,
2728
2761
  hasSubMesh,
2729
2762
  subTriangles,
2763
+ getSubMeshPayload,
2730
2764
  frame,
2731
2765
  captureCanonicalViews,
2732
2766
  captureCurrent,
package/src/gltf.js ADDED
@@ -0,0 +1,6 @@
1
+ // partforge/gltf — the cached-preview writer. One view's posed display meshes as a
2
+ // small GLB (see src/framework/gltf/encode-preview.js for the file's contract).
3
+ // Worker-safe: no three.js, no DOM, so it runs in Node (a server-side preview job),
4
+ // a page (runtime.exportPreview) and a Web Worker alike. Read the files back with
5
+ // mount()'s `preview` option or mountPreview(), both on the main entry.
6
+ export { encodePreviewGlb, PREVIEW_ENCODER_VERSION } from "./framework/gltf/encode-preview.js";
package/src/index.js CHANGED
@@ -3,8 +3,13 @@
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
+ // The kernel-free viewer for a cached preview GLB (partforge/gltf): no part, no worker.
7
+ export { mountPreview } from "./framework/mount-preview.js";
6
8
  export { viewSubParts } from "./framework/part-model.js";
7
9
  // The cut & print kit's options surface, for a host that draws its own options screen
8
10
  // (partforge-cloud's coupling test reads KIT_OPTIONS_ERROR from here). export/formats.js
9
11
  // is import-free, so this adds nothing to the app bundle but a few constants.
10
12
  export { EXPORT_FORMATS, KIT_OPTIONS_ERROR, KIT_DEFAULTS, validateKitOptions } from "./framework/export/formats.js";
13
+ // Which view mount() opens on — for a host that builds a part outside the viewer
14
+ // (a cached preview, a server-side render) and must agree with it.
15
+ export { resolveDefaultView } from "./framework/default-view.js";
package/src/testing.js CHANGED
@@ -11,6 +11,7 @@ export { createManifoldKernel } from "./framework/geometry/manifold-backend.js";
11
11
  export { bootManifoldKernel } from "./testing/manifold.js";
12
12
  export { handle } from "./framework/jobs.js";
13
13
  export { viewSubParts } from "./framework/part-model.js";
14
+ export { resolveDefaultView } from "./framework/default-view.js";
14
15
  export { resolveDerived } from "./framework/derive.js";
15
16
  export { relevantParamKeys, RELEVANT_ALL } from "./framework/param-deps.js";
16
17
  export { assemblyOverlaps } from "./framework/assembly.js";
@@ -0,0 +1,40 @@
1
+ // partforge/gltf — the cached-preview writer. Worker-safe: no three.js, no DOM.
2
+
3
+ import type { SubPartDefinition } from "./part.js";
4
+
5
+ /** The slice of a part `encodePreviewGlb` reads; any `PartDefinition` satisfies it, so does a plain object. */
6
+ export interface PreviewPart {
7
+ parts?: Record<string, { display?: SubPartDisplay }>;
8
+ meta?: { background?: number; environment?: string };
9
+ }
10
+
11
+ type SubPartDisplay = NonNullable<SubPartDefinition["display"]>;
12
+
13
+ /** One sub-part's posed display mesh — the shape the viewer, the worker's `meshes` reply and `buildView` hold. */
14
+ export interface PreviewMesh {
15
+ /** The sub-part key; names the glTF node, mesh and material. */
16
+ name: string;
17
+ /** xyz triples in millimetres, Z-up, already posed. */
18
+ positions: Float32Array;
19
+ normals?: Float32Array | null;
20
+ /** Absent for a non-indexed soup (Manifold); OCCT sends indices. */
21
+ indices?: Uint32Array | null;
22
+ triangles?: number;
23
+ /** Ignored: feature edges are not encoded in this version. */
24
+ edges?: Float32Array | null;
25
+ }
26
+
27
+ export interface EncodePreviewInput {
28
+ /** At least one; a zero-length mesh gets no node. */
29
+ meshes: PreviewMesh[];
30
+ /** Read for `parts[name].display` (materials) and `meta.background` / `meta.environment`. */
31
+ part?: PreviewPart | null;
32
+ /** The view these meshes show; names the scene and lands in the file's extras as `partforgeView`. */
33
+ view?: string | null;
34
+ }
35
+
36
+ /** Bumped whenever the encoder writes different bytes for the same input. */
37
+ export const PREVIEW_ENCODER_VERSION: number;
38
+
39
+ /** Encode one view's posed meshes as a welded, quantized, meshopt-compressed GLB (Y-up, millimetres). Deterministic. */
40
+ export function encodePreviewGlb(input: EncodePreviewInput): Promise<Uint8Array>;
package/types/index.d.ts CHANGED
@@ -243,6 +243,17 @@ export interface MountOptions {
243
243
  * dropped, never fatal.
244
244
  */
245
245
  viewerState?: ViewerState | null;
246
+ /**
247
+ * A cached preview GLB (from `partforge/gltf`'s `encodePreviewGlb`, or a previous
248
+ * runtime's `exportPreview()`) drawn before the worker is ready and replaced in
249
+ * place, sub-part by sub-part, by the first live build — the camera is kept.
250
+ * Only a file for the view the viewer opens on is drawn, and only meshes named
251
+ * for that view's sub-parts; a file for another view, or one that will not
252
+ * decode, is logged once and ignored. A file written without a view (no `view`
253
+ * given to `encodePreviewGlb`) is drawn on sub-part name match alone, for any view.
254
+ * Carries no edge lines.
255
+ */
256
+ preview?: Uint8Array | ArrayBuffer | null;
246
257
  /**
247
258
  * A previous mount's `runtime.getPanelState()`: the transient state of the
248
259
  * part's custom controls (a selected tile), keyed by param. Same remount
@@ -613,6 +624,22 @@ export interface PartRuntime {
613
624
  * view-cube click, Reframe, or an animation cue).
614
625
  */
615
626
  getViewerState(): ViewerState;
627
+ /**
628
+ * True while the `preview` GLB is on screen: from the moment it decodes until a
629
+ * build delivers the whole view (a build error leaves it on). Picking, hover
630
+ * labels, measure, sketch and cutaway wait meanwhile; orbit, zoom, pan, the view
631
+ * cube, the view style and the rail work. Read it from the runtime each time —
632
+ * a copy of the handle freezes it.
633
+ */
634
+ readonly previewActive: boolean;
635
+ /**
636
+ * The view on screen as a preview GLB (`partforge/gltf`'s format), every
637
+ * sub-part posed exactly as drawn — what a later `mount({ preview })` shows
638
+ * before its kernel boots. Rejects while `previewActive`, before the view is
639
+ * fully built at the current params (a pending rebuild counts), for a `view`
640
+ * other than the one on screen, and after `dispose()`.
641
+ */
642
+ exportPreview(opts?: { view?: string }): Promise<Uint8Array>;
616
643
  /** Every custom control's non-empty transient state, keyed by param. Hand back as `panelState`. */
617
644
  getPanelState(): PanelState;
618
645
  /** What custom controls reported failing this mount, in order. */
@@ -698,6 +725,52 @@ export interface PartRuntime {
698
725
  /** Mount a full parametric-part app from a `PartDefinition`. */
699
726
  export function mount(part: PartDefinition, options: MountOptions): PartRuntime;
700
727
 
728
+ /** Options for `mountPreview`. */
729
+ export interface MountPreviewOptions {
730
+ /** A GLB from `partforge/gltf`'s `encodePreviewGlb`, or a runtime's `exportPreview()`. */
731
+ glb: Uint8Array | ArrayBuffer;
732
+ /** Optional viewer buttons, wired as `mount()` wires them; `#viewbar` inside the container is found by id. */
733
+ elements?: { chrome?: { reframe?: HTMLElement | null; theme?: HTMLElement | null } };
734
+ /** A previous runtime's `getViewerState()`; `cutaway` is ignored (a preview has no live solid). */
735
+ viewerState?: ViewerState | null;
736
+ }
737
+
738
+ /** The handle `mountPreview()` returns. Every method is safe before `ready` settles. */
739
+ export interface PreviewRuntime {
740
+ /** Resolves once the GLB is drawn; rejects when it will not decode or holds no meshes. */
741
+ ready: Promise<void>;
742
+ /** Full teardown. Idempotent. */
743
+ dispose(): void;
744
+ /** The view the file was written for (`null` until drawn, or when the file names none). */
745
+ getView(): string | null;
746
+ /** Camera, projection, render mode and environment; `cutaway` is always `null`. */
747
+ getViewerState(): ViewerState;
748
+ /** Apply a state (camera, projection, render mode, environment) now, or once drawn. */
749
+ setViewerState(state: ViewerState | null): void;
750
+ /** Park/unpark the viewer, as `PartRuntime.setActive`. */
751
+ setActive(active: boolean): void;
752
+ /** WebGL context loss. Returns an unsubscribe. */
753
+ onContextLost(listener: () => void): () => void;
754
+ /** Accepted and ignored — there is no rail. */
755
+ setHostPane(pane: HostPane): void;
756
+ /** Accepted and ignored — there is no rail. */
757
+ setRailLayout(layout: HostRailLayout): void;
758
+ renderMode: {
759
+ get(): RenderMode;
760
+ set(mode: RenderMode): Promise<RenderMode>;
761
+ onChange(cb: (e: RenderModeEvent) => void): () => void;
762
+ };
763
+ environment: {
764
+ get(): string;
765
+ set(id: string): Promise<string>;
766
+ onChange(cb: (id: string) => void): () => void;
767
+ list(): Array<{ id: string; label: string }>;
768
+ };
769
+ }
770
+
771
+ /** A kernel-free viewer for a cached preview GLB: the viewer, view cube, view style and theme — no part, no worker, no rail. */
772
+ export function mountPreview(container: HTMLElement, options: MountPreviewOptions): PreviewRuntime;
773
+
701
774
  /** Every format `exportParts` can write in this version (`runtime.listExportFormats()` returns copies). */
702
775
  export const EXPORT_FORMATS: ReadonlyArray<ExportFormatInfo>;
703
776
 
@@ -729,3 +802,10 @@ export function viewSubParts(
729
802
  params: Record<string, ParamValue>,
730
803
  ): string[];
731
804
 
805
+ /**
806
+ * The view `mount()` opens on: a view flagged `default: true`, else the one
807
+ * placing the most sub-parts at the part's defaults (ties keep declaration
808
+ * order). `null` for a part with no views. Exported so a host building a part
809
+ * outside the viewer — a cached preview — opens the same view.
810
+ */
811
+ export function resolveDefaultView(part: PartDefinition): string | null;
@@ -869,3 +869,6 @@ export function annotationRay(
869
869
  ): AnnotationRay;
870
870
  /** Intersect a ray with a plane; null on parallel / behind-origin misses. */
871
871
  export function rayPlane(ray: AnnotationRay, plane: PlaneSpec): RayPlaneHit | null;
872
+
873
+ /** The view `mount()` opens on (see the main entry's declaration). */
874
+ export function resolveDefaultView(part: PartDefinition): string | null;