lecodes-assets 0.2.0 → 2.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
package/README.md CHANGED
@@ -1,449 +1,738 @@
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 avatar girl.glb --meta SK_Girl.fbx.meta # a humanoid's bones in the Unity/VRM canon (vs Unity's own map)
28
- lecodes assets unpack pack.unitypackage -o out [--only re …] # stream a .unitypackage: unity-index.txt + assets (+ .meta)
29
- lecodes assets convert Barrel_v1.FBX --list-nodes # multi-variant prop file: what is in it
30
- lecodes assets convert Barrel_v1.FBX --keep Barrel_v1_LD1 -o barrel.glb # one variant, re-origined on the ground
31
- lecodes assets convert Barrel_v1.FBX --split props/ # every top-level node → its own GLB
32
- lecodes assets convert Body.FBX --merge-skins Shirt.FBX Gloves.FBX --clips … -o hero.glb # modular character → ONE skeleton
33
- lecodes assets probe hero.glb --filmstrip # a CPU-skinned PNG strip per clip (no GPU)
34
- lecodes assets scene Map_v8.unity -o level.json # Unity scene → placed prefabs with world TRS
35
- ```
36
-
37
- ## Props, modular characters, scenes — the Unity-pack workflow
38
-
39
- **Multi-variant prop files.** Packs put every LOD, colour set and arrangement of a prop in one FBX
40
- (`Barrel_v1.FBX` = 15 meshes). `--list-nodes [depth]` prints the scene's nodes with mesh/triangle
41
- counts and world sizes; `--keep <node> …` keeps only those subtrees (ancestors stay as the path,
42
- descendants come along) and **re-origins** the result — footprint centre at X/Z = 0, lowest vertex at
43
- Y = 0 — so `Model.load` places the prop exactly where the node is (`--no-ground` keeps the file's
44
- own placement); `--split [dir]` writes one re-origined GLB per top-level node. Both run before the
45
- doctor passes, so `--keep X --ktx2 --max-size 1024` is the whole prop pipeline.
46
-
47
- **Modular characters.** Body, shirt, gloves and boots each arrive as their own skinned FBX over the
48
- same skeleton. `--merge-skins b.fbx c.glb …` moves the donors' skinned meshes onto the model's skin
49
- (joints matched by name — exact, then the part after the last `:`/`|`, the Animator's own rule) and
50
- drops their skeleton copies. That last part matters: a hand merge that leaves the donor skeleton in
51
- gives every bone name a plain-node twin, and a name-bound `Animator` drives the twin while the mesh
52
- freezes in its rest pose. `probe` and `doctor` now flag duplicate node names, and the doctor marks
53
- the joint-shadowing case as BLOCKING. Donor bones the base lacks are reported with the skin weight
54
- that sat on them (it moves to the root).
55
-
56
- **Checking a clip without a GPU.** `probe` prints, per clip and skeleton root, how far the root
57
- travels (root motion) and how far its first frame is from the rest pose — a root that starts ~90°
58
- off is the double-axis-turn symptom of a clip from another export. `probe --filmstrip [out.png]`
59
- renders a software-skinned PNG strip of every clip (`--clip` for one, `--frames N`, `--view
60
- side|front|top|threequarter|lowfront`, `--bones` for the skeleton overlay, `--cell` px per frame);
61
- `tools/filmstrip.ts` is the same renderer with reference frames from other files.
62
-
63
- **Unity scenes.** `scene Map.unity [-o level.json]` reads a text-serialised scene: every
64
- `PrefabInstance` (source prefab GUID, the position/rotation/scale/name/active overrides, its parent)
65
- and every `MeshFilter` object, composes the scene's own `Transform` hierarchy on top (groups like
66
- `Static/Roads`), and writes the instances with their **world** TRS plus a prefab table — path, use
67
- count, and a guess at the FBX + `--keep` node that renders it (the FBX from the prefab's folder /
68
- `Source/` whose stem prefixes the prefab name; most packs ship their prefabs binary-serialised, so
69
- the prefab's own contents are not read). Frame: Unity is left-handed and its FBX importer mirrors X,
70
- so the default undoes that — position `(−x, y, z)`, quaternion `(x, −y, −z, w)` — `--handedness z`
71
- mirrors Z instead (content authored inside Unity), `none` keeps Unity's numbers. Subsets:
72
- `--filter <re>` (prefab path / name / group), `--group <name>`, `--region x0,z0,x1,z1` (Unity
73
- metres), `--include-inactive`. A parent that sits inside a binary prefab resolves to that prefab's
74
- root and the instance is marked `approximate`. The output is plain JSON:
75
- `{ prefabs: { guid: { path, name, fbx, node, count } }, instances: [ { name, prefab, group,
76
- position, rotation, scale } ] }` — one `Model.load` per prefab, one `clone()` per instance.
77
-
78
- ## Unity packs — `unpack` + materials from `.mat`
79
-
80
- ```
81
- lecodes assets unpack "Tactical FPS Animations.unitypackage" -o D:/Unity/tactical # everything
82
- lecodes assets unpack pack.unitypackage -o out --only 'Meshes/' 'Animations/' 'Materials/' # regex subset
83
- lecodes assets unpack pack.unitypackage -o out --list --only '\.wav$' # just list
84
- lecodes assets convert out/Assets/Pack/Weapons/SKM_TR-15.FBX --clips … --ktx2 -o tr15.glb # .mat-aware
85
- ```
86
-
87
- A `.unitypackage` is a gzip'd tar of one folder per asset GUID. `unpack` streams it twice (flat
88
- memory on multi-GB packs): pass 1 writes `<out>/unity-index.txt` (GUID → path), pass 2 writes the
89
- matching assets + their `.meta` sidecars under `<out>/Assets/…`.
90
-
91
- `convert` then reads the **Unity `.mat` files** found next to (or one folder above) the FBX and
92
- rewrites the glTF materials from them — an FBX exported for Unity usually references only a legacy
93
- base colour and a normal map, while metallic / roughness / occlusion / emission and the alpha mode
94
- live in the material. Matching is by name modulo `M_`/`MI_`/`MAT_` prefixes, then by containment
95
- (`MI_Tshirt` ↔ `M_Shirt`), then by a shared base texture (`MI_Skin` ↔ `M_Body` through
96
- `TX_Body_BaseColor`). Texture GUIDs resolve through `unity-index.txt` (found walking up from the
97
- FBX, or `--unity-index`), else through `.meta` sidecars of a real Unity project. Slots understood:
98
- `_BaseMap/_MainTex/_BaseColor/_BC`, `_BumpMap/_Normal`, a ready `_ORM`, HDRP/URP `_MaskMap`
99
- (R metal, G AO, A smooth), Standard `_MetallicGlossMap` (R metal, A smooth), separate `_Metallic`,
100
- `_Roughness` / `_Smoothness`, `_OcclusionMap`, `_EmissionMap` (+ `_EMISSION` keyword / colour),
101
- `_Surface`/`_Mode`/`_AlphaClip`/`_Cutoff`/`_Cull`. Everything that is not already one texture is
102
- **packed the glTF way** (R = AO, G = roughness, B = metallic; PNG/TGA/BMP decoded in-process, other
103
- formats through `sharp`) — one metallic-roughness(+occlusion) texture per material. `--no-unity`
104
- keeps the FBX references; `--unity-mats <dir>` looks elsewhere. Only YAML-serialised `.mat` files are
105
- readable (Unity "Force Text", the asset-store default since 2017); binary ones are listed and skipped.
106
-
107
- ## Retarget — clips from a different skeleton
108
-
109
- `convert --clips` binds tracks by BONE NAME and copies them verbatim; that only works when both
110
- files came from the same rig (the Mixamo case). `retarget` drives one rig with a completely
111
- different rig's clips — different bone names, different chain lengths, different proportions,
112
- different local axis conventions, even different facing:
113
-
114
- ```
115
- lecodes assets retarget fox.glb --from Wolf_Animation.fbx --map auto --clips wolf_walk wolf_run -o fox-anim.glb
116
- lecodes assets retarget giraffe.glb --from Deer_Animation.fbx --auto-map # writes giraffe-from-Deer_Animation.map.json to edit
117
- lecodes assets retarget giraffe.glb --from Deer_Animation.fbx --map giraffe-from-Deer_Animation.map.json -o giraffe-anim.glb
118
- lecodes assets retarget fox-anim.glb --from Wolf_Howl.fbx --map … --keep-clips --clip-names howl
119
- lecodes assets retarget fox.glb --from Wolf_Animation.fbx --skeletons # both bone lists, to write a map by hand
120
- lecodes assets retarget fox.glb --from Cat.fbx --map auto --anim Cat_Sleep.anim # donor clips that live outside the FBX
121
- ```
122
-
123
- **The map** is target-bone → donor-bone; `root` names the bone that receives root motion. `--map auto`
124
- / `--auto-map` derive it from the two skeletons' SHAPE — root, spine, neck, head, jaw, ears, tail and
125
- the four legs (side + front/back) are read off the bind pose, then paired chain by chain with bones
126
- matched by arc length (names only break ties when they agree: `backAnkle`↔`Ankle_L`). It works on
127
- `joint1…joint42` rigs and prints exactly what it paired — read it before trusting it. Optional keys:
128
-
129
- ```json
130
- { "root": "Hips",
131
- "bones": { "Hips": "Root_M", "chest": "Chest_M", "backleg": "Hip_L", "backleg0": "Knee_L", "…": "…" },
132
- "align": { "chest": "keep", "tail": "direction", "head": [ "headend" ] }, // per-bone reference policy (see below)
133
- "legs": [ "backleg2", "R_backleg2", "frontleg2", "R_frontleg2" ], // feet, when auto-detection picks wrong
134
- "donorRef": "auto" | "bind" | { "clip": "wolf_idle", "time": 0 } }
135
- ```
136
-
137
- **How it works.** Everything happens in WORLD space, where a rotation means the same thing in both
138
- rigs: `ΔR(t) = Rdonor(t)·Rdonor_ref⁻¹` is applied to the target's own reference orientation and
139
- converted back to local against the already-retargeted parent. That transfer is exact — the thing
140
- that decides quality is what the two REFERENCE poses are and what happens where proportions differ:
141
-
142
- - **Donor reference** (`donorRef`, default `auto`). An exporter's bind is often a posture the clips
143
- never visit — the polyperfect wolf binds upright on vertical thighs and walks 10% lower with its
144
- thighs 30° forward; measured from that bind every clip reads as "crouch and lean". When the two
145
- binds put the legs differently (>12°: different conventions), the donor is referenced to the MEAN
146
- pose of its walk (else idle, else the longest clip) — its actual neutral posture. When the binds
147
- agree, bind-to-bind is the faithful transfer (an identity retarget reproduces the donor exactly).
148
- `--donor-ref bind|auto|<clip>` overrides.
149
- - **Target reference**, per bone: `keep` its bind orientation, or `direction` — turn it so the bone
150
- points the way the donor's does (an auto-T-pose step, measured to the nearest mapped descendant).
151
- A target that binds in a STANCE (feet level, legs hanging) keeps everything: that is its natural
152
- posture and the donor's deltas ride on it — the horse stays tall under wolf clips, the giraffe's
153
- neck stays up under deer clips. A target that binds posed (T-pose, folded legs) keeps its core
154
- (root, pelvis, chest, head — artists put those joints anywhere, so their joint lines are not
155
- comparable) and direction-aligns its chains. `align` overrides per bone.
156
- - **Feet** (`--ik legs`, the default). Directions transfer, positions do not: with different
157
- front/back leg ratios the feet float or sink, which the eye catches first. Legs are detected from
158
- the map (lateral chains reaching the ground; a foot is what stands on the donor's ground, a horse's
159
- wrist-ended front leg is not), the reference pose is planted (root lowered/raised, every foot bent
160
- onto the ground), then every frame each foot gets a goal built as THE TARGET'S OWN STANCE plus the
161
- donor's departure from ITS stance, scaled by that leg's length ratio, on all three axes — height
162
- above each rig's own stance level, and the horizontal offset each rig plants its foot at under its
163
- own hip — and a FABRIK pass from the FK pose bends the chain onto it. Both builds are therefore
164
- preserved: a target that tucks its feet inboard of its hips keeps doing so against a donor that
165
- stands on vertical legs. (Copying the donor's absolute foot-under-hip offset instead planted this
166
- repo's fox — which tucks each foot ~8% of body height inboard, while every polyperfect animal
167
- stands vertical — bow-legged in every clip.) When the legs are scaled copies the goal IS the FK
168
- pose, so IK only ever spends the proportion residual; goals out of reach are reported per leg.
169
- - **Root motion** is scaled by the mean leg-length ratio (a stride is leg length, not body height),
170
- so stance feet that were still on the donor stay still on the target.
171
- - **Chain lengths.** A longer donor chain collapses for free (the target's `chest` mapped to
172
- `Chest_M` picks up Spine1+Spine2, because a world rotation already contains everything above it).
173
- A longer target chain — unmapped bones between two mapped ones — interpolates the delta along the
174
- chain (a 6-bone spine against a 2-bone one bends everywhere, not at one joint).
175
- - **Spaces.** The anchor is the BIND pose (inverse of the skin's inverse-bind matrices), never the TRS
176
- on the nodes — the wolf's node pose is 0.37 m from its bind. A skin whose bind space is scaled
177
- against node space (Maya "actor" exports, ×100) is rescaled; a donor facing the other way is turned
178
- (facing is read from the skeleton shape, so `joint1…` rigs work too); non-uniform scales are refused.
179
-
180
- - **Second pass on proportions** (humanoid plan phase 3, 2026-08-27; the quadruped path shares the
181
- planting and the root-motion switch):
182
- - **A humanoid foot = contact + orientation** (2026-08-27). The leg's IK effector is the ankle,
183
- but its goal comes from the BALL of the foot (the canonical `Toes` bone): the ball goes where
184
- the donor's ball goes (leg and foot each scaled by their own ratio, stance-corrected), the foot
185
- keeps the donor's rotation on the target's own flat bind, and the ankle follows from the two.
186
- Rigs put the ankle at different places over the ball — a boot's foot bone dives 56° to the ball,
187
- X-bot's 27°, both soles flat: driving the ball with the foot as an IK link let the solver pick
188
- the foot's pitch (50° off the donor in the swing), transferring the ankle's position AND the
189
- foot's rotation lifted the boot 10 cm off the ground at toe-off. Now the stance foot is flat,
190
- toe-off has the heel up and the toes on the ground, and the report says `contact <ball>` when
191
- it differs from the effector. What the two feet's shapes cost at full extension (a few cm) is
192
- silent; `reach` reports what the LEG cannot do.
193
- - **Foot planting** (default; `--no-plant`). While a donor foot stands still on its ground (under
194
- 15 % of a leg length per second, within 6 % of a leg length of its stance height, ≥ 3 frames),
195
- the target's foot goal is held at ONE world point — the mean of where the FK would have put it
196
- over the span, which splits the slide between landing and lift-off; a 4-frame ease at each end
197
- hides both halves, a span across a cycle's seam is one plant held modulo the clip's travel. A
198
- span whose mean sits more than 5 % of a leg length from some frame's FK goal is a shuffle, not a
199
- plant, and is left alone. Per-leg ratios and the hips' own sway otherwise slide a planted foot a
200
- few cm per step. The planted point is the contact (the ball rolls under a rising heel; the ankle
201
- moves). Reported per leg (`planted N frames`) and per clip as spans.
202
- - **Where the travel goes** (`--root-motion auto|hips|root|none`). A rig with a root ABOVE the hips
203
- gets the donor's horizontal travel on the root and the vertical bob on the hips (`root`, the
204
- default when such a root exists — a character controller reads the root's travel and must not
205
- bob with it); `hips` puts everything on the hips as the donor had it; `none` strips the travel
206
- for an in-place clip (feet still planted in the travelling frame, so they slide back under the
207
- body like a treadmill). A donor whose hips bob relative to their own root (MoCap Central:
208
- `root` travels, `pelvis` bobs) has that bob re-applied on the target's hips.
209
- - **Hands as effectors** (`--ik hands`): the same ratio-scaled goal as a foot, measured from the
210
- upper arm, chain capped at upper + lower arm, for clips where the hands meet or hold.
211
- - **Twist helpers** (`--twist N`, default 0.5; `off` = ride rigidly). A helper at fraction `t`
212
- along its limb bone takes `f·(t·twistOut − (1 − t)·twistIn)` about the bone's axis on top of
213
- riding it — near the joint it counter-rotates the bone's own axial turn (the shoulder skin stays
214
- with the clavicle), near the far end it follows the child's (the wrist follows the hand's
215
- pronation). Helpers come from the avatar's `twist` map (shape classifier or `.avatar.json`).
216
- - The stance correction inside a foot goal (where THIS rig rests its foot under its hip, against
217
- where the donor does) is shrunk — never the donor's vector — when it would put the goal past the
218
- leg's length (a running push-off on a rig whose hips sit higher above its feet); that removed
219
- the "out of reach" residue on Humanoid_F.
220
-
221
- What it does not do yet: stride warping when a leg is far too short for the donor's reach (reported
222
- as "out of reach"), torso pitch to compensate for mismatched front/hind leg ratios; for humanoids
223
- the knee/elbow bend-plane roll of the UPPER arm and the legs —
224
- [docs/humanoid-retarget-plan.md](../../docs/humanoid-retarget-plan.md).
225
-
226
- ### Humanoids — avatars
227
-
228
- A humanoid rig is described by its **avatar**: its bones named in the Unity/VRM humanoid canon
229
- (`Hips`, `Spine`/`Chest`/`UpperChest`, `Neck`, `Head`, `Jaw`, `LeftEye`…, `LeftShoulder`/`LeftUpperArm`/
230
- `LeftLowerArm`/`LeftHand`, `LeftUpperLeg`/`LeftLowerLeg`/`LeftFoot`/`LeftToes`, `Left Thumb Proximal`…
231
- — 55 bones). An avatar belongs to ONE rig; a humanoid map is two avatars joined on the canon, so
232
- each rig is mapped once and any pair retargets.
233
-
234
- ```
235
- lecodes assets avatar girl.glb # read the avatar off the skeleton's shape
236
- lecodes assets avatar girl.glb --meta SK_Girl.fbx.meta -o girl.avatar.json # check vs Unity's map, write Unity's
237
- lecodes assets retarget girl.glb --from Swat.fbx --auto-map # both rigs' avatars from shape → map.json
238
- lecodes assets retarget girl.glb --from Swat.fbx --avatar girl.avatar.json --map auto --clips Walking
239
- ```
240
-
241
- `avatar` reads the structure off the bind pose (`src/retarget/humanoid.ts`): the hips are the joint
242
- two chains hang from that reach the ground on opposite sides; the hands are the lateral joints fanning
243
- out into short chains (fingers) — a weapon socket or IK target on the hand is shed; the spine is the
244
- centre path from the hips to where the arms meet; the head is the last branch above the arms (hair
245
- rigs branch below it, so a `head` name breaks that tie); the toes say which way is forward, and so
246
- which side is left. Spine bones: 1 → `Spine`, 2 → `+Chest`, 3 → `+UpperChest`, more → first /
247
- arc-middle / arm-attach with the rest **bridged**; a second neck bone and finger metacarpals bridge
248
- too. **Twist/roll helpers** (leaf children on their parent's axis — `upperarm_twist_01_l`,
249
- `calf_twist_02_r`) are recorded as `twist: { bone: canonical }`, never mapped. Names only break ties
250
- (thumb vs index when both are named; a `jaw` child) and the report says when they did. Everything
251
- else (hair, cloth, `ik_*`, bag, breasts) is listed as "not part of the body" and keeps its bind.
252
-
253
- **A person's word rides in the avatar** (`.avatar.json` `transfer`, written by the character studio,
254
- read by `retarget --avatar`): `align: { <canonical bone>: "keep" | "align" }` — this bone is BUILT
255
- that way (keep the bind, whatever the angle) or POSED (align to the donor, the 8° threshold
256
- notwithstanding; a standing leg too); `roll: { Left?, Right? }` — degrees added to the measured palm
257
- roll per arm, for a finger that lands almost right; and the transfer settings (`rootMotion`,
258
- `plant`, `hands`, `twist`) the CLI takes as defaults — flags still win. The report lists
259
- `under 8° kept` bones with their angle so the decision can be checked, and `avatar transfer: …`
260
- says what the file asked for.
261
-
262
- **The humanoid transfer** (`retarget` with both avatars — `--map auto`, `--avatar`, or a map.json
263
- that carries `avatars`) keeps the world-delta machinery above and decides the reference by canonical
264
- ROLE: hips, spine, neck, head, face and (standing) legs keep their bind; shoulder → upper arm →
265
- forearm → hand and every phalanx align to the donor's bind directions with explicit references (the
266
- hand to its finger bases), but only past an **8° threshold** — under it a mismatch is joint placement
267
- and stays, over it is posture (an A-pose against a T-pose, a bent elbow) and turns. Direction fixes
268
- two of three degrees of freedom; the **palm roll** — where the palm faces, the thing no direction can
269
- see — is read off the knuckle line (index base → little base) across the forearm, and the forearm
270
- chain turns until the target's line lies where the donor's does. Metacarpals ride the hand rigidly.
271
- The donor is referenced to its bind (its clips are authored on it); a target `root` above the pelvis
272
- that shares the donor's hips takes the hips' travel only. An identity retarget is exact
273
- (`tests/humanoidRetarget.test.ts`, key reduction off); Mixamo Walking on the zoo lands arm directions
274
- within 3° and palms within 10° on every frame.
275
-
276
- A Unity model importer's `.meta` carries Unity's own humanoid map: `--meta` diffs the shape result
277
- against it — the zoo in the plan agrees on every bone but the Chest/UpperChest picks on a 5-bone spine
278
- — and `-o` with `--meta` writes Unity's map as the `.avatar.json`. `retarget --avatar` / `--donor-avatar`
279
- accept either file; without them both rigs classify from shape (a biped↔quadruped pair is refused).
280
-
281
- Bind space: IBMs live in the MESH's geometry space. When an exporter parks the Z-up→Y-up turn on a
282
- geometry helper node (Female Runner's `Humanoid_F`) or ×100 on the mesh node (Maya "actor"), `rig.ts`
283
- folds that node's world transform in front of `inv(IBM)` — kept only when it brings the bind closer to
284
- the node rest pose.
285
-
286
- ### Donors whose clips are not in the FBX
287
-
288
- Asset packs routinely ship the rig as FBX and the animations as Unity `.anim` assets — the
289
- polyperfect cat and all three dogs have EMPTY FBX files and eight `.anim` clips each. `--anim` reads
290
- those directly (`src/retarget/unityAnim.ts`, YAML only — a project saved with binary serialisation
291
- has to be re-exported with *Asset Serialization = Force Text*) and attaches them to the donor rig as
292
- clips, bound by the LAST segment of each curve's transform path:
293
-
294
- ```
295
- 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
296
- ```
297
-
298
- Pass the donor's locomotion clips even when you do not want them in the output: `--donor-ref auto`
299
- takes the donor's neutral posture from a walk/idle, and without one it falls back to the bind pose.
300
-
301
- **Skeleton-only clip files** — the other pack convention (Female Runner, MoCap Central, Mixamo
302
- "without skin"): each clip is an FBX of nodes + curves and NO mesh, so it has no skin to classify
303
- and no bind to measure from. `--donor-rig <model.fbx|.glb>` names the rig it was animated on; the
304
- clips bind to it by bone name (exact, then without the `namespace:` prefix — `rebindClips` in
305
- `rig.ts`) and the model's bind, skin and avatar carry the motion:
306
-
307
- ```
308
- lecodes assets retarget vex.glb --from MCU_af_Loco_Walk_Fwd.FBX --donor-rig rig_MCUnity.fbx --map auto -o vex-mc-walk.glb
309
- ```
310
-
311
- Whether a clip file IS the character's own rig (copy the tracks by name, the Mixamo flow) or only
312
- shares its naming convention (two Unreal-style rigs — retarget) is decided by `sameSkeleton`:
313
- ≥ 80 % of the names must match AND every matched bone ≥ 5 cm must keep its local offset's
314
- DIRECTION (the child in its parent's frame — fixed by the rig, unlike the node rotations, which
315
- a clip file carries posed at its first frame). A Mixamo character against another's clip stays
316
- within ~20°; MoCap Central's rig against IdaFaber's turns the pelvis by 88° — a copied clip would
317
- lay the character down.
318
-
319
- ### What a short spine cannot borrow
320
-
321
- Rotations transfer; positions follow from the TARGET's own bones. Leg IK covers that for feet, but
322
- nothing covers the spine, so a clip whose whole point is a spine CURL does not survive a target with
323
- fewer spine bones. The cat sits by curving a 7-bone spine to hold its chest up over a dropping
324
- pelvis; this fox has two spine bones and no way to curve, so its chest is simply carried down by the
325
- pelvis and the head ends up on the floor (4% of body height, against the cat's 79%). Hanging the
326
- pelvis on a mid-spine bone instead recovers part of it (27%) but then breaks lying-down clips, whose
327
- heads go through the floor. Clips that END lying (`Sleep`) or start there (`StandUp`) transfer fine —
328
- they need no upward curl.
329
-
330
- ### Seeing the result
331
-
332
- A bone map is judged by eye. `tools/filmstrip.ts` skins a clip on the CPU and writes a PNG strip —
333
- no browser, no GPU, no engine, so it runs on every iteration:
334
-
335
- ```
336
- bun tools/filmstrip.ts fox-anim.glb strip.png 6 wolf_walk threequarter
337
- BONES=1 CELL=360 bun tools/filmstrip.ts fox-anim.glb strip.png 5 wolf_walk side # skeleton overlay
338
- BIND=1 BONES=1 bun tools/filmstrip.ts fox.glb bind.png 1 - side # the bind pose itself
339
- CELL=440 bun tools/filmstrip.ts fox-anim.glb strip.png 4 wolf_walk side "fox.glb|-|0" # rest pose first
340
- ```
341
-
342
- For the real thing on the real engine, `packages/viewer` → `node anim-check/model-check.mjs`.
343
-
344
- ## Scale
345
-
346
- `probe` and `doctor` always **report** the model's rendered world size; nothing is changed without a
347
- flag. A skinned primitive ignores its node's transform (glTF spec), so the size is measured through
348
- the skin's bind space (`world(joint₀)·IBM₀`) — measuring via the mesh node reports garbage on exactly
349
- the assets that are mis-scaled.
350
-
351
- | flag | what it does |
352
- |---|---|
353
- | `--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 |
354
- | `--scale N` | bake an explicit factor (works on clip-only files too) |
355
- | `--scale-to M` | bake whatever makes the largest dimension `M` metres |
356
-
357
- The factor is **baked into the data** — positions, node translations, IBM offsets and translation
358
- tracks all × s — not parked on a wrapper node. No node is added and no local scale is rewritten, so
359
- every node path an app addresses stays what it was. Rotation and scale tracks are untouched by
360
- construction: scaling only translations gives `world' = S·world·S⁻¹`, and the skinning identity then
361
- needs `IBM' = S·IBM·S⁻¹`, which is just the IBM's translation column × s.
362
-
363
- Quantized (`KHR_mesh_quantization`) positions are refused rather than silently corrupted.
364
-
365
- ## Shading — why a downloaded model renders too bright
366
-
367
- `doctor` always reports two material quirks that make an asset look washed out, and neither one is
368
- the usual suspect (metallic, or the texture):
369
-
370
- - **self-lit** — the base colour texture is *also* wired into `emissiveTexture` with
371
- `emissiveFactor = [1,1,1]`, so the shader draws `lit(albedo) + albedo`: roughly twice as bright as
372
- intended, with the shading flattened out. Common from Sketchfab downloads and several FBX
373
- exporters. `--fix-emissive` drops it and leaves the material otherwise PBR.
374
- - **specular boost** — `KHR_materials_specular` with a `specularColorFactor` above 1.
375
-
376
- `--unlit` goes further and converts the material to **`KHR_materials_unlit`**: flat
377
- `baseColorFactor × baseColorTexture`, no lights, no IBL, no exposure — the shading model hand-painted
378
- stylised assets are usually authored for. It also clears the emissive and drops specular/ior/normal/
379
- occlusion/MR inputs, because gltfio *refuses* to combine specular with unlit ("Cannot use specular
380
- extension with unlit") and silently falls back to lit if it is left in place.
381
-
382
- Both engines implement it: Filament through gltfio's `unlit_opaque` ubershader, web-lite through the
383
- `UNLIT` branch of its glTF shader (`viewer-lite/src/gl/glb.ts`, covered by `bun run render:check`).
384
-
385
- ## What `convert` does
386
-
387
- - **Reads FBX with [ufbx](https://github.com/ufbx/ufbx)** compiled to wasm (`native/fbx_reader.c` +
388
- `vendor/ufbx`, built by `build-wasm.sh` into `src/wasm/fbxReader.js` — a single-file module, no
389
- native builds, runs in Bun/Node/browsers). Everything is converted to glTF conventions in C:
390
- meters, right-handed Y-up (`space_conversion = MODIFY_GEOMETRY`), geometry transforms / inherit
391
- modes as helper nodes, triangulated + welded primitives, top-4 skin weights renormalized, UV v
392
- flipped, animations **baked** (30 fps, key-reduced, trimmed to start at 0). One JSON description +
393
- one binary arena cross the wasm boundary; `src/fbx/toDocument.ts` builds the Document.
394
- - **Textures** embedded: PNG/JPEG pass through, TGA/BMP decoded here (→ PNG), everything else via
395
- `sharp` when installed; duplicates de-duplicated by content hash; external references resolved
396
- next to the FBX / `textures/`.
397
- - **Materials**: ufbx's PBR view of FBX Phong/Lambert → metallic-roughness (opacity map → MASK,
398
- opacity factor < 1 → BLEND, double-sided honored). Separate metallic/roughness maps are not
399
- packed (factors used).
400
- - **`--clips`**: each clip file's animations are re-targeted onto the model **by bone name** (exact,
401
- then the part after the last `:`/`|` — the same rule the SDK `Animator` uses); clip meshes/skins
402
- dropped; unbound tracks counted + warned. Naming: a single-clip file → its stack name unless generic
403
- (`mixamo.com`, `Take 001`, `Unreal Take`) → the file's basename; `--clip-names` overrides.
404
- **Parent-space correction**: a track is a LOCAL transform, and two exports of one rig need not
405
- agree on what sits above a bone — Unreal/Unity clip exports put `root` straight under the scene
406
- (rest −90°X, the Z-up→Y-up turn) while the skinned-mesh export parks that turn on an armature node
407
- and leaves `root` at identity; copied verbatim the turn applies twice and the character plays lying
408
- on the floor (KINEMATION's Tactical Shooter Pack, every UE5 Mannequin export). For each bound bone
409
- the converter compares the world transform of its parent in both files — with the clip's first
410
- frame written onto every bone it animates, so only the unanimated STRUCTURE above differs, never
411
- the hair-width rest-pose drift between re-exports — and re-expresses the track in the model's
412
- parent space (`local_model = inv(Pmodel)·Pclip·local_clip`). Reported as an ℹ line per clip file.
413
- - Output goes through the same validation as the doctor (`validateGlb`) and a `probe` summary.
414
-
415
- Skipped in v1: cameras/lights, morph targets, NURBS.
416
-
417
- ## Verified with
418
-
419
- `packages/viewer` → `node anim-check/model-check.mjs anim-check/fixtures/<file>.glb --clip N`: loads the
420
- GLB on the Filament wasm engine, binds every clip through the native Animator, screenshots the rest
421
- pose and a playing clip (the mesh must draw and deform). `bun test` here covers the reader/converter
422
- against the Mixamo sample set when `LECODES_FBX_SAMPLES` (or the default sample folder) exists.
423
-
424
- Retargeting was tuned on the polyperfect *Low Poly Animated Animals* zoo (different authoring
425
- conventions within one pack: AdvancedSkeleton `Root_M/Hip_L`, Maya `actor:skin_*` with ×100 bind
426
- space and −Z facing, unnamed `joint1…joint42`): wolf → Meshy fox, lion → cat, wolf → horse / golden
427
- retriever, deer → giraffe, lion → elephant, all through `--map auto`, judged on `BONES=1` filmstrips
428
- next to the donor's own walk. `tests/retarget.test.ts` pins the invariants on toy rigs (exact
429
- direction transfer, keep/direction policies, bridges, leg detection + IK goals, donor reference).
430
-
431
- ## Layout
432
-
433
- ```
434
- native/fbx_reader.c ufbx → JSON + arena (compiled to wasm)
435
- vendor/ufbx/ ufbx.h / ufbx.c (MIT)
436
- src/wasm/fbxReader.js generated single-file Emscripten module (tracked; rebuild with build-wasm.sh)
437
- src/fbx/ read.ts (wasm boundary + IR) · toDocument.ts · convert.ts (merge, write)
438
- src/textures/ resolveImage.ts (sniff, TGA/BMP decode, PNG encode, external lookup)
439
- src/doctor/ doctor.ts (analyze/apply/report) · reduceGlbBones · transcodeTextures · transcodeKtx2 · normalizeScale · lightmapUv (xatlas wasm) · validateGlb (mirror of viewer's)
440
- src/retarget/ rig.ts (hierarchy + bind pose + clip sampling) · retarget.ts (reference poses, world-delta transfer, leg IK, bridges) · automap.ts (quadruped classification + chain pairing) · humanoid.ts (humanoid canon, biped classifier, Unity .meta avatars, avatar↔avatar map) · unityAnim.ts (Unity .anim YAML reader)
441
- tools/humanoid-survey.ts bind-pose survey of the humanoid zoo (arm drop / elbow / facing / proportions per rig)
442
- tools/humanoid-phase3.ts every zoo clip onto every zoo target: planted frames, reach, twist, root motion (--strips for filmstrips)
443
- src/retarget/ghost.ts a rig's skeleton alone with one clip — the character studio's donor "ghost"
444
- src/unity/ unpack.ts (.unitypackage streaming) · materials.ts (.mat → glTF materials, ORM packing) · png.ts · scene.ts (.unity → level JSON)
445
- src/props.ts --list-nodes / --keep / --split (subtree carving + re-origin)
446
- src/mergeSkins.ts --merge-skins (skinned meshes onto one skeleton) · duplicateNodeNames
447
- src/filmstrip.ts CPU-skinned PNG filmstrip renderer (probe --filmstrip; tools/filmstrip.ts wraps it)
448
- src/probe.ts · src/cli.ts · src/index.ts
449
- ```
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
+ `lecodes assets` lists the commands, `lecodes assets <command> --help` prints one command's flags
8
+ (`lecodes assets unreal tex --help` for a subcommand). The help is generated from the command
9
+ declarations, so it is always the full and current list — this README explains the workflows.
10
+
11
+ Install it the same way you installed the CLI — the peer is loaded by a bare `import` that resolves
12
+ upward from the CLI's own file, so a global `lecodes` only sees a global peer:
13
+
14
+ ```sh
15
+ npm install -g lecodes-assets
16
+ ```
17
+
18
+ KTX2 (`--ktx2`) additionally needs [KTX-Software](https://github.com/KhronosGroup/KTX-Software/releases):
19
+ its `toktx`, or — where there is none, e.g. KTX-Software 5 / the macOS installer — its `ktx` tool
20
+ (`ktx create`, ≥ 4.4). Looked up at `KTX_TOKTX` / `KTX_TOOL`, the installer's folder
21
+ (`%LOCALAPPDATA%\Programs\KTX-Software\bin`, `/usr/local/bin`, `/opt/homebrew/bin`), then PATH.
22
+ `sharp` is an optional dependency used to normalize exotic texture formats.
23
+
24
+ ```
25
+ lecodes assets convert "Erika Archer.fbx" --clips Idle.fbx Walking.fbx "Fast Run.fbx" --drop-model-clips -o hero.glb
26
+ lecodes assets convert hero.glb --clips Jump.fbx # add clips to an existing GLB
27
+ lecodes assets convert hero.fbx --fix # + doctor passes (bone cap, texture normalize)
28
+ lecodes assets convert hero.fbx --ktx2 # + textures → KTX2 (needs KTX-Software)
29
+ lecodes assets probe hero.fbx | hero.glb # skeleton / clips / meshes / textures / bind sanity / world size
30
+ lecodes assets doctor hero.glb [--fix] [--ktx2] [--dump] # the old tools/glb-reduce
31
+ lecodes assets doctor hero.glb --fix-scale # bake a unit fix (see Scale below)
32
+ lecodes assets unwrap props/ # TEXCOORD_1 lightmap UVs (xatlas), in place (see Lightmap UVs below)
33
+ lecodes assets doctor hero.glb --merge [--atlas-size 4096] # ONE draw call: parts → one primitive, maps → atlases (see Merge below)
34
+ lecodes assets retarget fox.glb --from wolf.fbx --map auto # clips from a DIFFERENT skeleton (map from skeleton shape)
35
+ lecodes assets avatar girl.glb --meta SK_Girl.fbx.meta # a humanoid's bones in the Unity/VRM canon (vs Unity's own map)
36
+ lecodes assets unpack pack.unitypackage -o out [--only re …] # stream a .unitypackage: unity-index.txt + assets (+ .meta)
37
+ lecodes assets convert Barrel_v1.FBX --list-nodes # multi-variant prop file: what is in it
38
+ lecodes assets convert Barrel_v1.FBX --keep Barrel_v1_LD1 -o barrel.glb # one variant, re-origined on the ground
39
+ lecodes assets convert Barrel_v1.FBX --split props/ # every top-level node → its own GLB
40
+ lecodes assets convert Body.FBX --merge-skins Shirt.FBX Gloves.FBX --clips … -o hero.glb # modular character → ONE skeleton
41
+ lecodes assets probe hero.glb --filmstrip # a CPU-skinned PNG strip per clip (no GPU)
42
+ lecodes assets scene Map_v8.unity -o level.json # Unity scene → kinds + placements as JSON
43
+ lecodes assets scene Outdoor.unity --project . --name school # …or built INTO a LeCodes project (GLBs + scene files)
44
+ lecodes assets unreal ls "Stairs Set/StairsSet" # what an UNREAL pack holds (see below)
45
+ lecodes assets unreal tex T_Manny_01_D.uasset -o manny_d.png # a texture's SOURCE image (needs Oodle, see below)
46
+ lecodes assets unreal wav EngineSfx/Sound -o assets/engine --ogg # every SoundWave's imported WAV → mono OGG (Oodle + ffmpeg)
47
+ lecodes assets convert SKM_Manny.uasset --clips Anims/**/*.uasset -o manny.glb # Unreal mesh + clips → GLB
48
+ ```
49
+
50
+ ## Props, modular characters, scenes — the Unity-pack workflow
51
+
52
+ **Multi-variant prop files.** Packs put every LOD, colour set and arrangement of a prop in one FBX
53
+ (`Barrel_v1.FBX` = 15 meshes). `--list-nodes [depth]` prints the scene's nodes with mesh/triangle
54
+ counts and world sizes; `--keep <node> …` keeps only those subtrees (ancestors stay as the path,
55
+ descendants come along) and **re-origins** the result — footprint centre at X/Z = 0, lowest vertex at
56
+ Y = 0 — so `Model.load` places the prop exactly where the node is (`--no-ground` keeps the file's
57
+ own placement); `--split [dir]` writes one re-origined GLB per top-level node. Both run before the
58
+ doctor passes, so `--keep X --ktx2 --max-size 1024` is the whole prop pipeline.
59
+
60
+ **Modular characters.** Body, shirt, gloves and boots each arrive as their own skinned FBX over the
61
+ same skeleton. `--merge-skins b.fbx c.glb …` moves the donors' skinned meshes onto the model's skin
62
+ (joints matched by name — exact, then the part after the last `:`/`|`, the Animator's own rule) and
63
+ drops their skeleton copies. That last part matters: a hand merge that leaves the donor skeleton in
64
+ gives every bone name a plain-node twin, and a name-bound `Animator` drives the twin while the mesh
65
+ freezes in its rest pose. `probe` and `doctor` now flag duplicate node names, and the doctor marks
66
+ the joint-shadowing case as BLOCKING. Donor bones the base lacks are reported with the skin weight
67
+ that sat on them (it moves to the root).
68
+
69
+ **Checking a clip without a GPU.** `probe` prints, per clip and skeleton root, how far the root
70
+ travels (root motion) and how far its first frame is from the rest pose — a root that starts ~90°
71
+ off is the double-axis-turn symptom of a clip from another export. `probe --filmstrip [out.png]`
72
+ renders a software-skinned PNG strip of every clip (`--clip` for one, `--frames N`, `--view
73
+ side|front|top|threequarter|lowfront`, `--bones` for the skeleton overlay, `--cell` px per frame);
74
+ `tools/filmstrip.ts` is the same renderer with reference frames from other files.
75
+
76
+ **Unity scenes.** `scene Map.unity` reads a text-serialised scene and expands it the way the Unity
77
+ editor shows it: nested `PrefabInstance`s cloned from their source prefabs (a model file is a prefab
78
+ too), their `m_Modifications` applied, stripped stubs resolved. Every placed prefab becomes a **kind**
79
+ — its mesh parts with the material GUIDs per submesh, LOD levels, colliders, lights — keyed by
80
+ CONTENT, so two instances of one prefab share a kind unless the scene overrode something inside one
81
+ of them (that one becomes `<Kind>_v2`). Kinds stop at nested prefab roots: a wall built from rock
82
+ prefabs is placements of the rocks. The terrain is read from its binary `TerrainData` (heights, splat
83
+ layers merged down to four by coverage; an HDRP layer's smoothness — the mask map's alpha × `m_Smoothness` —
84
+ becomes the roughness map in the albedo's alpha, `1 − smoothness`, which is where terrain.mat reads it),
85
+ its painted trees and detail meshes become placements too. A VEGETATION kind (the `--foliage` list, or the
86
+ importer's folder / name rule) keeps its vertex colours as DATA and gets its sky visibility baked into them at
87
+ import — `COLOR_0 = (ao, 1, 1, wind weight)`, what foliage.mat reads (`src/doctor/vertexAo.ts`); a material that
88
+ draws a DETAIL layer through another UV set (NatureManufacture's bark: `_DETALUSEUV3_ON` under a white
89
+ `_DetailMask`) takes that layer's textures on TEXCOORD_0 = that set × its tiling. Output without `--project` is
90
+ plain JSON: `{ kinds, instances: [ { kind, name, group, position, rotation, scale } ], lights,
91
+ cameras, terrain, stats }`.
92
+
93
+ `--project <dir> [--name map]` builds it INTO a LeCodes project instead: `assets/<map>/<Kind>.glb`
94
+ (meshes pulled from the FBX files, materials written from the `.mat` files the renderers name,
95
+ textures capped by `--max-size` and KTX2 unless `--png`), `src/<map>/<Kind>.scene.ts` (the model +
96
+ its colliders as `Shape` / `Physics` aspects), `assets/<map>/terrain/…` and `src/<map>.scene.ts`
97
+ placing everything, grouped like the Unity scene. GLBs are rebuilt only when their kind changed
98
+ (`assets/<map>/kinds.json`) or with `--force`. Everything is regenerated by the next import — a game
99
+ level instances `src/<map>.scene.ts` as a prefab and adds its gameplay there. **The command line is
100
+ written into that file's header: re-import with the same line**, because the flags carry decisions
101
+ the file cannot show — `--collider-fit <m>` (how far a pack's BOX collider face may stand off the
102
+ art before the kind takes a mesh collider instead, default 1.5 cm: the shooting range's wall module is
103
+ flat at ±0.100 m while its box was fitted to the ±0.138 m of its trim, so bullets stopped 3.8 cm in
104
+ front of the wall and their holes and sparks landed in mid-air), `--drop <re>` (props a pack sinks into the geometry they decorate), `--drop-at
105
+ "x,y,z;…"` (the placements standing at these points, in the frame the scene file prints: the one instance
106
+ of a kind the game moves while its namesakes stay — the range's five lane targets),
107
+ `--no-lightmap <re>` (lamp fixtures and anything the game moves take `lightmap: false`), `--foliage
108
+ <re>` / `--no-foliage` (which kinds wear the vegetation tier; the default verdict — a material in a
109
+ Vegetation/Plants/Grass/Trees folder or named so — is printed), `--no-sun`, `--no-trim-cards`,
110
+ `--max-tris <n>` (a kind over this many triangles is simplified at import, default 50 000, 0 = no cap:
111
+ a photogrammetry scan otherwise arrives as a million-triangle prop AND mesh collider — a faceted scan
112
+ loses its per-face normals to weld and gets smooth ones back; its detail is in the normal map).
113
+ Shader Graph materials whose textures go through a **Triplanar** node (URP walls and floors) get the
114
+ projection baked into their UVs — the node's own planes and Tile — so they stay stock PBR and can
115
+ take part in a lightmap bake.
116
+ Subsets: `--filter <re>` (kind / group / node name), `--group <name>`, `--region x0,z0,x1,z1` (Unity
117
+ metres), `--no-trees`, `--no-details`, `--include-inactive`.
118
+
119
+ Frame: Unity is left-handed and its FBX importer mirrors X, so everything read from Unity is
120
+ conjugated by that mirror — position `(−x, y, z)`, quaternion `(x, −y, −z, w)` — while mesh geometry
121
+ comes from the FBX itself, which already is right-handed (`--handedness none` keeps Unity's numbers).
122
+ Two importer rules matter for models whose FBX has a single top node that IS the mesh (the prefab's
123
+ root in Unity): its position and rotation are never the model's — every `PrefabInstance` overrides
124
+ them — and what does survive is the model importer's `bakeAxisConversion`, which bakes `RotY(180°) ·
125
+ R_node` into the mesh (a Blender export's Z-up→Y-up correction lives there; without it the doors of a
126
+ kit stand beside their doorways).
127
+
128
+ A re-import renames nodes, so a **baked lightmap is invalid after it** — re-bake the level. The
129
+ imported GLBs carry no lightmap UVs; `lecodes lightmap bake` unwraps them itself (below).
130
+
131
+ ## Lightmap UVs — `unwrap`
132
+
133
+ ```
134
+ lecodes assets unwrap assets/props # every GLB in the folder, in place
135
+ lecodes assets unwrap "assets/school/*.glb" --force # again, over the sets they have
136
+ lecodes assets unwrap assets/arena/arena.glb --groups 12 --zone "-30,0,-20,30,3,20" --zone-max 16
137
+ ```
138
+
139
+ The lightmap bake traces a second UV set, `TEXCOORD_1`: every triangle of the file in ONE
140
+ non-overlapping unit square, cut into charts by xatlas (`src/unwrap/lightmapUv.ts`, the wasm in
141
+ `src/wasm/lightmapUv.js`). `unwrap` writes it **into the file itself** — scenes reference the
142
+ original, so there is no `.fixed.glb` to copy over it — through a temp file renamed over the target
143
+ (`-o` writes elsewhere). A file whose primitives all carry `TEXCOORD_1` is skipped (`--force` unwraps
144
+ it again); skinned meshes are left as they are; `<name>_LOD<n>` siblings take level 0's layout by
145
+ projection. Seam vertices are split (the report says how many); every other attribute, the
146
+ materials and the textures stay as they were.
147
+
148
+ `lecodes lightmap bake` runs the same code on every GLB the app references that has no
149
+ `TEXCOORD_1`, so a raw import needs no step of its own. By hand it is for what the bake cannot
150
+ decide: a whole level in one GLB wants `--groups <n>` (n unit squares = n atlas rects: one rect
151
+ cannot span pages) and `--zone` (what is far from where the player walks is cut and baked at a
152
+ coarser texel). The bake re-packs every file's charts at its own density (engines/bake, the
153
+ `<stem>-uv1.bin` sidecar), so only the CUTTING matters here: the layout padding is fixed, and
154
+ `--texel` (the bake's, 0.05) only sizes it for the files whose square the bake keeps as it is.
155
+
156
+ ## Unity packs — `unpack` + materials from `.mat`
157
+
158
+ ```
159
+ lecodes assets unpack "Tactical FPS Animations.unitypackage" -o D:/Unity/tactical # everything
160
+ lecodes assets unpack pack.unitypackage -o out --only 'Meshes/' 'Animations/' 'Materials/' # regex subset
161
+ lecodes assets unpack pack.unitypackage -o out --list --only '\.wav$' # just list
162
+ lecodes assets convert out/Assets/Pack/Weapons/SKM_TR-15.FBX --clips … --ktx2 -o tr15.glb # .mat-aware
163
+ ```
164
+
165
+ A `.unitypackage` is a gzip'd tar of one folder per asset GUID. `unpack` streams it twice (flat
166
+ memory on multi-GB packs): pass 1 writes `<out>/unity-index.txt` (GUID → path), pass 2 writes the
167
+ matching assets + their `.meta` sidecars under `<out>/Assets/…`.
168
+
169
+ `convert` then reads the **Unity `.mat` files** found next to (or one folder above) the FBX and
170
+ rewrites the glTF materials from them — an FBX exported for Unity usually references only a legacy
171
+ base colour and a normal map, while metallic / roughness / occlusion / emission and the alpha mode
172
+ live in the material. Matching is by name modulo `M_`/`MI_`/`MAT_` prefixes, then by containment
173
+ (`MI_Tshirt` ↔ `M_Shirt`), then by a shared base texture (`MI_Skin` ↔ `M_Body` through
174
+ `TX_Body_BaseColor`). Texture GUIDs resolve through `unity-index.txt` (found walking up from the
175
+ FBX, or `--unity-index`), else through `.meta` sidecars of a real Unity project. Slots understood:
176
+ `_BaseMap/_MainTex/_BaseColor/_BC`, `_BumpMap/_Normal`, a ready `_ORM`, HDRP/URP `_MaskMap`
177
+ (R metal, G AO, A smooth), Standard `_MetallicGlossMap` (R metal, A smooth), separate `_Metallic`,
178
+ `_Roughness` / `_Smoothness`, `_OcclusionMap`, `_EmissionMap` (+ `_EMISSION` keyword / colour),
179
+ `_Surface`/`_Mode`/`_AlphaClip`/`_Cutoff`/`_Cull`. Everything that is not already one texture is
180
+ **packed the glTF way** (R = AO, G = roughness, B = metallic; PNG/TGA/BMP decoded in-process, other
181
+ formats through `sharp`) — one metallic-roughness(+occlusion) texture per material. `--no-unity`
182
+ keeps the FBX references; `--unity-mats <dir>` looks elsewhere. Only YAML-serialised `.mat` files are
183
+ readable (Unity "Force Text", the asset-store default since 2017); binary ones are listed and skipped.
184
+
185
+ **Emission and baked light.** A material that glows keeps Unity's answer to "does the glow light the
186
+ level": its inspector's *Global Illumination* setting, `m_LightmapFlags` in the `.mat` (bit 2 =
187
+ Baked, bit 1 = Realtime — which a project without realtime GI bakes too, neither = None). None
188
+ becomes the glTF material's extras `{"lecodes":{"emissiveBake":false}}` (merged with the foliage
189
+ `wind` profile when both are there) and the slot summary says `emissiveBake=false (Unity GI: None)`:
190
+ the surface still glows, but `lecodes lightmap bake` makes no lamp of it. That is how packs lit by
191
+ their own lamps arrive — the shooting range's `M_Lamp_long` glows (`_EmissiveColor` 4) with flags 4
192
+ (HDRP leaves EmissiveIsBlack on materials that do glow), and its rooms are lit by area lights. A
193
+ `.mat` without `m_LightmapFlags` keeps the default: it bakes. A level that wants none of its panels
194
+ in the bake says so once, `bake('…', { emissiveBake: false })` (the SDK's lightmap docs, "Emission:
195
+ three forms, one switch").
196
+
197
+ ## Retarget — clips from a different skeleton
198
+
199
+ `convert --clips` binds tracks by BONE NAME and copies them verbatim; that only works when both
200
+ files came from the same rig (the Mixamo case). `retarget` drives one rig with a completely
201
+ different rig's clips — different bone names, different chain lengths, different proportions,
202
+ different local axis conventions, even different facing:
203
+
204
+ ```
205
+ lecodes assets retarget fox.glb --from Wolf_Animation.fbx --map auto --clips wolf_walk wolf_run -o fox-anim.glb
206
+ lecodes assets retarget giraffe.glb --from Deer_Animation.fbx --auto-map # writes giraffe-from-Deer_Animation.map.json to edit
207
+ lecodes assets retarget giraffe.glb --from Deer_Animation.fbx --map giraffe-from-Deer_Animation.map.json -o giraffe-anim.glb
208
+ lecodes assets retarget fox-anim.glb --from Wolf_Howl.fbx --map … --keep-clips --clip-names howl
209
+ lecodes assets retarget fox.glb --from Wolf_Animation.fbx --skeletons # both bone lists, to write a map by hand
210
+ lecodes assets retarget fox.glb --from Cat.fbx --map auto --anim Cat_Sleep.anim # donor clips that live outside the FBX
211
+ ```
212
+
213
+ **The map** is target-bone → donor-bone; `root` names the bone that receives root motion. `--map auto`
214
+ / `--auto-map` derive it from the two skeletons' SHAPE — root, spine, neck, head, jaw, ears, tail and
215
+ the four legs (side + front/back) are read off the bind pose, then paired chain by chain with bones
216
+ matched by arc length (names only break ties when they agree: `backAnkle`↔`Ankle_L`). It works on
217
+ `joint1…joint42` rigs and prints exactly what it paired — read it before trusting it. Optional keys:
218
+
219
+ ```json
220
+ { "root": "Hips",
221
+ "bones": { "Hips": "Root_M", "chest": "Chest_M", "backleg": "Hip_L", "backleg0": "Knee_L", "…": "…" },
222
+ "align": { "chest": "keep", "tail": "direction", "head": [ "headend" ] }, // per-bone reference policy (see below)
223
+ "legs": [ "backleg2", "R_backleg2", "frontleg2", "R_frontleg2" ], // feet, when auto-detection picks wrong
224
+ "donorRef": "auto" | "bind" | { "clip": "wolf_idle", "time": 0 } }
225
+ ```
226
+
227
+ **How it works.** Everything happens in WORLD space, where a rotation means the same thing in both
228
+ rigs: `ΔR(t) = Rdonor(t)·Rdonor_ref⁻¹` is applied to the target's own reference orientation and
229
+ converted back to local against the already-retargeted parent. That transfer is exact — the thing
230
+ that decides quality is what the two REFERENCE poses are and what happens where proportions differ:
231
+
232
+ - **Donor reference** (`donorRef`, default `auto`). An exporter's bind is often a posture the clips
233
+ never visit — the polyperfect wolf binds upright on vertical thighs and walks 10% lower with its
234
+ thighs 30° forward; measured from that bind every clip reads as "crouch and lean". When the two
235
+ binds put the legs differently (>12°: different conventions), the donor is referenced to the MEAN
236
+ pose of its walk (else idle, else the longest clip) — its actual neutral posture. When the binds
237
+ agree, bind-to-bind is the faithful transfer (an identity retarget reproduces the donor exactly).
238
+ `--donor-ref bind|auto|<clip>` overrides.
239
+ - **Target reference**, per bone: `keep` its bind orientation, or `direction` — turn it so the bone
240
+ points the way the donor's does (an auto-T-pose step, measured to the nearest mapped descendant).
241
+ A target that binds in a STANCE (feet level, legs hanging) keeps everything: that is its natural
242
+ posture and the donor's deltas ride on it — the horse stays tall under wolf clips, the giraffe's
243
+ neck stays up under deer clips. A target that binds posed (T-pose, folded legs) keeps its core
244
+ (root, pelvis, chest, head — artists put those joints anywhere, so their joint lines are not
245
+ comparable) and direction-aligns its chains. `align` overrides per bone.
246
+ - **Feet** (`--ik legs`, the default). Directions transfer, positions do not: with different
247
+ front/back leg ratios the feet float or sink, which the eye catches first. Legs are detected from
248
+ the map (lateral chains reaching the ground; a foot is what stands on the donor's ground, a horse's
249
+ wrist-ended front leg is not), the reference pose is planted (root lowered/raised, every foot bent
250
+ onto the ground), then every frame each foot gets a goal built as THE TARGET'S OWN STANCE plus the
251
+ donor's departure from ITS stance, scaled by that leg's length ratio, on all three axes — height
252
+ above each rig's own stance level, and the horizontal offset each rig plants its foot at under its
253
+ own hip — and a FABRIK pass from the FK pose bends the chain onto it. Both builds are therefore
254
+ preserved: a target that tucks its feet inboard of its hips keeps doing so against a donor that
255
+ stands on vertical legs. (Copying the donor's absolute foot-under-hip offset instead planted this
256
+ repo's fox — which tucks each foot ~8% of body height inboard, while every polyperfect animal
257
+ stands vertical — bow-legged in every clip.) When the legs are scaled copies the goal IS the FK
258
+ pose, so IK only ever spends the proportion residual; goals out of reach are reported per leg.
259
+ - **Root motion** is scaled by the mean leg-length ratio (a stride is leg length, not body height),
260
+ so stance feet that were still on the donor stay still on the target.
261
+ - **Chain lengths.** A longer donor chain collapses for free (the target's `chest` mapped to
262
+ `Chest_M` picks up Spine1+Spine2, because a world rotation already contains everything above it).
263
+ A longer target chain — unmapped bones between two mapped ones — interpolates the delta along the
264
+ chain (a 6-bone spine against a 2-bone one bends everywhere, not at one joint).
265
+ - **Spaces.** The anchor is the BIND pose (inverse of the skin's inverse-bind matrices), never the TRS
266
+ on the nodes — the wolf's node pose is 0.37 m from its bind. A skin whose bind space is scaled
267
+ against node space (Maya "actor" exports, ×100) is rescaled; a donor facing the other way is turned
268
+ (facing is read from the skeleton shape, so `joint1…` rigs work too); non-uniform scales are refused.
269
+
270
+ - **Second pass on proportions** (humanoid plan phase 3, 2026-08-27; the quadruped path shares the
271
+ planting and the root-motion switch):
272
+ - **A humanoid foot = contact + orientation** (2026-08-27). The leg's IK effector is the ankle,
273
+ but its goal comes from the BALL of the foot (the canonical `Toes` bone): the ball goes where
274
+ the donor's ball goes (leg and foot each scaled by their own ratio, stance-corrected), the foot
275
+ keeps the donor's rotation on the target's own flat bind, and the ankle follows from the two.
276
+ Rigs put the ankle at different places over the ball — a boot's foot bone dives 56° to the ball,
277
+ X-bot's 27°, both soles flat: driving the ball with the foot as an IK link let the solver pick
278
+ the foot's pitch (50° off the donor in the swing), transferring the ankle's position AND the
279
+ foot's rotation lifted the boot 10 cm off the ground at toe-off. Now the stance foot is flat,
280
+ toe-off has the heel up and the toes on the ground, and the report says `contact <ball>` when
281
+ it differs from the effector. What the two feet's shapes cost at full extension (a few cm) is
282
+ silent; `reach` reports what the LEG cannot do.
283
+ - **Foot planting** (default; `--no-plant`). While a donor foot stands still on its ground (under
284
+ 15 % of a leg length per second, within 6 % of a leg length of its stance height, ≥ 3 frames),
285
+ the target's foot goal is held at ONE world point — the mean of where the FK would have put it
286
+ over the span, which splits the slide between landing and lift-off; a 4-frame ease at each end
287
+ hides both halves, a span across a cycle's seam is one plant held modulo the clip's travel. A
288
+ span whose mean sits more than 5 % of a leg length from some frame's FK goal is a shuffle, not a
289
+ plant, and is left alone. Per-leg ratios and the hips' own sway otherwise slide a planted foot a
290
+ few cm per step. The planted point is the contact (the ball rolls under a rising heel; the ankle
291
+ moves). Reported per leg (`planted N frames`) and per clip as spans.
292
+ - **Where the travel goes** (`--root-motion auto|hips|root|none`). A rig with a root ABOVE the hips
293
+ gets the donor's horizontal travel on the root and the vertical bob on the hips (`root`, the
294
+ default when such a root exists — a character controller reads the root's travel and must not
295
+ bob with it); `hips` puts everything on the hips as the donor had it; `none` strips the travel
296
+ for an in-place clip (feet still planted in the travelling frame, so they slide back under the
297
+ body like a treadmill). A donor whose hips bob relative to their own root (MoCap Central:
298
+ `root` travels, `pelvis` bobs) has that bob re-applied on the target's hips.
299
+ - **Hands as effectors** (`--ik hands`): the same ratio-scaled goal as a foot, measured from the
300
+ upper arm, chain capped at upper + lower arm, for clips where the hands meet or hold.
301
+ - **Twist helpers** (`--twist N`, default 0.5; `off` = ride rigidly). A helper at fraction `t`
302
+ along its limb bone takes `f·(t·twistOut − (1 − t)·twistIn)` about the bone's axis on top of
303
+ riding it — near the joint it counter-rotates the bone's own axial turn (the shoulder skin stays
304
+ with the clavicle), near the far end it follows the child's (the wrist follows the hand's
305
+ pronation). Helpers come from the avatar's `twist` map (shape classifier or `.avatar.json`).
306
+ - The stance correction inside a foot goal (where THIS rig rests its foot under its hip, against
307
+ where the donor does) is shrunk — never the donor's vector — when it would put the goal past the
308
+ leg's length (a running push-off on a rig whose hips sit higher above its feet); that removed
309
+ the "out of reach" residue on Humanoid_F.
310
+
311
+ What it does not do yet: stride warping when a leg is far too short for the donor's reach (reported
312
+ as "out of reach"), torso pitch to compensate for mismatched front/hind leg ratios; for humanoids
313
+ the knee/elbow bend-plane roll of the UPPER arm and the legs —
314
+ [docs/humanoid-retarget-plan.md](../../docs/humanoid-retarget-plan.md).
315
+
316
+ ### Humanoids — avatars
317
+
318
+ A humanoid rig is described by its **avatar**: its bones named in the Unity/VRM humanoid canon
319
+ (`Hips`, `Spine`/`Chest`/`UpperChest`, `Neck`, `Head`, `Jaw`, `LeftEye`…, `LeftShoulder`/`LeftUpperArm`/
320
+ `LeftLowerArm`/`LeftHand`, `LeftUpperLeg`/`LeftLowerLeg`/`LeftFoot`/`LeftToes`, `Left Thumb Proximal`…
321
+ — 55 bones). An avatar belongs to ONE rig; a humanoid map is two avatars joined on the canon, so
322
+ each rig is mapped once and any pair retargets.
323
+
324
+ ```
325
+ lecodes assets avatar girl.glb # read the avatar off the skeleton's shape
326
+ lecodes assets avatar girl.glb --meta SK_Girl.fbx.meta -o girl.avatar.json # check vs Unity's map, write Unity's
327
+ lecodes assets retarget girl.glb --from Swat.fbx --auto-map # both rigs' avatars from shape → map.json
328
+ lecodes assets retarget girl.glb --from Swat.fbx --avatar girl.avatar.json --map auto --clips Walking
329
+ ```
330
+
331
+ `avatar` reads the structure off the bind pose (`src/retarget/humanoid.ts`): the hips are the joint
332
+ two chains hang from that reach the ground on opposite sides; the hands are the lateral joints fanning
333
+ out into short chains (fingers) — a weapon socket or IK target on the hand is shed; the spine is the
334
+ centre path from the hips to where the arms meet; the head is the last branch above the arms (hair
335
+ rigs branch below it, so a `head` name breaks that tie); the toes say which way is forward, and so
336
+ which side is left. Spine bones: 1 → `Spine`, 2 → `+Chest`, 3 → `+UpperChest`, more → first /
337
+ arc-middle / arm-attach with the rest **bridged**; a second neck bone and finger metacarpals bridge
338
+ too. **Twist/roll helpers** (leaf children on their parent's axis — `upperarm_twist_01_l`,
339
+ `calf_twist_02_r`) are recorded as `twist: { bone: canonical }`, never mapped. Names only break ties
340
+ (thumb vs index when both are named; a `jaw` child) and the report says when they did. Everything
341
+ else (hair, cloth, `ik_*`, bag, breasts) is listed as "not part of the body" and keeps its bind.
342
+
343
+ **A person's word rides in the avatar** (`.avatar.json` `transfer`, written by the character studio,
344
+ read by `retarget --avatar`): `align: { <canonical bone>: "keep" | "align" }` — this bone is BUILT
345
+ that way (keep the bind, whatever the angle) or POSED (align to the donor, the 8° threshold
346
+ notwithstanding; a standing leg too); `roll: { Left?, Right? }` — degrees added to the measured palm
347
+ roll per arm, for a finger that lands almost right; and the transfer settings (`rootMotion`,
348
+ `plant`, `hands`, `twist`) the CLI takes as defaults — flags still win. The report lists
349
+ `under 8° kept` bones with their angle so the decision can be checked, and `avatar transfer: …`
350
+ says what the file asked for.
351
+
352
+ **The humanoid transfer** (`retarget` with both avatars — `--map auto`, `--avatar`, or a map.json
353
+ that carries `avatars`) keeps the world-delta machinery above and decides the reference by canonical
354
+ ROLE: hips, spine, neck, head, face and (standing) legs keep their bind; shoulder → upper arm →
355
+ forearm → hand and every phalanx align to the donor's bind directions with explicit references (the
356
+ hand to its finger bases), but only past an **8° threshold** — under it a mismatch is joint placement
357
+ and stays, over it is posture (an A-pose against a T-pose, a bent elbow) and turns. Direction fixes
358
+ two of three degrees of freedom; the **palm roll** — where the palm faces, the thing no direction can
359
+ see — is read off the knuckle line (index base → little base) across the forearm, and the forearm
360
+ chain turns until the target's line lies where the donor's does. Metacarpals ride the hand rigidly.
361
+ The donor is referenced to its bind (its clips are authored on it); a target `root` above the pelvis
362
+ that shares the donor's hips takes the hips' travel only. An identity retarget is exact
363
+ (`tests/humanoidRetarget.test.ts`, key reduction off); Mixamo Walking on the zoo lands arm directions
364
+ within 3° and palms within 10° on every frame.
365
+
366
+ A Unity model importer's `.meta` carries Unity's own humanoid map: `--meta` diffs the shape result
367
+ against it — the zoo in the plan agrees on every bone but the Chest/UpperChest picks on a 5-bone spine
368
+ — and `-o` with `--meta` writes Unity's map as the `.avatar.json`. `retarget --avatar` / `--donor-avatar`
369
+ accept either file; without them both rigs classify from shape (a biped↔quadruped pair is refused).
370
+
371
+ Bind space: IBMs live in the MESH's geometry space. When an exporter parks the Z-up→Y-up turn on a
372
+ geometry helper node (Female Runner's `Humanoid_F`) or ×100 on the mesh node (Maya "actor"), `rig.ts`
373
+ folds that node's world transform in front of `inv(IBM)` — kept only when it brings the bind closer to
374
+ the node rest pose.
375
+
376
+ ### Donors whose clips are not in the FBX
377
+
378
+ Asset packs routinely ship the rig as FBX and the animations as Unity `.anim` assets — the
379
+ polyperfect cat and all three dogs have EMPTY FBX files and eight `.anim` clips each. `--anim` reads
380
+ those directly (`src/retarget/unityAnim.ts`, YAML only — a project saved with binary serialisation
381
+ has to be re-exported with *Asset Serialization = Force Text*) and attaches them to the donor rig as
382
+ clips, bound by the LAST segment of each curve's transform path:
383
+
384
+ ```
385
+ 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
386
+ ```
387
+
388
+ Pass the donor's locomotion clips even when you do not want them in the output: `--donor-ref auto`
389
+ takes the donor's neutral posture from a walk/idle, and without one it falls back to the bind pose.
390
+
391
+ **Skeleton-only clip files** — the other pack convention (Female Runner, MoCap Central, Mixamo
392
+ "without skin"): each clip is an FBX of nodes + curves and NO mesh, so it has no skin to classify
393
+ and no bind to measure from. `--donor-rig <model.fbx|.glb>` names the rig it was animated on; the
394
+ clips bind to it by bone name (exact, then without the `namespace:` prefix — `rebindClips` in
395
+ `rig.ts`) and the model's bind, skin and avatar carry the motion:
396
+
397
+ ```
398
+ lecodes assets retarget vex.glb --from MCU_af_Loco_Walk_Fwd.FBX --donor-rig rig_MCUnity.fbx --map auto -o vex-mc-walk.glb
399
+ ```
400
+
401
+ Whether a clip file IS the character's own rig (copy the tracks by name, the Mixamo flow) or only
402
+ shares its naming convention (two Unreal-style rigs — retarget) is decided by `sameSkeleton`:
403
+ ≥ 80 % of the names must match AND every matched bone ≥ 5 cm must keep its local offset's
404
+ DIRECTION (the child in its parent's frame — fixed by the rig, unlike the node rotations, which
405
+ a clip file carries posed at its first frame). A Mixamo character against another's clip stays
406
+ within ~20°; MoCap Central's rig against IdaFaber's turns the pelvis by 88° — a copied clip would
407
+ lay the character down.
408
+
409
+ ### What a short spine cannot borrow
410
+
411
+ Rotations transfer; positions follow from the TARGET's own bones. Leg IK covers that for feet, but
412
+ nothing covers the spine, so a clip whose whole point is a spine CURL does not survive a target with
413
+ fewer spine bones. The cat sits by curving a 7-bone spine to hold its chest up over a dropping
414
+ pelvis; this fox has two spine bones and no way to curve, so its chest is simply carried down by the
415
+ pelvis and the head ends up on the floor (4% of body height, against the cat's 79%). Hanging the
416
+ pelvis on a mid-spine bone instead recovers part of it (27%) but then breaks lying-down clips, whose
417
+ heads go through the floor. Clips that END lying (`Sleep`) or start there (`StandUp`) transfer fine —
418
+ they need no upward curl.
419
+
420
+ ### Seeing the result
421
+
422
+ A bone map is judged by eye. `tools/filmstrip.ts` skins a clip on the CPU and writes a PNG strip —
423
+ no browser, no GPU, no engine, so it runs on every iteration:
424
+
425
+ ```
426
+ bun tools/filmstrip.ts fox-anim.glb strip.png 6 wolf_walk threequarter
427
+ BONES=1 CELL=360 bun tools/filmstrip.ts fox-anim.glb strip.png 5 wolf_walk side # skeleton overlay
428
+ BIND=1 BONES=1 bun tools/filmstrip.ts fox.glb bind.png 1 - side # the bind pose itself
429
+ CELL=440 bun tools/filmstrip.ts fox-anim.glb strip.png 4 wolf_walk side "fox.glb|-|0" # rest pose first
430
+ ```
431
+
432
+ For the real thing on the real engine, `runtime/web` → `node anim-check/model-check.mjs`.
433
+
434
+ ## Unreal packs — `.uasset` skeletons, clips, meshes
435
+
436
+ An Unreal asset-store pack is a Content folder of **uncooked editor packages**: one `.uasset` per
437
+ asset with everything inline. `convert`, `probe` and `retarget` read them directly — no Unreal
438
+ install, no FBX export step:
439
+
440
+ ```
441
+ lecodes assets unreal ls "Stairs Set/StairsSet" # triage: classes, rigs, what is unreadable and why
442
+ lecodes assets probe .../A_Stairs_WalkFwd_Up_Loop.uasset # engine, data model, skeleton, keys, root travel
443
+ lecodes assets convert .../SKM_Manny.uasset --clips .../Animations/Mannequin/**/*.uasset -o manny.glb
444
+ lecodes assets convert .../A_Stairs_WalkFwd_Up_Loop.uasset -o walk.glb # clip-only GLB (its skeleton + the clip)
445
+ lecodes assets convert hero.glb --clips .../A_Stairs_WalkFwd_Up_Loop.uasset # merge one onto YOUR character by bone name
446
+ lecodes assets retarget vex.glb --from .../SKM_Manny.uasset --anim .../Walk/*.uasset --map auto --root-motion hips -o vex-stairs.glb
447
+ ```
448
+
449
+ **Both data models are read.** An `AnimSequence` keeps its raw motion in one of two places, and a
450
+ pack's choice is not yours: `AnimDataModel` is bone tracks (what an FBX import writes), while a clip
451
+ authored or edited in **Sequencer** (UE 5.2+, through a Control Rig) keeps the same motion as
452
+ MovieScene channels — one FK control per bone, nine `FMovieSceneFloatChannel`s each (3 translation,
453
+ 3 rotation as an `FRotator` in degrees, 3 scale). `sequencer.ts` evaluates those onto the clip's own
454
+ frame grid, so callers never learn which model a file used. The grid lands exactly on the authored
455
+ keys (checked across a pack's 48 Sequencer clips: 0 ms offset, 0 value error), and the channels'
456
+ constant values reproduce the skeleton's reference pose to 1e-4 cm and 0.001° — which is what pins
457
+ the channel order and the `FRotator` convention.
458
+
459
+ **What is read.** `SkeletalMesh` (LOD geometry, UVs, skin weights, bone list — from the editor
460
+ import data the FBX importer left behind), `Skeleton` (bones + reference pose), `AnimSequence`
461
+ (the raw uncompressed bone tracks of its `AnimDataModel`, plus fps, length, root-motion flag,
462
+ additive type, sync markers / notifies), and `Texture2D` — the SOURCE image the artist imported,
463
+ not the compressed mips a cooked build ships (see **Textures** below).
464
+
465
+ **Where a clip's skeleton comes from.** An AnimSequence names it only as an object path
466
+ (`/Game/StairsSet/Demo/Mannequins/Meshes/SK_Mannequin`), where `/Game/` is the pack's Content
467
+ folder — which nothing on disk marks. The path is turned into a relative file name and looked for
468
+ walking up from the clip; the first directory that answers IS the Content root. `--skeleton
469
+ <SK_*.uasset>` names it outright, `--content-root <dir>` names the folder. A `SKM_*.uasset` works
470
+ as a skeleton too (its own bone list is the bind reference).
471
+
472
+ **Coordinates.** UE is centimetres, X-forward/Y-right/Z-up and left-handed; glTF is metres, Y-up
473
+ and right-handed. A `.uasset` GLB has to be interchangeable with one made from the same pack's FBX
474
+ (both feed `--clips` and the retargeter), so the conversion reproduces what UE's FBX exporter plus
475
+ ufbx do: mirror Y on every local transform, and turn the ROOT bone −90° about X. Checked against
476
+ the Stairs pack's own `SourceFiles.zip`: all 267 channels of a clip match by bone name to
477
+ **1e-5 m / 0.02°** (`tests/unreal.test.ts`), and the recipe's composition is pinned by a unit test —
478
+ the per-bone rules must add up to the global `(x, z, y)·0.01` map on world points.
479
+
480
+ **Limits** (each one says so at the point it bites, rather than producing a broken GLB):
481
+
482
+ | Not supported | What you get |
483
+ |---|---|
484
+ | UE 5.4+ / UE 5.5+ packages, UE4 packages, cooked assets | refused by version, with the engine that saved the file |
485
+ | meshes whose LODs were generated in-engine (no import data — the Stairs pack's UE4 mannequin) | refused; only the skeleton of such a mesh can be read |
486
+ | morph targets, curves, sockets, physics assets, Oodle-compressed MESH bulk | reported, not guessed at |
487
+
488
+ Twelve bones per vertex become four (top by weight, renormalised) — the trim is reported as a
489
+ percentage of redistributed weight. Triangle winding is decided from the data (the mirror reverses
490
+ handedness) rather than hidden behind a double-sided material.
491
+
492
+ **Textures.** A texture asset keeps its source image (usually the original PNG) inside an
493
+ `FCompressedBuffer`, compressed with **Oodle** — proprietary, so nothing is shipped here. The
494
+ decoder is loaded from a library the machine already has:
495
+
496
+ ```sh
497
+ export LECODES_OODLE=".../Engine/Binaries/ThirdParty/Oodle/Win64/oo2core_9_win64.dll" # any UE install
498
+ # or drop the file into ~/.lecodes/oodle/ — Unreal installs are also found automatically
499
+ lecodes assets unreal tex T_Manny_01_D.uasset -o manny_d.png # one texture, out
500
+ lecodes assets convert SKM_Manny.uasset -o manny.glb # textures wired to the materials
501
+ ```
502
+
503
+ Without it the conversion still runs and says so once; only the materials stay grey. With it, each
504
+ slot's `MaterialInstanceConstant` is read and its `TextureParameterValues` are wired to glTF:
505
+ base colour, normal, and ORM (UE packs occlusion/roughness/metallic into R/G/B, which is exactly
506
+ what glTF reads). Two fixes are applied on the way, both required and both verified against the
507
+ render: a `TSF_BGRA8` source has its red and blue channels the other way round (without the swap the
508
+ mannequin comes out blue), and UE authors normal maps DirectX-style, so the green channel is flipped
509
+ for glTF (`--no-normal-flip` keeps them as they were). Textures are capped at `--max-size` (2048)
510
+ and re-encoded — JPEG unless the alpha channel is actually used.
511
+
512
+ What is NOT wired: everything the material graph would have to be evaluated to understand. The UE5
513
+ mannequin's material names seven textures — a bent normal, an aniso/AO/paint mask, a clear-coat
514
+ mask, an MSR pack, a tangent map — and only the base colour and the normal map have an honest glTF
515
+ slot. The rest are listed in the report (`not in glTF: …`) rather than guessed into one. Scalar and
516
+ vector parameters (colour tints, roughness powers) are read but not applied for the same reason:
517
+ gear comes out in its neutral colours.
518
+
519
+ **Sounds.** A `SoundWave` keeps the WAV it was imported from in the same Oodle `FCompressedBuffer`
520
+ (cooked builds carry platform Vorbis/ADPCM instead — those are not read). `unreal wav <Sound dir> -o
521
+ <dir>` writes every one as `<name>.wav` exactly as imported; `--ogg` re-encodes through ffmpeg (PATH
522
+ or `LECODES_FFMPEG`) to mono Vorbis, a tenth of the size and what `Sound.load` decodes to anyway
523
+ (`--stereo` keeps the channels, `--quality` is libvorbis' `-q`, default 5). `SoundCue` packages next
524
+ to the waves are skipped. Loop points are untouched: an engine pack's RPM loops are cut to the sample.
525
+
526
+ **Retargeting from a UE rig.** `--from x.uasset` builds the rig directly. The UE5 mannequin weights
527
+ its limbs to the twist helpers (`thigh_twist_01_l`) and leaves `thigh_l` itself without a single
528
+ vertex, so "part of the body" is the skinned subtree's INTERIOR, not just the bones carrying
529
+ weights — otherwise the humanoid classifier finds no legs and refuses the rig. Unskinned ends
530
+ (`ik_foot_root`, `ik_hand_gun`, `weapon_l`, `interaction`, `center_of_mass`, `root`) stay out of the
531
+ body, which is what they should be.
532
+
533
+ Stair clips carry vertical root travel (0.40 m per loop on the Stairs pack's walk-up). `--root-motion
534
+ hips` keeps the climb on the character; `auto`/`root` treat vertical motion as hips bob and would
535
+ swallow it. Feet below the origin plane on a walk-DOWN clip are reported as "toe under the ground" —
536
+ expected for stair content, where the ground is not flat.
537
+
538
+
539
+ ## Scale
540
+
541
+ `probe` and `doctor` always **report** the model's rendered world size; nothing is changed without a
542
+ flag. A skinned primitive ignores its node's transform (glTF spec), so the size is measured through
543
+ the skin's bind space (`world(joint₀)·IBM₀`) — measuring via the mesh node reports garbage on exactly
544
+ the assets that are mis-scaled.
545
+
546
+ | flag | what it does |
547
+ |---|---|
548
+ | `--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 |
549
+ | `--scale N` | bake an explicit factor (works on clip-only files too) |
550
+ | `--scale-to M` | bake whatever makes the largest dimension `M` metres |
551
+
552
+ The factor is **baked into the data** — positions, node translations, IBM offsets and translation
553
+ tracks all × s — not parked on a wrapper node. No node is added and no local scale is rewritten, so
554
+ every node path an app addresses stays what it was. Rotation and scale tracks are untouched by
555
+ construction: scaling only translations gives `world' = S·world·S⁻¹`, and the skinning identity then
556
+ needs `IBM' = S·IBM·S⁻¹`, which is just the IBM's translation column × s.
557
+
558
+ Quantized (`KHR_mesh_quantization`) positions are refused rather than silently corrupted.
559
+
560
+ ## Shading — why a downloaded model renders too bright
561
+
562
+ `doctor` always reports two material quirks that make an asset look washed out, and neither one is
563
+ the usual suspect (metallic, or the texture):
564
+
565
+ - **self-lit** — the base colour texture is *also* wired into `emissiveTexture` with
566
+ `emissiveFactor = [1,1,1]`, so the shader draws `lit(albedo) + albedo`: roughly twice as bright as
567
+ intended, with the shading flattened out. Common from Sketchfab downloads and several FBX
568
+ exporters. `--fix-emissive` drops it and leaves the material otherwise PBR.
569
+ - **specular boost** — `KHR_materials_specular` with a `specularColorFactor` above 1.
570
+
571
+ `--unlit` goes further and converts the material to **`KHR_materials_unlit`**: flat
572
+ `baseColorFactor × baseColorTexture`, no lights, no IBL, no exposure — the shading model hand-painted
573
+ stylised assets are usually authored for. It also clears the emissive and drops specular/ior/normal/
574
+ occlusion/MR inputs, because gltfio *refuses* to combine specular with unlit ("Cannot use specular
575
+ extension with unlit") and silently falls back to lit if it is left in place.
576
+
577
+ Both engines implement it: Filament through gltfio's `unlit_opaque` ubershader, web-lite through the
578
+ `UNLIT` branch of its glTF shader (`viewer-lite/src/gl/glb.ts`, covered by `bun run render:check`).
579
+
580
+ ## Merge — one draw call per character
581
+
582
+ A modular character (or any multi-material prop) arrives as a dozen parts — head, hands, shirt,
583
+ pants, boots, vest, helmet, pouches — each its own mesh node, its own skin over a subset of the
584
+ joints, its own material with its own base / normal / ORM maps. Every material is a draw call and
585
+ three texture binds, so the character costs 16 draw calls and a squad of twelve costs 200.
586
+ `doctor --merge` (also `convert … --merge`) folds all of it into **one primitive over one material
587
+ per compatibility class**:
588
+
589
+ - **Classes.** Primitives are grouped by what one draw cannot mix: alpha mode (+ cutoff for MASK),
590
+ double-sidedness, unlit. A Military kit becomes `merged` (everything opaque) plus `merged.blend`
591
+ (the goggles' glass). A material with an extension the pass does not understand, a
592
+ `KHR_texture_transform`, or UVs that tile a texture more than 4× forms a class of its own — its
593
+ geometry still merges with its siblings, its textures are kept as they are.
594
+ - **Kinds.** A *skinned* part joins the union skin (joints in tree order, inverse bind matrices from
595
+ the skin that has most of them; a part bound in another bind space is moved into the canonical one
596
+ when its matrices differ by one rigid transform, and skipped when they disagree per joint). A
597
+ *rigid* part — a plain mesh sitting under a bone: a hat, a strap, a holstered gun — is skinned to
598
+ that bone with weight 1. A *static* part — a plain node — has its world transform baked and lands in
599
+ `merged.static`. Node-animated parts, morph targets and names matching `--merge-exclude <regex>`
600
+ stay as they are and are reported.
601
+ - **Atlases.** Per class, one texture per map kind the parts use: base colour, normal, packed
602
+ occlusion-roughness-metallic, emissive. Each material gets a tile (materials with the same maps and
603
+ factors share one); its FACTORS are baked into the tile — base colour factor in linear light,
604
+ roughness / metallic into the ORM channels, normal scale, emissive factor, occlusion strength — so
605
+ the merged material is factor-free and reads as the parts did; a material without a map gets a flat
606
+ tile of its factor; UVs are remapped into the tile; every tile is padded by `--atlas-pad` (8)
607
+ texels of edge replication so bilinear filtering and the first mips stay clean.
608
+ - **Tile sizes.** The atlas is the smallest power-of-two square (≤ `--atlas-size`, default 2048)
609
+ that holds every map at full size. When they do not fit, tiles shrink **by surface area**: each
610
+ material's area on the model (its triangles in bind / world space) over its UV coverage gives the
611
+ texel density its tile would have, and the tiles are sized so every part lands at the same
612
+ texels-per-metre, none past its native map — a torso keeps its map, a buckle gets the small tile
613
+ it needs. On the HorizonDemo hero at 2048 that is jacket and trousers at 89 %, straps and buckles
614
+ at 9 %; `--atlas-size 4096` keeps everything native. `--atlas-uniform` scales every tile by the
615
+ same factor instead. The report prints the range (`tiles 9–89% by area`).
616
+ - **Order.** The pass runs before the LOD / card passes (they see the merged primitive)
617
+ and before the texture passes: the atlases are written as PNG and go through `--ktx2` and the
618
+ format normalisation like any map — except that an atlas is capped by `--atlas-size`, not
619
+ `--max-size` (the per-part cap), so `--merge --ktx2 --max-size 1024` keeps a 2048² atlas.
620
+ - **Sources.** Run it from the source textures (PNG / JPEG) — a build that ends in the doctor just
621
+ adds the flag. A GLB that already carries KTX2 maps works too, decoded through KTX-Software's
622
+ `ktx extract` (`KTX_TOOL`, next to `KTX_TOKTX`, or found like the encoder), but that is a second lossy encode.
623
+ - **Attributes.** The merged primitive carries the union: positions, normals (computed for a part
624
+ without any), UVs, colours (white where a part had none), joints / weights; tangents only when every
625
+ part has them, a lightmap UV set (`TEXCOORD_1`) only when every part has one. Indices go 32-bit past
626
+ 65 535 vertices.
627
+
628
+ What disappears: the parts' nodes (unless they still have children), meshes, skins, materials and
629
+ textures. Code that addressed a part by node name needs `--merge-exclude`; bones and empties
630
+ (`RH_WP`) are untouched, clips play as before. The merged skin's joint count is checked against the
631
+ bone cap (`--target`).
632
+
633
+ ```
634
+ 🔍 chr1.glb
635
+ ── MERGE ──
636
+ • merge 21 primitive(s) over 15 material(s) → 2 draw call(s)
637
+ merged: 20 primitives, 14 material(s) → 1 (atlas 2048² base+normal+orm, 14 tiles (tiles 16–68% by area), pad 8), 61 joints in one skin
638
+ merge: 21 primitive(s) → 2 draw call(s)
639
+ merged: 20 primitives / 14 material(s) → 15129 vertices, 21150 triangles, atlas 2048² base+normal+orm (tiles 16–68% by area), 61 joints
640
+ 💾 wrote chr1.glb (5880KB → 1510KB)
641
+ ```
642
+
643
+ Without the flag the doctor still says what the merge would fold (`ℹ 21 primitive(s) over 15
644
+ material(s) → 2 draw call(s) with --merge`) and what it would skip.
645
+
646
+ ## What `convert` does
647
+
648
+ - **Reads FBX with [ufbx](https://github.com/ufbx/ufbx)** compiled to wasm (`native/fbx_reader.c` +
649
+ `vendor/ufbx`, built by `build-wasm.sh` into `src/wasm/fbxReader.js` — a single-file module, no
650
+ native builds, runs in Bun/Node/browsers). Everything is converted to glTF conventions in C:
651
+ meters, right-handed Y-up (`space_conversion = MODIFY_GEOMETRY`), geometry transforms / inherit
652
+ modes as helper nodes, triangulated + welded primitives, top-4 skin weights renormalized, UV v
653
+ flipped, animations **baked** (30 fps, key-reduced, trimmed to start at 0). One JSON description +
654
+ one binary arena cross the wasm boundary; `src/fbx/toDocument.ts` builds the Document.
655
+ - **Textures** embedded: PNG/JPEG pass through, TGA/BMP decoded here (→ PNG), everything else via
656
+ `sharp` when installed; duplicates de-duplicated by content hash; external references resolved
657
+ next to the FBX / `textures/`.
658
+ - **Materials**: ufbx's PBR view of FBX Phong/Lambert → metallic-roughness (opacity map → MASK,
659
+ opacity factor < 1 → BLEND, double-sided honored). Separate metallic/roughness maps are not
660
+ packed (factors used).
661
+ - **`--clips`**: each clip file's animations are re-targeted onto the model **by bone name** (exact,
662
+ then the part after the last `:`/`|` — the same rule the SDK `Animator` uses); clip meshes/skins
663
+ dropped; unbound tracks counted + warned. Naming: a single-clip file → its stack name unless generic
664
+ (`mixamo.com`, `Take 001`, `Unreal Take`) → the file's basename; `--clip-names` overrides.
665
+ **Parent-space correction**: a track is a LOCAL transform, and two exports of one rig need not
666
+ agree on what sits above a bone — Unreal/Unity clip exports put `root` straight under the scene
667
+ (rest −90°X, the Z-up→Y-up turn) while the skinned-mesh export parks that turn on an armature node
668
+ and leaves `root` at identity; copied verbatim the turn applies twice and the character plays lying
669
+ on the floor (KINEMATION's Tactical Shooter Pack, every UE5 Mannequin export). For each bound bone
670
+ the converter compares the world transform of its parent in both files — with the clip's first
671
+ frame written onto every bone it animates, so only the unanimated STRUCTURE above differs, never
672
+ the hair-width rest-pose drift between re-exports — and re-expresses the track in the model's
673
+ parent space (`local_model = inv(Pmodel)·Pclip·local_clip`). Reported as an ℹ line per clip file.
674
+ - Output goes through the same validation as the doctor (`validateGlb`) and a `probe` summary.
675
+
676
+ Skipped in v1: cameras/lights, morph targets, NURBS.
677
+
678
+ ## Verified with
679
+
680
+ `runtime/web` → `node anim-check/model-check.mjs anim-check/fixtures/<file>.glb --clip N`: loads the
681
+ GLB on the Filament wasm engine, binds every clip through the native Animator, screenshots the rest
682
+ pose and a playing clip (the mesh must draw and deform). `bun test` here covers the reader/converter
683
+ against the Mixamo sample set when `LECODES_FBX_SAMPLES` (or the default sample folder) exists.
684
+
685
+ The Unreal reader was verified against two real packs — *Stairs Set UE 5.0-5.4* (UE 5.0: 159
686
+ packages, 84 AnimSequences on two mannequins, a 26 MB zlib-chunked mesh bulk) and a UE 5.3 modular
687
+ character pack (796 packages, raw bulk, all 48 clips Sequencer-authored) — every package parsed or refused
688
+ with a reason, and the clips compared channel-by-channel against the pack's own FBX sources.
689
+ `tests/unreal.test.ts` runs those fixtures when `LECODES_UE_PACK` (or the default pack folder)
690
+ exists, plus format/math unit tests that always run. The texture path was checked against the
691
+ predecessor Python extractor with the same Oodle library: the images come out BYTE-IDENTICAL on
692
+ both packs (67 MB, 21 MB and 1 MB payloads), and the converted mannequin renders with its own
693
+ materials through `runtime/web`'s Filament check.
694
+
695
+ Retargeting was tuned on the polyperfect *Low Poly Animated Animals* zoo (different authoring
696
+ conventions within one pack: AdvancedSkeleton `Root_M/Hip_L`, Maya `actor:skin_*` with ×100 bind
697
+ space and −Z facing, unnamed `joint1…joint42`): wolf → Meshy fox, lion → cat, wolf → horse / golden
698
+ retriever, deer → giraffe, lion → elephant, all through `--map auto`, judged on `BONES=1` filmstrips
699
+ next to the donor's own walk. `tests/retarget.test.ts` pins the invariants on toy rigs (exact
700
+ direction transfer, keep/direction policies, bridges, leg detection + IK goals, donor reference).
701
+
702
+ ## Layout
703
+
704
+ ```
705
+ native/fbx_reader.c ufbx → JSON + arena (compiled to wasm)
706
+ vendor/ufbx/ ufbx.h / ufbx.c (MIT)
707
+ src/wasm/fbxReader.js generated single-file Emscripten module (tracked; rebuild with build-wasm.sh)
708
+ src/fbx/ read.ts (wasm boundary + IR) · toDocument.ts · convert.ts (merge, write)
709
+ src/textures/ resolveImage.ts (sniff, TGA/BMP decode, PNG encode, external lookup)
710
+ src/doctor/ doctor.ts (analyze/apply/report) · reduceGlbBones · transcodeTextures · transcodeKtx2 · normalizeScale · cardTrim · lod · validateGlb (mirror of viewer's)
711
+ src/unwrap/ lightmapUv.ts (xatlas wasm: charts, groups, zone, LOD projection) · index.ts (whole files, in place — `unwrap` and `lecodes lightmap bake`)
712
+ src/doctor/merge/ --merge: classify (what can merge, as what) · bindSpace (one bind space for several skins) · atlasLayout (tiles by surface area) · atlasPixels (factors baked in) · geometry (the merged primitive + union skin) · plan · index (apply, cleanup)
713
+ src/retarget/ rig.ts (hierarchy + bind pose + clip sampling) · retarget.ts (reference poses, world-delta transfer, leg IK, bridges) · automap.ts (quadruped classification + chain pairing) · humanoid.ts (humanoid canon, biped classifier, Unity .meta avatars, avatar↔avatar map) · unityAnim.ts (Unity .anim YAML reader)
714
+ tools/humanoid-survey.ts bind-pose survey of the humanoid zoo (arm drop / elbow / facing / proportions per rig)
715
+ tools/humanoid-phase3.ts every zoo clip onto every zoo target: planted frames, reach, twist, root motion (--strips for filmstrips)
716
+ src/retarget/ghost.ts a rig's skeleton alone with one clip — the character studio's donor "ghost"
717
+ src/unreal/ package.ts (uncooked UE5 .uasset: summary/names/imports/exports/tagged properties/bulk data) · skeleton.ts · anim.ts (both data models) · sequencer.ts (MovieScene ControlRig channels) · mesh.ts (FSkeletalMeshImportData) · convert.ts (the UE→glTF coordinate recipe) · toDocument.ts · clipSource.ts (--clips merging) · rig.ts (retargeter) · resolve.ts (object path → file) · texture.ts (FCompressedBuffer + FTextureSource) · sound.ts (SoundWave → WAV/OGG) · oodle.ts (the machine's own oo2core, via bun:ffi / koffi) · materials.ts (material instances → glTF slots) · probe.ts · list.ts (unreal ls) · convertAsset.ts · index.ts
718
+ src/unity/ unpack.ts (.unitypackage streaming) · guid.ts (GUID → file: unity-index.txt / .meta) · matFile.ts (.mat reader, binary fallback) · materialWriter.ts (one .mat → one glTF material, ORM packing) · materials.ts (the FBX-convert flow + prefab overrides) · image.ts · png.ts
719
+ scene import: yaml.ts · gameObjects.ts (the tree) · prefabTree.ts (nested prefabs + modifications) · fbxModel.ts (a model file as a prefab: ids, collapse, bakeAxisConversion) · propertyPath.ts · scene.ts (.unity → kinds + placements) · kindGlb.ts (one kind → one GLB) · materialSlots.ts (slot ↔ primitive by name) · lecodesLevel.ts + levelTerrain.ts (→ project files) · terrain.ts + serialized.ts (binary TerrainData) · foliage.ts · math.ts (the mirror-X frame)
720
+ src/props.ts --list-nodes / --keep / --split (subtree carving + re-origin)
721
+ src/mergeSkins.ts --merge-skins (skinned meshes onto one skeleton) · duplicateNodeNames
722
+ src/filmstrip.ts CPU-skinned PNG filmstrip renderer (probe --filmstrip; tools/filmstrip.ts wraps it)
723
+ src/commands/ one file per command — a command is DATA: name, summary, flags, run (retarget/ is a folder); shared.ts = the flag groups several commands take (doctor passes, Unreal locations)
724
+ src/cli-kit/ defineCommand + the argv parser that reads a line AGAINST a command's flags + the generated help
725
+ src/probe.ts · src/cli.ts (runAssetsCli — what `lecodes assets` calls) · src/index.ts
726
+ ```
727
+
728
+ ## Adding or changing a command
729
+
730
+ A command is one `defineCommand({ name, summary, usage, description, flags, examples, run })` in
731
+ `src/commands/`, listed in `src/commands/index.ts`. Flags are declared with `str` / `num` / `int` /
732
+ `bool` / `list` (`bare:` lets a value flag stand alone — `--split` next to `--split dir`) and arrive
733
+ in `run` typed; `given` tells a bare flag from an absent one. There is no hand-written help and no
734
+ separate list of value-taking flags to keep in sync: a flag the code reads and the parser does not
735
+ know cannot exist, and an unknown flag on the line is an error (its value would otherwise be read
736
+ as an input file). `run` resolves to the exit code; throw `CliError` (1 = the work failed) /
737
+ `usageError` / `missing(…)` (2 = the line is wrong, the latter followed by the command's help).
738
+ `bun run build` afterwards — `lecodes assets` runs `dist/`.