partforge 0.157.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.157.0",
3
+ "version": "0.158.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",
@@ -91,3 +91,23 @@ export function rotateNormals(normals, m) {
91
91
  normals[i + 2] = m[2] * x + m[6] * y + m[10] * z;
92
92
  }
93
93
  }
94
+
95
+ // Whether a column-major mat4 is a rigid motion within `tol`: finite, affine
96
+ // (last row 0 0 0 1), an orthonormal 3x3 block (RᵀR = I) with det = +1 — no
97
+ // scale, shear or mirror. The preview GLB's encoder refuses any other pose, and
98
+ // the sampler (preview-animation.js) applies the same test so its output never
99
+ // trips that refusal.
100
+ export function isRigid(m, tol = 1e-6) {
101
+ if (!Array.isArray(m) && !ArrayBuffer.isView(m)) return false;
102
+ if (m.length !== 16 || !Array.prototype.every.call(m, Number.isFinite)) return false;
103
+ if (Math.abs(m[3]) > tol || Math.abs(m[7]) > tol || Math.abs(m[11]) > tol || Math.abs(m[15] - 1) > tol) return false;
104
+ const col = (c) => [m[c * 4], m[c * 4 + 1], m[c * 4 + 2]];
105
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
106
+ const cols = [col(0), col(1), col(2)];
107
+ for (let i = 0; i < 3; i++) {
108
+ for (let j = 0; j < 3; j++) if (Math.abs(dot(cols[i], cols[j]) - (i === j ? 1 : 0)) > tol) return false;
109
+ }
110
+ const [a, b, c] = cols;
111
+ const det = a[0] * (b[1] * c[2] - b[2] * c[1]) - b[0] * (a[1] * c[2] - a[2] * c[1]) + c[0] * (a[1] * b[2] - a[2] * b[1]);
112
+ return Math.abs(det - 1) <= tol;
113
+ }
@@ -24,9 +24,25 @@
24
24
  // which the viewer reads as "no feature edges" (never undefined, which would
25
25
  // make it derive lines by angle).
26
26
  //
27
+ // The baked animation (encoder version 4) comes back as `animation`, null for a
28
+ // file without the partforgeAnimation extra (every version 1–3 file):
29
+ // { ...the extra (name, label, loop, duration, cues, opacity, steps, animated),
30
+ // tracks: { <sub>: { times, translations, rotations } }, rest: { <sub>: mat16 } }.
31
+ // `times` are seconds; `translations` xyz triples and `rotations` xyzw quads are
32
+ // the node's TRS keyframes, which the writer expresses in the root's LOCAL frame
33
+ // — the part's Z-up MODEL frame — so they need no conversion (positions do: they
34
+ // go through the node's world matrix, root rotation included). Only sub-parts
35
+ // that move have a track; `rest[name]` is the column-major mat4 of a track's
36
+ // first keyframe (t = 0), the pose already baked into that sub-part's
37
+ // positions — meshes decode at the OPENING pose. Channels are read off the raw
38
+ // glTF JSON and resolved through the parser, never by AnimationClip track name
39
+ // (three sanitizes node names). An animated sub-part's mesh sits on an unnamed
40
+ // child of its node (quantize() moves it there), so a mesh's sub-part is found
41
+ // by walking up to the nearest node carrying partforgeSubPart.
42
+ //
27
43
  // Rejects on anything that is not a decodable GLB; the callers (mount's
28
44
  // `preview`, mountPreview) decide what a failure means for them.
29
- import { Matrix3, Matrix4, Vector3 } from "three";
45
+ import { Matrix3, Matrix4, Quaternion, Vector3 } from "three";
30
46
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
31
47
  import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";
32
48
 
@@ -64,16 +80,72 @@ function metaOf(extras) {
64
80
  return Object.keys(out).length ? out : null;
65
81
  }
66
82
 
