lecodes-assets 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +240 -5
  2. package/dist/cli.js +7757 -683
  3. package/dist/index.js +7866 -675
  4. package/package.json +13 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli.ts +1039 -0
  8. package/src/doctor/doctor.ts +241 -0
  9. package/src/doctor/lightmapUv.ts +452 -0
  10. package/src/doctor/lod.ts +177 -0
  11. package/src/doctor/normalizeScale.ts +220 -0
  12. package/src/doctor/reduceGlbBones.ts +218 -0
  13. package/src/doctor/shading.ts +95 -0
  14. package/src/doctor/transcodeKtx2.ts +124 -0
  15. package/src/doctor/transcodeTextures.ts +101 -0
  16. package/src/doctor/validateGlb.ts +264 -0
  17. package/src/fbx/convert.ts +327 -0
  18. package/src/fbx/read.ts +109 -0
  19. package/src/fbx/toDocument.ts +209 -0
  20. package/src/filmstrip.ts +263 -0
  21. package/src/index.ts +17 -0
  22. package/src/mergeSkins.ts +117 -0
  23. package/src/probe.ts +167 -0
  24. package/src/props.ts +137 -0
  25. package/src/retarget/automap.ts +276 -0
  26. package/src/retarget/ghost.ts +23 -0
  27. package/src/retarget/heading.ts +171 -0
  28. package/src/retarget/humanoid.ts +561 -0
  29. package/src/retarget/inPlace.ts +130 -0
  30. package/src/retarget/marks.ts +581 -0
  31. package/src/retarget/mirror.ts +0 -0
  32. package/src/retarget/retarget.ts +2272 -0
  33. package/src/retarget/rig.ts +542 -0
  34. package/src/retarget/unityAnim.ts +171 -0
  35. package/src/retarget/writeMarks.ts +132 -0
  36. package/src/sky/analyze.ts +136 -0
  37. package/src/sky/cmgen.ts +71 -0
  38. package/src/sky/index.ts +151 -0
  39. package/src/terrainPack.ts +169 -0
  40. package/src/textures/resolveImage.ts +202 -0
  41. package/src/unity/materials.ts +593 -0
  42. package/src/unity/png.ts +75 -0
  43. package/src/unity/prefabs.ts +80 -0
  44. package/src/unity/scene.ts +363 -0
  45. package/src/unity/unpack.ts +130 -0
  46. package/src/unreal/anim.ts +284 -0
  47. package/src/unreal/clipSource.ts +25 -0
  48. package/src/unreal/convert.ts +67 -0
  49. package/src/unreal/convertAsset.ts +81 -0
  50. package/src/unreal/index.ts +114 -0
  51. package/src/unreal/list.ts +113 -0
  52. package/src/unreal/materials.ts +291 -0
  53. package/src/unreal/mesh.ts +205 -0
  54. package/src/unreal/oodle.ts +153 -0
  55. package/src/unreal/package.ts +659 -0
  56. package/src/unreal/probe.ts +144 -0
  57. package/src/unreal/resolve.ts +51 -0
  58. package/src/unreal/rig.ts +149 -0
  59. package/src/unreal/sequencer.ts +193 -0
  60. package/src/unreal/skeleton.ts +74 -0
  61. package/src/unreal/texture.ts +202 -0
  62. package/src/unreal/toDocument.ts +360 -0
  63. package/src/unreal/versions.ts +100 -0
  64. package/src/wasm/fbxReader.js +14 -0
  65. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,263 @@
