lecodes-assets 0.1.1 → 0.1.2

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 (4) hide show
  1. package/README.md +326 -325
  2. package/dist/cli.js +3113 -1867
  3. package/dist/index.js +3113 -1867
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,325 +1,326 @@
1
- # lecodes-assets — the LeCodes asset pipeline
2
-
3
- FBX → GLB converter + GLB doctor, one package on one glTF-Transform `Document`. Reachable as
4
- `lecodes assets …` (optional peer of the `lecodes` CLI) or `bun src/cli.ts …` in the monorepo.
5
- Plan/rationale: [docs/animation-plan.md §2.9](../../docs/animation-plan.md).
6
-
7
- Install it the same way you installed the CLI — the peer is loaded by a bare `import` that resolves
8
- upward from the CLI's own file, so a global `lecodes` only sees a global peer:
9
-
10
- ```sh
11
- npm install -g lecodes-assets
12
- ```
13
-
14
- KTX2 (`--ktx2`) additionally needs KTX-Software's `toktx` on PATH; `sharp` is an optional dependency
15
- used to normalize exotic texture formats.
16
-
17
- ```
18
- lecodes assets convert "Erika Archer.fbx" --clips Idle.fbx Walking.fbx "Fast Run.fbx" --drop-model-clips -o hero.glb
19
- lecodes assets convert hero.glb --clips Jump.fbx # add clips to an existing GLB
20
- lecodes assets convert hero.fbx --fix # + doctor passes (bone cap, texture normalize)
21
- lecodes assets convert hero.fbx --ktx2 # + textures → KTX2 (needs KTX-Software toktx)
22
- lecodes assets probe hero.fbx | hero.glb # skeleton / clips / meshes / textures / bind sanity / world size
23
- lecodes assets doctor hero.glb [--fix] [--ktx2] [--dump] # the old tools/glb-reduce
24
- lecodes assets doctor hero.glb --fix-scale # bake a unit fix (see Scale below)
25
- lecodes assets retarget fox.glb --from wolf.fbx --map auto # clips from a DIFFERENT skeleton (map from skeleton shape)
26
- lecodes assets unpack pack.unitypackage -o out [--only re …] # stream a .unitypackage: unity-index.txt + assets (+ .meta)
27
- lecodes assets convert Barrel_v1.FBX --list-nodes # multi-variant prop file: what is in it
28
- lecodes assets convert Barrel_v1.FBX --keep Barrel_v1_LD1 -o barrel.glb # one variant, re-origined on the ground
29
- lecodes assets convert Barrel_v1.FBX --split props/ # every top-level node → its own GLB
30
- lecodes assets convert Body.FBX --merge-skins Shirt.FBX Gloves.FBX --clips … -o hero.glb # modular character → ONE skeleton
31
- lecodes assets probe hero.glb --filmstrip # a CPU-skinned PNG strip per clip (no GPU)
32
- lecodes assets scene Map_v8.unity -o level.json # Unity scene → placed prefabs with world TRS
33
- ```
34
-
35
- ## Props, modular characters, scenes — the Unity-pack workflow
36
-
37
- **Multi-variant prop files.** Packs put every LOD, colour set and arrangement of a prop in one FBX
38
- (`Barrel_v1.FBX` = 15 meshes). `--list-nodes [depth]` prints the scene's nodes with mesh/triangle
39
- counts and world sizes; `--keep <node> …` keeps only those subtrees (ancestors stay as the path,
40
- descendants come along) and **re-origins** the result — footprint centre at X/Z = 0, lowest vertex at
41
- Y = 0 — so `Model.load` places the prop exactly where the node is (`--no-ground` keeps the file's
42
- own placement); `--split [dir]` writes one re-origined GLB per top-level node. Both run before the
43
- doctor passes, so `--keep X --ktx2 --max-size 1024` is the whole prop pipeline.
44
-
45
- **Modular characters.** Body, shirt, gloves and boots each arrive as their own skinned FBX over the
46
- same skeleton. `--merge-skins b.fbx c.glb …` moves the donors' skinned meshes onto the model's skin
47
- (joints matched by name — exact, then the part after the last `:`/`|`, the Animator's own rule) and
48
- drops their skeleton copies. That last part matters: a hand merge that leaves the donor skeleton in
49
- gives every bone name a plain-node twin, and a name-bound `Animator` drives the twin while the mesh
50
- freezes in its rest pose. `probe` and `doctor` now flag duplicate node names, and the doctor marks
51
- the joint-shadowing case as BLOCKING. Donor bones the base lacks are reported with the skin weight
52
- that sat on them (it moves to the root).
53
-
54
- **Checking a clip without a GPU.** `probe` prints, per clip and skeleton root, how far the root
55
- travels (root motion) and how far its first frame is from the rest pose — a root that starts ~90°
56
- off is the double-axis-turn symptom of a clip from another export. `probe --filmstrip [out.png]`
57
- renders a software-skinned PNG strip of every clip (`--clip` for one, `--frames N`, `--view
58
- side|front|top|threequarter|lowfront`, `--bones` for the skeleton overlay, `--cell` px per frame);
59
- `tools/filmstrip.ts` is the same renderer with reference frames from other files.
60
-
61
- **Unity scenes.** `scene Map.unity [-o level.json]` reads a text-serialised scene: every
62
- `PrefabInstance` (source prefab GUID, the position/rotation/scale/name/active overrides, its parent)
63
- and every `MeshFilter` object, composes the scene's own `Transform` hierarchy on top (groups like
64
- `Static/Roads`), and writes the instances with their **world** TRS plus a prefab table — path, use
65
- count, and a guess at the FBX + `--keep` node that renders it (the FBX from the prefab's folder /
66
- `Source/` whose stem prefixes the prefab name; most packs ship their prefabs binary-serialised, so
67
- the prefab's own contents are not read). Frame: Unity is left-handed and its FBX importer mirrors X,
68
- so the default undoes that — position `(−x, y, z)`, quaternion `(x, −y, −z, w)` — `--handedness z`
69
- mirrors Z instead (content authored inside Unity), `none` keeps Unity's numbers. Subsets:
70
- `--filter <re>` (prefab path / name / group), `--group <name>`, `--region x0,z0,x1,z1` (Unity
71
- metres), `--include-inactive`. A parent that sits inside a binary prefab resolves to that prefab's
72
- root and the instance is marked `approximate`. The output is plain JSON:
73
- `{ prefabs: { guid: { path, name, fbx, node, count } }, instances: [ { name, prefab, group,
74
- position, rotation, scale } ] }` — one `Model.load` per prefab, one `clone()` per instance.
75
-
76
- ## Unity packs — `unpack` + materials from `.mat`
77
-
78
- ```
79
- lecodes assets unpack "Tactical FPS Animations.unitypackage" -o D:/Unity/tactical # everything
80
- lecodes assets unpack pack.unitypackage -o out --only 'Meshes/' 'Animations/' 'Materials/' # regex subset
81
- lecodes assets unpack pack.unitypackage -o out --list --only '\.wav$' # just list
82
- lecodes assets convert out/Assets/Pack/Weapons/SKM_TR-15.FBX --clips … --ktx2 -o tr15.glb # .mat-aware
83
- ```
84
-
85
- A `.unitypackage` is a gzip'd tar of one folder per asset GUID. `unpack` streams it twice (flat
86
- memory on multi-GB packs): pass 1 writes `<out>/unity-index.txt` (GUID → path), pass 2 writes the
87
- matching assets + their `.meta` sidecars under `<out>/Assets/…`.
88
-
89
- `convert` then reads the **Unity `.mat` files** found next to (or one folder above) the FBX and
90
- rewrites the glTF materials from them — an FBX exported for Unity usually references only a legacy
91
- base colour and a normal map, while metallic / roughness / occlusion / emission and the alpha mode
92
- live in the material. Matching is by name modulo `M_`/`MI_`/`MAT_` prefixes, then by containment
93
- (`MI_Tshirt` ↔ `M_Shirt`), then by a shared base texture (`MI_Skin` ↔ `M_Body` through
94
- `TX_Body_BaseColor`). Texture GUIDs resolve through `unity-index.txt` (found walking up from the
95
- FBX, or `--unity-index`), else through `.meta` sidecars of a real Unity project. Slots understood:
96
- `_BaseMap/_MainTex/_BaseColor/_BC`, `_BumpMap/_Normal`, a ready `_ORM`, HDRP/URP `_MaskMap`
97
- (R metal, G AO, A smooth), Standard `_MetallicGlossMap` (R metal, A smooth), separate `_Metallic`,
98
- `_Roughness` / `_Smoothness`, `_OcclusionMap`, `_EmissionMap` (+ `_EMISSION` keyword / colour),
99
- `_Surface`/`_Mode`/`_AlphaClip`/`_Cutoff`/`_Cull`. Everything that is not already one texture is
100
- **packed the glTF way** (R = AO, G = roughness, B = metallic; PNG/TGA/BMP decoded in-process, other
101
- formats through `sharp`) — one metallic-roughness(+occlusion) texture per material. `--no-unity`
102
- keeps the FBX references; `--unity-mats <dir>` looks elsewhere. Only YAML-serialised `.mat` files are
103
- readable (Unity "Force Text", the asset-store default since 2017); binary ones are listed and skipped.
104
-
105
- ## Retarget — clips from a different skeleton
106
-
107
- `convert --clips` binds tracks by BONE NAME and copies them verbatim; that only works when both
108
- files came from the same rig (the Mixamo case). `retarget` drives one rig with a completely
109
- different rig's clips — different bone names, different chain lengths, different proportions,
110
- different local axis conventions, even different facing:
111
-
112
- ```
113
- lecodes assets retarget fox.glb --from Wolf_Animation.fbx --map auto --clips wolf_walk wolf_run -o fox-anim.glb
114
- lecodes assets retarget giraffe.glb --from Deer_Animation.fbx --auto-map # writes giraffe-from-Deer_Animation.map.json to edit
115
- lecodes assets retarget giraffe.glb --from Deer_Animation.fbx --map giraffe-from-Deer_Animation.map.json -o giraffe-anim.glb
116
- lecodes assets retarget fox-anim.glb --from Wolf_Howl.fbx --map … --keep-clips --clip-names howl
117
- lecodes assets retarget fox.glb --from Wolf_Animation.fbx --skeletons # both bone lists, to write a map by hand
118
- lecodes assets retarget fox.glb --from Cat.fbx --map auto --anim Cat_Sleep.anim # donor clips that live outside the FBX
119
- ```
120
-
121
- **The map** is target-bone → donor-bone; `root` names the bone that receives root motion. `--map auto`
122
- / `--auto-map` derive it from the two skeletons' SHAPE — root, spine, neck, head, jaw, ears, tail and
123
- the four legs (side + front/back) are read off the bind pose, then paired chain by chain with bones
124
- matched by arc length (names only break ties when they agree: `backAnkle`↔`Ankle_L`). It works on
125
- `joint1…joint42` rigs and prints exactly what it paired — read it before trusting it. Optional keys:
126
-
127
- ```json
128
- { "root": "Hips",
129
- "bones": { "Hips": "Root_M", "chest": "Chest_M", "backleg": "Hip_L", "backleg0": "Knee_L", "…": "…" },
130
- "align": { "chest": "keep", "tail": "direction", "head": [ "headend" ] }, // per-bone reference policy (see below)
131
- "legs": [ "backleg2", "R_backleg2", "frontleg2", "R_frontleg2" ], // feet, when auto-detection picks wrong
132
- "donorRef": "auto" | "bind" | { "clip": "wolf_idle", "time": 0 } }
133
- ```
134
-
135
- **How it works.** Everything happens in WORLD space, where a rotation means the same thing in both
136
- rigs: `ΔR(t) = Rdonor(t)·Rdonor_ref⁻¹` is applied to the target's own reference orientation and
137
- converted back to local against the already-retargeted parent. That transfer is exact — the thing
138
- that decides quality is what the two REFERENCE poses are and what happens where proportions differ:
139
-
140
- - **Donor reference** (`donorRef`, default `auto`). An exporter's bind is often a posture the clips
141
- never visit — the polyperfect wolf binds upright on vertical thighs and walks 10% lower with its
142
- thighs 30° forward; measured from that bind every clip reads as "crouch and lean". When the two
143
- binds put the legs differently (>12°: different conventions), the donor is referenced to the MEAN
144
- pose of its walk (else idle, else the longest clip) — its actual neutral posture. When the binds
145
- agree, bind-to-bind is the faithful transfer (an identity retarget reproduces the donor exactly).
146
- `--donor-ref bind|auto|<clip>` overrides.
147
- - **Target reference**, per bone: `keep` its bind orientation, or `direction` — turn it so the bone
148
- points the way the donor's does (an auto-T-pose step, measured to the nearest mapped descendant).
149
- A target that binds in a STANCE (feet level, legs hanging) keeps everything: that is its natural
150
- posture and the donor's deltas ride on it — the horse stays tall under wolf clips, the giraffe's
151
- neck stays up under deer clips. A target that binds posed (T-pose, folded legs) keeps its core
152
- (root, pelvis, chest, head — artists put those joints anywhere, so their joint lines are not
153
- comparable) and direction-aligns its chains. `align` overrides per bone.
154
- - **Feet** (`--ik legs`, the default). Directions transfer, positions do not: with different
155
- front/back leg ratios the feet float or sink, which the eye catches first. Legs are detected from
156
- the map (lateral chains reaching the ground; a foot is what stands on the donor's ground, a horse's
157
- wrist-ended front leg is not), the reference pose is planted (root lowered/raised, every foot bent
158
- onto the ground), then every frame each foot gets a goal built as THE TARGET'S OWN STANCE plus the
159
- donor's departure from ITS stance, scaled by that leg's length ratio, on all three axes — height
160
- above each rig's own stance level, and the horizontal offset each rig plants its foot at under its
161
- own hip — and a FABRIK pass from the FK pose bends the chain onto it. Both builds are therefore
162
- preserved: a target that tucks its feet inboard of its hips keeps doing so against a donor that
163
- stands on vertical legs. (Copying the donor's absolute foot-under-hip offset instead planted this
164
- repo's fox — which tucks each foot ~8% of body height inboard, while every polyperfect animal
165
- stands vertical — bow-legged in every clip.) When the legs are scaled copies the goal IS the FK
166
- pose, so IK only ever spends the proportion residual; goals out of reach are reported per leg.
167
- - **Root motion** is scaled by the mean leg-length ratio (a stride is leg length, not body height),
168
- so stance feet that were still on the donor stay still on the target.
169
- - **Chain lengths.** A longer donor chain collapses for free (the target's `chest` mapped to
170
- `Chest_M` picks up Spine1+Spine2, because a world rotation already contains everything above it).
171
- A longer target chain — unmapped bones between two mapped ones — interpolates the delta along the
172
- chain (a 6-bone spine against a 2-bone one bends everywhere, not at one joint).
173
- - **Spaces.** The anchor is the BIND pose (inverse of the skin's inverse-bind matrices), never the TRS
174
- on the nodes — the wolf's node pose is 0.37 m from its bind. A skin whose bind space is scaled
175
- against node space (Maya "actor" exports, ×100) is rescaled; a donor facing the other way is turned
176
- (facing is read from the skeleton shape, so `joint1…` rigs work too); non-uniform scales are refused.
177
-
178
- What it does not do yet: foot locking on the target beyond what the donor's feet do (an in-place
179
- donor still treadmills), stride warping when a leg is far too short for the donor's reach (reported
180
- as "out of reach"), torso pitch to compensate for mismatched front/hind leg ratios, bipeds/arms (the
181
- auto-map is quadruped-shaped; a humanoid map is best written by hand or by name).
182
-
183
- ### Donors whose clips are not in the FBX
184
-
185
- Asset packs routinely ship the rig as FBX and the animations as Unity `.anim` assets — the
186
- polyperfect cat and all three dogs have EMPTY FBX files and eight `.anim` clips each. `--anim` reads
187
- those directly (`src/retarget/unityAnim.ts`, YAML only — a project saved with binary serialisation
188
- has to be re-exported with *Asset Serialization = Force Text*) and attaches them to the donor rig as
189
- clips, bound by the LAST segment of each curve's transform path:
190
-
191
- ```
192
- lecodes assets retarget fox.glb --from Cat.fbx --map fox-from-cat.map.json --anim Cat_Walk.anim Cat_Idle.anim Cat_Sleep.anim Cat_StandUp.anim --clips Cat_Sleep Cat_StandUp --clip-names sleep stand_up -o fox-cat.glb
193
- ```
194
-
195
- Pass the donor's locomotion clips even when you do not want them in the output: `--donor-ref auto`
196
- takes the donor's neutral posture from a walk/idle, and without one it falls back to the bind pose.
197
-
198
- ### What a short spine cannot borrow
199
-
200
- Rotations transfer; positions follow from the TARGET's own bones. Leg IK covers that for feet, but
201
- nothing covers the spine, so a clip whose whole point is a spine CURL does not survive a target with
202
- fewer spine bones. The cat sits by curving a 7-bone spine to hold its chest up over a dropping
203
- pelvis; this fox has two spine bones and no way to curve, so its chest is simply carried down by the
204
- pelvis and the head ends up on the floor (4% of body height, against the cat's 79%). Hanging the
205
- pelvis on a mid-spine bone instead recovers part of it (27%) but then breaks lying-down clips, whose
206
- heads go through the floor. Clips that END lying (`Sleep`) or start there (`StandUp`) transfer fine —
207
- they need no upward curl.
208
-
209
- ### Seeing the result
210
-
211
- A bone map is judged by eye. `tools/filmstrip.ts` skins a clip on the CPU and writes a PNG strip —
212
- no browser, no GPU, no engine, so it runs on every iteration:
213
-
214
- ```
215
- bun tools/filmstrip.ts fox-anim.glb strip.png 6 wolf_walk threequarter
216
- BONES=1 CELL=360 bun tools/filmstrip.ts fox-anim.glb strip.png 5 wolf_walk side # skeleton overlay
217
- BIND=1 BONES=1 bun tools/filmstrip.ts fox.glb bind.png 1 - side # the bind pose itself
218
- CELL=440 bun tools/filmstrip.ts fox-anim.glb strip.png 4 wolf_walk side "fox.glb|-|0" # rest pose first
219
- ```
220
-
221
- For the real thing on the real engine, `packages/viewer` → `node anim-check/model-check.mjs`.
222
-
223
- ## Scale
224
-
225
- `probe` and `doctor` always **report** the model's rendered world size; nothing is changed without a
226
- flag. A skinned primitive ignores its node's transform (glTF spec), so the size is measured through
227
- the skin's bind space (`world(joint₀)·IBM₀`) — measuring via the mesh node reports garbage on exactly
228
- the assets that are mis-scaled.
229
-
230
- | flag | what it does |
231
- |---|---|
232
- | `--fix-scale` | guess the unit and bake it: cm / mm / feet / inches / the Blender-FBX `0.01` armature quirk. Picks the **smallest** correction that lands the largest dimension in 0.1–100 m |
233
- | `--scale N` | bake an explicit factor (works on clip-only files too) |
234
- | `--scale-to M` | bake whatever makes the largest dimension `M` metres |
235
-
236
- The factor is **baked into the data** — positions, node translations, IBM offsets and translation
237
- tracks all × s — not parked on a wrapper node. No node is added and no local scale is rewritten, so
238
- every node path an app addresses stays what it was. Rotation and scale tracks are untouched by
239
- construction: scaling only translations gives `world' = S·world·S⁻¹`, and the skinning identity then
240
- needs `IBM' = S·IBM·S⁻¹`, which is just the IBM's translation column × s.
241
-
242
- Quantized (`KHR_mesh_quantization`) positions are refused rather than silently corrupted.
243
-
244
- ## Shading — why a downloaded model renders too bright
245
-
246
- `doctor` always reports two material quirks that make an asset look washed out, and neither one is
247
- the usual suspect (metallic, or the texture):
248
-
249
- - **self-lit** — the base colour texture is *also* wired into `emissiveTexture` with
250
- `emissiveFactor = [1,1,1]`, so the shader draws `lit(albedo) + albedo`: roughly twice as bright as
251
- intended, with the shading flattened out. Common from Sketchfab downloads and several FBX
252
- exporters. `--fix-emissive` drops it and leaves the material otherwise PBR.
253
- - **specular boost** — `KHR_materials_specular` with a `specularColorFactor` above 1.
254
-
255
- `--unlit` goes further and converts the material to **`KHR_materials_unlit`**: flat
256
- `baseColorFactor × baseColorTexture`, no lights, no IBL, no exposure — the shading model hand-painted
257
- stylised assets are usually authored for. It also clears the emissive and drops specular/ior/normal/
258
- occlusion/MR inputs, because gltfio *refuses* to combine specular with unlit ("Cannot use specular
259
- extension with unlit") and silently falls back to lit if it is left in place.
260
-
261
- Both engines implement it: Filament through gltfio's `unlit_opaque` ubershader, web-lite through the
262
- `UNLIT` branch of its glTF shader (`viewer-lite/src/gl/glb.ts`, covered by `bun run render:check`).
263
-
264
- ## What `convert` does
265
-
266
- - **Reads FBX with [ufbx](https://github.com/ufbx/ufbx)** compiled to wasm (`native/fbx_reader.c` +
267
- `vendor/ufbx`, built by `build-wasm.sh` into `src/wasm/fbxReader.js` — a single-file module, no
268
- native builds, runs in Bun/Node/browsers). Everything is converted to glTF conventions in C:
269
- meters, right-handed Y-up (`space_conversion = MODIFY_GEOMETRY`), geometry transforms / inherit
270
- modes as helper nodes, triangulated + welded primitives, top-4 skin weights renormalized, UV v
271
- flipped, animations **baked** (30 fps, key-reduced, trimmed to start at 0). One JSON description +
272
- one binary arena cross the wasm boundary; `src/fbx/toDocument.ts` builds the Document.
273
- - **Textures** embedded: PNG/JPEG pass through, TGA/BMP decoded here (→ PNG), everything else via
274
- `sharp` when installed; duplicates de-duplicated by content hash; external references resolved
275
- next to the FBX / `textures/`.
276
- - **Materials**: ufbx's PBR view of FBX Phong/Lambert → metallic-roughness (opacity map → MASK,
277
- opacity factor < 1 → BLEND, double-sided honored). Separate metallic/roughness maps are not
278
- packed (factors used).
279
- - **`--clips`**: each clip file's animations are re-targeted onto the model **by bone name** (exact,
280
- then the part after the last `:`/`|` — the same rule the SDK `Animator` uses); clip meshes/skins
281
- dropped; unbound tracks counted + warned. Naming: a single-clip file → its stack name unless generic
282
- (`mixamo.com`, `Take 001`, `Unreal Take`) → the file's basename; `--clip-names` overrides.
283
- **Parent-space correction**: a track is a LOCAL transform, and two exports of one rig need not
284
- agree on what sits above a bone — Unreal/Unity clip exports put `root` straight under the scene
285
- (rest −90°X, the Z-up→Y-up turn) while the skinned-mesh export parks that turn on an armature node
286
- and leaves `root` at identity; copied verbatim the turn applies twice and the character plays lying
287
- on the floor (KINEMATION's Tactical Shooter Pack, every UE5 Mannequin export). For each bound bone
288
- the converter compares the world transform of its parent in both files — with the clip's first
289
- frame written onto every bone it animates, so only the unanimated STRUCTURE above differs, never
290
- the hair-width rest-pose drift between re-exports — and re-expresses the track in the model's
291
- parent space (`local_model = inv(Pmodel)·Pclip·local_clip`). Reported as an ℹ line per clip file.
292
- - Output goes through the same validation as the doctor (`validateGlb`) and a `probe` summary.
293
-
294
- Skipped in v1: cameras/lights, morph targets, NURBS.
295
-
296
- ## Verified with
297
-
298
- `packages/viewer` → `node anim-check/model-check.mjs anim-check/fixtures/<file>.glb --clip N`: loads the
299
- GLB on the Filament wasm engine, binds every clip through the native Animator, screenshots the rest
300
- pose and a playing clip (the mesh must draw and deform). `bun test` here covers the reader/converter
301
- against the Mixamo sample set when `LECODES_FBX_SAMPLES` (or the default sample folder) exists.
302
-
303
- Retargeting was tuned on the polyperfect *Low Poly Animated Animals* zoo (different authoring
304
- conventions within one pack: AdvancedSkeleton `Root_M/Hip_L`, Maya `actor:skin_*` with ×100 bind
305
- space and −Z facing, unnamed `joint1…joint42`): wolf → Meshy fox, lion → cat, wolf → horse / golden
306
- retriever, deer → giraffe, lion → elephant, all through `--map auto`, judged on `BONES=1` filmstrips
307
- next to the donor's own walk. `tests/retarget.test.ts` pins the invariants on toy rigs (exact
308
- direction transfer, keep/direction policies, bridges, leg detection + IK goals, donor reference).
309
-
310
- ## Layout
311
-
312
- ```
313
- native/fbx_reader.c ufbx → JSON + arena (compiled to wasm)
314
- vendor/ufbx/ ufbx.h / ufbx.c (MIT)
315
- src/wasm/fbxReader.js generated single-file Emscripten module (tracked; rebuild with build-wasm.sh)
316
- src/fbx/ read.ts (wasm boundary + IR) · toDocument.ts · convert.ts (merge, write)
317
- src/textures/ resolveImage.ts (sniff, TGA/BMP decode, PNG encode, external lookup)
318
- src/doctor/ doctor.ts (analyze/apply/report) · reduceGlbBones · transcodeTextures · transcodeKtx2 · normalizeScale · validateGlb (mirror of viewer's)
319
- src/retarget/ rig.ts (hierarchy + bind pose + clip sampling) · retarget.ts (reference poses, world-delta transfer, leg IK, bridges) · automap.ts (skeleton classification + chain pairing) · unityAnim.ts (Unity .anim YAML reader)
320
- src/unity/ unpack.ts (.unitypackage streaming) · materials.ts (.mat → glTF materials, ORM packing) · png.ts · scene.ts (.unity → level JSON)
321
- src/props.ts --list-nodes / --keep / --split (subtree carving + re-origin)
322
- src/mergeSkins.ts --merge-skins (skinned meshes onto one skeleton) · duplicateNodeNames
323
- src/filmstrip.ts CPU-skinned PNG filmstrip renderer (probe --filmstrip; tools/filmstrip.ts wraps it)
324
- src/probe.ts · src/cli.ts · src/index.ts
325
- ```
1
+ # lecodes-assets — the LeCodes asset pipeline
2
+
3
+ FBX → GLB converter + GLB doctor, one package on one glTF-Transform `Document`. Reachable as
4
+ `lecodes assets …` (optional peer of the `lecodes` CLI) or `bun src/cli.ts …` in the monorepo.
5
+ Plan/rationale: [docs/animation-plan.md §2.9](../../docs/animation-plan.md).
6
+
7
+ Install it the same way you installed the CLI — the peer is loaded by a bare `import` that resolves
8
+ upward from the CLI's own file, so a global `lecodes` only sees a global peer:
9
+
10
+ ```sh
11
+ npm install -g lecodes-assets
12
+ ```
13
+
14
+ KTX2 (`--ktx2`) additionally needs KTX-Software's `toktx` on PATH; `sharp` is an optional dependency
15
+ used to normalize exotic texture formats.
16
+
17
+ ```
18
+ lecodes assets convert "Erika Archer.fbx" --clips Idle.fbx Walking.fbx "Fast Run.fbx" --drop-model-clips -o hero.glb
19
+ lecodes assets convert hero.glb --clips Jump.fbx # add clips to an existing GLB
20
+ lecodes assets convert hero.fbx --fix # + doctor passes (bone cap, texture normalize)
21
+ lecodes assets convert hero.fbx --ktx2 # + textures → KTX2 (needs KTX-Software toktx)
22
+ lecodes assets probe hero.fbx | hero.glb # skeleton / clips / meshes / textures / bind sanity / world size
23
+ lecodes assets doctor hero.glb [--fix] [--ktx2] [--dump] # the old tools/glb-reduce
24
+ lecodes assets doctor hero.glb --fix-scale # bake a unit fix (see Scale below)
25
+ lecodes assets doctor props/*.glb --lightmap-uv -o props/ # + TEXCOORD_1 lightmap UVs (xatlas) for `lecodes lightmap bake`
26
+ lecodes assets retarget fox.glb --from wolf.fbx --map auto # clips from a DIFFERENT skeleton (map from skeleton shape)
27
+ lecodes assets unpack pack.unitypackage -o out [--only re …] # stream a .unitypackage: unity-index.txt + assets (+ .meta)
28
+ lecodes assets convert Barrel_v1.FBX --list-nodes # multi-variant prop file: what is in it
29
+ lecodes assets convert Barrel_v1.FBX --keep Barrel_v1_LD1 -o barrel.glb # one variant, re-origined on the ground
30
+ lecodes assets convert Barrel_v1.FBX --split props/ # every top-level node → its own GLB
31
+ lecodes assets convert Body.FBX --merge-skins Shirt.FBX Gloves.FBX --clips … -o hero.glb # modular character → ONE skeleton
32
+ lecodes assets probe hero.glb --filmstrip # a CPU-skinned PNG strip per clip (no GPU)
33
+ lecodes assets scene Map_v8.unity -o level.json # Unity scene → placed prefabs with world TRS
34
+ ```
35
+
36
+ ## Props, modular characters, scenes — the Unity-pack workflow
37
+
38
+ **Multi-variant prop files.** Packs put every LOD, colour set and arrangement of a prop in one FBX
39
+ (`Barrel_v1.FBX` = 15 meshes). `--list-nodes [depth]` prints the scene's nodes with mesh/triangle
40
+ counts and world sizes; `--keep <node> …` keeps only those subtrees (ancestors stay as the path,
41
+ descendants come along) and **re-origins** the result — footprint centre at X/Z = 0, lowest vertex at
42
+ Y = 0 — so `Model.load` places the prop exactly where the node is (`--no-ground` keeps the file's
43
+ own placement); `--split [dir]` writes one re-origined GLB per top-level node. Both run before the
44
+ doctor passes, so `--keep X --ktx2 --max-size 1024` is the whole prop pipeline.
45
+
46
+ **Modular characters.** Body, shirt, gloves and boots each arrive as their own skinned FBX over the
47
+ same skeleton. `--merge-skins b.fbx c.glb …` moves the donors' skinned meshes onto the model's skin
48
+ (joints matched by name — exact, then the part after the last `:`/`|`, the Animator's own rule) and
49
+ drops their skeleton copies. That last part matters: a hand merge that leaves the donor skeleton in
50
+ gives every bone name a plain-node twin, and a name-bound `Animator` drives the twin while the mesh
51
+ freezes in its rest pose. `probe` and `doctor` now flag duplicate node names, and the doctor marks
52
+ the joint-shadowing case as BLOCKING. Donor bones the base lacks are reported with the skin weight
53
+ that sat on them (it moves to the root).
54
+
55
+ **Checking a clip without a GPU.** `probe` prints, per clip and skeleton root, how far the root
56
+ travels (root motion) and how far its first frame is from the rest pose — a root that starts ~90°
57
+ off is the double-axis-turn symptom of a clip from another export. `probe --filmstrip [out.png]`
58
+ renders a software-skinned PNG strip of every clip (`--clip` for one, `--frames N`, `--view
59
+ side|front|top|threequarter|lowfront`, `--bones` for the skeleton overlay, `--cell` px per frame);
60
+ `tools/filmstrip.ts` is the same renderer with reference frames from other files.
61
+
62
+ **Unity scenes.** `scene Map.unity [-o level.json]` reads a text-serialised scene: every
63
+ `PrefabInstance` (source prefab GUID, the position/rotation/scale/name/active overrides, its parent)
64
+ and every `MeshFilter` object, composes the scene's own `Transform` hierarchy on top (groups like
65
+ `Static/Roads`), and writes the instances with their **world** TRS plus a prefab table — path, use
66
+ count, and a guess at the FBX + `--keep` node that renders it (the FBX from the prefab's folder /
67
+ `Source/` whose stem prefixes the prefab name; most packs ship their prefabs binary-serialised, so
68
+ the prefab's own contents are not read). Frame: Unity is left-handed and its FBX importer mirrors X,
69
+ so the default undoes that — position `(−x, y, z)`, quaternion `(x, −y, −z, w)` — `--handedness z`
70
+ mirrors Z instead (content authored inside Unity), `none` keeps Unity's numbers. Subsets:
71
+ `--filter <re>` (prefab path / name / group), `--group <name>`, `--region x0,z0,x1,z1` (Unity
72
+ metres), `--include-inactive`. A parent that sits inside a binary prefab resolves to that prefab's
73
+ root and the instance is marked `approximate`. The output is plain JSON:
74
+ `{ prefabs: { guid: { path, name, fbx, node, count } }, instances: [ { name, prefab, group,
75
+ position, rotation, scale } ] }` — one `Model.load` per prefab, one `clone()` per instance.
76
+
77
+ ## Unity packs — `unpack` + materials from `.mat`
78
+
79
+ ```
80
+ lecodes assets unpack "Tactical FPS Animations.unitypackage" -o D:/Unity/tactical # everything
81
+ lecodes assets unpack pack.unitypackage -o out --only 'Meshes/' 'Animations/' 'Materials/' # regex subset
82
+ lecodes assets unpack pack.unitypackage -o out --list --only '\.wav$' # just list
83
+ lecodes assets convert out/Assets/Pack/Weapons/SKM_TR-15.FBX --clips … --ktx2 -o tr15.glb # .mat-aware
84
+ ```
85
+
86
+ A `.unitypackage` is a gzip'd tar of one folder per asset GUID. `unpack` streams it twice (flat
87
+ memory on multi-GB packs): pass 1 writes `<out>/unity-index.txt` (GUID → path), pass 2 writes the
88
+ matching assets + their `.meta` sidecars under `<out>/Assets/…`.
89
+
90
+ `convert` then reads the **Unity `.mat` files** found next to (or one folder above) the FBX and
91
+ rewrites the glTF materials from them — an FBX exported for Unity usually references only a legacy
92
+ base colour and a normal map, while metallic / roughness / occlusion / emission and the alpha mode
93
+ live in the material. Matching is by name modulo `M_`/`MI_`/`MAT_` prefixes, then by containment
94
+ (`MI_Tshirt` ↔ `M_Shirt`), then by a shared base texture (`MI_Skin` ↔ `M_Body` through
95
+ `TX_Body_BaseColor`). Texture GUIDs resolve through `unity-index.txt` (found walking up from the
96
+ FBX, or `--unity-index`), else through `.meta` sidecars of a real Unity project. Slots understood:
97
+ `_BaseMap/_MainTex/_BaseColor/_BC`, `_BumpMap/_Normal`, a ready `_ORM`, HDRP/URP `_MaskMap`
98
+ (R metal, G AO, A smooth), Standard `_MetallicGlossMap` (R metal, A smooth), separate `_Metallic`,
99
+ `_Roughness` / `_Smoothness`, `_OcclusionMap`, `_EmissionMap` (+ `_EMISSION` keyword / colour),
100
+ `_Surface`/`_Mode`/`_AlphaClip`/`_Cutoff`/`_Cull`. Everything that is not already one texture is
101
+ **packed the glTF way** (R = AO, G = roughness, B = metallic; PNG/TGA/BMP decoded in-process, other
102
+ formats through `sharp`) — one metallic-roughness(+occlusion) texture per material. `--no-unity`
103
+ keeps the FBX references; `--unity-mats <dir>` looks elsewhere. Only YAML-serialised `.mat` files are
104
+ readable (Unity "Force Text", the asset-store default since 2017); binary ones are listed and skipped.
105
+
106
+ ## Retarget — clips from a different skeleton
107
+
108
+ `convert --clips` binds tracks by BONE NAME and copies them verbatim; that only works when both
109
+ files came from the same rig (the Mixamo case). `retarget` drives one rig with a completely
110
+ different rig's clips — different bone names, different chain lengths, different proportions,
111
+ different local axis conventions, even different facing:
112
+
113
+ ```
114
+ lecodes assets retarget fox.glb --from Wolf_Animation.fbx --map auto --clips wolf_walk wolf_run -o fox-anim.glb
115
+ lecodes assets retarget giraffe.glb --from Deer_Animation.fbx --auto-map # writes giraffe-from-Deer_Animation.map.json to edit
116
+ lecodes assets retarget giraffe.glb --from Deer_Animation.fbx --map giraffe-from-Deer_Animation.map.json -o giraffe-anim.glb
117
+ lecodes assets retarget fox-anim.glb --from Wolf_Howl.fbx --map … --keep-clips --clip-names howl
118
+ lecodes assets retarget fox.glb --from Wolf_Animation.fbx --skeletons # both bone lists, to write a map by hand
119
+ lecodes assets retarget fox.glb --from Cat.fbx --map auto --anim Cat_Sleep.anim # donor clips that live outside the FBX
120
+ ```
121
+
122
+ **The map** is target-bone → donor-bone; `root` names the bone that receives root motion. `--map auto`
123
+ / `--auto-map` derive it from the two skeletons' SHAPE — root, spine, neck, head, jaw, ears, tail and
124
+ the four legs (side + front/back) are read off the bind pose, then paired chain by chain with bones
125
+ matched by arc length (names only break ties when they agree: `backAnkle`↔`Ankle_L`). It works on
126
+ `joint1…joint42` rigs and prints exactly what it paired — read it before trusting it. Optional keys:
127
+
128
+ ```json
129
+ { "root": "Hips",
130
+ "bones": { "Hips": "Root_M", "chest": "Chest_M", "backleg": "Hip_L", "backleg0": "Knee_L", "…": "…" },
131
+ "align": { "chest": "keep", "tail": "direction", "head": [ "headend" ] }, // per-bone reference policy (see below)
132
+ "legs": [ "backleg2", "R_backleg2", "frontleg2", "R_frontleg2" ], // feet, when auto-detection picks wrong
133
+ "donorRef": "auto" | "bind" | { "clip": "wolf_idle", "time": 0 } }
134
+ ```
135
+
136
+ **How it works.** Everything happens in WORLD space, where a rotation means the same thing in both
137
+ rigs: `ΔR(t) = Rdonor(t)·Rdonor_ref⁻¹` is applied to the target's own reference orientation and
138
+ converted back to local against the already-retargeted parent. That transfer is exact — the thing
139
+ that decides quality is what the two REFERENCE poses are and what happens where proportions differ:
140
+
141
+ - **Donor reference** (`donorRef`, default `auto`). An exporter's bind is often a posture the clips
142
+ never visit — the polyperfect wolf binds upright on vertical thighs and walks 10% lower with its
143
+ thighs 30° forward; measured from that bind every clip reads as "crouch and lean". When the two
144
+ binds put the legs differently (>12°: different conventions), the donor is referenced to the MEAN
145
+ pose of its walk (else idle, else the longest clip) — its actual neutral posture. When the binds
146
+ agree, bind-to-bind is the faithful transfer (an identity retarget reproduces the donor exactly).
147
+ `--donor-ref bind|auto|<clip>` overrides.
148
+ - **Target reference**, per bone: `keep` its bind orientation, or `direction` — turn it so the bone
149
+ points the way the donor's does (an auto-T-pose step, measured to the nearest mapped descendant).
150
+ A target that binds in a STANCE (feet level, legs hanging) keeps everything: that is its natural
151
+ posture and the donor's deltas ride on it — the horse stays tall under wolf clips, the giraffe's
152
+ neck stays up under deer clips. A target that binds posed (T-pose, folded legs) keeps its core
153
+ (root, pelvis, chest, head — artists put those joints anywhere, so their joint lines are not
154
+ comparable) and direction-aligns its chains. `align` overrides per bone.
155
+ - **Feet** (`--ik legs`, the default). Directions transfer, positions do not: with different
156
+ front/back leg ratios the feet float or sink, which the eye catches first. Legs are detected from
157
+ the map (lateral chains reaching the ground; a foot is what stands on the donor's ground, a horse's
158
+ wrist-ended front leg is not), the reference pose is planted (root lowered/raised, every foot bent
159
+ onto the ground), then every frame each foot gets a goal built as THE TARGET'S OWN STANCE plus the
160
+ donor's departure from ITS stance, scaled by that leg's length ratio, on all three axes — height
161
+ above each rig's own stance level, and the horizontal offset each rig plants its foot at under its
162
+ own hip — and a FABRIK pass from the FK pose bends the chain onto it. Both builds are therefore
163
+ preserved: a target that tucks its feet inboard of its hips keeps doing so against a donor that
164
+ stands on vertical legs. (Copying the donor's absolute foot-under-hip offset instead planted this
165
+ repo's fox — which tucks each foot ~8% of body height inboard, while every polyperfect animal
166
+ stands vertical — bow-legged in every clip.) When the legs are scaled copies the goal IS the FK
167
+ pose, so IK only ever spends the proportion residual; goals out of reach are reported per leg.
168
+ - **Root motion** is scaled by the mean leg-length ratio (a stride is leg length, not body height),
169
+ so stance feet that were still on the donor stay still on the target.
170
+ - **Chain lengths.** A longer donor chain collapses for free (the target's `chest` mapped to
171
+ `Chest_M` picks up Spine1+Spine2, because a world rotation already contains everything above it).
172
+ A longer target chain — unmapped bones between two mapped ones — interpolates the delta along the
173
+ chain (a 6-bone spine against a 2-bone one bends everywhere, not at one joint).
174
+ - **Spaces.** The anchor is the BIND pose (inverse of the skin's inverse-bind matrices), never the TRS
175
+ on the nodes — the wolf's node pose is 0.37 m from its bind. A skin whose bind space is scaled
176
+ against node space (Maya "actor" exports, ×100) is rescaled; a donor facing the other way is turned
177
+ (facing is read from the skeleton shape, so `joint1…` rigs work too); non-uniform scales are refused.
178
+
179
+ What it does not do yet: foot locking on the target beyond what the donor's feet do (an in-place
180
+ donor still treadmills), stride warping when a leg is far too short for the donor's reach (reported
181
+ as "out of reach"), torso pitch to compensate for mismatched front/hind leg ratios, bipeds/arms (the
182
+ auto-map is quadruped-shaped; a humanoid map is best written by hand or by name).
183
+
184
+ ### Donors whose clips are not in the FBX
185
+
186
+ Asset packs routinely ship the rig as FBX and the animations as Unity `.anim` assets — the
187
+ polyperfect cat and all three dogs have EMPTY FBX files and eight `.anim` clips each. `--anim` reads
188
+ those directly (`src/retarget/unityAnim.ts`, YAML only — a project saved with binary serialisation
189
+ has to be re-exported with *Asset Serialization = Force Text*) and attaches them to the donor rig as
190
+ clips, bound by the LAST segment of each curve's transform path:
191
+
192
+ ```
193
+ lecodes assets retarget fox.glb --from Cat.fbx --map fox-from-cat.map.json --anim Cat_Walk.anim Cat_Idle.anim Cat_Sleep.anim Cat_StandUp.anim --clips Cat_Sleep Cat_StandUp --clip-names sleep stand_up -o fox-cat.glb
194
+ ```
195
+
196
+ Pass the donor's locomotion clips even when you do not want them in the output: `--donor-ref auto`
197
+ takes the donor's neutral posture from a walk/idle, and without one it falls back to the bind pose.
198
+
199
+ ### What a short spine cannot borrow
200
+
201
+ Rotations transfer; positions follow from the TARGET's own bones. Leg IK covers that for feet, but
202
+ nothing covers the spine, so a clip whose whole point is a spine CURL does not survive a target with
203
+ fewer spine bones. The cat sits by curving a 7-bone spine to hold its chest up over a dropping
204
+ pelvis; this fox has two spine bones and no way to curve, so its chest is simply carried down by the
205
+ pelvis and the head ends up on the floor (4% of body height, against the cat's 79%). Hanging the
206
+ pelvis on a mid-spine bone instead recovers part of it (27%) but then breaks lying-down clips, whose
207
+ heads go through the floor. Clips that END lying (`Sleep`) or start there (`StandUp`) transfer fine —
208
+ they need no upward curl.
209
+
210
+ ### Seeing the result
211
+
212
+ A bone map is judged by eye. `tools/filmstrip.ts` skins a clip on the CPU and writes a PNG strip —
213
+ no browser, no GPU, no engine, so it runs on every iteration:
214
+
215
+ ```
216
+ bun tools/filmstrip.ts fox-anim.glb strip.png 6 wolf_walk threequarter
217
+ BONES=1 CELL=360 bun tools/filmstrip.ts fox-anim.glb strip.png 5 wolf_walk side # skeleton overlay
218
+ BIND=1 BONES=1 bun tools/filmstrip.ts fox.glb bind.png 1 - side # the bind pose itself
219
+ CELL=440 bun tools/filmstrip.ts fox-anim.glb strip.png 4 wolf_walk side "fox.glb|-|0" # rest pose first
220
+ ```
221
+
222
+ For the real thing on the real engine, `packages/viewer` → `node anim-check/model-check.mjs`.
223
+
224
+ ## Scale
225
+
226
+ `probe` and `doctor` always **report** the model's rendered world size; nothing is changed without a
227
+ flag. A skinned primitive ignores its node's transform (glTF spec), so the size is measured through
228
+ the skin's bind space (`world(joint₀)·IBM₀`) — measuring via the mesh node reports garbage on exactly
229
+ the assets that are mis-scaled.
230
+
231
+ | flag | what it does |
232
+ |---|---|
233
+ | `--fix-scale` | guess the unit and bake it: cm / mm / feet / inches / the Blender-FBX `0.01` armature quirk. Picks the **smallest** correction that lands the largest dimension in 0.1–100 m |
234
+ | `--scale N` | bake an explicit factor (works on clip-only files too) |
235
+ | `--scale-to M` | bake whatever makes the largest dimension `M` metres |
236
+
237
+ The factor is **baked into the data** — positions, node translations, IBM offsets and translation
238
+ tracks all × s — not parked on a wrapper node. No node is added and no local scale is rewritten, so
239
+ every node path an app addresses stays what it was. Rotation and scale tracks are untouched by
240
+ construction: scaling only translations gives `world' = S·world·S⁻¹`, and the skinning identity then
241
+ needs `IBM' = S·IBM·S⁻¹`, which is just the IBM's translation column × s.
242
+
243
+ Quantized (`KHR_mesh_quantization`) positions are refused rather than silently corrupted.
244
+
245
+ ## Shading — why a downloaded model renders too bright
246
+
247
+ `doctor` always reports two material quirks that make an asset look washed out, and neither one is
248
+ the usual suspect (metallic, or the texture):
249
+
250
+ - **self-lit** — the base colour texture is *also* wired into `emissiveTexture` with
251
+ `emissiveFactor = [1,1,1]`, so the shader draws `lit(albedo) + albedo`: roughly twice as bright as
252
+ intended, with the shading flattened out. Common from Sketchfab downloads and several FBX
253
+ exporters. `--fix-emissive` drops it and leaves the material otherwise PBR.
254
+ - **specular boost** — `KHR_materials_specular` with a `specularColorFactor` above 1.
255
+
256
+ `--unlit` goes further and converts the material to **`KHR_materials_unlit`**: flat
257
+ `baseColorFactor × baseColorTexture`, no lights, no IBL, no exposure — the shading model hand-painted
258
+ stylised assets are usually authored for. It also clears the emissive and drops specular/ior/normal/
259
+ occlusion/MR inputs, because gltfio *refuses* to combine specular with unlit ("Cannot use specular
260
+ extension with unlit") and silently falls back to lit if it is left in place.
261
+
262
+ Both engines implement it: Filament through gltfio's `unlit_opaque` ubershader, web-lite through the
263
+ `UNLIT` branch of its glTF shader (`viewer-lite/src/gl/glb.ts`, covered by `bun run render:check`).
264
+
265
+ ## What `convert` does
266
+
267
+ - **Reads FBX with [ufbx](https://github.com/ufbx/ufbx)** compiled to wasm (`native/fbx_reader.c` +
268
+ `vendor/ufbx`, built by `build-wasm.sh` into `src/wasm/fbxReader.js` — a single-file module, no
269
+ native builds, runs in Bun/Node/browsers). Everything is converted to glTF conventions in C:
270
+ meters, right-handed Y-up (`space_conversion = MODIFY_GEOMETRY`), geometry transforms / inherit
271
+ modes as helper nodes, triangulated + welded primitives, top-4 skin weights renormalized, UV v
272
+ flipped, animations **baked** (30 fps, key-reduced, trimmed to start at 0). One JSON description +
273
+ one binary arena cross the wasm boundary; `src/fbx/toDocument.ts` builds the Document.
274
+ - **Textures** embedded: PNG/JPEG pass through, TGA/BMP decoded here (→ PNG), everything else via
275
+ `sharp` when installed; duplicates de-duplicated by content hash; external references resolved
276
+ next to the FBX / `textures/`.
277
+ - **Materials**: ufbx's PBR view of FBX Phong/Lambert → metallic-roughness (opacity map → MASK,
278
+ opacity factor < 1 → BLEND, double-sided honored). Separate metallic/roughness maps are not
279
+ packed (factors used).
280
+ - **`--clips`**: each clip file's animations are re-targeted onto the model **by bone name** (exact,
281
+ then the part after the last `:`/`|` — the same rule the SDK `Animator` uses); clip meshes/skins
282
+ dropped; unbound tracks counted + warned. Naming: a single-clip file → its stack name unless generic
283
+ (`mixamo.com`, `Take 001`, `Unreal Take`) → the file's basename; `--clip-names` overrides.
284
+ **Parent-space correction**: a track is a LOCAL transform, and two exports of one rig need not
285
+ agree on what sits above a bone — Unreal/Unity clip exports put `root` straight under the scene
286
+ (rest −90°X, the Z-up→Y-up turn) while the skinned-mesh export parks that turn on an armature node
287
+ and leaves `root` at identity; copied verbatim the turn applies twice and the character plays lying
288
+ on the floor (KINEMATION's Tactical Shooter Pack, every UE5 Mannequin export). For each bound bone
289
+ the converter compares the world transform of its parent in both files — with the clip's first
290
+ frame written onto every bone it animates, so only the unanimated STRUCTURE above differs, never
291
+ the hair-width rest-pose drift between re-exports — and re-expresses the track in the model's
292
+ parent space (`local_model = inv(Pmodel)·Pclip·local_clip`). Reported as an ℹ line per clip file.
293
+ - Output goes through the same validation as the doctor (`validateGlb`) and a `probe` summary.
294
+
295
+ Skipped in v1: cameras/lights, morph targets, NURBS.
296
+
297
+ ## Verified with
298
+
299
+ `packages/viewer` → `node anim-check/model-check.mjs anim-check/fixtures/<file>.glb --clip N`: loads the
300
+ GLB on the Filament wasm engine, binds every clip through the native Animator, screenshots the rest
301
+ pose and a playing clip (the mesh must draw and deform). `bun test` here covers the reader/converter
302
+ against the Mixamo sample set when `LECODES_FBX_SAMPLES` (or the default sample folder) exists.
303
+
304
+ Retargeting was tuned on the polyperfect *Low Poly Animated Animals* zoo (different authoring
305
+ conventions within one pack: AdvancedSkeleton `Root_M/Hip_L`, Maya `actor:skin_*` with ×100 bind
306
+ space and −Z facing, unnamed `joint1…joint42`): wolf → Meshy fox, lion → cat, wolf → horse / golden
307
+ retriever, deer → giraffe, lion → elephant, all through `--map auto`, judged on `BONES=1` filmstrips
308
+ next to the donor's own walk. `tests/retarget.test.ts` pins the invariants on toy rigs (exact
309
+ direction transfer, keep/direction policies, bridges, leg detection + IK goals, donor reference).
310
+
311
+ ## Layout
312
+
313
+ ```
314
+ native/fbx_reader.c ufbx → JSON + arena (compiled to wasm)
315
+ vendor/ufbx/ ufbx.h / ufbx.c (MIT)
316
+ src/wasm/fbxReader.js generated single-file Emscripten module (tracked; rebuild with build-wasm.sh)
317
+ src/fbx/ read.ts (wasm boundary + IR) · toDocument.ts · convert.ts (merge, write)
318
+ src/textures/ resolveImage.ts (sniff, TGA/BMP decode, PNG encode, external lookup)
319
+ src/doctor/ doctor.ts (analyze/apply/report) · reduceGlbBones · transcodeTextures · transcodeKtx2 · normalizeScale · lightmapUv (xatlas wasm) · validateGlb (mirror of viewer's)
320
+ src/retarget/ rig.ts (hierarchy + bind pose + clip sampling) · retarget.ts (reference poses, world-delta transfer, leg IK, bridges) · automap.ts (skeleton classification + chain pairing) · unityAnim.ts (Unity .anim YAML reader)
321
+ src/unity/ unpack.ts (.unitypackage streaming) · materials.ts (.mat → glTF materials, ORM packing) · png.ts · scene.ts (.unity → level JSON)
322
+ src/props.ts --list-nodes / --keep / --split (subtree carving + re-origin)
323
+ src/mergeSkins.ts --merge-skins (skinned meshes onto one skeleton) · duplicateNodeNames
324
+ src/filmstrip.ts CPU-skinned PNG filmstrip renderer (probe --filmstrip; tools/filmstrip.ts wraps it)
325
+ src/probe.ts · src/cli.ts · src/index.ts
326
+ ```