lecodes-assets 0.2.1 → 2.5.0

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 (99) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +18157 -11441
  3. package/dist/index.js +16603 -9878
  4. package/package.json +3 -1
  5. package/src/cli-kit/command.ts +100 -0
  6. package/src/cli-kit/help.ts +72 -0
  7. package/src/cli-kit/index.ts +4 -0
  8. package/src/cli-kit/parse.ts +72 -0
  9. package/src/cli-kit/run.ts +52 -0
  10. package/src/cli.ts +24 -1039
  11. package/src/commands/avatar.ts +77 -0
  12. package/src/commands/bvh.ts +26 -0
  13. package/src/commands/convert.ts +156 -0
  14. package/src/commands/doctor.ts +79 -0
  15. package/src/commands/geometry.ts +45 -0
  16. package/src/commands/index.ts +24 -0
  17. package/src/commands/marks.ts +38 -0
  18. package/src/commands/mirror.ts +35 -0
  19. package/src/commands/probe.ts +43 -0
  20. package/src/commands/retarget/donor.ts +63 -0
  21. package/src/commands/retarget/index.ts +169 -0
  22. package/src/commands/retarget/map.ts +81 -0
  23. package/src/commands/retarget/report.ts +77 -0
  24. package/src/commands/scene.ts +108 -0
  25. package/src/commands/shared.ts +140 -0
  26. package/src/commands/sky.ts +35 -0
  27. package/src/commands/terrainPack.ts +40 -0
  28. package/src/commands/unpack.ts +43 -0
  29. package/src/commands/unreal.ts +109 -0
  30. package/src/commands/unwrap.ts +56 -0
  31. package/src/doctor/cardTrim.ts +288 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/impostor.ts +614 -0
  34. package/src/doctor/merge/accessors.ts +30 -0
  35. package/src/doctor/merge/atlasLayout.ts +94 -0
  36. package/src/doctor/merge/atlasPixels.ts +114 -0
  37. package/src/doctor/merge/bindSpace.ts +59 -0
  38. package/src/doctor/merge/classify.ts +136 -0
  39. package/src/doctor/merge/geometry.ts +167 -0
  40. package/src/doctor/merge/index.ts +98 -0
  41. package/src/doctor/merge/matrix.ts +60 -0
  42. package/src/doctor/merge/plan.ts +88 -0
  43. package/src/doctor/merge/textures.ts +60 -0
  44. package/src/doctor/merge/types.ts +77 -0
  45. package/src/doctor/transcodeKtx2.ts +186 -31
  46. package/src/doctor/transcodeTextures.ts +6 -1
  47. package/src/doctor/validateGlb.ts +2 -2
  48. package/src/doctor/vertexAo.ts +333 -0
  49. package/src/fbx/read.ts +2 -0
  50. package/src/fbx/toDocument.ts +227 -209
  51. package/src/geometry.ts +222 -0
  52. package/src/index.ts +2 -0
  53. package/src/retarget/heading.ts +238 -171
  54. package/src/retarget/humanoid.ts +572 -561
  55. package/src/retarget/retarget.ts +2375 -2272
  56. package/src/retarget/rig.ts +4 -1
  57. package/src/retarget/writeMarks.ts +134 -132
  58. package/src/sky/cmgen.ts +3 -3
  59. package/src/sky/index.ts +57 -8
  60. package/src/terrainPack.ts +61 -22
  61. package/src/unity/colliderFit.ts +287 -0
  62. package/src/unity/fbxModel.ts +194 -0
  63. package/src/unity/foliage.ts +63 -0
  64. package/src/unity/gameObjects.ts +90 -0
  65. package/src/unity/guid.ts +104 -0
  66. package/src/unity/image.ts +58 -0
  67. package/src/unity/kindGlb.ts +364 -0
  68. package/src/unity/lecodesLevel.ts +414 -0
  69. package/src/unity/levelTerrain.ts +125 -0
  70. package/src/unity/matFile.ts +179 -0
  71. package/src/unity/materialSlots.ts +53 -0
  72. package/src/unity/materialWriter.ts +316 -0
  73. package/src/unity/materials.ts +34 -429
  74. package/src/unity/math.ts +82 -0
  75. package/src/unity/prefabTree.ts +285 -0
  76. package/src/unity/prefabs.ts +10 -36
  77. package/src/unity/propertyPath.ts +43 -0
  78. package/src/unity/scene.ts +339 -304
  79. package/src/unity/serialized.ts +242 -0
  80. package/src/unity/terrain.ts +221 -0
  81. package/src/unity/triBudget.ts +77 -0
  82. package/src/unity/triplanar.ts +122 -0
  83. package/src/unity/unityMesh.ts +151 -0
  84. package/src/unity/unpack.ts +24 -5
  85. package/src/unity/yaml.ts +131 -0
  86. package/src/unreal/index.ts +6 -5
  87. package/src/unreal/materialGraph.ts +224 -0
  88. package/src/unreal/materials.ts +117 -9
  89. package/src/unreal/package.ts +30 -12
  90. package/src/unreal/resolve.ts +6 -4
  91. package/src/unreal/sound.ts +128 -0
  92. package/src/unreal/texture.ts +70 -3
  93. package/src/unreal/toDocument.ts +7 -0
  94. package/src/unreal/versions.ts +8 -0
  95. package/src/unwrap/index.ts +167 -0
  96. package/src/unwrap/lightmapUv.ts +761 -0
  97. package/src/wasm/fbxReader.js +1 -1
  98. package/src/wasm/lightmapUv.js +1 -1
  99. package/src/doctor/lightmapUv.ts +0 -452
