@aura3d/assets 3.0.0 → 4.0.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 (162) hide show
  1. package/dist/AdvancedAssetCorpus.d.ts.map +1 -1
  2. package/dist/AdvancedAssetCorpus.js +7 -12
  3. package/dist/AdvancedAssetCorpus.js.map +1 -1
  4. package/dist/AssetDecoderRegistry.d.ts +56 -0
  5. package/dist/AssetDecoderRegistry.d.ts.map +1 -0
  6. package/dist/AssetDecoderRegistry.js +225 -0
  7. package/dist/AssetDecoderRegistry.js.map +1 -0
  8. package/dist/GLTFAnimationRuntime.d.ts +300 -6
  9. package/dist/GLTFAnimationRuntime.d.ts.map +1 -1
  10. package/dist/GLTFAnimationRuntime.js +981 -60
  11. package/dist/GLTFAnimationRuntime.js.map +1 -1
  12. package/dist/GLTFAssetParseCache.d.ts +24 -0
  13. package/dist/GLTFAssetParseCache.d.ts.map +1 -0
  14. package/dist/GLTFAssetParseCache.js +100 -0
  15. package/dist/GLTFAssetParseCache.js.map +1 -0
  16. package/dist/GLTFCompressionDecoders.d.ts +1 -0
  17. package/dist/GLTFCompressionDecoders.d.ts.map +1 -1
  18. package/dist/GLTFCompressionDecoders.js +20 -5
  19. package/dist/GLTFCompressionDecoders.js.map +1 -1
  20. package/dist/GLTFExtensionSupport.js +3 -3
  21. package/dist/GLTFExtensionSupport.js.map +1 -1
  22. package/dist/GLTFLoader.d.ts +28 -1
  23. package/dist/GLTFLoader.d.ts.map +1 -1
  24. package/dist/GLTFLoader.js +197 -30
  25. package/dist/GLTFLoader.js.map +1 -1
  26. package/dist/GLTFRenderResources.d.ts +77 -2
  27. package/dist/GLTFRenderResources.d.ts.map +1 -1
  28. package/dist/GLTFRenderResources.js +310 -153
  29. package/dist/GLTFRenderResources.js.map +1 -1
  30. package/dist/KTX2BasisTextureTranscoder.d.ts +70 -12
  31. package/dist/KTX2BasisTextureTranscoder.d.ts.map +1 -1
  32. package/dist/KTX2BasisTextureTranscoder.js +225 -184
  33. package/dist/KTX2BasisTextureTranscoder.js.map +1 -1
  34. package/dist/KTX2TargetSelection.d.ts +25 -0
  35. package/dist/KTX2TargetSelection.d.ts.map +1 -0
  36. package/dist/KTX2TargetSelection.js +36 -0
  37. package/dist/KTX2TargetSelection.js.map +1 -0
  38. package/dist/KTX2TranscodeDriver.d.ts +70 -0
  39. package/dist/KTX2TranscodeDriver.d.ts.map +1 -0
  40. package/dist/KTX2TranscodeDriver.js +104 -0
  41. package/dist/KTX2TranscodeDriver.js.map +1 -0
  42. package/dist/KTX2TranscodeWorker.d.ts +30 -0
  43. package/dist/KTX2TranscodeWorker.d.ts.map +1 -0
  44. package/dist/KTX2TranscodeWorker.js +230 -0
  45. package/dist/KTX2TranscodeWorker.js.map +1 -0
  46. package/dist/MikkTSpaceTangents.d.ts +43 -0
  47. package/dist/MikkTSpaceTangents.d.ts.map +1 -0
  48. package/dist/MikkTSpaceTangents.js +149 -0
  49. package/dist/MikkTSpaceTangents.js.map +1 -0
  50. package/dist/asset-corpus/ProductionAssetCorpus.d.ts.map +1 -1
  51. package/dist/asset-corpus/ProductionAssetCorpus.js +7 -13
  52. package/dist/asset-corpus/ProductionAssetCorpus.js.map +1 -1
  53. package/dist/asset-corpus/ProductionGLTFRenderPipeline.d.ts +43 -0
  54. package/dist/asset-corpus/ProductionGLTFRenderPipeline.d.ts.map +1 -1
  55. package/dist/asset-corpus/ProductionGLTFRenderPipeline.js +146 -22
  56. package/dist/asset-corpus/ProductionGLTFRenderPipeline.js.map +1 -1
  57. package/dist/assetPhaseMarks.d.ts +14 -0
  58. package/dist/assetPhaseMarks.d.ts.map +1 -0
  59. package/dist/assetPhaseMarks.js +23 -0
  60. package/dist/assetPhaseMarks.js.map +1 -0
  61. package/dist/browser-index.d.ts +5 -1
  62. package/dist/browser-index.d.ts.map +1 -1
  63. package/dist/browser-index.js +3 -1
  64. package/dist/browser-index.js.map +1 -1
  65. package/dist/contracts/decoders.d.ts +15 -0
  66. package/dist/contracts/decoders.d.ts.map +1 -0
  67. package/dist/contracts/decoders.js +8 -0
  68. package/dist/contracts/decoders.js.map +1 -0
  69. package/dist/decoderLoad.d.ts +28 -0
  70. package/dist/decoderLoad.d.ts.map +1 -0
  71. package/dist/decoderLoad.js +49 -0
  72. package/dist/decoderLoad.js.map +1 -0
  73. package/dist/gltf/ImageDecode.d.ts +18 -0
  74. package/dist/gltf/ImageDecode.d.ts.map +1 -0
  75. package/dist/gltf/ImageDecode.js +140 -0
  76. package/dist/gltf/ImageDecode.js.map +1 -0
  77. package/dist/gltf-runtime.d.ts +1 -1
  78. package/dist/gltf-runtime.d.ts.map +1 -1
  79. package/dist/gltf-runtime.js.map +1 -1
  80. package/dist/index.d.ts +5 -1
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +3 -1
  83. package/dist/index.js.map +1 -1
  84. package/dist/lanes/index.d.ts +17 -0
  85. package/dist/lanes/index.d.ts.map +1 -0
  86. package/dist/lanes/index.js +17 -0
  87. package/dist/lanes/index.js.map +1 -0
  88. package/dist/lanes/prd01.d.ts +6 -0
  89. package/dist/lanes/prd01.d.ts.map +1 -0
  90. package/dist/lanes/prd01.js +6 -0
  91. package/dist/lanes/prd01.js.map +1 -0
  92. package/dist/lanes/prd02.d.ts +6 -0
  93. package/dist/lanes/prd02.d.ts.map +1 -0
  94. package/dist/lanes/prd02.js +6 -0
  95. package/dist/lanes/prd02.js.map +1 -0
  96. package/dist/lanes/prd03.d.ts +6 -0
  97. package/dist/lanes/prd03.d.ts.map +1 -0
  98. package/dist/lanes/prd03.js +6 -0
  99. package/dist/lanes/prd03.js.map +1 -0
  100. package/dist/lanes/prd04.d.ts +6 -0
  101. package/dist/lanes/prd04.d.ts.map +1 -0
  102. package/dist/lanes/prd04.js +6 -0
  103. package/dist/lanes/prd04.js.map +1 -0
  104. package/dist/lanes/prd05.d.ts +14 -0
  105. package/dist/lanes/prd05.d.ts.map +1 -0
  106. package/dist/lanes/prd05.js +14 -0
  107. package/dist/lanes/prd05.js.map +1 -0
  108. package/dist/lanes/prd06.d.ts +6 -0
  109. package/dist/lanes/prd06.d.ts.map +1 -0
  110. package/dist/lanes/prd06.js +6 -0
  111. package/dist/lanes/prd06.js.map +1 -0
  112. package/dist/lanes/prd07.d.ts +6 -0
  113. package/dist/lanes/prd07.d.ts.map +1 -0
  114. package/dist/lanes/prd07.js +6 -0
  115. package/dist/lanes/prd07.js.map +1 -0
  116. package/dist/lanes/prd08.d.ts +6 -0
  117. package/dist/lanes/prd08.d.ts.map +1 -0
  118. package/dist/lanes/prd08.js +6 -0
  119. package/dist/lanes/prd08.js.map +1 -0
  120. package/dist/lanes/prd09.d.ts +6 -0
  121. package/dist/lanes/prd09.d.ts.map +1 -0
  122. package/dist/lanes/prd09.js +6 -0
  123. package/dist/lanes/prd09.js.map +1 -0
  124. package/dist/lanes/prd10.d.ts +6 -0
  125. package/dist/lanes/prd10.d.ts.map +1 -0
  126. package/dist/lanes/prd10.js +6 -0
  127. package/dist/lanes/prd10.js.map +1 -0
  128. package/dist/lanes/prd11.d.ts +6 -0
  129. package/dist/lanes/prd11.d.ts.map +1 -0
  130. package/dist/lanes/prd11.js +6 -0
  131. package/dist/lanes/prd11.js.map +1 -0
  132. package/dist/lanes/prd12.d.ts +6 -0
  133. package/dist/lanes/prd12.d.ts.map +1 -0
  134. package/dist/lanes/prd12.js +6 -0
  135. package/dist/lanes/prd12.js.map +1 -0
  136. package/dist/lanes/prd13.d.ts +6 -0
  137. package/dist/lanes/prd13.d.ts.map +1 -0
  138. package/dist/lanes/prd13.js +6 -0
  139. package/dist/lanes/prd13.js.map +1 -0
  140. package/dist/lanes/prd14.d.ts +6 -0
  141. package/dist/lanes/prd14.d.ts.map +1 -0
  142. package/dist/lanes/prd14.js +6 -0
  143. package/dist/lanes/prd14.js.map +1 -0
  144. package/dist/lanes/prd15.d.ts +6 -0
  145. package/dist/lanes/prd15.d.ts.map +1 -0
  146. package/dist/lanes/prd15.js +6 -0
  147. package/dist/lanes/prd15.js.map +1 -0
  148. package/dist/vite/auraDecoders.d.ts +31 -0
  149. package/dist/vite/auraDecoders.d.ts.map +1 -0
  150. package/dist/vite/auraDecoders.js +89 -0
  151. package/dist/vite/auraDecoders.js.map +1 -0
  152. package/package.json +13 -5
  153. package/vendor/basis/README.md +25 -0
  154. package/vendor/basis/basis_transcoder.js +19 -0
  155. package/vendor/basis/basis_transcoder.wasm +0 -0
  156. package/vendor/draco/README.md +23 -0
  157. package/vendor/draco/draco_decoder.js +33 -0
  158. package/vendor/draco/draco_decoder.wasm +0 -0
  159. package/vendor/meshopt/README.md +21 -0
  160. package/vendor/meshopt/meshopt_decoder.cjs +199 -0
  161. package/vendor/meshopt/meshopt_decoder.mjs +196 -0
  162. package/vendor/meshopt_decoder.mjs +196 -0
