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.
- package/README.md +561 -449
- package/dist/cli.js +5632 -521
- package/dist/index.js +5647 -526
- package/package.json +13 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli.ts +1039 -0
- package/src/doctor/doctor.ts +241 -0
- package/src/doctor/lightmapUv.ts +452 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +124 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +109 -0
- package/src/fbx/toDocument.ts +209 -0
- package/src/filmstrip.ts +263 -0
- package/src/index.ts +17 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +171 -0
- package/src/retarget/humanoid.ts +561 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2272 -0
- package/src/retarget/rig.ts +542 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +132 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +151 -0
- package/src/terrainPack.ts +169 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/materials.ts +593 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabs.ts +80 -0
- package/src/unity/scene.ts +363 -0
- package/src/unity/unpack.ts +130 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +114 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materials.ts +291 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +659 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +51 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/texture.ts +202 -0
- package/src/unreal/toDocument.ts +360 -0
- package/src/unreal/versions.ts +100 -0
- package/src/wasm/fbxReader.js +14 -0
- 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
|
+
}
|