package/README.md CHANGED
@@ -4,6 +4,10 @@ FBX → GLB converter + GLB doctor, one package on one glTF-Transform `Document`
4
4
  `lecodes assets …` (optional peer of the `lecodes` CLI) or `bun src/cli.ts …` in the monorepo.
5
5
  Plan/rationale: [docs/animation-plan.md §2.9](../../docs/animation-plan.md).
6
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
+
7
11
  Install it the same way you installed the CLI — the peer is loaded by a bare `import` that resolves
8
12
  upward from the CLI's own file, so a global `lecodes` only sees a global peer:
9
13
 
@@ -11,18 +15,22 @@ upward from the CLI's own file, so a global `lecodes` only sees a global peer:
11
15
  npm install -g lecodes-assets
12
16
  ```
13
17
 
14
- KTX2 (`--ktx2`) additionally needs KTX-Software's `toktx` on PATH; `sharp` is an optional dependency
15
- used to normalize exotic texture formats.
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.
16
23
 
17
24
  ```
18
25
  lecodes assets convert "Erika Archer.fbx" --clips Idle.fbx Walking.fbx "Fast Run.fbx" --drop-model-clips -o hero.glb
19
26
  lecodes assets convert hero.glb --clips Jump.fbx # add clips to an existing GLB
20
27
  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)
28
+ lecodes assets convert hero.fbx --ktx2 # + textures → KTX2 (needs KTX-Software)
22
29
  lecodes assets probe hero.fbx | hero.glb # skeleton / clips / meshes / textures / bind sanity / world size
23
30
  lecodes assets doctor hero.glb [--fix] [--ktx2] [--dump] # the old tools/glb-reduce
24
31
  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`
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)
26
34
  lecodes assets retarget fox.glb --from wolf.fbx --map auto # clips from a DIFFERENT skeleton (map from skeleton shape)
27
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)
28
36
  lecodes assets unpack pack.unitypackage -o out [--only re …] # stream a .unitypackage: unity-index.txt + assets (+ .meta)
@@ -31,9 +39,11 @@ lecodes assets convert Barrel_v1.FBX --keep Barrel_v1_LD1 -o barrel.glb # one
31
39
  lecodes assets convert Barrel_v1.FBX --split props/ # every top-level node → its own GLB
32
40
  lecodes assets convert Body.FBX --merge-skins Shirt.FBX Gloves.FBX --clips … -o hero.glb # modular character → ONE skeleton
33
41
  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
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)
35
44
  lecodes assets unreal ls "Stairs Set/StairsSet" # what an UNREAL pack holds (see below)
36
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)
37
47
  lecodes assets convert SKM_Manny.uasset --clips Anims/**/*.uasset -o manny.glb # Unreal mesh + clips → GLB
38
48
  ```