@@ -1,5 +1,8 @@
1
- import { AnimationAction, AnimationClip, AnimationMixer, createFootIkRig, normalizeQuat, slerpQuat, solveTwoBoneIk } from "@aura3d/animation";
2
- import { invertMat4, MAX_RENDERABLE_SKINNING_JOINTS, multiplyMat4, Renderable, Scene, transformPoint } from "@aura3d/scene";
1
+ import { AnimationAction, AnimationClip, AnimationMixer, consumeRootMotion, extractRootMotion, createFootIkRig, normalizeQuat, slerpQuat, solveTwoBoneIk } from "@aura3d/animation";
2
+ import { bindSkeleton, compileClip, createPoseBuffer, makeClipAdditive, PoseMixer } from "@aura3d/animation/lanes";
3
+ import { AURA3D_RETARGET_ENGINE_VERSION, bakeClipsInWorker, bakeRetargetedClipMap, createRetargetWorker, decompileCompiledClip, readRetargetCache, retargetCacheKey, retargetClipsHash, retargetSkeletonHash, writeRetargetCache } from "@aura3d/animation/lanes";
4
+ import { composeMat4, decomposeMat4, invertMat4, MAX_RENDERABLE_SKINNING_JOINTS, multiplyMat4, Renderable, Scene, transformPoint } from "@aura3d/scene";
5
+ import { skinningPaletteCache } from "@aura3d/rendering";
3
6
  /**
4
7
  * Synonym groups for fuzzy clip-name matching. Real catalog assets label the same
5
8
  * motion wildly differently ("Loops" vs "Idle", "Take 001" vs "Run", "sprint" vs
@@ -17,19 +20,31 @@ const GLTF_CLIP_SYNONYM_GROUPS = [
17
20
  function normalizeClipToken(name) {
18
21
  return name.toLowerCase().replace(/[\s_.\-]+/g, "");
19
22
  }
20
- /**
21
- * Resolve a requested clip name to the best available clip name.
22
- *
23
- * Matching order:
24
- * 1. Exact case-insensitive (ignoring whitespace/underscores/dots/dashes).
25
- * 2. Synonym-group match: the requested name and an available name belong to
26
- * the same synonym group (e.g. "run" resolves to "Sprint").
27
- * 3. Substring match in either direction (e.g. "walk" -> "WalkCycle").
28
- * 4. Fallback to the first available clip.
29
- *
30
- * Returns `undefined` only when there are no available clips.
31
- */
32
- export function resolveGLTFClipName(requested, available) {
23
+ const animationClipDefaultsAre31 = () => {
24
+ const parseList = (value) => value === undefined ? [] : value.split(/[\s,]+/).map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");
25
+ const env = typeof process !== "undefined" ? process.env : undefined;
26
+ if (env !== undefined) {
27
+ const scoped = env["A3D_QR_ANIMATION"];
28
+ if (scoped !== undefined && scoped !== "0" && scoped.toLowerCase() !== "false" && scoped.toLowerCase() !== "off")
29
+ return true;
30
+ const root = parseList(env["A3D_QR"] ?? env["A3D_QR_FLAGS"]);
31
+ if (root.includes("animation") || root.includes("all") || root.includes("true"))
32
+ return true;
33
+ }
34
+ if (typeof location !== "undefined" && typeof location.search === "string") {
35
+ try {
36
+ const params = new URLSearchParams(location.search);
37
+ const urlFlags = parseList(params.get("a3d-qr") ?? undefined);
38
+ if (urlFlags.includes("animation") || urlFlags.includes("all") || urlFlags.includes("true"))
39
+ return true;
40
+ }
41
+ catch {
42
+ // malformed URLSearchParams input — ignore URL source
43
+ }
44
+ }
45
+ return false;
46
+ };
47
+ export function resolveGLTFClipName(requested, available, options) {
33
48
  if (available.length === 0)
34
49
  return undefined;
35
50
  const requestedToken = normalizeClipToken(requested);
@@ -50,7 +65,8 @@ export function resolveGLTFClipName(requested, available) {
50
65
  return name;
51
66
  }
52
67
  }
53
- return available[0];
68
+ const fallback = options?.fallback ?? (animationClipDefaultsAre31() ? "error" : "first");
69
+ return fallback === "first" ? available[0] : undefined;
54
70
  }
55
71
  function clipMaskAllowsNode(mask, nodeName) {
56
72
  if (!mask)
@@ -253,15 +269,104 @@ export class GLTFImportedSkeletonIKController {
253
269
  };
254
270
  }
255
271
  }
