@aura3d/animation 1.0.6 → 1.3.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 (121) hide show
  1. package/dist/AnimationClipEvents.d.ts +55 -0
  2. package/dist/AnimationClipEvents.d.ts.map +1 -1
  3. package/dist/AnimationClipEvents.js +136 -0
  4. package/dist/AnimationClipEvents.js.map +1 -1
  5. package/dist/AnimationClipRegistry.d.ts +22 -0
  6. package/dist/AnimationClipRegistry.d.ts.map +1 -1
  7. package/dist/AnimationClipRegistry.js +89 -0
  8. package/dist/AnimationClipRegistry.js.map +1 -1
  9. package/dist/AnimationController.d.ts +15 -0
  10. package/dist/AnimationController.d.ts.map +1 -1
  11. package/dist/AnimationController.js +28 -0
  12. package/dist/AnimationController.js.map +1 -1
  13. package/dist/AnimationMixer.d.ts +10 -0
  14. package/dist/AnimationMixer.d.ts.map +1 -1
  15. package/dist/AnimationMixer.js +35 -0
  16. package/dist/AnimationMixer.js.map +1 -1
  17. package/dist/AnimationStateGraph.d.ts +20 -1
  18. package/dist/AnimationStateGraph.d.ts.map +1 -1
  19. package/dist/AnimationStateGraph.js +73 -1
  20. package/dist/AnimationStateGraph.js.map +1 -1
  21. package/dist/AnimationStateMachine.d.ts +26 -0
  22. package/dist/AnimationStateMachine.d.ts.map +1 -1
  23. package/dist/AnimationStateMachine.js +32 -0
  24. package/dist/AnimationStateMachine.js.map +1 -1
  25. package/dist/Bone.d.ts +4 -0
  26. package/dist/Bone.d.ts.map +1 -1
  27. package/dist/Bone.js +3 -0
  28. package/dist/Bone.js.map +1 -1
  29. package/dist/FighterAnimationAdapter.d.ts +49 -0
  30. package/dist/FighterAnimationAdapter.d.ts.map +1 -0
  31. package/dist/FighterAnimationAdapter.js +71 -0
  32. package/dist/FighterAnimationAdapter.js.map +1 -0
  33. package/dist/FootIk.d.ts +77 -0
  34. package/dist/FootIk.d.ts.map +1 -0
  35. package/dist/FootIk.js +153 -0
  36. package/dist/FootIk.js.map +1 -0
  37. package/dist/HumanoidBoneInference.d.ts +61 -0
  38. package/dist/HumanoidBoneInference.d.ts.map +1 -0
  39. package/dist/HumanoidBoneInference.js +239 -0
  40. package/dist/HumanoidBoneInference.js.map +1 -0
  41. package/dist/HumanoidRetargeting.d.ts +57 -0
  42. package/dist/HumanoidRetargeting.d.ts.map +1 -1
  43. package/dist/HumanoidRetargeting.js +184 -6
  44. package/dist/HumanoidRetargeting.js.map +1 -1
  45. package/dist/IK.d.ts +5 -0
  46. package/dist/IK.d.ts.map +1 -1
  47. package/dist/IK.js +9 -0
  48. package/dist/IK.js.map +1 -1
  49. package/dist/Inertialization.d.ts +65 -0
  50. package/dist/Inertialization.d.ts.map +1 -0
  51. package/dist/Inertialization.js +211 -0
  52. package/dist/Inertialization.js.map +1 -0
  53. package/dist/LocomotionController.d.ts +5 -0
  54. package/dist/LocomotionController.d.ts.map +1 -1
  55. package/dist/LocomotionController.js +3 -0
  56. package/dist/LocomotionController.js.map +1 -1
  57. package/dist/LocomotionKit.d.ts +52 -0
  58. package/dist/LocomotionKit.d.ts.map +1 -0
  59. package/dist/LocomotionKit.js +38 -0
  60. package/dist/LocomotionKit.js.map +1 -0
  61. package/dist/MotionQuality.d.ts +6 -0
  62. package/dist/MotionQuality.d.ts.map +1 -1
  63. package/dist/MotionQuality.js +16 -0
  64. package/dist/MotionQuality.js.map +1 -1
  65. package/dist/RigQuality.d.ts +31 -0
  66. package/dist/RigQuality.d.ts.map +1 -0
  67. package/dist/RigQuality.js +70 -0
  68. package/dist/RigQuality.js.map +1 -0
  69. package/dist/SecondaryAnimationFixtures.d.ts.map +1 -1
  70. package/dist/SecondaryAnimationFixtures.js +1 -3
  71. package/dist/SecondaryAnimationFixtures.js.map +1 -1
  72. package/dist/Skeleton.d.ts +16 -1
  73. package/dist/Skeleton.d.ts.map +1 -1
  74. package/dist/Skeleton.js +42 -0
  75. package/dist/Skeleton.js.map +1 -1
  76. package/dist/SpringBones.d.ts +60 -0
  77. package/dist/SpringBones.d.ts.map +1 -0
  78. package/dist/SpringBones.js +173 -0
  79. package/dist/SpringBones.js.map +1 -0
  80. package/dist/browser-index.d.ts +12 -3
  81. package/dist/browser-index.d.ts.map +1 -1
  82. package/dist/browser-index.js +11 -2
  83. package/dist/browser-index.js.map +1 -1
  84. package/dist/index.d.ts +11 -3
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +10 -2
  87. package/dist/index.js.map +1 -1
  88. package/dist/library/performanceStateGraph.d.ts +42 -0
  89. package/dist/library/performanceStateGraph.d.ts.map +1 -0
  90. package/dist/library/performanceStateGraph.js +101 -0
  91. package/dist/library/performanceStateGraph.js.map +1 -0
  92. package/dist/library/standardHumanoidClips.d.ts +27 -0
  93. package/dist/library/standardHumanoidClips.d.ts.map +1 -0
  94. package/dist/library/standardHumanoidClips.js +357 -0
  95. package/dist/library/standardHumanoidClips.js.map +1 -0
  96. package/package.json +3 -3
  97. package/src/AnimationClipEvents.ts +181 -0
  98. package/src/AnimationClipRegistry.ts +124 -0
  99. package/src/AnimationController.ts +50 -0
  100. package/src/AnimationMixer.ts +36 -0
  101. package/src/AnimationStateGraph.ts +97 -0
  102. package/src/AnimationStateMachine.ts +48 -0
  103. package/src/Bone.ts +5 -0
  104. package/src/FighterAnimationAdapter.ts +124 -0
  105. package/src/FootIk.ts +251 -0
  106. package/src/HumanoidBoneInference.ts +323 -0
  107. package/src/HumanoidRetargeting.ts +230 -6
  108. package/src/IK.ts +10 -0
  109. package/src/Inertialization.ts +269 -0
  110. package/src/LocomotionController.ts +6 -0
  111. package/src/LocomotionKit.ts +89 -0
  112. package/src/MotionQuality.ts +26 -0
  113. package/src/RigQuality.ts +101 -0
  114. package/src/SecondaryAnimationFixtures.ts +1 -3
  115. package/src/Skeleton.ts +45 -1
  116. package/src/SpringBones.ts +243 -0
  117. package/src/browser-index.ts +13 -2
  118. package/src/index.ts +12 -2
  119. package/src/library/performanceStateGraph.ts +117 -0
  120. package/src/library/standardHumanoidClips.ts +442 -0
  121. package/src/Retargeting.ts +0 -29
