@ludic-labs/assembly 0.0.0-stage → 0.4.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/LICENSE +2 -0
  3. package/README.md +50 -2
  4. package/dist/bake.d.ts +80 -0
  5. package/dist/bake.js +214 -0
  6. package/dist/browser.d.ts +89 -0
  7. package/dist/browser.js +534 -0
  8. package/dist/compat.d.ts +17 -0
  9. package/dist/compat.js +153 -0
  10. package/dist/compiler.d.ts +18 -0
  11. package/dist/compiler.js +1264 -0
  12. package/dist/diagnostics.d.ts +5 -0
  13. package/dist/diagnostics.js +8 -0
  14. package/dist/export.d.ts +54 -0
  15. package/dist/export.js +272 -0
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.js +3 -0
  18. package/dist/legacy.d.ts +36 -0
  19. package/dist/legacy.js +180 -0
  20. package/dist/math.d.ts +16 -0
  21. package/dist/math.js +62 -0
  22. package/dist/mesh.d.ts +11 -0
  23. package/dist/mesh.js +153 -0
  24. package/dist/motion.d.ts +63 -0
  25. package/dist/motion.js +1476 -0
  26. package/dist/pixel.d.ts +268 -0
  27. package/dist/pixel.js +942 -0
  28. package/dist/required.d.ts +2 -0
  29. package/dist/required.js +6 -0
  30. package/dist/shapes.d.ts +71 -0
  31. package/dist/shapes.js +396 -0
  32. package/dist/sprite-brief.d.ts +5 -0
  33. package/dist/sprite-brief.js +47 -0
  34. package/dist/sprite-compiler.d.ts +62 -0
  35. package/dist/sprite-compiler.js +1745 -0
  36. package/dist/sprite-decompile.d.ts +68 -0
  37. package/dist/sprite-decompile.js +786 -0
  38. package/dist/sprite-format.d.ts +4 -0
  39. package/dist/sprite-format.js +155 -0
  40. package/dist/sprite-guide.d.ts +40 -0
  41. package/dist/sprite-guide.js +330 -0
  42. package/dist/sprite-scene.d.ts +277 -0
  43. package/dist/sprite-scene.js +664 -0
  44. package/dist/sprite-types.d.ts +110 -0
  45. package/dist/sprite-types.js +1 -0
  46. package/dist/sprite.d.ts +8 -0
  47. package/dist/sprite.js +8 -0
  48. package/dist/svg/schema.d.ts +71 -0
  49. package/dist/svg/schema.js +217 -0
  50. package/dist/svg/types.d.ts +148 -0
  51. package/dist/svg/types.js +1 -0
  52. package/dist/svg.d.ts +26 -0
  53. package/dist/svg.js +539 -0
  54. package/dist/text.d.ts +12 -0
  55. package/dist/text.js +75 -0
  56. package/dist/types.d.ts +478 -0
  57. package/dist/types.js +1 -0
  58. package/docs/svg/README.md +150 -0
  59. package/docs/svg/specification.md +108 -0
  60. package/examples/svg/arcane-shield.json +261 -0
  61. package/examples/svg/aurora-ribbon.json +158 -0
  62. package/examples/svg/bouncing-ember.json +76 -0
  63. package/examples/svg/ember-fountain.json +224 -0
  64. package/examples/svg/lantern-keeper.json +1159 -0
  65. package/examples/svg/night-workshop.json +1120 -0
  66. package/examples/svg/waygate-platform.json +64 -0
  67. package/examples/svg/waygate-spark.json +92 -0
  68. package/examples/svg/waygate-wisp.json +105 -0
  69. package/examples/svg/waygate.json +485 -0
  70. package/package.json +132 -4
  71. package/schemas/asset-assembly-0.1.schema.json +354 -0
  72. package/schemas/asset-assembly-0.2.schema.json +429 -0
  73. package/schemas/asset-assembly-0.3.schema.json +572 -0
  74. package/schemas/motion-set-0.1.schema.json +178 -0
  75. package/schemas/pixel-sprite-0.1.schema.json +892 -0
  76. package/schemas/pixel-sprite-scene-0.1.schema.json +149 -0
  77. package/schemas/svg-asset-0.1.schema.json +1157 -0
  78. package/tools/svg-asset.mjs +106 -0
