partforge 0.146.3 → 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 +25 -0
- package/docs/AUTHORING-PARTS.md +6 -1
- package/package.json +12 -1
- package/src/framework/default-view.js +6 -3
- package/src/framework/gltf/decode-preview.js +89 -0
- package/src/framework/gltf/encode-preview.js +142 -0
- package/src/framework/mount-preview.js +157 -0
- package/src/framework/mount.js +150 -38
- package/src/framework/panel/widgets/custom.js +22 -1
- package/src/framework/panel/widgets/numeric.js +12 -2
- package/src/framework/preview-gate.js +37 -0
- package/src/framework/viewbar-clear.js +23 -0
- package/src/framework/viewer.js +34 -0
- package/src/gltf.js +6 -0
- package/src/index.js +5 -0
- package/src/testing.js +1 -0
- package/types/gltf.d.ts +40 -0
- package/types/index.d.ts +80 -0
- package/types/testing.d.ts +3 -0
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/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -1248,7 +1248,12 @@ export function tilePicker(host) {
|
|
|
1248
1248
|
`update({reason, disabled})` is called when your key changes from outside the widget
|
|
1249
1249
|
(`reason: "sync"` — a preset, undo, `setParams`), when `derived` changes
|
|
1250
1250
|
(`"derived"`), and once after `host.state` was restored on mount (`"restore"`). It is
|
|
1251
|
-
**not** called for your own `set
|
|
1251
|
+
**not** called for your own `set`, and a `"derived"` update is **held while a gesture is
|
|
1252
|
+
in flight** — a `set` with `commit: false`, or a `host.controls` sub-control being
|
|
1253
|
+
edited — and delivered once `host.commit()` ends it (`host.derived` is current
|
|
1254
|
+
throughout). That is what lets the pattern above rebuild its sub-panel on every
|
|
1255
|
+
`update` without pulling the slider or number box out from under the user: every
|
|
1256
|
+
build the drag itself triggers ends in a derived update. `dispose()` runs on teardown.
|
|
1252
1257
|
|
|
1253
1258
|
**Rules that keep it working.**
|
|
1254
1259
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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.
|
|
246
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
969
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -69,6 +69,10 @@ export function makeCustom(node, params, { onChange, onCommit, info, custom = {}
|
|
|
69
69
|
let instance = null;
|
|
70
70
|
let retired = false;
|
|
71
71
|
let lastDerived = "{}";
|
|
72
|
+
// A derived update that arrived while a deferred gesture was in flight
|
|
73
|
+
// (`pending` non-empty): held, delivered once the gesture commits. See
|
|
74
|
+
// `update` below.
|
|
75
|
+
let heldUpdate = false;
|
|
72
76
|
|
|
73
77
|
const report = (phase, message) => onError?.({ key: node.key, label, phase, message });
|
|
74
78
|
|
|
@@ -131,6 +135,9 @@ export function makeCustom(node, params, { onChange, onCommit, info, custom = {}
|
|
|
131
135
|
if (!changed.length) return;
|
|
132
136
|
for (const k of changed) pending.delete(k);
|
|
133
137
|
onCommit?.(changed);
|
|
138
|
+
// The gesture is over: deliver the derived update it held back (once,
|
|
139
|
+
// for the latest derived — host.derived already carries it).
|
|
140
|
+
if (heldUpdate && pending.size === 0) { heldUpdate = false; update("derived"); }
|
|
134
141
|
},
|
|
135
142
|
|
|
136
143
|
setState(patch) { if (retired) return; Object.assign(host.state, patch); },
|
|
@@ -210,7 +217,21 @@ export function makeCustom(node, params, { onChange, onCommit, info, custom = {}
|
|
|
210
217
|
instance = r && typeof r === "object" ? r : null;
|
|
211
218
|
});
|
|
212
219
|
|
|
213
|
-
|
|
220
|
+
// A derived update is HELD while a deferred gesture is in flight — a
|
|
221
|
+
// `host.set(…, {commit: false})` or a host.controls sub-control edit that
|
|
222
|
+
// has not committed yet. Every build the gesture itself triggers (a slider
|
|
223
|
+
// drag's live preview, each keystroke in a number box) ends in a derived
|
|
224
|
+
// refresh, and the documented widget pattern answers update() by rebuilding
|
|
225
|
+
// its sub-panel — which tore the input being edited out of the DOM before
|
|
226
|
+
// its `change` could fire. Chromium dispatches `change` on detach; WebKit
|
|
227
|
+
// does not, so on Safari the edit reached the viewer and was never
|
|
228
|
+
// committed (partforge-cloud feedback #172). host.derived is still updated
|
|
229
|
+
// at once; only the call is deferred, to the commit that ends the gesture.
|
|
230
|
+
// `sync` is never held: an outside write supersedes the gesture.
|
|
231
|
+
const update = (reason) => {
|
|
232
|
+
if (reason === "derived" && pending.size > 0) { heldUpdate = true; return; }
|
|
233
|
+
guarded("update", () => instance?.update?.({ reason, disabled: host.disabled }));
|
|
234
|
+
};
|
|
214
235
|
|
|
215
236
|
// Restoring panel state is not part of creation: a throw here is a bad
|
|
216
237
|
// `update`, not a bad `create` — the widget did construct successfully.
|
|
@@ -67,6 +67,10 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
|
|
|
67
67
|
// the log mapping don't, so be explicit).
|
|
68
68
|
const thumbFor = (v) => log ? toPosSafe(v) : Math.max(node.min, Math.min(node.max, v));
|
|
69
69
|
|
|
70
|
+
// Edited (an `input`) since the last `change`. A widget torn down mid-edit
|
|
71
|
+
// commits what it holds (dispose below): the DOM fires no `change` for a
|
|
72
|
+
// detached input on WebKit, and the value is already in params either way.
|
|
73
|
+
let dirty = false;
|
|
70
74
|
let slider = null;
|
|
71
75
|
if (!numeric) {
|
|
72
76
|
slider = document.createElement("input");
|
|
@@ -78,9 +82,10 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
|
|
|
78
82
|
params[node.key] = v;
|
|
79
83
|
box.value = numStr(v);
|
|
80
84
|
paintWarn();
|
|
85
|
+
dirty = true;
|
|
81
86
|
onChange?.();
|
|
82
87
|
});
|
|
83
|
-
slider.addEventListener("change", () => onCommit?.());
|
|
88
|
+
slider.addEventListener("change", () => { dirty = false; onCommit?.(); });
|
|
84
89
|
wrap.append(slider);
|
|
85
90
|
}
|
|
86
91
|
|
|
@@ -131,6 +136,7 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
|
|
|
131
136
|
params[node.key] = v;
|
|
132
137
|
if (slider) slider.value = thumbFor(v);
|
|
133
138
|
paintWarn();
|
|
139
|
+
dirty = true;
|
|
134
140
|
onChange?.();
|
|
135
141
|
});
|
|
136
142
|
box.addEventListener("change", () => {
|
|
@@ -140,6 +146,7 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
|
|
|
140
146
|
box.value = numStr(v);
|
|
141
147
|
if (slider) slider.value = thumbFor(v);
|
|
142
148
|
paintWarn();
|
|
149
|
+
dirty = false;
|
|
143
150
|
onChange?.();
|
|
144
151
|
onCommit?.();
|
|
145
152
|
});
|
|
@@ -150,5 +157,8 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
|
|
|
150
157
|
paintWarn();
|
|
151
158
|
};
|
|
152
159
|
paintWarn();
|
|
153
|
-
|
|
160
|
+
// Torn down with an edit still uncommitted (a sub-panel rebuilt under the
|
|
161
|
+
// pointer, a remount): commit it, since no `change` is coming.
|
|
162
|
+
const dispose = () => { if (dirty) { dirty = false; onCommit?.(); } };
|
|
163
|
+
return { el: wrap, sync, dispose };
|
|
154
164
|
}
|
|
@@ -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
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -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";
|
package/types/gltf.d.ts
ADDED
|
@@ -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;
|
package/types/testing.d.ts
CHANGED
|
@@ -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;
|