67
- // The object standing for a glTF node: the object itself when its node had one
68
- // primitive, else the Group GLTFLoader made for a multi-primitive mesh — the
69
- // one carrying the node's extras / name (a primitive's own Mesh has neither).
83
+ // The object standing for a glTF node: the nearest of the object and its
84
+ // ancestors (below the scene root) carrying the writer's partforgeSubPart — an
85
+ // animated sub-part's mesh sits one or two levels under it (quantize()'s unnamed
86
+ // child, then GLTFLoader's Group for a multi-primitive mesh). Failing that, the
87
+ // object itself when its node had one primitive, else the Group GLTFLoader made
88
+ // for a multi-primitive mesh — the one carrying the node's extras / name (a
89
+ // primitive's own Mesh has neither).
70
90
  function nodeObjectOf(obj) {
91
+ for (let o = obj; o && o.parent; o = o.parent) {
92
+ if (typeof o.userData?.partforgeSubPart === "string") return o;
93
+ }
71
94
  const own = obj.userData ?? {};
72
95
  if ("partforgeSubPart" in own || "name" in own) return obj;
73
96
  const parent = obj.parent;
74
97
  return parent?.isGroup && parent.userData && ("partforgeSubPart" in parent.userData || "name" in parent.userData) ? parent : obj;
75
98
  }
76
99
 
100
+ // The sub-part key a node object stands for: the writer's own record of the key,
101
+ // then the unsanitized glTF node name.
102
+ function subPartNameOf(node) {
103
+ const { partforgeSubPart, name } = node.userData ?? {};
104
+ return typeof partforgeSubPart === "string" ? partforgeSubPart : (name ?? node.name);
105
+ }
106
+
107
+ // An accessor's elements as a Float32Array of `size` components each, through
108
+ // getX/getY/getZ/getW so a normalized output would be denormalized (this writer
109
+ // stores the channels as plain float32; the guard is for any other writer).
110
+ function readComponents(attr, size) {
111
+ const out = new Float32Array(attr.count * size);
112
+ const get = [attr.getX, attr.getY, attr.getZ, attr.getW].slice(0, size);
113
+ for (let i = 0; i < attr.count; i++) for (let k = 0; k < size; k++) out[i * size + k] = get[k].call(attr, i);
114
+ return out;
115
+ }
116
+
117
+ // The baked animation (see the header): the partforgeAnimation extra plus the
118
+ // first glTF animation's channels as model-frame keyframes per sub-part.
119
+ async function readAnimation(gltf) {
120
+ const extra = gltf.userData?.partforgeAnimation;
121
+ if (!extra || typeof extra !== "object") return null;
122
+ const tracks = {};
123
+ const rest = {};
124
+ const def = gltf.parser.json.animations?.[0];
125
+ for (const channel of def?.channels ?? []) {
126
+ const path = channel.target?.path;
127
+ if ((path !== "translation" && path !== "rotation") || channel.target.node === undefined) continue;
128
+ const sampler = def.samplers[channel.sampler];
129
+ const node = await gltf.parser.getDependency("node", channel.target.node);
130
+ const [input, output] = await Promise.all([
131
+ gltf.parser.getDependency("accessor", sampler.input),
132
+ gltf.parser.getDependency("accessor", sampler.output),
133
+ ]);
134
+ const track = (tracks[subPartNameOf(nodeObjectOf(node))] ??= { times: null, translations: null, rotations: null });
135
+ track.times ??= readComponents(input, 1);
136
+ if (path === "translation") track.translations = readComponents(output, 3);
137
+ else track.rotations = readComponents(output, 4);
138
+ }
139
+ for (const [name, track] of Object.entries(tracks)) {
140
+ const t = track.translations ?? new Float32Array(3);
141
+ const r = track.rotations ?? Float32Array.of(0, 0, 0, 1);
142
+ rest[name] = new Matrix4()
143
+ .compose(new Vector3(t[0], t[1], t[2]), new Quaternion(r[0], r[1], r[2], r[3]).normalize(), new Vector3(1, 1, 1))
144
+ .toArray();
145
+ }
146
+ return { ...extra, tracks, rest };
147
+ }
148
+
77
149
  // Line segments as endpoint pairs in model frame: an indexed LINES primitive
78
150
  // expanded back to pairs. Version 3's position attribute is the SURFACE's
79
151
  // (GLTFLoader hands both objects the same BufferAttribute), so only the