39
49
 
@@ -63,20 +73,85 @@ renders a software-skinned PNG strip of every clip (`--clip` for one, `--frames
63
73
  side|front|top|threequarter|lowfront`, `--bones` for the skeleton overlay, `--cell` px per frame);
64
74
  `tools/filmstrip.ts` is the same renderer with reference frames from other files.
65
75
 
66
- **Unity scenes.** `scene Map.unity [-o level.json]` reads a text-serialised scene: every
67
- `PrefabInstance` (source prefab GUID, the position/rotation/scale/name/active overrides, its parent)
68
- and every `MeshFilter` object, composes the scene's own `Transform` hierarchy on top (groups like
69
- `Static/Roads`), and writes the instances with their **world** TRS plus a prefab table — path, use
70
- count, and a guess at the FBX + `--keep` node that renders it (the FBX from the prefab's folder /
71
- `Source/` whose stem prefixes the prefab name; most packs ship their prefabs binary-serialised, so
72
- the prefab's own contents are not read). Frame: Unity is left-handed and its FBX importer mirrors X,
73
- so the default undoes that — position `(−x, y, z)`, quaternion `(x, −y, −z, w)` — `--handedness z`
74
- mirrors Z instead (content authored inside Unity), `none` keeps Unity's numbers. Subsets:
75
- `--filter <re>` (prefab path / name / group), `--group <name>`, `--region x0,z0,x1,z1` (Unity
76
- metres), `--include-inactive`. A parent that sits inside a binary prefab resolves to that prefab's
77
- root and the instance is marked `approximate`. The output is plain JSON:
78
- `{ prefabs: { guid: { path, name, fbx, node, count } }, instances: [ { name, prefab, group,
79
- position, rotation, scale } ] }` — one `Model.load` per prefab, one `clone()` per instance.
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.
80
155
 
81
156
  ## Unity packs — `unpack` + materials from `.mat`
82
157
 
@@ -107,6 +182,18 @@ formats through `sharp`) — one metallic-roughness(+occlusion) texture per mate
107
182
  keeps the FBX references; `--unity-mats <dir>` looks elsewhere. Only YAML-serialised `.mat` files are
108
183
  readable (Unity "Force Text", the asset-store default since 2017); binary ones are listed and skipped.
109
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
+
110
197
  ## Retarget — clips from a different skeleton
111
198
 
112
199
  `convert --clips` binds tracks by BONE NAME and copies them verbatim; that only works when both
@@ -342,7 +429,7 @@ BIND=1 BONES=1 bun tools/filmstrip.ts fox.glb bind.png 1 - side
342
429
  CELL=440 bun tools/filmstrip.ts fox-anim.glb strip.png 4 wolf_walk side "fox.glb|-|0" # rest pose first
