lecodes-assets 0.2.0 → 2.4.10

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 (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,545 @@
1
+ // A rig — the minimum both sides of a retarget need: a bone hierarchy, its rest pose, and a way to
2
+ // sample an animated pose at a time. Built from either an FBX scene (the clip donor) or a glTF
3
+ // Document (the model being animated), so the retargeter never sees the difference.
4
+ //
5
+ // Rotations are carried as quaternions composed up the chain rather than extracted from world
6
+ // matrices. That is only valid while every scale in the chain is uniform — `checkUniformScale`
7
+ // enforces it, because a non-uniform scale makes "the world rotation of this bone" meaningless.
8
+ //
9
+ // The pose a rig is MEASURED AGAINST is its BIND pose (inverse of the skin's inverse-bind matrices),
10
+ // not the TRS sitting on its nodes. Those two are the same in a clean export, but an FBX carrying
11
+ // baked clips routinely stores whatever pose the exporter last evaluated — the polyperfect wolf's
12
+ // node pose is 0.37 m away from its bind pose on a 1.5 m animal. Measuring a retarget delta from
13
+ // that is measuring from nothing, so `bind` is what the retargeter anchors on and node TRS is only
14
+ // the fallback for bones no skin binds (leaf tips, helpers).
15
+ import type { Document } from '@gltf-transform/core'
16
+ import { readFbx, f32 } from '../fbx/read'
17
+ import { readFileSync } from 'node:fs'
18
+
19
+ export type Quat = [number, number, number, number] // x, y, z, w
20
+ export type Vec3 = [number, number, number]
21
+
22
+ export const qmul = (a: Quat, b: Quat): Quat => [
23
+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
24
+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
25
+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
26
+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
27
+ ]
28
+ export const qinv = (q: Quat): Quat => [ -q[0], -q[1], -q[2], q[3] ] // unit quaternions only
29
+ const qconj = qinv
30
+ export const qnorm = (q: Quat): Quat => {
31
+ const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1
32
+ return [ q[0] / l, q[1] / l, q[2] / l, q[3] / l ]
33
+ }
34
+ export const qslerp = (a: Quat, b: Quat, t: number): Quat => {
35
+ let d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]
36
+ let e = b
37
+ if (d < 0) { d = -d; e = [ -b[0], -b[1], -b[2], -b[3] ] }
38
+ if (d > 0.9995) return qnorm([ a[0] + (e[0] - a[0]) * t, a[1] + (e[1] - a[1]) * t, a[2] + (e[2] - a[2]) * t, a[3] + (e[3] - a[3]) * t ])
39
+ const th = Math.acos(d), s = Math.sin(th)
40
+ const wa = Math.sin((1 - t) * th) / s, wb = Math.sin(t * th) / s
41
+ return [ a[0] * wa + e[0] * wb, a[1] * wa + e[1] * wb, a[2] * wa + e[2] * wb, a[3] * wa + e[3] * wb ]
42
+ }
43
+ /** Rotate a vector by a quaternion. */
44
+ const sub = (a: Vec3, b: Vec3): Vec3 => [ a[0] - b[0], a[1] - b[1], a[2] - b[2] ]
45
+ const add = (a: Vec3, b: Vec3): Vec3 => [ a[0] + b[0], a[1] + b[1], a[2] + b[2] ]
46
+ const scl = (a: Vec3, k: number): Vec3 => [ a[0] * k, a[1] * k, a[2] * k ]
47
+ const len = (v: Vec3): number => Math.hypot(v[0], v[1], v[2])
48
+ export const qrot = (q: Quat, v: Vec3): Vec3 => {
49
+ const [ x, y, z, w ] = q
50
+ const tx = 2 * (y * v[2] - z * v[1]), ty = 2 * (z * v[0] - x * v[2]), tz = 2 * (x * v[1] - y * v[0])
51
+ return [
52
+ v[0] + w * tx + y * tz - z * ty,
53
+ v[1] + w * ty + z * tx - x * tz,
54
+ v[2] + w * tz + x * ty - y * tx,
55
+ ]
56
+ }
57
+
58
+ export interface Pose { t: Vec3[]; r: Quat[]; s: Vec3[] }
59
+ export interface Clip { name: string; duration: number; sample: (time: number) => Pose }
60
+
61
+ /** The pose a retarget measures against: bind where a skin defines it, node rest elsewhere. */
62
+ export interface BindPose {
63
+ worldRot: Quat[]; worldPos: Vec3[]; fromSkin: boolean[]
64
+ /** Uniform factor baked into the bind positions to bring the skin's bind space to node-world units (1 = none). */
65
+ scale: number
66
+ /** The same pose in local space — the base a clip is sampled onto, so bones a clip does not
67
+ * animate fall back to BIND rather than to whatever pose the exporter left on the nodes. */
68
+ local: Pose
69
+ }
70
+
71
+ export interface Rig {
72
+ label: string
73
+ names: string[]
74
+ parents: number[]
75
+ /** Rest pose in local space, and the world rotation / position it produces. */
76
+ rest: Pose
77
+ restWorldRot: Quat[]
78
+ restWorldPos: Vec3[]
79
+ bind: BindPose
80
+ order: number[] // topological: every parent before its children
81
+ index: Map<string, number>
82
+ clips: Clip[]
83
+ }
84
+
85
+ const buildOrder = (parents: number[]): number[] => {
86
+ const order: number[] = []
87
+ const done = new Set<number>()
88
+ const visit = (i: number): void => {
89
+ if (done.has(i)) return
90
+ if (parents[i] >= 0) visit(parents[i])
91
+ done.add(i); order.push(i)
92
+ }
93
+ parents.forEach((_, i) => visit(i))
94
+ return order
95
+ }
96
+
97
+ /** World rotation/position for a whole pose, honouring a uniform scale in the chain. */
98
+ export const worldOf = (rig: Rig, pose: Pose): { rot: Quat[]; pos: Vec3[]; scale: number[] } => {
99
+ const rot: Quat[] = new Array(rig.names.length)
100
+ const pos: Vec3[] = new Array(rig.names.length)
101
+ const scale: number[] = new Array(rig.names.length)
102
+ for (const i of rig.order) {
103
+ const p = rig.parents[i]
104
+ if (p < 0) { rot[i] = qnorm(pose.r[i]); pos[i] = pose.t[i]; scale[i] = pose.s[i][0]; continue }
105
+ rot[i] = qnorm(qmul(rot[p], pose.r[i]))
106
+ const off = qrot(rot[p], [ pose.t[i][0] * scale[p], pose.t[i][1] * scale[p], pose.t[i][2] * scale[p] ])
107
+ pos[i] = [ pos[p][0] + off[0], pos[p][1] + off[1], pos[p][2] + off[2] ]
108
+ scale[i] = scale[p] * pose.s[i][0]
109
+ }
110
+ return { rot, pos, scale }
111
+ }
112
+
113
+ export type Mat4 = number[] // column-major
114
+
115
+ const matMul = (a: Mat4, b: Mat4): Mat4 => {
116
+ const o = new Array(16).fill(0)
117
+ for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
118
+ return o
119
+ }
120
+ const matCompose = (t: Vec3, q: Quat, s: Vec3): Mat4 => {
121
+ const [ x, y, z, w ] = q, x2 = x + x, y2 = y + y, z2 = z + z
122
+ const xx = x * x2, xy = x * y2, xz = x * z2, yy = y * y2, yz = y * z2, zz = z * z2, wx = w * x2, wy = w * y2, wz = w * z2
123
+ return [ (1 - (yy + zz)) * s[0], (xy + wz) * s[0], (xz - wy) * s[0], 0,
124
+ (xy - wz) * s[1], (1 - (xx + zz)) * s[1], (yz + wx) * s[1], 0,
125
+ (xz + wy) * s[2], (yz - wx) * s[2], (1 - (xx + yy)) * s[2], 0, t[0], t[1], t[2], 1 ]
126
+ }
127
+ /** Inverse of a rigid matrix with a uniform scale — all an IBM ever is. */
128
+ const matInvRigid = (m: Mat4): Mat4 => {
129
+ const s2 = m[0] * m[0] + m[1] * m[1] + m[2] * m[2] || 1
130
+ const r: Mat4 = [ m[0] / s2, m[4] / s2, m[8] / s2, 0, m[1] / s2, m[5] / s2, m[9] / s2, 0, m[2] / s2, m[6] / s2, m[10] / s2, 0, 0, 0, 0, 1 ]
131
+ r[12] = -(r[0] * m[12] + r[4] * m[13] + r[8] * m[14])
132
+ r[13] = -(r[1] * m[12] + r[5] * m[13] + r[9] * m[14])
133
+ r[14] = -(r[2] * m[12] + r[6] * m[13] + r[10] * m[14])
134
+ return r
135
+ }
136
+ /** Rotation of a matrix as a quaternion, scale divided out first. */
137
+ const matToQuat = (m: Mat4): Quat => {
138
+ const sx = Math.hypot(m[0], m[1], m[2]) || 1, sy = Math.hypot(m[4], m[5], m[6]) || 1, sz = Math.hypot(m[8], m[9], m[10]) || 1
139
+ const a = m[0] / sx, b = m[1] / sx, c = m[2] / sx
140
+ const d = m[4] / sy, e = m[5] / sy, f = m[6] / sy
141
+ const g = m[8] / sz, h = m[9] / sz, i = m[10] / sz
142
+ const tr = a + e + i
143
+ if (tr > 0) { const k = Math.sqrt(tr + 1) * 2; return qnorm([ (f - h) / k, (g - c) / k, (b - d) / k, k / 4 ]) }
144
+ if (a > e && a > i) { const k = Math.sqrt(1 + a - e - i) * 2; return qnorm([ k / 4, (d + b) / k, (g + c) / k, (f - h) / k ]) }
145
+ if (e > i) { const k = Math.sqrt(1 + e - a - i) * 2; return qnorm([ (d + b) / k, k / 4, (h + f) / k, (g - c) / k ]) }
146
+ const k = Math.sqrt(1 + i - a - e) * 2
147
+ return qnorm([ (g + c) / k, (h + f) / k, k / 4, (b - d) / k ])
148
+ }
149
+
150
+ /**
151
+ * Bind pose from inverse-bind matrices, filled in down the hierarchy for bones no skin binds.
152
+ * `ibmByNode` maps a node index to its 16 IBM floats.
153
+ *
154
+ * IBMs are authored in the MESH's space, which is not always the node-world space: a Maya "actor"
155
+ * export keeps the skeleton in metres while the mesh (and so every IBM) sits in a 100× smaller
156
+ * space with a ×100 mesh-node scale that glTF ignores for skinned meshes. The bind pose then lands
157
+ * 100× too small next to the rest pose. Bone LENGTHS are invariant under whatever junk rotations an
158
+ * exporter left on the nodes, so the ratio of total rest bone length to total bind bone length is
159
+ * a robust measure of that scale; it is baked into the bind positions (`scale` reports it).
160
+ */
161
+ const buildBind = (parents: number[], order: number[], rest: Pose, ibmByNode: Map<number, number[]>, spaceByNode: Map<number, Mat4>): BindPose => {
162
+ const world: Mat4[] = new Array(parents.length)
163
+ const fromSkin: boolean[] = new Array(parents.length).fill(false)
164
+ // Rest world matrices, for the mesh-space fold below and the fill-in of unskinned nodes.
165
+ const restWorld: Mat4[] = new Array(parents.length)
166
+ for (const i of order) {
167
+ const local = matCompose(rest.t[i], rest.r[i], rest.s[i])
168
+ restWorld[i] = parents[i] >= 0 ? matMul(restWorld[parents[i]], local) : local
169
+ }
170
+ // inv(IBM) is the joint in the MESH's geometry space. That space is the world only when the node
171
+ // carrying the mesh sits at identity; an exporter that parks the Z-up→Y-up turn on a geometry
172
+ // helper node (Female Runner's `Humanoid_F`: helper −90°X, joints Y-up, IBMs Z-up) or a ×100 scale
173
+ // on the mesh node (Maya "actor" exports) leaves the raw bind turned/shrunk against the joints.
174
+ // Folding the mesh node's world transform in front is exact for those; it is kept only when it
175
+ // brings the bind CLOSER to the node rest pose (a rig whose mesh node carries an unrelated
176
+ // transform — glTF ignores it for skinned meshes — must not be turned by it).
177
+ let rawDist = 0, foldedDist = 0
178
+ const raw: Mat4[] = new Array(parents.length), folded: Mat4[] = new Array(parents.length)
179
+ for (const i of order) {
180
+ const ibm = ibmByNode.get(i)
181
+ if (!ibm) continue
182
+ raw[i] = matInvRigid(ibm)
183
+ const space = spaceByNode.get(i)
184
+ folded[i] = space ? matMul(space, raw[i]) : raw[i]
185
+ fromSkin[i] = true
186
+ rawDist += Math.hypot(raw[i][12] - restWorld[i][12], raw[i][13] - restWorld[i][13], raw[i][14] - restWorld[i][14])
187
+ foldedDist += Math.hypot(folded[i][12] - restWorld[i][12], folded[i][13] - restWorld[i][13], folded[i][14] - restWorld[i][14])
188
+ }
189
+ const useFolded = foldedDist <= rawDist
190
+ for (const i of order) if (fromSkin[i]) world[i] = useFolded ? folded[i] : raw[i]
191
+ // Accumulated (uniform) scale, taken from the rest pose — scale is not part of the bind problem.
192
+ const accum: number[] = new Array(parents.length).fill(1)
193
+ for (const i of order) accum[i] = (parents[i] >= 0 ? accum[parents[i]] : 1) * rest.s[i][0]
194
+ let restLen = 0, bindLen = 0
195
+ for (const i of order) {
196
+ const p = parents[i]
197
+ if (p < 0 || !fromSkin[i] || !fromSkin[p]) continue
198
+ restLen += Math.hypot(...rest.t[i]) * accum[p]
199
+ bindLen += Math.hypot(world[i][12] - world[p][12], world[i][13] - world[p][13], world[i][14] - world[p][14])
200
+ }
201
+ const scale = restLen > 0 && bindLen > 0 ? restLen / bindLen : 1
202
+ if (Math.abs(scale - 1) > 0.01) for (const i of order) if (fromSkin[i]) { world[i][12] *= scale; world[i][13] *= scale; world[i][14] *= scale }
203
+ for (const i of order) {
204
+ if (fromSkin[i]) continue
205
+ const local = matCompose(rest.t[i], rest.r[i], rest.s[i])
206
+ world[i] = parents[i] >= 0 ? matMul(world[parents[i]], local) : local
207
+ }
208
+ const worldRot = world.map(matToQuat)
209
+ const worldPos = world.map((m) => [ m[12], m[13], m[14] ] as Vec3)
210
+ const local: Pose = { t: [], r: [], s: rest.s.map((v) => [ ...v ] as Vec3) }
211
+ for (const i of order) {
212
+ const p = parents[i]
213
+ if (p < 0) { local.t[i] = [ ...worldPos[i] ] as Vec3; local.r[i] = worldRot[i]; continue }
214
+ const off: Vec3 = [ worldPos[i][0] - worldPos[p][0], worldPos[i][1] - worldPos[p][1], worldPos[i][2] - worldPos[p][2] ]
215
+ const rel = qrot(qconj(worldRot[p]), off)
216
+ const k = accum[p] || 1
217
+ local.t[i] = [ rel[0] / k, rel[1] / k, rel[2] / k ]
218
+ local.r[i] = qnorm(qmul(qconj(worldRot[p]), worldRot[i]))
219
+ }
220
+ return { worldRot, worldPos, fromSkin, local, scale: Math.abs(scale - 1) > 0.01 ? scale : 1 }
221
+ }
222
+
223
+ /** Rest world matrix of one node (parents composed), for the mesh-space fold in `buildBind`. */
224
+ const restWorldOf = (parents: number[], rest: Pose, node: number): Mat4 => {
225
+ const chain: number[] = []
226
+ for (let a = node; a >= 0; a = parents[a]) chain.unshift(a)
227
+ let m: Mat4 | null = null
228
+ for (const i of chain) { const local = matCompose(rest.t[i], rest.r[i], rest.s[i]); m = m ? matMul(m, local) : local }
229
+ return m!
230
+ }
231
+
232
+ /**
233
+ * Assemble a Rig: topological order, the bind pose from inverse-bind matrices (`ibmByNode` — the
234
+ * bones a skin binds; the rest are filled in down the hierarchy), and the clips, which are built
235
+ * from the BIND local pose so a bone no clip animates falls back to bind rather than to whatever
236
+ * the exporter left on the node. The one constructor — FBX, glTF and Unreal rigs all come out of it.
237
+ */
238
+ export const makeRig = (label: string, names: string[], parents: number[], rest: Pose, makeClips: (base: Pose) => Clip[], ibmByNode: Map<number, number[]>, spaceByNode: Map<number, Mat4> = new Map()): Rig => {
239
+ const order = buildOrder(parents)
240
+ const bind = buildBind(parents, order, rest, ibmByNode, spaceByNode)
241
+ const rig: Rig = {
242
+ label, names, parents, rest,
243
+ restWorldRot: [], restWorldPos: [],
244
+ bind,
245
+ order,
246
+ index: new Map(names.map((n, i) => [ n, i ])),
247
+ clips: makeClips(bind.local),
248
+ }
249
+ const w = worldOf(rig, rest)
250
+ rig.restWorldRot = w.rot
251
+ rig.restWorldPos = w.pos
252
+ return rig
253
+ }
254
+
255
+ /** How far a rig's bind pose sits from the TRS on its nodes — large means the node pose is junk. */
256
+ export const bindVsRest = (rig: Rig): { worst: number; bone: string; skinned: number } => {
257
+ let worst = 0, bone = ''
258
+ for (let i = 0; i < rig.names.length; i++) {
259
+ if (!rig.bind.fromSkin[i]) continue
260
+ const a = rig.bind.worldPos[i], b = rig.restWorldPos[i]
261
+ const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2])
262
+ if (d > worst) { worst = d; bone = rig.names[i] }
263
+ }
264
+ return { worst, bone, skinned: rig.bind.fromSkin.filter(Boolean).length }
265
+ }
266
+
267
+ /** Throws on a non-uniform scale — the quaternion chain above would silently lie. */
268
+ export const checkUniformScale = (rig: Rig): void => {
269
+ for (let i = 0; i < rig.names.length; i++) {
270
+ const [ a, b, c ] = rig.rest.s[i]
271
+ // RELATIVE: a Blender 0.01 armature under a 100× export leaves [99.99986, 99.99999, 100.00001] — float
272
+ // noise, not a squash (an absolute 1e-4 refused every rig-studio cloak bone)
273
+ const tol = 1e-5 * Math.max(1, Math.abs(a), Math.abs(b), Math.abs(c))
274
+ if (Math.abs(a - b) > tol || Math.abs(b - c) > tol) {
275
+ throw new Error(`${rig.label}: bone "${rig.names[i]}" has a non-uniform rest scale [${rig.rest.s[i]}] — retargeting needs uniform scales`)
276
+ }
277
+ }
278
+ }
279
+
280
+ // ---- FBX (the clip donor) -----------------------------------------------------------------------
281
+
282
+ /** Sample one baked FBX track (times/values in the arena) at `time`. */
283
+ const sampleTrack = (times: Float32Array, values: Float32Array, n: number, time: number, step = false): number[] => {
284
+ const last = times.length - 1
285
+ const at = (i: number): number[] => Array.from(values.subarray(i * n, i * n + n))
286
+ if (time <= times[0]) return at(0)
287
+ if (time >= times[last]) return at(last)
288
+ let i = 0
289
+ while (i < last && times[i + 1] < time) i++
290
+ if (step) return at(i)
291
+ const u = (time - times[i]) / (times[i + 1] - times[i] || 1)
292
+ const a = at(i), b = at(i + 1)
293
+ if (n === 4) return qslerp(a as Quat, b as Quat, u)
294
+ return a.map((v, k) => v + (b[k] - v) * u)
295
+ }
296
+
297
+ export const rigFromFbx = async (path: string, bytes: Uint8Array): Promise<Rig> => {
298
+ const scene = await readFbx(bytes)
299
+ const names = scene.nodes.map((n) => n.name)
300
+ const parents = scene.nodes.map((n) => n.parent)
301
+ const rest: Pose = {
302
+ t: scene.nodes.map((n) => [ ...n.t ] as Vec3),
303
+ r: scene.nodes.map((n) => [ ...n.r ] as Quat),
304
+ s: scene.nodes.map((n) => [ ...n.s ] as Vec3),
305
+ }
306
+ const ibmByNode = new Map<number, number[]>()
307
+ const spaceByNode = new Map<number, Mat4>()
308
+ scene.skins.forEach((skin, si) => {
309
+ const ibm = f32(scene, skin.ibm, skin.joints.length * 16)
310
+ // The node carrying this skin's mesh (ufbx parks a geometry transform on a helper child, which
311
+ // is then the node that has the mesh): its world is the IBMs' space.
312
+ const meshNode = scene.nodes.findIndex((n) => n.skin === si)
313
+ const space = meshNode >= 0 ? restWorldOf(parents, rest, meshNode) : null
314
+ skin.joints.forEach((node, j) => {
315
+ if (ibmByNode.has(node)) return
316
+ ibmByNode.set(node, Array.from(ibm.subarray(j * 16, j * 16 + 16)))
317
+ if (space) spaceByNode.set(node, space)
318
+ })
319
+ })
320
+ const makeClips = (base: Pose): Clip[] => scene.animations.filter((a) => a.tracks.length > 0).map((anim) => {
321
+ // Pre-resolve the arena views once; sample() is called per frame per clip.
322
+ const tracks = anim.tracks.map((tr) => {
323
+ const n = tr.path === 'rotation' ? 4 : 3
324
+ return { node: tr.node, path: tr.path, n, times: f32(scene, tr.times, tr.count), values: f32(scene, tr.values, tr.count * n) }
325
+ })
326
+ return {
327
+ name: anim.name,
328
+ duration: anim.duration,
329
+ sample: (time: number): Pose => {
330
+ const pose: Pose = { t: base.t.map((v) => [ ...v ] as Vec3), r: base.r.map((v) => [ ...v ] as Quat), s: base.s.map((v) => [ ...v ] as Vec3) }
331
+ for (const tr of tracks) {
332
+ const v = sampleTrack(tr.times, tr.values, tr.n, time)
333
+ if (tr.path === 'translation') pose.t[tr.node] = v as Vec3
334
+ else if (tr.path === 'rotation') pose.r[tr.node] = qnorm(v as Quat)
335
+ else pose.s[tr.node] = v as Vec3
336
+ }
337
+ return pose
338
+ },
339
+ }
340
+ })
341
+ return makeRig(path, names, parents, rest, makeClips, ibmByNode, spaceByNode)
342
+ }
343
+
344
+ // ---- glTF Document (the model being animated) ---------------------------------------------------
345
+
346
+ export interface DocRig extends Rig { doc: Document; nodes: any[] }
347
+
348
+ /** Clips from a glTF Document's animations, sampled the same way the FBX side is. */
349
+ const clipsFromDocument = (doc: Document, nodes: any[], base: Pose): Clip[] => {
350
+ const idx = new Map(nodes.map((n, i) => [ n, i ]))
351
+ return doc.getRoot().listAnimations().map((anim) => {
352
+ const chans = anim.listChannels().map((ch) => {
353
+ const s = ch.getSampler()!
354
+ return {
355
+ node: idx.get(ch.getTargetNode()!)!,
356
+ path: ch.getTargetPath() as string,
357
+ step: s.getInterpolation() === 'STEP',
358
+ times: s.getInput()!.getArray() as Float32Array,
359
+ values: s.getOutput()!.getArray() as Float32Array,
360
+ }
361
+ }).filter((c) => c.node !== undefined)
362
+ let duration = 0
363
+ for (const c of chans) duration = Math.max(duration, c.times[c.times.length - 1] ?? 0)
364
+ return {
365
+ name: anim.getName(),
366
+ duration,
367
+ sample: (time: number): Pose => {
368
+ const pose: Pose = { t: base.t.map((v) => [ ...v ] as Vec3), r: base.r.map((v) => [ ...v ] as Quat), s: base.s.map((v) => [ ...v ] as Vec3) }
369
+ for (const c of chans) {
370
+ const n = c.path === 'rotation' ? 4 : 3
371
+ const v = sampleTrack(c.times, c.values as Float32Array, n, time, c.step)
372
+ if (c.path === 'translation') pose.t[c.node] = v as Vec3
373
+ else if (c.path === 'rotation') pose.r[c.node] = qnorm(v as Quat)
374
+ else if (c.path === 'scale') pose.s[c.node] = v as Vec3
375
+ }
376
+ return pose
377
+ },
378
+ }
379
+ })
380
+ }
381
+
382
+ export const rigFromDocument = (doc: Document, label: string): DocRig => {
383
+ const nodes = doc.getRoot().listNodes()
384
+ const idx = new Map(nodes.map((n, i) => [ n, i ]))
385
+ const names = nodes.map((n) => n.getName())
386
+ const parents = nodes.map((n) => { const p = n.getParentNode(); return p ? idx.get(p)! : -1 })
387
+ const rest: Pose = {
388
+ t: nodes.map((n) => [ ...n.getTranslation() ] as Vec3),
389
+ r: nodes.map((n) => [ ...n.getRotation() ] as Quat),
390
+ s: nodes.map((n) => [ ...n.getScale() ] as Vec3),
391
+ }
392
+ const ibmByNode = new Map<number, number[]>()
393
+ const spaceByNode = new Map<number, Mat4>()
394
+ for (const skin of doc.getRoot().listSkins()) {
395
+ const acc = skin.getInverseBindMatrices()
396
+ if (!acc) continue
397
+ // A converted FBX keeps the mesh on the (helper) node whose world is the IBMs' space; glTF
398
+ // ignores that transform for skinning, so it survives as the record of where the bind lives.
399
+ const meshNode = nodes.findIndex((n) => n.getSkin() === skin)
400
+ const space = meshNode >= 0 ? restWorldOf(parents, rest, meshNode) : null
401
+ skin.listJoints().forEach((joint, j) => {
402
+ const i = idx.get(joint)
403
+ if (i === undefined || ibmByNode.has(i)) return
404
+ ibmByNode.set(i, acc.getElement(j, []))
405
+ if (space) spaceByNode.set(i, space)
406
+ })
407
+ }
408
+ return { ...makeRig(label, names, parents, rest, (base) => clipsFromDocument(doc, nodes, base), ibmByNode, spaceByNode), doc, nodes }
409
+ }
410
+
411
+ export const loadRig = async (path: string): Promise<Rig> => rigFromFbx(path, new Uint8Array(readFileSync(path)))
412
+
413
+ /** A skeleton with a skin somewhere — the kind the classifiers can read. */
414
+ export const hasSkin = (rig: Rig): boolean => rig.bind.fromSkin.some(Boolean)
415
+
416
+ const shortBoneName = (n: string): string => n.slice(n.search(/[^:|]*$/))
417
+
418
+ /**
419
+ * Is `source` (a clip file, a model) the same skeleton as `rig` — the kind a clip can be copied
420
+ * onto bone by bone? Names must match (≥ 80 %), and the matched bones' LOCAL OFFSETS (the child in
421
+ * its parent's frame) must point the same way: that vector is fixed by the rig and survives any
422
+ * pose the exporter baked into the nodes, while local rotations do not (a mocap pack's clip files
423
+ * carry their first frame as the rest pose). Bones under 5 cm (fingers, eyes) are ignored. A rig
424
+ * of the same FAMILY (another Mixamo character: same joint frames, other proportions) stays within
425
+ * ~20°; a rig that merely shares the naming convention (two Unreal-style rigs) turns some joint —
426
+ * typically the root's — by 90°, and copying its clips would twist the character.
427
+ */
428
+ export const sameSkeleton = (rig: Rig, source: Rig): { same: boolean; share: number; worstDeg: number; worstBone: string } => {
429
+ const short = new Map<string, number>()
430
+ rig.names.forEach((n, i) => { const s = shortBoneName(n); if (!short.has(s)) short.set(s, i) })
431
+ const map = source.names.map((n) => rig.index.get(n) ?? short.get(shortBoneName(n)) ?? -1)
432
+ const share = map.filter((j) => j >= 0).length / Math.max(1, source.names.length)
433
+ let worstDeg = 0, worstBone = ''
434
+ source.names.forEach((_, i) => {
435
+ const j = map[i]
436
+ if (j < 0) return
437
+ const ps = source.parents[i], pt = rig.parents[j]
438
+ if (ps < 0 || pt < 0 || map[ps] !== pt) return
439
+ const a = rig.rest.t[j], b = source.rest.t[i]
440
+ const la = Math.hypot(...a), lb = Math.hypot(...b)
441
+ if (la < 0.05 || lb < 0.05) return
442
+ const deg = Math.acos(Math.max(-1, Math.min(1, (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) / (la * lb)))) * 180 / Math.PI
443
+ if (deg > worstDeg) { worstDeg = deg; worstBone = source.names[i] }
444
+ })
445
+ return { same: share >= 0.8 && worstDeg < 45, share, worstDeg, worstBone }
446
+ }
447
+
448
+ /**
449
+ * Clips of a SKELETON-ONLY file (a mocap pack's clip FBX: nodes and curves, no mesh, no skin) bound
450
+ * to the rig they were animated on, by bone name — exact first, then the name without its
451
+ * `namespace:` prefix. The result is `rig` with those clips: each sample starts from the rig's
452
+ * bind and takes the clip's LOCAL ROTATION on every bone it names, so the rig's own bind pose,
453
+ * skin and classification carry the motion. Bone OFFSETS stay the rig's — a clip file's skeleton
454
+ * is not always the model's (Mixamo's skin-less downloads are ~9 % larger than the character they
455
+ * were made for, with the thighs 22° elsewhere; the legs would come out longer than the bind and
456
+ * every straight leg out of reach). The translations a clip ANIMATES (the hips' travel and bob,
457
+ * a root's travel) are taken from the clip scaled by `scale`: 1 when the two skeletons have the
458
+ * same bone lengths (a pack's own clip files — exact), else the factor that puts the lowest joint
459
+ * of the rebound rig over the clips on the rig's own ground (a locomotion clip touches the ground
460
+ * somewhere; no bone-length ratio says how high the hips ride when the proportions differ).
461
+ * Bones the clip does not name stay in bind; bones the clip names and the rig lacks are reported.
462
+ */
463
+ export const rebindClips = (rig: Rig, source: Rig): { rig: Rig; bound: number; total: number; unbound: string[]; scale: number } => {
464
+ const short = new Map<string, number>()
465
+ rig.names.forEach((n, i) => { const s = shortBoneName(n); if (!short.has(s)) short.set(s, i) })
466
+ const map = source.names.map((n) => rig.index.get(n) ?? short.get(shortBoneName(n)) ?? -1)
467
+ const unbound = source.names.filter((_, i) => map[i] < 0)
468
+ const bound = map.filter((j) => j >= 0).length
469
+ const base = rig.bind.local, srcRest = source.rest
470
+ // Which translations the clips animate: a bone whose translation leaves its rest anywhere in a clip.
471
+ const animated = source.names.map(() => false)
472
+ for (const c of source.clips) for (let k = 0; k <= 8; k++) {
473
+ const p = c.sample((k / 8) * c.duration)
474
+ p.t.forEach((t, i) => { if (!animated[i] && len(sub(t, srcRest.t[i])) > 1e-6) animated[i] = true })
475
+ }
476
+ const sampleAt = (c: Clip, time: number, scale: number): Pose => {
477
+ const p = c.sample(time)
478
+ const out: Pose = { t: base.t.map((v) => [ ...v ] as Vec3), r: base.r.map((q) => [ ...q ] as Quat), s: base.s.map((v) => [ ...v ] as Vec3) }
479
+ map.forEach((j, i) => {
480
+ if (j < 0) return
481
+ out.r[j] = p.r[i]
482
+ if (animated[i]) out.t[j] = scl(p.t[i], scale)
483
+ })
484
+ return out
485
+ }
486
+ // Same skeleton (every still matched offset within 1 % of its length) → the clip's translations
487
+ // are the rig's, exactly. Otherwise solve for the scale that grounds the rebound rig.
488
+ let sameLengths = true, num = 0, den = 0
489
+ map.forEach((j, i) => {
490
+ if (j < 0 || animated[i] || source.parents[i] < 0 || rig.parents[j] < 0) return
491
+ const a = len(base.t[j]), b = len(srcRest.t[i])
492
+ num += a; den += b
493
+ if (Math.abs(a - b) > 0.01 * Math.max(a, b, 0.01)) sameLengths = false
494
+ })
495
+ let scale = 1
496
+ if (!sameLengths && source.clips.length) {
497
+ const skinned = rig.bind.fromSkin.some(Boolean) ? rig.bind.worldPos.filter((_, i) => rig.bind.fromSkin[i]) : rig.bind.worldPos
498
+ const ground = Math.min(...skinned.map((p) => p[1]))
499
+ const lowest = (s: number): number => {
500
+ let lo = Infinity
501
+ for (const c of source.clips) for (let k = 0; k < 16; k++) {
502
+ const w = worldOf(rig, sampleAt(c, (k / 15) * c.duration, s))
503
+ for (let i = 0; i < w.pos.length; i++) if (rig.bind.fromSkin[i] || !rig.bind.fromSkin.some(Boolean)) lo = Math.min(lo, w.pos[i][1])
504
+ }
505
+ return lo
506
+ }
507
+ let a = 0.5, b = 2, fa = lowest(a) - ground, fb = lowest(b) - ground
508
+ if (fa < 0 && fb > 0) {
509
+ for (let it = 0; it < 24; it++) { const m = (a + b) / 2, fm = lowest(m) - ground; if (fm < 0) { a = m; fa = fm } else { b = m; fb = fm } }
510
+ scale = (a + b) / 2
511
+ } else scale = den > 0 && num > 0 ? num / den : 1 // no ground contact to solve on: the bone-length ratio
512
+ }
513
+ const clips: Clip[] = source.clips.map((c) => ({ name: c.name, duration: c.duration, sample: (time: number): Pose => sampleAt(c, time, scale) }))
514
+ return { rig: { ...rig, clips }, bound, total: source.names.length, unbound, scale }
515
+ }
516
+
517
+ /**
518
+ * The same rig turned by a world rotation (its root nodes get `q` in front of them): every world
519
+ * orientation, position and sampled pose comes out rotated, nothing else changes. Used to turn a
520
+ * donor to face the way the target faces before a retarget.
521
+ */
522
+ export const rotateRig = <T extends Rig>(rig: T, q: Quat): T => {
523
+ const roots = rig.parents.map((p, i) => (p < 0 ? i : -1)).filter((i) => i >= 0)
524
+ const turn = (pose: Pose): Pose => {
525
+ const out: Pose = { t: pose.t.map((v) => [ ...v ] as Vec3), r: pose.r.map((v) => [ ...v ] as Quat), s: pose.s }
526
+ for (const i of roots) { out.t[i] = qrot(q, pose.t[i]); out.r[i] = qnorm(qmul(q, pose.r[i])) }
527
+ return out
528
+ }
529
+ const bind: BindPose = {
530
+ ...rig.bind,
531
+ worldRot: rig.bind.worldRot.map((r) => qnorm(qmul(q, r))),
532
+ worldPos: rig.bind.worldPos.map((p) => qrot(q, p)),
533
+ local: turn(rig.bind.local),
534
+ }
535
+ const out: T = {
536
+ ...rig,
537
+ rest: turn(rig.rest),
538
+ bind,
539
+ clips: rig.clips.map((c) => ({ ...c, sample: (t: number) => turn(c.sample(t)) })),
540
+ }
541
+ const w = worldOf(out, out.rest)
542
+ out.restWorldRot = w.rot
543
+ out.restWorldPos = w.pos
544
+ return out
545
+ }