@@ -137,10 +209,9 @@ export async function decodePreviewGlb(bytes) {
137
209
  const indices = geometry.index ? Uint32Array.from(geometry.index.array) : null;
138
210
  const count = surfaceVertexCount(position, geometry.index);
139
211
  const node = nodeObjectOf(obj);
140
- const { partforgeSubPart, partforgeDisplay, name: originalName } = node.userData;
212
+ const { partforgeDisplay } = node.userData;
141
213
  const payload = {
142
- // The writer's own record of the key, then the unsanitized glTF node name.
143
- name: typeof partforgeSubPart === "string" ? partforgeSubPart : (originalName ?? node.name),
214
+ name: subPartNameOf(node),
144
215
  positions: readVec3(position, toModel, false, count),
145
216
  normals: normal ? readVec3(normal, normalMatrix, true, count) : null,
146
217
  indices,
@@ -166,5 +237,6 @@ export async function decodePreviewGlb(bytes) {
166
237
  view: typeof extras.partforgeView === "string" ? extras.partforgeView : null,
167
238
  meta: metaOf(extras),
168
239
  meshes,
240
+ animation: await readAnimation(gltf),
169
241
  };
170
242
  }
@@ -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
- export const PREVIEW_ENCODER_VERSION = 3; // 2: feature edges as a LINES primitive; 3: sharing the surface's vertices
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
- export async function encodePreviewGlb({ meshes, part = null, view = null } = {}) {
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
- root.addChild(doc.createNode(m.name)
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. The decoder loads on demand (it carries the
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;
@@ -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.147.0: a cached `preview` GLB is drawn before the kernel
285
- // boots and swapped in place by the first build; 0.141.0: a place()-able sub-part
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. Rejects while
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
- // encoder loads on first use: a host that never exports never pays for it.
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
- export function buildView(kernel, part, view, params = {}, { reads } = {}) {
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 = buildPosed(kernel, part, name, { purpose: "display", view, p, d });
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
+ }
@@ -0,0 +1,161 @@
1
+ // The preview player: plays the autoplay animation a preview GLB carries
2
+ // (decoded by gltf/decode-preview.js) off the viewer's own frame loop, with no
3
+ // part code running. The live driver (animation-controls.js) moves params and
4
+ // rebuilds; here every animated sub-part's motion is already baked as
5
+ // model-frame keyframes, so a frame is a pose per sub-part and nothing more.
6
+ //
7
+ // Timing, cues and looping are not reimplemented: createPlayback (animation.js)
8
+ // runs on a synthetic normalized animation with no tracked params and no
9
+ // opacity keys, so its snapshot contributes only status, t and the cue to fire.
10
+ // The player turns that t into poses and opacities itself.
11
+ //
12
+ // Three rules (spec §3):
13
+ // - A looping animation wraps; a once-through one rests at its end pose and
14
+ // the player does nothing further each frame.
15
+ // - A user orbit disarms the remaining camera cues and never pauses the
16
+ // motion (mid-intro it releases the intro gate, so playback still starts).
17
+ // - Reduced motion means no player: start() writes nothing — no pose, no
18
+ // opacity, no ground hold, no cue — so the visitor sees exactly what the
19
+ // file draws without a player (the opening pose, every sub-part opaque; an
20
+ // opacity fade-in must not leave a sub-part hidden for good).
21
+ import { createPlayback } from "./animation.js";
22
+ import { invertRigid, mulMat4 } from "./geometry/pose.js";
23
+
24
+ // A frame after a stall (a hidden tab, a long GC) advances at most this many
25
+ // seconds, so the motion resumes where it was rather than jumping.
26
+ export const MAX_FRAME_DT = 0.1;
27
+ const INTRO_TWEEN_SECONDS = 0.6;
28
+
29
+ // Spherical interpolation between two unit quaternions [x, y, z, w].
30
+ export function slerp(a, b, u) {
31
+ let [bx, by, bz, bw] = b;
32
+ let cos = a[0] * bx + a[1] * by + a[2] * bz + a[3] * bw;
33
+ if (cos < 0) { cos = -cos; bx = -bx; by = -by; bz = -bz; bw = -bw; } // shortest arc
34
+ let wa = 1 - u, wb = u;
35
+ if (cos < 0.9995) {
36
+ const theta = Math.acos(cos), s = Math.sin(theta);
37
+ wa = Math.sin((1 - u) * theta) / s;
38
+ wb = Math.sin(u * theta) / s;
39
+ }
40
+ const q = [wa * a[0] + wb * bx, wa * a[1] + wb * by, wa * a[2] + wb * bz, wa * a[3] + wb * bw];
41
+ const len = Math.hypot(q[0], q[1], q[2], q[3]) || 1;
42
+ return q.map((c) => c / len);
43
+ }
44
+
45
+ // Column-major mat16 for rotation q followed by translation p.
46
+ export function quatToMat4([x, y, z, w], [px, py, pz]) {
47
+ const xx = x * x, yy = y * y, zz = z * z, xy = x * y, xz = x * z, yz = y * z, wx = w * x, wy = w * y, wz = w * z;
48
+ return [
49
+ 1 - 2 * (yy + zz), 2 * (xy + wz), 2 * (xz - wy), 0,
50
+ 2 * (xy - wz), 1 - 2 * (xx + zz), 2 * (yz + wx), 0,
51
+ 2 * (xz + wy), 2 * (yz - wx), 1 - 2 * (xx + yy), 0,
52
+ px, py, pz, 1,
53
+ ];
54
+ }
55
+
56
+ // The keyframed transform at `seconds`: translation lerped, rotation slerped
57
+ // between the bracketing keyframes, held before the first and after the last.
58
+ export function sampleTrack({ times, translations, rotations }, seconds) {
59
+ const n = times.length;
60
+ const tr = (i) => [translations[i * 3], translations[i * 3 + 1], translations[i * 3 + 2]];
61
+ const rot = (i) => [rotations[i * 4], rotations[i * 4 + 1], rotations[i * 4 + 2], rotations[i * 4 + 3]];
62
+ if (n === 1 || seconds <= times[0]) return quatToMat4(rot(0), tr(0));
63
+ if (seconds >= times[n - 1]) return quatToMat4(rot(n - 1), tr(n - 1));
64
+ let i = 0;
65
+ while (i < n - 2 && seconds >= times[i + 1]) i++;
66
+ const u = (seconds - times[i]) / (times[i + 1] - times[i] || 1);
67
+ const a = tr(i), b = tr(i + 1);
68
+ return quatToMat4(slerp(rot(i), rot(i + 1), u), [0, 1, 2].map((k) => a[k] + (b[k] - a[k]) * u));
69
+ }
70
+
71
+ // Linear opacity over [[t, v], …] keyframes in normalized t, clamped at the ends.
72
+ export function sampleOpacity(keys, t) {
73
+ if (t <= keys[0][0]) return keys[0][1];
74
+ for (let i = 1; i < keys.length; i++) {
75
+ const [t1, v1] = keys[i];
76
+ if (t <= t1) {
77
+ const [t0, v0] = keys[i - 1];
78
+ return v0 + (v1 - v0) * ((t - t0) / (t1 - t0 || 1));
79
+ }
80
+ }
81
+ return keys[keys.length - 1][1];
82
+ }
83
+
84
+ export function createPreviewPlayer(viewer, animation, { reducedMotion = false } = {}) {
85
+ const { duration, tracks, rest, opacity = {} } = animation;
86
+ const steps = animation.steps ?? [];
87
+ const playback = createPlayback({
88
+ name: animation.name,
89
+ loop: animation.loop,
90
+ totalDuration: duration,
91
+ cues: animation.cues ?? [],
92
+ steps: steps.map((s) => ({ label: s.label, duration: s.duration, easing: "linear", tracks: {}, opacity: {}, camera: null })),
93
+ stepStarts: steps.map((s) => s.start),
94
+ trackedKeys: [],
95
+ opacityKeys: [],
96
+ });
97
+ // Pose = keyframed transform with the rest transform taken out: the mesh
98
+ // already sits at its t = 0 placement, so the identity pose is the rest pose.
99
+ const restInverse = Object.fromEntries(Object.keys(tracks).map((name) => [name, invertRigid(rest[name])]));
100
+
101
+ let offFrame = null, offOrbit = null, started = false, disposed = false;
102
+
103
+ function apply(t) {
104
+ const seconds = t * duration;
105
+ for (const [name, track] of Object.entries(tracks)) viewer.setSubPose(name, mulMat4(sampleTrack(track, seconds), restInverse[name]));
106
+ for (const [name, keys] of Object.entries(opacity)) if (keys?.length) viewer.setSubPartOpacity(name, sampleOpacity(keys, t));
107
+ }
108
+
109
+ // A snapshot from play(): an intro cue tweens and gates playback until it lands.
110
+ function honorIntro(snap) {
111
+ if (snap.status !== "intro" || !snap.cue) return;
112
+ viewer.tweenCameraTo(snap.cue.view, {
113
+ duration: INTRO_TWEEN_SECONDS,
114
+ onComplete: () => { if (!disposed) playback.introDone(); },
115
+ });
116
+ }
117
+
118
+ function onFrame(dt) {
119
+ const snap = playback.tick(Math.min(dt, MAX_FRAME_DT));
120
+ if (!snap) return; // not playing: intro, paused, or done — the last poses stand
121
+ apply(snap.t);
122
+ if (snap.cue) viewer.tweenCameraTo(snap.cue.view, { duration: snap.cue.sweep ?? INTRO_TWEEN_SECONDS });
123
+ }
124
+
125
+ function onOrbit() {
126
+ playback.disarmCues();
127
+ if (playback.state().status === "intro") playback.introDone();
128
+ }
129
+
130
+ return {
131
+ start() {
132
+ if (started || disposed) return;
133
+ started = true;
134
+ if (reducedMotion) return;
135
+ apply(0);
136
+ viewer.holdGround(true);
137
+ offFrame = viewer.onFrame(onFrame);
138
+ offOrbit = viewer.onCameraStart(onOrbit);
139
+ honorIntro(playback.play());
140
+ },
141
+ pause() {
142
+ if (!disposed) playback.pause();
143
+ },
144
+ resume() {
145
+ if (disposed || playback.state().status !== "paused") return;
146
+ honorIntro(playback.play());
147
+ },
148
+ dispose() {
149
+ if (disposed) return;
150
+ disposed = true;
151
+ if (!offFrame) return; // never played (not started, or reduced motion): nothing to undo
152
+ offFrame(); offOrbit();
153
+ offFrame = offOrbit = null;
154
+ viewer.holdGround(false);
155
+ },
156
+ state() {
157
+ const { status, t } = playback.state();
158
+ return { status, t };
159
+ },
160
+ };
161
+ }
package/src/oracle.js CHANGED
@@ -17,6 +17,9 @@
17
17
  export { assemblyGaps, meshGaps } from "./framework/oracle/gaps.js";
18
18
  export { meshVolume, bboxSize, bounds, meshArea } from "./framework/oracle/mesh.js";
19
19
  export { buildView } from "./framework/oracle/build.js";
20
+ // Preview animation — whether a view's autoplay animation is place-only, and its
21
+ // sampled place() matrices, for the cached preview GLB (partforge/gltf) to bake.
22
+ export { samplePreviewAnimation, PREVIEW_SAMPLE_HZ, PREVIEW_SAMPLE_CAP } from "./framework/preview-animation.js";
20
23
  export { measure } from "./framework/oracle/measure.js";
21
24
  export { verify } from "./framework/oracle/verify.js";
22
25
  // Change tracking — the `inspect` job's opt-in "what changed since my last
package/types/gltf.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  // partforge/gltf — the cached-preview writer. Worker-safe: no three.js, no DOM.
2
2
 
3
3
  import type { SubPartDefinition } from "./part.js";
4
+ import type { PreviewAnimationInput } from "./oracle.js";
5
+
6
+ export type { PreviewAnimationInput };
4
7
 
5
8
  /** The slice of a part `encodePreviewGlb` reads; any `PartDefinition` satisfies it, so does a plain object. */
6
9
  export interface PreviewPart {
@@ -38,9 +41,19 @@ export interface EncodePreviewInput {
38
41
  part?: PreviewPart | null;
39
42
  /** The view these meshes show; names the scene and lands in the file's extras as `partforgeView`. */
40
43
  view?: string | null;
44
+ /**
45
+ * The view's autoplay animation as `samplePreviewAnimation` returns it (encoder
46
+ * version 4). Each sampled sub-part's node gets a rest transform `rel(0)` and,
47
+ * when it moves, LINEAR `translation` + `rotation` channels in seconds, where
48
+ * `rel(t) = M(t) · base⁻¹`; the top-level extras gain `partforgeAnimation`
49
+ * `{ name, label, loop, duration, cues, opacity, steps, animated }`. Refused
50
+ * (throws) for a non-rigid pose, samples not running from t = 0 to t = 1, a
51
+ * sample missing a sub-part another has, or a sub-part with no mesh.
52
+ */
53
+ animation?: PreviewAnimationInput | null;
41
54
  }
42
55
 
43
- /** Bumped whenever the encoder writes different bytes for the same input (2: feature edges; 3: edges as indices into the surface's vertices). */
56
+ /** Bumped whenever the encoder writes different bytes for the same input (2: feature edges; 3: edges as indices into the surface's vertices; 4: animation channels + partforgeAnimation extras). */
44
57
  export const PREVIEW_ENCODER_VERSION: number;
45
58
 
46
59
  /** Encode one view's posed meshes as a welded, quantized, meshopt-compressed GLB (Y-up, millimetres). Deterministic. */
package/types/index.d.ts CHANGED
@@ -780,6 +780,8 @@ export interface MountPreviewOptions {
780
780
  elements?: { chrome?: { reframe?: HTMLElement | null; theme?: HTMLElement | null } };
781
781
  /** A previous runtime's `getViewerState()`; `cutaway` is ignored (a preview has no live solid). */
782
782
  viewerState?: ViewerState | null;
783
+ /** Play the file's baked animation once drawn (default true). The editor's own boot preview goes through mount(), not here. */
784
+ animate?: boolean;
783
785
  }
784
786
 
785
787
  /** The handle `mountPreview()` returns. Every method is safe before `ready` settles. */
@@ -802,6 +804,12 @@ export interface PreviewRuntime {
802
804
  setHostPane(pane: HostPane): void;
803
805
  /** Accepted and ignored — there is no rail. */
804
806
  setRailLayout(layout: HostRailLayout): void;
807
+ /** The baked animation's controls: `null` before `ready`, for a still file, or with `animate: false`. */
808
+ readonly animation: {
809
+ state(): { status: "idle" | "intro" | "playing" | "paused" | "done"; t: number };
810
+ pause(): void;
811
+ resume(): void;
812
+ } | null;
805
813
  renderMode: {
806
814
  get(): RenderMode;
807
815
  set(mode: RenderMode): Promise<RenderMode>;
package/types/oracle.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // report/mask/gap types a caller needs to annotate results. The semantic mesh
6
6
  // oracle (`describe`) is its own closed package now — its types ship with it, and
7
7
  // the direction of this seam is what lets that package consume these helpers.
8
+ import type { CameraCue, Easing, Keyframes, ParamValue, PartDefinition } from "./part.js";
8
9
  export type { GeometryKernel, Mesh, PartDefinition, ResolvedParams, Solid } from "./testing.js";
9
10
  export {
10
11
  // measurement + verification
@@ -29,3 +30,80 @@ export {
29
30
  annotationRay, rayPlane,
30
31
  type AnnotationRay, type RayPlaneHit, type PlaneSpec,
31
32
  } from "./testing.js";
33
+
34
+ /** One step of a normalized animation (every optional field filled in). */
35
+ export interface NormalizedAnimationStep {
36
+ /** `null` for the single anonymous step of a bare-`tracks` animation. */
37
+ label: string | null;
38
+ duration: number;
39
+ easing: Easing;
40
+ tracks: Record<string, Keyframes>;
41
+ opacity: Record<string, Keyframes>;
42
+ camera: CameraCue | null;
43
+ }
44
+
45
+ /** A camera cue on the normalized timeline: fires at `t`, sweeping for `sweep` seconds. */
46
+ export interface NormalizedAnimationCue {
47
+ t: number;
48
+ view: CameraCue;
49
+ /** Seconds; `null` for a lone intro angle. */
50
+ sweep: number | null;
51
+ }
52
+
53
+ /** An animation in the canonical shape every consumer (playback, lint, CLI, preview) works against. */
54
+ export interface NormalizedAnimation {
55
+ name: string;
56
+ label: string;
57
+ description: string | null;
58
+ loop: boolean;
59
+ autoplay: boolean;
60
+ steps: NormalizedAnimationStep[];
61
+ /** Each step's start as normalized t. */
62
+ stepStarts: number[];
63
+ /** Seconds. */
64
+ totalDuration: number;
65
+ cues: NormalizedAnimationCue[];
66
+ /** Params any step tracks. */
67
+ trackedKeys: string[];
68
+ /** Sub-parts any step fades. */
69
+ opacityKeys: string[];
70
+ }
71
+
72
+ /** One sampled instant of a baked preview animation. */
73
+ export interface PreviewAnimationSample {
74
+ /** Normalized time in [0, 1]. */
75
+ t: number;
76
+ /** Sub-part name → column-major model-frame place matrix (16 numbers). */
77
+ poses: Record<string, number[]>;
78
+ /** Sub-part name → opacity 0–1; `{}` when the animation declares no fades. */
79
+ opacity: Record<string, number>;
80
+ }
81
+
82
+ /** What `samplePreviewAnimation` returns for a bakeable autoplay animation. */
83
+ export interface PreviewAnimationInput {
84
+ anim: NormalizedAnimation;
85
+ /** Sorted by `t`; the first has `t === 0`, the last `t === 1`. */
86
+ samples: PreviewAnimationSample[];
87
+ /** Sub-part name → place matrix at the params handed in. */
88
+ base: Record<string, number[]>;
89
+ }
90
+
91
+ /** Sampling grid rate over the animation's real duration, in Hz. */
92
+ export const PREVIEW_SAMPLE_HZ: 30;
93
+ /** Most samples one preview animation carries; the grid thins, keyframe times never do. */
94
+ export const PREVIEW_SAMPLE_CAP: 600;
95
+
96
+ /**
97
+ * Decide whether the view's autoplay animation can be baked into the preview
98
+ * GLB — every tracked param place-only (the build reads none, and place() is a
99
+ * rigid motion at every sample) and the shown sub-parts constant — and if so
100
+ * sample the place matrices along its timeline. `buildReads(name)` returns the
101
+ * param keys that sub-part's build read, or `null` when unknown. Returns `null`
102
+ * when there is no autoplay animation or any check fails; never throws.
103
+ */
104
+ export function samplePreviewAnimation(
105
+ part: PartDefinition,
106
+ view: string,
107
+ params?: Record<string, ParamValue>,
108
+ options?: { buildReads: (name: string) => ReadonlySet<string> | null },
109
+ ): PreviewAnimationInput | null;
@@ -11,6 +11,12 @@ import type { Derived, FontSource, PartDefinition, ResolvedParams } from "./part
11
11
  import type { KitOptions } from "./index.js";
12
12
 
13
13
  export type { Derived, GeometryKernel, Mesh, PartDefinition, ResolvedParams, Solid };
14
+ // Declared in oracle.d.ts (its own surface); testing.js re-exports all of oracle.js.
15
+ export {
16
+ samplePreviewAnimation, PREVIEW_SAMPLE_HZ, PREVIEW_SAMPLE_CAP,
17
+ type PreviewAnimationInput, type PreviewAnimationSample, type NormalizedAnimation,
18
+ type NormalizedAnimationStep, type NormalizedAnimationCue,
19
+ } from "./oracle.js";
14
20
 
15
21
  // --- kernels ----------------------------------------------------------------
16
22
 
@@ -184,12 +190,17 @@ export function meshGaps(built: BuiltSubPart[], opts?: { bvhCache?: Map<Mesh, BV
184
190
  /**
185
191
  * Build every sub-part of a view in its display pose. Keeps solids LIVE (does
186
192
  * NOT call `kernel.cleanup()`) so callers can read exact solid facts first.
193
+ *
194
+ * `readsBySubPart`, when given, is filled with each sub-part's name → the params
195
+ * its `build()` read (expanded through derived values). `place()` reads are
196
+ * excluded, so a pose-only param never reads as a rebuild.
187
197
  */
188
198
  export function buildView(
189
199
  kernel: GeometryKernel,
190
200
  part: PartDefinition,
191
201
  view: string,
192
202
  params?: ResolvedParams,
203
+ opts?: { readsBySubPart?: Map<string, string[]> },
193
204
  ): BuiltSubPart[];
194
205
 
195
206
  // --- mesh math --------------------------------------------------------------