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.
- package/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -0
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -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 +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -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 +238 -0
- package/src/retarget/humanoid.ts +572 -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 +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +149 -0
- package/src/unity/yaml.ts +131 -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 +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -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/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- 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
|
+
}
|