1
+ // Software-skinned mesh filmstrip — the real silhouette of a GLB clip with no browser, no GPU, no
2
+ // engine and no native dependency. Evaluates the animation, skins the vertices on the CPU, flat-
3
+ // shades and z-buffers the triangles into one PNG strip (own encoder). This is the sanity check for
4
+ // `convert --clips`, `retarget` and `--merge-skins`: "does the character actually walk upright" is
5
+ // not a question a numeric check answers. `probe --filmstrip` and tools/filmstrip.ts both call it.
6
+
7
+ import { type Document } from "@gltf-transform/core"
8
+ import { rigFromDocument, qnorm, qslerp, type Pose, type Quat, type Vec3 } from "./retarget/rig"
9
+ import { encodePng } from "./textures/resolveImage"
10
+
11
+ export type FilmstripView = "side" | "front" | "top" | "threequarter" | "lowfront"
12
+ export type FilmstripOptions = {
13
+ /** clip name; null/undefined = the rest pose (one frame) */
14
+ clip?: string | null
15
+ frames?: number
16
+ view?: FilmstripView
17
+ /** pixels per frame (default 240) */
18
+ cell?: number
19
+ /** overlay the skeleton (joints + bones) */
20
+ bones?: boolean
21
+ /** the rest frame shows the BIND pose (from the IBMs) rather than the node TRS */
22
+ bind?: boolean
23
+ /** reference frames from other documents, drawn first at the same projection */
24
+ refs?: { doc: Document, clip?: string | null, time?: number, label?: string }[]
25
+ /** RGB of the mesh */
26
+ color?: [number, number, number]
27
+ }
28
+ export type FilmstripResult = { png: Uint8Array, width: number, height: number, label: string, duration: number }
29
+
30
+ const matMul = (a: number[], b: number[]): number[] => {
31
+ const o = new Array(16).fill(0)
32
+ 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]
33
+ return o
34
+ }
35
+ const compose = (t: Vec3, q: Quat, s: Vec3): number[] => {
36
+ const [ x, y, z, w ] = q, x2 = x + x, y2 = y + y, z2 = z + z
37
+ const xx = x * x2, xy = x * y2, xz = x * z2, yy = y * y2, yz = y * z2, zz = z * z2, wx = w * x2, wy = w * y2, wz = w * z2
38
+ return [ (1 - (yy + zz)) * s[0], (xy + wz) * s[0], (xz - wy) * s[0], 0, (xy - wz) * s[1], (1 - (xx + zz)) * s[1], (yz + wx) * s[1], 0,
39
+ (xz + wy) * s[2], (yz - wx) * s[2], (1 - (xx + yy)) * s[2], 0, t[0], t[1], t[2], 1 ]
40
+ }
41
+
42
+ type Frame = { pos: Float32Array, tris: ArrayLike<number> }[]
43
+ type Bone = { a: Vec3, b: Vec3 }
44
+
45
+ /** Everything needed to pose + skin one document: rig, channels, skinned mesh nodes. */
46
+ const prepare = (doc: Document, clipName: string | null | undefined, label: string) => {
47
+ const rig: any = rigFromDocument(doc, label)
48
+ const anim = clipName ? doc.getRoot().listAnimations().find((a) => a.getName() === clipName) ?? null : null
49
+ if (clipName && !anim) throw new Error(`no clip "${clipName}" in ${label} (has: ${doc.getRoot().listAnimations().map((a) => a.getName()).join(", ") || "none"})`)
50
+ const chans = (anim?.listChannels() ?? []).map((ch) => ({
51
+ node: rig.nodes.indexOf(ch.getTargetNode()!), path: ch.getTargetPath(),
52
+ times: ch.getSampler()!.getInput()!.getArray()!, vals: ch.getSampler()!.getOutput()!.getArray()!,
53
+ })).filter((c) => c.node >= 0)
54
+ let duration = 0
55
+ for (const c of chans) duration = Math.max(duration, c.times[c.times.length - 1])
56
+ const poseAt = (t: number, base: Pose): Pose => {
57
+ const p: Pose = { t: base.t.map((v: Vec3) => [ ...v ] as Vec3), r: base.r.map((v: Quat) => [ ...v ] as Quat), s: base.s.map((v: Vec3) => [ ...v ] as Vec3) }
58
+ for (const c of chans) {
59
+ const n = c.path === "rotation" ? 4 : 3, last = c.times.length - 1
60
+ let i = 0; while (i < last && c.times[i + 1] < t) i++
61
+ const u = i < last ? Math.max(0, Math.min(1, (t - c.times[i]) / (c.times[i + 1] - c.times[i] || 1))) : 0
62
+ const a = Array.from(c.vals.slice(i * n, i * n + n)) as number[]
63
+ const b = i < last ? Array.from(c.vals.slice((i + 1) * n, (i + 1) * n + n)) as number[] : a
64
+ const v = n === 4 ? qslerp(a as Quat, b as Quat, u) : a.map((x, k) => x + (b[k] - x) * u)
65
+ if (c.path === "rotation") p.r[c.node] = qnorm(v as Quat)
66
+ else if (c.path === "translation") p.t[c.node] = v as Vec3
67
+ else p.s[c.node] = v as Vec3
68
+ }
69
+ return p
70
+ }
71
+ const worldMats = (pose: Pose): number[][] => {
72
+ const out: number[][] = new Array(rig.names.length)
73
+ for (const i of rig.order) {
74
+ const local = compose(pose.t[i], pose.r[i], pose.s[i])
75
+ out[i] = rig.parents[i] >= 0 ? matMul(out[rig.parents[i]], local) : local
76
+ }
77
+ return out
78
+ }
79
+ // every skinned mesh node (a modular character has several); static meshes go through their node matrix
80
+ const skinnedNodes = rig.nodes.filter((n: any) => n.getMesh() && n.getSkin())
81
+ const staticNodes = rig.nodes.filter((n: any) => n.getMesh() && !n.getSkin())
82
+ if (skinnedNodes.length === 0 && staticNodes.length === 0) throw new Error(`${label}: no mesh to draw`)
83
+ const skinned = (pose: Pose): Frame => {
84
+ const wm = worldMats(pose)
85
+ const out: Frame = []
86
+ for (const meshNode of skinnedNodes) {
87
+ const skin = meshNode.getSkin(), ibmAcc = skin.getInverseBindMatrices()
88
+ const jointIdx = skin.listJoints().map((j: any) => rig.nodes.indexOf(j))
89
+ const mats = jointIdx.map((n: number, j: number) => matMul(wm[n], ibmAcc.getElement(j, [])))
90
+ for (const prim of meshNode.getMesh().listPrimitives()) {
91
+ const P = prim.getAttribute("POSITION")?.getArray(), J = prim.getAttribute("JOINTS_0")?.getArray(), W = prim.getAttribute("WEIGHTS_0")?.getArray()
92
+ if (!P || !J || !W) continue
93
+ const count = prim.getAttribute("POSITION").getCount()
94
+ const o = new Float32Array(count * 3)
95
+ for (let v = 0; v < count; v++) {
96
+ let x = 0, y = 0, z = 0
97
+ for (let k = 0; k < 4; k++) {
98
+ const w = W[v * 4 + k]
99
+ if (!w) continue
100
+ const m = mats[J[v * 4 + k]]
101
+ if (!m) continue
102
+ const px = P[v * 3], py = P[v * 3 + 1], pz = P[v * 3 + 2]
103
+ x += w * (m[0] * px + m[4] * py + m[8] * pz + m[12]); y += w * (m[1] * px + m[5] * py + m[9] * pz + m[13]); z += w * (m[2] * px + m[6] * py + m[10] * pz + m[14])
104
+ }
105
+ o[v * 3] = x; o[v * 3 + 1] = y; o[v * 3 + 2] = z
106
+ }
107
+ out.push({ pos: o, tris: prim.getIndices()?.getArray() ?? seq(count) })
108
+ }
109
+ }
110
+ for (const meshNode of staticNodes) {
111
+ const m = wm[rig.nodes.indexOf(meshNode)]
112
+ for (const prim of meshNode.getMesh().listPrimitives()) {
113
+ const P = prim.getAttribute("POSITION")?.getArray()
114
+ if (!P) continue
115
+ const count = prim.getAttribute("POSITION").getCount(), o = new Float32Array(count * 3)
116
+ for (let v = 0; v < count; v++) {
117
+ const px = P[v * 3], py = P[v * 3 + 1], pz = P[v * 3 + 2]
118
+ o[v * 3] = m[0] * px + m[4] * py + m[8] * pz + m[12]; o[v * 3 + 1] = m[1] * px + m[5] * py + m[9] * pz + m[13]; o[v * 3 + 2] = m[2] * px + m[6] * py + m[10] * pz + m[14]
119
+ }
120
+ out.push({ pos: o, tris: prim.getIndices()?.getArray() ?? seq(count) })
121
+ }
122
+ }
123
+ return out
124
+ }
125
+ const bonesOf = (pose: Pose): Bone[] => {
126
+ const wm = worldMats(pose)
127
+ const out: Bone[] = []
128
+ for (let i = 0; i < rig.names.length; i++) {
129
+ const p = rig.parents[i]
130
+ if (p < 0 || !rig.bind.fromSkin[i]) continue
131
+ out.push({ a: [ wm[p][12], wm[p][13], wm[p][14] ], b: [ wm[i][12], wm[i][13], wm[i][14] ] })
132
+ }
133
+ return out
134
+ }
135
+ return { rig, anim, duration, poseAt, skinned, bonesOf }
136
+ }
137
+ const seq = (n: number): Uint32Array => { const a = new Uint32Array(n); for (let i = 0; i < n; i++) a[i] = i; return a }
138
+
139
+ // 3×5 glyphs for the time labels ("0.25s") and the header — enough without a font library.
140
+ const FONT: Record<string, number[]> = {
141
+ "0": [ 7, 5, 5, 5, 7 ], "1": [ 2, 6, 2, 2, 7 ], "2": [ 7, 1, 7, 4, 7 ], "3": [ 7, 1, 7, 1, 7 ], "4": [ 5, 5, 7, 1, 1 ],
142
+ "5": [ 7, 4, 7, 1, 7 ], "6": [ 7, 4, 7, 5, 7 ], "7": [ 7, 1, 1, 1, 1 ], "8": [ 7, 5, 7, 5, 7 ], "9": [ 7, 5, 7, 1, 7 ],
143
+ ".": [ 0, 0, 0, 0, 2 ], "s": [ 0, 3, 4, 2, 6 ], " ": [ 0, 0, 0, 0, 0 ], "-": [ 0, 0, 7, 0, 0 ], ":": [ 0, 2, 0, 2, 0 ], "@": [ 7, 5, 7, 4, 7 ],
144
+ }
145
+ const drawText = (img: Uint8Array, W: number, x: number, y: number, text: string, rgb: [number, number, number], scale = 2): void => {
146
+ for (const ch of text.toLowerCase()) {
147
+ const g = FONT[ch] ?? FONT[" "]
148
+ for (let r = 0; r < 5; r++) for (let c = 0; c < 3; c++) if (g[r] & (4 >> c)) {
149
+ for (let sy = 0; sy < scale; sy++) for (let sx = 0; sx < scale; sx++) {
150
+ const px = x + c * scale + sx, py = y + r * scale + sy
151
+ const i = (py * W + px) * 4
152
+ if (i >= 0 && i + 3 < img.length) { img[i] = rgb[0]; img[i + 1] = rgb[1]; img[i + 2] = rgb[2] }
153
+ }
154
+ }
155
+ x += 4 * scale
156
+ }
157
+ }
158
+ const drawLine = (img: Uint8Array, W: number, H: number, x0: number, y0: number, x1: number, y1: number, rgb: [number, number, number]): void => {
159
+ const n = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))))
160
+ for (let i = 0; i <= n; i++) {
161
+ const x = Math.round(x0 + (x1 - x0) * i / n), y = Math.round(y0 + (y1 - y0) * i / n)
162
+ if (x < 0 || y < 0 || x >= W || y >= H) continue
163
+ const o = (y * W + x) * 4
164
+ img[o] = rgb[0]; img[o + 1] = rgb[1]; img[o + 2] = rgb[2]
165
+ }
166
+ }
167
+
168
+ export const renderFilmstrip = (doc: Document, opts: FilmstripOptions = {}): FilmstripResult => {
169
+ const VIEW = opts.view ?? "side"
170
+ const main = prepare(doc, opts.clip, "model")
171
+ const N = main.anim ? Math.max(1, Math.floor(opts.frames ?? 8)) : 1 // the rest pose is one frame
172
+ const restPose: Pose = opts.bind ? main.rig.bind.local : main.rig.rest
173
+ const times = Array.from({ length: N }, (_, i) => (main.anim ? (i / Math.max(1, N - 1)) * main.duration : 0))
174
+ const frames: Frame[] = times.map((t) => main.skinned(main.anim ? main.poseAt(t, main.rig.rest) : restPose))
175
+ const boneLines: Bone[][] = opts.bones ? times.map((t) => main.bonesOf(main.anim ? main.poseAt(t, main.rig.rest) : restPose)) : []
176
+ const labels = times.map((t) => (main.anim ? `${t.toFixed(2)}s` : "rest"))
177
+ // reference frames (other files / clips) first, same projection
178
+ for (const ref of [ ...(opts.refs ?? []) ].reverse()) {
179
+ const r = prepare(ref.doc, ref.clip, ref.label ?? "ref")
180
+ frames.unshift(r.skinned(r.anim ? r.poseAt(ref.time ?? 0, r.rig.rest) : r.rig.rest))
181
+ if (opts.bones) boneLines.unshift([])
182
+ labels.unshift(ref.label ?? (ref.clip ? `${ref.clip}@${ref.time ?? 0}` : "ref"))
183
+ }
184
+ const NF = frames.length
185
+
186
+ // one projection for the whole strip, from the union of every frame's bounds
187
+ const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
188
+ for (const f of frames) for (const p of f) for (let v = 0; v < p.pos.length; v += 3)
189
+ for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i], p.pos[v + i]); hi[i] = Math.max(hi[i], p.pos[v + i]) }
190
+ if (!Number.isFinite(lo[0])) { lo[0] = lo[1] = lo[2] = -1; hi[0] = hi[1] = hi[2] = 1 }
191
+
192
+ const CELL = opts.cell ?? 240, PAD = 10, HEAD = 20
193
+ const bases: Record<string, { r: Vec3, u: Vec3, d: Vec3 }> = {
194
+ side: { r: [ 0, 0, 1 ], u: [ 0, 1, 0 ], d: [ -1, 0, 0 ] },
195
+ front: { r: [ 1, 0, 0 ], u: [ 0, 1, 0 ], d: [ 0, 0, -1 ] },
196
+ top: { r: [ 0, 0, 1 ], u: [ 1, 0, 0 ], d: [ 0, -1, 0 ] },
197
+ threequarter: { r: [ 0.7, 0, 0.71 ], u: [ -0.2, 0.96, 0.2 ], d: [ -0.68, -0.28, 0.68 ] },
198
+ lowfront: { r: [ 1, 0, 0 ], u: [ 0, 0.87, -0.5 ], d: [ 0, 0.5, -0.87 ] },
199
+ }
200
+ const B = bases[VIEW] ?? bases.side
201
+ const dot = (a: Vec3, b: ArrayLike<number>): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
202
+ const corners: Vec3[] = []
203
+ for (let i = 0; i < 8; i++) corners.push([ i & 1 ? hi[0] : lo[0], i & 2 ? hi[1] : lo[1], i & 4 ? hi[2] : lo[2] ])
204
+ const ru = corners.map((c) => [ dot(B.r, c), dot(B.u, c) ])
205
+ const rlo = [ Math.min(...ru.map((p) => p[0])), Math.min(...ru.map((p) => p[1])) ]
206
+ const rhi = [ Math.max(...ru.map((p) => p[0])), Math.max(...ru.map((p) => p[1])) ]
207
+ const k = (CELL - 2 * PAD) / Math.max(rhi[0] - rlo[0], rhi[1] - rlo[1], 1e-9)
208
+ const sx = (ox: number, q: ArrayLike<number>): number => ox + PAD + (dot(B.r, q) - rlo[0]) * k + (CELL - 2 * PAD - (rhi[0] - rlo[0]) * k) / 2
209
+ const sy = (q: ArrayLike<number>): number => HEAD + CELL - PAD - (dot(B.u, q) - rlo[1]) * k
210
+
211
+ const W = CELL * NF, H = CELL + HEAD
212
+ const img = new Uint8Array(W * H * 4)
213
+ for (let i = 0; i < W * H; i++) { img[i * 4] = 17; img[i * 4 + 1] = 21; img[i * 4 + 2] = 28; img[i * 4 + 3] = 255 }
214
+ const depth = new Float32Array(W * H).fill(Infinity)
215
+ const light: Vec3 = [ -0.4, 0.75, 0.53 ]
216
+ const base = opts.color ?? [ 214, 126, 58 ]
217
+ frames.forEach((frame, f) => {
218
+ const ox = f * CELL
219
+ for (const { pos, tris } of frame) {
220
+ for (let t = 0; t + 2 < tris.length; t += 3) {
221
+ const p: number[][] = []
222
+ for (let c = 0; c < 3; c++) { const v = tris[t + c] * 3; p.push([ pos[v], pos[v + 1], pos[v + 2] ]) }
223
+ const e1 = [ p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2] ], e2 = [ p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2] ]
224
+ const nx = e1[1] * e2[2] - e1[2] * e2[1], ny = e1[2] * e2[0] - e1[0] * e2[2], nz = e1[0] * e2[1] - e1[1] * e2[0]
225
+ const nl = Math.hypot(nx, ny, nz) || 1
226
+ const shade = 0.22 + 0.78 * Math.abs((nx / nl) * light[0] + (ny / nl) * light[1] + (nz / nl) * light[2])
227
+ const col = [ Math.round(base[0] * shade), Math.round(base[1] * shade), Math.round(base[2] * shade) ]
228
+ const sp = p.map((q) => [ sx(ox, q), sy(q), -dot(B.d, q) ])
229
+ const minX = Math.max(ox, Math.floor(Math.min(sp[0][0], sp[1][0], sp[2][0]))), maxX = Math.min(ox + CELL - 1, Math.ceil(Math.max(sp[0][0], sp[1][0], sp[2][0])))
230
+ const minY = Math.max(HEAD, Math.floor(Math.min(sp[0][1], sp[1][1], sp[2][1]))), maxY = Math.min(H - 1, Math.ceil(Math.max(sp[0][1], sp[1][1], sp[2][1])))
231
+ const d = (sp[1][0] - sp[0][0]) * (sp[2][1] - sp[0][1]) - (sp[2][0] - sp[0][0]) * (sp[1][1] - sp[0][1])
232
+ if (Math.abs(d) < 1e-12) continue
233
+ for (let y = minY; y <= maxY; y++) for (let x = minX; x <= maxX; x++) {
234
+ const px = x + 0.5, py = y + 0.5
235
+ const w0 = ((sp[1][0] - px) * (sp[2][1] - py) - (sp[2][0] - px) * (sp[1][1] - py)) / d
236
+ const w1 = ((sp[2][0] - px) * (sp[0][1] - py) - (sp[0][0] - px) * (sp[2][1] - py)) / d
237
+ const w2 = 1 - w0 - w1
238
+ if (w0 < 0 || w1 < 0 || w2 < 0) continue
239
+ const z = w0 * sp[0][2] + w1 * sp[1][2] + w2 * sp[2][2]
240
+ const i = y * W + x
241
+ if (z >= depth[i]) continue
242
+ depth[i] = z
243
+ img[i * 4] = col[0]; img[i * 4 + 1] = col[1]; img[i * 4 + 2] = col[2]
244
+ }
245
+ }
246
+ }
247
+ })
248
+ // overlays: cell borders, bones, labels
249
+ for (let f = 0; f < NF; f++) {
250
+ const ox = f * CELL
251
+ drawLine(img, W, H, ox, HEAD, ox, H - 1, [ 35, 44, 56 ])
252
+ drawLine(img, W, H, ox, HEAD, ox + CELL - 1, HEAD, [ 35, 44, 56 ])
253
+ for (const { a, b } of boneLines[f] ?? []) {
254
+ drawLine(img, W, H, sx(ox, a), sy(a), sx(ox, b), sy(b), [ 127, 224, 255 ])
255
+ const bx = Math.round(sx(ox, b)), by = Math.round(sy(b))
256
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (bx + dx >= 0 && by + dy >= 0 && bx + dx < W && by + dy < H) { const o = ((by + dy) * W + bx + dx) * 4; img[o] = 255; img[o + 1] = 255; img[o + 2] = 255 }
257
+ }
258
+ drawText(img, W, ox + 6, HEAD + 6, labels[f], [ 140, 150, 170 ])
259
+ }
260
+ const label = `${main.anim ? main.anim.getName() : "rest pose"} · ${VIEW} view · ${NF} frames`
261
+ drawText(img, W, 6, 5, main.anim ? `${main.duration.toFixed(2)}s - ${N}` : "-", [ 136, 170, 170 ]) // the 3×5 font has digits, '.', 's', '-' only
262
+ return { png: encodePng({ width: W, height: H, data: img }), width: W, height: H, label, duration: main.duration }
263
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ // lecodes-assets — programmatic API. CLI: src/cli.ts (`lecodes-assets …` / `lecodes assets …`).
2
+ export { mirrorClips, partnerName, type MirrorOptions, type MirrorReport } from './retarget/mirror'
3
+ export { readFbx, type FbxScene } from "./fbx/read"
4
+ export { toDocument, type ToDocumentOptions, type ConvertWarning } from "./fbx/toDocument"
5
+ export { convertFbx, mergeClipScene, writeGlb, makeIO, type ConvertOptions, type ConvertReport, type ConvertResult } from "./fbx/convert"
6
+ export { probeFile, probeFbx, probeDocument } from "./probe"
7
+ export { analyzeDoc, applyPlan, reportPlan, dumpTextures, type DoctorPlan, type DoctorOptions } from "./doctor/doctor"
8
+ export { validateGlb, formatReport } from "./doctor/validateGlb"
9
+ export { measureScale, applyScale, sceneBounds, type ScaleInfo } from "./doctor/normalizeScale"
10
+ export { loadRig, rigFromFbx, rigFromDocument, checkUniformScale, type Rig, type DocRig } from "./retarget/rig"
11
+ export { retarget, analyzeFacing, facingMismatch, bindAccessors, type BoneMap, type RetargetOptions, type RetargetReport, type AlignPolicy, type DonorRef, type LegReport, type ClipContacts, type ContactSpan, type RetargetEvent } from "./retarget/retarget"
12
+ export { makeInPlace, speedProfile, yawProfile, type InPlaceResult, type InPlaceOptions } from "./retarget/inPlace"
13
+ export { detectMarks, fitStaircase, readMarks, stairsMatcher, describeMarks, type ClipMarks, type MarkSpan, type Stairs, type FootTrack, type MarksInput } from "./retarget/marks"
14
+ export { writeMarks, type WriteMarksOptions } from "./retarget/writeMarks"
15
+ export { autoMap, classify, describeSkeleton, type AutoMapResult, type Skeleton } from "./retarget/automap"
16
+ export { packTerrainTextures, resolveTerrainLayer, type TerrainPackLayer, type TerrainPackOptions, type TerrainPackResult } from "./terrainPack"
17
+ export { runAssetsCli } from "./cli"
@@ -0,0 +1,117 @@
1
+ // Modular characters: the body, the shirt, the gloves and the boots each come as their OWN skinned
2
+ // FBX over the same skeleton. `mergeSkins` moves the donors' skinned meshes onto the base skeleton
3
+ // (joints matched by name — exact, then the part after the last ':'/'|', the Animator's own rule)
4
+ // and drops the donors' skeleton copies entirely. Leaving those copies in is the classic silent
5
+ // failure: every bone name exists twice, a name-bound Animator finds the unskinned copy first, and
6
+ // the mesh freezes in its rest pose. See docs/animation-plan.md §2.9.
7
+
8
+ import { Logger, type Document, type Node, type Skin } from "@gltf-transform/core"
9
+ import { mergeDocuments, prune, unpartition } from "@gltf-transform/functions"
10
+
11
+ export type MergeSkinsWarning = { level: "warn" | "info", message: string }
12
+ export type MergeSkinsReport = {
13
+ /** per donor: meshes moved, joints that had no counterpart in the base, total skin weight lost to them */
14
+ donors: { label: string, meshes: number, missingJoints: string[], lostWeight: number }[]
15
+ joints: number
16
+ }
17
+
18
+ const shortName = (n: string): string => n.slice(n.search(/[^:|]*$/))
19
+
20
+ const findSkinnedNode = (doc: Document): Node | null => {
21
+ let found: Node | null = null
22
+ for (const scene of doc.getRoot().listScenes()) scene.traverse((n) => { if (!found && n.getSkin() && n.getMesh()) found = n })
23
+ return found
24
+ }
25
+
26
+ /** Merge the skinned meshes of `donors` into `base` (mutated). Donor documents are consumed. */
27
+ export const mergeSkins = async (base: Document, donors: { doc: Document, label: string }[], warn: (w: MergeSkinsWarning) => void = () => {}): Promise<MergeSkinsReport> => {
28
+ const baseSkin: Skin | undefined = base.getRoot().listSkins()[0]
29
+ if (!baseSkin) throw new Error("the base model has no skin — --merge-skins needs a skinned base (the body)")
30
+ const baseJoints = baseSkin.listJoints()
31
+ const byName = new Map<string, number>(), byShort = new Map<string, number>()
32
+ baseJoints.forEach((j, i) => {
33
+ const n = j.getName()
34
+ if (!byName.has(n)) byName.set(n, i)
35
+ const s = shortName(n)
36
+ if (!byShort.has(s)) byShort.set(s, i)
37
+ })
38
+ const baseScene = base.getRoot().getDefaultScene() ?? base.getRoot().listScenes()[0]
39
+ const baseMeshNode = findSkinnedNode(base)
40
+ const homeParent = baseMeshNode?.getParentNode() ?? null
41
+ const report: MergeSkinsReport = { donors: [], joints: baseJoints.length }
42
+
43
+ for (const { doc: donor, label } of donors) {
44
+ const donorSkins = donor.getRoot().listSkins()
45
+ if (donorSkins.length === 0) { warn({ level: "warn", message: `${label}: no skin — nothing to merge (static meshes attach at runtime instead)` }); continue }
46
+ // donor joint index → base joint index, per donor skin
47
+ const remaps = new Map<Skin, number[]>()
48
+ const missing = new Set<string>()
49
+ for (const s of donorSkins) {
50
+ remaps.set(s, s.listJoints().map((j) => {
51
+ const n = j.getName()
52
+ const hit = byName.get(n) ?? byShort.get(shortName(n))
53
+ if (hit === undefined) missing.add(n)
54
+ return hit ?? -1
55
+ }))
56
+ }
57
+ const donorMeshNodes: Node[] = []
58
+ for (const scene of donor.getRoot().listScenes()) scene.traverse((n) => { if (n.getSkin() && n.getMesh()) donorMeshNodes.push(n) })
59
+ const donorScenes = donor.getRoot().listScenes()
60
+
61
+ const map = mergeDocuments(base, donor)
62
+ let lostWeight = 0
63
+ for (const src of donorMeshNodes) {
64
+ const node = map.get(src) as Node | undefined
65
+ if (!node) continue
66
+ const remap = remaps.get(src.getSkin()!)!
67
+ for (const prim of node.getMesh()!.listPrimitives()) {
68
+ const J = prim.getAttribute("JOINTS_0"), W = prim.getAttribute("WEIGHTS_0")
69
+ if (!J) continue
70
+ const ja = J.getArray()!, wa = W?.getArray() ?? null
71
+ const wNorm = W ? (W.getComponentType() === 5126 ? 1 : W.getComponentType() === 5121 ? 255 : 65535) : 1
72
+ for (let i = 0; i < ja.length; i++) {
73
+ const m = remap[ja[i]]
74
+ if (m >= 0) { ja[i] = m; continue }
75
+ if (wa) { lostWeight += wa[i] / wNorm; wa[i] = 0 }
76
+ ja[i] = 0
77
+ }
78
+ J.setArray(ja)
79
+ if (wa) W!.setArray(wa)
80
+ }
81
+ node.setSkin(baseSkin)
82
+ // keep the mesh node's own TRS; it is re-homed next to the base skinned mesh
83
+ if (homeParent) homeParent.addChild(node)
84
+ else baseScene.addChild(node)
85
+ }
86
+ // drop the donor's skeleton copy: every merged node that is not one of the re-homed mesh nodes
87
+ const kept = new Set(donorMeshNodes.map((n) => map.get(n)))
88
+ for (const s of donorSkins) { const ms = map.get(s) as Skin | undefined; if (ms && ms !== baseSkin) ms.dispose() }
89
+ for (const ds of donorScenes) {
90
+ const scene = map.get(ds)
91
+ if (!scene) continue
92
+ const doomed: Node[] = []
93
+ for (const root of (scene as typeof baseScene).listChildren()) root.traverse((n) => { if (!kept.has(n)) doomed.push(n) })
94
+ for (const n of doomed.reverse()) n.dispose()
95
+ if (scene !== baseScene) (scene as typeof baseScene).dispose()
96
+ }
97
+ if (lostWeight > 0.5) warn({ level: "warn", message: `${label}: ${lostWeight.toFixed(1)} of skin weight sat on bones the base skeleton lacks (${[ ...missing ].slice(0, 5).join(", ")}${missing.size > 5 ? "…" : ""}) — those vertices now follow the root` })
98
+ report.donors.push({ label, meshes: donorMeshNodes.length, missingJoints: [ ...missing ], lostWeight })
99
+ }
100
+ await base.setLogger(new Logger(Logger.Verbosity.SILENT)).transform(prune(), unpartition())
101
+ return report
102
+ }
103
+
104
+ /** Node names that occur more than once, and whether a skin joint is shadowed by a plain node of the same name. */
105
+ export const duplicateNodeNames = (doc: Document): { name: string, count: number, shadowsJoint: boolean }[] => {
106
+ const joints = new Set(doc.getRoot().listSkins().flatMap((s) => s.listJoints()))
107
+ const seen = new Map<string, { count: number, joint: number, plain: number }>()
108
+ for (const n of doc.getRoot().listNodes()) {
109
+ const name = n.getName()
110
+ if (!name) continue
111
+ const e = seen.get(name) ?? { count: 0, joint: 0, plain: 0 }
112
+ e.count++
113
+ if (joints.has(n)) e.joint++; else e.plain++
114
+ seen.set(name, e)
115
+ }
116
+ return [ ...seen ].filter(([ , e ]) => e.count > 1).map(([ name, e ]) => ({ name, count: e.count, shadowsJoint: e.joint > 0 && e.plain > 0 }))
117
+ }
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
+ }