package/src/FootIk.ts ADDED
@@ -0,0 +1,251 @@
1
+ // Runtime foot IK + foot-lock, built on the existing two-bone solver (`solveTwoBoneIk`) plus a
2
+ // ground query (a downward ray). This is "wire up what we already have" — no new solver. It removes
3
+ // foot sliding (foot-lock holds a planted foot in world space during stance and releases it on
4
+ // lift) and grounds characters on uneven terrain (each ankle is pulled to the ground height under
5
+ // it, the hip drops so the lower foot can reach).
6
+ //
7
+ // The animation package stays dependency-free of @aura3d/physics: callers inject a `GroundRaycaster`
8
+ // (the physics package's `groundHeightRaycaster` adapter satisfies this interface, or use the
9
+ // analytic `createHeightFieldGround` here). Pure and deterministic: given the same pose stream and
10
+ // ground, the solved feet and lock state are identical every run.
11
+
12
+ import { solveTwoBoneIk, type TwoBoneIkResult } from "./IK.js";
13
+ import type { Vec3 } from "./Keyframe.js";
14
+ // `FootIkSample` is the single source-of-truth data shape, defined in (and exported from)
15
+ // SecondaryAnimationFixtures; the runtime here produces that exact shape.
16
+ import type { FootIkSample } from "./SecondaryAnimationFixtures.js";
17
+
18
+ /** Result of a downward ground query: where the ground is and its surface normal. */
19
+ export interface GroundSample {
20
+ readonly point: Vec3;
21
+ readonly normal: Vec3;
22
+ readonly distance: number;
23
+ }
24
+
25
+ /** Minimal ground query the rig needs: cast a downward ray and report the first ground hit. */
26
+ export interface GroundRaycaster {
27
+ raycastDown(origin: Vec3, maxDistance: number): GroundSample | undefined;
28
+ }
29
+
30
+ /**
31
+ * Analytic ground query from a height function — deterministic and physics-free, ideal for tests
32
+ * and procedural terrain. `heightAt` returns the ground height (and optional normal) at an (x, z).
33
+ */
34
+ export function createHeightFieldGround(heightAt: (x: number, z: number) => { height: number; normal?: Vec3 }): GroundRaycaster {
35
+ return {
36
+ raycastDown(origin: Vec3, maxDistance: number): GroundSample | undefined {
37
+ const { height, normal } = heightAt(origin[0], origin[2]);
38
+ const distance = origin[1] - height;
39
+ if (distance < 0 || distance > maxDistance) return undefined;
40
+ return { point: [origin[0], height, origin[2]], normal: normal ?? [0, 1, 0], distance };
41
+ }
42
+ };
43
+ }
44
+
45
+ /** One leg: the hip (root), knee (mid), ankle (end), and an optional pole/knee hint. */
46
+ export interface FootLegInput {
47
+ readonly side: "left" | "right";
48
+ readonly hip: Vec3;
49
+ readonly knee: Vec3;
50
+ readonly ankle: Vec3;
51
+ readonly pole?: Vec3;
52
+ }
53
+
54
+ export interface FootIkRigOptions {
55
+ readonly legs: readonly FootLegInput[];
56
+ readonly raycaster: GroundRaycaster;
57
+ /** Height the ankle joint sits above the ground contact point (default 0.035). */
58
+ readonly ankleHeight?: number;
59
+ /** How far above the ankle to start the downward ray (default 0.6). */
60
+ readonly rayStartHeight?: number;
61
+ /** Max ray distance (default 2). */
62
+ readonly maxRayDistance?: number;
63
+ /** Foot is in stance (locks) when its height above the ground target is ≤ this (default 0.02). */
64
+ readonly plantThreshold?: number;
65
+ /** Hip-drop factor applied to the deepest required correction (default 0.72, matches fixture). */
66
+ readonly hipDropFactor?: number;
67
+ }
68
+
69
+ export interface SolvedLeg {
70
+ readonly side: "left" | "right";
71
+ readonly sample: FootIkSample;
72
+ /** Solved joint chain (write these back onto the skeleton). */
73
+ readonly hip: Vec3;
74
+ readonly knee: Vec3;
75
+ readonly ankle: Vec3;
76
+ /** True while the foot is locked to a world position (stance). */
77
+ readonly locked: boolean;
78
+ readonly reached: boolean;
79
+ }
80
+
81
+ export interface FootIkSolveResult {
82
+ readonly feet: readonly SolvedLeg[];
83
+ /** Vertical hip offset (≤ 0) so the lower planted foot can reach the ground. */
84
+ readonly hipOffset: number;
85
+ readonly groundedFeet: number;
86
+ readonly averageTargetError: number;
87
+ }
88
+
89
+ interface LegLockState {
90
+ locked: boolean;
91
+ lockedPosition: Vec3;
92
+ }
93
+
94
+ export interface FootIkRig {
95
+ /**
96
+ * Solve foot placement for the current pose. Pass updated leg transforms (e.g. sampled from the
97
+ * animation) via `pose.legs`, or omit to reuse the legs the rig was created with. Optionally
98
+ * override the ground query for this frame.
99
+ */
100
+ solveFootPlacement(pose?: { legs?: readonly FootLegInput[] }, groundQuery?: GroundRaycaster): FootIkSolveResult;
101
+ /** Whether a given side is currently foot-locked. */
102
+ isLocked(side: "left" | "right"): boolean;
103
+ /** Clear all foot-lock state (e.g. on teleport / respawn). */
104
+ reset(): void;
105
+ }
106
+
107
+ /** Create a foot-IK rig. Foot-lock state persists across `solveFootPlacement` calls. */
108
+ export function createFootIkRig(options: FootIkRigOptions): FootIkRig {
109
+ const ankleHeight = options.ankleHeight ?? 0.035;
110
+ const rayStartHeight = options.rayStartHeight ?? 0.6;
111
+ const maxRayDistance = options.maxRayDistance ?? 2;
112
+ const plantThreshold = options.plantThreshold ?? 0.02;
113
+ const hipDropFactor = options.hipDropFactor ?? 0.72;
114
+ let legs = options.legs;
115
+ const locks = new Map<"left" | "right", LegLockState>();
116
+
117
+ function lockFor(side: "left" | "right"): LegLockState {
118
+ let state = locks.get(side);
119
+ if (!state) {
120
+ state = { locked: false, lockedPosition: [0, 0, 0] };
121
+ locks.set(side, state);
122
+ }
123
+ return state;
124
+ }
125
+
126
+ return {
127
+ solveFootPlacement(pose, groundQuery) {
128
+ const raycaster = groundQuery ?? options.raycaster;
129
+ if (pose?.legs) legs = pose.legs;
130
+ const solved: SolvedLeg[] = [];
131
+ let deepestCorrection = 0;
132
+ for (const leg of legs) {
133
+ const lock = lockFor(leg.side);
134
+ const result = solveLeg(leg, lock, raycaster, { ankleHeight, rayStartHeight, maxRayDistance, plantThreshold });
135
+ solved.push(result);
136
+ deepestCorrection = Math.max(deepestCorrection, Math.max(0, result.sample.verticalCorrection));
137
+ }
138
+ const hipOffset = round(-deepestCorrection * hipDropFactor);
139
+ const groundedFeet = solved.filter((leg) => leg.sample.grounded).length;
140
+ const averageTargetError = solved.length === 0
141
+ ? 0
142
+ : round(solved.reduce((sum, leg) => sum + leg.sample.targetError, 0) / solved.length);
143
+ return { feet: solved, hipOffset, groundedFeet, averageTargetError };
144
+ },
145
+ isLocked(side) {
146
+ return locks.get(side)?.locked ?? false;
147
+ },
148
+ reset() {
149
+ locks.clear();
150
+ }
151
+ };
152
+ }
153
+
154
+ interface SolveParams {
155
+ readonly ankleHeight: number;
156
+ readonly rayStartHeight: number;
157
+ readonly maxRayDistance: number;
158
+ readonly plantThreshold: number;
159
+ }
160
+
161
+ function solveLeg(leg: FootLegInput, lock: LegLockState, raycaster: GroundRaycaster, params: SolveParams): SolvedLeg {
162
+ const sourceAnkle = leg.ankle;
163
+ const ground = raycaster.raycastDown(
164
+ [sourceAnkle[0], sourceAnkle[1] + params.rayStartHeight, sourceAnkle[2]],
165
+ params.rayStartHeight + params.maxRayDistance
166
+ );
167
+
168
+ if (!ground) {
169
+ // No ground under the foot (airborne / off the edge): release the lock and pass the pose through.
170
+ lock.locked = false;
171
+ return {
172
+ side: leg.side,
173
+ sample: {
174
+ side: leg.side,
175
+ sourceFoot: sourceAnkle,
176
+ plantedFoot: sourceAnkle,
177
+ groundNormal: [0, 1, 0],
178
+ grounded: false,
179
+ verticalCorrection: 0,
180
+ targetError: 0
181
+ },
182
+ hip: leg.hip,
183
+ knee: leg.knee,
184
+ ankle: sourceAnkle,
185
+ locked: false,
186
+ reached: true
187
+ };
188
+ }
189
+
190
+ const groundTargetY = ground.point[1] + params.ankleHeight;
191
+ const heightAboveGround = sourceAnkle[1] - groundTargetY;
192
+ const inStance = heightAboveGround <= params.plantThreshold;
193
+
194
+ let target: Vec3;
195
+ if (inStance) {
196
+ if (!lock.locked) {
197
+ // Just entered stance: plant the foot at the ground point under it.
198
+ lock.locked = true;
199
+ lock.lockedPosition = [sourceAnkle[0], groundTargetY, sourceAnkle[2]];
200
+ }
201
+ // Hold the locked world position so the foot does not slide while it should be stationary.
202
+ target = lock.lockedPosition;
203
+ } else {
204
+ // Swing phase: release the lock; the foot follows above the ground under it.
205
+ lock.locked = false;
206
+ target = [sourceAnkle[0], Math.max(sourceAnkle[1], groundTargetY), sourceAnkle[2]];
207
+ }
208
+
209
+ // Blend IK in fully during stance, fade out across the swing so the leg returns to the clip pose.
210
+ const weight = inStance ? 1 : clamp(1 - heightAboveGround / 0.25, 0, 1);
211
+ const solved: TwoBoneIkResult = solveTwoBoneIk({
212
+ root: leg.hip,
213
+ mid: leg.knee,
214
+ end: leg.ankle,
215
+ target,
216
+ pole: leg.pole,
217
+ weight
218
+ });
219
+
220
+ const verticalCorrection = round(sourceAnkle[1] - target[1]);
221
+ const targetError = round(solved.endDistanceToTarget);
222
+ return {
223
+ side: leg.side,
224
+ sample: {
225
+ side: leg.side,
226
+ sourceFoot: roundVec(sourceAnkle),
227
+ plantedFoot: roundVec(target),
228
+ groundNormal: roundVec(ground.normal),
229
+ grounded: inStance,
230
+ verticalCorrection,
231
+ targetError
232
+ },
233
+ hip: leg.hip,
234
+ knee: roundVec(solved.mid),
235
+ ankle: roundVec(solved.end),
236
+ locked: lock.locked,
237
+ reached: solved.reached
238
+ };
239
+ }
240
+
241
+ function clamp(value: number, min: number, max: number): number {
242
+ return Math.max(min, Math.min(max, value));
243
+ }
244
+
245
+ function round(value: number): number {
246
+ return Number(value.toFixed(4));
247
+ }
248
+
249
+ function roundVec(value: Vec3): Vec3 {
250
+ return [round(value[0]), round(value[1]), round(value[2])];
251
+ }
@@ -0,0 +1,323 @@
1
+ import {
2
+ HUMANOID_BONES,
3
+ REQUIRED_HUMANOID_BONES,
4
+ type HumanoidBoneBinding,
5
+ type HumanoidBoneName,
6
+ type HumanoidRigDefinition
7
+ } from "./HumanoidRetargeting.js";
8
+
9
+ /**
10
+ * Options for {@link inferHumanoidRig}. All optional — sensible defaults match the
11
+ * conventions found across the free GLB/glTF catalog (Mixamo, Sketchfab/Blender, UE-ish).
12
+ */
13
+ export interface InferHumanoidRigOptions {
14
+ /** Id assigned to the produced {@link HumanoidRigDefinition}. Defaults to `"inferred-humanoid"`. */
15
+ readonly id?: string;
16
+ /** Human-readable rig name copied onto the definition. */
17
+ readonly name?: string;
18
+ /**
19
+ * Extra alias entries merged on top of the built-in table. Use this for one-off rigs whose
20
+ * node names don't follow any common convention. Keys are {@link HumanoidBoneName}s; values are
21
+ * additional name fragments (compared case-insensitively after normalization).
22
+ */
23
+ readonly extraAliases?: Partial<Record<HumanoidBoneName, readonly string[]>>;
24
+ /** Forwarded onto the resulting rig definition. */
25
+ readonly units?: HumanoidRigDefinition["units"];
26
+ /** Forwarded onto the resulting rig definition. */
27
+ readonly facingAxis?: HumanoidRigDefinition["facingAxis"];
28
+ /** Forwarded onto the resulting rig definition. */
29
+ readonly scale?: number;
30
+ }
31
+
32
+ /**
33
+ * Detail about a single resolved (or unresolved) humanoid slot — exposed via
34
+ * {@link HumanoidRigInference.matches} so callers can debug ambiguous skeletons.
35
+ */
36
+ export interface HumanoidBoneMatch {
37
+ readonly bone: HumanoidBoneName;
38
+ /** Resolved source node name, when one was found. */
39
+ readonly node?: string;
40
+ /** Score of the winning candidate (higher is a more confident match). */
41
+ readonly score: number;
42
+ /** Every node name that the alias table considered a plausible candidate for this slot. */
43
+ readonly candidates: readonly string[];
44
+ }
45
+
46
+ export interface HumanoidRigInference {
47
+ readonly rig: HumanoidRigDefinition;
48
+ readonly matches: readonly HumanoidBoneMatch[];
49
+ /** Required bones (see {@link REQUIRED_HUMANOID_BONES}) that could not be mapped. */
50
+ readonly missingRequired: readonly HumanoidBoneName[];
51
+ }
52
+
53
+ type Side = "left" | "right" | null;
54
+
55
+ /**
56
+ * Per-logical-group "core" tokens. These are matched against normalized node names. Side-bearing
57
+ * groups omit the side here — sidedness is detected separately (`L_`, `Left`, `_l`, `.R`, etc.) so
58
+ * a single table serves both sides. Keys are *logical groups* (`shoulder`, `upperArm`, …), not
59
+ * {@link HumanoidBoneName}s; the side-resolved slots are derived below in {@link SLOT_SPECS}.
60
+ */
61
+ const ALIAS_BY_KEY: Record<string, readonly string[]> = {
62
+ hips: ["hips", "hip", "pelvis", "root", "cog", "bip01pelvis"],
63
+ spine: ["spine", "spine0", "spine00", "spine1", "spine01", "abdomen", "lowerback", "torso"],
64
+ chest: ["chest", "spine2", "spine02", "spine3", "ribcage", "upperback"],
65
+ upperChest: ["upperchest", "spine3", "spine03", "spine4", "upperchestspine"],
66
+ neck: ["neck", "neck0", "neck01", "neck1"],
67
+ head: ["head"],
68
+ shoulder: ["shoulder", "clavicle", "collar", "scapula"],
69
+ upperArm: ["upperarm", "armupper", "arm", "uparm", "shoulderarm", "humerus", "armupper01"],
70
+ lowerArm: ["lowerarm", "armlower", "forearm", "loarm", "elbow", "ulna", "armlower01"],
71
+ hand: ["hand", "wrist", "palm"],
72
+ upperLeg: ["upperleg", "legupper", "thigh", "upleg", "legupper01", "femur"],
73
+ lowerLeg: ["lowerleg", "leglower", "calf", "shin", "leg", "loleg", "knee", "leglower01", "tibia"],
74
+ foot: ["foot", "ankle"],
75
+ toes: ["toe", "toes", "toebase", "ball"]
76
+ };
77
+
78
+ /** Logical (side-stripped) classification for each humanoid slot. */
79
+ interface SlotSpec {
80
+ readonly bone: HumanoidBoneName;
81
+ readonly aliasKey: string;
82
+ readonly side: Side;
83
+ /** Bones that are required for a usable rig (used to compute `missingRequired`). */
84
+ readonly required: boolean;
85
+ }
86
+
87
+ const REQUIRED_SET = new Set<HumanoidBoneName>(REQUIRED_HUMANOID_BONES);
88
+
89
+ const SLOT_SPECS: readonly SlotSpec[] = ([
90
+ { bone: "hips", aliasKey: "hips", side: null },
91
+ { bone: "spine", aliasKey: "spine", side: null },
92
+ { bone: "chest", aliasKey: "chest", side: null },
93
+ { bone: "upperChest", aliasKey: "upperChest", side: null },
94
+ { bone: "neck", aliasKey: "neck", side: null },
95
+ { bone: "head", aliasKey: "head", side: null },
96
+ { bone: "leftShoulder", aliasKey: "shoulder", side: "left" },
97
+ { bone: "leftUpperArm", aliasKey: "upperArm", side: "left" },
98
+ { bone: "leftLowerArm", aliasKey: "lowerArm", side: "left" },
99
+ { bone: "leftHand", aliasKey: "hand", side: "left" },
100
+ { bone: "rightShoulder", aliasKey: "shoulder", side: "right" },
101
+ { bone: "rightUpperArm", aliasKey: "upperArm", side: "right" },
102
+ { bone: "rightLowerArm", aliasKey: "lowerArm", side: "right" },
103
+ { bone: "rightHand", aliasKey: "hand", side: "right" },
104
+ { bone: "leftUpperLeg", aliasKey: "upperLeg", side: "left" },
105
+ { bone: "leftLowerLeg", aliasKey: "lowerLeg", side: "left" },
106
+ { bone: "leftFoot", aliasKey: "foot", side: "left" },
107
+ { bone: "leftToes", aliasKey: "toes", side: "left" },
108
+ { bone: "rightUpperLeg", aliasKey: "upperLeg", side: "right" },
109
+ { bone: "rightLowerLeg", aliasKey: "lowerLeg", side: "right" },
110
+ { bone: "rightFoot", aliasKey: "foot", side: "right" },
111
+ { bone: "rightToes", aliasKey: "toes", side: "right" }
112
+ ] as ReadonlyArray<{ bone: HumanoidBoneName; aliasKey: string; side: Side }>).map(
113
+ (spec): SlotSpec => ({ ...spec, required: REQUIRED_SET.has(spec.bone) })
114
+ );
115
+
116
+ interface NormalizedNode {
117
+ readonly raw: string;
118
+ /** Lowercased, namespace-stripped (`mixamorig:`), separator-collapsed. */
119
+ readonly canon: string;
120
+ /** Side detected from prefixes/suffixes/tokens. */
121
+ readonly side: Side;
122
+ }
123
+
124
+ const NAMESPACE_RE = /^(?:mixamorig\d*:|mixamorig\d*|bip\d*\s*|biped\s*|armature[|:_]?)/i;
125
+
126
+ function stripNamespace(name: string): string {
127
+ let out = name;
128
+ // Blender/glTF skinned hierarchies often prefix `Armature|` or `Armature/`.
129
+ out = out.replace(/^armature[|/]/i, "");
130
+ out = out.replace(NAMESPACE_RE, "");
131
+ return out;
132
+ }
133
+
134
+ /** Collapse to alphanumerics only, lowercased — `L_Arm_Upper.01` -> `larmupper01`. */
135
+ function canonicalize(name: string): string {
136
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");
137
+ }
138
+
139
+ function detectSide(raw: string): Side {
140
+ const lower = raw.toLowerCase();
141
+ // Explicit word boundaries first (left/right) — these are unambiguous.
142
+ if (/(^|[^a-z])left([^a-z]|$)|leftarm|leftleg|lefthand|leftfoot|leftshoulder|leftupleg/.test(lower)) return "left";
143
+ if (/(^|[^a-z])right([^a-z]|$)|rightarm|rightleg|righthand|rightfoot|rightshoulder|rightupleg/.test(lower)) return "right";
144
+ // Side prefixes: L_, R_, l., r. and suffixes: _l, _r, .l, .r, _L, name ends in L/R
145
+ if (/(^|[^a-z])l[_.\- ]/.test(lower) || /[_.\- ]l($|[^a-z])/.test(lower)) return "left";
146
+ if (/(^|[^a-z])r[_.\- ]/.test(lower) || /[_.\- ]r($|[^a-z])/.test(lower)) return "right";
147
+ // Leading single side letter directly glued to a part: "LShoulder", "RArm", "L_Leg".
148
+ if (/^l(?=[a-z0-9])/.test(lower) && !/^le(?!ft)/.test(lower) && !/^lo/.test(lower) && !/^la(?!rm)/.test(lower)) {
149
+ // crude: only treat leading "l" as side when followed by a known limb-ish token.
150
+ if (/^l(shoulder|arm|upperarm|lowerarm|forearm|hand|wrist|leg|upperleg|lowerleg|thigh|calf|shin|foot|ankle|toe|clav|coll)/.test(lower)) {
151
+ return "left";
152
+ }
153
+ }
154
+ if (/^r(?=[a-z0-9])/.test(lower)) {
155
+ if (/^r(shoulder|arm|upperarm|lowerarm|forearm|hand|wrist|leg|upperleg|lowerleg|thigh|calf|shin|foot|ankle|toe|clav|coll)/.test(lower)) {
156
+ return "right";
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+
162
+ function normalizeNode(raw: string): NormalizedNode {
163
+ const stripped = stripNamespace(raw);
164
+ return {
165
+ raw,
166
+ canon: canonicalize(stripped),
167
+ side: detectSide(stripped) ?? detectSide(raw)
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Score how well `node` fits `aliasKey`. Returns 0 for no match. Higher = better.
173
+ * The score rewards: exact-token equality, longer alias fragments (more specific), and earlier
174
+ * occurrence in the name. This lets `spine2`/`chest` win the chest slot over a generic `spine`.
175
+ */
176
+ function scoreCandidate(node: NormalizedNode, aliasKey: string, aliases: readonly string[]): number {
177
+ let best = 0;
178
+ for (const alias of aliases) {
179
+ const a = canonicalize(alias);
180
+ if (a.length === 0) continue;
181
+ const idx = node.canon.indexOf(a);
182
+ if (idx < 0) continue;
183
+ // Base: specificity by alias length.
184
+ let score = 10 + a.length * 2;
185
+ // Exact match (whole canon equals alias) is the strongest signal.
186
+ if (node.canon === a) score += 40;
187
+ // Side bones: a side-stripped exact-ish match where the remainder is only side tokens.
188
+ const remainder = node.canon.replace(a, "");
189
+ if (remainder.length <= 2) score += 15;
190
+ // Prefer matches that appear toward the end (the "leaf" semantic token) for limb parts,
191
+ // but prefer earlier for spine/hips where the root token leads.
192
+ score += Math.max(0, 6 - idx);
193
+ if (score > best) best = score;
194
+ }
195
+ // De-emphasize generic "spine" matching when the name clearly carries a chest/upperchest digit.
196
+ if (aliasKey === "spine" && /(spine[2-9]|spine0[2-9])/.test(node.canon)) {
197
+ best = Math.max(0, best - 12);
198
+ }
199
+ return best;
200
+ }
201
+
202
+ /**
203
+ * Infer a {@link HumanoidRigDefinition} from a flat list of skeleton node names.
204
+ *
205
+ * Strategy: each humanoid slot has a side-agnostic alias table plus an L/R/Left/Right side tag.
206
+ * For every slot we score all candidate nodes (filtered by detected side when the slot is sided),
207
+ * pick the highest-scoring one, and forbid reusing a node across slots. Conventions covered:
208
+ * Mixamo (`mixamorig:Hips`/`LeftArm`), Sketchfab/Blender (`Hip_00`, `Spine1..3`, `L_Shoulder`,
209
+ * `L_Arm_Upper_01`, `L_Leg_Upper_01`, `L_Hand`, `L_Foot`), and UE-ish (`pelvis`, `upperarm_l`,
210
+ * `thigh_r`, `calf_l`).
211
+ *
212
+ * Each produced {@link HumanoidBoneBinding} carries `name` (the resolved node) and `aliases`
213
+ * (the other plausible candidates for that slot), wiring the previously-unread `aliases` field.
214
+ */
215
+ export function inferHumanoidRigDetailed(
216
+ nodeNames: readonly string[],
217
+ options: InferHumanoidRigOptions = {}
218
+ ): HumanoidRigInference {
219
+ const nodes = nodeNames.map(normalizeNode);
220
+ const used = new Set<string>();
221
+ const bones: Partial<Record<HumanoidBoneName, HumanoidBoneBinding>> = {};
222
+ const matches: HumanoidBoneMatch[] = [];
223
+
224
+ // Process required + more-specific slots first so generic "spine"/"leg" don't steal nodes that a
225
+ // more specific slot (chest/upperChest/lowerLeg) wants. We sort by a static priority.
226
+ const ordered = [...SLOT_SPECS].sort((a, b) => slotPriority(a.bone) - slotPriority(b.bone));
227
+
228
+ for (const spec of ordered) {
229
+ const aliasList = mergeAliases(spec, options.extraAliases);
230
+ const candidates: { node: NormalizedNode; score: number }[] = [];
231
+
232
+ for (const node of nodes) {
233
+ if (used.has(node.raw)) continue;
234
+ if (spec.side && node.side && node.side !== spec.side) continue;
235
+ // For sided slots, skip nodes with no detectable side only if a sided alternative exists.
236
+ const score = scoreCandidate(node, spec.aliasKey, aliasList);
237
+ if (score <= 0) continue;
238
+ // Penalize sided slot matching a side-less node (ambiguous).
239
+ const adjusted = spec.side && !node.side ? score - 8 : score;
240
+ if (adjusted <= 0) continue;
241
+ candidates.push({ node, score: adjusted });
242
+ }
243
+
244
+ candidates.sort((a, b) => b.score - a.score);
245
+ const winner = candidates[0];
246
+ const candidateNames = candidates.map((c) => c.node.raw);
247
+
248
+ if (winner) {
249
+ used.add(winner.node.raw);
250
+ bones[spec.bone] = {
251
+ name: winner.node.raw,
252
+ aliases: candidateNames.slice(1)
253
+ };
254
+ matches.push({ bone: spec.bone, node: winner.node.raw, score: winner.score, candidates: candidateNames });
255
+ } else {
256
+ matches.push({ bone: spec.bone, score: 0, candidates: [] });
257
+ }
258
+ }
259
+
260
+ const missingRequired = REQUIRED_HUMANOID_BONES.filter((bone) => !bones[bone]);
261
+
262
+ const rig: HumanoidRigDefinition = {
263
+ id: options.id ?? "inferred-humanoid",
264
+ ...(options.name !== undefined ? { name: options.name } : {}),
265
+ bones,
266
+ ...(options.scale !== undefined ? { scale: options.scale } : {}),
267
+ ...(options.units !== undefined ? { units: options.units } : {}),
268
+ ...(options.facingAxis !== undefined ? { facingAxis: options.facingAxis } : {}),
269
+ metadata: { inferred: true, inferredFromNodeCount: nodeNames.length }
270
+ };
271
+
272
+ return { rig, matches, missingRequired };
273
+ }
274
+
275
+ /** Convenience wrapper returning just the rig definition. */
276
+ export function inferHumanoidRig(
277
+ nodeNames: readonly string[],
278
+ options: InferHumanoidRigOptions = {}
279
+ ): HumanoidRigDefinition {
280
+ return inferHumanoidRigDetailed(nodeNames, options).rig;
281
+ }
282
+
283
+ function mergeAliases(spec: SlotSpec, extra?: Partial<Record<HumanoidBoneName, readonly string[]>>): readonly string[] {
284
+ const base = ALIAS_BY_KEY[spec.aliasKey] ?? [];
285
+ const extraForBone = extra?.[spec.bone] ?? [];
286
+ return extraForBone.length > 0 ? [...extraForBone, ...base] : base;
287
+ }
288
+
289
+ /**
290
+ * Lower number = resolved earlier. More-specific slots resolve first so they claim their nodes
291
+ * before generic siblings (e.g. `chest` before `spine`, `lowerLeg` before `upperLeg` before the
292
+ * generic `leg` token shared by both).
293
+ */
294
+ function slotPriority(bone: HumanoidBoneName): number {
295
+ const table: Partial<Record<HumanoidBoneName, number>> = {
296
+ hips: 0,
297
+ head: 1,
298
+ upperChest: 2,
299
+ chest: 3,
300
+ neck: 4,
301
+ leftHand: 5,
302
+ rightHand: 5,
303
+ leftFoot: 6,
304
+ rightFoot: 6,
305
+ leftToes: 7,
306
+ rightToes: 7,
307
+ leftShoulder: 8,
308
+ rightShoulder: 8,
309
+ leftLowerArm: 9,
310
+ rightLowerArm: 9,
311
+ leftUpperArm: 10,
312
+ rightUpperArm: 10,
313
+ leftLowerLeg: 11,
314
+ rightLowerLeg: 11,
315
+ leftUpperLeg: 12,
316
+ rightUpperLeg: 12,
317
+ spine: 13
318
+ };
319
+ return table[bone] ?? 20;
320
+ }
321
+
322
+ /** Re-exported for callers wanting to enumerate which slots inference targets. */
323
+ export const INFERABLE_HUMANOID_BONES: readonly HumanoidBoneName[] = HUMANOID_BONES;