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,561 @@
1
+ // Humanoid rigs: the canonical skeleton ("avatar"), the biped classifier that derives a rig's
2
+ // avatar from its SHAPE, the Unity `.meta` avatar reader, and the map between two avatars.
3
+ //
4
+ // The canon is Unity's Humanoid / VRM bone list, so a Unity `.meta` humanoid block imports verbatim
5
+ // and doubles as the oracle for the classifier. An avatar is a property of ONE rig (map each rig
6
+ // once, retarget any pair through the canon), never of a (donor, target) pair.
7
+ //
8
+ // Classification is shape-first, in the bind pose (Y-up, meters — `rig.ts` folds any mesh-space
9
+ // turn in): legs are the two chains from one joint that reach the ground on opposite sides, hands
10
+ // are the lateral joints fanning out into finger chains, the spine is the centre path between them,
11
+ // the head is where the path above the arms last branches. Names only break ties (thumb vs index,
12
+ // a `head` joint under a hair rig) and are quoted in the report when they did. Twist/roll helpers
13
+ // (leaf children collinear with their parent) are recorded, never mapped: they take a fraction of
14
+ // the parent's twist at retarget time.
15
+ import type { Rig, Vec3 } from './rig'
16
+ import type { BoneMap } from './retarget'
17
+
18
+ // ---- canon ---------------------------------------------------------------------------------------
19
+
20
+ export const SIDES = [ 'Left', 'Right' ] as const
21
+ export type Side = typeof SIDES[number]
22
+ export const FINGERS = [ 'Thumb', 'Index', 'Middle', 'Ring', 'Little' ] as const
23
+ export type Finger = typeof FINGERS[number]
24
+ export const PHALANGES = [ 'Proximal', 'Intermediate', 'Distal' ] as const
25
+
26
+ const BODY = [ 'Hips', 'Spine', 'Chest', 'UpperChest', 'Neck', 'Head', 'Jaw', 'LeftEye', 'RightEye' ] as const
27
+ const LIMB = [ 'Shoulder', 'UpperArm', 'LowerArm', 'Hand', 'UpperLeg', 'LowerLeg', 'Foot', 'Toes' ] as const
28
+ export const HUMAN_BONES = [
29
+ ...BODY,
30
+ ...SIDES.flatMap((s) => LIMB.map((l) => `${s}${l}`)),
31
+ ...SIDES.flatMap((s) => FINGERS.flatMap((f) => PHALANGES.map((p) => `${s} ${f} ${p}`))),
32
+ ] as const
33
+ export type HumanBone = typeof HUMAN_BONES[number]
34
+ const HUMAN_SET = new Set<string>(HUMAN_BONES)
35
+ export const isHumanBone = (s: string): s is HumanBone => HUMAN_SET.has(s)
36
+
37
+ /** The canonical description of one rig. `bones` = canonical name → the rig's bone name. */
38
+ /**
39
+ * What a person decided about THIS rig's transfer, riding in its `.avatar.json` so the studio's
40
+ * clicks and `lecodes assets retarget --avatar` agree: per canonical bone whether it is built that
41
+ * way (`keep` the bind) or posed (`align` to the donor, the 8° threshold notwithstanding); degrees
42
+ * added to the measured palm roll per arm; and the transfer settings the CLI takes as defaults.
43
+ */
44
+ export interface AvatarTransfer {
45
+ align?: Partial<Record<HumanBone, 'keep' | 'align'>>
46
+ roll?: { Left?: number; Right?: number }
47
+ rootMotion?: 'auto' | 'hips' | 'root' | 'none'
48
+ plant?: boolean
49
+ hands?: boolean
50
+ twist?: number
51
+ }
52
+
53
+ export interface Avatar {
54
+ /** Bone that receives root motion: the skinned ancestor of Hips when the rig has one, else Hips. */
55
+ root: string
56
+ bones: Partial<Record<HumanBone, string>>
57
+ /** Twist/roll helpers → the canonical bone whose twist they follow. */
58
+ twist: Record<string, HumanBone>
59
+ /** Overrides and defaults from the avatar file (see `AvatarTransfer`). */
60
+ transfer?: AvatarTransfer
61
+ /** Rig bones sitting between two mapped ones (extra spine/neck links, metacarpals) — bridged. */
62
+ bridged: string[]
63
+ notes: string[]
64
+ }
65
+
66
+ // ---- Unity `.meta` avatar ----------------------------------------------------------------------
67
+
68
+ export interface UnityAvatar { bones: Partial<Record<HumanBone, string>>; rootMotionBone: string | null; unknown: string[] }
69
+
70
+ /** The `humanDescription.human` block of a Unity model importer `.meta` (YAML) — boneName ↔ humanName. */
71
+ export const parseUnityAvatar = (yaml: string): UnityAvatar => {
72
+ const bones: Partial<Record<HumanBone, string>> = {}
73
+ const unknown: string[] = []
74
+ const unq = (s: string): string => s.trim().replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1')
75
+ const re = /boneName:\s*([^\n]+)\n\s*humanName:\s*([^\n]+)/g
76
+ for (let m = re.exec(yaml); m; m = re.exec(yaml)) {
77
+ const bone = unq(m[1]), human = unq(m[2])
78
+ if (isHumanBone(human)) bones[human] = bone
79
+ else unknown.push(human)
80
+ }
81
+ const rm = /rootMotionBoneName:\s*([^\n]*)/.exec(yaml)
82
+ const rootMotionBone = rm && unq(rm[1]) ? unq(rm[1]) : null
83
+ return { bones, rootMotionBone, unknown }
84
+ }
85
+
86
+ // ---- classifier ----------------------------------------------------------------------------------
87
+
88
+ export interface BipedLeg { side: Side; upper: number; extra: number[]; lower: number; foot: number; toes: number | null; chain: number[] }
89
+ export interface BipedArm { side: Side; attach: number; shoulder: number | null; extra: number[]; upper: number; lower: number; hand: number; fingers: Partial<Record<Finger, number[]>>; metacarpals: number[] }
90
+
91
+ export interface Biped {
92
+ root: number | null
93
+ hips: number
94
+ forward: Vec3; lateral: Vec3; up: Vec3
95
+ height: number; ground: number
96
+ spine: number[] // hips (excl) … chest (incl), in order
97
+ chest: number
98
+ neck: number[] // chest (excl) … head (excl)
99
+ head: number
100
+ jaw: number | null
101
+ eyes: [number, number] | null // [left, right]
102
+ legs: BipedLeg[]
103
+ arms: BipedArm[]
104
+ twist: { bone: number; of: number }[]
105
+ helpers: number[] // skinned joints not part of the structure (hair, cloth, ik_*)
106
+ notes: string[]
107
+ }
108
+
109
+ const sub = (a: Vec3, b: Vec3): Vec3 => [ a[0] - b[0], a[1] - b[1], a[2] - b[2] ]
110
+ const dot = (a: Vec3, b: Vec3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
111
+ const len = (v: Vec3): number => Math.hypot(v[0], v[1], v[2])
112
+ const norm = (v: Vec3): Vec3 => { const l = len(v) || 1; return [ v[0] / l, v[1] / l, v[2] / l ] }
113
+ const cross = (a: Vec3, b: Vec3): Vec3 => [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0] ]
114
+ const angleDeg = (a: Vec3, b: Vec3): number => Math.acos(Math.max(-1, Math.min(1, dot(norm(a), norm(b))))) * 180 / Math.PI
115
+
116
+ const TOKEN: Record<string, RegExp> = {
117
+ head: /head/i, neck: /neck/i, jaw: /jaw/i, eye: /eye(?!.*(brow|lash|lid))/i,
118
+ thumb: /thumb/i, index: /index/i, middle: /middle/i, ring: /ring(?!.*(ear|fore))/i, little: /pinky|little/i,
119
+ twist: /twist|roll/i,
120
+ }
121
+
122
+ /** Structure of a biped rig from its bind pose. Throws when the shape is not a biped. */
123
+ export const classifyBiped = (rig: Rig): Biped => {
124
+ const notes: string[] = []
125
+ const N = rig.names.length
126
+ const pos = rig.bind.worldPos
127
+ const skinned = (i: number): boolean => rig.bind.fromSkin[i]
128
+ const children: number[][] = rig.names.map(() => [])
129
+ rig.parents.forEach((p, i) => { if (p >= 0) children[p].push(i) })
130
+ /** Skinned descendants reachable without crossing another skinned joint. */
131
+ const skids = (i: number): number[] => {
132
+ const out: number[] = []
133
+ const q = [ ...children[i] ]
134
+ while (q.length) { const c = q.shift()!; if (skinned(c)) out.push(c); else q.push(...children[c]) }
135
+ return out
136
+ }
137
+ const sparent = (i: number): number => { let p = rig.parents[i]; while (p >= 0 && !skinned(p)) p = rig.parents[p]; return p }
138
+ const joints = rig.order.filter(skinned)
139
+ if (joints.length < 12) throw new Error(`${rig.label}: ${joints.length} skinned joints — not a humanoid`)
140
+ const subtree = (i: number): number[] => { const out: number[] = []; const q = [ i ]; while (q.length) { const j = q.shift()!; out.push(j); q.push(...skids(j)) } return out }
141
+ const minY = (i: number): number => Math.min(...subtree(i).map((j) => pos[j][1]))
142
+ const maxY = (i: number): number => Math.max(...subtree(i).map((j) => pos[j][1]))
143
+ const extent = (i: number): number => { const s = subtree(i); let e = 0; for (const j of s) e = Math.max(e, len(sub(pos[j], pos[i]))); return e }
144
+
145
+ let lo = Infinity, hi = -Infinity
146
+ for (const i of joints) { lo = Math.min(lo, pos[i][1]); hi = Math.max(hi, pos[i][1]) }
147
+ const height = hi - lo || 1, ground = lo
148
+ const up: Vec3 = [ 0, 1, 0 ]
149
+ const horiz = (v: Vec3): Vec3 => [ v[0], 0, v[2] ]
150
+
151
+ // --- legs: from one joint, two chains reaching the ground on opposite sides ---------------------
152
+ /** Greedy descent: at each joint take the child whose subtree goes lowest. */
153
+ const descend = (from: number): number[] => {
154
+ const chain = [ from ]
155
+ for (let guard = 0; guard < 64; guard++) {
156
+ const ks = skids(chain[chain.length - 1])
157
+ if (!ks.length) break
158
+ chain.push(ks.sort((a, b) => minY(a) - minY(b))[0])
159
+ }
160
+ return chain
161
+ }
162
+ let hips = -1
163
+ let legChains: number[][] = []
164
+ for (const h of joints) {
165
+ const cands = skids(h)
166
+ .map((c) => descend(c))
167
+ .filter((ch) => ch.length >= 3 && minY(ch[0]) < ground + 0.12 * height && len(horiz(sub(pos[ch[0]], pos[h]))) > 0.02 * height)
168
+ // Opposite sides of h, horizontally.
169
+ let best: [number[], number[]] | null = null, bestScore = -Infinity
170
+ for (let a = 0; a < cands.length; a++) for (let b = a + 1; b < cands.length; b++) {
171
+ const oa = horiz(sub(pos[cands[a][0]], pos[h])), ob = horiz(sub(pos[cands[b][0]], pos[h]))
172
+ if (dot(oa, ob) >= 0) continue
173
+ const score = cands[a].length + cands[b].length + len(sub(oa, ob)) / height * 10
174
+ if (score > bestScore) { bestScore = score; best = [ cands[a], cands[b] ] }
175
+ }
176
+ if (!best) continue
177
+ // The lowest joint that owns such a pair is the pelvis (a root above it also "owns" the legs).
178
+ if (hips < 0 || pos[h][1] < pos[hips][1]) { hips = h; legChains = best }
179
+ }
180
+ if (hips < 0) throw new Error(`${rig.label}: no pelvis with two legs reaching the ground — not a biped`)
181
+ const legJoints = new Set(legChains.flat())
182
+
183
+ // --- hands: lateral joints fanning into finger chains ---------------------------------------
184
+ const centreX = pos[hips]
185
+ const handScore = (i: number): number => {
186
+ if (legJoints.has(i) || i === hips) return -1
187
+ const ks = skids(i).filter((c) => subtree(c).length <= 6 && extent(c) < 0.12 * height)
188
+ if (ks.length < 3) return -1
189
+ const lat = len(horiz(sub(pos[i], centreX)))
190
+ return lat > 0.12 * height ? lat : -1
191
+ }
192
+ const handCands = joints.filter((i) => handScore(i) > 0)
193
+
194
+ // --- spine: hips → the joint the arms hang off; head: last branch above it ------------------
195
+ const ancestors = (i: number): number[] => { const out: number[] = []; for (let a = i; a >= 0; a = sparent(a)) out.push(a); return out }
196
+ const lca = (list: number[]): number => {
197
+ let common = ancestors(list[0])
198
+ for (const i of list.slice(1)) { const set = new Set(ancestors(i)); common = common.filter((a) => set.has(a)) }
199
+ return common[0]
200
+ }
201
+ // Provisional forward from the legs' feet (toes − ankle) so sides can be told apart; refined below.
202
+ const footOf = (chain: number[]): { foot: number; toes: number | null } => {
203
+ const last = chain[chain.length - 1]
204
+ // An END SITE (Mixamo `LeftToe_End`, a `_end` / `Nub` leaf): the link before it is already on
205
+ // the ground — the ball — so the foot is one link higher. Without this a 5-link Mixamo leg
206
+ // reads as upper · (extra) · lower = Foot · foot = ToeBase, and the ankle's motion leaks into
207
+ // the knee through the leg IK (the knee locks straight at every toe-off).
208
+ if (chain.length >= 5) {
209
+ const ball = chain[chain.length - 2], ankle = chain[chain.length - 3]
210
+ const endSite = /(_end|end|nub|tip)$/i.test(rig.names[last]) && children[last].length === 0
211
+ if ((endSite || pos[ball][1] - ground < 0.03 * height) && pos[ball][1] - ground < 0.06 * height && pos[ankle][1] - ground < 0.14 * height) return { foot: ankle, toes: ball }
212
+ }
213
+ if (chain.length >= 4 && pos[last][1] - ground < 0.06 * height && pos[chain[chain.length - 2]][1] - ground < 0.14 * height) return { foot: chain[chain.length - 2], toes: last }
214
+ return { foot: last, toes: null }
215
+ }
216
+ let forward: Vec3 = [ 0, 0, 1 ]
217
+ {
218
+ const dirs = legChains.map(footOf).filter((f) => f.toes !== null).map((f) => horiz(sub(pos[f.toes!], pos[f.foot])))
219
+ if (dirs.length) forward = norm(dirs.reduce((a, d) => [ a[0] + d[0], 0, a[2] + d[2] ], [ 0, 0, 0 ] as Vec3))
220
+ else notes.push('no toe bones — facing assumed +Z')
221
+ }
222
+ let lateral: Vec3 = norm(cross(up, forward)) // +lateral = the rig's LEFT
223
+ const latOf = (i: number): number => dot(sub(pos[i], pos[hips]), lateral)
224
+
225
+ // Hands: on each side the candidate furthest out; fall back to the furthest lateral leaf off the torso.
226
+ const handOn = (sign: 1 | -1): number | null => {
227
+ const side = handCands.filter((i) => Math.sign(latOf(i)) === sign).sort((a, b) => Math.abs(latOf(b)) - Math.abs(latOf(a)))
228
+ if (side.length) return side[0]
229
+ const leaves = joints.filter((i) => !legJoints.has(i) && skids(i).length === 0 && Math.sign(latOf(i)) === sign && Math.abs(latOf(i)) > 0.15 * height && pos[i][1] > pos[hips][1])
230
+ .sort((a, b) => Math.abs(latOf(b)) - Math.abs(latOf(a)))
231
+ if (leaves.length) { notes.push(`no finger chains on the ${sign > 0 ? 'left' : 'right'} — hand = furthest lateral joint ${rig.names[leaves[0]]}`); return leaves[0] }
232
+ return null
233
+ }
234
+ const handL = handOn(1), handR = handOn(-1)
235
+ if (handL === null || handR === null) throw new Error(`${rig.label}: ${handL === null ? 'left' : 'right'} hand not found — not a biped`)
236
+
237
+ // Arm chains up to the spine; the chest is where they meet.
238
+ const upTo = (from: number, stop: (j: number) => boolean): number[] => { const out: number[] = []; for (let a = from; a >= 0 && !stop(a); a = sparent(a)) out.unshift(a); return out }
239
+ const chest = lca([ handL, handR ])
240
+ if (chest === hips || !ancestors(chest).includes(hips)) throw new Error(`${rig.label}: arms meet at ${rig.names[chest]}, not above the pelvis — not a biped`)
241
+ const spine = upTo(chest, (j) => j === hips) // hips excl … chest incl
242
+ const spineSet = new Set(spine)
243
+ const armOf = (hand: number, side: Side): BipedArm => {
244
+ const path = upTo(hand, (j) => spineSet.has(j)) // attach excl … hand incl
245
+ const attach = sparent(path[0])
246
+ const links = path.slice(0, -1) // clavicle?, upper, lower (+ extras)
247
+ if (links.length < 2) notes.push(`${side} arm has ${links.length} link(s) between ${rig.names[attach]} and the hand — check the map`)
248
+ const lower = links[links.length - 1], upper = links[links.length - 2] ?? lower
249
+ const shoulder = links.length >= 3 ? links[0] : null
250
+ const extra = links.length > 3 ? links.slice(1, -2) : []
251
+ return { side, attach, shoulder, extra, upper, lower, hand, fingers: {}, metacarpals: [] }
252
+ }
253
+ const arms = [ armOf(handL, 'Left'), armOf(handR, 'Right') ]
254
+ const armJoints = new Set(arms.flatMap((a) => [ a.shoulder ?? -1, ...a.extra, a.upper, a.lower, a.hand ].filter((i) => i >= 0)))
255
+
256
+ // Head: up the centre path from the chest, past the arms; the last branching joint, else the end.
257
+ const centred = (i: number): boolean => Math.abs(latOf(i)) < 0.05 * height
258
+ const path: number[] = []
259
+ {
260
+ let cur = chest
261
+ for (let guard = 0; guard < 64; guard++) {
262
+ const ks = skids(cur).filter((c) => !armJoints.has(c) && !legJoints.has(c) && centred(c) && pos[c][1] > pos[cur][1] - 0.01 * height)
263
+ if (!ks.length) break
264
+ cur = ks.sort((a, b) => maxY(b) - maxY(a))[0]
265
+ path.push(cur)
266
+ }
267
+ }
268
+ if (!path.length) throw new Error(`${rig.label}: nothing above ${rig.names[chest]} — no neck/head`)
269
+ let h = path.length - 1
270
+ while (h > 0 && skids(path[h]).length < 2) h--
271
+ if (skids(path[h]).length < 2) h = path.length - 1
272
+ const named = path.findIndex((i) => TOKEN.head.test(rig.names[i]))
273
+ if (named >= 0 && named !== h) { notes.push(`head: shape picked ${rig.names[path[h]]}, name says ${rig.names[path[named]]} — using the name`); h = named }
274
+ const head = path[h]
275
+ const neck = path.slice(0, h)
276
+
277
+ // Eyes: a lateral pair of head children with the largest forward offset; jaw: centred, forward-down.
278
+ let eyes: [number, number] | null = null, jaw: number | null = null
279
+ {
280
+ const kids = skids(head)
281
+ const pair = kids.filter((c) => Math.abs(latOf(c) - latOf(head)) > 0.01 * height)
282
+ const fwdOf = (c: number): number => dot(sub(pos[c], pos[head]), forward)
283
+ let best = -Infinity
284
+ for (const a of pair) for (const b of pair) {
285
+ if (latOf(a) - latOf(head) <= 0 || latOf(b) - latOf(head) >= 0) continue
286
+ if (Math.abs(pos[a][1] - pos[b][1]) > 0.03 * height) continue
287
+ const score = fwdOf(a) + fwdOf(b) + (TOKEN.eye.test(rig.names[a]) && TOKEN.eye.test(rig.names[b]) ? height : 0)
288
+ if (score > best) { best = score; eyes = [ a, b ] }
289
+ }
290
+ if (eyes && best < 0.005 * height) eyes = null
291
+ // Jaw: a centred child forward of the head joint (it hinges at the skull base, level with the
292
+ // head joint, and hangs forward-down) — the lowest such; a `jaw` name wins outright.
293
+ const eyeSet = new Set(eyes ?? [])
294
+ const byName = kids.find((c) => !eyeSet.has(c) && TOKEN.jaw.test(rig.names[c]))
295
+ const j = byName ?? kids.filter((c) => !eyeSet.has(c) && Math.abs(latOf(c) - latOf(head)) < 0.03 * height && fwdOf(c) > 0.01 * height && pos[c][1] < pos[head][1] + 0.03 * height)
296
+ .sort((a, b) => pos[a][1] - pos[b][1])[0]
297
+ if (j !== undefined) jaw = j
298
+ }
299
+
300
+ // Legs: segment each chain into upper / (extra) / lower / foot / toes and side them.
301
+ const legs: BipedLeg[] = legChains.map((chain) => {
302
+ const { foot, toes } = footOf(chain)
303
+ const body = chain.slice(0, chain.indexOf(foot)) // upper … lower
304
+ const side: Side = latOf(chain[0]) > 0 ? 'Left' : 'Right'
305
+ if (body.length < 2) { notes.push(`${side} leg has ${body.length} link(s) above the foot — check the map`); return { side, upper: body[0] ?? foot, extra: [], lower: body[body.length - 1] ?? foot, foot, toes, chain } }
306
+ return { side, upper: body[0], extra: body.slice(1, -1), lower: body[body.length - 1], foot, toes, chain }
307
+ }).sort((a, b) => (a.side === 'Left' ? 0 : 1) - (b.side === 'Left' ? 0 : 1))
308
+ if (legs[0].side === legs[1].side) notes.push('both legs on the same side — facing is wrong')
309
+ const rootAbove = ancestors(hips).slice(1)
310
+ const root = rootAbove.length ? rootAbove[rootAbove.length - 1] : null
311
+
312
+ // Fingers: chains off the hand; a trailing unskinned leaf completes a short chain (a low-poly
313
+ // hand's distal phalanx often carries no weights), a 4th link ahead of three is a metacarpal.
314
+ const fingerChain = (from: number): number[] => {
315
+ const chain = [ from ]
316
+ for (let guard = 0; guard < 8; guard++) {
317
+ const cur = chain[chain.length - 1]
318
+ const ks = skids(cur)
319
+ if (ks.length) { chain.push(ks.sort((a, b) => extent(b) - extent(a))[0]); continue }
320
+ const raw = children[cur].filter((c) => !skinned(c))
321
+ if (chain.length < 3 && raw.length === 1 && !/end|nub|tip|leaf/i.test(rig.names[raw[0]])) chain.push(raw[0])
322
+ break
323
+ }
324
+ return chain
325
+ }
326
+ for (const arm of arms) {
327
+ let chains = skids(arm.hand).filter((c) => subtree(c).length <= 6 && extent(c) < 0.12 * height).map(fingerChain)
328
+ if (!chains.length) continue
329
+ // Identity: names when they single out five chains (a weapon socket or an IK target on the hand
330
+ // is then just dropped), else the thumb = the base nearest the wrist and the rest ordered from
331
+ // the thumb outward, after shedding the shortest extra chains (sockets are one joint).
332
+ const FINGER_TOKENS = [ 'thumb', 'index', 'middle', 'ring', 'little' ]
333
+ const nameOf = (c: number[]): string | undefined => FINGER_TOKENS.find((t) => c.some((i) => TOKEN[t].test(rig.names[i])))
334
+ const named = chains.map(nameOf)
335
+ const namedSet = new Set(named.filter(Boolean))
336
+ let useNames = false
337
+ if (namedSet.size === 5 && named.filter(Boolean).length === 5) { chains = chains.filter((_, k) => named[k]); useNames = true }
338
+ else if (chains.length > 5) {
339
+ chains = [ ...chains ].sort((a, b) => b.length - a.length || extent(b[0]) - extent(a[0])).slice(0, 5)
340
+ }
341
+ const meta: number[] = []
342
+ const phal = chains.map((c) => (c.length >= 4 ? (meta.push(c[0]), c.slice(1, 4)) : c.slice(0, 3)))
343
+ arm.metacarpals = meta
344
+ const byName = chains.map(nameOf)
345
+ const assigned: Partial<Record<Finger, number[]>> = {}
346
+ if (useNames) {
347
+ byName.forEach((t, k) => { assigned[(t!.charAt(0).toUpperCase() + t!.slice(1)) as Finger] = phal[k] })
348
+ } else {
349
+ const order = phal.map((c, k) => ({ k, d: len(sub(pos[c[0]], pos[arm.hand])) })).sort((a, b) => a.d - b.d)
350
+ const thumb = order[0].k
351
+ const rest = phal.map((_, k) => k).filter((k) => k !== thumb)
352
+ const mean = rest.reduce((a, k) => [ a[0] + pos[phal[k][0]][0], a[1] + pos[phal[k][0]][1], a[2] + pos[phal[k][0]][2] ] as Vec3, [ 0, 0, 0 ] as Vec3).map((v) => v / (rest.length || 1)) as Vec3
353
+ const axis = norm(sub(pos[phal[thumb][0]], mean))
354
+ rest.sort((a, b) => dot(sub(pos[phal[b][0]], mean), axis) - dot(sub(pos[phal[a][0]], mean), axis))
355
+ assigned.Thumb = phal[thumb]
356
+ const names: Finger[] = [ 'Index', 'Middle', 'Ring', 'Little' ]
357
+ rest.slice(0, 4).forEach((k, n) => { assigned[names[n]] = phal[k] })
358
+ if (phal.length !== 5) notes.push(`${arm.side} hand has ${phal.length} finger chains — identities by position`)
359
+ else notes.push(`${arm.side} fingers identified by position (no finger names)`)
360
+ }
361
+ arm.fingers = assigned
362
+ }
363
+
364
+ // Twist helpers: extra children of a limb bone that are leaves (or tiny chains) collinear with it.
365
+ const twist: { bone: number; of: number }[] = []
366
+ const limbLinks: [number, number][] = []
367
+ for (const l of legs) { const seq = [ l.upper, ...l.extra, l.lower, l.foot ]; for (let k = 0; k + 1 < seq.length; k++) limbLinks.push([ seq[k], seq[k + 1] ]) }
368
+ for (const a of arms) { const seq = [ ...(a.shoulder !== null ? [ a.shoulder ] : []), ...a.extra, a.upper, a.lower, a.hand ]; for (let k = 0; k + 1 < seq.length; k++) limbLinks.push([ seq[k], seq[k + 1] ]) }
369
+ const structural = new Set<number>([ hips, ...spine, ...neck, head, ...(jaw !== null ? [ jaw ] : []), ...(eyes ?? []), ...legs.flatMap((l) => l.chain), ...armJoints, ...arms.flatMap((a) => [ ...a.metacarpals, ...Object.values(a.fingers).flat() ]), ...(root !== null ? [ root ] : []) ])
370
+ for (const [ b, c ] of limbLinks) {
371
+ const dir = sub(pos[c], pos[b])
372
+ for (const t of skids(b)) {
373
+ if (structural.has(t) || subtree(t).length > 2) continue
374
+ const d = sub(pos[t], pos[b])
375
+ const onAxis = len(d) < 1e-6 || angleDeg(d, dir) < 25
376
+ if (onAxis && (TOKEN.twist.test(rig.names[t]) || subtree(t).length === 1)) { twist.push({ bone: t, of: b }); structural.add(t) }
377
+ }
378
+ }
379
+ const helpers = joints.filter((i) => !structural.has(i))
380
+
381
+ // Final frame: forward from the toes (or eyes), lateral from it.
382
+ if (eyes) {
383
+ const eyeFwd = horiz(sub([ (pos[eyes[0]][0] + pos[eyes[1]][0]) / 2, 0, (pos[eyes[0]][2] + pos[eyes[1]][2]) / 2 ], pos[head]))
384
+ if (len(eyeFwd) > 0.005 * height && angleDeg(eyeFwd, forward) > 90) notes.push('eyes look the other way than the toes point — check facing')
385
+ }
386
+ lateral = norm(cross(up, forward))
387
+ return { root, hips, forward, lateral, up, height, ground, spine, chest, neck, head, jaw, eyes, legs, arms, twist, helpers, notes }
388
+ }
389
+
390
+ /** A cheap "is this a biped" probe for callers that must choose a classifier. */
391
+ export const isBiped = (rig: Rig): boolean => {
392
+ try { classifyBiped(rig); return true } catch { return false }
393
+ }
394
+
395
+ // ---- avatar from a classification / from Unity ----------------------------------------------------
396
+
397
+ export const avatarFromBiped = (rig: Rig, b: Biped): Avatar => {
398
+ const n = (i: number): string => rig.names[i]
399
+ const bones: Partial<Record<HumanBone, string>> = {}
400
+ const bridged: string[] = []
401
+ bones.Hips = n(b.hips)
402
+ // Spine: 1 → Spine; 2 → Spine, Chest; 3 → Spine, Chest, UpperChest; more → first, middle-by-arc, last.
403
+ const s = b.spine
404
+ if (s.length === 1) bones.Spine = n(s[0])
405
+ else if (s.length === 2) { bones.Spine = n(s[0]); bones.Chest = n(s[1]) }
406
+ else if (s.length === 3) { bones.Spine = n(s[0]); bones.Chest = n(s[1]); bones.UpperChest = n(s[2]) }
407
+ else if (s.length > 3) {
408
+ const pos = rig.bind.worldPos
409
+ let acc = 0
410
+ const arc = s.map((i, k) => (acc += len(sub(pos[i], pos[k ? s[k - 1] : b.hips]))))
411
+ const mid = arc.slice(1, -1).map((v, k) => ({ k: k + 1, d: Math.abs(v - acc / 2) })).sort((a, c) => a.d - c.d)[0].k
412
+ bones.Spine = n(s[0]); bones.Chest = n(s[mid]); bones.UpperChest = n(s[s.length - 1])
413
+ s.forEach((i, k) => { if (k !== 0 && k !== mid && k !== s.length - 1) bridged.push(n(i)) })
414
+ }
415
+ if (b.neck.length) { bones.Neck = n(b.neck[0]); bridged.push(...b.neck.slice(1).map(n)) }
416
+ bones.Head = n(b.head)
417
+ if (b.jaw !== null) bones.Jaw = n(b.jaw)
418
+ if (b.eyes) { bones.LeftEye = n(b.eyes[0]); bones.RightEye = n(b.eyes[1]) }
419
+ for (const l of b.legs) {
420
+ bones[`${l.side}UpperLeg`] = n(l.upper); bones[`${l.side}LowerLeg`] = n(l.lower); bones[`${l.side}Foot`] = n(l.foot)
421
+ if (l.toes !== null) bones[`${l.side}Toes`] = n(l.toes)
422
+ bridged.push(...l.extra.map(n))
423
+ }
424
+ for (const a of b.arms) {
425
+ if (a.shoulder !== null) bones[`${a.side}Shoulder`] = n(a.shoulder)
426
+ bones[`${a.side}UpperArm`] = n(a.upper); bones[`${a.side}LowerArm`] = n(a.lower); bones[`${a.side}Hand`] = n(a.hand)
427
+ bridged.push(...a.extra.map(n), ...a.metacarpals.map(n))
428
+ for (const f of FINGERS) {
429
+ const chain = a.fingers[f]
430
+ if (!chain) continue
431
+ chain.forEach((i, k) => { if (k < 3) bones[`${a.side} ${f} ${PHALANGES[k]}`] = n(i) })
432
+ }
433
+ }
434
+ const byIndex = new Map<number, HumanBone>()
435
+ for (const [ k, v ] of Object.entries(bones)) byIndex.set(rig.index.get(v!)!, k as HumanBone)
436
+ const twist: Record<string, HumanBone> = {}
437
+ for (const t of b.twist) { const of = byIndex.get(t.of); if (of) twist[n(t.bone)] = of }
438
+ return { root: b.root !== null ? n(b.root) : n(b.hips), bones, twist, bridged, notes: [ ...b.notes ] }
439
+ }
440
+
441
+ /** Derive a rig's avatar from its shape. */
442
+ export const avatarFromRig = (rig: Rig): Avatar => avatarFromBiped(rig, classifyBiped(rig))
443
+
444
+ /**
445
+ * Avatar from a Unity `.meta` humanoid block, checked against the rig; the root and the twist
446
+ * helpers come from the rig's shape when it classifies (a `.meta` has neither).
447
+ */
448
+ export const avatarFromUnity = (rig: Rig, unity: UnityAvatar): Avatar => {
449
+ const notes: string[] = []
450
+ const bones: Partial<Record<HumanBone, string>> = {}
451
+ for (const [ k, v ] of Object.entries(unity.bones)) {
452
+ if (rig.index.has(v!)) bones[k as HumanBone] = v
453
+ else notes.push(`.meta maps ${k} to '${v}', which the rig does not have — dropped`)
454
+ }
455
+ if (unity.unknown.length) notes.push(`.meta human names not in the canon: ${unity.unknown.join(', ')}`)
456
+ if (!bones.Hips) throw new Error(`${rig.label}: the .meta avatar has no Hips`)
457
+ let shape: Biped | null = null
458
+ try { shape = classifyBiped(rig) } catch (e) { notes.push(`shape classification failed (${(e as Error).message}) — no twist helpers, root = skinned ancestor of Hips`) }
459
+ const hips = rig.index.get(bones.Hips)!
460
+ let root = bones.Hips
461
+ if (unity.rootMotionBone && rig.index.has(unity.rootMotionBone)) root = unity.rootMotionBone
462
+ else { let top = -1; for (let a = rig.parents[hips]; a >= 0; a = rig.parents[a]) if (rig.bind.fromSkin[a]) top = a; if (top >= 0) root = rig.names[top] }
463
+ const twist: Record<string, HumanBone> = {}
464
+ if (shape) {
465
+ const byIndex = new Map<number, HumanBone>()
466
+ for (const [ k, v ] of Object.entries(bones)) byIndex.set(rig.index.get(v!)!, k as HumanBone)
467
+ for (const t of shape.twist) { const of = byIndex.get(t.of); if (of) twist[rig.names[t.bone]] = of }
468
+ }
469
+ return { root, bones, twist, bridged: [], notes }
470
+ }
471
+
472
+ /** The `.avatar.json` file shape — what `--auto-map` writes for a humanoid and `--avatar` reads. */
473
+ export interface AvatarFile { root?: string; bones: Record<string, string>; twist?: Record<string, string>; transfer?: AvatarTransfer }
474
+
475
+ export const avatarFromFile = (rig: Rig, file: AvatarFile): Avatar => {
476
+ const bones: Partial<Record<HumanBone, string>> = {}
477
+ const notes: string[] = []
478
+ for (const [ k, v ] of Object.entries(file.bones)) {
479
+ if (!isHumanBone(k)) { notes.push(`'${k}' is not a canonical humanoid bone — ignored`); continue }
480
+ if (!rig.index.has(v)) { notes.push(`${k} → '${v}': the rig has no such bone — dropped`); continue }
481
+ bones[k] = v
482
+ }
483
+ if (!bones.Hips) throw new Error(`${rig.label}: avatar has no Hips`)
484
+ const twist: Record<string, HumanBone> = {}
485
+ for (const [ t, of ] of Object.entries(file.twist ?? {})) if (isHumanBone(of) && rig.index.has(t)) twist[t] = of
486
+ let transfer: AvatarTransfer | undefined
487
+ if (file.transfer && typeof file.transfer === 'object') {
488
+ transfer = { ...file.transfer }
489
+ if (transfer.align) {
490
+ const align: AvatarTransfer['align'] = {}
491
+ for (const [ k, v ] of Object.entries(transfer.align)) {
492
+ if (!isHumanBone(k)) { notes.push(`transfer.align: '${k}' is not a canonical bone — ignored`); continue }
493
+ if (v === 'keep' || v === 'align') align[k] = v
494
+ }
495
+ transfer.align = align
496
+ }
497
+ }
498
+ return { root: file.root && rig.index.has(file.root) ? file.root : bones.Hips, bones, twist, bridged: [], notes, ...(transfer ? { transfer } : {}) }
499
+ }
500
+
501
+ export const avatarToFile = (a: Avatar): AvatarFile => ({ root: a.root, bones: { ...a.bones } as Record<string, string>, twist: Object.keys(a.twist).length ? { ...a.twist } : undefined, ...(a.transfer && Object.keys(a.transfer).length ? { transfer: { ...a.transfer } } : {}) })
502
+
503
+ // ---- map between two avatars --------------------------------------------------------------------
504
+
505
+ export interface HumanoidMap { map: BoneMap; root: string; legs: string[]; shared: HumanBone[]; targetOnly: HumanBone[]; donorOnly: HumanBone[] }
506
+
507
+ /** target bone → donor bone for every canonical bone both avatars have. */
508
+ export const mapAvatars = (target: Avatar, donor: Avatar): HumanoidMap => {
509
+ const map: BoneMap = {}
510
+ const shared: HumanBone[] = [], targetOnly: HumanBone[] = [], donorOnly: HumanBone[] = []
511
+ for (const k of HUMAN_BONES) {
512
+ const t = target.bones[k], d = donor.bones[k]
513
+ if (t && d) { map[t] = d; shared.push(k) }
514
+ else if (t) targetOnly.push(k)
515
+ else if (d) donorOnly.push(k)
516
+ }
517
+ // Root motion: the target's root receives the donor's root; when only one side has a root above
518
+ // the hips the hips carry it (the retarget scales and re-expresses it either way).
519
+ map[target.root] = donor.root
520
+ if (target.root !== target.bones.Hips && donor.root === donor.bones.Hips) map[target.bones.Hips!] = donor.bones.Hips!
521
+ // Leg effectors: the ANKLES. The foot's orientation is the donor's rotation on the target's own
522
+ // flat bind — a boot whose foot bone dives steeply to the ball (Swat: −56°, X-bot: −27° for the
523
+ // same flat sole) must not become an IK link, or the solver decides its pitch (up to 50° off the
524
+ // donor in the swing). Toes stay FK below the foot.
525
+ const legs = SIDES.map((s) => target.bones[`${s}Foot`]).filter((x): x is string => !!x)
526
+ return { map, root: target.root, legs, shared, targetOnly, donorOnly }
527
+ }
528
+
529
+ // ---- reports -------------------------------------------------------------------------------------
530
+
531
+ export const describeBiped = (rig: Rig, b: Biped): string => {
532
+ const n = (i: number | null): string => (i === null ? '-' : rig.names[i])
533
+ const fingers = b.arms.map((a) => Object.keys(a.fingers).length).join('/')
534
+ return `hips ${n(b.hips)}${b.root !== null ? ` (root ${n(b.root)})` : ''} · spine ${b.spine.length} · neck ${b.neck.length} · head ${n(b.head)} · jaw ${n(b.jaw)} · eyes ${b.eyes ? `${n(b.eyes[0])}/${n(b.eyes[1])}` : '-'} · arms ${b.arms.map((a) => `${a.side[0]}:${(a.shoulder !== null ? 1 : 0) + 2 + a.extra.length}`).join(' ')} · fingers ${fingers} · legs ${b.legs.map((l) => `${l.side[0]}:${2 + l.extra.length}${l.toes !== null ? '+toes' : ''}`).join(' ')} · twist ${b.twist.length} · helpers ${b.helpers.length} · facing [${b.forward.map((x) => x.toFixed(2)).join(',')}]`
535
+ }
536
+
537
+ /** The pairing report for a humanoid map: one line per canonical group. */
538
+ export const describeHumanoidMap = (target: Avatar, donor: Avatar, hm: HumanoidMap): string[] => {
539
+ const lines: string[] = []
540
+ const pair = (k: HumanBone): string => `${target.bones[k]}←${donor.bones[k]}`
541
+ const group = (label: string, keys: HumanBone[]): void => {
542
+ const have = keys.filter((k) => hm.shared.includes(k))
543
+ if (have.length) lines.push(` ${label}: ${have.map(pair).join(', ')}`)
544
+ }
545
+ lines.push(` root: ${target.root}←${donor.root}`)
546
+ group('torso', [ 'Hips', 'Spine', 'Chest', 'UpperChest', 'Neck', 'Head', 'Jaw', 'LeftEye', 'RightEye' ])
547
+ for (const s of SIDES) group(`${s.toLowerCase()} arm`, [ `${s}Shoulder`, `${s}UpperArm`, `${s}LowerArm`, `${s}Hand` ])
548
+ for (const s of SIDES) group(`${s.toLowerCase()} leg`, [ `${s}UpperLeg`, `${s}LowerLeg`, `${s}Foot`, `${s}Toes` ])
549
+ for (const s of SIDES) {
550
+ const fs = FINGERS.filter((f) => hm.shared.includes(`${s} ${f} Proximal`))
551
+ if (fs.length) lines.push(` ${s.toLowerCase()} fingers: ${fs.map((f) => `${f.toLowerCase()} ×${PHALANGES.filter((p) => hm.shared.includes(`${s} ${f} ${p}`)).length}`).join(', ')}`)
552
+ }
553
+ if (hm.targetOnly.length) lines.push(` target only (kept at bind): ${hm.targetOnly.join(', ')}`)
554
+ if (hm.donorOnly.length) lines.push(` donor only (dropped): ${hm.donorOnly.join(', ')}`)
555
+ const tw = Object.entries(target.twist)
556
+ if (tw.length) lines.push(` target twist helpers: ${tw.map(([ b, of ]) => `${b}→${of}`).join(', ')}`)
557
+ if (target.bridged.length) lines.push(` target bridged: ${target.bridged.join(', ')}`)
558
+ for (const n of target.notes) lines.push(` ⚠ target: ${n}`)
559
+ for (const n of donor.notes) lines.push(` ⚠ donor: ${n}`)
560
+ return lines
561
+ }