partforge 0.156.0 → 0.158.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/docs/AUTHORING-PARTS.md +41 -16
- package/package.json +1 -1
- package/src/framework/export/bundle.js +47 -26
- package/src/framework/export/drawing.js +35 -2
- package/src/framework/export/formats.js +10 -2
- package/src/framework/export/readme.js +20 -7
- package/src/framework/geometry/pose.js +20 -0
- package/src/framework/gltf/decode-preview.js +79 -7
- package/src/framework/gltf/encode-preview.js +196 -10
- package/src/framework/mount-preview.js +28 -2
- package/src/framework/mount.js +23 -6
- package/src/framework/oracle/build.js +30 -3
- package/src/framework/preview-animation.js +128 -0
- package/src/framework/preview-player.js +161 -0
- package/src/framework/process/cnc-router/descriptor.js +41 -19
- package/src/framework/process/cnc-router/export.js +54 -31
- package/src/framework/sheet/routed-resolve.js +16 -4
- package/src/oracle.js +3 -0
- package/types/geometry.d.ts +6 -4
- package/types/gltf.d.ts +14 -1
- package/types/index.d.ts +14 -0
- package/types/oracle.d.ts +78 -0
- package/types/part.d.ts +2 -0
- package/types/testing.d.ts +11 -0
|
@@ -33,7 +33,37 @@
|
|
|
33
33
|
// - each material from cadAppearance — the CAD look the viewer itself draws —
|
|
34
34
|
// and each node's extras carrying the sub-part's own `display` block, so a
|
|
35
35
|
// part-less viewer (mountPreview) can rebuild the exact look, realistic too
|
|
36
|
-
// - top-level extras { partforgeView, partforgeEncoder, partforgeMeta
|
|
36
|
+
// - top-level extras { partforgeView, partforgeEncoder, partforgeMeta?,
|
|
37
|
+
// partforgeAnimation? }
|
|
38
|
+
// - an `animation` (samplePreviewAnimation's output, version 4) adds:
|
|
39
|
+
// * every sampled sub-part node's rest TRS = rel(0), where
|
|
40
|
+
// rel(t) = M(t) · base⁻¹ carries the mesh from the pose baked into its
|
|
41
|
+
// positions to the pose at normalized time t. TRS is in the root's local
|
|
42
|
+
// frame, which is the part's Z-up model frame, so no frame change.
|
|
43
|
+
// * one glTF animation named anim.name, holding a LINEAR `translation` and
|
|
44
|
+
// `rotation` channel (input in SECONDS, t · totalDuration) for each
|
|
45
|
+
// sub-part whose rel(t) differs from rel(0) beyond 1e-6 — none at all
|
|
46
|
+
// when nothing moves (a camera-only animation). Consecutive quaternions
|
|
47
|
+
// share a hemisphere, so LINEAR blending never takes the long way round.
|
|
48
|
+
// An animated node's mesh sits on a child node (quantize() moves it
|
|
49
|
+
// there to keep the dequantization off the animated TRS); the writer
|
|
50
|
+
// gives that child the sub-part's name and the SAME extras as the
|
|
51
|
+
// animated node, so a reader that looks only one level up from a mesh
|
|
52
|
+
// (≤ 0.157) still names, styles and edges it, at the opening pose.
|
|
53
|
+
// * partforgeAnimation = { name, label, loop, duration (seconds), cues
|
|
54
|
+
// (verbatim), steps: [{ label, start (normalized), duration (seconds) }],
|
|
55
|
+
// animated (names with channels, mesh order), opacity: { <sub>: [[t, v],
|
|
56
|
+
// …] } } — t normalized, one pair per sample that names the sub-part (a
|
|
57
|
+
// sample that omits it contributes no pair; a reader interpolates).
|
|
58
|
+
// The samples are refused unless every pose is rigid (RᵀR = I, det = +1,
|
|
59
|
+
// within 1e-6 — geometry/pose.js's isRigid, which the sampler applies too),
|
|
60
|
+
// they run from t = 0 to t = 1, every sample poses the same sub-parts, and
|
|
61
|
+
// each of those (and each `base` entry) is one of `meshes`. A sampled
|
|
62
|
+
// sub-part whose mesh is present but EMPTY (its build() returned nothing) is
|
|
63
|
+
// dropped from the animation — it has no node — and the rest still plays.
|
|
64
|
+
// The channels are stored LOSSLESSLY: EXT_meshopt_compression's QUANTIZE
|
|
65
|
+
// method applies no meshopt filter (gltf-transform filters only under its
|
|
66
|
+
// FILTER method), so the float32 keyframes are byte-compressed, not rounded.
|
|
37
67
|
// - weld() → reorder() → quantize() → EXT_meshopt_compression ("medium",
|
|
38
68
|
// i.e. what meshopt({ level: "medium" }) does), 5–8 bytes a triangle, the
|
|
39
69
|
// recipe measured in partforge-cloud's local_glb_benchmark.md. The line
|
|
@@ -55,8 +85,11 @@ import { EXTMeshoptCompression, KHRMeshQuantization } from "@gltf-transform/exte
|
|
|
55
85
|
import { quantize, reorder, weld } from "@gltf-transform/functions";
|
|
56
86
|
import { MeshoptEncoder } from "meshoptimizer/encoder";
|
|
57
87
|
import { cadAppearance, DISPLAY_KEYS } from "../materials/resolve.js";
|
|
88
|
+
import { invertRigid, isRigid, mulMat4 } from "../geometry/pose.js";
|
|
58
89
|
|
|
59
|
-
|
|
90
|
+
// 2: feature edges as a LINES primitive; 3: sharing the surface's vertices;
|
|
91
|
+
// 4: animation channels + partforgeAnimation extras
|
|
92
|
+
export const PREVIEW_ENCODER_VERSION = 4;
|
|
60
93
|
|
|
61
94
|
// −90° about X as [x, y, z, w]: model (x, y, z) → glTF (x, z, −y), the viewer's pivot.
|
|
62
95
|
const Z_UP_TO_Y_UP = [-Math.SQRT1_2, 0, 0, Math.SQRT1_2];
|
|
@@ -278,10 +311,142 @@ function checkMesh(m, seen) {
|
|
|
278
311
|
}
|
|
279
312
|
}
|
|
280
313
|
|
|
281
|
-
|
|
314
|
+
// ── The baked animation (version 4) ─────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
const MOVES_TOL = 1e-6;
|
|
317
|
+
|
|
318
|
+
// Refuse what the file could not carry faithfully; returns the sampled names
|
|
319
|
+
// that are drawn, in mesh order. `drawn` is the set of names with a non-empty
|
|
320
|
+
// mesh. A name with NO mesh entry is refused (fail closed: the sampler and the
|
|
321
|
+
// meshes disagree about the view); a name whose mesh is present but EMPTY (a
|
|
322
|
+
// sub-part whose build() returned nothing) is dropped — it has no node to
|
|
323
|
+
// animate, and the rest of the file still plays.
|
|
324
|
+
function checkAnimation(animation, meshes, drawn) {
|
|
325
|
+
const { anim, samples, base } = animation;
|
|
326
|
+
if (!anim || typeof anim !== "object" || typeof anim.name !== "string") throw new TypeError("encodePreviewGlb: animation.anim must be a normalized animation");
|
|
327
|
+
if (!(Number.isFinite(anim.totalDuration) && anim.totalDuration > 0)) throw new Error("encodePreviewGlb: animation duration must be a positive number");
|
|
328
|
+
if (!base || typeof base !== "object") throw new TypeError("encodePreviewGlb: animation.base must map sub-parts to matrices");
|
|
329
|
+
if (!Array.isArray(samples) || samples.length < 2) throw new Error("encodePreviewGlb: animation samples must run from t = 0 to t = 1");
|
|
330
|
+
if (samples[0].t !== 0) throw new Error("encodePreviewGlb: animation samples must start at t = 0");
|
|
331
|
+
if (samples.at(-1).t !== 1) throw new Error("encodePreviewGlb: animation samples must end at t = 1");
|
|
332
|
+
for (let i = 1; i < samples.length; i++) {
|
|
333
|
+
if (!(samples[i].t > samples[i - 1].t)) throw new Error("encodePreviewGlb: animation sample times must increase");
|
|
334
|
+
}
|
|
335
|
+
const present = new Set(meshes.map((m) => m.name));
|
|
336
|
+
const sampled = new Set();
|
|
337
|
+
for (const s of samples) for (const name of Object.keys(s.poses ?? {})) sampled.add(name);
|
|
338
|
+
for (const name of [...sampled, ...Object.keys(base)]) {
|
|
339
|
+
if (!present.has(name)) throw new Error(`encodePreviewGlb: animation names "${name}", which has no mesh`);
|
|
340
|
+
}
|
|
341
|
+
const names = [...sampled].filter((name) => drawn.has(name));
|
|
342
|
+
for (const name of names) {
|
|
343
|
+
if (!isRigid(base[name])) {
|
|
344
|
+
if (base[name] == null) throw new Error(`encodePreviewGlb: animation has no base pose for "${name}"`);
|
|
345
|
+
throw new Error(`encodePreviewGlb: "${name}" base pose is not a rigid motion`);
|
|
346
|
+
}
|
|
347
|
+
for (const s of samples) {
|
|
348
|
+
const m = s.poses?.[name];
|
|
349
|
+
if (m == null) throw new Error(`encodePreviewGlb: animation sample at t=${s.t} has no pose for "${name}"`);
|
|
350
|
+
if (!isRigid(m)) throw new Error(`encodePreviewGlb: "${name}" pose at t=${s.t} is not a rigid motion`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return meshes.map((m) => m.name).filter((name) => names.includes(name)); // names are unique (checkMesh)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// rel = M · base⁻¹: the motion from the baked pose to M.
|
|
357
|
+
const relativePose = (m, base) => mulMat4(Array.from(m), invertRigid(Array.from(base)));
|
|
358
|
+
|
|
359
|
+
// Unit quaternion [x, y, z, w] of a rigid matrix's rotation block (Shepperd).
|
|
360
|
+
function quaternionOf(m) {
|
|
361
|
+
const at = (r, c) => m[c * 4 + r];
|
|
362
|
+
const trace = at(0, 0) + at(1, 1) + at(2, 2);
|
|
363
|
+
let q;
|
|
364
|
+
if (trace > 0) {
|
|
365
|
+
const s = Math.sqrt(trace + 1) * 2;
|
|
366
|
+
q = [(at(2, 1) - at(1, 2)) / s, (at(0, 2) - at(2, 0)) / s, (at(1, 0) - at(0, 1)) / s, s / 4];
|
|
367
|
+
} else if (at(0, 0) > at(1, 1) && at(0, 0) > at(2, 2)) {
|
|
368
|
+
const s = Math.sqrt(1 + at(0, 0) - at(1, 1) - at(2, 2)) * 2;
|
|
369
|
+
q = [s / 4, (at(0, 1) + at(1, 0)) / s, (at(0, 2) + at(2, 0)) / s, (at(2, 1) - at(1, 2)) / s];
|
|
370
|
+
} else if (at(1, 1) > at(2, 2)) {
|
|
371
|
+
const s = Math.sqrt(1 + at(1, 1) - at(0, 0) - at(2, 2)) * 2;
|
|
372
|
+
q = [(at(0, 1) + at(1, 0)) / s, s / 4, (at(1, 2) + at(2, 1)) / s, (at(0, 2) - at(2, 0)) / s];
|
|
373
|
+
} else {
|
|
374
|
+
const s = Math.sqrt(1 + at(2, 2) - at(0, 0) - at(1, 1)) * 2;
|
|
375
|
+
q = [(at(0, 2) + at(2, 0)) / s, (at(1, 2) + at(2, 1)) / s, s / 4, (at(1, 0) - at(0, 1)) / s];
|
|
376
|
+
}
|
|
377
|
+
const len = Math.hypot(...q);
|
|
378
|
+
return q.map((v) => v / len);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Per sampled sub-part: the rest TRS (rel(0)) and, when it moves, the keyframes.
|
|
382
|
+
function animationTracks(animation, names) {
|
|
383
|
+
const { samples, base } = animation;
|
|
384
|
+
const tracks = new Map();
|
|
385
|
+
for (const name of names) {
|
|
386
|
+
const rels = samples.map((s) => relativePose(s.poses[name], base[name]));
|
|
387
|
+
const moves = rels.some((r) => r.some((v, i) => Math.abs(v - rels[0][i]) > MOVES_TOL));
|
|
388
|
+
const rotations = [];
|
|
389
|
+
for (const r of rels) {
|
|
390
|
+
const q = quaternionOf(r);
|
|
391
|
+
const prev = rotations.at(-1);
|
|
392
|
+
if (prev && q[0] * prev[0] + q[1] * prev[1] + q[2] * prev[2] + q[3] * prev[3] < 0) for (let k = 0; k < 4; k++) q[k] = -q[k];
|
|
393
|
+
rotations.push(q);
|
|
394
|
+
}
|
|
395
|
+
tracks.set(name, {
|
|
396
|
+
rest: { translation: [rels[0][12], rels[0][13], rels[0][14]], rotation: rotations[0] },
|
|
397
|
+
keys: moves ? { translations: rels.map((r) => [r[12], r[13], r[14]]), rotations } : null,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
return tracks;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// The glTF animation: one sampler pair per moving sub-part, sharing one input.
|
|
404
|
+
// Returns the names that got channels.
|
|
405
|
+
function writeAnimation(doc, buffer, nodesByName, animation, tracks) {
|
|
406
|
+
const moving = [...tracks].filter(([, track]) => track.keys);
|
|
407
|
+
if (!moving.length) return [];
|
|
408
|
+
const { anim, samples } = animation;
|
|
409
|
+
const times = doc.createAccessor().setType("SCALAR")
|
|
410
|
+
.setArray(Float32Array.from(samples, (s) => s.t * anim.totalDuration)).setBuffer(buffer);
|
|
411
|
+
const gltfAnimation = doc.createAnimation(anim.name);
|
|
412
|
+
for (const [name, { keys }] of moving) {
|
|
413
|
+
for (const [path, type, values] of [["translation", "VEC3", keys.translations], ["rotation", "VEC4", keys.rotations]]) {
|
|
414
|
+
const sampler = doc.createAnimationSampler().setInput(times).setInterpolation("LINEAR")
|
|
415
|
+
.setOutput(doc.createAccessor().setType(type).setArray(Float32Array.from(values.flat())).setBuffer(buffer));
|
|
416
|
+
gltfAnimation.addSampler(sampler).addChannel(doc.createAnimationChannel()
|
|
417
|
+
.setTargetNode(nodesByName.get(name)).setTargetPath(path).setSampler(sampler));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return moving.map(([name]) => name);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// The partforgeAnimation extra: everything a viewer needs beyond the channels.
|
|
424
|
+
function animationExtras(animation, animated, drawn) {
|
|
425
|
+
const { anim, samples } = animation;
|
|
426
|
+
const opacity = {};
|
|
427
|
+
for (const s of samples) {
|
|
428
|
+
for (const [name, v] of Object.entries(s.opacity ?? {})) {
|
|
429
|
+
if (drawn.has(name) && Number.isFinite(v)) (opacity[name] ??= []).push([s.t, v]);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
name: anim.name,
|
|
434
|
+
label: anim.label ?? null,
|
|
435
|
+
loop: !!anim.loop,
|
|
436
|
+
duration: anim.totalDuration,
|
|
437
|
+
cues: anim.cues ?? [],
|
|
438
|
+
opacity,
|
|
439
|
+
steps: (anim.steps ?? []).map((step, i) => ({ label: step.label ?? null, start: anim.stepStarts[i], duration: step.duration })),
|
|
440
|
+
animated,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function encodePreviewGlb({ meshes, part = null, view = null, animation = null } = {}) {
|
|
282
445
|
if (!Array.isArray(meshes) || meshes.length === 0) throw new TypeError("encodePreviewGlb: meshes must be a non-empty array");
|
|
283
446
|
const seen = new Set();
|
|
284
447
|
for (const m of meshes) checkMesh(m, seen);
|
|
448
|
+
const drawn = new Set(meshes.filter((m) => m.positions.length > 0).map((m) => m.name));
|
|
449
|
+
const tracks = animation ? animationTracks(animation, checkAnimation(animation, meshes, drawn)) : null;
|
|
285
450
|
try {
|
|
286
451
|
await (encoderReady ??= MeshoptEncoder.ready);
|
|
287
452
|
} catch (error) {
|
|
@@ -297,13 +462,9 @@ export async function encodePreviewGlb({ meshes, part = null, view = null } = {}
|
|
|
297
462
|
const root = doc.createNode("partforge").setRotation(Z_UP_TO_Y_UP);
|
|
298
463
|
scene.addChild(root);
|
|
299
464
|
const meta = metaExtras(part?.meta);
|
|
300
|
-
doc.getRoot().setExtras({
|
|
301
|
-
partforgeView: viewName,
|
|
302
|
-
partforgeEncoder: PREVIEW_ENCODER_VERSION,
|
|
303
|
-
...(meta ? { partforgeMeta: meta } : {}),
|
|
304
|
-
});
|
|
305
465
|
|
|
306
466
|
const pending = []; // { mesh, surface, edges } — line primitives to add after quantize()
|
|
467
|
+
const nodesByName = new Map();
|
|
307
468
|
for (const m of meshes) {
|
|
308
469
|
if (m.positions.length === 0) continue; // glTF forbids a zero-count accessor
|
|
309
470
|
// Copies throughout: setArray keeps the array it is handed, and the
|
|
@@ -326,11 +487,23 @@ export async function encodePreviewGlb({ meshes, part = null, view = null } = {}
|
|
|
326
487
|
const mesh = doc.createMesh(m.name).addPrimitive(prim);
|
|
327
488
|
if (m.edges?.length) pending.push({ mesh, surface: prim, edges: Float32Array.from(m.edges) });
|
|
328
489
|
const extras = displayExtras(display);
|
|
329
|
-
|
|
490
|
+
const node = doc.createNode(m.name)
|
|
330
491
|
.setMesh(mesh)
|
|
331
|
-
.setExtras(extras ? { partforgeSubPart: m.name, partforgeDisplay: extras } : { partforgeSubPart: m.name })
|
|
492
|
+
.setExtras(extras ? { partforgeSubPart: m.name, partforgeDisplay: extras } : { partforgeSubPart: m.name });
|
|
493
|
+
const rest = tracks?.get(m.name)?.rest;
|
|
494
|
+
if (rest) node.setTranslation(rest.translation).setRotation(rest.rotation); // before the fallback's `before` snapshot
|
|
495
|
+
nodesByName.set(m.name, node);
|
|
496
|
+
root.addChild(node);
|
|
332
497
|
}
|
|
333
498
|
if (root.listChildren().length === 0) throw new Error("encodePreviewGlb: every mesh was empty");
|
|
499
|
+
// Channels before quantize(), which then keeps the dequantization off the animated nodes.
|
|
500
|
+
const animated = tracks ? writeAnimation(doc, buffer, nodesByName, animation, tracks) : null;
|
|
501
|
+
doc.getRoot().setExtras({
|
|
502
|
+
partforgeView: viewName,
|
|
503
|
+
partforgeEncoder: PREVIEW_ENCODER_VERSION,
|
|
504
|
+
...(meta ? { partforgeMeta: meta } : {}),
|
|
505
|
+
...(tracks ? { partforgeAnimation: animationExtras(animation, animated, drawn) } : {}),
|
|
506
|
+
});
|
|
334
507
|
|
|
335
508
|
// The surfaces alone through weld + reorder: their vertex arrays are final after this.
|
|
336
509
|
await doc.transform(weld(), reorder({ encoder: MeshoptEncoder, target: "size" }));
|
|
@@ -371,6 +544,19 @@ export async function encodePreviewGlb({ meshes, part = null, view = null } = {}
|
|
|
371
544
|
|
|
372
545
|
await doc.transform(quantize());
|
|
373
546
|
|
|
547
|
+
// quantize() moves an animated node's mesh onto a new, unnamed child (the
|
|
548
|
+
// dequantization must not ride the animated TRS). Name that child and give it
|
|
549
|
+
// the SAME extras as the sub-part node: a reader that looks only at the
|
|
550
|
+
// mesh's own node or its immediate parent (partforge ≤ 0.157's decoder) then
|
|
551
|
+
// still finds the sub-part's name, look and edges; the current decoder's
|
|
552
|
+
// ancestor walk finds identical values one level sooner.
|
|
553
|
+
for (const [name, node] of nodesByName) {
|
|
554
|
+
if (node.getMesh()) continue;
|
|
555
|
+
for (const child of node.listChildren()) {
|
|
556
|
+
if (child.getMesh()) child.setName(name).setExtras({ ...node.getExtras() });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
374
560
|
// The fallback lines: float positions mapped into the frame quantize() left the
|
|
375
561
|
// mesh in. quantize() folds the mesh's offset and scale into the node holding it
|
|
376
562
|
// (or a new child node), so a vertex q now draws at after·q where it drew p at
|
|
@@ -11,7 +11,19 @@
|
|
|
11
11
|
// sub-part's display only — no sheet stock, no layer-line print frames (a
|
|
12
12
|
// residual of the format). CAD feature edges come from the file (encoder
|
|
13
13
|
// version 2, sharing the surface's vertices since version 3); a version-1 file
|
|
14
|
-
// decodes with none.
|
|
14
|
+
// decodes with none.
|
|
15
|
+
//
|
|
16
|
+
// A file that carries a baked autoplay animation (encoder version 4) plays it
|
|
17
|
+
// once drawn, through preview-player.js — poses, opacities and camera cues off
|
|
18
|
+
// the viewer's own frame loop, still with no part code. `animate: false` shows
|
|
19
|
+
// the opening pose and no player (the editor's own boot preview goes through
|
|
20
|
+
// mount(), not here). Under prefers-reduced-motion the player never starts: the
|
|
21
|
+
// file's opening pose, every sub-part opaque, nothing moves. Parking
|
|
22
|
+
// (setActive(false)) stops the viewer's frame loop, and the player's clock with
|
|
23
|
+
// it, so a parked preview is a paused one. `handle.animation` is null once
|
|
24
|
+
// disposed.
|
|
25
|
+
//
|
|
26
|
+
// The decoder loads on demand (it carries the
|
|
15
27
|
// GLTFLoader and meshopt), so a bundle that never mounts a preview never ships it.
|
|
16
28
|
// Hence `ready`. Every handle method is safe before it resolves.
|
|
17
29
|
import "./app.css";
|
|
@@ -23,6 +35,7 @@ import { observeViewbarClear } from "./viewbar-clear.js";
|
|
|
23
35
|
import { createTooltipPresenter } from "./tooltip.js";
|
|
24
36
|
import { isFaceAlignedState } from "./projection.js";
|
|
25
37
|
import { ENVIRONMENTS } from "./materials/environments.js";
|
|
38
|
+
import { createPreviewPlayer } from "./preview-player.js";
|
|
26
39
|
|
|
27
40
|
function previewPart({ meshes, view, meta }) {
|
|
28
41
|
return {
|
|
@@ -44,12 +57,13 @@ const relay = (listeners) => (event) => {
|
|
|
44
57
|
}
|
|
45
58
|
};
|
|
46
59
|
|
|
47
|
-
export function mountPreview(container, { glb, elements = {}, viewerState = null } = {}) {
|
|
60
|
+
export function mountPreview(container, { glb, elements = {}, viewerState = null, animate = true } = {}) {
|
|
48
61
|
if (!container) throw new TypeError("mountPreview: a container element is required");
|
|
49
62
|
let disposed = false;
|
|
50
63
|
let active = true;
|
|
51
64
|
let carried = viewerState ?? null; // applied once the viewer exists, and on every setViewerState
|
|
52
65
|
let live = null; // { viewer, view } once the GLB has decoded
|
|
66
|
+
let player = null; // the baked animation's player, once drawn
|
|
53
67
|
const cleanups = [];
|
|
54
68
|
const modeListeners = new Set();
|
|
55
69
|
const envListeners = new Set();
|
|
@@ -92,6 +106,13 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
92
106
|
live = { viewer, view };
|
|
93
107
|
applyState(carried);
|
|
94
108
|
if (!active) viewer.setActive(false);
|
|
109
|
+
if (decoded.animation && animate !== false) {
|
|
110
|
+
const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
111
|
+
player = createPreviewPlayer(viewer, decoded.animation, { reducedMotion });
|
|
112
|
+
const own = player;
|
|
113
|
+
cleanups.push(() => own.dispose());
|
|
114
|
+
player.start();
|
|
115
|
+
}
|
|
95
116
|
});
|
|
96
117
|
ready.catch(() => {}); // the host awaits it; never an unhandled rejection
|
|
97
118
|
|
|
@@ -119,6 +140,7 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
119
140
|
if (disposed) return;
|
|
120
141
|
disposed = true;
|
|
121
142
|
live = null;
|
|
143
|
+
player = null; // `animation` answers null once disposed (the cleanup below disposes it)
|
|
122
144
|
const errors = [];
|
|
123
145
|
while (cleanups.length) {
|
|
124
146
|
try { cleanups.pop()(); } catch (error) { errors.push(error); }
|
|
@@ -127,6 +149,10 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
127
149
|
if (errors.length > 1) throw new AggregateError(errors, "partforge mountPreview cleanup failed");
|
|
128
150
|
},
|
|
129
151
|
getView: () => live?.view ?? null,
|
|
152
|
+
// null before ready, for a still file, with animate: false, and once disposed.
|
|
153
|
+
get animation() {
|
|
154
|
+
return player ? { state: player.state, pause: player.pause, resume: player.resume } : null;
|
|
155
|
+
},
|
|
130
156
|
getViewerState,
|
|
131
157
|
setViewerState(next) {
|
|
132
158
|
carried = next ?? null;
|
package/src/framework/mount.js
CHANGED
|
@@ -43,6 +43,7 @@ import { attachAnnotateControls } from "./annotate/annotate-controls.js";
|
|
|
43
43
|
import { attachSketchToolbar } from "./annotate/sketch-toolbar.js";
|
|
44
44
|
import { attachViewcubeControls } from "./viewcube/viewcube-controls.js";
|
|
45
45
|
import { createPreviewGate } from "./preview-gate.js";
|
|
46
|
+
import { samplePreviewAnimation } from "./preview-animation.js";
|
|
46
47
|
import { observeViewbarClear } from "./viewbar-clear.js";
|
|
47
48
|
|
|
48
49
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
@@ -281,8 +282,11 @@ function createCleanupStack() {
|
|
|
281
282
|
// mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
|
|
282
283
|
// so Vite can bundle the worker (see geometry-service.js).
|
|
283
284
|
//
|
|
284
|
-
// Embedding contract (0.
|
|
285
|
-
//
|
|
285
|
+
// Embedding contract (0.158.0: a preview GLB may carry its view's autoplay
|
|
286
|
+
// animation — mount({ preview }) draws its opening pose and does not play it,
|
|
287
|
+
// mountPreview plays it unless `animate: false`, and exportPreview bakes it only
|
|
288
|
+
// when every track is place-only; 0.147.0: a cached `preview` GLB is drawn before
|
|
289
|
+
// the kernel boots and swapped in place by the first build; 0.141.0: a place()-able sub-part
|
|
286
290
|
// is delivered in its canonical frame and posed by the viewer; pose-only edits
|
|
287
291
|
// never rebuild):
|
|
288
292
|
// const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload, onViewChange });
|
|
@@ -404,7 +408,9 @@ function createCleanupStack() {
|
|
|
404
408
|
// // measure, sketch and cutaway wait; orbit, the view cube,
|
|
405
409
|
// // the view style and the rail work
|
|
406
410
|
// await runtime.exportPreview(); // the view on screen as a preview GLB (Uint8Array,
|
|
407
|
-
// // partforge/gltf), fast-path poses baked in
|
|
411
|
+
// // partforge/gltf), fast-path poses baked in, plus the
|
|
412
|
+
// // view's autoplay animation when every track is
|
|
413
|
+
// // place-only. Rejects while
|
|
408
414
|
// // previewActive, before the view is fully built (a pending
|
|
409
415
|
// // rebuild counts), for a view other than the one on screen,
|
|
410
416
|
// // and after dispose
|
|
@@ -443,7 +449,10 @@ function createCleanupStack() {
|
|
|
443
449
|
// // the first live build, camera kept. Only a file for the view
|
|
444
450
|
// // on screen is drawn, and only its sub-parts of that view; a
|
|
445
451
|
// // file for another view, or one that will not decode, is
|
|
446
|
-
// // logged once and ignored.
|
|
452
|
+
// // logged once and ignored. A file that carries the view's
|
|
453
|
+
// // baked autoplay animation (encoder version 4) is drawn
|
|
454
|
+
// // in its opening pose and never played here (playing it is
|
|
455
|
+
// // mountPreview's); the first live build replaces it.
|
|
447
456
|
// panelState: PanelState // a previous mount's runtime.getPanelState(): the transient
|
|
448
457
|
// // state of the part's custom controls (a selected tile), keyed
|
|
449
458
|
// // by param. Same remount story as viewerState; omit on a first
|
|
@@ -1718,7 +1727,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1718
1727
|
// buildView returns. Only the view on screen (no other view's meshes are held),
|
|
1719
1728
|
// only when it is fully built at the live params (missingParts covers both a
|
|
1720
1729
|
// first build and an edit still rebuilding), never a cached preview. The
|
|
1721
|
-
//
|
|
1730
|
+
// view's autoplay animation rides along as channels when every track is
|
|
1731
|
+
// place-only (samplePreviewAnimation decides; otherwise the file is still).
|
|
1732
|
+
// The encoder loads on first use: a host that never exports never pays for it.
|
|
1722
1733
|
async function exportPreview({ view: wanted } = {}) {
|
|
1723
1734
|
if (disposed) throw new Error("exportPreview: this mount is disposed");
|
|
1724
1735
|
if (previewGate.isActive()) throw new Error("exportPreview: a cached preview is showing — wait for the first build");
|
|
@@ -1731,8 +1742,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1731
1742
|
throw new Error("exportPreview: the view on screen is not fully built");
|
|
1732
1743
|
}
|
|
1733
1744
|
const meshes = needed.map((name) => viewer.getSubMeshPayload(name)).filter(Boolean);
|
|
1745
|
+
// The view's autoplay animation, sampled at the params the meshes above were
|
|
1746
|
+
// built at. Build reads come from the cache's stamps; null (no stamp, or an
|
|
1747
|
+
// every-param stamp) is unknown, so the animation is left out.
|
|
1748
|
+
const animation = samplePreviewAnimation(part, current, params, {
|
|
1749
|
+
buildReads: (name) => (cache.hasStamp(name) ? cache.readsOf(name) : null),
|
|
1750
|
+
});
|
|
1734
1751
|
const { encodePreviewGlb } = await import("./gltf/encode-preview.js");
|
|
1735
|
-
return encodePreviewGlb({ meshes, part, view: current });
|
|
1752
|
+
return encodePreviewGlb({ meshes, part, view: current, ...(animation ? { animation } : {}) });
|
|
1736
1753
|
}
|
|
1737
1754
|
|
|
1738
1755
|
return makeHandle({
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import { viewSubParts, resolveParams, buildPosed } from "../part-model.js";
|
|
1
|
+
import { viewSubParts, resolveParams, resolveParamsAttributed, recordedParams, buildPosed } from "../part-model.js";
|
|
2
|
+
import { newReadSink, expandReads } from "../read-recorder.js";
|
|
3
|
+
import { placeOf } from "../sub-part-views.js";
|
|
2
4
|
|
|
3
5
|
// Build every sub-part of a view in its display (assembly) pose with the given
|
|
4
6
|
// Manifold kernel, returning live solids + copied-out meshes. Mirrors the
|
|
5
7
|
// `generate` path in jobs.js, but keeps solids LIVE (does NOT call
|
|
6
8
|
// kernel.cleanup()) so callers can read exact solid facts (volume/genus/empty)
|
|
7
9
|
// before they free the kernel. Meshes are JS-owned arrays and survive cleanup.
|
|
8
|
-
|
|
10
|
+
//
|
|
11
|
+
// `reads` (a read sink) records every param the whole view read, build AND place.
|
|
12
|
+
// `readsBySubPart` (a Map) is filled with name → the expanded params that
|
|
13
|
+
// sub-part's build() read, place() reads EXCLUDED — the same split jobs.js's
|
|
14
|
+
// displayWithReads makes: build runs on per-sub-part recorded p/d, place on the
|
|
15
|
+
// view's own p/d (never the sub-part's recorder), so a pose-only param (a lid
|
|
16
|
+
// angle read only in place()) never looks like a rebuild.
|
|
17
|
+
export function buildView(kernel, part, view, params = {}, { reads, readsBySubPart } = {}) {
|
|
9
18
|
const { p, d } = resolveParams(part, params, undefined, reads);
|
|
19
|
+
const attribution = readsBySubPart ? resolveParamsAttributed(part, params).attribution : undefined;
|
|
10
20
|
// Cache round for the whole view. The name is the ORACLE's, deliberately not the
|
|
11
21
|
// display sub-part names the generate path brackets under: a distinct partition
|
|
12
22
|
// still reuses those solids (the cache indexes entries by content hash across
|
|
@@ -18,8 +28,25 @@ export function buildView(kernel, part, view, params = {}, { reads } = {}) {
|
|
|
18
28
|
kernel.beginSubPart?.(`oracle:view:${view}`);
|
|
19
29
|
try {
|
|
20
30
|
return viewSubParts(part, view, p).map((name) => {
|
|
21
|
-
const solid =
|
|
31
|
+
const solid = readsBySubPart
|
|
32
|
+
? buildRecorded(kernel, part, name, view, p, d, attribution, readsBySubPart)
|
|
33
|
+
: buildPosed(kernel, part, name, { purpose: "display", view, p, d });
|
|
22
34
|
return { name, solid, mesh: solid.toMesh() };
|
|
23
35
|
});
|
|
24
36
|
} finally { kernel.endSubPart?.(); }
|
|
25
37
|
}
|
|
38
|
+
|
|
39
|
+
// buildPosed split in two: build() on recorded p/d (a fresh sink per sub-part),
|
|
40
|
+
// place() on the p/d the view resolved — so place reads land only in the
|
|
41
|
+
// view-level `reads` sink, never in the sub-part's.
|
|
42
|
+
function buildRecorded(kernel, part, name, view, p, d, attribution, readsBySubPart) {
|
|
43
|
+
const sp = part.parts[name];
|
|
44
|
+
const sink = newReadSink();
|
|
45
|
+
sink.attribution = attribution;
|
|
46
|
+
const rec = recordedParams({ p, d }, sink);
|
|
47
|
+
const built = sp.build(kernel, rec.p, rec.d);
|
|
48
|
+
const place = placeOf(sp);
|
|
49
|
+
const solid = place ? place(built, { view, purpose: "display", p, d }) : built;
|
|
50
|
+
readsBySubPart.set(name, expandReads(sink));
|
|
51
|
+
return solid;
|
|
52
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Bakeability + sampling for the preview GLB's animation. The cached
|
|
2
|
+
// preview stores one view's meshes posed at the default params; this module
|
|
3
|
+
// decides whether that view's AUTOPLAY animation can be carried in the same file
|
|
4
|
+
// faithfully, and if so samples the place() matrices along its timeline. Turning
|
|
5
|
+
// the samples into glTF channels is the encoder's job, not this module's.
|
|
6
|
+
//
|
|
7
|
+
// Rulings:
|
|
8
|
+
// - Bakeable means EVERY tracked param is place-only for every shown sub-part:
|
|
9
|
+
// the build reads none of them (`buildReads`, supplied by the caller, which
|
|
10
|
+
// already holds the mesh cache's read sets), and at every sample the place
|
|
11
|
+
// probe is trusted with a CANONICAL base hash — a rigid matrix over the mesh
|
|
12
|
+
// the file already holds. A reshaping place() (scale/mirror/union) is not a
|
|
13
|
+
// rigid motion, so a matrix track could not reproduce it.
|
|
14
|
+
// - The shown sub-part set must not change along the timeline: a mesh that
|
|
15
|
+
// comes and goes with a tracked param (`enabled`) is not in the file to show.
|
|
16
|
+
// Opacity fades are display-only and ride the samples beside the poses.
|
|
17
|
+
// - Fail CLOSED: unknown build reads (null), any failed check and any throw
|
|
18
|
+
// return null — the preview then simply has no animation. Never throws.
|
|
19
|
+
// - The output always passes the encoder's own refusals (checkAnimation in
|
|
20
|
+
// gltf/encode-preview.js): a finite, positive duration (and finite step
|
|
21
|
+
// durations and starts), finite sample times, and every pose rigid by the
|
|
22
|
+
// same isRigid test. Anything else is
|
|
23
|
+
// null here, so an odd animation costs the preview its motion, never the
|
|
24
|
+
// whole file (an encoder throw would leave the forge with no preview).
|
|
25
|
+
// - Sample times are normalized t in [0, 1]: both ends, every step boundary,
|
|
26
|
+
// every track and opacity keyframe mapped to global t, plus a
|
|
27
|
+
// PREVIEW_SAMPLE_HZ grid over the real duration. Over PREVIEW_SAMPLE_CAP the
|
|
28
|
+
// GRID thins; keyframe and boundary times are never dropped, since they are
|
|
29
|
+
// where the motion turns.
|
|
30
|
+
//
|
|
31
|
+
// Imports only the pure framework leaves below, so the partforge/oracle entry's
|
|
32
|
+
// closure stays DOM-, three- and node:-free (test/oracle-entry.test.js).
|
|
33
|
+
import { viewAnimations, evaluate } from "./animation.js";
|
|
34
|
+
import { probePoses } from "./pose-probe.js";
|
|
35
|
+
import { CANONICAL } from "./pose-probe-core.js";
|
|
36
|
+
import { viewSubParts } from "./part-model.js";
|
|
37
|
+
import { composePose, isRigid } from "./geometry/pose.js";
|
|
38
|
+
|
|
39
|
+
export const PREVIEW_SAMPLE_HZ = 30;
|
|
40
|
+
export const PREVIEW_SAMPLE_CAP = 600;
|
|
41
|
+
|
|
42
|
+
const EPS = 1e-9;
|
|
43
|
+
|
|
44
|
+
// Sorted, deduped normalized sample times for one animation.
|
|
45
|
+
function sampleTimes(anim) {
|
|
46
|
+
const fixed = [0, 1, ...anim.stepStarts];
|
|
47
|
+
anim.steps.forEach((step, i) => {
|
|
48
|
+
const start = anim.stepStarts[i], span = step.duration / anim.totalDuration;
|
|
49
|
+
for (const field of ["tracks", "opacity"]) {
|
|
50
|
+
for (const kf of Object.values(step[field] ?? {})) {
|
|
51
|
+
if (!Array.isArray(kf)) continue;
|
|
52
|
+
for (const frame of kf) {
|
|
53
|
+
const u = Array.isArray(frame) ? Number(frame[0]) : NaN;
|
|
54
|
+
if (Number.isFinite(u)) fixed.push(Math.min(1, Math.max(0, start + u * span)));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
const keep = dedupe(fixed);
|
|
60
|
+
// Interior grid points j/n, 0 < j < n; thinned so the total fits the cap.
|
|
61
|
+
let n = Math.round(anim.totalDuration * PREVIEW_SAMPLE_HZ);
|
|
62
|
+
n = Math.max(1, Math.min(n, PREVIEW_SAMPLE_CAP - keep.length + 1));
|
|
63
|
+
const grid = [];
|
|
64
|
+
for (let j = 1; j < n; j++) grid.push(j / n);
|
|
65
|
+
return dedupe([...keep, ...grid]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function dedupe(times) {
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const t of [...times].sort((a, b) => a - b)) if (!out.length || t - out.at(-1) > EPS) out.push(t);
|
|
71
|
+
// Both ends stay exact: a near-duplicate must not displace 0 or 1.
|
|
72
|
+
out[0] = 0;
|
|
73
|
+
out[out.length - 1] = 1;
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Place matrices for every shown sub-part, or null if any is not place-only.
|
|
78
|
+
function placePoses(part, view, p, shown) {
|
|
79
|
+
const probes = probePoses(part, view, p, { scope: "place" });
|
|
80
|
+
const poses = {};
|
|
81
|
+
for (const name of shown) {
|
|
82
|
+
const entry = probes.get(name);
|
|
83
|
+
if (!entry?.trusted || entry.baseHash !== CANONICAL) return null;
|
|
84
|
+
const m = Array.from(composePose(entry.pose));
|
|
85
|
+
if (!isRigid(m)) return null; // e.g. a rotate about a zero-length axis
|
|
86
|
+
poses[name] = m;
|
|
87
|
+
}
|
|
88
|
+
return poses;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sameSet = (a, b) => a.length === b.length && a.every((name) => b.includes(name));
|
|
92
|
+
|
|
93
|
+
export function samplePreviewAnimation(part, view, params = {}, { buildReads } = {}) {
|
|
94
|
+
try {
|
|
95
|
+
const anim = viewAnimations(part).get(view)?.find((a) => a.autoplay) ?? null;
|
|
96
|
+
if (!anim || typeof buildReads !== "function") return null;
|
|
97
|
+
if (!(Number.isFinite(anim.totalDuration) && anim.totalDuration > 0)) return null;
|
|
98
|
+
// A NaN step duration normalizes to a total of 1 (animation.js's `|| 1`),
|
|
99
|
+
// so the steps are checked too: the file's step list must be real numbers.
|
|
100
|
+
if (!anim.steps.every((s) => Number.isFinite(s.duration) && s.duration >= 0)) return null;
|
|
101
|
+
if (!anim.stepStarts.every(Number.isFinite)) return null;
|
|
102
|
+
// Layered over the defaults here (not only inside the probe) so `enabled`
|
|
103
|
+
// sees the same params the build and place() do.
|
|
104
|
+
const p0 = { ...part.defaults, ...params };
|
|
105
|
+
const shown = viewSubParts(part, view, p0);
|
|
106
|
+
const base = placePoses(part, view, p0, shown);
|
|
107
|
+
if (!base) return null;
|
|
108
|
+
for (const name of shown) {
|
|
109
|
+
const reads = buildReads(name);
|
|
110
|
+
if (!reads) return null;
|
|
111
|
+
if (anim.trackedKeys.some((key) => reads.has(key))) return null;
|
|
112
|
+
}
|
|
113
|
+
const samples = [];
|
|
114
|
+
const times = sampleTimes(anim);
|
|
115
|
+
if (!times.every(Number.isFinite)) return null;
|
|
116
|
+
for (const t of times) {
|
|
117
|
+
const { values, opacity } = evaluate(anim, t);
|
|
118
|
+
const p = { ...p0, ...values };
|
|
119
|
+
if (!sameSet(viewSubParts(part, view, p), shown)) return null;
|
|
120
|
+
const poses = placePoses(part, view, p, shown);
|
|
121
|
+
if (!poses) return null;
|
|
122
|
+
samples.push({ t, poses, opacity });
|
|
123
|
+
}
|
|
124
|
+
return { anim, samples, base };
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|