343
430
  ```
344
431
 
345
- For the real thing on the real engine, `packages/viewer` → `node anim-check/model-check.mjs`.
432
+ For the real thing on the real engine, `runtime/web` → `node anim-check/model-check.mjs`.
346
433
 
347
434
  ## Unreal packs — `.uasset` skeletons, clips, meshes
348
435
 
@@ -429,6 +516,13 @@ slot. The rest are listed in the report (`not in glTF: …`) rather than guessed
429
516
  vector parameters (colour tints, roughness powers) are read but not applied for the same reason:
430
517
  gear comes out in its neutral colours.
431
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
+
432
526
  **Retargeting from a UE rig.** `--from x.uasset` builds the rig directly. The UE5 mannequin weights
433
527
  its limbs to the twist helpers (`thigh_twist_01_l`) and leaves `thigh_l` itself without a single
434
528
  vertex, so "part of the body" is the skinned subtree's INTERIOR, not just the bones carrying
@@ -483,6 +577,72 @@ extension with unlit") and silently falls back to lit if it is left in place.
483
577
  Both engines implement it: Filament through gltfio's `unlit_opaque` ubershader, web-lite through the
484
578
  `UNLIT` branch of its glTF shader (`viewer-lite/src/gl/glb.ts`, covered by `bun run render:check`).
485
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
+
486
646
  ## What `convert` does
487
647
 
488
648
  - **Reads FBX with [ufbx](https://github.com/ufbx/ufbx)** compiled to wasm (`native/fbx_reader.c` +
@@ -517,7 +677,7 @@ Skipped in v1: cameras/lights, morph targets, NURBS.
517
677
 
518
678
  ## Verified with
519
679
 
520
- `packages/viewer` → `node anim-check/model-check.mjs anim-check/fixtures/<file>.glb --clip N`: loads the
680
+ `runtime/web` → `node anim-check/model-check.mjs anim-check/fixtures/<file>.glb --clip N`: loads the
521
681
  GLB on the Filament wasm engine, binds every clip through the native Animator, screenshots the rest
522
682
  pose and a playing clip (the mesh must draw and deform). `bun test` here covers the reader/converter
523
683
  against the Mixamo sample set when `LECODES_FBX_SAMPLES` (or the default sample folder) exists.
@@ -530,7 +690,7 @@ with a reason, and the clips compared channel-by-channel against the pack's own
530
690
  exists, plus format/math unit tests that always run. The texture path was checked against the
531
691
  predecessor Python extractor with the same Oodle library: the images come out BYTE-IDENTICAL on
532
692
  both packs (67 MB, 21 MB and 1 MB payloads), and the converted mannequin renders with its own
533
- materials through `packages/viewer`'s Filament check.
693
+ materials through `runtime/web`'s Filament check.
534
694
 
535
695
  Retargeting was tuned on the polyperfect *Low Poly Animated Animals* zoo (different authoring
536
696
  conventions within one pack: AdvancedSkeleton `Root_M/Hip_L`, Maya `actor:skin_*` with ×100 bind
@@ -547,15 +707,32 @@ vendor/ufbx/ ufbx.h / ufbx.c (MIT)
547
707
  src/wasm/fbxReader.js generated single-file Emscripten module (tracked; rebuild with build-wasm.sh)
548
708
  src/fbx/ read.ts (wasm boundary + IR) · toDocument.ts · convert.ts (merge, write)
549
709
  src/textures/ resolveImage.ts (sniff, TGA/BMP decode, PNG encode, external lookup)
550
- src/doctor/ doctor.ts (analyze/apply/report) · reduceGlbBones · transcodeTextures · transcodeKtx2 · normalizeScale · lightmapUv (xatlas wasm) · validateGlb (mirror of viewer's)
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)
551
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)
552
714
  tools/humanoid-survey.ts bind-pose survey of the humanoid zoo (arm drop / elbow / facing / proportions per rig)
553
715
  tools/humanoid-phase3.ts every zoo clip onto every zoo target: planted frames, reach, twist, root motion (--strips for filmstrips)
554
716
  src/retarget/ghost.ts a rig's skeleton alone with one clip — the character studio's donor "ghost"
555
- 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) · 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
556
- src/unity/ unpack.ts (.unitypackage streaming) · materials.ts (.mat → glTF materials, ORM packing) · png.ts · scene.ts (.unity → level JSON)
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)
557
720
  src/props.ts --list-nodes / --keep / --split (subtree carving + re-origin)
558
721
  src/mergeSkins.ts --merge-skins (skinned meshes onto one skeleton) · duplicateNodeNames
559
722
  src/filmstrip.ts CPU-skinned PNG filmstrip renderer (probe --filmstrip; tools/filmstrip.ts wraps it)
560
- src/probe.ts · src/cli.ts · src/index.ts
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
561
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/`.