lecodes-assets 0.2.0 → 2.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
package/src/probe.ts ADDED
@@ -0,0 +1,167 @@
1
+ // probe — what's in an FBX or GLB: skeleton, clips + track counts, meshes, materials, textures, and
2
+ // (GLB) a skin bind sanity check. Prints what the AI/dev needs to decide before/after converting.
3
+
4
+ import { readFileSync } from "node:fs"
5
+ import { extname } from "node:path"
6
+ import type { Document, Node } from "@gltf-transform/core"
7
+ import { readFbx, f32 } from "./fbx/read"
8
+ import { makeIO } from "./fbx/convert"
9
+ import { measureScale } from "./doctor/normalizeScale"
10
+ import { duplicateNodeNames } from "./mergeSkins"
11
+
12
+ const fmt = (n: number, d = 2): string => n.toFixed(d)
13
+
14
+ export const probeFbx = async (path: string): Promise<string> => {
15
+ const s = await readFbx(new Uint8Array(readFileSync(path)))
16
+ const bones = s.nodes.filter((n) => n.isBone)
17
+ const meshNodes = s.nodes.filter((n) => n.mesh >= 0)
18
+ const lines: string[] = []
19
+ lines.push(`FBX ${path}`)
20
+ lines.push(` creator: ${s.meta.creator || "?"} · file unit: ${s.meta.unitMeters} m (converted to meters) · nodes ${s.nodes.length} · bones ${bones.length}`)
21
+ lines.push(` meshes ${s.meshes.length} (${meshNodes.map((n) => n.name).join(", ") || "none"}) · skins ${s.skins.length}${s.skins.length ? ` (${s.skins.map((k) => k.joints.length + " joints").join(", ")})` : ""}`)
22
+ for (const m of s.meshes) for (const p of m.primitives) lines.push(` ${m.name}: ${p.vertexCount} verts / ${p.indexCount / 3} tris · mat ${p.material >= 0 ? s.materials[p.material]?.name : "-"} · ${p.joints >= 0 ? "skinned" : "static"} · size ${p.max.map((v, i) => fmt(v - p.min[i])).join("×")} m`)
23
+ lines.push(` materials ${s.materials.length}: ${s.materials.map((m) => `${m.name}[${m.shader}${m.baseTex >= 0 ? ",tex" : ""}${m.normalTex >= 0 ? ",nrm" : ""}${m.opacityTex >= 0 ? ",alpha" : ""}]`).join(", ") || "none"}`)
24
+ lines.push(` textures ${s.textures.length}: ${s.textures.map((t) => `${(t.relative || t.filename).split(/[\\/]/).pop()}${t.content >= 0 ? ` (embedded ${(t.size / 1024).toFixed(0)}KB)` : " (external)"}`).join(", ") || "none"}`)
25
+ const anims = s.animations.filter((a) => a.tracks.length > 0)
26
+ lines.push(` clips ${anims.length}: ${anims.map((a) => `"${a.name}" ${fmt(a.duration)}s ${a.tracks.length} tracks/${a.tracks.reduce((n, t) => n + t.count, 0)} keys${a.tracks.every((t) => t.constant) ? " (STATIC pose)" : ""}`).join(", ") || "none"}`)
27
+ const rootBones = bones.filter((b) => !(s.nodes[b.parent]?.isBone))
28
+ if (rootBones.length) lines.push(` skeleton root(s): ${rootBones.map((b) => b.name || "(unnamed)").join(", ")}`)
29
+ // per clip: what the root bone(s) do — travel (root motion) and the first frame vs the rest pose
30
+ for (const a of anims) {
31
+ for (const rb of rootBones.filter((b) => !b.helper && b.name)) {
32
+ const idx = s.nodes.indexOf(rb)
33
+ const tr = a.tracks.find((t) => t.node === idx && t.path === "translation")
34
+ const rot = a.tracks.find((t) => t.node === idx && t.path === "rotation")
35
+ if (!tr && !rot) continue
36
+ const parts: string[] = []
37
+ if (tr) {
38
+ const v = f32(s, tr.values, tr.count * 3)
39
+ const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
40
+ for (let i = 0; i < v.length; i += 3) for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], v[i + k]); hi[k] = Math.max(hi[k], v[i + k]) }
41
+ const start = Math.hypot(v[0] - rb.t[0], v[1] - rb.t[1], v[2] - rb.t[2])
42
+ parts.push(`moves ${fmt(hi[0] - lo[0])}×${fmt(hi[1] - lo[1])}×${fmt(hi[2] - lo[2])} m`)
43
+ if (start > 0.02) parts.push(`starts ${fmt(start)} m from rest`)
44
+ }
45
+ if (rot) {
46
+ const q = f32(s, rot.values, rot.count * 4)
47
+ const d = Math.min(1, Math.abs(q[0] * rb.r[0] + q[1] * rb.r[1] + q[2] * rb.r[2] + q[3] * rb.r[3]))
48
+ parts.push(`first frame ${(2 * Math.acos(d) * 180 / Math.PI).toFixed(0)}° from rest`)
49
+ }
50
+ lines.push(` 🎞 "${a.name}" root '${rb.name || "(unnamed)"}': ${parts.join(" · ")}`)
51
+ }
52
+ }
53
+ const names = new Map<string, number>()
54
+ for (const n of s.nodes) if (n.name && !n.isRoot) names.set(n.name, (names.get(n.name) ?? 0) + 1)
55
+ const dups = [ ...names ].filter(([ , c ]) => c > 1)
56
+ if (dups.length) lines.push(` ⚠ ${dups.length} duplicate node name(s): ${dups.slice(0, 6).map(([ n, c ]) => `${n} ×${c}`).join(", ")}${dups.length > 6 ? "…" : ""} — clips bind by name to the first one`)
57
+ return lines.join("\n")
58
+ }
59
+
60
+ const mul = (a: number[], b: number[]): number[] => {
61
+ const o = new Array(16).fill(0)
62
+ 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]
63
+ return o
64
+ }
65
+
66
+ const worldMatrix = (n: Node, cache: Map<Node, number[]>): number[] => {
67
+ const hit = cache.get(n)
68
+ if (hit) return hit
69
+ const local = n.getMatrix() as unknown as number[]
70
+ const parent = n.getParentNode()
71
+ const w = parent ? mul(worldMatrix(parent, cache), Array.from(local)) : Array.from(local)
72
+ cache.set(n, w)
73
+ return w
74
+ }
75
+
76
+ export const probeDocument = (doc: Document, label: string): string => {
77
+ const root = doc.getRoot()
78
+ const lines: string[] = []
79
+ const nodes = root.listNodes()
80
+ const skins = root.listSkins()
81
+ const jointSet = new Set(skins.flatMap((s) => s.listJoints()))
82
+ lines.push(`GLB ${label}`)
83
+ lines.push(` nodes ${nodes.length} · joints ${jointSet.size} · meshes ${root.listMeshes().length} · skins ${skins.length}${skins.length ? ` (${skins.map((s) => s.listJoints().length + " joints").join(", ")})` : ""} · materials ${root.listMaterials().length} · textures ${root.listTextures().length}`)
84
+ for (const m of root.listMeshes()) for (const p of m.listPrimitives()) {
85
+ const pos = p.getAttribute("POSITION")
86
+ const idx = p.getIndices()
87
+ const min = pos?.getMin([]) ?? [ 0, 0, 0 ], max = pos?.getMax([]) ?? [ 0, 0, 0 ]
88
+ lines.push(` ${m.getName() || "mesh"}: ${pos?.getCount() ?? 0} verts / ${idx ? idx.getCount() / 3 : "?"} tris · mat ${p.getMaterial()?.getName() || "-"} · ${p.getAttribute("JOINTS_0") ? "skinned" : "static"} · size ${max.map((v, i) => fmt(v - min[i])).join("×")}`)
89
+ }
90
+ lines.push(` materials: ${root.listMaterials().map((m) => `${m.getName() || "?"}[${m.getAlphaMode()}${m.getBaseColorTexture() ? ",tex" : ""}${m.getNormalTexture() ? ",nrm" : ""}${m.getDoubleSided() ? ",2side" : ""} r${fmt(m.getRoughnessFactor(), 2)} m${fmt(m.getMetallicFactor(), 2)}]`).join(", ") || "none"}`)
91
+ lines.push(` textures: ${root.listTextures().map((t) => `${t.getName() || "?"} ${t.getMimeType()} ${((t.getImage()?.byteLength ?? 0) / 1024).toFixed(0)}KB`).join(", ") || "none"}`)
92
+ // Rendered world size — the mesh rows above are LOCAL bounds, which say nothing about how big the
93
+ // asset lands in a scene once node transforms (or a skin's bind space) are applied.
94
+ const sc = measureScale(doc)
95
+ if (sc) lines.push(` world size: ${sc.dims.map((d) => fmt(d, 3)).join("×")} m${sc.suspicious ? ` ⚠ implausible (${sc.label}) — doctor --fix-scale bakes ×${+sc.suggested.toPrecision(6)}` : ""}`)
96
+ const anims = root.listAnimations()
97
+ lines.push(` clips ${anims.length}: ${anims.map((a) => {
98
+ let dur = 0
99
+ for (const s of a.listSamplers()) { const t = s.getInput(); if (t) dur = Math.max(dur, t.getMax([])[0] ?? 0) }
100
+ return `"${a.getName()}" ${fmt(dur)}s ${a.listChannels().length} channels`
101
+ }).join(", ") || "none"}`)
102
+ // Per-clip root summary: how far the skeleton root travels (root motion) and whether the clip's
103
+ // first frame agrees with the rest pose. A root that starts 90° from rest is the double-axis-turn
104
+ // symptom (a clip merged from an export with a different wrapper) — the rig plays lying down.
105
+ const rootJoints = [ ...jointSet ].filter((j) => j.getName() && (() => { const p = j.getParentNode(); return !p || !jointSet.has(p) })())
106
+ for (const a of anims) {
107
+ for (const rj of rootJoints) {
108
+ const tr = a.listChannels().find((c) => c.getTargetNode() === rj && c.getTargetPath() === "translation")
109
+ const rot = a.listChannels().find((c) => c.getTargetNode() === rj && c.getTargetPath() === "rotation")
110
+ if (!tr && !rot) continue
111
+ const parts: string[] = []
112
+ let bad = false
113
+ if (tr) {
114
+ const v = tr.getSampler()!.getOutput()!.getArray()!
115
+ const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
116
+ for (let i = 0; i < v.length; i += 3) for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], v[i + k]); hi[k] = Math.max(hi[k], v[i + k]) }
117
+ const rest = rj.getTranslation()
118
+ const start = Math.hypot(v[0] - rest[0], v[1] - rest[1], v[2] - rest[2])
119
+ parts.push(`moves ${fmt(hi[0] - lo[0])}×${fmt(hi[1] - lo[1])}×${fmt(hi[2] - lo[2])} m`)
120
+ if (start > 0.02) parts.push(`starts ${fmt(start)} m from rest`)
121
+ if (start > 0.5) bad = true
122
+ }
123
+ if (rot) {
124
+ const q = rot.getSampler()!.getOutput()!.getArray()!
125
+ const r = rj.getRotation()
126
+ const d = Math.min(1, Math.abs(q[0] * r[0] + q[1] * r[1] + q[2] * r[2] + q[3] * r[3]))
127
+ const deg = 2 * Math.acos(d) * 180 / Math.PI
128
+ parts.push(`first frame ${deg.toFixed(0)}° from rest`)
129
+ if (deg > 60) bad = true // Mixamo hips routinely start 10–45° off a T-pose rest; the double axis turn is 90°
130
+ }
131
+ lines.push(` 🎞 "${a.getName()}" root '${rj.getName()}': ${parts.join(" · ")}${bad ? " ⚠ clip starts far from the rest pose — a parent/axis mismatch with the model (clip from another export?)" : ""}`)
132
+ }
133
+ }
134
+ const dups = duplicateNodeNames(doc)
135
+ if (dups.length) {
136
+ const shadow = dups.filter((d) => d.shadowsJoint)
137
+ lines.push(` ${shadow.length ? "⚠" : "ℹ"} ${dups.length} duplicate node name(s): ${dups.slice(0, 6).map((d) => `${d.name} ×${d.count}`).join(", ")}${dups.length > 6 ? "…" : ""}${shadow.length ? ` — ${shadow.length} shadow a skinned joint with a plain node: a name-bound Animator drives the copy and the mesh freezes (stray donor skeleton? use convert --merge-skins)` : " — clips and node paths address the first one"}`)
138
+ }
139
+ // bind sanity: for each skin, world(joint at rest) × IBM should agree across all joints (= the mesh's bind-space transform)
140
+ const cache = new Map<Node, number[]>()
141
+ for (const s of skins) {
142
+ const ibm = s.getInverseBindMatrices()
143
+ if (!ibm) continue
144
+ let ref: number[] | null = null
145
+ let maxDev = 0
146
+ const m = new Array(16).fill(0)
147
+ s.listJoints().forEach((j, i) => {
148
+ ibm.getElement(i, m)
149
+ const prod = mul(worldMatrix(j, cache), m.slice())
150
+ if (!ref) { ref = prod; return }
151
+ for (let k = 0; k < 16; k++) maxDev = Math.max(maxDev, Math.abs(prod[k] - ref[k]))
152
+ })
153
+ const r = ref as number[] | null
154
+ lines.push(` skin "${s.getName() || "?"}": bind sanity — joints agree to ${maxDev.toExponential(1)}${r ? `, bind space ≈ ${Math.abs(r[0] - 1) < 1e-3 && Math.abs(r[5] - 1) < 1e-3 && Math.hypot(r[12], r[13], r[14]) < 1e-3 ? "identity" : `[scale ${fmt(Math.hypot(r[0], r[1], r[2]), 3)}, offset ${fmt(r[12])},${fmt(r[13])},${fmt(r[14])}]`}` : ""}${maxDev > 0.1 ? " ⚠ rest pose ≠ bind pose by a lot — check units/axes/scale" : maxDev > 1e-3 ? " (rest pose ≠ bind pose slightly — an asset property, harmless)" : ""}`)
155
+ }
156
+ const orphans = nodes.filter((n) => !n.getMesh() && !jointSet.has(n) && n.listChildren().length === 0)
157
+ if (orphans.length) lines.push(` ${orphans.length} leaf node(s) with no mesh/joint role (e.g. ${orphans.slice(0, 4).map((n) => n.getName()).join(", ")})`)
158
+ return lines.join("\n")
159
+ }
160
+
161
+ export const probeFile = async (path: string): Promise<string> => {
162
+ const ext = extname(path).toLowerCase()
163
+ if (ext === ".fbx") return probeFbx(path)
164
+ if (ext === ".uasset") return (await import("./unreal/probe")).probeUnreal(path)
165
+ const doc = await makeIO().read(path)
166
+ return probeDocument(doc, path)
167
+ }
package/src/props.ts ADDED
@@ -0,0 +1,137 @@
1
+ // Props out of multi-variant files. Asset-store packs ship every LOD, colour set and arrangement of
2
+ // a prop in ONE FBX (`Barrel_v1.FBX` = 15 meshes: Barrel_v1_LD1, _LD2, _S1, _rusty…); a game wants
3
+ // one of them per GLB, sitting on the ground at the origin. `keepNodes` carves the named subtrees
4
+ // out of a Document and re-origins what is left (X/Z centred on its footprint, Y = 0 at the lowest
5
+ // vertex) so a `Model.load` lands the prop exactly where the node is; `splitDoc` does that for every
6
+ // top-level node at once (one Document each). Static geometry only — skinned meshes keep their
7
+ // skeleton's frame and are left where they are.
8
+
9
+ import { Logger, type Document, type Node } from "@gltf-transform/core"
10
+ import { cloneDocument, prune, unpartition } from "@gltf-transform/functions"
11
+
12
+ /** prune() narrates what it removed through the document's logger — not news for a deliberate cut. */
13
+ export const quiet = (doc: Document): Document => doc.setLogger(new Logger(Logger.Verbosity.SILENT))
14
+
15
+ export type Vec3 = [number, number, number]
16
+ export type NodeInfo = { name: string, depth: number, meshes: number, tris: number, size: Vec3, children: number }
17
+
18
+ const IDENTITY = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]
19
+ const mul = (a: number[], b: number[]): number[] => {
20
+ const o = new Array(16).fill(0)
21
+ 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]
22
+ return o
23
+ }
24
+ const xf = (m: number[], x: number, y: number, z: number): Vec3 =>
25
+ [ m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14] ]
26
+
27
+ type Bounds = { lo: Vec3, hi: Vec3, tris: number, meshes: number }
28
+ const emptyBounds = (): Bounds => ({ lo: [ Infinity, Infinity, Infinity ], hi: [ -Infinity, -Infinity, -Infinity ], tris: 0, meshes: 0 })
29
+ const grow = (b: Bounds, p: Vec3): void => { for (let k = 0; k < 3; k++) { if (p[k] < b.lo[k]) b.lo[k] = p[k]; if (p[k] > b.hi[k]) b.hi[k] = p[k] } }
30
+
31
+ /** World-space bounds of a subtree (node transforms applied; skinned primitives measured as-is). */
32
+ export const subtreeBounds = (node: Node, parentWorld: number[] = IDENTITY, into: Bounds = emptyBounds()): Bounds => {
33
+ const world = mul(parentWorld, Array.from(node.getMatrix()))
34
+ const mesh = node.getMesh()
35
+ if (mesh) {
36
+ into.meshes++
37
+ for (const prim of mesh.listPrimitives()) {
38
+ const pos = prim.getAttribute("POSITION")
39
+ if (!pos) continue
40
+ const idx = prim.getIndices()
41
+ into.tris += idx ? idx.getCount() / 3 : pos.getCount() / 3
42
+ const a = pos.getArray()!
43
+ for (let i = 0; i < a.length; i += 3) grow(into, xf(world, a[i], a[i + 1], a[i + 2]))
44
+ }
45
+ }
46
+ for (const c of node.listChildren()) subtreeBounds(c, world, into)
47
+ return into
48
+ }
49
+
50
+ const sizeOf = (b: Bounds): Vec3 => (b.lo[0] === Infinity ? [ 0, 0, 0 ] : [ b.hi[0] - b.lo[0], b.hi[1] - b.lo[1], b.hi[2] - b.lo[2] ])
51
+
52
+ /** The scene's nodes down to `depth` levels with mesh counts and world sizes — what `--keep` takes. */
53
+ export const listNodes = (doc: Document, depth = 1): NodeInfo[] => {
54
+ const out: NodeInfo[] = []
55
+ const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]
56
+ if (!scene) return out
57
+ const walk = (n: Node, d: number, parentWorld: number[]): void => {
58
+ const b = subtreeBounds(n, parentWorld)
59
+ out.push({ name: n.getName(), depth: d, meshes: b.meshes, tris: b.tris, size: sizeOf(b), children: n.listChildren().length })
60
+ if (d + 1 < depth) {
61
+ const world = mul(parentWorld, Array.from(n.getMatrix()))
62
+ for (const c of n.listChildren()) walk(c, d + 1, world)
63
+ }
64
+ }
65
+ for (const c of scene.listChildren()) walk(c, 0, IDENTITY)
66
+ return out
67
+ }
68
+
69
+ export type KeepResult = { kept: string[], missing: string[], size: Vec3, offset: Vec3 }
70
+
71
+ /**
72
+ * Keep only the named nodes (any depth; their ancestors stay as the path to them, their descendants
73
+ * stay with them), drop everything else, then re-origin: the kept geometry's footprint centre goes to
74
+ * X/Z = 0 and its lowest point to Y = 0 (`ground: false` keeps the file's own placement).
75
+ */
76
+ export const keepNodes = async (doc: Document, names: string[], opts: { ground?: boolean } = {}): Promise<KeepResult> => {
77
+ const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]
78
+ if (!scene) throw new Error("the file has no scene")
79
+ const wanted = new Set(names)
80
+ const found = new Set<string>()
81
+ const keep = new Set<Node>()
82
+ // a node survives if it is wanted, sits under a wanted node, or leads to one
83
+ const mark = (n: Node, underKept: boolean): boolean => {
84
+ const self = wanted.has(n.getName())
85
+ if (self) found.add(n.getName())
86
+ let leads = false
87
+ for (const c of n.listChildren()) if (mark(c, underKept || self)) leads = true
88
+ const survives = self || underKept || leads
89
+ if (survives) keep.add(n)
90
+ return survives
91
+ }
92
+ for (const c of scene.listChildren()) mark(c, false)
93
+ const doomed: Node[] = []
94
+ scene.traverse((n) => { if (!keep.has(n)) doomed.push(n) })
95
+ // children first: disposing a parent detaches its subtree, which is fine, but keeps the order obvious
96
+ for (const n of doomed.reverse()) n.dispose()
97
+ const missing = names.filter((n) => !found.has(n))
98
+
99
+ const b = emptyBounds()
100
+ for (const c of scene.listChildren()) subtreeBounds(c, IDENTITY, b)
101
+ const offset: Vec3 = [ 0, 0, 0 ]
102
+ if (opts.ground !== false && b.lo[0] !== Infinity) {
103
+ offset[0] = -(b.lo[0] + b.hi[0]) / 2; offset[1] = -b.lo[1]; offset[2] = -(b.lo[2] + b.hi[2]) / 2
104
+ for (const c of scene.listChildren()) {
105
+ const t = c.getTranslation()
106
+ c.setTranslation([ t[0] + offset[0], t[1] + offset[1], t[2] + offset[2] ])
107
+ }
108
+ }
109
+ await quiet(doc).transform(prune(), unpartition())
110
+ return { kept: [ ...found ], missing, size: sizeOf(b), offset }
111
+ }
112
+
113
+ /** One Document per top-level node, each re-origined (see `keepNodes`). Names are de-duplicated with ` (2)`. */
114
+ export const splitDoc = async (doc: Document, opts: { ground?: boolean } = {}): Promise<{ name: string, doc: Document, size: Vec3 }[]> => {
115
+ const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]
116
+ if (!scene) return []
117
+ const out: { name: string, doc: Document, size: Vec3 }[] = []
118
+ const seen = new Map<string, number>()
119
+ for (const top of scene.listChildren()) {
120
+ const name = top.getName() || "node"
121
+ const n = (seen.get(name) ?? 0) + 1
122
+ seen.set(name, n)
123
+ // keepNodes picks by NAME — a duplicate top-level name gets its siblings too; give it a unique one first
124
+ const unique = n > 1 ? `${name} (${n})` : name
125
+ const copy = cloneDocument(doc)
126
+ const copyScene = copy.getRoot().getDefaultScene() ?? copy.getRoot().listScenes()[0]
127
+ const twin = copyScene.listChildren()[scene.listChildren().indexOf(top)]
128
+ twin.setName(`__split__${unique}`)
129
+ const r = await keepNodes(copy, [ `__split__${unique}` ], opts)
130
+ twin.setName(name)
131
+ out.push({ name: unique, doc: copy, size: r.size })
132
+ }
133
+ return out
134
+ }
135
+
136
+ /** File-system friendly stem for a node name (`Barrel_v1 (2)` → `Barrel_v1-2`). */
137
+ export const fileStem = (name: string): string => name.replace(/\s*\((\d+)\)$/, "-$1").replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "") || "node"
@@ -0,0 +1,276 @@
1
+ // Automatic bone map for a quadruped retarget — from SKELETON SHAPE, not names.
2
+ //
3
+ // Bone names are unreliable across rigs (`backleg0`, `Knee_L`, `actor:skin_backKnee_L`, `joint17`),
4
+ // but every quadruped skeleton has the same shape: a centreline from the tail through the pelvis
5
+ // and chest to the head, four lateral chains hanging down to the ground, ears/jaw/eyes branching off
6
+ // the head. `classify` reads that structure off the bind pose of one rig — root, spine, neck, head,
7
+ // tail, legs (side + end), ears, jaw — and `autoMap` pairs the two structures chain by chain,
8
+ // matching bones within a chain by normalized arc length (a 6-bone spine against a 3-bone spine maps
9
+ // its ends exactly and the middle to the nearest donor bone; leftover target bones stay unmapped and
10
+ // become bridges in the retarget). The result is a starting map the user reads and corrects, with a
11
+ // report of what was and was not recognised — never a silent guess.
12
+ import type { Rig, Vec3 } from './rig'
13
+ import type { BoneMap } from './retarget'
14
+
15
+ export interface LegInfo { side: 'L' | 'R'; end: 'front' | 'back'; chain: number[] } // hip → … → ball of foot
16
+
17
+ export interface Skeleton {
18
+ root: number
19
+ forward: Vec3 // horizontal unit vector, tail → head
20
+ lateral: Vec3 // unit vector to the rig's LEFT (+x when facing +z)
21
+ size: number // largest bounding extent
22
+ spine: number[] // root … chest (inclusive both ends; length 1 when root == chest)
23
+ chest: number
24
+ neck: number[] // first bone after chest … head (inclusive)
25
+ head: number | null
26
+ jaw: number | null
27
+ ears: [number, number] | null // [left, right]
28
+ tail: number[] // first tail bone … tip
29
+ legs: LegInfo[]
30
+ notes: string[]
31
+ }
32
+
33
+ const sub = (a: Vec3, b: Vec3): Vec3 => [ a[0] - b[0], a[1] - b[1], a[2] - b[2] ]
34
+ const dot = (a: Vec3, b: Vec3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
35
+ const len = (v: Vec3): number => Math.hypot(v[0], v[1], v[2])
36
+
37
+ /** Structure of one rig from its bind pose. Works on skinned joints only. */
38
+ export const classify = (rig: Rig): Skeleton => {
39
+ const notes: string[] = []
40
+ const skinned = (i: number): boolean => rig.bind.fromSkin[i]
41
+ const pos = rig.bind.worldPos
42
+ const children: number[][] = rig.names.map(() => [])
43
+ rig.parents.forEach((p, i) => { if (p >= 0) children[p].push(i) })
44
+ const skids = (i: number): number[] => {
45
+ // Skinned descendants reachable without crossing another skinned joint.
46
+ const out: number[] = []
47
+ const q = [ ...children[i] ]
48
+ while (q.length) { const c = q.shift()!; if (skinned(c)) out.push(c); else q.push(...children[c]) }
49
+ return out
50
+ }
51
+ const sparent = (i: number): number => { let p = rig.parents[i]; while (p >= 0 && !skinned(p)) p = rig.parents[p]; return p }
52
+
53
+ // Root: the skinned joint with no skinned ancestor (the first one in topological order).
54
+ const root = rig.order.find((i) => skinned(i) && sparent(i) < 0)
55
+ if (root === undefined) throw new Error(`${rig.label}: no skinned joints`)
56
+ const joints = rig.order.filter(skinned)
57
+
58
+ // Bounds and body frame. Forward = the principal horizontal axis, pointing to the end where the
59
+ // high joints are (head, ears, neck); lateral = up × forward.
60
+ let lo: Vec3 = [ Infinity, Infinity, Infinity ], hi: Vec3 = [ -Infinity, -Infinity, -Infinity ]
61
+ for (const i of joints) for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], pos[i][k]); hi[k] = Math.max(hi[k], pos[i][k]) }
62
+ const size = Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) || 1
63
+ const height = hi[1] - lo[1] || size
64
+ const cx = joints.reduce((a, i) => a + pos[i][0], 0) / joints.length, cz = joints.reduce((a, i) => a + pos[i][2], 0) / joints.length
65
+ let sxx = 0, sxz = 0, szz = 0
66
+ for (const i of joints) { const x = pos[i][0] - cx, z = pos[i][2] - cz; sxx += x * x; sxz += x * z; szz += z * z }
67
+ // Largest-eigenvector of the 2×2 covariance [[sxx, sxz], [sxz, szz]].
68
+ const theta = 0.5 * Math.atan2(2 * sxz, sxx - szz)
69
+ let forward: Vec3 = [ Math.cos(theta), 0, Math.sin(theta) ]
70
+ const top = [ ...joints ].sort((a, b) => pos[b][1] - pos[a][1]).slice(0, Math.max(1, Math.ceil(joints.length * 0.1)))
71
+ const topProj = top.reduce((a, i) => a + dot(sub(pos[i], [ cx, 0, cz ]), forward), 0) / top.length
72
+ if (topProj < 0) forward = [ -forward[0], 0, -forward[2] ]
73
+ const lateral: Vec3 = [ forward[2], 0, -forward[0] ] // up × forward = (0,1,0) × (fx,0,fz) = (fz, 0, -fx)
74
+ const fwd = (i: number): number => dot(sub(pos[i], pos[root]), forward)
75
+ const lat = (i: number): number => dot(sub(pos[i], pos[root]), lateral)
76
+ const centre = (i: number): boolean => Math.abs(lat(i)) < 0.03 * size
77
+
78
+ // Legs: chains that hang down from the body to the ground, off the centreline.
79
+ const legs: LegInfo[] = []
80
+ {
81
+ const leaves = joints.filter((i) => skids(i).length === 0)
82
+ const feet = leaves.filter((i) => pos[i][1] - lo[1] < 0.2 * height && Math.abs(lat(i)) > 0.03 * size)
83
+ const taken = new Set<number>()
84
+ for (const f of feet) {
85
+ // Up to the first centreline joint (the attach point on the body): that is the leg's extent.
86
+ const chain: number[] = []
87
+ let b = f
88
+ while (b >= 0 && !centre(b)) { chain.unshift(b); b = sparent(b) }
89
+ if (b < 0 || chain.length < 3) continue
90
+ if (chain.some((i) => taken.has(i))) continue // toes of a leg already found
91
+ // Ball of the foot: the first joint from the hip at the chain's lowest level.
92
+ const lowest = Math.min(...chain.map((i) => pos[i][1]))
93
+ const ball = chain.findIndex((i) => pos[i][1] - lowest < 0.04 * height)
94
+ const leg = chain.slice(0, Math.max(2, ball) + 1)
95
+ for (const i of leg) taken.add(i)
96
+ legs.push({ side: lat(leg[0]) > 0 ? 'L' : 'R', end: 'back', chain: leg })
97
+ }
98
+ // Front/back by where the leg attaches along the body. Two or four legs expected.
99
+ legs.sort((a, b) => fwd(a.chain[0]) - fwd(b.chain[0]))
100
+ if (legs.length >= 4) { legs[legs.length - 1].end = 'front'; legs[legs.length - 2].end = 'front' }
101
+ else if (legs.length === 3) { legs[2].end = 'front'; notes.push('three legs found — check the map') }
102
+ else if (legs.length === 2) {
103
+ // A biped, or a quadruped whose other pair was not recognised.
104
+ const mid = (fwd(legs[0].chain[0]) + fwd(legs[1].chain[0])) / 2
105
+ const bodyMid = (Math.min(...joints.map(fwd)) + Math.max(...joints.map(fwd))) / 2
106
+ if (mid > bodyMid) for (const l of legs) l.end = 'front'
107
+ notes.push('two legs found')
108
+ } else if (!legs.length) notes.push('no legs recognised')
109
+ }
110
+
111
+ // Chest: the lowest common ancestor of the front legs (or of all legs when there is no front pair).
112
+ const ancestors = (i: number): number[] => { const out: number[] = []; for (let a = i; a >= 0; a = sparent(a)) out.push(a); return out }
113
+ const lca = (list: number[]): number => {
114
+ if (!list.length) return root
115
+ let common = ancestors(list[0])
116
+ for (const i of list.slice(1)) { const set = new Set(ancestors(i)); common = common.filter((a) => set.has(a)) }
117
+ return common[0] ?? root
118
+ }
119
+ const front = legs.filter((l) => l.end === 'front')
120
+ let chest = front.length ? lca(front.map((l) => sparent(l.chain[0]))) : root
121
+ // The hip attach of the back legs should be at/near the root; if the root sits far behind the
122
+ // pelvis (a long "root bone"), the spine still starts at the root.
123
+ const pathUp = (from: number, to: number): number[] => { const out: number[] = []; for (let a = from; a >= 0; a = sparent(a)) { out.unshift(a); if (a === to) break } return out }
124
+ if (!ancestors(chest).includes(root)) chest = root
125
+ const spine = pathUp(chest, root)
126
+ if (spine[0] !== root) { notes.push('chest is not below the root — spine left as root only'); spine.splice(0, spine.length, root) }
127
+
128
+ // Neck/head: from the chest, the centreline path that keeps going forward. The head is the last
129
+ // joint on it that has side branches (ears/eyes/jaw); otherwise the path's end.
130
+ const legJoints = new Set(legs.flatMap((l) => l.chain))
131
+ const tail: number[] = []
132
+ const neck: number[] = []
133
+ let head: number | null = null, jaw: number | null = null, ears: [number, number] | null = null
134
+ {
135
+ const frontKids = (i: number): number[] => skids(i).filter((c) => !legJoints.has(c) && centre(c) && fwd(c) > fwd(i) - 0.01 * size)
136
+ let cur = chest
137
+ const path: number[] = []
138
+ for (let guard = 0; guard < 64; guard++) {
139
+ const ks = frontKids(cur).sort((a, b) => fwd(b) - fwd(a))
140
+ if (!ks.length) break
141
+ cur = ks[0]; path.push(cur)
142
+ }
143
+ if (path.length) {
144
+ // Head: the furthest joint on the path that branches; jaw/ears hang off it.
145
+ let h = path.length - 1
146
+ while (h > 0 && skids(path[h]).length < 2) h--
147
+ if (skids(path[h]).length < 2) h = path.length - 1
148
+ head = path[h]
149
+ neck.push(...path.slice(0, h + 1))
150
+ const kids = skids(head)
151
+ const pair = kids.filter((c) => Math.abs(lat(c)) > 0.02 * size)
152
+ const left = pair.filter((c) => lat(c) > 0).sort((a, b) => pos[b][1] - pos[a][1]), right = pair.filter((c) => lat(c) < 0).sort((a, b) => pos[b][1] - pos[a][1])
153
+ if (left.length && right.length) ears = [ left[0], right[0] ] // the highest lateral pair: ears
154
+ const centreKids = kids.filter((c) => !pair.includes(c))
155
+ // Jaw (or snout): a centreline child heading forward and DOWN, with its own chain preferably.
156
+ const j = centreKids.filter((c) => pos[c][1] < pos[head!][1] - 0.01 * size && fwd(c) > fwd(head!)).sort((a, b) => skids(b).length - skids(a).length)[0]
157
+ if (j !== undefined) jaw = j
158
+ } else notes.push('no neck/head found forward of the chest')
159
+
160
+ // Tail: from the root (or a joint right next to it), the centreline path heading backward. The
161
+ // first tail bone often sits ON the root, so a candidate counts by where its subtree goes.
162
+ const subtreeFwd = (i: number): number => { let s = 0, c = 0; const q = [ i ]; while (q.length) { const j = q.shift()!; s += fwd(j); c++; q.push(...skids(j)) } return s / c }
163
+ const backKids = (i: number): number[] => skids(i).filter((c) => !legJoints.has(c) && !spine.includes(c) && !neck.includes(c) && centre(c) && subtreeFwd(c) < fwd(i) - 0.01 * size)
164
+ let start = root
165
+ let ks = backKids(start)
166
+ if (!ks.length && spine.length > 1) { start = spine[1]; ks = backKids(start) }
167
+ cur = start
168
+ for (let guard = 0; guard < 64 && ks.length; guard++) {
169
+ cur = ks.sort((a, b) => fwd(a) - fwd(b))[0]
170
+ tail.push(cur)
171
+ ks = backKids(cur)
172
+ }
173
+ }
174
+
175
+ return { root, forward, lateral, size, spine, chest, neck, head, jaw, ears, tail, legs, notes }
176
+ }
177
+
178
+ export interface AutoMapResult {
179
+ map: BoneMap
180
+ root: string
181
+ legs: string[]
182
+ target: Skeleton
183
+ donor: Skeleton
184
+ lines: string[] // human-readable pairing report
185
+ }
186
+
187
+ /**
188
+ * Map two classified skeletons onto each other. Chains pair by role; bones inside a chain pair by
189
+ * normalized arc length, ends exact, each donor bone used at most once per chain (extra target bones
190
+ * are left out → bridges).
191
+ */
192
+ export const autoMap = (target: Rig, donor: Rig): AutoMapResult => {
193
+ const T = classify(target), D = classify(donor)
194
+ const map: BoneMap = {}
195
+ const lines: string[] = []
196
+ const name = (rig: Rig, i: number): string => rig.names[i]
197
+
198
+ const arc = (rig: Rig, chain: number[], anchor: number): number[] => {
199
+ // Cumulative length from the chain's anchor joint (the joint it hangs off) through each bone,
200
+ // normalized so the chain's end is 1. A one-bone chain is therefore [1]: it IS the end.
201
+ const out: number[] = []
202
+ let acc = 0, prev = anchor
203
+ for (const c of chain) { acc += len(sub(rig.bind.worldPos[c], rig.bind.worldPos[prev])); out.push(acc); prev = c }
204
+ const total = acc || 1
205
+ return out.map((v) => v / total)
206
+ }
207
+ // Name hints: bones whose names share an anatomical token (knee/Knee_L, backAnkle/Ankle) pair for
208
+ // less — names are unreliable across rigs, but when they do agree they beat arc length.
209
+ const TOKENS = [ 'scapula', 'shoulder', 'elbow', 'wrist', 'finger', 'hand', 'hip', 'thigh', 'knee', 'shin', 'calf', 'ankle', 'toe', 'foot', 'spine', 'chest', 'neck', 'head', 'jaw', 'ear', 'eye', 'tail', 'rump' ]
210
+ // "frontHip/frontKnee/frontAnkle/frontToes/frontRump" is one rig family's name for the front leg:
211
+ // translate to the forelimb words so they meet Shoulder/Elbow/Wrist/Fingers/Scapula.
212
+ const FRONT: Record<string, string> = { hip: 'shoulder', thigh: 'shoulder', knee: 'elbow', shin: 'wrist', ankle: 'wrist', toe: 'finger', foot: 'hand', rump: 'scapula' }
213
+ const tokensOf = (s: string): Set<string> => {
214
+ const l = s.toLowerCase()
215
+ const found = TOKENS.filter((t) => l.includes(t))
216
+ return new Set(/front|fore/.test(l) ? found.map((t) => FRONT[t] ?? t) : found)
217
+ }
218
+ const nameBonus = (t: number, d: number): number => {
219
+ const a = tokensOf(name(target, t)), b = tokensOf(name(donor, d))
220
+ for (const x of a) if (b.has(x)) return 0.2
221
+ return 0
222
+ }
223
+ const pairChain = (role: string, tc: number[], dc: number[], tAnchor: number, dAnchor: number): void => {
224
+ if (!tc.length || !dc.length) { if (tc.length || dc.length) lines.push(` ${role}: ${tc.length ? 'donor' : 'target'} has none — skipped`); return }
225
+ const tu = arc(target, tc, tAnchor), du = arc(donor, dc, dAnchor)
226
+ // Monotone alignment of the two chains (sequence alignment on arc length): pairing costs the
227
+ // arc-length gap, skipping a bone on either side costs GAP, the two ENDS always pair (foot,
228
+ // head, tail tip). Extra target bones are left out and become bridges in the retarget.
229
+ const GAP = 0.12
230
+ const cost = (k: number, j: number): number => Math.abs(tu[k - 1] - du[j - 1]) - nameBonus(tc[k - 1], dc[j - 1])
231
+ const nt = tc.length - 1, nd = dc.length - 1 // ends handled separately
232
+ const Dp: number[][] = Array.from({ length: nt + 1 }, () => new Array(nd + 1).fill(0))
233
+ for (let k = 1; k <= nt; k++) Dp[k][0] = k * GAP
234
+ for (let j = 1; j <= nd; j++) Dp[0][j] = j * GAP
235
+ for (let k = 1; k <= nt; k++) for (let j = 1; j <= nd; j++) {
236
+ Dp[k][j] = Math.min(Dp[k - 1][j - 1] + cost(k, j), Dp[k - 1][j] + GAP, Dp[k][j - 1] + GAP)
237
+ }
238
+ const pairs: [number, number][] = []
239
+ for (let k = nt, j = nd; k > 0 && j > 0;) {
240
+ if (Math.abs(Dp[k][j] - (Dp[k - 1][j - 1] + cost(k, j))) < 1e-12) { pairs.unshift([ tc[k - 1], dc[j - 1] ]); k--; j-- }
241
+ else if (Dp[k][j] === Dp[k - 1][j] + GAP) k--
242
+ else j--
243
+ }
244
+ pairs.push([ tc[tc.length - 1], dc[dc.length - 1] ])
245
+ for (const [ t, d ] of pairs) map[name(target, t)] = name(donor, d)
246
+ lines.push(` ${role}: ${pairs.map(([ t, d ]) => `${name(target, t)}←${name(donor, d)}`).join(', ')}${tc.length > pairs.length ? ` (+${tc.length - pairs.length} target bone(s) bridged)` : ''}`)
247
+ }
248
+ const sparentOf = (rig: Rig) => (i: number): number => { let p = rig.parents[i]; while (p >= 0 && !rig.bind.fromSkin[p]) p = rig.parents[p]; return p }
249
+ const tsp = sparentOf(target), dsp = sparentOf(donor)
250
+
251
+ // The root pairs with the root; the rest of the spine hangs off it.
252
+ map[name(target, T.root)] = name(donor, D.root)
253
+ lines.push(` root: ${name(target, T.root)}←${name(donor, D.root)}`)
254
+ pairChain('spine', T.spine.slice(1), D.spine.slice(1), T.root, D.root)
255
+ pairChain('neck/head', T.neck, D.neck, T.chest, D.chest)
256
+ if (T.jaw !== null && D.jaw !== null) { map[name(target, T.jaw)] = name(donor, D.jaw); lines.push(` jaw: ${name(target, T.jaw)}←${name(donor, D.jaw)}`) }
257
+ if (T.ears && D.ears) { map[name(target, T.ears[0])] = name(donor, D.ears[0]); map[name(target, T.ears[1])] = name(donor, D.ears[1]); lines.push(` ears: ${name(target, T.ears[0])}←${name(donor, D.ears[0])}, ${name(target, T.ears[1])}←${name(donor, D.ears[1])}`) }
258
+ if (T.tail.length && D.tail.length) pairChain('tail', T.tail, D.tail, tsp(T.tail[0]), dsp(D.tail[0]))
259
+ else if (T.tail.length || D.tail.length) lines.push(` tail: ${T.tail.length ? 'donor' : 'target'} has none — skipped`)
260
+ const legNames: string[] = []
261
+ for (const tl of T.legs) {
262
+ const dl = D.legs.find((l) => l.side === tl.side && l.end === tl.end)
263
+ if (!dl) { lines.push(` leg ${tl.end}-${tl.side}: donor has none — skipped`); continue }
264
+ pairChain(`leg ${tl.end}-${tl.side}`, tl.chain, dl.chain, tsp(tl.chain[0]), dsp(dl.chain[0]))
265
+ legNames.push(name(target, tl.chain[tl.chain.length - 1]))
266
+ }
267
+ for (const n of [ ...T.notes.map((s) => `target: ${s}`), ...D.notes.map((s) => `donor: ${s}`) ]) lines.push(` ⚠ ${n}`)
268
+ return { map, root: name(target, T.root), legs: legNames, target: T, donor: D, lines }
269
+ }
270
+
271
+ /** One-line summary of a classified skeleton, for the CLI. */
272
+ export const describeSkeleton = (rig: Rig, s: Skeleton): string => {
273
+ const n = (i: number | null): string => (i === null ? '-' : rig.names[i])
274
+ return `root ${n(s.root)} · spine ${s.spine.length} · neck ${s.neck.length} · head ${n(s.head)} · jaw ${n(s.jaw)} · ears ${s.ears ? `${n(s.ears[0])}/${n(s.ears[1])}` : '-'} · tail ${s.tail.length} · legs ${s.legs.map((l) => `${l.end[0]}${l.side}:${l.chain.length}`).join(' ')} · facing [${s.forward.map((x) => x.toFixed(2)).join(',')}]`
275
+ }
276
+
@@ -0,0 +1,23 @@
1
+ // A rig's "ghost": the skeleton alone, posed by ONE of its clips — what a viewport overlays on a
2
+ // retargeted character to compare the transfer against the original. Meshes, skins, materials
3
+ // and the other clips go; the node tree (names = bone names) and the chosen animation stay.
4
+ import type { Document } from '@gltf-transform/core'
5
+ import { prune } from '@gltf-transform/functions'
6
+
7
+ export const ghostSkeleton = async (doc: Document, clip: number): Promise<Document> => {
8
+ const root = doc.getRoot()
9
+ for (const n of root.listNodes()) { n.setMesh(null); n.setSkin(null) }
10
+ for (const m of root.listMeshes()) { for (const p of m.listPrimitives()) p.dispose(); m.dispose() }
11
+ for (const s of root.listSkins()) s.dispose()
12
+ root.listAnimations().forEach((a, i) => { if (i !== clip) a.dispose() })
13
+ await doc.transform(prune())
14
+ // One degenerate triangle at the origin: glTF viewers built around meshes (OGL's GLTFLoader) refuse
15
+ // a file with none, and a mesh-less load leaves the node tree without a scene graph to animate.
16
+ const pos = doc.createAccessor('ghost_pos').setType('VEC3').setArray(new Float32Array(9))
17
+ const prim = doc.createPrimitive().setAttribute('POSITION', pos)
18
+ const mesh = doc.createMesh('ghost_mesh').addPrimitive(prim)
19
+ const node = doc.createNode('ghost_mesh').setMesh(mesh)
20
+ const scene = root.listScenes()[0] ?? doc.createScene('ghost')
21
+ scene.addChild(node)
22
+ return doc
23
+ }