/** * Canonical skeleton — GDD §10.2. * * Mixamo-compatible core set. The harness builds joints under these names and * binds skin weights to them, so every generated character plays the same * procedural clips (and any future shared animation library). */ export const CANONICAL_BONES = [ "Hips", "Spine", "Spine1", "Spine2", "Neck", "Head", "LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand", "RightShoulder", "RightArm", "RightForeArm", "RightHand", "LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase", "RightUpLeg", "RightLeg", "RightFoot", "RightToeBase", ] as const; export type CanonicalBone = (typeof CANONICAL_BONES)[number]; /** Parent of each bone; `null` marks the skeleton root. */ export const CANONICAL_HIERARCHY: Record = { Hips: null, Spine: "Hips", Spine1: "Spine", Spine2: "Spine1", Neck: "Spine2", Head: "Neck", LeftShoulder: "Spine2", LeftArm: "LeftShoulder", LeftForeArm: "LeftArm", LeftHand: "LeftForeArm", RightShoulder: "Spine2", RightArm: "RightShoulder", RightForeArm: "RightArm", RightHand: "RightForeArm", LeftUpLeg: "Hips", LeftLeg: "LeftUpLeg", LeftFoot: "LeftLeg", LeftToeBase: "LeftFoot", RightUpLeg: "Hips", RightLeg: "RightUpLeg", RightFoot: "RightLeg", RightToeBase: "RightFoot", }; /** Bones a rigger must produce for a clip to be playable at all. */ export const REQUIRED_BONES: readonly CanonicalBone[] = [ "Hips", "Spine", "Head", "LeftArm", "RightArm", "LeftUpLeg", "LeftLeg", "LeftFoot", "RightUpLeg", "RightLeg", "RightFoot", ]; const CANONICAL_SET: ReadonlySet = new Set(CANONICAL_BONES); export function isCanonicalBone(name: string): name is CanonicalBone { return CANONICAL_SET.has(name); } /** Depth-first walk order, parents always before children. */ export function canonicalWalkOrder(): CanonicalBone[] { const out: CanonicalBone[] = []; const visit = (bone: CanonicalBone) => { out.push(bone); for (const child of CANONICAL_BONES) { if (CANONICAL_HIERARCHY[child] === bone) visit(child); } }; visit("Hips"); return out; } /** * Common aliases emitted by third-party riggers, normalised to canonical names. * Mixamo's `mixamorig:` prefix is stripped before lookup. */ export const BONE_ALIASES: Record = { hip: "Hips", pelvis: "Hips", root: "Hips", spine_01: "Spine", spine_02: "Spine1", spine_03: "Spine2", chest: "Spine2", upperchest: "Spine2", head_end: "Head", clavicle_l: "LeftShoulder", clavicle_r: "RightShoulder", upperarm_l: "LeftArm", upperarm_r: "RightArm", lowerarm_l: "LeftForeArm", lowerarm_r: "RightForeArm", hand_l: "LeftHand", hand_r: "RightHand", thigh_l: "LeftUpLeg", thigh_r: "RightUpLeg", calf_l: "LeftLeg", calf_r: "RightLeg", foot_l: "LeftFoot", foot_r: "RightFoot", ball_l: "LeftToeBase", ball_r: "RightToeBase", }; /** Best-effort mapping of an arbitrary rigger bone name onto the canonical set. */ export function normaliseBoneName(raw: string): CanonicalBone | null { const stripped = raw.replace(/^mixamorig[:_]?/i, "").trim(); if (isCanonicalBone(stripped)) return stripped; const key = stripped.toLowerCase().replace(/[\s.-]+/g, "_"); const alias = BONE_ALIASES[key]; if (alias) return alias; // `LeftArm` vs `leftarm` vs `Left_Arm` const squashed = key.replace(/_/g, ""); for (const bone of CANONICAL_BONES) { if (bone.toLowerCase() === squashed) return bone; } return null; }