272
+ /** PRD-06 §9.8 — wall clock, ms (`performance.now` where it exists). */
273
+ function nowMs() {
274
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
275
+ }
276
+ /** Column-major mat4 multiply written into a Float32Array slot — the T0.11 no-alloc palette path. Exported so the §13 palette-build micro-budget test measures the exact per-joint write the runtime performs. `a` is hoisted into scalars once per call so the inner writes are pure FMA-grade flops — the indexed-read loop measured ~1.3× over the §13 palette budget. */
277
+ export function multiplyMat4Into(out, outOffset, a, b) {
278
+ const a00 = a[0], a10 = a[1], a20 = a[2], a30 = a[3];
279
+ const a01 = a[4], a11 = a[5], a21 = a[6], a31 = a[7];
280
+ const a02 = a[8], a12 = a[9], a22 = a[10], a32 = a[11];
281
+ const a03 = a[12], a13 = a[13], a23 = a[14], a33 = a[15];
282
+ for (let col = 0; col < 4; col += 1) {
283
+ const b0 = b[col * 4 + 0], b1 = b[col * 4 + 1], b2 = b[col * 4 + 2], b3 = b[col * 4 + 3];
284
+ out[outOffset + col * 4 + 0] = a00 * b0 + a01 * b1 + a02 * b2 + a03 * b3;
285
+ out[outOffset + col * 4 + 1] = a10 * b0 + a11 * b1 + a12 * b2 + a13 * b3;
286
+ out[outOffset + col * 4 + 2] = a20 * b0 + a21 * b1 + a22 * b2 + a23 * b3;
287
+ out[outOffset + col * 4 + 3] = a30 * b0 + a31 * b1 + a32 * b2 + a33 * b3;
288
+ }
289
+ }
290
+ /** Column-major general 4×4 inverse into `out` (adjugate / determinant, gl-matrix `mat4.invert`; 06-T0: out[7], out[10], out[11], out[14], out[15] previously used the wrong cofactor terms, so even inverse(identity) had out[11] = -1 and every skinning palette carried a non-affine row 3 that projected skinned vertices off-screen on the GPU). §13: `invertMat4` allocates two objects per call — over 600 frames that alone blows the steady-state budget. */
291
+ export function invertMat4Into(out, a) {
292
+ const a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3];
293
+ const a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7];
294
+ const a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11];
295
+ const a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15];
296
+ const b00 = a00 * a11 - a01 * a10;
297
+ const b01 = a00 * a12 - a02 * a10;
298
+ const b02 = a00 * a13 - a03 * a10;
299
+ const b03 = a01 * a12 - a02 * a11;
300
+ const b04 = a01 * a13 - a03 * a11;
301
+ const b05 = a02 * a13 - a03 * a12;
302
+ const b06 = a20 * a31 - a21 * a30;
303
+ const b07 = a20 * a32 - a22 * a30;
304
+ const b08 = a20 * a33 - a23 * a30;
305
+ const b09 = a21 * a32 - a22 * a31;
306
+ const b10 = a21 * a33 - a23 * a31;
307
+ const b11 = a22 * a33 - a23 * a32;
308
+ let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
309
+ if (!det) {
310
+ out.fill(0);
311
+ return out;
312
+ }
313
+ det = 1.0 / det;
314
+ out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det;
315
+ out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det;
316
+ out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det;
317
+ out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det;
318
+ out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det;
319
+ out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det;
320
+ out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det;
321
+ out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det;
322
+ out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det;
323
+ out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det;
324
+ out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det;
325
+ out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det;
326
+ out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det;
327
+ out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det;
328
+ out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det;
329
+ out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det;
330
+ return out;
331
+ }
256
332
  export class GLTFSceneAnimationRuntime {
257
333
  options;
258
334
  clipsByName = new Map();
259
335
  nodesByName = new Map();
260
336
  morphRenderablesByNodeName = new Map();
261
337
  skinningBindings = [];
338
+ disposed = false;
339
+ footBindMatrices = new Map();
340
+ footOrientationLocks = new Map();
341
+ footDescendantLocks = new Map();
342
+ footSurfaceVertices = new Map();
262
343
  footPlanting;
263
344
  footRig;
264
345
  lastApply;
346
+ /** T0.11 scratch for the per-joint palette multiply (inverseMeshWorld * jointWorld). */
347
+ paletteScratch = new Float32Array(16);
348
+ /** §13: reusable scratch for the per-binding inverse world matrix (no alloc/frame). */
349
+ inverseWorldScratch = new Float32Array(16);
350
+ /** §13: persistent result object for `refreshSkinningPalettes` (0 bytes/frame). */
351
+ paletteResult = { updated: 0, missingTargets: [] };
352
+ /**
353
+ * T1.7 (PRD-06) — the per-runtime pose pipeline backing `applyClips`: a
354
+ * scene-wide SkeletonBinding (every traversed node in order, rest pose = the
355
+ * node's local TRS at bind time), one PoseMixer, and the compiled clips.
356
+ * Rebuilt whenever `reindexScene` re-reads the node set.
357
+ */
358
+ poseState;
359
+ /**
360
+ * T3.5 — world→model matrix provider for pose constraints. Defaults to the
361
+ * scene root's world matrix; the `prd06.animation` actor extension installs
362
+ * the app-node transform when one is known.
363
+ */
364
+ poseConstraintModelMatrix;
365
+ /**
366
+ * Wall-clock (ms) of the last constraint evaluation under `applyClips` —
367
+ * its caller passes no dt, so constraint dynamics measure the real gap.
368
+ */
369
+ lastConstraintEvalAtMs;
265
370
  constructor(options) {
266
371
  this.options = options;
267
372
  for (const clip of options.clips) {
@@ -284,9 +389,150 @@ export class GLTFSceneAnimationRuntime {
284
389
  }
285
390
  if (!sameFootPlantingShape(this.footPlanting, config)) {
286
391
  this.footRig = undefined;
392
+ this.footOrientationLocks.clear();
393
+ this.footDescendantLocks.clear();
287
394
  }
288
395
  this.footPlanting = config;
289
396
  }
397
+ /**
398
+ * Extract real authored displacement over unwrapped clip time, hand it to the physical
399
+ * authority once, then apply an in-place root pose and solve feet in the updated actor
400
+ * transform. Rejected movement is returned and discarded rather than accumulating drift.
401
+ * The caller refreshes footPlanting.worldFromLocal inside move after committing movement.
402
+ */
403
+ applyRootMotionClip(name, options) {
404
+ return this.applyRootMotionClips([{ clipName: name, ...options }], options);
405
+ }
406
+ /** Blend displacement and consumed poses with the same base/additive weights. */
407
+ applyRootMotionClips(samples, options) {
408
+ if (samples.length === 0)
409
+ throw new Error("glTF root motion blend requires at least one clip sample.");
410
+ const accumulators = new Map();
411
+ const motionAccumulators = new Map();
412
+ const unsupportedTracks = [];
413
+ const names = [];
414
+ let maxTime = 0;
415
+ // Finish validation and sampling before the physical authority can mutate the actor.
416
+ for (const sample of samples) {
417
+ const clip = this.clipsByName.get(sample.clipName);
418
+ if (!clip)
419
+ throw new Error(`glTF animation clip "${sample.clipName}" was not found.`);
420
+ const weight = sample.weight ?? 1;
421
+ if (!Number.isFinite(weight) || weight < 0)
422
+ throw new Error("glTF root motion blend weight must be finite and non-negative.");
423
+ const extracted = extractRootMotion(clip, sample);
424
+ if (weight === 0)
425
+ continue;
426
+ const time = sample.loop && clip.duration > 0
427
+ ? ((sample.toTime % clip.duration) + clip.duration) % clip.duration
428
+ : Math.max(0, Math.min(clip.duration, sample.toTime));
429
+ maxTime = Math.max(maxTime, time);
430
+ names.push(`${clip.name}@${time}x${weight}${sample.additive ? "+add" : ""}`);
431
+ blendInto(motionAccumulators, "motion", "vector3", extracted.delta, weight, sample.additive === true);
432
+ for (const track of clip.tracks) {
433
+ if (!parseAnimationTarget(track.target)) {
434
+ unsupportedTracks.push(track.target);
435
+ continue;
436
+ }
437
+ // Consume each clip's own root before blending, including additive root tracks.
438
+ const value = track.sample(track.target === sample.target ? 0 : time);
439
+ blendInto(accumulators, track.target, track.valueType, value, weight, sample.additive === true);
440
+ }
441
+ }
442
+ const values = new Map();
443
+ for (const [target, accumulator] of accumulators)
444
+ values.set(target, finalizeTargetBlend(accumulator));
445
+ const motionAccumulator = motionAccumulators.get("motion");
446
+ const delta = motionAccumulator ? finalizeTargetBlend(motionAccumulator) : [0, 0, 0];
447
+ const motion = consumeRootMotion({ target: "blend", fromTime: 0, toTime: maxTime, looped: samples.some(sample => sample.loop), delta }, options.worldFromLocal, options.move);
448
+ this.lastApply = this.applySampledTargets(`root-motion:${names.join(",")}`, maxTime, { sampledTargets: values, unsupportedTracks }, samples.length);
449
+ return { motion, applyResult: this.lastApply };
450
+ }
451
+ /**
452
+ * T3.7 (PRD-06 §6.8) — resolve the root-motion translation track target for
453
+ * spec-level `play(clip, {rootMotion})`: with `bone` set, the bone's own
454
+ * `translation`/`position` track; without it, the clip's root-motion
455
+ * candidate (largest planar displacement on a hips/root/pelvis node, then
456
+ * any translation track by displacement). Throws when nothing qualifies.
457
+ */
458
+ rootMotionTargetFor(clipName, bone) {
459
+ const clip = this.clipsByName.get(clipName);
460
+ if (!clip)
461
+ throw new Error(`glTF animation clip "${clipName}" was not found.`);
462
+ const isTranslation = (target) => target.endsWith(".translation") || target.endsWith(".position");
463
+ if (bone !== undefined) {
464
+ for (const leaf of ["translation", "position"]) {
465
+ const candidate = `${bone}.${leaf}`;
466
+ if (clip.tracks.some(track => track.target === candidate && track.valueType === "vector3"))
467
+ return candidate;
468
+ }
469
+ throw new Error(`Root motion bone "${bone}" has no translation track in glTF animation clip "${clipName}".`);
470
+ }
471
+ let best;
472
+ let bestDistance = -1;
473
+ let fallback;
474
+ for (const track of clip.tracks) {
475
+ if (track.valueType !== "vector3" || !isTranslation(track.target))
476
+ continue;
477
+ fallback ??= track.target;
478
+ const keys = track.keyframes ?? [];
479
+ if (keys.length < 2)
480
+ continue;
481
+ const first = vec3OfKeyframeValue(keys[0].value);
482
+ const last = vec3OfKeyframeValue(keys[keys.length - 1].value);
483
+ if (!first || !last)
484
+ continue;
485
+ const distance = Math.hypot(last[0] - first[0], last[2] - first[2]);
486
+ const rooted = /hips|root|pelvis/i.test(track.target);
487
+ if (distance > 0.05 && (rooted || best === undefined || distance > bestDistance)) {
488
+ if (rooted || best === undefined || !/hips|root|pelvis/i.test(best)) {
489
+ best = track.target;
490
+ bestDistance = distance;
491
+ }
492
+ }
493
+ }
494
+ const resolved = best ?? fallback;
495
+ if (resolved === undefined) {
496
+ throw new Error(`glTF animation clip "${clipName}" has no translation track for root motion.`);
497
+ }
498
+ return resolved;
499
+ }
500
+ /**
501
+ * T3.7 — yaw delta (radians, about local Y) authored on `bone`'s rotation
502
+ * track between `fromTime`/`toTime` (loop-aware). Returns 0 when the clip
503
+ * has no matching rotation track.
504
+ */
505
+ rootMotionYawDelta(clipName, bone, fromTime, toTime, loop) {
506
+ const clip = this.clipsByName.get(clipName);
507
+ if (!clip)
508
+ throw new Error(`glTF animation clip "${clipName}" was not found.`);
509
+ const node = bone ?? this.rootMotionTargetFor(clipName).replace(/\.(translation|position)$/, "");
510
+ const track = clip.tracks.find(candidate => candidate.valueType === "quaternion" &&
511
+ (candidate.target === `${node}.rotation` || candidate.target === `${node}.quaternion`));
512
+ if (!track)
513
+ return 0;
514
+ const yawAt = (time) => {
515
+ const q = track.sample(time);
516
+ const arr = (Array.isArray(q) || ArrayBuffer.isView(q)) ? q : undefined;
517
+ const [x, y, z, w] = arr !== undefined && arr.length >= 4
518
+ ? [arr[0], arr[1], arr[2], arr[3]]
519
+ : [0, 0, 0, 1];
520
+ // forward = q * (0,0,1); yaw = atan2(forward.x, forward.z)
521
+ const fx = 2 * (x * z + w * y);
522
+ const fz = 1 - 2 * (x * x + y * y);
523
+ return Math.atan2(fx, fz);
524
+ };
525
+ const wrap01 = (t) => ((t % clip.duration) + clip.duration) % clip.duration;
526
+ const unwrap = (delta) => delta - Math.round(delta / (Math.PI * 2)) * Math.PI * 2;
527
+ if (!loop || clip.duration <= 0) {
528
+ const clamp = (t) => Math.max(0, Math.min(clip.duration, t));
529
+ return unwrap(yawAt(clamp(toTime)) - yawAt(clamp(fromTime)));
530
+ }
531
+ // Continuous-loop yaw: same-cycle delta plus per-cycle contribution per wrap.
532
+ const perCycle = unwrap(yawAt(clip.duration) - yawAt(0));
533
+ const crossings = Math.floor(toTime / clip.duration) - Math.floor(fromTime / clip.duration);
534
+ return unwrap(yawAt(wrap01(toTime)) - yawAt(wrap01(fromTime))) + crossings * perCycle;
535
+ }
290
536
  applyClipByName(name, time) {
291
537
  const clip = this.clipsByName.get(name);
292
538
  if (!clip) {
@@ -298,13 +544,27 @@ export class GLTFSceneAnimationRuntime {
298
544
  clipNames() {
299
545
  return [...this.clipsByName.keys()];
300
546
  }
547
+ /**
548
+ * T0.6 (PRD-06) — the C-19 `AuraResolvedClipInfo` surface that backs the
549
+ * `prd06.animation` handle extension's `resolveAnimationClips()`: real GLB
550
+ * clip durations (plus channel counts and the 5 cm XZ-displacement
551
+ * root-motion candidate heuristic of T0.7) after the asset has loaded.
552
+ */
553
+ resolvedClipInfos() {
554
+ return [...this.clipsByName.values()].map((clip) => ({
555
+ name: clip.name,
556
+ duration: clip.duration,
557
+ channelCount: clip.tracks.length,
558
+ hasRootMotionCandidate: clipHasRootMotionCandidate(clip)
559
+ }));
560
+ }
301
561
  /**
302
562
  * Resolve a requested clip name to the best available registered clip name
303
563
  * using fuzzy matching (exact -> synonym group -> substring -> first clip).
304
564
  * Returns `undefined` when the runtime has no clips.
305
565
  */
306
- resolveClipName(name) {
307
- return resolveGLTFClipName(name, this.clipNames());
566
+ resolveClipName(name, options) {
567
+ return resolveGLTFClipName(name, this.clipNames(), options);
308
568
  }
309
569
  /**
310
570
  * Apply a clip selected by fuzzy name resolution. Unlike {@link applyClipByName}
@@ -326,14 +586,35 @@ export class GLTFSceneAnimationRuntime {
326
586
  this.lastApply = this.applySampledTargets(clip.name, wrappedTime, sampleClipTracks(clip, wrappedTime, 1, false));
327
587
  return this.lastApply;
328
588
  }
589
+ /**
590
+ * T1.7 (PRD-06 §10) — re-implemented on the per-runtime `PoseMixer` in
591
+ * stateless mode (`evaluateSamples` with explicit per-sample times). Node
592
+ * translation/rotation/scale tracks bound to a scene node go through the
593
+ * pose pipeline (incremental-weight accumulate + rest fill + additive
594
+ * accumulators, three r185 `AnimationMixer` order); morph-weight, material
595
+ * and light pointer tracks keep the existing accumulator path, and so do
596
+ * node tracks aimed at nodes the binding does not cover (they keep their
597
+ * `missingTargets` reporting). Two documented semantic changes vs the old
598
+ * accumulator blend: partial-weight mixes fill the remainder from the rest
599
+ * pose instead of renormalising (rest blend), and a bone covered by the
600
+ * blend but not by a given channel resets that channel to rest (rest reset).
601
+ */
329
602
  applyClips(samples) {
330
603
  if (samples.length === 0) {
331
604
  throw new Error("glTF animation runtime blend requires at least one clip sample.");
332
605
  }
606
+ const phaseStart = nowMs();
607
+ let mixerMs = 0;
608
+ let constraintsMs = 0;
609
+ let springsMs = 0;
333
610
  const accumulators = new Map();
334
611
  const unsupportedTracks = [];
335
612
  let maxTime = 0;
336
613
  const names = [];
614
+ const pose = this.poseRuntime();
615
+ const poseSpecs = [];
616
+ const touchedBones = new Set();
617
+ const maskWeights = new Map();
337
618
  for (const sample of samples) {
338
619
  const clip = this.clipsByName.get(sample.clipName);
339
620
  if (!clip) {
@@ -351,25 +632,353 @@ export class GLTFSceneAnimationRuntime {
351
632
  const wrappedTime = clip.duration > 0 && sample.time > clip.duration ? sample.time % clip.duration : Math.min(sample.time, clip.duration);
352
633
  maxTime = Math.max(maxTime, wrappedTime);
353
634
  names.push(`${clip.name}@${Number(wrappedTime.toFixed(4))}x${Number(weight.toFixed(4))}${sample.additive ? "+add" : ""}`);
635
+ let bindsNodes = false;
354
636
  for (const track of clip.tracks) {
355
637
  const target = parseAnimationTarget(track.target);
356
638
  if (!target) {
357
639
  unsupportedTracks.push(track.target);
358
640
  continue;
359
641
  }
360
- // Bone masks filter node tracks only; material/light property tracks always apply.
361
- if (target.kind === "node" && !clipMaskAllowsNode(sample.mask, target.nodeName))
642
+ if (target.kind === "node" && target.path !== "weights") {
643
+ // Node TRS: pose path when the name binds and the mask allows it.
644
+ const boneIndices = pose.binding.jointIndicesByName.get(target.nodeName);
645
+ if (boneIndices !== undefined && boneIndices.length > 0 && clipMaskAllowsNode(sample.mask, target.nodeName)) {
646
+ bindsNodes = true;
647
+ touchedBones.add(boneIndices[0]);
648
+ }
649
+ else if (boneIndices === undefined) {
650
+ // Track points at a node absent from the scene — legacy path so
651
+ // `missingTargets` reporting is unchanged.
652
+ blendInto(accumulators, track.target, track.valueType, track.sample(wrappedTime), weight, sample.additive === true);
653
+ }
362
654
  continue;
655
+ }
363
656
  blendInto(accumulators, track.target, track.valueType, track.sample(wrappedTime), weight, sample.additive === true);
364
657
  }
658
+ if (bindsNodes) {
659
+ let mask = null;
660
+ if (sample.mask !== undefined) {
661
+ let cached = maskWeights.get(sample.mask);
662
+ if (cached === undefined) {
663
+ cached = new Float32Array(pose.binding.boneCount);
664
+ for (let i = 0; i < pose.binding.boneCount; i += 1) {
665
+ cached[i] = clipMaskAllowsNode(sample.mask, pose.binding.jointNames[i]) ? 1 : 0;
666
+ }
667
+ maskWeights.set(sample.mask, cached);
668
+ }
669
+ mask = cached;
670
+ }
671
+ poseSpecs.push({ clipName: clip.name, time: wrappedTime, weight, additive: sample.additive === true, mask });
672
+ }
673
+ }
674
+ const sampledTargets = new Map();
675
+ if (poseSpecs.length > 0) {
676
+ const mixerStart = nowMs();
677
+ pose.mixer.evaluateSamples(poseSpecs, pose.pose);
678
+ mixerMs = nowMs() - mixerStart;
679
+ // T3.5 — constraints apply to the freshly-mixed pose before palette
680
+ // build; touched joints union into the emitted sampled targets. Their
681
+ // dynamics (look-at half-life, spring substeps) integrate in wall-clock
682
+ // seconds, so measure the real gap between applies — a hardcoded 1/60
683
+ // makes smoothing converge ~6× too slowly in sub-60fps sessions.
684
+ const now = nowMs();
685
+ const constraintDt = this.lastConstraintEvalAtMs === undefined
686
+ ? 1 / 60
687
+ : Math.min(0.25, Math.max(1e-4, (now - this.lastConstraintEvalAtMs) / 1000));
688
+ this.lastConstraintEvalAtMs = now;
689
+ const constrained = this.runPoseConstraints(pose.pose, pose.binding, constraintDt);
690
+ constraintsMs = constrained.constraintsMs;
691
+ springsMs = constrained.springsMs;
692
+ for (const boneIndex of constrained.touched)
693
+ touchedBones.add(boneIndex);
694
+ for (const boneIndex of touchedBones) {
695
+ const name = pose.binding.jointNames[boneIndex];
696
+ const p = boneIndex * 3;
697
+ const q = boneIndex * 4;
698
+ sampledTargets.set(`${name}.translation`, [pose.pose.positions[p], pose.pose.positions[p + 1], pose.pose.positions[p + 2]]);
699
+ sampledTargets.set(`${name}.rotation`, [pose.pose.rotations[q], pose.pose.rotations[q + 1], pose.pose.rotations[q + 2], pose.pose.rotations[q + 3]]);
700
+ sampledTargets.set(`${name}.scale`, [pose.pose.scales[p], pose.pose.scales[p + 1], pose.pose.scales[p + 2]]);
701
+ }
702
+ }
703
+ for (const [target, accumulator] of accumulators) {
704
+ sampledTargets.set(target, finalizeTargetBlend(accumulator));
705
+ }
706
+ this.lastApply = this.applySampledTargets(`blend:${names.join(",")}`, maxTime, { sampledTargets, unsupportedTracks }, samples.length, { mixerMs, constraintsMs, springsMs, paletteBytes: this.paletteBufferBytes(), cpuMs: nowMs() - phaseStart });
707
+ return this.lastApply;
708
+ }
709
+ /**
710
+ * §10 (PRD-06) — the scene-wide pose binding the pose path evaluates on.
711
+ * Joints are every scene node in `traverse` order, resolved by node index
712
+ * (duplicate names bind all slots); the rest pose is each node's local TRS
713
+ * captured at bind time.
714
+ */
715
+ skeletons() {
716
+ return [this.poseRuntime().binding];
717
+ }
718
+ /** §10 (PRD-06) — the per-clip compiled tracks used by the pose path. */
719
+ compiledClips() {
720
+ return this.poseRuntime().compiled;
721
+ }
722
+ /** §10 (PRD-06) — the per-runtime PoseMixer `applyClips` evaluates on. */
723
+ mixer() {
724
+ return this.poseRuntime().mixer;
725
+ }
726
+ /**
727
+ * T3.8 (PRD-06 §7.2, C-19) — `actor.animation.addClipsFrom(source)` retarget-
728
+ * bakes another skeleton's compiled clips onto this runtime's skeleton and
729
+ * registers them by name (raw + compiled + mixer) so `play`/`playLayer`
730
+ * resolve them like loaded clips. The bake runs in `pose/retarget.worker.ts`
731
+ * when the platform has workers, otherwise in-process, and its output is
732
+ * cached in IndexedDB under `(engineVersion, sourceHash, targetHash)` so a
733
+ * rollback ignores newer caches. Returns the registered clip names.
734
+ */
735
+ async addClipsFrom(source, options = {}) {
736
+ const pose = this.poseRuntime();
737
+ const sourceSkeleton = "skeletons" in source ? source.skeletons()[0] : source.skeleton;
738
+ const sourceClips = "compiledClips" in source ? source.compiledClips() : source.clips;
739
+ if (sourceSkeleton === undefined) {
740
+ throw new Error("addClipsFrom: source has no skeleton to retarget from.");
741
+ }
742
+ const engineVersion = options.engineVersion ?? AURA3D_RETARGET_ENGINE_VERSION;
743
+ const sourceHash = `${retargetSkeletonHash(sourceSkeleton)}:${retargetClipsHash(sourceClips)}`;
744
+ const targetHash = retargetSkeletonHash(pose.binding);
745
+ const key = retargetCacheKey(engineVersion, sourceHash, targetHash);
746
+ let baked = options.cache === false ? undefined : await readRetargetCache(key);
747
+ if (baked === undefined) {
748
+ const bakeOptions = {
749
+ ...(options.map !== undefined ? { map: options.map } : {}),
750
+ hipsScale: options.hipsScale ?? "leg-length",
751
+ ...(options.fingers !== undefined ? { fingers: options.fingers } : {}),
752
+ ...(options.retarget !== undefined ? { retarget: options.retarget } : {})
753
+ };
754
+ const worker = options.worker === false ? undefined : createRetargetWorker();
755
+ baked = worker !== undefined
756
+ ? await bakeClipsInWorker(worker, {
757
+ sourceSkeleton,
758
+ targetSkeleton: pose.binding,
759
+ clips: sourceClips,
760
+ options: bakeOptions
761
+ })
762
+ : bakeRetargetedClipMap({ skeleton: sourceSkeleton, clips: sourceClips }, pose.binding, bakeOptions);
763
+ if (options.cache !== false)
764
+ void writeRetargetCache(key, baked);
765
+ }
766
+ const names = [];
767
+ for (const [name, compiled] of baked) {
768
+ const raw = decompileCompiledClip(name, compiled);
769
+ this.clipsByName.set(name, raw);
770
+ pose.compiled.set(name, compiled);
771
+ pose.mixer.addCompiledClip(name, compiled, raw);
772
+ names.push(name);
773
+ }
774
+ return names;
775
+ }
776
+ /**
777
+ * T1.9 (PRD-06 §10) — advance the per-runtime `PoseMixer` by `dt` seconds
778
+ * and write the evaluated pose to scene nodes in `applySampledTargets`
779
+ * order. `restPoseReset !== false` writes every bound bone (rest-filled
780
+ * channels reset); `false` writes only the bones covered by active actions'
781
+ * bound tracks, so uncovered bones keep their current scene values.
782
+ * Non-pose tracks (morph weights, material/light pointer, node tracks
783
+ * aimed at nodes outside the binding) keep the legacy accumulator path,
784
+ * sampled at each active action's own clock and effective weight.
785
+ */
786
+ applyPoseMixer(dt, options) {
787
+ const phaseStart = nowMs();
788
+ const pose = this.poseRuntime();
789
+ const mixerStart = nowMs();
790
+ pose.mixer.update(dt);
791
+ pose.mixer.evaluate(pose.pose);
792
+ const mixerMs = nowMs() - mixerStart;
793
+ // T3.5 — constraints evaluate post-mixer / pre-palette; their touched
794
+ // bones union into `covered` so the write-back emits them.
795
+ this.lastConstraintEvalAtMs = nowMs();
796
+ const constrained = this.runPoseConstraints(pose.pose, pose.binding, dt);
797
+ const constraintsMs = constrained.constraintsMs;
798
+ const springsMs = constrained.springsMs;
799
+ const actions = pose.mixer.activeActions();
800
+ let covered;
801
+ if (options?.restPoseReset === false) {
802
+ const set = new Set();
803
+ for (const action of actions) {
804
+ for (const binding of action.bindings)
805
+ set.add(binding.boneIndex);
806
+ }
807
+ for (const boneIndex of constrained.touched)
808
+ set.add(boneIndex);
809
+ covered = set;
365
810
  }
811
+ // `covered === undefined` already emits every bound bone below — the
812
+ // constraint union matters only on the covered path.
366
813
  const sampledTargets = new Map();
814
+ const writeBone = (boneIndex) => {
815
+ const name = pose.binding.jointNames[boneIndex];
816
+ const p = boneIndex * 3;
817
+ const q = boneIndex * 4;
818
+ sampledTargets.set(`${name}.translation`, [pose.pose.positions[p], pose.pose.positions[p + 1], pose.pose.positions[p + 2]]);
819
+ sampledTargets.set(`${name}.rotation`, [pose.pose.rotations[q], pose.pose.rotations[q + 1], pose.pose.rotations[q + 2], pose.pose.rotations[q + 3]]);
820
+ sampledTargets.set(`${name}.scale`, [pose.pose.scales[p], pose.pose.scales[p + 1], pose.pose.scales[p + 2]]);
821
+ };
822
+ if (covered === undefined) {
823
+ for (let i = 0; i < pose.binding.boneCount; i += 1)
824
+ writeBone(i);
825
+ }
826
+ else {
827
+ for (const boneIndex of covered)
828
+ writeBone(boneIndex);
829
+ }
830
+ // Legacy accumulator path for every track the pose binding does not
831
+ // cover — sampled at each action's clock and effective weight, in active
832
+ // action order (same semantics as `applyClips`' per-sample partition).
833
+ const accumulators = new Map();
834
+ const unsupportedTracks = [];
835
+ for (const action of actions) {
836
+ const source = this.clipsByName.get(action.clipName);
837
+ if (source === undefined)
838
+ continue;
839
+ for (const track of source.tracks) {
840
+ const target = parseAnimationTarget(track.target);
841
+ if (target === undefined) {
842
+ unsupportedTracks.push(track.target);
843
+ continue;
844
+ }
845
+ if (target.kind === "node" && target.path !== "weights") {
846
+ const boneIndices = pose.binding.jointIndicesByName.get(target.nodeName);
847
+ if (boneIndices !== undefined && boneIndices.length > 0)
848
+ continue; // bound → mixer path
849
+ }
850
+ blendInto(accumulators, track.target, track.valueType, track.sample(action.time), action.effectiveWeight, action.additive);
851
+ }
852
+ }
367
853
  for (const [target, accumulator] of accumulators) {
368
854
  sampledTargets.set(target, finalizeTargetBlend(accumulator));
369
855
  }
370
- this.lastApply = this.applySampledTargets(`blend:${names.join(",")}`, maxTime, { sampledTargets, unsupportedTracks }, samples.length);
856
+ const active = actions.filter((action) => action.effectiveWeight > 0);
857
+ this.lastApply = this.applySampledTargets(`mixer:${options?.label ?? (actions.map((action) => action.clipName).join("+") || "idle")}`, active[0]?.time ?? 0, { sampledTargets, unsupportedTracks }, actions.length > 0 ? actions.length : undefined, { mixerMs, constraintsMs, springsMs, paletteBytes: this.paletteBufferBytes(), cpuMs: nowMs() - phaseStart });
371
858
  return this.lastApply;
372
859
  }
860
+ /**
861
+ * T1.10 (PRD-06 §10, C-19 `blendMode: "additive"`/`additiveReference`) — lazily
862
+ * compile an additive variant of `clipName` via `makeClipAdditive` (reference
863
+ * defaults to the clip's own first frame) and register it on the mixer under
864
+ * a synthesized name. Returns the registered clip key for `clipAction`/`playLayer`.
865
+ */
866
+ ensureAdditiveClip(clipName, reference) {
867
+ const pose = this.poseRuntime();
868
+ const source = this.clipsByName.get(clipName);
869
+ if (source === undefined) {
870
+ return clipName;
871
+ }
872
+ const referenceClip = reference?.clip === undefined ? undefined : this.clipsByName.get(reference.clip);
873
+ const referenceTime = reference?.time ?? 0;
874
+ const name = `${clipName}#additive:${reference?.clip ?? "self"}:${referenceTime}`;
875
+ if (!pose.compiled.has(name)) {
876
+ const additive = makeClipAdditive(source, referenceClip === undefined ? referenceTime : { clip: referenceClip, time: referenceTime });
877
+ const compiledClip = compileClip(additive);
878
+ pose.compiled.set(name, compiledClip);
879
+ pose.mixer.addCompiledClip(name, compiledClip, additive);
880
+ }
881
+ return name;
882
+ }
883
+ /**
884
+ * T1.10 (C-19 `animation.mask`) — bind-order joint names for `createBoneMask`.
885
+ */
886
+ skeletonJointNames() {
887
+ return this.poseRuntime().binding.jointNames;
888
+ }
889
+ /**
890
+ * T3.5 — register a pose constraint. Entries evaluate in insertion order
891
+ * after each mixer write; returns a disposer that removes exactly this
892
+ * constraint (by identity — a duplicate spec stays).
893
+ */
894
+ addPoseConstraint(constraint) {
895
+ const state = this.poseRuntime();
896
+ state.constraints.push(constraint);
897
+ let live = true;
898
+ return () => {
899
+ if (!live)
900
+ return;
901
+ live = false;
902
+ const index = state.constraints.indexOf(constraint);
903
+ if (index >= 0)
904
+ state.constraints.splice(index, 1);
905
+ };
906
+ }
907
+ /** T3.5 — drop every pose constraint (constant-time identity sweep). */
908
+ clearPoseConstraints() {
909
+ if (this.poseState === undefined)
910
+ return;
911
+ this.poseState.constraints.length = 0;
912
+ }
913
+ /** T3.5 — the count of live pose constraints (diagnostics/tests). */
914
+ poseConstraintCount() {
915
+ return this.poseState?.constraints.length ?? 0;
916
+ }
917
+ /** T3.5 — install the world→model matrix provider (actor extension). */
918
+ setPoseConstraintModelMatrix(provider) {
919
+ this.poseConstraintModelMatrix = provider;
920
+ }
921
+ /**
922
+ * T3.5 — evaluate the constraint list in order onto the just-mixed pose and
923
+ * return the union of touched joint indices (empty when no constraints),
924
+ * plus the §9.8 wall-clock split between `"springs"`-tagged constraints and
925
+ * the rest.
926
+ */
927
+ runPoseConstraints(pose, binding, dt) {
928
+ const touched = new Set();
929
+ const constraints = this.poseState?.constraints;
930
+ let constraintsMs = 0;
931
+ let springsMs = 0;
932
+ if (constraints === undefined || constraints.length === 0)
933
+ return { touched, constraintsMs, springsMs };
934
+ const modelMatrix = this.poseConstraintModelMatrix?.() ?? this.options.scene.root.transform.worldMatrix;
935
+ for (const constraint of constraints) {
936
+ const start = nowMs();
937
+ constraint.evaluate(pose, binding, modelMatrix, { dt });
938
+ const elapsed = nowMs() - start;
939
+ if (constraint.kind === "springs")
940
+ springsMs += elapsed;
941
+ else
942
+ constraintsMs += elapsed;
943
+ for (const bone of constraint.bones)
944
+ touched.add(bone);
945
+ }
946
+ return { touched, constraintsMs, springsMs };
947
+ }
948
+ /** §9.8/§13 — CPU-side palette payload this runtime owns, bytes (16 f32/joint). */
949
+ paletteBufferBytes() {
950
+ let bytes = 0;
951
+ for (const binding of this.skinningBindings)
952
+ bytes += binding.skin.joints.length * 16 * 4;
953
+ return bytes;
954
+ }
955
+ poseRuntime() {
956
+ if (this.poseState === undefined) {
957
+ const nodes = [];
958
+ this.options.scene.traverse((node) => nodes.push(node));
959
+ const indexByNode = new Map(nodes.map((node, index) => [node, index]));
960
+ const binding = bindSkeleton({
961
+ joints: nodes.map((_, index) => index),
962
+ resolveNode: (index) => {
963
+ const node = nodes[index];
964
+ if (node === undefined)
965
+ return undefined;
966
+ return { name: node.name, position: node.transform.position, rotation: node.transform.rotation, scale: node.transform.scale };
967
+ },
968
+ jointNames: nodes.map((node) => node.name),
969
+ parentIndices: nodes.map((node) => (node.parent === null ? -1 : indexByNode.get(node.parent) ?? -1))
970
+ });
971
+ const mixer = new PoseMixer({ skeleton: binding });
972
+ const compiled = new Map();
973
+ for (const [name, clip] of this.clipsByName) {
974
+ const compiledClip = compileClip(clip);
975
+ compiled.set(name, compiledClip);
976
+ mixer.addCompiledClip(name, compiledClip, clip);
977
+ }
978
+ this.poseState = { binding, mixer, compiled, pose: createPoseBuffer(binding.boneCount), constraints: [] };
979
+ }
980
+ return this.poseState;
981
+ }
373
982
  applyAnimationValues(label, time, values, unsupportedTracks = []) {
374
983
  if (label.trim().length === 0) {
375
984
  throw new Error("glTF animation runtime value application label cannot be empty.");
@@ -383,6 +992,22 @@ export class GLTFSceneAnimationRuntime {
383
992
  });
384
993
  return this.lastApply;
385
994
  }
995
+ /**
996
+ * §16 S3 (PRD-06) — teardown frees the C-18 palette textures this runtime
997
+ * stamped, keyed on each skin binding (`skinningPaletteCache.release`).
998
+ * Morph array textures and CPU-morph scratch are released by the actor's
999
+ * own teardown (`prd06.animation` extension dispose / pipeline lifecycle),
1000
+ * which is the layer that can see resolved `Geometry` objects — the scene
1001
+ * layer here only knows geometry handles.
1002
+ */
1003
+ dispose() {
1004
+ if (this.disposed)
1005
+ return;
1006
+ for (const binding of this.skinningBindings) {
1007
+ skinningPaletteCache.release(binding.paletteView?.paletteKey ?? binding);
1008
+ }
1009
+ this.disposed = true;
1010
+ }
386
1011
  /**
387
1012
  * Render-time bridge: drive the loaded GLB from an externally-computed pose whose keys are GLB
388
1013
  * **node names** (e.g. the output of `@aura3d/animation`'s `retargetHumanoidPose`, whose
@@ -525,10 +1150,18 @@ export class GLTFSceneAnimationRuntime {
525
1150
  return [...this.clipsByName.values()].map((clip) => this.inspectClipBinding(clip));
526
1151
  }
527
1152
  reindexScene() {
1153
+ this.poseState = undefined;
1154
+ this.lastConstraintEvalAtMs = undefined;
1155
+ this.options.scene.updateWorldTransforms();
1156
+ this.footBindMatrices.clear();
1157
+ this.footOrientationLocks.clear();
1158
+ this.footDescendantLocks.clear();
528
1159
  this.nodesByName.clear();
529
1160
  this.morphRenderablesByNodeName.clear();
530
1161
  this.skinningBindings.length = 0;
1162
+ this.footSurfaceVertices.clear();
531
1163
  this.options.scene.traverse((node) => {
1164
+ this.footBindMatrices.set(node.id, { world: [...node.transform.worldMatrix], local: [...node.transform.localMatrix] });
532
1165
  const nodes = this.nodesByName.get(node.name) ?? [];
533
1166
  nodes.push(node);
534
1167
  this.nodesByName.set(node.name, nodes);
@@ -551,7 +1184,7 @@ export class GLTFSceneAnimationRuntime {
551
1184
  const skin = mesh?.skinIndex === undefined ? undefined : this.options.asset.skins[mesh.skinIndex];
552
1185
  if (!mesh || !skin || skin.joints.length > MAX_RENDERABLE_SKINNING_JOINTS)
553
1186
  continue;
554
- this.skinningBindings.push({ node, renderable, mesh, skin });
1187
+ this.skinningBindings.push({ node, renderable, mesh, skin, bindWorldMatrix: [...node.transform.worldMatrix], paletteMatrices: new Float32Array(skin.joints.length * 16) });
555
1188
  }
556
1189
  }
557
1190
  }
@@ -559,9 +1192,9 @@ export class GLTFSceneAnimationRuntime {
559
1192
  return this.options.scene.collectLights().find((light) => light.name === name);
560
1193
  }
561
1194
  /**
562
- * Foot-planting post-pass (E2): solves the configured leg chains against the ground and
563
- * writes the solved hip/knee/ankle world positions back onto the skeleton. Position-only
564
- * write-back (rotations stay as animated); missing leg nodes are reported in
1195
+ * Foot-planting post-pass (E2): solves the configured leg chains against the ground,
1196
+ * applies a shared pelvis reach correction, and rotates rigid hip/knee chains toward the
1197
+ * solved targets. Missing leg nodes are reported in
565
1198
  * `missingLegNodes` and skipped, never faked. Returns undefined when unconfigured.
566
1199
  */
567
1200
  applyFootPlanting() {
@@ -595,11 +1228,20 @@ export class GLTFSceneAnimationRuntime {
595
1228
  missingLegNodes.push(`${leg.side}:ankle:${leg.ankle}`);
596
1229
  if (!hip || !knee || !ankle)
597
1230
  continue;
598
- resolved.push({ side: leg.side, hip, knee, ankle, pole: leg.pole });
1231
+ resolved.push({ side: leg.side, hip, knee, ankle, pole: leg.pole, ...(leg.contact === undefined ? {} : { contact: leg.contact }), ...(leg.support ? { support: leg.support } : {}) });
599
1232
  }
600
1233
  if (resolved.length === 0) {
601
1234
  return { groundedFeet: 0, averageTargetError: 0, lockedSides: [], missingLegNodes, hipOffset: 0 };
602
1235
  }
1236
+ // Measure each animated ankle-to-sole offset from the exact pre-solve skinning
1237
+ // palette. One global value cannot plant asymmetric shoes or a rotated touchdown.
1238
+ this.refreshSkinningPalettes();
1239
+ const rawSoles = this.sampleFootSurfaces();
1240
+ const measuredAnkleHeights = new Map(resolved.map(entry => {
1241
+ const ankle = solvePoint(worldPosition(entry.ankle));
1242
+ const minimum = Math.min(Infinity, ...rawSoles.filter(point => point.side === entry.side).map(point => point.worldPosition[1]));
1243
+ return [entry.side, Number.isFinite(minimum) ? Math.max(0, ankle[1] - minimum) : undefined];
1244
+ }));
603
1245
  if (!this.footRig) {
604
1246
  this.footRig = createFootIkRig({
605
1247
  legs: resolved.map((entry) => ({
@@ -607,6 +1249,7 @@ export class GLTFSceneAnimationRuntime {
607
1249
  hip: solvePoint(worldPosition(entry.hip)),
608
1250
  knee: solvePoint(worldPosition(entry.knee)),
609
1251
  ankle: solvePoint(worldPosition(entry.ankle)),
1252
+ ...(measuredAnkleHeights.get(entry.side) ?? config.ankleHeight) !== undefined ? { ankleHeight: measuredAnkleHeights.get(entry.side) ?? config.ankleHeight } : {},
610
1253
  ...(entry.pole ? { pole: entry.pole } : {})
611
1254
  })),
612
1255
  raycaster: config.ground,
@@ -617,38 +1260,116 @@ export class GLTFSceneAnimationRuntime {
617
1260
  ...(config.hipDropFactor !== undefined ? { hipDropFactor: config.hipDropFactor } : {})
618
1261
  });
619
1262
  }
1263
+ const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
1264
+ const originalLengths = new Map(resolved.map(entry => [entry.side, {
1265
+ upper: distance(solvePoint(worldPosition(entry.hip)), solvePoint(worldPosition(entry.knee))),
1266
+ lower: distance(solvePoint(worldPosition(entry.knee)), solvePoint(worldPosition(entry.ankle)))
1267
+ }]));
620
1268
  const solved = this.footRig.solveFootPlacement({
621
1269
  legs: resolved.map((entry) => ({
622
1270
  side: entry.side,
623
1271
  hip: solvePoint(worldPosition(entry.hip)),
624
1272
  knee: solvePoint(worldPosition(entry.knee)),
625
1273
  ankle: solvePoint(worldPosition(entry.ankle)),
626
- ...(entry.pole ? { pole: entry.pole } : {})
1274
+ ...(measuredAnkleHeights.get(entry.side) ?? config.ankleHeight) !== undefined ? { ankleHeight: measuredAnkleHeights.get(entry.side) ?? config.ankleHeight } : {},
1275
+ ...(entry.pole ? { pole: entry.pole } : {}),
1276
+ ...(entry.contact === undefined ? {} : { contact: entry.contact }),
1277
+ ...(entry.support ? { support: entry.support } : {})
627
1278
  }))
628
1279
  });
1280
+ // A reach correction belongs to the shared pelvis owner. Move it once before
1281
+ // rotating either leg; translating each upper-leg root dislocates the chain,
1282
+ // while omitting the drop leaves a fully extended stance foot above ground.
1283
+ if (solved.hipOffset < 0) {
1284
+ const common = commonAncestor(resolved.map(entry => entry.hip));
1285
+ const anchors = common && common.parent ? [common] : [...new Set(resolved.map(entry => entry.hip))];
1286
+ for (const anchor of anchors) {
1287
+ const currentWorld = solvePoint(worldPosition(anchor));
1288
+ const dropped = actorPoint([currentWorld[0], currentWorld[1] + solved.hipOffset, currentWorld[2]]);
1289
+ setWorldPosition(anchor, dropped);
1290
+ }
1291
+ this.options.scene.updateWorldTransforms();
1292
+ }
629
1293
  for (const foot of solved.feet) {
630
1294
  const entry = resolved.find((candidate) => candidate.side === foot.side);
631
1295
  if (!entry)
632
1296
  continue;
633
- // The rig returns the pelvis drop separately: apply it to the hip so an over-extended
634
- // leg can reach its planted foot. Solved knee/ankle targets already account for the
635
- // drop — the rig re-solves over-extended legs from the dropped hip — so they write
636
- // back directly; a leg that needed no drop keeps phase-one targets and lets its knee
637
- // absorb the pelvis motion by bending, the natural single-pelvis compromise.
638
- // Refresh down the chain after each write: setWorldPosition reads the parent's world
639
- // matrix, so the knee must see the new hip world before its own write (same for ankle).
640
- // Solved targets are world-space; convert to actor-local for the write-back.
641
- const hipTarget = actorPoint([foot.hip[0], foot.hip[1] + solved.hipOffset, foot.hip[2]]);
642
- setWorldPosition(entry.hip, [hipTarget[0], hipTarget[1], hipTarget[2]]);
643
- entry.hip.updateWorldTransform();
1297
+ // Apply the two-bone solution with joint rotations. Moving hip/knee/ankle
1298
+ // translations directly changes authored bone lengths and deforms every weighted
1299
+ // mesh. The solver targets are world-space; orient each parent toward its solved
1300
+ // child while preserving the animation's local translations and scales.
644
1301
  const kneeTarget = actorPoint(foot.knee);
645
- setWorldPosition(entry.knee, [kneeTarget[0], kneeTarget[1], kneeTarget[2]]);
646
- entry.knee.updateWorldTransform();
1302
+ orientJointToward(entry.hip, entry.knee, kneeTarget);
1303
+ entry.hip.updateWorldTransform();
647
1304
  const ankleTarget = actorPoint(foot.ankle);
648
- setWorldPosition(entry.ankle, [ankleTarget[0], ankleTarget[1], ankleTarget[2]]);
1305
+ orientJointToward(entry.knee, entry.ankle, ankleTarget);
1306
+ entry.knee.updateWorldTransform();
1307
+ }
1308
+ this.options.scene.updateWorldTransforms();
1309
+ for (const foot of solved.feet) {
1310
+ const entry = resolved.find(candidate => candidate.side === foot.side);
1311
+ if (!foot.locked || config.lockFootRotation === false) {
1312
+ this.footOrientationLocks.delete(foot.side);
1313
+ this.footDescendantLocks.delete(foot.side);
1314
+ continue;
1315
+ }
1316
+ let locked = this.footOrientationLocks.get(foot.side);
1317
+ if (!locked) {
1318
+ // Capture the actual animated sole orientation and toe pose at touchdown.
1319
+ // Reusing the asset bind pose snaps arbitrary rigs and moves their sole vertices.
1320
+ locked = toWorld ? multiplyMat4(toWorld, entry.ankle.transform.worldMatrix) : [...entry.ankle.transform.worldMatrix];
1321
+ const descendants = new Map();
1322
+ entry.ankle.children.forEach(child => child.traverse(node => descendants.set(node.id, [...node.transform.localMatrix])));
1323
+ this.footDescendantLocks.set(foot.side, descendants);
1324
+ const normal = foot.sample.groundNormal;
1325
+ const norm = Math.hypot(...normal);
1326
+ if (!(norm > 0))
1327
+ throw new Error("Foot contact requires a nonzero ground normal.");
1328
+ const nx = normal[0] / norm, ny = normal[1] / norm, nz = normal[2] / norm;
1329
+ if (ny <= -0.999)
1330
+ throw new Error("Foot contact cannot plant on a downward facing surface.");
1331
+ const vx = nz, vz = -nx, t = 1 / (1 + ny);
1332
+ const tilt = [1 - vz * vz * t, vz, vx * vz * t, 0, -vz, 1 - (vx * vx + vz * vz) * t, vx, 0, vx * vz * t, -vx, 1 - vx * vx * t, 0, 0, 0, 0, 1];
1333
+ locked = multiplyMat4(tilt, locked);
1334
+ this.footOrientationLocks.set(foot.side, locked);
1335
+ }
1336
+ // Keep the solved ankle position while holding the sole orientation in world space.
1337
+ const ankleWorld = solvePoint(worldPosition(entry.ankle));
1338
+ const desired = [...locked];
1339
+ desired[12] = ankleWorld[0];
1340
+ desired[13] = ankleWorld[1];
1341
+ desired[14] = ankleWorld[2];
1342
+ const actorMatrix = toLocal ? multiplyMat4(toLocal, desired) : desired;
1343
+ const parent = entry.ankle.parent;
1344
+ const local = parent ? multiplyMat4(invertMat4(parent.transform.worldMatrix), actorMatrix) : actorMatrix;
1345
+ entry.ankle.transform.setFromLocalMatrix(local);
1346
+ // Toe joints remain in their touchdown relationship during stance; animated
1347
+ // toe curl otherwise moves the sole while the ankle still reports zero slip.
1348
+ const touchdown = this.footDescendantLocks.get(foot.side);
1349
+ const restore = (node) => {
1350
+ const rest = touchdown?.get(node.id);
1351
+ if (rest)
1352
+ node.transform.setFromLocalMatrix(rest);
1353
+ node.children.forEach(restore);
1354
+ };
1355
+ entry.ankle.children.forEach(restore);
1356
+ entry.ankle.updateWorldTransform();
649
1357
  }
650
1358
  this.options.scene.updateWorldTransforms();
651
1359
  return {
1360
+ legDeformation: resolved.map(entry => {
1361
+ const before = originalLengths.get(entry.side);
1362
+ const upperAfter = distance(solvePoint(worldPosition(entry.hip)), solvePoint(worldPosition(entry.knee)));
1363
+ const lowerAfter = distance(solvePoint(worldPosition(entry.knee)), solvePoint(worldPosition(entry.ankle)));
1364
+ return { side: entry.side, upperBefore: before.upper, lowerBefore: before.lower, upperAfter, lowerAfter,
1365
+ maxRelativeLengthChange: Math.max(Math.abs(upperAfter - before.upper) / Math.max(1e-9, before.upper), Math.abs(lowerAfter - before.lower) / Math.max(1e-9, before.lower)) };
1366
+ }),
1367
+ feet: solved.feet.map(foot => {
1368
+ const entry = resolved.find(candidate => candidate.side === foot.side);
1369
+ const position = solvePoint(worldPosition(entry.ankle));
1370
+ return { side: foot.side, worldPosition: position, locked: foot.locked,
1371
+ contactError: Math.hypot(...position.map((value, axis) => value - foot.sample.plantedFoot[axis])) };
1372
+ }),
652
1373
  groundedFeet: solved.groundedFeet,
653
1374
  averageTargetError: solved.averageTargetError,
654
1375
  lockedSides: solved.feet.filter((foot) => foot.locked).map((foot) => foot.side),
@@ -699,37 +1420,150 @@ export class GLTFSceneAnimationRuntime {
699
1420
  light.range = asPositiveScalar(value, trackTarget);
700
1421
  return true;
701
1422
  }
1423
+ /** CPU skinning oracle using the exact palette submitted to rendering, not IK targets. */
1424
+ sampleFootSurfaces() {
1425
+ const config = this.footPlanting;
1426
+ if (!config)
1427
+ return [];
1428
+ const samples = [];
1429
+ for (const leg of config.legs) {
1430
+ const ankle = this.nodesByName.get(leg.ankle)?.[0];
1431
+ if (!ankle)
1432
+ continue;
1433
+ const footNames = new Set();
1434
+ const visit = (node) => { footNames.add(node.name); node.children.forEach(visit); };
1435
+ visit(ankle);
1436
+ for (const binding of this.skinningBindings) {
1437
+ const palette = binding.renderable.skinning;
1438
+ if (!palette)
1439
+ continue;
1440
+ const mesh = binding.mesh;
1441
+ const cacheKey = `${leg.ankle}:${binding.node.id}:${mesh.name}`;
1442
+ let selected = this.footSurfaceVertices.get(cacheKey);
1443
+ if (!selected) {
1444
+ const candidates = [];
1445
+ for (let vertex = 0; vertex < mesh.positions.length; vertex++) {
1446
+ let footWeight = 0;
1447
+ for (let lane = 0; lane < 4; lane++) {
1448
+ if (footNames.has(binding.skin.jointNames[mesh.joints[vertex]?.[lane] ?? -1] ?? ""))
1449
+ footWeight += mesh.weights[vertex]?.[lane] ?? 0;
1450
+ if (footNames.has(binding.skin.jointNames[mesh.joints1?.[vertex]?.[lane] ?? -1] ?? ""))
1451
+ footWeight += mesh.weights1?.[vertex]?.[lane] ?? 0;
1452
+ }
1453
+ if (footWeight >= 0.5)
1454
+ candidates.push(vertex);
1455
+ }
1456
+ if (!candidates.length) {
1457
+ this.footSurfaceVertices.set(cacheKey, []);
1458
+ continue;
1459
+ }
1460
+ // Fixed bind-space sole strip: never reselect vertices from animated positions.
1461
+ const bindHeights = new Map(candidates.map(vertex => [vertex, transformPoint(binding.bindWorldMatrix, [...mesh.positions[vertex]])[1]]));
1462
+ const ys = [...bindHeights.values()];
1463
+ const minimum = Math.min(...ys), maximum = Math.max(...ys);
1464
+ const strip = Math.max(1e-6, (maximum - minimum) * 0.001);
1465
+ selected = candidates.filter(index => bindHeights.get(index) <= minimum + strip);
1466
+ this.footSurfaceVertices.set(cacheKey, selected);
1467
+ }
1468
+ const toWorld = config.worldFromLocal ? multiplyMat4(config.worldFromLocal, binding.node.transform.worldMatrix) : binding.node.transform.worldMatrix;
1469
+ for (const vertex of selected) {
1470
+ const authored = [...mesh.positions[vertex]];
1471
+ for (let morph = 0; morph < mesh.morphTargets.length; morph++) {
1472
+ const weight = binding.renderable.morphWeights?.[morph] ?? 0;
1473
+ const delta = mesh.morphTargets[morph]?.positions[vertex];
1474
+ if (delta && weight)
1475
+ for (let axis = 0; axis < 3; axis++)
1476
+ authored[axis] += delta[axis] * weight;
1477
+ }
1478
+ const skinned = [0, 0, 0];
1479
+ let total = 0;
1480
+ for (let set = 0; set < 2; set++)
1481
+ for (let lane = 0; lane < 4; lane++) {
1482
+ const weight = (set ? mesh.weights1?.[vertex]?.[lane] : mesh.weights[vertex]?.[lane]) ?? 0;
1483
+ const joint = (set ? mesh.joints1?.[vertex]?.[lane] : mesh.joints[vertex]?.[lane]) ?? -1;
1484
+ if (weight <= 0)
1485
+ continue;
1486
+ if (joint < 0 || joint >= palette.jointCount)
1487
+ throw new Error("Foot surface vertex has an invalid palette influence.");
1488
+ const point = transformPoint(Array.from(palette.matrices.subarray(joint * 16, joint * 16 + 16)), authored);
1489
+ for (let axis = 0; axis < 3; axis++)
1490
+ skinned[axis] += point[axis] * weight;
1491
+ total += weight;
1492
+ }
1493
+ if (Math.abs(total - 1) > 0.001)
1494
+ throw new Error("Foot surface vertex weights must sum to one.");
1495
+ const worldPosition = transformPoint(toWorld, skinned);
1496
+ const hit = config.ground.raycastDown([worldPosition[0], worldPosition[1] + 2, worldPosition[2]], 4);
1497
+ samples.push({ side: leg.side, mesh: `${binding.node.userData.gltfNodeIndex ?? binding.node.name}:${mesh.name}`, vertex, worldPosition,
1498
+ groundError: hit ? worldPosition[1] - hit.point[1] : null,
1499
+ ...(vertex === selected[0] ? { skinningProbe: { position: authored,
1500
+ joints: [...(mesh.joints[vertex] ?? [0, 0, 0, 0]), ...(mesh.joints1?.[vertex] ?? [0, 0, 0, 0])],
1501
+ weights: [...(mesh.weights[vertex] ?? [0, 0, 0, 0]), ...(mesh.weights1?.[vertex] ?? [0, 0, 0, 0])],
1502
+ matrices: Array.from(palette.matrices), modelMatrix: [...toWorld], expectedWorldPosition: worldPosition } } : {}) });
1503
+ }
1504
+ }
1505
+ }
1506
+ return samples;
1507
+ }
702
1508
  refreshSkinningPalettes() {
1509
+ const result = this.paletteResult;
1510
+ result.updated = 0;
1511
+ result.missingTargets.length = 0;
703
1512
  if (this.skinningBindings.length === 0) {
704
- return { updated: 0, missingTargets: [] };
1513
+ return result;
705
1514
  }
706
- let updated = 0;
707
- const missingTargets = [];
1515
+ const missingTargets = result.missingTargets;
708
1516
  for (const binding of this.skinningBindings) {
709
- const matrices = new Float32Array(binding.skin.joints.length * 16);
710
- const inverseMeshWorld = invertMat4(binding.node.transform.worldMatrix);
1517
+ // T0.11: write into the binding's persistent palette buffer — no per-frame
1518
+ // Float32Array allocation, and the renderer's C-18 cache can key on it.
1519
+ const matrices = binding.paletteMatrices;
1520
+ const inverseMeshWorld = invertMat4Into(this.inverseWorldScratch, binding.node.transform.worldMatrix);
1521
+ // §13: joint nodes resolved once per binding instead of a string Map
1522
+ // lookup per joint per frame; the cache rebuilds while any joint is
1523
+ // missing so late-registered nodes still resolve.
1524
+ let jointNodes = binding.jointNodes;
1525
+ if (!jointNodes) {
1526
+ jointNodes = new Array(binding.skin.jointNames.length);
1527
+ let allResolved = true;
1528
+ for (let index = 0; index < binding.skin.jointNames.length; index += 1) {
1529
+ const node = this.nodesByName.get(binding.skin.jointNames[index])?.[0];
1530
+ jointNodes[index] = node;
1531
+ if (!node)
1532
+ allResolved = false;
1533
+ }
1534
+ if (allResolved)
1535
+ binding.jointNodes = jointNodes;
1536
+ }
711
1537
  let complete = true;
712
1538
  for (let index = 0; index < binding.skin.jointNames.length; index += 1) {
713
- const jointName = binding.skin.jointNames[index];
714
- const jointNode = this.nodesByName.get(jointName)?.[0];
1539
+ const jointNode = jointNodes[index];
715
1540
  const inverseBind = binding.skin.inverseBindMatrices[index];
716
1541
  if (!jointNode || !inverseBind) {
717
- missingTargets.push(`${binding.skin.name}.${jointName}`);
1542
+ missingTargets.push(`${binding.skin.name}.${binding.skin.jointNames[index]}`);
718
1543
  complete = false;
719
1544
  break;
720
1545
  }
721
- const jointMatrix = multiplyMat4(multiplyMat4(inverseMeshWorld, jointNode.transform.worldMatrix), inverseBind);
722
- matrices.set(jointMatrix, index * 16);
1546
+ // (inverseMeshWorld * jointWorld) * inverseBind, straight into the palette.
1547
+ multiplyMat4Into(this.paletteScratch, 0, inverseMeshWorld, jointNode.transform.worldMatrix);
1548
+ multiplyMat4Into(matrices, index * 16, this.paletteScratch, inverseBind);
723
1549
  }
724
1550
  if (!complete)
725
1551
  continue;
726
- binding.renderable.skinning = {
727
- jointCount: binding.skin.joints.length,
728
- matrices
729
- };
730
- updated += 1;
1552
+ let skinningPalette = binding.paletteView;
1553
+ if (!skinningPalette) {
1554
+ skinningPalette = {
1555
+ jointCount: binding.skin.joints.length,
1556
+ matrices
1557
+ };
1558
+ binding.paletteView = skinningPalette;
1559
+ }
1560
+ // C-18/§9.2: the stable per-skin key the palette cache binds on is this
1561
+ // binding object, not the per-frame `renderable.skinning` wrapper (E40).
1562
+ skinningPalette.paletteKey = binding;
1563
+ binding.renderable.skinning = skinningPalette;
1564
+ result.updated += 1;
731
1565
  }
732
- return { updated, missingTargets };
1566
+ return result;
733
1567
  }
734
1568
  inspectClipBinding(clip) {
735
1569
  let supportedTrackCount = 0;
@@ -810,7 +1644,7 @@ export class GLTFSceneAnimationRuntime {
810
1644
  animatesSkeleton: this.skinningBindings.length > 0 && boundTrackCount > 0 && transformTrackCount > 0
811
1645
  };
812
1646
  }
813
- applySampledTargets(clipName, time, sampled, blendedClipCount) {
1647
+ applySampledTargets(clipName, time, sampled, blendedClipCount, phaseTimings) {
814
1648
  let transformTracksApplied = 0;
815
1649
  let morphWeightTracksApplied = 0;
816
1650
  let materialTracksApplied = 0;
@@ -865,6 +1699,7 @@ export class GLTFSceneAnimationRuntime {
865
1699
  this.options.scene.updateWorldTransforms();
866
1700
  const footPlanting = this.applyFootPlanting();
867
1701
  const skinning = this.refreshSkinningPalettes();
1702
+ const measuredFootPlanting = footPlanting ? { ...footPlanting, surfaces: this.sampleFootSurfaces() } : undefined;
868
1703
  return {
869
1704
  clipName,
870
1705
  time,
@@ -874,10 +1709,11 @@ export class GLTFSceneAnimationRuntime {
874
1709
  morphWeightTracksApplied,
875
1710
  materialTracksApplied,
876
1711
  lightTracksApplied,
877
- ...(footPlanting === undefined ? {} : { footPlanting }),
1712
+ ...(measuredFootPlanting === undefined ? {} : { footPlanting: measuredFootPlanting }),
878
1713
  skinningPalettesUpdated: skinning.updated,
879
1714
  missingTargets: [...missingTargets, ...skinning.missingTargets],
880
- unsupportedTracks: sampled.unsupportedTracks
1715
+ unsupportedTracks: sampled.unsupportedTracks,
1716
+ ...(phaseTimings === undefined ? {} : { phaseTimings })
881
1717
  };
882
1718
  }
883
1719
  }
@@ -896,6 +1732,7 @@ export class GLTFSceneAnimationMixerBinding {
896
1732
  constructor(options) {
897
1733
  this.runtime = new GLTFSceneAnimationRuntime(options);
898
1734
  this.mixer = new AnimationMixer({
1735
+ ...(options.consumeRootMotion ? { consumeRootMotion: options.consumeRootMotion } : {}),
899
1736
  setAnimationValue: (target, value) => {
900
1737
  this.pendingValues.set(target, cloneAnimationValue(value));
901
1738
  }
@@ -1200,6 +2037,8 @@ function sameFootPlantingShape(previous, next) {
1200
2037
  return false;
1201
2038
  if (previous.hipDropFactor !== next.hipDropFactor)
1202
2039
  return false;
2040
+ if (previous.lockFootRotation !== next.lockFootRotation)
2041
+ return false;
1203
2042
  if (previous.legs.length !== next.legs.length)
1204
2043
  return false;
1205
2044
  return previous.legs.every((leg, index) => {
@@ -1209,6 +2048,7 @@ function sameFootPlantingShape(previous, next) {
1209
2048
  && leg.hip === other.hip
1210
2049
  && leg.knee === other.knee
1211
2050
  && leg.ankle === other.ankle
2051
+ && leg.ankleHeight === other.ankleHeight
1212
2052
  && (leg.pole ?? []).join(",") === (other.pole ?? []).join(",");
1213
2053
  });
1214
2054
  }
@@ -1216,6 +2056,47 @@ function worldPosition(node) {
1216
2056
  node.updateWorldTransform();
1217
2057
  return [node.transform.worldMatrix[12], node.transform.worldMatrix[13], node.transform.worldMatrix[14]];
1218
2058
  }
2059
+ function commonAncestor(nodes) {
2060
+ if (nodes.length === 0)
2061
+ return undefined;
2062
+ const first = [];
2063
+ for (let node = nodes[0]; node; node = node.parent)
2064
+ first.push(node);
2065
+ return first.find(candidate => nodes.every(node => node === candidate || candidate.isAncestorOf(node)));
2066
+ }
2067
+ function orientJointToward(node, child, target) {
2068
+ node.updateWorldTransform();
2069
+ child.updateWorldTransform();
2070
+ const origin = worldPosition(node);
2071
+ const current = worldPosition(child);
2072
+ const from = [current[0] - origin[0], current[1] - origin[1], current[2] - origin[2]];
2073
+ const to = [target[0] - origin[0], target[1] - origin[1], target[2] - origin[2]];
2074
+ const delta = quatFromUnitVectors(from, to);
2075
+ const worldRotation = multiplyRigidQuat(delta, decomposeMat4(node.transform.worldMatrix).rotation);
2076
+ const parentRotation = node.parent ? decomposeMat4(node.parent.transform.worldMatrix).rotation : [0, 0, 0, 1];
2077
+ const localRotation = multiplyRigidQuat(invertRigidQuat(parentRotation), worldRotation);
2078
+ node.transform.setRotation(localRotation[0], localRotation[1], localRotation[2], localRotation[3]);
2079
+ }
2080
+ function quatFromUnitVectors(from, to) {
2081
+ const fl = Math.hypot(...from), tl = Math.hypot(...to);
2082
+ if (!(fl > 1e-12) || !(tl > 1e-12))
2083
+ return [0, 0, 0, 1];
2084
+ const fx = from[0] / fl, fy = from[1] / fl, fz = from[2] / fl, tx = to[0] / tl, ty = to[1] / tl, tz = to[2] / tl;
2085
+ const dot = fx * tx + fy * ty + fz * tz;
2086
+ if (dot < -0.999999) {
2087
+ const axis = Math.abs(fx) > 0.1 ? [-fz, 0, fx] : [0, fz, -fy];
2088
+ const length = Math.hypot(...axis);
2089
+ return [axis[0] / length, axis[1] / length, axis[2] / length, 0];
2090
+ }
2091
+ if (dot > 0.999999)
2092
+ return [0, 0, 0, 1];
2093
+ return normalizeQuaternion([fy * tz - fz * ty, fz * tx - fx * tz, fx * ty - fy * tx, 1 + dot]);
2094
+ }
2095
+ function multiplyRigidQuat(a, b) {
2096
+ return normalizeQuaternion([a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]]);
2097
+ }
2098
+ function invertRigidQuat(q) { return [-q[0], -q[1], -q[2], q[3]]; }
2099
+ function normalizeQuaternion(q) { const n = Math.hypot(...q); return n > 0 ? [q[0] / n, q[1] / n, q[2] / n, q[3] / n] : [0, 0, 0, 1]; }
1219
2100
  function setWorldPosition(node, position) {
1220
2101
  const local = node.parent
1221
2102
  ? transformPoint(invertMat4(node.parent.transform.worldMatrix), [position[0], position[1], position[2]])
@@ -1442,4 +2323,44 @@ function multiplyQuat(a, b) {
1442
2323
  a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]
1443
2324
  ];
1444
2325
  }
2326
+ /**
2327
+ * T0.6/T0.7 (PRD-06) — a clip "has a root-motion candidate" when a translation
2328
+ * track on a hips/root-named target nets more than 5 cm of XZ displacement
2329
+ * between its first and last keyframes.
2330
+ */
2331
+ function clipHasRootMotionCandidate(clip) {
2332
+ for (const track of clip.tracks) {
2333
+ const target = parseAnimationTarget(track.target);
2334
+ if (target?.kind !== "node" || target.path !== "translation")
2335
+ continue;
2336
+ if (!/hips|root|pelvis/i.test(target.nodeName))
2337
+ continue;
2338
+ const keys = track.keyframes ?? [];
2339
+ if (keys.length < 2)
2340
+ continue;
2341
+ const first = vec3OfKeyframeValue(keys[0].value);
2342
+ const last = vec3OfKeyframeValue(keys[keys.length - 1].value);
2343
+ if (!first || !last)
2344
+ continue;
2345
+ const dx = last[0] - first[0];
2346
+ const dz = last[2] - first[2];
2347
+ if (Math.hypot(dx, dz) > 0.05)
2348
+ return true;
2349
+ }
2350
+ return false;
2351
+ }
2352
+ function vec3OfKeyframeValue(value) {
2353
+ if (Array.isArray(value) || ArrayBuffer.isView(value)) {
2354
+ const arr = value;
2355
+ if (arr.length >= 3)
2356
+ return [arr[0], arr[1], arr[2]];
2357
+ return undefined;
2358
+ }
2359
+ if (value && typeof value === "object") {
2360
+ const v = value;
2361
+ if (typeof v.x === "number" && typeof v.z === "number")
2362
+ return [v.x, v.y ?? 0, v.z];
2363
+ }
2364
+ return undefined;
2365
+ }
1445
2366
  //# sourceMappingURL=GLTFAnimationRuntime.js.map