partforge 0.157.0 → 0.158.1
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 +1 -1
- 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/param-deps.js +47 -6
- package/src/framework/preview-animation.js +128 -0
- package/src/framework/preview-player.js +161 -0
- package/src/oracle.js +3 -0
- package/types/gltf.d.ts +14 -1
- package/types/index.d.ts +8 -0
- package/types/oracle.d.ts +78 -0
- package/types/testing.d.ts +11 -0
package/package.json
CHANGED
|
@@ -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
|
|
68
|
-
//
|
|
69
|
-
//
|
|
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 {
|
|
212
|
+
const { partforgeDisplay } = node.userData;
|
|
141
213
|
const payload = {
|
|
142
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -5,7 +5,9 @@ import { resolveDerivedAttributed } from "./derive.js";
|
|
|
5
5
|
import { createProbeKernel } from "./geometry/probe.js";
|
|
6
6
|
import { inView, placeOf } from "./sub-part-views.js";
|
|
7
7
|
import { byteAwareReplacer } from "./geometry/solid-hash.js";
|
|
8
|
-
import { viewSubParts } from "./part-model.js";
|
|
8
|
+
import { viewSubParts, resolveParamsAttributed, recordedParams } from "./part-model.js";
|
|
9
|
+
import { newReadSink, expandReads } from "./read-recorder.js";
|
|
10
|
+
import { probeSubPartPose } from "./pose-probe-core.js";
|
|
9
11
|
|
|
10
12
|
export const RELEVANT_ALL = Symbol("relevant-all");
|
|
11
13
|
|
|
@@ -77,10 +79,12 @@ export function subPartParamKeys(part, view, params, readsOf, name) {
|
|
|
77
79
|
const rec = readsOf?.(name);
|
|
78
80
|
if (rec) {
|
|
79
81
|
// The worker records the BUILD's reads; the sub-part's own show/hide gate
|
|
80
|
-
//
|
|
82
|
+
// and its place() reads in this view are added here, as the prediction rung
|
|
83
|
+
// does (subPartReadKeys runs enabled() and place()).
|
|
81
84
|
const out = new Set(rec);
|
|
82
85
|
const sp = part.parts[name];
|
|
83
86
|
if (sp?.enabled) sp.enabled(recorder(params, out));
|
|
87
|
+
for (const k of placeReadKeys(part, view, params, name)) out.add(k);
|
|
84
88
|
return [...out].sort();
|
|
85
89
|
}
|
|
86
90
|
const reads = subPartReadKeys(part, view, params);
|
|
@@ -88,16 +92,53 @@ export function subPartParamKeys(part, view, params, readsOf, name) {
|
|
|
88
92
|
return keys.sort();
|
|
89
93
|
}
|
|
90
94
|
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
95
|
+
// The params one sub-part's place() reads in THIS view, derive attribution
|
|
96
|
+
// included. A canonical delivery (jobs.js displayWithReads) is stamped with its
|
|
97
|
+
// BUILD reads alone — the pose is applied as a matrix, and the pose fast path
|
|
98
|
+
// needs a build-only stamp — so a param read only in place() (an explode slider
|
|
99
|
+
// feeding views.cutaway) never reaches the recorded rung. The display side adds
|
|
100
|
+
// it back here, per view: a view whose place() never reads the param leaves it
|
|
101
|
+
// irrelevant. Runs place() on the geometry-free canonical token (no build); the
|
|
102
|
+
// reads count whether or not the probe trusts the pose — they were read either
|
|
103
|
+
// way. Never throws; an unreadable place() contributes nothing (a posed delivery
|
|
104
|
+
// already recorded its place reads in the stamp). `resolved` lets a caller
|
|
105
|
+
// walking many sub-parts run derive() once (a resolveParamsAttributed result).
|
|
106
|
+
export function placeReadKeys(part, view, params, name, resolved) {
|
|
107
|
+
const sp = part.parts[name];
|
|
108
|
+
if (!sp || !placeOf(sp)) return [];
|
|
109
|
+
try {
|
|
110
|
+
resolved ??= resolveParamsAttributed(part, params);
|
|
111
|
+
const sink = newReadSink();
|
|
112
|
+
sink.attribution = resolved.attribution;
|
|
113
|
+
const rec = recordedParams(resolved, sink);
|
|
114
|
+
probeSubPartPose(sp, { view, purpose: "display", p: rec.p, d: rec.d }, { scope: "place" });
|
|
115
|
+
return expandReads(sink);
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Panel relevance from RECORDED reads: the union over on-screen sub-parts, each
|
|
122
|
+
// with its place() reads in this view (a not-yet-built one contributes its
|
|
123
|
+
// prediction — display only, so a wrong guess costs one build's dimming), plus
|
|
124
|
+
// the gate params of every in-view sub-part.
|
|
94
125
|
export function recordedRelevantKeys(part, view, params, readsOf) {
|
|
95
126
|
try {
|
|
96
127
|
const relevant = new Set();
|
|
97
128
|
let predicted = null;
|
|
129
|
+
let resolved = null;
|
|
98
130
|
for (const name of viewSubParts(part, view, params)) {
|
|
99
131
|
const rec = readsOf(name);
|
|
100
|
-
if (rec) {
|
|
132
|
+
if (rec) {
|
|
133
|
+
for (const k of rec) relevant.add(k);
|
|
134
|
+
// derive() once per call, and only if some sub-part has a pose to read.
|
|
135
|
+
// A throwing derive() costs only the place reads, never the recorded ones.
|
|
136
|
+
if (resolved === null && placeOf(part.parts[name])) {
|
|
137
|
+
try { resolved = resolveParamsAttributed(part, params); } catch { resolved = false; }
|
|
138
|
+
}
|
|
139
|
+
if (resolved) for (const k of placeReadKeys(part, view, params, name, resolved)) relevant.add(k);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
101
142
|
predicted ??= subPartReadKeys(part, view, params);
|
|
102
143
|
if (predicted === RELEVANT_ALL) return RELEVANT_ALL;
|
|
103
144
|
for (const k of predicted.get(name) ?? []) relevant.add(k);
|
|
@@ -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;
|
package/types/testing.d.ts
CHANGED
|
@@ -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 --------------------------------------------------------------
|