@aura3d/assets 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
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 { composeMat4, decomposeMat4, invertMat4, MAX_RENDERABLE_SKINNING_JOINTS, multiplyMat4, Renderable, Scene, transformPoint } from "@aura3d/scene";
3
3
  /**
4
4
  * Synonym groups for fuzzy clip-name matching. Real catalog assets label the same
5
5
  * motion wildly differently ("Loops" vs "Idle", "Take 001" vs "Run", "sprint" vs
@@ -259,6 +259,10 @@ export class GLTFSceneAnimationRuntime {
259
259
  nodesByName = new Map();
260
260
  morphRenderablesByNodeName = new Map();
261
261
  skinningBindings = [];
262
+ footBindMatrices = new Map();
263
+ footOrientationLocks = new Map();
264
+ footDescendantLocks = new Map();
265
+ footSurfaceVertices = new Map();
262
266
  footPlanting;
263
267
  footRig;
264
268
  lastApply;
@@ -284,9 +288,65 @@ export class GLTFSceneAnimationRuntime {
284
288
  }
285
289
  if (!sameFootPlantingShape(this.footPlanting, config)) {
286
290
  this.footRig = undefined;
291
+ this.footOrientationLocks.clear();
292
+ this.footDescendantLocks.clear();
287
293
  }
288
294
  this.footPlanting = config;
289
295
  }
296
+ /**
297
+ * Extract real authored displacement over unwrapped clip time, hand it to the physical
298
+ * authority once, then apply an in-place root pose and solve feet in the updated actor
299
+ * transform. Rejected movement is returned and discarded rather than accumulating drift.
300
+ * The caller refreshes footPlanting.worldFromLocal inside move after committing movement.
301
+ */
302
+ applyRootMotionClip(name, options) {
303
+ return this.applyRootMotionClips([{ clipName: name, ...options }], options);
304
+ }
305
+ /** Blend displacement and consumed poses with the same base/additive weights. */
306
+ applyRootMotionClips(samples, options) {
307
+ if (samples.length === 0)
308
+ throw new Error("glTF root motion blend requires at least one clip sample.");
309
+ const accumulators = new Map();
310
+ const motionAccumulators = new Map();
311
+ const unsupportedTracks = [];
312
+ const names = [];
313
+ let maxTime = 0;
314
+ // Finish validation and sampling before the physical authority can mutate the actor.
315
+ for (const sample of samples) {
316
+ const clip = this.clipsByName.get(sample.clipName);
317
+ if (!clip)
318
+ throw new Error(`glTF animation clip "${sample.clipName}" was not found.`);
319
+ const weight = sample.weight ?? 1;
320
+ if (!Number.isFinite(weight) || weight < 0)
321
+ throw new Error("glTF root motion blend weight must be finite and non-negative.");
322
+ const extracted = extractRootMotion(clip, sample);
323
+ if (weight === 0)
324
+ continue;
325
+ const time = sample.loop && clip.duration > 0
326
+ ? ((sample.toTime % clip.duration) + clip.duration) % clip.duration
327
+ : Math.max(0, Math.min(clip.duration, sample.toTime));
328
+ maxTime = Math.max(maxTime, time);
329
+ names.push(`${clip.name}@${time}x${weight}${sample.additive ? "+add" : ""}`);
330
+ blendInto(motionAccumulators, "motion", "vector3", extracted.delta, weight, sample.additive === true);
331
+ for (const track of clip.tracks) {
332
+ if (!parseAnimationTarget(track.target)) {
333
+ unsupportedTracks.push(track.target);
334
+ continue;
335
+ }
336
+ // Consume each clip's own root before blending, including additive root tracks.
337
+ const value = track.sample(track.target === sample.target ? 0 : time);
338
+ blendInto(accumulators, track.target, track.valueType, value, weight, sample.additive === true);
339
+ }
340
+ }
341
+ const values = new Map();
342
+ for (const [target, accumulator] of accumulators)
343
+ values.set(target, finalizeTargetBlend(accumulator));
344
+ const motionAccumulator = motionAccumulators.get("motion");
345
+ const delta = motionAccumulator ? finalizeTargetBlend(motionAccumulator) : [0, 0, 0];
346
+ const motion = consumeRootMotion({ target: "blend", fromTime: 0, toTime: maxTime, looped: samples.some(sample => sample.loop), delta }, options.worldFromLocal, options.move);
347
+ this.lastApply = this.applySampledTargets(`root-motion:${names.join(",")}`, maxTime, { sampledTargets: values, unsupportedTracks }, samples.length);
348
+ return { motion, applyResult: this.lastApply };
349
+ }
290
350
  applyClipByName(name, time) {
291
351
  const clip = this.clipsByName.get(name);
292
352
  if (!clip) {
@@ -525,10 +585,16 @@ export class GLTFSceneAnimationRuntime {
525
585
  return [...this.clipsByName.values()].map((clip) => this.inspectClipBinding(clip));
526
586
  }
527
587
  reindexScene() {
588
+ this.options.scene.updateWorldTransforms();
589
+ this.footBindMatrices.clear();
590
+ this.footOrientationLocks.clear();
591
+ this.footDescendantLocks.clear();
528
592
  this.nodesByName.clear();
529
593
  this.morphRenderablesByNodeName.clear();
530
594
  this.skinningBindings.length = 0;
595
+ this.footSurfaceVertices.clear();
531
596
  this.options.scene.traverse((node) => {
597
+ this.footBindMatrices.set(node.id, { world: [...node.transform.worldMatrix], local: [...node.transform.localMatrix] });
532
598
  const nodes = this.nodesByName.get(node.name) ?? [];
533
599
  nodes.push(node);
534
600
  this.nodesByName.set(node.name, nodes);
@@ -551,7 +617,7 @@ export class GLTFSceneAnimationRuntime {
551
617
  const skin = mesh?.skinIndex === undefined ? undefined : this.options.asset.skins[mesh.skinIndex];
552
618
  if (!mesh || !skin || skin.joints.length > MAX_RENDERABLE_SKINNING_JOINTS)
553
619
  continue;
554
- this.skinningBindings.push({ node, renderable, mesh, skin });
620
+ this.skinningBindings.push({ node, renderable, mesh, skin, bindWorldMatrix: [...node.transform.worldMatrix] });
555
621
  }
556
622
  }
557
623
  }
@@ -559,9 +625,9 @@ export class GLTFSceneAnimationRuntime {
559
625
  return this.options.scene.collectLights().find((light) => light.name === name);
560
626
  }
561
627
  /**
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
628
+ * Foot-planting post-pass (E2): solves the configured leg chains against the ground,
629
+ * applies a shared pelvis reach correction, and rotates rigid hip/knee chains toward the
630
+ * solved targets. Missing leg nodes are reported in
565
631
  * `missingLegNodes` and skipped, never faked. Returns undefined when unconfigured.
566
632
  */
567
633
  applyFootPlanting() {
@@ -595,11 +661,20 @@ export class GLTFSceneAnimationRuntime {
595
661
  missingLegNodes.push(`${leg.side}:ankle:${leg.ankle}`);
596
662
  if (!hip || !knee || !ankle)
597
663
  continue;
598
- resolved.push({ side: leg.side, hip, knee, ankle, pole: leg.pole });
664
+ resolved.push({ side: leg.side, hip, knee, ankle, pole: leg.pole, ...(leg.contact === undefined ? {} : { contact: leg.contact }), ...(leg.support ? { support: leg.support } : {}) });
599
665
  }
600
666
  if (resolved.length === 0) {
601
667
  return { groundedFeet: 0, averageTargetError: 0, lockedSides: [], missingLegNodes, hipOffset: 0 };
602
668
  }
669
+ // Measure each animated ankle-to-sole offset from the exact pre-solve skinning
670
+ // palette. One global value cannot plant asymmetric shoes or a rotated touchdown.
671
+ this.refreshSkinningPalettes();
672
+ const rawSoles = this.sampleFootSurfaces();
673
+ const measuredAnkleHeights = new Map(resolved.map(entry => {
674
+ const ankle = solvePoint(worldPosition(entry.ankle));
675
+ const minimum = Math.min(Infinity, ...rawSoles.filter(point => point.side === entry.side).map(point => point.worldPosition[1]));
676
+ return [entry.side, Number.isFinite(minimum) ? Math.max(0, ankle[1] - minimum) : undefined];
677
+ }));
603
678
  if (!this.footRig) {
604
679
  this.footRig = createFootIkRig({
605
680
  legs: resolved.map((entry) => ({
@@ -607,6 +682,7 @@ export class GLTFSceneAnimationRuntime {
607
682
  hip: solvePoint(worldPosition(entry.hip)),
608
683
  knee: solvePoint(worldPosition(entry.knee)),
609
684
  ankle: solvePoint(worldPosition(entry.ankle)),
685
+ ...(measuredAnkleHeights.get(entry.side) ?? config.ankleHeight) !== undefined ? { ankleHeight: measuredAnkleHeights.get(entry.side) ?? config.ankleHeight } : {},
610
686
  ...(entry.pole ? { pole: entry.pole } : {})
611
687
  })),
612
688
  raycaster: config.ground,
@@ -617,38 +693,116 @@ export class GLTFSceneAnimationRuntime {
617
693
  ...(config.hipDropFactor !== undefined ? { hipDropFactor: config.hipDropFactor } : {})
618
694
  });
619
695
  }
696
+ const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
697
+ const originalLengths = new Map(resolved.map(entry => [entry.side, {
698
+ upper: distance(solvePoint(worldPosition(entry.hip)), solvePoint(worldPosition(entry.knee))),
699
+ lower: distance(solvePoint(worldPosition(entry.knee)), solvePoint(worldPosition(entry.ankle)))
700
+ }]));
620
701
  const solved = this.footRig.solveFootPlacement({
621
702
  legs: resolved.map((entry) => ({
622
703
  side: entry.side,
623
704
  hip: solvePoint(worldPosition(entry.hip)),
624
705
  knee: solvePoint(worldPosition(entry.knee)),
625
706
  ankle: solvePoint(worldPosition(entry.ankle)),
626
- ...(entry.pole ? { pole: entry.pole } : {})
707
+ ...(measuredAnkleHeights.get(entry.side) ?? config.ankleHeight) !== undefined ? { ankleHeight: measuredAnkleHeights.get(entry.side) ?? config.ankleHeight } : {},
708
+ ...(entry.pole ? { pole: entry.pole } : {}),
709
+ ...(entry.contact === undefined ? {} : { contact: entry.contact }),
710
+ ...(entry.support ? { support: entry.support } : {})
627
711
  }))
628
712
  });
713
+ // A reach correction belongs to the shared pelvis owner. Move it once before
714
+ // rotating either leg; translating each upper-leg root dislocates the chain,
715
+ // while omitting the drop leaves a fully extended stance foot above ground.
716
+ if (solved.hipOffset < 0) {
717
+ const common = commonAncestor(resolved.map(entry => entry.hip));
718
+ const anchors = common && common.parent ? [common] : [...new Set(resolved.map(entry => entry.hip))];
719
+ for (const anchor of anchors) {
720
+ const currentWorld = solvePoint(worldPosition(anchor));
721
+ const dropped = actorPoint([currentWorld[0], currentWorld[1] + solved.hipOffset, currentWorld[2]]);
722
+ setWorldPosition(anchor, dropped);
723
+ }
724
+ this.options.scene.updateWorldTransforms();
725
+ }
629
726
  for (const foot of solved.feet) {
630
727
  const entry = resolved.find((candidate) => candidate.side === foot.side);
631
728
  if (!entry)
632
729
  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();
730
+ // Apply the two-bone solution with joint rotations. Moving hip/knee/ankle
731
+ // translations directly changes authored bone lengths and deforms every weighted
732
+ // mesh. The solver targets are world-space; orient each parent toward its solved
733
+ // child while preserving the animation's local translations and scales.
644
734
  const kneeTarget = actorPoint(foot.knee);
645
- setWorldPosition(entry.knee, [kneeTarget[0], kneeTarget[1], kneeTarget[2]]);
646
- entry.knee.updateWorldTransform();
735
+ orientJointToward(entry.hip, entry.knee, kneeTarget);
736
+ entry.hip.updateWorldTransform();
647
737
  const ankleTarget = actorPoint(foot.ankle);
648
- setWorldPosition(entry.ankle, [ankleTarget[0], ankleTarget[1], ankleTarget[2]]);
738
+ orientJointToward(entry.knee, entry.ankle, ankleTarget);
739
+ entry.knee.updateWorldTransform();
740
+ }
741
+ this.options.scene.updateWorldTransforms();
742
+ for (const foot of solved.feet) {
743
+ const entry = resolved.find(candidate => candidate.side === foot.side);
744
+ if (!foot.locked || config.lockFootRotation === false) {
745
+ this.footOrientationLocks.delete(foot.side);
746
+ this.footDescendantLocks.delete(foot.side);
747
+ continue;
748
+ }
749
+ let locked = this.footOrientationLocks.get(foot.side);
750
+ if (!locked) {
751
+ // Capture the actual animated sole orientation and toe pose at touchdown.
752
+ // Reusing the asset bind pose snaps arbitrary rigs and moves their sole vertices.
753
+ locked = toWorld ? multiplyMat4(toWorld, entry.ankle.transform.worldMatrix) : [...entry.ankle.transform.worldMatrix];
754
+ const descendants = new Map();
755
+ entry.ankle.children.forEach(child => child.traverse(node => descendants.set(node.id, [...node.transform.localMatrix])));
756
+ this.footDescendantLocks.set(foot.side, descendants);
757
+ const normal = foot.sample.groundNormal;
758
+ const norm = Math.hypot(...normal);
759
+ if (!(norm > 0))
760
+ throw new Error("Foot contact requires a nonzero ground normal.");
761
+ const nx = normal[0] / norm, ny = normal[1] / norm, nz = normal[2] / norm;
762
+ if (ny <= -0.999)
763
+ throw new Error("Foot contact cannot plant on a downward facing surface.");
764
+ const vx = nz, vz = -nx, t = 1 / (1 + ny);
765
+ 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];
766
+ locked = multiplyMat4(tilt, locked);
767
+ this.footOrientationLocks.set(foot.side, locked);
768
+ }
769
+ // Keep the solved ankle position while holding the sole orientation in world space.
770
+ const ankleWorld = solvePoint(worldPosition(entry.ankle));
771
+ const desired = [...locked];
772
+ desired[12] = ankleWorld[0];
773
+ desired[13] = ankleWorld[1];
774
+ desired[14] = ankleWorld[2];
775
+ const actorMatrix = toLocal ? multiplyMat4(toLocal, desired) : desired;
776
+ const parent = entry.ankle.parent;
777
+ const local = parent ? multiplyMat4(invertMat4(parent.transform.worldMatrix), actorMatrix) : actorMatrix;
778
+ entry.ankle.transform.setFromLocalMatrix(local);
779
+ // Toe joints remain in their touchdown relationship during stance; animated
780
+ // toe curl otherwise moves the sole while the ankle still reports zero slip.
781
+ const touchdown = this.footDescendantLocks.get(foot.side);
782
+ const restore = (node) => {
783
+ const rest = touchdown?.get(node.id);
784
+ if (rest)
785
+ node.transform.setFromLocalMatrix(rest);
786
+ node.children.forEach(restore);
787
+ };
788
+ entry.ankle.children.forEach(restore);
789
+ entry.ankle.updateWorldTransform();
649
790
  }
650
791
  this.options.scene.updateWorldTransforms();
651
792
  return {
793
+ legDeformation: resolved.map(entry => {
794
+ const before = originalLengths.get(entry.side);
795
+ const upperAfter = distance(solvePoint(worldPosition(entry.hip)), solvePoint(worldPosition(entry.knee)));
796
+ const lowerAfter = distance(solvePoint(worldPosition(entry.knee)), solvePoint(worldPosition(entry.ankle)));
797
+ return { side: entry.side, upperBefore: before.upper, lowerBefore: before.lower, upperAfter, lowerAfter,
798
+ 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)) };
799
+ }),
800
+ feet: solved.feet.map(foot => {
801
+ const entry = resolved.find(candidate => candidate.side === foot.side);
802
+ const position = solvePoint(worldPosition(entry.ankle));
803
+ return { side: foot.side, worldPosition: position, locked: foot.locked,
804
+ contactError: Math.hypot(...position.map((value, axis) => value - foot.sample.plantedFoot[axis])) };
805
+ }),
652
806
  groundedFeet: solved.groundedFeet,
653
807
  averageTargetError: solved.averageTargetError,
654
808
  lockedSides: solved.feet.filter((foot) => foot.locked).map((foot) => foot.side),
@@ -699,6 +853,91 @@ export class GLTFSceneAnimationRuntime {
699
853
  light.range = asPositiveScalar(value, trackTarget);
700
854
  return true;
701
855
  }
856
+ /** CPU skinning oracle using the exact palette submitted to rendering, not IK targets. */
857
+ sampleFootSurfaces() {
858
+ const config = this.footPlanting;
859
+ if (!config)
860
+ return [];
861
+ const samples = [];
862
+ for (const leg of config.legs) {
863
+ const ankle = this.nodesByName.get(leg.ankle)?.[0];
864
+ if (!ankle)
865
+ continue;
866
+ const footNames = new Set();
867
+ const visit = (node) => { footNames.add(node.name); node.children.forEach(visit); };
868
+ visit(ankle);
869
+ for (const binding of this.skinningBindings) {
870
+ const palette = binding.renderable.skinning;
871
+ if (!palette)
872
+ continue;
873
+ const mesh = binding.mesh;
874
+ const cacheKey = `${leg.ankle}:${binding.node.id}:${mesh.name}`;
875
+ let selected = this.footSurfaceVertices.get(cacheKey);
876
+ if (!selected) {
877
+ const candidates = [];
878
+ for (let vertex = 0; vertex < mesh.positions.length; vertex++) {
879
+ let footWeight = 0;
880
+ for (let lane = 0; lane < 4; lane++) {
881
+ if (footNames.has(binding.skin.jointNames[mesh.joints[vertex]?.[lane] ?? -1] ?? ""))
882
+ footWeight += mesh.weights[vertex]?.[lane] ?? 0;
883
+ if (footNames.has(binding.skin.jointNames[mesh.joints1?.[vertex]?.[lane] ?? -1] ?? ""))
884
+ footWeight += mesh.weights1?.[vertex]?.[lane] ?? 0;
885
+ }
886
+ if (footWeight >= 0.5)
887
+ candidates.push(vertex);
888
+ }
889
+ if (!candidates.length) {
890
+ this.footSurfaceVertices.set(cacheKey, []);
891
+ continue;
892
+ }
893
+ // Fixed bind-space sole strip: never reselect vertices from animated positions.
894
+ const bindHeights = new Map(candidates.map(vertex => [vertex, transformPoint(binding.bindWorldMatrix, [...mesh.positions[vertex]])[1]]));
895
+ const ys = [...bindHeights.values()];
896
+ const minimum = Math.min(...ys), maximum = Math.max(...ys);
897
+ const strip = Math.max(1e-6, (maximum - minimum) * 0.001);
898
+ selected = candidates.filter(index => bindHeights.get(index) <= minimum + strip);
899
+ this.footSurfaceVertices.set(cacheKey, selected);
900
+ }
901
+ const toWorld = config.worldFromLocal ? multiplyMat4(config.worldFromLocal, binding.node.transform.worldMatrix) : binding.node.transform.worldMatrix;
902
+ for (const vertex of selected) {
903
+ const authored = [...mesh.positions[vertex]];
904
+ for (let morph = 0; morph < mesh.morphTargets.length; morph++) {
905
+ const weight = binding.renderable.morphWeights?.[morph] ?? 0;
906
+ const delta = mesh.morphTargets[morph]?.positions[vertex];
907
+ if (delta && weight)
908
+ for (let axis = 0; axis < 3; axis++)
909
+ authored[axis] += delta[axis] * weight;
910
+ }
911
+ const skinned = [0, 0, 0];
912
+ let total = 0;
913
+ for (let set = 0; set < 2; set++)
914
+ for (let lane = 0; lane < 4; lane++) {
915
+ const weight = (set ? mesh.weights1?.[vertex]?.[lane] : mesh.weights[vertex]?.[lane]) ?? 0;
916
+ const joint = (set ? mesh.joints1?.[vertex]?.[lane] : mesh.joints[vertex]?.[lane]) ?? -1;
917
+ if (weight <= 0)
918
+ continue;
919
+ if (joint < 0 || joint >= palette.jointCount)
920
+ throw new Error("Foot surface vertex has an invalid palette influence.");
921
+ const point = transformPoint(Array.from(palette.matrices.subarray(joint * 16, joint * 16 + 16)), authored);
922
+ for (let axis = 0; axis < 3; axis++)
923
+ skinned[axis] += point[axis] * weight;
924
+ total += weight;
925
+ }
926
+ if (Math.abs(total - 1) > 0.001)
927
+ throw new Error("Foot surface vertex weights must sum to one.");
928
+ const worldPosition = transformPoint(toWorld, skinned);
929
+ const hit = config.ground.raycastDown([worldPosition[0], worldPosition[1] + 2, worldPosition[2]], 4);
930
+ samples.push({ side: leg.side, mesh: `${binding.node.userData.gltfNodeIndex ?? binding.node.name}:${mesh.name}`, vertex, worldPosition,
931
+ groundError: hit ? worldPosition[1] - hit.point[1] : null,
932
+ ...(vertex === selected[0] ? { skinningProbe: { position: authored,
933
+ joints: [...(mesh.joints[vertex] ?? [0, 0, 0, 0]), ...(mesh.joints1?.[vertex] ?? [0, 0, 0, 0])],
934
+ weights: [...(mesh.weights[vertex] ?? [0, 0, 0, 0]), ...(mesh.weights1?.[vertex] ?? [0, 0, 0, 0])],
935
+ matrices: Array.from(palette.matrices), modelMatrix: [...toWorld], expectedWorldPosition: worldPosition } } : {}) });
936
+ }
937
+ }
938
+ }
939
+ return samples;
940
+ }
702
941
  refreshSkinningPalettes() {
703
942
  if (this.skinningBindings.length === 0) {
704
943
  return { updated: 0, missingTargets: [] };
@@ -865,6 +1104,7 @@ export class GLTFSceneAnimationRuntime {
865
1104
  this.options.scene.updateWorldTransforms();
866
1105
  const footPlanting = this.applyFootPlanting();
867
1106
  const skinning = this.refreshSkinningPalettes();
1107
+ const measuredFootPlanting = footPlanting ? { ...footPlanting, surfaces: this.sampleFootSurfaces() } : undefined;
868
1108
  return {
869
1109
  clipName,
870
1110
  time,
@@ -874,7 +1114,7 @@ export class GLTFSceneAnimationRuntime {
874
1114
  morphWeightTracksApplied,
875
1115
  materialTracksApplied,
876
1116
  lightTracksApplied,
877
- ...(footPlanting === undefined ? {} : { footPlanting }),
1117
+ ...(measuredFootPlanting === undefined ? {} : { footPlanting: measuredFootPlanting }),
878
1118
  skinningPalettesUpdated: skinning.updated,
879
1119
  missingTargets: [...missingTargets, ...skinning.missingTargets],
880
1120
  unsupportedTracks: sampled.unsupportedTracks
@@ -896,6 +1136,7 @@ export class GLTFSceneAnimationMixerBinding {
896
1136
  constructor(options) {
897
1137
  this.runtime = new GLTFSceneAnimationRuntime(options);
898
1138
  this.mixer = new AnimationMixer({
1139
+ ...(options.consumeRootMotion ? { consumeRootMotion: options.consumeRootMotion } : {}),
899
1140
  setAnimationValue: (target, value) => {
900
1141
  this.pendingValues.set(target, cloneAnimationValue(value));
901
1142
  }
@@ -1200,6 +1441,8 @@ function sameFootPlantingShape(previous, next) {
1200
1441
  return false;
1201
1442
  if (previous.hipDropFactor !== next.hipDropFactor)
1202
1443
  return false;
1444
+ if (previous.lockFootRotation !== next.lockFootRotation)
1445
+ return false;
1203
1446
  if (previous.legs.length !== next.legs.length)
1204
1447
  return false;
1205
1448
  return previous.legs.every((leg, index) => {
@@ -1209,6 +1452,7 @@ function sameFootPlantingShape(previous, next) {
1209
1452
  && leg.hip === other.hip
1210
1453
  && leg.knee === other.knee
1211
1454
  && leg.ankle === other.ankle
1455
+ && leg.ankleHeight === other.ankleHeight
1212
1456
  && (leg.pole ?? []).join(",") === (other.pole ?? []).join(",");
1213
1457
  });
1214
1458
  }
@@ -1216,6 +1460,47 @@ function worldPosition(node) {
1216
1460
  node.updateWorldTransform();
1217
1461
  return [node.transform.worldMatrix[12], node.transform.worldMatrix[13], node.transform.worldMatrix[14]];
1218
1462
  }
1463
+ function commonAncestor(nodes) {
1464
+ if (nodes.length === 0)
1465
+ return undefined;
1466
+ const first = [];
1467
+ for (let node = nodes[0]; node; node = node.parent)
1468
+ first.push(node);
1469
+ return first.find(candidate => nodes.every(node => node === candidate || candidate.isAncestorOf(node)));
1470
+ }
1471
+ function orientJointToward(node, child, target) {
1472
+ node.updateWorldTransform();
1473
+ child.updateWorldTransform();
1474
+ const origin = worldPosition(node);
1475
+ const current = worldPosition(child);
1476
+ const from = [current[0] - origin[0], current[1] - origin[1], current[2] - origin[2]];
1477
+ const to = [target[0] - origin[0], target[1] - origin[1], target[2] - origin[2]];
1478
+ const delta = quatFromUnitVectors(from, to);
1479
+ const worldRotation = multiplyRigidQuat(delta, decomposeMat4(node.transform.worldMatrix).rotation);
1480
+ const parentRotation = node.parent ? decomposeMat4(node.parent.transform.worldMatrix).rotation : [0, 0, 0, 1];
1481
+ const localRotation = multiplyRigidQuat(invertRigidQuat(parentRotation), worldRotation);
1482
+ node.transform.setRotation(localRotation[0], localRotation[1], localRotation[2], localRotation[3]);
1483
+ }
1484
+ function quatFromUnitVectors(from, to) {
1485
+ const fl = Math.hypot(...from), tl = Math.hypot(...to);
1486
+ if (!(fl > 1e-12) || !(tl > 1e-12))
1487
+ return [0, 0, 0, 1];
1488
+ 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;
1489
+ const dot = fx * tx + fy * ty + fz * tz;
1490
+ if (dot < -0.999999) {
1491
+ const axis = Math.abs(fx) > 0.1 ? [-fz, 0, fx] : [0, fz, -fy];
1492
+ const length = Math.hypot(...axis);
1493
+ return [axis[0] / length, axis[1] / length, axis[2] / length, 0];
1494
+ }
1495
+ if (dot > 0.999999)
1496
+ return [0, 0, 0, 1];
1497
+ return normalizeQuaternion([fy * tz - fz * ty, fz * tx - fx * tz, fx * ty - fy * tx, 1 + dot]);
1498
+ }
1499
+ function multiplyRigidQuat(a, b) {
1500
+ 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]]);
1501
+ }
1502
+ function invertRigidQuat(q) { return [-q[0], -q[1], -q[2], q[3]]; }
1503
+ 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
1504
  function setWorldPosition(node, position) {
1220
1505
  const local = node.parent
1221
1506
  ? transformPoint(invertMat4(node.parent.transform.worldMatrix), [position[0], position[1], position[2]])