lecodes-assets 0.2.0 → 0.2.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.
Files changed (65) hide show
  1. package/README.md +561 -449
  2. package/dist/cli.js +5632 -521
  3. package/dist/index.js +5647 -526
  4. package/package.json +13 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli.ts +1039 -0
  8. package/src/doctor/doctor.ts +241 -0
  9. package/src/doctor/lightmapUv.ts +452 -0
  10. package/src/doctor/lod.ts +177 -0
  11. package/src/doctor/normalizeScale.ts +220 -0
  12. package/src/doctor/reduceGlbBones.ts +218 -0
  13. package/src/doctor/shading.ts +95 -0
  14. package/src/doctor/transcodeKtx2.ts +124 -0
  15. package/src/doctor/transcodeTextures.ts +101 -0
  16. package/src/doctor/validateGlb.ts +264 -0
  17. package/src/fbx/convert.ts +327 -0
  18. package/src/fbx/read.ts +109 -0
  19. package/src/fbx/toDocument.ts +209 -0
  20. package/src/filmstrip.ts +263 -0
  21. package/src/index.ts +17 -0
  22. package/src/mergeSkins.ts +117 -0
  23. package/src/probe.ts +167 -0
  24. package/src/props.ts +137 -0
  25. package/src/retarget/automap.ts +276 -0
  26. package/src/retarget/ghost.ts +23 -0
  27. package/src/retarget/heading.ts +171 -0
  28. package/src/retarget/humanoid.ts +561 -0
  29. package/src/retarget/inPlace.ts +130 -0
  30. package/src/retarget/marks.ts +581 -0
  31. package/src/retarget/mirror.ts +0 -0
  32. package/src/retarget/retarget.ts +2272 -0
  33. package/src/retarget/rig.ts +542 -0
  34. package/src/retarget/unityAnim.ts +171 -0
  35. package/src/retarget/writeMarks.ts +132 -0
  36. package/src/sky/analyze.ts +136 -0
  37. package/src/sky/cmgen.ts +71 -0
  38. package/src/sky/index.ts +151 -0
  39. package/src/terrainPack.ts +169 -0
  40. package/src/textures/resolveImage.ts +202 -0
  41. package/src/unity/materials.ts +593 -0
  42. package/src/unity/png.ts +75 -0
  43. package/src/unity/prefabs.ts +80 -0
  44. package/src/unity/scene.ts +363 -0
  45. package/src/unity/unpack.ts +130 -0
  46. package/src/unreal/anim.ts +284 -0
  47. package/src/unreal/clipSource.ts +25 -0
  48. package/src/unreal/convert.ts +67 -0
  49. package/src/unreal/convertAsset.ts +81 -0
  50. package/src/unreal/index.ts +114 -0
  51. package/src/unreal/list.ts +113 -0
  52. package/src/unreal/materials.ts +291 -0
  53. package/src/unreal/mesh.ts +205 -0
  54. package/src/unreal/oodle.ts +153 -0
  55. package/src/unreal/package.ts +659 -0
  56. package/src/unreal/probe.ts +144 -0
  57. package/src/unreal/resolve.ts +51 -0
  58. package/src/unreal/rig.ts +149 -0
  59. package/src/unreal/sequencer.ts +193 -0
  60. package/src/unreal/skeleton.ts +74 -0
  61. package/src/unreal/texture.ts +202 -0
  62. package/src/unreal/toDocument.ts +360 -0
  63. package/src/unreal/versions.ts +100 -0
  64. package/src/wasm/fbxReader.js +14 -0
  65. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,542 @@
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
+ if (Math.abs(a - b) > 1e-4 || Math.abs(b - c) > 1e-4) {
272
+ throw new Error(`${rig.label}: bone "${rig.names[i]}" has a non-uniform rest scale [${rig.rest.s[i]}] — retargeting needs uniform scales`)
273
+ }
274
+ }
275
+ }
276
+
277
+ // ---- FBX (the clip donor) -----------------------------------------------------------------------
278
+
279
+ /** Sample one baked FBX track (times/values in the arena) at `time`. */
280
+ const sampleTrack = (times: Float32Array, values: Float32Array, n: number, time: number, step = false): number[] => {
281
+ const last = times.length - 1
282
+ const at = (i: number): number[] => Array.from(values.subarray(i * n, i * n + n))
283
+ if (time <= times[0]) return at(0)
284
+ if (time >= times[last]) return at(last)
285
+ let i = 0
286
+ while (i < last && times[i + 1] < time) i++
287
+ if (step) return at(i)
288
+ const u = (time - times[i]) / (times[i + 1] - times[i] || 1)
289
+ const a = at(i), b = at(i + 1)
290
+ if (n === 4) return qslerp(a as Quat, b as Quat, u)
291
+ return a.map((v, k) => v + (b[k] - v) * u)
292
+ }
293
+
294
+ export const rigFromFbx = async (path: string, bytes: Uint8Array): Promise<Rig> => {
295
+ const scene = await readFbx(bytes)
296
+ const names = scene.nodes.map((n) => n.name)
297
+ const parents = scene.nodes.map((n) => n.parent)
298
+ const rest: Pose = {
299
+ t: scene.nodes.map((n) => [ ...n.t ] as Vec3),
300
+ r: scene.nodes.map((n) => [ ...n.r ] as Quat),
301
+ s: scene.nodes.map((n) => [ ...n.s ] as Vec3),
302
+ }
303
+ const ibmByNode = new Map<number, number[]>()
304
+ const spaceByNode = new Map<number, Mat4>()
305
+ scene.skins.forEach((skin, si) => {
306
+ const ibm = f32(scene, skin.ibm, skin.joints.length * 16)
307
+ // The node carrying this skin's mesh (ufbx parks a geometry transform on a helper child, which
308
+ // is then the node that has the mesh): its world is the IBMs' space.
309
+ const meshNode = scene.nodes.findIndex((n) => n.skin === si)
310
+ const space = meshNode >= 0 ? restWorldOf(parents, rest, meshNode) : null
311
+ skin.joints.forEach((node, j) => {
312
+ if (ibmByNode.has(node)) return
313
+ ibmByNode.set(node, Array.from(ibm.subarray(j * 16, j * 16 + 16)))
314
+ if (space) spaceByNode.set(node, space)
315
+ })
316
+ })
317
+ const makeClips = (base: Pose): Clip[] => scene.animations.filter((a) => a.tracks.length > 0).map((anim) => {
318
+ // Pre-resolve the arena views once; sample() is called per frame per clip.
319
+ const tracks = anim.tracks.map((tr) => {
320
+ const n = tr.path === 'rotation' ? 4 : 3
321
+ return { node: tr.node, path: tr.path, n, times: f32(scene, tr.times, tr.count), values: f32(scene, tr.values, tr.count * n) }
322
+ })
323
+ return {
324
+ name: anim.name,
325
+ duration: anim.duration,
326
+ sample: (time: number): Pose => {
327
+ 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) }
328
+ for (const tr of tracks) {
329
+ const v = sampleTrack(tr.times, tr.values, tr.n, time)
330
+ if (tr.path === 'translation') pose.t[tr.node] = v as Vec3
331
+ else if (tr.path === 'rotation') pose.r[tr.node] = qnorm(v as Quat)
332
+ else pose.s[tr.node] = v as Vec3
333
+ }
334
+ return pose
335
+ },
336
+ }
337
+ })
338
+ return makeRig(path, names, parents, rest, makeClips, ibmByNode, spaceByNode)
339
+ }
340
+
341
+ // ---- glTF Document (the model being animated) ---------------------------------------------------
342
+
343
+ export interface DocRig extends Rig { doc: Document; nodes: any[] }
344
+
345
+ /** Clips from a glTF Document's animations, sampled the same way the FBX side is. */
346
+ const clipsFromDocument = (doc: Document, nodes: any[], base: Pose): Clip[] => {
347
+ const idx = new Map(nodes.map((n, i) => [ n, i ]))
348
+ return doc.getRoot().listAnimations().map((anim) => {
349
+ const chans = anim.listChannels().map((ch) => {
350
+ const s = ch.getSampler()!
351
+ return {
352
+ node: idx.get(ch.getTargetNode()!)!,
353
+ path: ch.getTargetPath() as string,
354
+ step: s.getInterpolation() === 'STEP',
355
+ times: s.getInput()!.getArray() as Float32Array,
356
+ values: s.getOutput()!.getArray() as Float32Array,
357
+ }
358
+ }).filter((c) => c.node !== undefined)
359
+ let duration = 0
360
+ for (const c of chans) duration = Math.max(duration, c.times[c.times.length - 1] ?? 0)
361
+ return {
362
+ name: anim.getName(),
363
+ duration,
364
+ sample: (time: number): Pose => {
365
+ 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) }
366
+ for (const c of chans) {
367
+ const n = c.path === 'rotation' ? 4 : 3
368
+ const v = sampleTrack(c.times, c.values as Float32Array, n, time, c.step)
369
+ if (c.path === 'translation') pose.t[c.node] = v as Vec3
370
+ else if (c.path === 'rotation') pose.r[c.node] = qnorm(v as Quat)
371
+ else if (c.path === 'scale') pose.s[c.node] = v as Vec3
372
+ }
373
+ return pose
374
+ },
375
+ }
376
+ })
377
+ }
378
+
379
+ export const rigFromDocument = (doc: Document, label: string): DocRig => {
380
+ const nodes = doc.getRoot().listNodes()
381
+ const idx = new Map(nodes.map((n, i) => [ n, i ]))
382
+ const names = nodes.map((n) => n.getName())
383
+ const parents = nodes.map((n) => { const p = n.getParentNode(); return p ? idx.get(p)! : -1 })
384
+ const rest: Pose = {
385
+ t: nodes.map((n) => [ ...n.getTranslation() ] as Vec3),
386
+ r: nodes.map((n) => [ ...n.getRotation() ] as Quat),
387
+ s: nodes.map((n) => [ ...n.getScale() ] as Vec3),
388
+ }
389
+ const ibmByNode = new Map<number, number[]>()
390
+ const spaceByNode = new Map<number, Mat4>()
391
+ for (const skin of doc.getRoot().listSkins()) {
392
+ const acc = skin.getInverseBindMatrices()
393
+ if (!acc) continue
394
+ // A converted FBX keeps the mesh on the (helper) node whose world is the IBMs' space; glTF
395
+ // ignores that transform for skinning, so it survives as the record of where the bind lives.
396
+ const meshNode = nodes.findIndex((n) => n.getSkin() === skin)
397
+ const space = meshNode >= 0 ? restWorldOf(parents, rest, meshNode) : null
398
+ skin.listJoints().forEach((joint, j) => {
399
+ const i = idx.get(joint)
400
+ if (i === undefined || ibmByNode.has(i)) return
401
+ ibmByNode.set(i, acc.getElement(j, []))
402
+ if (space) spaceByNode.set(i, space)
403
+ })
404
+ }
405
+ return { ...makeRig(label, names, parents, rest, (base) => clipsFromDocument(doc, nodes, base), ibmByNode, spaceByNode), doc, nodes }
406
+ }
407
+
408
+ export const loadRig = async (path: string): Promise<Rig> => rigFromFbx(path, new Uint8Array(readFileSync(path)))
409
+
410
+ /** A skeleton with a skin somewhere — the kind the classifiers can read. */
411
+ export const hasSkin = (rig: Rig): boolean => rig.bind.fromSkin.some(Boolean)
412
+
413
+ const shortBoneName = (n: string): string => n.slice(n.search(/[^:|]*$/))
414
+
415
+ /**
416
+ * Is `source` (a clip file, a model) the same skeleton as `rig` — the kind a clip can be copied
417
+ * onto bone by bone? Names must match (≥ 80 %), and the matched bones' LOCAL OFFSETS (the child in
418
+ * its parent's frame) must point the same way: that vector is fixed by the rig and survives any
419
+ * pose the exporter baked into the nodes, while local rotations do not (a mocap pack's clip files
420
+ * carry their first frame as the rest pose). Bones under 5 cm (fingers, eyes) are ignored. A rig
421
+ * of the same FAMILY (another Mixamo character: same joint frames, other proportions) stays within
422
+ * ~20°; a rig that merely shares the naming convention (two Unreal-style rigs) turns some joint —
423
+ * typically the root's — by 90°, and copying its clips would twist the character.
424
+ */
425
+ export const sameSkeleton = (rig: Rig, source: Rig): { same: boolean; share: number; worstDeg: number; worstBone: string } => {
426
+ const short = new Map<string, number>()
427
+ rig.names.forEach((n, i) => { const s = shortBoneName(n); if (!short.has(s)) short.set(s, i) })
428
+ const map = source.names.map((n) => rig.index.get(n) ?? short.get(shortBoneName(n)) ?? -1)
429
+ const share = map.filter((j) => j >= 0).length / Math.max(1, source.names.length)
430
+ let worstDeg = 0, worstBone = ''
431
+ source.names.forEach((_, i) => {
432
+ const j = map[i]
433
+ if (j < 0) return
434
+ const ps = source.parents[i], pt = rig.parents[j]
435
+ if (ps < 0 || pt < 0 || map[ps] !== pt) return
436
+ const a = rig.rest.t[j], b = source.rest.t[i]
437
+ const la = Math.hypot(...a), lb = Math.hypot(...b)
438
+ if (la < 0.05 || lb < 0.05) return
439
+ 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
440
+ if (deg > worstDeg) { worstDeg = deg; worstBone = source.names[i] }
441
+ })
442
+ return { same: share >= 0.8 && worstDeg < 45, share, worstDeg, worstBone }
443
+ }
444
+
445
+ /**
446
+ * Clips of a SKELETON-ONLY file (a mocap pack's clip FBX: nodes and curves, no mesh, no skin) bound
447
+ * to the rig they were animated on, by bone name — exact first, then the name without its
448
+ * `namespace:` prefix. The result is `rig` with those clips: each sample starts from the rig's
449
+ * bind and takes the clip's LOCAL ROTATION on every bone it names, so the rig's own bind pose,
450
+ * skin and classification carry the motion. Bone OFFSETS stay the rig's — a clip file's skeleton
451
+ * is not always the model's (Mixamo's skin-less downloads are ~9 % larger than the character they
452
+ * were made for, with the thighs 22° elsewhere; the legs would come out longer than the bind and
453
+ * every straight leg out of reach). The translations a clip ANIMATES (the hips' travel and bob,
454
+ * a root's travel) are taken from the clip scaled by `scale`: 1 when the two skeletons have the
455
+ * same bone lengths (a pack's own clip files — exact), else the factor that puts the lowest joint
456
+ * of the rebound rig over the clips on the rig's own ground (a locomotion clip touches the ground
457
+ * somewhere; no bone-length ratio says how high the hips ride when the proportions differ).
458
+ * Bones the clip does not name stay in bind; bones the clip names and the rig lacks are reported.
459
+ */
460
+ export const rebindClips = (rig: Rig, source: Rig): { rig: Rig; bound: number; total: number; unbound: string[]; scale: number } => {
461
+ const short = new Map<string, number>()
462
+ rig.names.forEach((n, i) => { const s = shortBoneName(n); if (!short.has(s)) short.set(s, i) })
463
+ const map = source.names.map((n) => rig.index.get(n) ?? short.get(shortBoneName(n)) ?? -1)
464
+ const unbound = source.names.filter((_, i) => map[i] < 0)
465
+ const bound = map.filter((j) => j >= 0).length
466
+ const base = rig.bind.local, srcRest = source.rest
467
+ // Which translations the clips animate: a bone whose translation leaves its rest anywhere in a clip.
468
+ const animated = source.names.map(() => false)
469
+ for (const c of source.clips) for (let k = 0; k <= 8; k++) {
470
+ const p = c.sample((k / 8) * c.duration)
471
+ p.t.forEach((t, i) => { if (!animated[i] && len(sub(t, srcRest.t[i])) > 1e-6) animated[i] = true })
472
+ }
473
+ const sampleAt = (c: Clip, time: number, scale: number): Pose => {
474
+ const p = c.sample(time)
475
+ 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) }
476
+ map.forEach((j, i) => {
477
+ if (j < 0) return
478
+ out.r[j] = p.r[i]
479
+ if (animated[i]) out.t[j] = scl(p.t[i], scale)
480
+ })
481
+ return out
482
+ }
483
+ // Same skeleton (every still matched offset within 1 % of its length) → the clip's translations
484
+ // are the rig's, exactly. Otherwise solve for the scale that grounds the rebound rig.
485
+ let sameLengths = true, num = 0, den = 0
486
+ map.forEach((j, i) => {
487
+ if (j < 0 || animated[i] || source.parents[i] < 0 || rig.parents[j] < 0) return
488
+ const a = len(base.t[j]), b = len(srcRest.t[i])
489
+ num += a; den += b
490
+ if (Math.abs(a - b) > 0.01 * Math.max(a, b, 0.01)) sameLengths = false
491
+ })
492
+ let scale = 1
493
+ if (!sameLengths && source.clips.length) {
494
+ const skinned = rig.bind.fromSkin.some(Boolean) ? rig.bind.worldPos.filter((_, i) => rig.bind.fromSkin[i]) : rig.bind.worldPos
495
+ const ground = Math.min(...skinned.map((p) => p[1]))
496
+ const lowest = (s: number): number => {
497
+ let lo = Infinity
498
+ for (const c of source.clips) for (let k = 0; k < 16; k++) {
499
+ const w = worldOf(rig, sampleAt(c, (k / 15) * c.duration, s))
500
+ 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])
501
+ }
502
+ return lo
503
+ }
504
+ let a = 0.5, b = 2, fa = lowest(a) - ground, fb = lowest(b) - ground
505
+ if (fa < 0 && fb > 0) {
506
+ 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 } }
507
+ scale = (a + b) / 2
508
+ } else scale = den > 0 && num > 0 ? num / den : 1 // no ground contact to solve on: the bone-length ratio
509
+ }
510
+ const clips: Clip[] = source.clips.map((c) => ({ name: c.name, duration: c.duration, sample: (time: number): Pose => sampleAt(c, time, scale) }))
511
+ return { rig: { ...rig, clips }, bound, total: source.names.length, unbound, scale }
512
+ }
513
+
514
+ /**
515
+ * The same rig turned by a world rotation (its root nodes get `q` in front of them): every world
516
+ * orientation, position and sampled pose comes out rotated, nothing else changes. Used to turn a
517
+ * donor to face the way the target faces before a retarget.
518
+ */
519
+ export const rotateRig = <T extends Rig>(rig: T, q: Quat): T => {
520
+ const roots = rig.parents.map((p, i) => (p < 0 ? i : -1)).filter((i) => i >= 0)
521
+ const turn = (pose: Pose): Pose => {
522
+ const out: Pose = { t: pose.t.map((v) => [ ...v ] as Vec3), r: pose.r.map((v) => [ ...v ] as Quat), s: pose.s }
523
+ for (const i of roots) { out.t[i] = qrot(q, pose.t[i]); out.r[i] = qnorm(qmul(q, pose.r[i])) }
524
+ return out
525
+ }
526
+ const bind: BindPose = {
527
+ ...rig.bind,
528
+ worldRot: rig.bind.worldRot.map((r) => qnorm(qmul(q, r))),
529
+ worldPos: rig.bind.worldPos.map((p) => qrot(q, p)),
530
+ local: turn(rig.bind.local),
531
+ }
532
+ const out: T = {
533
+ ...rig,
534
+ rest: turn(rig.rest),
535
+ bind,
536
+ clips: rig.clips.map((c) => ({ ...c, sample: (t: number) => turn(c.sample(t)) })),
537
+ }
538
+ const w = worldOf(out, out.rest)
539
+ out.restWorldRot = w.rot
540
+ out.restWorldPos = w.pos
541
+ return out
542
+ }