package/dist/motion.js ADDED
@@ -0,0 +1,1476 @@
1
+ import { requireValue } from "./required.js";
2
+ import { linear } from "./compat.js";
3
+ import { AssetCompileError } from "./diagnostics.js";
4
+ import { appendMesh, emptyMesh, meshBounds, sourceHash } from "./mesh.js";
5
+ import { IDENTITY, applyPoint, applyVector, compose, quantize, rotation, translation } from "./math.js";
6
+ /** Motion sets for asset-assembly/0.2 characters. */
7
+ export const MOTION_SCHEMA = "motion-set/0.1";
8
+ /** Lift and ground-penetration tolerance as a fraction of the asset's rest height. */
9
+ export const CONTACT_TOLERANCE = 0.02;
10
+ /** Planted-effector drift tolerance as a fraction of the asset's rest height. */
11
+ export const SLIP_TOLERANCE = 0.04;
12
+ /** Uniform verification samples per clip; contact intervals receive 16 additional samples each. */
13
+ export const VERIFICATION_SAMPLES = 48;
14
+ const pointer = (s) => s.replaceAll("~", "~0").replaceAll("/", "~1");
15
+ /** A string value as itself; anything else as "", without coercion (which can throw or alias). */
16
+ const text = (v) => (typeof v === "string" ? v : "");
17
+ const record = (v) => !!v &&
18
+ typeof v === "object" &&
19
+ !Array.isArray(v) &&
20
+ (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
21
+ const has = (v, k) => Object.hasOwn(v, k);
22
+ const idPattern = /^[A-Za-z][A-Za-z0-9_-]{0,79}$/;
23
+ const refPattern = /^[A-Za-z][A-Za-z0-9_-]{0,79}(\/(left|right|0|[1-9][0-9]{0,2}))?$/;
24
+ const finite = (v) => typeof v === "number" && Number.isFinite(v);
25
+ const round = (v, digits = 4) => Math.round(v * 10 ** digits) / 10 ** digits;
26
+ function structural(input) {
27
+ const diagnostics = [];
28
+ const fail = (path, message, code = "SCHEMA_ERROR", extra = {}) => diagnostics.push({ code, path, message, ...extra });
29
+ const obj = (v, path, allowed, required = []) => {
30
+ if (!record(v)) {
31
+ fail(path, "Expected a plain JSON object.");
32
+ return false;
33
+ }
34
+ for (const k of Object.keys(v))
35
+ if (!allowed.includes(k))
36
+ fail(`${path}/${pointer(k)}`, "Unknown field or unsupported operation.", "UNKNOWN_FIELD");
37
+ for (const k of required)
38
+ if (!has(v, k))
39
+ fail(`${path}/${k}`, "Required field is missing.");
40
+ return true;
41
+ };
42
+ const dictionary = (v, path, fn, pattern = idPattern, min = 0, max = 256) => {
43
+ if (!record(v)) {
44
+ fail(path, "Expected a dictionary of named entries.");
45
+ return;
46
+ }
47
+ if (Object.keys(v).length < min || Object.keys(v).length > max)
48
+ fail(path, `Expected ${min}–${max} entries.`, "GEOMETRY_BUDGET");
49
+ for (const [id, entry] of Object.entries(v)) {
50
+ if (!pattern.test(id))
51
+ fail(`${path}/${pointer(id)}`, pattern === refPattern
52
+ ? "Use a joint or part ID, optionally followed by /left, /right or an array copy index such as /0."
53
+ : "IDs must start with a letter and contain letters, digits, underscores or hyphens.");
54
+ fn(entry, `${path}/${pointer(id)}`);
55
+ }
56
+ };
57
+ const values = (v, path) => dictionary(v, path, (dofs, p) => dictionary(dofs, p, (n, dp) => {
58
+ if (!finite(n))
59
+ fail(dp, "Angles are finite numbers of degrees.", "NON_FINITE_VALUE");
60
+ }), refPattern);
61
+ const states = (v, path) => dictionary(v, path, (state, sp) => {
62
+ if (typeof state !== "string")
63
+ fail(sp, "Expected a state name.");
64
+ });
65
+ if (!obj(input, "", ["schema", "poses", "expressions", "clips"], ["schema", "clips"]))
66
+ return diagnostics;
67
+ const root = input;
68
+ if (root.schema !== MOTION_SCHEMA)
69
+ fail("/schema", `Expected ${MOTION_SCHEMA}.`, "UNSUPPORTED_SCHEMA", { received: root.schema });
70
+ if (root.poses !== undefined)
71
+ dictionary(root.poses, "/poses", (v, p) => {
72
+ if (obj(v, p, ["joints"], ["joints"]))
73
+ values(v.joints, `${p}/joints`);
74
+ });
75
+ if (root.expressions !== undefined)
76
+ dictionary(root.expressions, "/expressions", (v, p) => {
77
+ if (!obj(v, p, ["joints", "states"]))
78
+ return;
79
+ const e = v;
80
+ if (e.joints !== undefined)
81
+ values(e.joints, `${p}/joints`);
82
+ if (e.states !== undefined)
83
+ states(e.states, `${p}/states`);
84
+ });
85
+ const poses = record(root.poses) ? root.poses : {}, expressions = record(root.expressions) ? root.expressions : {};
86
+ dictionary(root.clips, "/clips", (v, p) => {
87
+ if (!obj(v, p, [
88
+ "duration",
89
+ "loop",
90
+ "interpolation",
91
+ "symmetry",
92
+ "keys",
93
+ "contacts",
94
+ "grounding",
95
+ "travel",
96
+ "solve",
97
+ ], ["duration", "loop", "keys"]))
98
+ return;
99
+ const clip = v;
100
+ if (!finite(clip.duration) || clip.duration <= 0 || clip.duration > 600)
101
+ fail(`${p}/duration`, "Duration is a positive number of seconds, at most 600.");
102
+ if (typeof clip.loop !== "boolean")
103
+ fail(`${p}/loop`, "Expected a boolean.");
104
+ if (clip.interpolation !== undefined &&
105
+ !["smooth", "linear", "step"].includes(text(clip.interpolation)))
106
+ fail(`${p}/interpolation`, "Choose smooth, linear or step.", "SCHEMA_ERROR", {
107
+ received: clip.interpolation,
108
+ expected: ["smooth", "linear", "step"],
109
+ });
110
+ if (clip.symmetry !== undefined && !["none", "alternate"].includes(text(clip.symmetry)))
111
+ fail(`${p}/symmetry`, "Choose none or alternate.", "SCHEMA_ERROR", {
112
+ received: clip.symmetry,
113
+ expected: ["none", "alternate"],
114
+ });
115
+ if (!Array.isArray(clip.keys) || clip.keys.length < 1 || clip.keys.length > 256)
116
+ fail(`${p}/keys`, "Expected an array of 1–256 keys.");
117
+ else
118
+ clip.keys.forEach((key, i) => {
119
+ const kp = `${p}/keys/${i}`;
120
+ if (!obj(key, kp, ["at", "pose", "expression", "joints", "states"], ["at"]))
121
+ return;
122
+ const k = key;
123
+ if (!finite(k.at) || k.at < 0 || k.at > 1)
124
+ fail(`${kp}/at`, "Key times are normalized from 0 to 1 of the clip duration.");
125
+ if (Object.keys(k).length < 2)
126
+ fail(kp, "A key needs a pose, an expression, joints or states.");
127
+ if (k.expression !== undefined &&
128
+ (typeof k.expression !== "string" || !has(expressions, k.expression)))
129
+ fail(`${kp}/expression`, "Unknown expression.", "UNKNOWN_EXPRESSION", {
130
+ received: k.expression,
131
+ expected: Object.keys(expressions).sort(),
132
+ });
133
+ if (k.states !== undefined)
134
+ states(k.states, `${kp}/states`);
135
+ if (k.pose !== undefined && (typeof k.pose !== "string" || !has(poses, k.pose)))
136
+ fail(`${kp}/pose`, "Unknown pose.", "UNKNOWN_POSE", {
137
+ received: k.pose,
138
+ expected: Object.keys(poses).sort(),
139
+ });
140
+ if (k.joints !== undefined)
141
+ values(k.joints, `${kp}/joints`);
142
+ });
143
+ if (clip.contacts !== undefined)
144
+ dictionary(clip.contacts, `${p}/contacts`, (intervals, cp) => {
145
+ if (!Array.isArray(intervals) || intervals.length < 1 || intervals.length > 16) {
146
+ fail(cp, "Expected an array of 1–16 [start, end] intervals.");
147
+ return;
148
+ }
149
+ intervals.forEach((interval, i) => {
150
+ if (!Array.isArray(interval) || interval.length !== 2 || !interval.every(finite))
151
+ fail(`${cp}/${i}`, "Expected [start, end] in normalized clip time.");
152
+ });
153
+ }, refPattern);
154
+ const g = clip.grounding;
155
+ if (g !== undefined && obj(g, `${p}/grounding`, ["type", "part", "anchor"], ["type"])) {
156
+ const r = g;
157
+ if (!["none", "contacts", "anchor"].includes(text(r.type)))
158
+ fail(`${p}/grounding/type`, "Choose none, contacts or anchor.", "SCHEMA_ERROR", {
159
+ received: r.type,
160
+ expected: ["none", "contacts", "anchor"],
161
+ });
162
+ else if (r.type === "anchor") {
163
+ if (typeof r.part !== "string")
164
+ fail(`${p}/grounding/part`, "Expected a part or instance ID.");
165
+ if (typeof r.anchor !== "string")
166
+ fail(`${p}/grounding/anchor`, "Expected an anchor name.");
167
+ }
168
+ else
169
+ for (const k of ["part", "anchor"])
170
+ if (r[k] !== undefined)
171
+ fail(`${p}/grounding/${k}`, "Only anchor grounding names a part.", "UNKNOWN_FIELD");
172
+ }
173
+ if (clip.solve !== undefined && !["none", "planted"].includes(text(clip.solve)))
174
+ fail(`${p}/solve`, "Choose none or planted.", "SCHEMA_ERROR", {
175
+ received: clip.solve,
176
+ expected: ["none", "planted"],
177
+ });
178
+ const t = clip.travel;
179
+ if (t !== undefined && t !== "contacts" && obj(t, `${p}/travel`, ["x", "y", "z"], ["x", "y", "z"]))
180
+ for (const a of ["x", "y", "z"])
181
+ if (!finite(t[a]))
182
+ fail(`${p}/travel/${a}`, "Travel is a finite displacement per cycle.", "NON_FINITE_VALUE");
183
+ }, idPattern, 1);
184
+ return diagnostics;
185
+ }
186
+ /* --------------------------------------------------------------------------- interpolation */
187
+ const sign = (v) => (v > 0 ? 1 : v < 0 ? -1 : 0);
188
+ /** Steffen (1990) monotone tangents: C1, passes through every key and never overshoots a segment. */
189
+ function tangents(times, values, loop, cycle = 0) {
190
+ const n = times.length;
191
+ if (n < 2 && !cycle)
192
+ return new Array(n).fill(0);
193
+ const at = (i) => {
194
+ if (!loop)
195
+ return [requireValue(times[i]), requireValue(values[i])];
196
+ const wrap = Math.floor(i / n), j = i - wrap * n;
197
+ // A continuous DOF unwraps: each further cycle adds `cycle` degrees.
198
+ return [
199
+ requireValue(times[j]) + wrap,
200
+ cycle ? requireValue(values[j]) + wrap * cycle : requireValue(values[j]),
201
+ ];
202
+ };
203
+ return times.map((_, i) => {
204
+ if (!loop && (i === 0 || i === n - 1))
205
+ return 0;
206
+ const [x0, y0] = at(i - 1), [x1, y1] = at(i), [x2, y2] = at(i + 1);
207
+ const h0 = x1 - x0, h1 = x2 - x1, s0 = (y1 - y0) / h0, s1 = (y2 - y1) / h1, p = (s0 * h1 + s1 * h0) / (h0 + h1);
208
+ return (sign(s0) + sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(p));
209
+ });
210
+ }
211
+ function evaluate(channel, u, loop, mode) {
212
+ const { times: t, values: v, tangents: m } = channel, n = t.length, cycle = loop ? (channel.cycle ?? 0) : 0;
213
+ if (n === 1 && !cycle)
214
+ return requireValue(v[0]);
215
+ let i, x0, x1;
216
+ let base = 0;
217
+ if (loop) {
218
+ if (u < requireValue(t[0])) {
219
+ u += 1;
220
+ // Before the first key of a continuous channel, the closing segment ends one cycle earlier.
221
+ base = -cycle;
222
+ }
223
+ i = n - 1;
224
+ for (let k = 0; k < n - 1; k++)
225
+ if (u >= requireValue(t[k]) && u < requireValue(t[k + 1])) {
226
+ i = k;
227
+ break;
228
+ }
229
+ x0 = requireValue(t[i]);
230
+ x1 = i === n - 1 ? requireValue(t[0]) + 1 : requireValue(t[i + 1]);
231
+ }
232
+ else {
233
+ if (u <= requireValue(t[0]))
234
+ return requireValue(v[0]);
235
+ if (u >= requireValue(t[n - 1]))
236
+ return requireValue(v[n - 1]);
237
+ i = 0;
238
+ while (u >= requireValue(t[i + 1]))
239
+ i++;
240
+ x0 = requireValue(t[i]);
241
+ x1 = requireValue(t[i + 1]);
242
+ }
243
+ const j = (i + 1) % n, h = x1 - x0, s = (u - x0) / h;
244
+ if (cycle) {
245
+ // The closing segment of a continuous channel runs on to the first key plus one cycle.
246
+ const a = requireValue(v[i]) + base, b = requireValue(v[j]) + base + (j === 0 ? cycle : 0);
247
+ if (mode === "step")
248
+ return a;
249
+ if (mode === "linear")
250
+ return a + (b - a) * s;
251
+ const s2 = s * s, s3 = s2 * s;
252
+ return ((2 * s3 - 3 * s2 + 1) * a +
253
+ (s3 - 2 * s2 + s) * h * requireValue(m[i]) +
254
+ (-2 * s3 + 3 * s2) * b +
255
+ (s3 - s2) * h * requireValue(m[j]));
256
+ }
257
+ if (mode === "step")
258
+ return requireValue(v[i]);
259
+ if (mode === "linear")
260
+ return requireValue(v[i]) + (requireValue(v[j]) - requireValue(v[i])) * s;
261
+ const s2 = s * s, s3 = s2 * s;
262
+ return ((2 * s3 - 3 * s2 + 1) * requireValue(v[i]) +
263
+ (s3 - 2 * s2 + s) * h * requireValue(m[i]) +
264
+ (-2 * s3 + 3 * s2) * requireValue(v[j]) +
265
+ (s3 - s2) * h * requireValue(m[j]));
266
+ }
267
+ /* --------------------------------------------------------------------------- posing */
268
+ /** Rotation about the joint's pivot: [R | p − R·p]. */
269
+ function localTransform(joint, values) {
270
+ let r = IDENTITY;
271
+ for (const dof of joint.dofs) {
272
+ const degrees = values[joint.id]?.[dof.name] ?? 0;
273
+ if (degrees !== 0)
274
+ r = r === IDENTITY ? rotation(dof.axis, degrees) : compose(r, rotation(dof.axis, degrees));
275
+ }
276
+ if (r === IDENTITY)
277
+ return IDENTITY;
278
+ const [x, y, z] = joint.pivot;
279
+ return [
280
+ requireValue(r[0]),
281
+ requireValue(r[1]),
282
+ requireValue(r[2]),
283
+ x - (requireValue(r[0]) * x + requireValue(r[1]) * y + requireValue(r[2]) * z),
284
+ requireValue(r[4]),
285
+ requireValue(r[5]),
286
+ requireValue(r[6]),
287
+ y - (requireValue(r[4]) * x + requireValue(r[5]) * y + requireValue(r[6]) * z),
288
+ requireValue(r[8]),
289
+ requireValue(r[9]),
290
+ requireValue(r[10]),
291
+ z - (requireValue(r[8]) * x + requireValue(r[9]) * y + requireValue(r[10]) * z),
292
+ ];
293
+ }
294
+ function jointTransforms(asset, values) {
295
+ const world = new Map();
296
+ for (const joint of requireValue(asset.skeleton).joints) {
297
+ const local = localTransform(joint, values), parent = joint.parent === null ? IDENTITY : requireValue(world.get(joint.parent));
298
+ world.set(joint.id, parent === IDENTITY ? local : local === IDENTITY ? parent : compose(parent, local));
299
+ }
300
+ return world;
301
+ }
302
+ const chains = new WeakMap();
303
+ /** World transform of one joint, composing only its ancestors (used inside the solver). */
304
+ function chainTransform(asset, values, id) {
305
+ let cache = chains.get(asset);
306
+ if (!cache)
307
+ chains.set(asset, (cache = new Map()));
308
+ let chain = cache.get(id);
309
+ if (!chain) {
310
+ const byId = new Map(requireValue(asset.skeleton).joints.map((j) => [j.id, j]));
311
+ chain = [];
312
+ for (let j = id; j !== null; j = requireValue(byId.get(j)).parent)
313
+ chain.unshift(requireValue(byId.get(j)));
314
+ cache.set(id, chain);
315
+ }
316
+ let m = IDENTITY;
317
+ for (const joint of chain) {
318
+ const local = localTransform(joint, values);
319
+ if (local !== IDENTITY)
320
+ m = m === IDENTITY ? local : compose(m, local);
321
+ }
322
+ return m;
323
+ }
324
+ const transformFor = (instance, world, root) => compose(translation(root), instance.joint ? requireValue(world.get(instance.joint)) : IDENTITY);
325
+ function lowest(instance, m) {
326
+ let y = Infinity;
327
+ const p = instance.mesh.positions;
328
+ for (let i = 0; i < p.length; i += 3)
329
+ y = Math.min(y, applyPoint(m, [requireValue(p[i]), requireValue(p[i + 1]), requireValue(p[i + 2])])[1]);
330
+ return y;
331
+ }
332
+ /* --------------------------------------------------------------------------- compilation */
333
+ function compileClips(spec, r) {
334
+ const { asset, joints, names } = r;
335
+ const errors = [], fail = (d) => {
336
+ errors.push(d);
337
+ };
338
+ const addressable = [...names.keys()].sort();
339
+ const channelKey = (joint, dof) => `${joint}\u0000${dof}`;
340
+ const allChannels = [...joints.values()].flatMap((j) => j.dofs.map((d) => channelKey(j.id, d.name)));
341
+ const isFace = (key) => requireValue(joints.get(requireValue(key.split("\u0000")[0]))).face === true;
342
+ const declaredStates = asset.states ?? {};
343
+ /**
344
+ * Resolve a values object into explicit channel values. Poses may only set body joints and
345
+ * expressions only face joints; key `joints` may set either.
346
+ */
347
+ const resolveValues = (values, path, alternate, track) => {
348
+ const out = new Map(), origin = new Map();
349
+ for (const ref of Object.keys(values).sort()) {
350
+ const targets = names.get(ref), jp = `${path}/${pointer(ref)}`;
351
+ if (!targets) {
352
+ fail({
353
+ code: "UNKNOWN_JOINT",
354
+ path: jp,
355
+ message: "Choose a joint exported by this asset's skeleton.",
356
+ received: ref,
357
+ expected: addressable,
358
+ });
359
+ continue;
360
+ }
361
+ const face = requireValue(joints.get(requireValue(targets[0]))).face === true;
362
+ if (track === "body" && face) {
363
+ fail({
364
+ code: "FACE_JOINT_IN_POSE",
365
+ path: jp,
366
+ message: "Poses set body joints. Set face joints in an expression or in a key's joints.",
367
+ received: ref,
368
+ });
369
+ continue;
370
+ }
371
+ if (track === "face" && !face) {
372
+ fail({
373
+ code: "BODY_JOINT_IN_EXPRESSION",
374
+ path: jp,
375
+ message: "Expressions set face joints and states only. Set body joints in a pose.",
376
+ received: ref,
377
+ });
378
+ continue;
379
+ }
380
+ // Alternate symmetry shifts sided body joints; faces stay symmetric.
381
+ if (alternate && !face && ref.endsWith("/right")) {
382
+ fail({
383
+ code: "SYMMETRY_CONFLICT",
384
+ path: jp,
385
+ message: "Alternate symmetry derives every right-side value from the left side half a cycle later.",
386
+ received: ref,
387
+ });
388
+ continue;
389
+ }
390
+ // A mirrored pair, not a two-copy array: only the left joint is keyed; the right follows.
391
+ const sided = targets.length === 2 && requireValue(joints.get(requireValue(targets[0]))).side === "left";
392
+ for (const id of alternate && sided && !face ? targets.slice(0, 1) : targets) {
393
+ const joint = requireValue(joints.get(id));
394
+ for (const dof of Object.keys(requireValue(values[ref])).sort()) {
395
+ const value = requireValue(requireValue(values[ref])[dof]), dp = `${jp}/${pointer(dof)}`, spec = joint.dofs.find((d) => d.name === dof);
396
+ if (!spec) {
397
+ fail({
398
+ code: "UNKNOWN_DOF",
399
+ path: dp,
400
+ message: `Choose a degree of freedom exported by joint ${joint.joint}.`,
401
+ received: dof,
402
+ expected: joint.dofs.map((d) => d.name),
403
+ });
404
+ continue;
405
+ }
406
+ if (value < spec.limits[0] || value > spec.limits[1])
407
+ fail({
408
+ code: "JOINT_LIMIT",
409
+ path: dp,
410
+ message: `Joint ${joint.joint} ${dof} accepts ${spec.limits[0]}° to ${spec.limits[1]}°.`,
411
+ received: value,
412
+ expected: spec.limits.map(String),
413
+ });
414
+ const key = channelKey(id, dof);
415
+ if (origin.has(key) && origin.get(key) !== ref)
416
+ fail({
417
+ code: "SYMMETRY_CONFLICT",
418
+ path: dp,
419
+ message: `Both ${origin.get(key)} and ${ref} set this value. Use the bare name for both sides or one side each.`,
420
+ });
421
+ origin.set(key, ref);
422
+ out.set(key, value);
423
+ }
424
+ }
425
+ }
426
+ return out;
427
+ };
428
+ const resolveStates = (values, path) => {
429
+ const out = new Map();
430
+ for (const channel of Object.keys(values).sort()) {
431
+ const sp = `${path}/${pointer(channel)}`, declared = declaredStates[channel];
432
+ if (!declared)
433
+ fail({
434
+ code: "UNKNOWN_STATE",
435
+ path: sp,
436
+ message: "Choose a state channel declared by the asset.",
437
+ received: channel,
438
+ expected: Object.keys(declaredStates).sort(),
439
+ });
440
+ else if (!declared.includes(requireValue(values[channel])))
441
+ fail({
442
+ code: "UNKNOWN_STATE_VALUE",
443
+ path: sp,
444
+ message: `Choose a state of channel ${channel}.`,
445
+ received: values[channel],
446
+ expected: declared,
447
+ });
448
+ else
449
+ out.set(channel, requireValue(values[channel]));
450
+ }
451
+ return out;
452
+ };
453
+ const poseSpecs = spec.poses ?? {}, expressionSpecs = spec.expressions ?? {};
454
+ /** A pose sets every body DOF: listed values, 0 for all others. */
455
+ const poseValues = (name, alternate, path) => {
456
+ const explicit = resolveValues(requireValue(poseSpecs[name]).joints, path, alternate, "body"), out = new Map();
457
+ for (const key of allChannels)
458
+ if (!isFace(key))
459
+ out.set(key, explicit.get(key) ?? 0);
460
+ return out;
461
+ };
462
+ /** An expression sets every face DOF and every state: listed values, rest values for all others. */
463
+ const expressionValues = (name, path) => {
464
+ const e = requireValue(expressionSpecs[name]), explicit = resolveValues(e.joints ?? {}, `${path}/joints`, false, "face"), listed = resolveStates(e.states ?? {}, `${path}/states`), values = new Map(), states = new Map();
465
+ for (const key of allChannels)
466
+ if (isFace(key))
467
+ values.set(key, explicit.get(key) ?? 0);
468
+ for (const [channel, options] of Object.entries(declaredStates))
469
+ states.set(channel, listed.get(channel) ?? requireValue(options[0]));
470
+ return { values, states };
471
+ };
472
+ const everything = [...asset.instances, ...(asset.alternates ?? [])];
473
+ const resolveEffectors = (ref, path) => {
474
+ if (everything.some((i) => i.id === ref))
475
+ return [ref];
476
+ const sided = [`${ref}/left`, `${ref}/right`];
477
+ if (sided.every((id) => everything.some((i) => i.id === id)))
478
+ return sided;
479
+ fail({
480
+ code: "UNKNOWN_REFERENCE",
481
+ path,
482
+ message: "Contacts name a part instance, or a sided part for both sides.",
483
+ targetPart: ref,
484
+ expected: everything.map((i) => i.id).sort(),
485
+ });
486
+ return [];
487
+ };
488
+ const stateTracks = (keys) => Object.keys(declaredStates)
489
+ .sort()
490
+ .flatMap((channel) => {
491
+ const times = [], values = [];
492
+ for (const k of keys)
493
+ if (k.states.has(channel)) {
494
+ times.push(k.at);
495
+ values.push(requireValue(k.states.get(channel)));
496
+ }
497
+ return times.length ? [{ channel, times, values }] : [];
498
+ });
499
+ const faceOnly = (channels) => channels.every((c) => requireValue(joints.get(c.joint)).face === true);
500
+ const build = (name, clip, path) => {
501
+ const before = errors.length;
502
+ const loop = clip.loop, alternate = clip.symmetry === "alternate", interpolation = clip.interpolation ?? "smooth", grounding = clip.grounding ?? { type: "none" };
503
+ if (alternate && !loop)
504
+ fail({
505
+ code: "SYMMETRY_REQUIRES_LOOP",
506
+ path: `${path}/symmetry`,
507
+ message: "Alternate symmetry shifts the left side by half a cycle and needs a looping clip.",
508
+ });
509
+ const keys = [];
510
+ clip.keys.forEach((key, index) => {
511
+ const kp = `${path}/keys/${index}`;
512
+ if (index > 0 && !(key.at > requireValue(clip.keys[index - 1]).at))
513
+ fail({
514
+ code: "KEY_ORDER",
515
+ path: `${kp}/at`,
516
+ message: "Key times must increase strictly in array order.",
517
+ received: key.at,
518
+ });
519
+ const values = key.pose !== undefined
520
+ ? poseValues(key.pose, alternate, `/poses/${pointer(key.pose)}/joints`)
521
+ : new Map();
522
+ let states = new Map();
523
+ if (key.expression !== undefined) {
524
+ const e = expressionValues(key.expression, `/expressions/${pointer(key.expression)}`);
525
+ for (const [k, v] of e.values)
526
+ values.set(k, v);
527
+ states = e.states;
528
+ }
529
+ if (key.joints)
530
+ for (const [k, v] of resolveValues(key.joints, `${kp}/joints`, alternate, "any"))
531
+ values.set(k, v);
532
+ if (key.states)
533
+ for (const [k, v] of resolveStates(key.states, `${kp}/states`))
534
+ states.set(k, v);
535
+ keys.push({ at: key.at, values, states, index });
536
+ });
537
+ /** Degrees per loop for continuous DOFs whose closing key adds whole turns. */
538
+ const cycles = new Map();
539
+ if (loop && keys.length && requireValue(keys[keys.length - 1]).at === 1) {
540
+ const last = requireValue(keys.pop()), first = keys[0];
541
+ const closes = (k, v) => {
542
+ if (!first?.values.has(k))
543
+ return false;
544
+ const diff = v - requireValue(first.values.get(k));
545
+ if (Math.abs(diff) < 1e-9)
546
+ return true;
547
+ const [joint, dof] = k.split("\u0000"), turns = Math.round(diff / 360);
548
+ if (!joints.get(joint)?.dofs.find((d) => d.name === dof)?.continuous)
549
+ return false;
550
+ if (turns === 0 || Math.abs(diff - turns * 360) >= 1e-9)
551
+ return false;
552
+ cycles.set(k, turns * 360);
553
+ return true;
554
+ };
555
+ const same = first !== undefined &&
556
+ first.at === 0 &&
557
+ last.values.size === first.values.size &&
558
+ last.states.size === first.states.size &&
559
+ [...last.values].every(([k, v]) => closes(k, v)) &&
560
+ [...last.states].every(([k, v]) => first.states.get(k) === v);
561
+ if (!same)
562
+ fail({
563
+ code: "CLIP_LOOP_MISMATCH",
564
+ path: `${path}/keys/${last.index}`,
565
+ message: "A looping clip closes itself. Remove the key at 1, or make it repeat the key at 0 exactly (a continuous DOF may instead add whole turns).",
566
+ });
567
+ }
568
+ const channels = [];
569
+ for (const key of allChannels) {
570
+ const [joint, dof] = key.split("\u0000");
571
+ const j = requireValue(joints.get(joint));
572
+ if (alternate && j.side === "right" && !j.face)
573
+ continue;
574
+ const times = [], vals = [];
575
+ for (const k of keys)
576
+ if (k.values.has(key)) {
577
+ times.push(k.at);
578
+ vals.push(requireValue(k.values.get(key)));
579
+ }
580
+ if (!times.length)
581
+ continue;
582
+ const cycle = cycles.get(key) ?? 0;
583
+ channels.push({
584
+ joint,
585
+ dof,
586
+ times,
587
+ values: vals,
588
+ tangents: tangents(times, vals, loop, cycle),
589
+ ...(cycle ? { cycle } : {}),
590
+ });
591
+ }
592
+ if (alternate)
593
+ for (const c of [...channels]) {
594
+ const j = requireValue(joints.get(c.joint));
595
+ if (j.side !== "left" || j.face)
596
+ continue;
597
+ const cycle = c.cycle ?? 0, pairs = c.times
598
+ .map((t, i) =>
599
+ // Keys moved back across the loop seam lose one cycle so a continuous channel stays unwrapped.
600
+ t + 0.5 >= 1
601
+ ? [t - 0.5, requireValue(c.values[i]) - cycle]
602
+ : [t + 0.5, requireValue(c.values[i])])
603
+ .sort((a, b) => a[0] - b[0]);
604
+ const times = pairs.map((p) => p[0]), values = pairs.map((p) => p[1]);
605
+ channels.push({
606
+ joint: `${j.joint}/right`,
607
+ dof: c.dof,
608
+ times,
609
+ values,
610
+ tangents: tangents(times, values, true, cycle),
611
+ ...(cycle ? { cycle } : {}),
612
+ });
613
+ }
614
+ channels.sort((a, b) => (a.joint < b.joint ? -1 : a.joint > b.joint ? 1 : a.dof < b.dof ? -1 : 1));
615
+ const contacts = [];
616
+ for (const ref of Object.keys(clip.contacts ?? {}).sort()) {
617
+ const cp = `${path}/contacts/${pointer(ref)}`;
618
+ if (alternate && ref.endsWith("/right")) {
619
+ fail({
620
+ code: "SYMMETRY_CONFLICT",
621
+ path: cp,
622
+ message: "Alternate symmetry derives right-side contacts from the left side half a cycle later.",
623
+ });
624
+ continue;
625
+ }
626
+ const intervals = requireValue(requireValue(clip.contacts)[ref]).map(([s, e]) => [s, e]);
627
+ intervals.forEach(([s, e], i) => {
628
+ const ok = loop ? s >= 0 && s < 1 && e > s && e <= s + 1 : s >= 0 && e > s && e <= 1;
629
+ if (!ok)
630
+ fail({
631
+ code: "INVALID_INTERVAL",
632
+ path: `${cp}/${i}`,
633
+ message: loop
634
+ ? "Looping intervals need 0 ≤ start < 1 and start < end ≤ start + 1; an end past 1 wraps."
635
+ : "Intervals need 0 ≤ start < end ≤ 1.",
636
+ received: [s, e],
637
+ });
638
+ });
639
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
640
+ for (let i = 0; i < sorted.length; i++) {
641
+ const next = sorted[i + 1] ?? (loop ? [requireValue(sorted[0])[0] + 1, 0] : null);
642
+ if (next && sorted.length > 1 && requireValue(sorted[i])[1] > next[0])
643
+ fail({
644
+ code: "INVALID_INTERVAL",
645
+ path: cp,
646
+ message: "Contact intervals of one effector must not overlap.",
647
+ });
648
+ }
649
+ let effectors = resolveEffectors(ref, cp);
650
+ // Under alternate symmetry an explicit left side behaves like the bare name, as joint values do.
651
+ if (alternate && effectors.length === 1 && requireValue(effectors[0]).endsWith("/left")) {
652
+ const right = `${requireValue(effectors[0]).slice(0, -5)}/right`;
653
+ if (everything.some((i) => i.id === right))
654
+ effectors = [requireValue(effectors[0]), right];
655
+ }
656
+ const targets = alternate && effectors.length === 2 ? effectors.slice(0, 1) : effectors;
657
+ for (const effector of targets)
658
+ contacts.push({ effector, intervals: sorted });
659
+ if (alternate && effectors.length === 2)
660
+ contacts.push({
661
+ effector: requireValue(effectors[1]),
662
+ intervals: sorted
663
+ .map(([s, e]) => (s + 0.5 >= 1 ? [s - 0.5, e - 0.5] : [s + 0.5, e + 0.5]))
664
+ .sort((a, b) => a[0] - b[0]),
665
+ });
666
+ }
667
+ contacts.sort((a, b) => (a.effector < b.effector ? -1 : 1));
668
+ if ((grounding.type === "contacts" || clip.travel === "contacts") && !contacts.length)
669
+ fail({
670
+ code: "MISSING_CONTACTS",
671
+ path: clip.travel === "contacts" && grounding.type !== "contacts"
672
+ ? `${path}/travel`
673
+ : `${path}/grounding`,
674
+ message: "Contact grounding and contact-derived travel need declared contacts.",
675
+ });
676
+ if (grounding.type === "anchor") {
677
+ const instance = everything.find((i) => i.id === grounding.part);
678
+ if (!instance)
679
+ fail({
680
+ code: "UNKNOWN_REFERENCE",
681
+ path: `${path}/grounding/part`,
682
+ message: "Anchor grounding names a part instance.",
683
+ targetPart: grounding.part,
684
+ expected: everything.map((i) => i.id).sort(),
685
+ });
686
+ else if (!has(instance.anchors, grounding.anchor))
687
+ fail({
688
+ code: "UNKNOWN_ANCHOR",
689
+ path: `${path}/grounding/anchor`,
690
+ message: "Choose an anchor exported by this part.",
691
+ part: instance.id,
692
+ received: grounding.anchor,
693
+ expected: Object.keys(instance.anchors).sort(),
694
+ });
695
+ }
696
+ const solve = clip.solve ?? "none";
697
+ if (solve === "planted") {
698
+ if (!contacts.length)
699
+ fail({
700
+ code: "MISSING_CONTACTS",
701
+ path: `${path}/solve`,
702
+ message: "The planted solve keeps declared contacts in place; declare contacts first.",
703
+ });
704
+ for (const c of contacts)
705
+ if (!(asset.limbs ?? []).some((l) => l.effector === c.effector))
706
+ fail({
707
+ code: "NO_LIMB",
708
+ path: `${path}/contacts`,
709
+ message: `No limb has effector ${c.effector}. Declare a limb in the asset, or set solve to none.`,
710
+ part: c.effector,
711
+ expected: (asset.limbs ?? []).map((l) => l.effector),
712
+ });
713
+ }
714
+ if (errors.length > before)
715
+ return null;
716
+ const compiled = {
717
+ name,
718
+ duration: clip.duration,
719
+ loop,
720
+ interpolation,
721
+ symmetry: alternate ? "alternate" : "none",
722
+ channels,
723
+ states: stateTracks(keys),
724
+ face: faceOnly(channels),
725
+ contacts,
726
+ grounding: structuredClone(grounding),
727
+ solve,
728
+ travel: [0, 0, 0],
729
+ metrics: { maxSlip: 0, maxLift: 0, minHeight: 0, speed: 0 },
730
+ };
731
+ verify(compiled, clip, path, r, fail);
732
+ return compiled;
733
+ };
734
+ /** Poses and expressions compile to single-key clips that sample the same way as clips. */
735
+ const still = (name, values, states) => {
736
+ const channels = [...values]
737
+ .filter(([, v]) => v !== 0)
738
+ .map(([key, v]) => {
739
+ const [joint, dof] = key.split("\u0000");
740
+ return { joint, dof, times: [0], values: [v], tangents: [0] };
741
+ });
742
+ return {
743
+ name,
744
+ duration: 1,
745
+ loop: false,
746
+ interpolation: "step",
747
+ symmetry: "none",
748
+ channels,
749
+ states: stateTracks([{ at: 0, states }]),
750
+ face: faceOnly(channels),
751
+ contacts: [],
752
+ grounding: { type: "none" },
753
+ solve: "none",
754
+ travel: [0, 0, 0],
755
+ metrics: { maxSlip: 0, maxLift: 0, minHeight: 0, speed: 0 },
756
+ };
757
+ };
758
+ const poses = Object.create(null), expressions = Object.create(null), clips = Object.create(null);
759
+ for (const name of Object.keys(poseSpecs).sort())
760
+ poses[name] = still(name, poseValues(name, false, `/poses/${pointer(name)}/joints`), new Map());
761
+ for (const name of Object.keys(expressionSpecs).sort()) {
762
+ const e = expressionValues(name, `/expressions/${pointer(name)}`);
763
+ expressions[name] = { ...still(name, e.values, e.states), face: true };
764
+ }
765
+ for (const name of Object.keys(spec.clips).sort()) {
766
+ const clip = build(name, requireValue(spec.clips[name]), `/clips/${pointer(name)}`);
767
+ if (clip)
768
+ clips[name] = clip;
769
+ }
770
+ const seen = new Set();
771
+ return {
772
+ poses,
773
+ expressions,
774
+ clips,
775
+ errors: errors.filter((d) => {
776
+ const key = JSON.stringify(d);
777
+ return seen.has(key) ? false : (seen.add(key), true);
778
+ }),
779
+ };
780
+ }
781
+ /** Planted effectors at a phase; intervals are closed and loops wrap. */
782
+ function planted(clip, u) {
783
+ return clip.contacts
784
+ .filter(({ intervals }) => intervals.some(([s, e]) => (u >= s && u <= e) || (clip.loop && u + 1 >= s && u + 1 <= e)))
785
+ .map((c) => c.effector);
786
+ }
787
+ function valuesAt(asset, clip, u) {
788
+ const out = {};
789
+ for (const joint of requireValue(asset.skeleton).joints) {
790
+ out[joint.id] = {};
791
+ for (const dof of joint.dofs)
792
+ requireValue(out[joint.id])[dof.name] = 0;
793
+ }
794
+ for (const c of clip.channels)
795
+ requireValue(out[c.joint])[c.dof] = quantize(evaluate(c, u, clip.loop, clip.interpolation));
796
+ return out;
797
+ }
798
+ function statesAt(asset, clip, u) {
799
+ const out = {};
800
+ for (const [channel, options] of Object.entries(asset.states ?? {}))
801
+ out[channel] = requireValue(options[0]);
802
+ for (const track of clip.states) {
803
+ let i = -1;
804
+ for (let k = 0; k < track.times.length; k++)
805
+ if (requireValue(track.times[k]) <= u)
806
+ i = k;
807
+ // Before the first key a loop holds the last state; a one-shot clip holds the first.
808
+ out[track.channel] = requireValue(track.values[i >= 0 ? i : clip.loop ? track.values.length - 1 : 0]);
809
+ }
810
+ return out;
811
+ }
812
+ /** Instances visible in the given states, in stable ID order. */
813
+ function visibleIn(asset, states) {
814
+ return [...asset.instances, ...(asset.alternates ?? [])]
815
+ .filter((i) => Object.entries(i.when ?? {}).every(([channel, values]) => values.includes(requireValue(states[channel]))))
816
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
817
+ }
818
+ const instanceOf = (asset, id) => requireValue(asset.instances.find((i) => i.id === id) ?? asset.alternates?.find((i) => i.id === id));
819
+ /* --------------------------------------------------------------------------- planted solve */
820
+ /** Normalized clip time over which the planted correction blends in before touchdown and out after lift-off. */
821
+ export const SOLVE_BLEND = 0.08;
822
+ const smoothstep = (x) => (x <= 0 ? 0 : x >= 1 ? 1 : x * x * (3 - 2 * x));
823
+ /** The contact interval governing an effector at phase u, with the blend weight and unwrapped time. */
824
+ function plantedWindow(clip, intervals, u) {
825
+ let best = null;
826
+ const n = intervals.length;
827
+ intervals.forEach(([s, e], i) => {
828
+ const prev = requireValue(intervals[(i + n - 1) % n]), next = requireValue(intervals[(i + 1) % n]);
829
+ const before = clip.loop ? (n === 1 ? 1 - (e - s) : (s - prev[1] + 1) % 1) : i === 0 ? s : s - prev[1], after = clip.loop ? (n === 1 ? 1 - (e - s) : (next[0] - e + 1) % 1) : i === n - 1 ? 1 - e : next[0] - e;
830
+ const bin = Math.min(SOLVE_BLEND, before / 2), bout = Math.min(SOLVE_BLEND, after / 2);
831
+ for (const t of clip.loop ? [u, u + 1, u - 1] : [u]) {
832
+ let weight = 0;
833
+ if (t >= s && t <= e)
834
+ weight = 1;
835
+ else if (bin > 0 && t >= s - bin && t < s)
836
+ weight = smoothstep((t - (s - bin)) / bin);
837
+ else if (bout > 0 && t > e && t <= e + bout)
838
+ weight = smoothstep(1 - (t - e) / bout);
839
+ if (weight > 0 && (!best || weight > best.weight))
840
+ best = { s, t, weight };
841
+ }
842
+ });
843
+ return best;
844
+ }
845
+ const targets = new WeakMap();
846
+ /** Where an effector touched down, in body space (pose + grounding, without travel). */
847
+ function plantTarget(asset, clip, limb, s) {
848
+ let cache = targets.get(clip);
849
+ if (!cache)
850
+ targets.set(clip, (cache = new Map()));
851
+ const key = `${limb.id}\u0000${s}`;
852
+ if (cache.has(key))
853
+ return requireValue(cache.get(key));
854
+ const u = clip.loop ? s - Math.floor(s) : s, world = jointTransforms(asset, valuesAt(asset, clip, u)), root = rootAt(asset, clip, u, world), instance = instanceOf(asset, limb.effector), rest = requireValue(instance.anchors[limb.anchor]), p = applyPoint(transformFor(instance, world, root), rest);
855
+ // On the ground, a levelled effector rests at its rest height above its lowest point.
856
+ const y = clip.grounding.type === "anchor"
857
+ ? p[1]
858
+ : limb.level
859
+ ? rest[1] - instance.bounds.min[1]
860
+ : p[1] - lowest(instance, transformFor(instance, world, root));
861
+ const target = [p[0], y, p[2]];
862
+ cache.set(key, target);
863
+ return target;
864
+ }
865
+ const roundDegrees = (v) => {
866
+ const q = Math.round(v * 1e9) / 1e9;
867
+ return q === 0 ? 0 : q;
868
+ };
869
+ /** Damped normal equations (JᵀJ + λI)Δ = Jᵀr for up to three columns, by Gaussian elimination. */
870
+ function leastSquares(columns, residual) {
871
+ const n = columns.length, dot3 = (a, b) => requireValue(a[0]) * requireValue(b[0]) +
872
+ requireValue(a[1]) * requireValue(b[1]) +
873
+ requireValue(a[2]) * requireValue(b[2]);
874
+ const m = columns.map((a, i) => [
875
+ ...columns.map((b, j) => dot3(a, b) + (i === j ? 1e-9 : 0)),
876
+ dot3(a, residual),
877
+ ]);
878
+ for (let c = 0; c < n; c++) {
879
+ let pivot = c;
880
+ for (let r = c + 1; r < n; r++)
881
+ if (Math.abs(requireValue(requireValue(m[r])[c])) > Math.abs(requireValue(requireValue(m[pivot])[c])))
882
+ pivot = r;
883
+ [m[c], m[pivot]] = [requireValue(m[pivot]), requireValue(m[c])];
884
+ if (Math.abs(requireValue(requireValue(m[c])[c])) < 1e-18)
885
+ return new Array(n).fill(0);
886
+ for (let r = 0; r < n; r++) {
887
+ if (r === c)
888
+ continue;
889
+ const f = requireValue(requireValue(m[r])[c]) / requireValue(requireValue(m[c])[c]);
890
+ for (let k = c; k <= n; k++) {
891
+ const row = requireValue(m[r]);
892
+ row[k] = requireValue(row[k]) - f * requireValue(requireValue(m[c])[k]);
893
+ }
894
+ }
895
+ }
896
+ return m.map((row, i) => requireValue(row[n]) / requireValue(row[i]));
897
+ }
898
+ const reaches = new WeakMap();
899
+ /** Rest distance from a limb's first reach pivot to its effector: the rest pose is full extension. */
900
+ function reachLength(asset, limb) {
901
+ let cache = reaches.get(asset);
902
+ if (!cache)
903
+ reaches.set(asset, (cache = new Map()));
904
+ if (!cache.has(limb.id)) {
905
+ const pivot = requireValue(requireValue(asset.skeleton).joints.find((j) => j.id === requireValue(limb.reach[0]).joint)).pivot, anchor = requireValue(instanceOf(asset, limb.effector).anchors[limb.anchor]);
906
+ cache.set(limb.id, Math.hypot(anchor[0] - pivot[0], anchor[1] - pivot[1], anchor[2] - pivot[2]));
907
+ }
908
+ return requireValue(cache.get(limb.id));
909
+ }
910
+ /**
911
+ * Authored values with declared limbs solved so planted effectors stay at their touchdown point, which
912
+ * recedes at the clip's travel speed. When a planted limb cannot reach at 99.5% of its rest length, the
913
+ * root drops by the shortfall. Then a deterministic damped Gauss-Newton solves up to three reach DOFs and
914
+ * a one-DOF level solve keeps the effector flat; results are clamped to limits and rounded to 1e-9°.
915
+ */
916
+ function solvedPoseAt(asset, clip, u) {
917
+ const values = valuesAt(asset, clip, u), root = rootAt(asset, clip, u);
918
+ if (clip.solve !== "planted")
919
+ return { values, root };
920
+ const joints = new Map(requireValue(asset.skeleton).joints.map((j) => [j.id, j]));
921
+ const clamp = (r, v) => {
922
+ const [lo, hi] = requireValue(requireValue(joints.get(r.joint)).dofs.find((d) => d.name === r.dof)).limits;
923
+ return Math.max(lo, Math.min(hi, v));
924
+ };
925
+ const plans = [];
926
+ for (const track of clip.contacts) {
927
+ const limb = requireValue(requireValue(asset.limbs).find((l) => l.effector === track.effector)), hit = plantedWindow(clip, track.intervals, u);
928
+ if (!hit)
929
+ continue;
930
+ const touchdown = plantTarget(asset, clip, limb, hit.s);
931
+ plans.push({
932
+ limb,
933
+ weight: hit.weight,
934
+ goal: [
935
+ touchdown[0] - clip.travel[0] * (hit.t - hit.s),
936
+ touchdown[1],
937
+ touchdown[2] - clip.travel[2] * (hit.t - hit.s),
938
+ ],
939
+ });
940
+ }
941
+ if (!plans.length)
942
+ return clearance(asset, clip, values, root, new Map());
943
+ const authored = jointTransforms(asset, values);
944
+ let drop = 0;
945
+ for (const { limb, goal, weight } of plans) {
946
+ const upper = requireValue(joints.get(requireValue(limb.reach[0]).joint)), p = applyPoint(requireValue(authored.get(upper.id)), upper.pivot), d = [goal[0] - p[0] - root[0], goal[1] - p[1] - root[1], goal[2] - p[2] - root[2]], reach = 0.995 * reachLength(asset, limb), horizontal = d[0] * d[0] + d[2] * d[2];
947
+ const needed = reach * reach > horizontal ? d[1] + Math.sqrt(reach * reach - horizontal) : d[1];
948
+ if (needed < 0)
949
+ drop = Math.min(drop, weight * needed);
950
+ }
951
+ const lowered = [root[0], root[1] + drop, root[2]];
952
+ const h = 0.01;
953
+ for (const { limb, goal, weight } of plans) {
954
+ const instance = instanceOf(asset, limb.effector), rest = requireValue(instance.anchors[limb.anchor]), solved = structuredClone(values);
955
+ const get = (r) => requireValue(requireValue(solved[r.joint])[r.dof]), set = (r, v) => (requireValue(solved[r.joint])[r.dof] = v);
956
+ const chain = requireValue(instance.joint), effector = () => applyPoint(compose(translation(lowered), chainTransform(asset, solved, chain)), rest);
957
+ for (let iteration = 0; iteration < 12; iteration++) {
958
+ const p = effector(), residual = [goal[0] - p[0], goal[1] - p[1], goal[2] - p[2]];
959
+ if (Math.hypot(...residual) < 1e-10)
960
+ break;
961
+ const columns = limb.reach.map((r) => {
962
+ const v = get(r);
963
+ set(r, v + h);
964
+ const plus = effector();
965
+ set(r, v - h);
966
+ const minus = effector();
967
+ set(r, v);
968
+ return [0, 1, 2].map((k) => (requireValue(plus[k]) - requireValue(minus[k])) / (2 * h));
969
+ });
970
+ const steps = leastSquares(columns, residual);
971
+ limb.reach.forEach((r, k) => set(r, clamp(r, get(r) + Math.max(-20, Math.min(20, requireValue(steps[k]))))));
972
+ }
973
+ if (limb.level) {
974
+ // Keep the effector part's front (rest −Z) horizontal.
975
+ const level = limb.level, tilt = () => applyVector(chainTransform(asset, solved, chain), [0, 0, -1])[1];
976
+ for (let iteration = 0; iteration < 8; iteration++) {
977
+ const v = get(level), g = tilt();
978
+ if (Math.abs(g) < 1e-12)
979
+ break;
980
+ set(level, v + h);
981
+ const plus = tilt();
982
+ set(level, v - h);
983
+ const minus = tilt();
984
+ const slope = (plus - minus) / (2 * h);
985
+ set(level, Math.abs(slope) < 1e-12 ? v : clamp(level, v - Math.max(-30, Math.min(30, g / slope))));
986
+ }
987
+ }
988
+ for (const r of [...limb.reach, ...(limb.level ? [limb.level] : [])]) {
989
+ const original = requireValue(requireValue(values[r.joint])[r.dof]);
990
+ requireValue(values[r.joint])[r.dof] = roundDegrees(original + weight * (get(r) - original));
991
+ }
992
+ }
993
+ return clearance(asset, clip, values, lowered, new Map(plans.map((p) => [p.limb.id, p.weight])));
994
+ }
995
+ /**
996
+ * Part of the planted solve: an unplanted limb whose effector part would sink below the ground is lifted
997
+ * by its reach DOFs until its lowest point touches the ground. Only ground-based clips are affected.
998
+ */
999
+ function clearance(asset, clip, values, root, planted) {
1000
+ if (clip.grounding.type === "anchor")
1001
+ return { values, root };
1002
+ const joints = new Map(requireValue(asset.skeleton).joints.map((j) => [j.id, j]));
1003
+ const clamp = (r, v) => {
1004
+ const [lo, hi] = requireValue(requireValue(joints.get(r.joint)).dofs.find((d) => d.name === r.dof)).limits;
1005
+ return Math.max(lo, Math.min(hi, v));
1006
+ };
1007
+ const get = (r) => requireValue(requireValue(values[r.joint])[r.dof]), set = (r, v) => (requireValue(values[r.joint])[r.dof] = v), h = 0.01;
1008
+ for (const limb of asset.limbs ?? []) {
1009
+ // Clearance fades in as the planted correction fades out, so lift-off stays continuous.
1010
+ const strength = 1 - (planted.get(limb.id) ?? 0);
1011
+ if (strength <= 0)
1012
+ continue;
1013
+ const original = limb.reach.map((r) => get(r));
1014
+ const instance = instanceOf(asset, limb.effector), rest = requireValue(instance.anchors[limb.anchor]), at = () => compose(translation(root), chainTransform(asset, values, requireValue(instance.joint))), effector = () => applyPoint(at(), rest);
1015
+ // Lifting bends the chain, which can pitch the effector part, so re-measure a few times.
1016
+ for (let pass = 0; pass < 4; pass++) {
1017
+ const depth = lowest(instance, at());
1018
+ if (depth >= -1e-6)
1019
+ break;
1020
+ const start = effector(), goal = [start[0], start[1] - depth, start[2]];
1021
+ for (let iteration = 0; iteration < 8; iteration++) {
1022
+ const p = effector(), residual = [goal[0] - p[0], goal[1] - p[1], goal[2] - p[2]];
1023
+ if (Math.hypot(...residual) < 1e-9)
1024
+ break;
1025
+ const columns = limb.reach.map((r) => {
1026
+ const v = get(r);
1027
+ set(r, v + h);
1028
+ const plus = effector();
1029
+ set(r, v - h);
1030
+ const minus = effector();
1031
+ set(r, v);
1032
+ return [0, 1, 2].map((k) => (requireValue(plus[k]) - requireValue(minus[k])) / (2 * h));
1033
+ });
1034
+ const steps = leastSquares(columns, residual);
1035
+ limb.reach.forEach((r, k) => set(r, clamp(r, get(r) + Math.max(-20, Math.min(20, requireValue(steps[k]))))));
1036
+ }
1037
+ }
1038
+ limb.reach.forEach((r, k) => set(r, roundDegrees(requireValue(original[k]) + strength * (get(r) - requireValue(original[k])))));
1039
+ }
1040
+ return { values, root };
1041
+ }
1042
+ function contactHeight(asset, clip, u, world) {
1043
+ const ids = planted(clip, u);
1044
+ if (!ids.length)
1045
+ return null;
1046
+ return Math.min(...ids.map((id) => {
1047
+ const instance = instanceOf(asset, id);
1048
+ return lowest(instance, transformFor(instance, world, [0, 0, 0]));
1049
+ }));
1050
+ }
1051
+ function rootAt(asset, clip, u, world = jointTransforms(asset, valuesAt(asset, clip, u))) {
1052
+ const g = clip.grounding;
1053
+ if (g.type === "anchor") {
1054
+ const instance = instanceOf(asset, g.part), p = applyPoint(transformFor(instance, world, [0, 0, 0]), requireValue(instance.anchors[g.anchor]));
1055
+ return [-p[0], -p[1], -p[2]];
1056
+ }
1057
+ if (g.type !== "contacts")
1058
+ return [0, 0, 0];
1059
+ const here = contactHeight(asset, clip, u, world);
1060
+ if (here !== null)
1061
+ return [0, -here, 0];
1062
+ // Between contacts the root height interpolates linearly from the last lift-off to the next touchdown.
1063
+ let back = Infinity, forward = Infinity, a = 0, b = 0;
1064
+ for (const { intervals } of clip.contacts)
1065
+ for (const [s, e] of intervals) {
1066
+ const end = e - Math.floor(e), db = clip.loop ? (u - end + 1) % 1 : u - e, df = clip.loop ? (s - u + 1) % 1 : s - u;
1067
+ if (db >= 0 && db < back) {
1068
+ back = db;
1069
+ a = e;
1070
+ }
1071
+ if (df >= 0 && df < forward) {
1072
+ forward = df;
1073
+ b = s;
1074
+ }
1075
+ }
1076
+ const heightAt = (t) => {
1077
+ const p = clip.loop ? t - Math.floor(t) : t;
1078
+ return requireValue(contactHeight(asset, clip, p, jointTransforms(asset, valuesAt(asset, clip, p))));
1079
+ };
1080
+ if (!Number.isFinite(back))
1081
+ return [0, -heightAt(b), 0];
1082
+ if (!Number.isFinite(forward))
1083
+ return [0, -heightAt(a), 0];
1084
+ const w = back / (back + forward);
1085
+ return [0, -(heightAt(a) * (1 - w) + heightAt(b) * w), 0];
1086
+ }
1087
+ function verify(clip, spec, path, r, fail) {
1088
+ const { asset, height } = r, lift = CONTACT_TOLERANCE * height, slipLimit = SLIP_TOLERANCE * height;
1089
+ const phase = (t) => (clip.loop ? t - Math.floor(t) : t);
1090
+ // Travel derives from the authored stride; slip, lift and penetration measure the solved motion.
1091
+ const point = (id, t, solved = true) => {
1092
+ const instance = instanceOf(asset, id), pose = solved
1093
+ ? solvedPoseAt(asset, clip, phase(t))
1094
+ : { values: valuesAt(asset, clip, phase(t)), root: [0, 0, 0] }, world = jointTransforms(asset, pose.values);
1095
+ // Horizontal drift ignores the root, whose x and z come from anchor grounding only.
1096
+ return applyPoint(transformFor(instance, world, [0, 0, 0]), requireValue(instance.anchors["bottom-center"]));
1097
+ };
1098
+ // Travel per cycle that keeps planted effectors stationary on average.
1099
+ let travel = [0, 0, 0];
1100
+ if (spec.travel === "contacts") {
1101
+ let dx = 0, dz = 0, span = 0;
1102
+ for (const c of clip.contacts)
1103
+ for (const [s, e] of c.intervals) {
1104
+ const a = point(c.effector, s, false), b = point(c.effector, e, false);
1105
+ dx += b[0] - a[0];
1106
+ dz += b[2] - a[2];
1107
+ span += e - s;
1108
+ }
1109
+ travel = [quantize(-dx / span), 0, quantize(-dz / span)];
1110
+ }
1111
+ else if (spec.travel)
1112
+ travel = [spec.travel.x, spec.travel.y, spec.travel.z];
1113
+ clip.travel = travel;
1114
+ let maxSlip = 0, maxLift = 0, minHeight = Infinity;
1115
+ for (const c of clip.contacts)
1116
+ for (const [s, e] of c.intervals) {
1117
+ const start = point(c.effector, s);
1118
+ let worst = 0;
1119
+ for (let k = 1; k <= 16; k++) {
1120
+ const t = s + ((e - s) * k) / 16, p = point(c.effector, t), drift = Math.hypot(p[0] - start[0] + travel[0] * (t - s), p[2] - start[2] + travel[2] * (t - s));
1121
+ worst = Math.max(worst, drift);
1122
+ }
1123
+ maxSlip = Math.max(maxSlip, worst);
1124
+ const original = Object.keys(spec.contacts ?? {}).find((ref) => c.effector === ref || c.effector.startsWith(`${ref}/`));
1125
+ if (worst > slipLimit)
1126
+ fail({
1127
+ code: "CONTACT_SLIP",
1128
+ path: `${path}/contacts/${pointer(original ?? c.effector)}`,
1129
+ message: `Planted ${c.effector} drifts ${round(worst, 3)} ${asset.units} (limit ${round(slipLimit, 3)}) during [${round(s)}, ${round(e)}]. Match the stride to the travel, or shorten the interval.`,
1130
+ part: c.effector,
1131
+ received: round(worst, 4),
1132
+ expected: [String(round(slipLimit, 4))],
1133
+ });
1134
+ }
1135
+ if (clip.grounding.type !== "anchor") {
1136
+ const phases = Array.from({ length: VERIFICATION_SAMPLES }, (_, k) => k / VERIFICATION_SAMPLES);
1137
+ for (const c of clip.contacts)
1138
+ for (const [s, e] of c.intervals)
1139
+ for (let k = 0; k <= 8; k++)
1140
+ phases.push(s + ((e - s) * k) / 8);
1141
+ for (const raw of phases) {
1142
+ const u = clip.loop ? raw - Math.floor(raw) : Math.min(1, raw), solved = solvedPoseAt(asset, clip, u), world = jointTransforms(asset, solved.values), root = solved.root;
1143
+ for (const instance of visibleIn(asset, statesAt(asset, clip, u))) {
1144
+ const y = lowest(instance, transformFor(instance, world, root));
1145
+ minHeight = Math.min(minHeight, y);
1146
+ }
1147
+ for (const id of planted(clip, u)) {
1148
+ const instance = instanceOf(asset, id), h = lowest(instance, transformFor(instance, world, root));
1149
+ maxLift = Math.max(maxLift, h);
1150
+ }
1151
+ }
1152
+ if (maxLift > lift)
1153
+ fail({
1154
+ code: "CONTACT_LIFT",
1155
+ path: `${path}/contacts`,
1156
+ message: `A planted effector rises ${round(maxLift, 3)} ${asset.units} above the ground (limit ${round(lift, 3)}). Use contact grounding or adjust the pose.`,
1157
+ received: round(maxLift, 4),
1158
+ expected: [String(round(lift, 4))],
1159
+ });
1160
+ if (minHeight < -lift)
1161
+ fail({
1162
+ code: "GROUND_PENETRATION",
1163
+ path,
1164
+ message: `Geometry reaches ${round(minHeight, 3)} ${asset.units}, below the ground (limit −${round(lift, 3)}).`,
1165
+ received: round(minHeight, 4),
1166
+ expected: [String(round(-lift, 4))],
1167
+ });
1168
+ }
1169
+ else
1170
+ minHeight = 0;
1171
+ clip.metrics = {
1172
+ maxSlip: round(maxSlip, 6),
1173
+ maxLift: round(maxLift, 6),
1174
+ minHeight: round(minHeight === Infinity ? 0 : minHeight, 6),
1175
+ speed: round(Math.hypot(...travel) / clip.duration, 6),
1176
+ };
1177
+ }
1178
+ function prepare(asset) {
1179
+ if (!asset || !asset.skeleton || !asset.skeleton.joints.length)
1180
+ throw new AssetCompileError([
1181
+ {
1182
+ code: "UNRIGGED_ASSET",
1183
+ path: "",
1184
+ message: "Motion needs an asset-assembly/0.2 compilation with at least one joint.",
1185
+ },
1186
+ ]);
1187
+ const joints = new Map(asset.skeleton.joints.map((j) => [j.id, j]));
1188
+ const names = new Map();
1189
+ for (const j of asset.skeleton.joints) {
1190
+ names.set(j.id, [j.id]);
1191
+ if (j.side === "left")
1192
+ names.set(j.joint, [j.id, `${j.joint}/right`]);
1193
+ // A bare copied joint (schema 0.3 arrays) addresses every copy.
1194
+ if (j.copy !== undefined)
1195
+ names.set(j.joint, [...(names.get(j.joint) ?? []), j.id]);
1196
+ }
1197
+ const bounds = asset.bounds;
1198
+ return { asset, joints, names, height: Math.max(1e-9, bounds.max[1] - bounds.min[1]) };
1199
+ }
1200
+ /**
1201
+ * Validate a motion set against a compiled rigged asset and verify its declared contacts.
1202
+ * Throws AssetCompileError with every diagnostic; use checkMotion to keep measured-only results.
1203
+ */
1204
+ export function compileMotion(input, asset) {
1205
+ const diagnostics = structural(input);
1206
+ if (diagnostics.length)
1207
+ throw new AssetCompileError(diagnostics);
1208
+ const { poses, expressions, clips, errors } = compileClips(input, prepare(asset));
1209
+ if (errors.length)
1210
+ throw new AssetCompileError(errors);
1211
+ return {
1212
+ schema: MOTION_SCHEMA,
1213
+ sourceHash: sourceHash(input),
1214
+ assetHash: asset.sourceHash,
1215
+ poses,
1216
+ expressions,
1217
+ clips,
1218
+ };
1219
+ }
1220
+ /** Diagnostics that measure a valid motion rather than reject its description. */
1221
+ export const MEASURED_CODES = ["CONTACT_SLIP", "CONTACT_LIFT", "GROUND_PENETRATION"];
1222
+ /**
1223
+ * For editors and repair loops: returns the compiled motion together with its diagnostics whenever
1224
+ * every diagnostic is a measurement (slip, lift, penetration). Structural or semantic errors leave
1225
+ * `motion` undefined. compileMotion remains the strict path.
1226
+ */
1227
+ export function checkMotion(input, asset) {
1228
+ const diagnostics = structural(input);
1229
+ if (diagnostics.length)
1230
+ return { diagnostics };
1231
+ let resolved;
1232
+ try {
1233
+ resolved = prepare(asset);
1234
+ }
1235
+ catch (error) {
1236
+ if (error instanceof AssetCompileError)
1237
+ return { diagnostics: error.diagnostics };
1238
+ throw error;
1239
+ }
1240
+ const { poses, expressions, clips, errors } = compileClips(input, resolved);
1241
+ if (errors.some((d) => !MEASURED_CODES.includes(d.code)))
1242
+ return { diagnostics: errors };
1243
+ return {
1244
+ motion: {
1245
+ schema: MOTION_SCHEMA,
1246
+ sourceHash: sourceHash(input),
1247
+ assetHash: asset.sourceHash,
1248
+ poses,
1249
+ expressions,
1250
+ clips,
1251
+ },
1252
+ diagnostics: errors,
1253
+ };
1254
+ }
1255
+ export function validateMotion(input, asset) {
1256
+ try {
1257
+ compileMotion(input, asset);
1258
+ return [];
1259
+ }
1260
+ catch (error) {
1261
+ if (error instanceof AssetCompileError)
1262
+ return error.diagnostics;
1263
+ throw error;
1264
+ }
1265
+ }
1266
+ /**
1267
+ * Sample a compiled clip, pose or expression at an integer frame, or at a normalized phase.
1268
+ * `face` overlays a face-only clip (an expression or face clip): it replaces every face DOF and state,
1269
+ * sampled at the same time on its own duration, while body joints, grounding and travel come from `clip`.
1270
+ */
1271
+ export function samplePose(asset, clip, time, options = {}) {
1272
+ prepare(asset);
1273
+ if ("frame" in time) {
1274
+ if (!Number.isInteger(time.frame) || time.frame < 0 || !Number.isInteger(time.fps) || time.fps < 1)
1275
+ throw new RangeError("Frames and fps are nonnegative integers; fps is at least 1");
1276
+ }
1277
+ else if (!Number.isFinite(time.phase))
1278
+ throw new RangeError("Phase must be finite");
1279
+ const cyclesOf = (c) => ("frame" in time ? time.frame / time.fps / c.duration : time.phase), phaseOf = (c, cycles) => c.loop ? cycles - Math.floor(cycles) : Math.max(0, Math.min(1, cycles));
1280
+ const cycles = cyclesOf(clip), phase = phaseOf(clip, cycles);
1281
+ const solved = solvedPoseAt(asset, clip, phase), values = solved.values;
1282
+ let states = statesAt(asset, clip, phase);
1283
+ if (options.face) {
1284
+ if (!options.face.face)
1285
+ throw new RangeError(`Clip ${options.face.name} keys body joints and cannot be a face overlay`);
1286
+ const fp = phaseOf(options.face, cyclesOf(options.face)), face = valuesAt(asset, options.face, fp);
1287
+ for (const joint of requireValue(asset.skeleton).joints)
1288
+ if (joint.face)
1289
+ values[joint.id] = requireValue(face[joint.id]);
1290
+ states = statesAt(asset, options.face, fp);
1291
+ }
1292
+ const world = jointTransforms(asset, values), root = solved.root.map(quantize), progress = clip.loop ? cycles : phase;
1293
+ return {
1294
+ clip: clip.name,
1295
+ ...(options.face ? { face: options.face.name } : {}),
1296
+ phase,
1297
+ values,
1298
+ states,
1299
+ root,
1300
+ travel: clip.travel.map((v) => quantize(v * progress)),
1301
+ transforms: Object.fromEntries([...world].map(([id, m]) => [id, m.map(quantize)])),
1302
+ contacts: planted(clip, phase),
1303
+ };
1304
+ }
1305
+ /**
1306
+ * A new compiled asset with posed geometry, bounds and anchors, containing the instances visible in the
1307
+ * pose's states. Rest `translation` values are kept.
1308
+ */
1309
+ export function poseAsset(asset, pose) {
1310
+ prepare(asset);
1311
+ const world = new Map(Object.entries(pose.transforms));
1312
+ const instances = visibleIn(asset, pose.states ?? {}).map((instance) => {
1313
+ const m = transformFor(instance, world, pose.root), src = instance.mesh, mesh = { positions: [], normals: [], colors: [...src.colors], indices: [...src.indices] };
1314
+ for (let i = 0; i < src.positions.length; i += 3) {
1315
+ const p = applyPoint(m, [
1316
+ requireValue(src.positions[i]),
1317
+ requireValue(src.positions[i + 1]),
1318
+ requireValue(src.positions[i + 2]),
1319
+ ]), n = applyVector(m, [
1320
+ requireValue(src.normals[i]),
1321
+ requireValue(src.normals[i + 1]),
1322
+ requireValue(src.normals[i + 2]),
1323
+ ]);
1324
+ mesh.positions.push(quantize(p[0]), quantize(p[1]), quantize(p[2]));
1325
+ mesh.normals.push(quantize(n[0]), quantize(n[1]), quantize(n[2]));
1326
+ }
1327
+ if (src.joints)
1328
+ mesh.joints = [...src.joints];
1329
+ return {
1330
+ ...instance,
1331
+ bounds: meshBounds(mesh),
1332
+ anchors: Object.fromEntries(Object.entries(instance.anchors).map(([k, v]) => [k, applyPoint(m, v).map(quantize)])),
1333
+ mesh,
1334
+ };
1335
+ });
1336
+ const layers = Object.create(null), all = emptyMesh();
1337
+ for (const instance of instances) {
1338
+ if (!has(layers, instance.material))
1339
+ layers[instance.material] = emptyMesh();
1340
+ appendMesh(requireValue(layers[instance.material]), instance.mesh);
1341
+ appendMesh(all, instance.mesh);
1342
+ }
1343
+ return {
1344
+ ...asset,
1345
+ layers,
1346
+ instances,
1347
+ bounds: meshBounds(all),
1348
+ triangles: all.indices.length / 3,
1349
+ ...(asset.alternates ? { alternates: [] } : {}),
1350
+ pose: { clip: pose.clip, phase: pose.phase },
1351
+ };
1352
+ }
1353
+ /**
1354
+ * Place a character clip that uses anchor grounding on a furniture anchor and measure floor clearance.
1355
+ * Both assets share the frame convention (front is −Z), so no rotation is involved.
1356
+ */
1357
+ export function seatOn(character, clip, furniture, seat) {
1358
+ prepare(character);
1359
+ const diagnostics = [];
1360
+ if (clip.grounding.type !== "anchor")
1361
+ throw new AssetCompileError([
1362
+ {
1363
+ code: "SEAT_REQUIRES_ANCHOR_GROUNDING",
1364
+ path: "/grounding",
1365
+ message: "Seated clips use anchor grounding so a character anchor can meet the seat anchor.",
1366
+ },
1367
+ ]);
1368
+ const instance = furniture.instances.find((i) => i.id === seat.part);
1369
+ if (!instance || !has(instance.anchors, seat.anchor))
1370
+ throw new AssetCompileError([
1371
+ {
1372
+ code: instance ? "UNKNOWN_ANCHOR" : "UNKNOWN_REFERENCE",
1373
+ path: instance ? "/anchor" : "/part",
1374
+ message: "Choose a furniture part instance and one of its anchors.",
1375
+ targetPart: seat.part,
1376
+ expected: instance ? Object.keys(instance.anchors).sort() : furniture.instances.map((i) => i.id),
1377
+ },
1378
+ ]);
1379
+ const offset = requireValue(instance.anchors[seat.anchor]).map(quantize), floor = furniture.bounds.min[1], tolerance = CONTACT_TOLERANCE * (character.bounds.max[1] - character.bounds.min[1]);
1380
+ let min = Infinity, max = -Infinity;
1381
+ for (let k = 0; k < VERIFICATION_SAMPLES; k++) {
1382
+ const pose = samplePose(character, clip, { phase: k / VERIFICATION_SAMPLES }), world = new Map(Object.entries(pose.transforms));
1383
+ let y = Infinity;
1384
+ for (const i of character.instances)
1385
+ y = Math.min(y, lowest(i, transformFor(i, world, pose.root)));
1386
+ const clearance = y + offset[1] - floor;
1387
+ min = Math.min(min, clearance);
1388
+ max = Math.max(max, clearance);
1389
+ }
1390
+ if (min > tolerance)
1391
+ diagnostics.push({
1392
+ code: "SEAT_FLOOR_GAP",
1393
+ path: "",
1394
+ message: `The seated character's lowest point stays ${round(min, 3)} ${character.units} above the floor. Lower the seat or lengthen the legs.`,
1395
+ received: round(min, 4),
1396
+ expected: [String(round(tolerance, 4))],
1397
+ });
1398
+ if (min < -tolerance)
1399
+ diagnostics.push({
1400
+ code: "SEAT_FLOOR_PENETRATION",
1401
+ path: "",
1402
+ message: `The seated character reaches ${round(min, 3)} ${character.units} below the floor. Raise the seat or shorten the legs.`,
1403
+ received: round(min, 4),
1404
+ expected: [String(round(-tolerance, 4))],
1405
+ });
1406
+ return { offset, clearance: { min: round(min, 6), max: round(max, 6) }, diagnostics };
1407
+ }
1408
+ /** Combine visible posed geometry without constructing posed instances, anchors or bounds. */
1409
+ export function posedMesh(asset, pose, tint) {
1410
+ prepare(asset);
1411
+ if (tint !== undefined && !/^#[a-fA-F0-9]{6}$/.test(tint))
1412
+ throw new RangeError("Tint must be #rrggbb");
1413
+ const color = tint ? [1, 3, 5].map((i) => linear(parseInt(tint.slice(i, i + 2), 16))) : [1, 1, 1], world = new Map(Object.entries(pose.transforms)), transforms = new Map(), layers = Object.create(null);
1414
+ let coordinates = 0, indices = 0, joints = false;
1415
+ for (const instance of visibleIn(asset, pose.states ?? {})) {
1416
+ if (!instance.mesh.positions.length)
1417
+ throw new RangeError("Bounds require nonempty geometry");
1418
+ (layers[instance.material] ??= []).push(instance);
1419
+ coordinates += instance.mesh.positions.length;
1420
+ indices += instance.mesh.indices.length;
1421
+ joints ||= !!instance.mesh.joints;
1422
+ }
1423
+ if (!coordinates)
1424
+ throw new RangeError("Bounds require nonempty geometry");
1425
+ // Each call owns its output; scratch transforms are shared only within this pose.
1426
+ const result = {
1427
+ positions: new Array(coordinates),
1428
+ normals: new Array(coordinates),
1429
+ colors: new Array(coordinates),
1430
+ indices: new Array(indices),
1431
+ };
1432
+ if (joints)
1433
+ result.joints = new Array(coordinates / 3).fill(-1);
1434
+ let at = 0, index = 0;
1435
+ for (const [material, instances] of Object.entries(layers)) {
1436
+ const first = at / 3, tinted = requireValue(asset.materials[material]).tintable && tint;
1437
+ for (const instance of instances) {
1438
+ let m = transforms.get(instance.joint);
1439
+ if (!m) {
1440
+ m = transformFor(instance, world, pose.root);
1441
+ transforms.set(instance.joint, m);
1442
+ }
1443
+ const src = instance.mesh, layerFirst = at / 3 - first;
1444
+ for (let i = 0; i < src.positions.length; i += 3) {
1445
+ const x = requireValue(src.positions[i]), y = requireValue(src.positions[i + 1]), z = requireValue(src.positions[i + 2]), nx = requireValue(src.normals[i]), ny = requireValue(src.normals[i + 1]), nz = requireValue(src.normals[i + 2]);
1446
+ // Preserve the two zero-offset appends used by poseAsset then meshForAsset.
1447
+ result.positions[at + i] =
1448
+ quantize(requireValue(m[0]) * x + requireValue(m[1]) * y + requireValue(m[2]) * z + requireValue(m[3])) +
1449
+ 0 +
1450
+ 0;
1451
+ result.positions[at + i + 1] =
1452
+ quantize(requireValue(m[4]) * x + requireValue(m[5]) * y + requireValue(m[6]) * z + requireValue(m[7])) +
1453
+ 0 +
1454
+ 0;
1455
+ result.positions[at + i + 2] =
1456
+ quantize(requireValue(m[8]) * x + requireValue(m[9]) * y + requireValue(m[10]) * z + requireValue(m[11])) +
1457
+ 0 +
1458
+ 0;
1459
+ result.normals[at + i] = quantize(requireValue(m[0]) * nx + requireValue(m[1]) * ny + requireValue(m[2]) * nz);
1460
+ result.normals[at + i + 1] = quantize(requireValue(m[4]) * nx + requireValue(m[5]) * ny + requireValue(m[6]) * nz);
1461
+ result.normals[at + i + 2] = quantize(requireValue(m[8]) * nx + requireValue(m[9]) * ny + requireValue(m[10]) * nz);
1462
+ for (let c = 0; c < 3; c++)
1463
+ result.colors[at + i + c] = tinted
1464
+ ? requireValue(src.colors[i + c]) * requireValue(color[c])
1465
+ : requireValue(src.colors[i + c]);
1466
+ }
1467
+ for (const i of src.indices)
1468
+ result.indices[index++] = i + layerFirst + first;
1469
+ if (src.joints)
1470
+ for (let i = 0; i < src.joints.length; i++)
1471
+ requireValue(result.joints)[at / 3 + i] = requireValue(src.joints[i]);
1472
+ at += src.positions.length;
1473
+ }
1474
+ }
1475
+ return result;
1476
+ }