lecodes-assets 0.2.1 → 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 (97) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +17948 -11911
  3. package/dist/index.js +17525 -11479
  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 +67 -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 +103 -0
  25. package/src/commands/shared.ts +121 -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 +286 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/merge/accessors.ts +30 -0
  34. package/src/doctor/merge/atlasLayout.ts +94 -0
  35. package/src/doctor/merge/atlasPixels.ts +114 -0
  36. package/src/doctor/merge/bindSpace.ts +59 -0
  37. package/src/doctor/merge/classify.ts +136 -0
  38. package/src/doctor/merge/geometry.ts +167 -0
  39. package/src/doctor/merge/index.ts +98 -0
  40. package/src/doctor/merge/matrix.ts +60 -0
  41. package/src/doctor/merge/plan.ts +88 -0
  42. package/src/doctor/merge/textures.ts +60 -0
  43. package/src/doctor/merge/types.ts +77 -0
  44. package/src/doctor/transcodeKtx2.ts +179 -28
  45. package/src/doctor/validateGlb.ts +2 -2
  46. package/src/doctor/vertexAo.ts +321 -0
  47. package/src/fbx/read.ts +2 -0
  48. package/src/fbx/toDocument.ts +227 -209
  49. package/src/geometry.ts +222 -0
  50. package/src/index.ts +2 -0
  51. package/src/retarget/heading.ts +238 -171
  52. package/src/retarget/humanoid.ts +572 -561
  53. package/src/retarget/retarget.ts +2375 -2272
  54. package/src/retarget/rig.ts +4 -1
  55. package/src/retarget/writeMarks.ts +134 -132
  56. package/src/sky/cmgen.ts +3 -3
  57. package/src/sky/index.ts +57 -8
  58. package/src/terrainPack.ts +61 -22
  59. package/src/unity/colliderFit.ts +287 -0
  60. package/src/unity/fbxModel.ts +194 -0
  61. package/src/unity/foliage.ts +63 -0
  62. package/src/unity/gameObjects.ts +81 -0
  63. package/src/unity/guid.ts +104 -0
  64. package/src/unity/image.ts +58 -0
  65. package/src/unity/kindGlb.ts +364 -0
  66. package/src/unity/lecodesLevel.ts +381 -0
  67. package/src/unity/levelTerrain.ts +116 -0
  68. package/src/unity/matFile.ts +179 -0
  69. package/src/unity/materialSlots.ts +53 -0
  70. package/src/unity/materialWriter.ts +306 -0
  71. package/src/unity/materials.ts +34 -429
  72. package/src/unity/math.ts +82 -0
  73. package/src/unity/prefabTree.ts +285 -0
  74. package/src/unity/prefabs.ts +10 -36
  75. package/src/unity/propertyPath.ts +43 -0
  76. package/src/unity/scene.ts +325 -304
  77. package/src/unity/serialized.ts +242 -0
  78. package/src/unity/terrain.ts +221 -0
  79. package/src/unity/triBudget.ts +77 -0
  80. package/src/unity/triplanar.ts +122 -0
  81. package/src/unity/unityMesh.ts +151 -0
  82. package/src/unity/unpack.ts +24 -5
  83. package/src/unity/yaml.ts +131 -0
  84. package/src/unreal/index.ts +6 -5
  85. package/src/unreal/materialGraph.ts +224 -0
  86. package/src/unreal/materials.ts +117 -9
  87. package/src/unreal/package.ts +30 -12
  88. package/src/unreal/resolve.ts +6 -4
  89. package/src/unreal/sound.ts +128 -0
  90. package/src/unreal/texture.ts +70 -3
  91. package/src/unreal/toDocument.ts +7 -0
  92. package/src/unreal/versions.ts +8 -0
  93. package/src/unwrap/index.ts +167 -0
  94. package/src/unwrap/lightmapUv.ts +761 -0
  95. package/src/wasm/fbxReader.js +1 -1
  96. package/src/wasm/lightmapUv.js +1 -1
  97. package/src/doctor/lightmapUv.ts +0 -452
package/src/cli.ts CHANGED
@@ -1,1039 +1,24 @@
1
- #!/usr/bin/env node
2
- // lecodes-assets — the LeCodes asset pipeline CLI (also reachable as `lecodes assets …`).
3
- //
4
- // convert <model.fbx|.glb|.uasset|.bvh> [--clips a.fbx b.uasset …] [--clip-names n1 n2 …] [--drop-model-clips]
5
- // [--rename from=to …] [-o out.glb] [--fix] [--ktx2 | --ktx2-hq] [--max-size N] [--target N]
6
- // [--fix-scale | --scale N | --scale-to M]
7
- // retarget <target.glb> --from <donor.fbx|.glb|.uasset|.bvh> --map <map.json> [-o out.glb] [--skeletons]
8
- // avatar <model.fbx|.glb|.uasset> [--meta model.fbx.meta] [-o model.avatar.json]
9
- // mirror <model.glb> [--clips a b …] [--suffix _M] [--axis x|y|z] [-o out.glb]
10
- // bvh <model.glb> --clip <name> [--fps 30] -o out.bvh one clip as a Kimodo-style BVH
11
- // unpack <pkg.unitypackage> [-o dir] [--only regex …] [--list]
12
- // sky <env.exr|.hdr> [-o dir] [--size N] [--ibl-size N] [--name n] [--ibl-name n] [--ibl-only]
13
- // terrain-pack <layer|dir> … [-o dir] [--normals n0 n1 …] [--names a b …] [--size N] [--png] [--flip-y]
14
- // probe <file.fbx|.glb|.uasset>
15
- // unreal ls <dir> [--all]
16
- // doctor <input.glb | dir | "*.glb"> [--fix] [--ktx2] [--ktx2-hq] [--fix-metallic] [--fix-emissive] [--unlit] [-o out] [--dump [dir]] [--target N] [--max-size N]
17
- // [--fix-scale | --scale N | --scale-to M]
18
-
19
- import { existsSync, statSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "node:fs"
20
- import { buildSky, formatSkyReport } from "./sky"
21
- import { basename, dirname, extname, join, resolve } from "node:path"
22
-
23
- const USAGE = `lecodes-assets — FBX → GLB converter + GLB doctor (docs/animation-plan.md §2.9)
24
-
25
- convert <model.fbx|.glb|.uasset> [opts] FBX, an existing GLB, or an UNREAL asset → LeCodes-ready GLB
26
- --clips a.fbx b.uasset … merge these files' animations into the model, bound by BONE NAME
27
- (FBX or Unreal AnimSequence; globs and folders expand)
28
- --clip-names n1 n2 … names for --clips (default: stack name, or the file name when generic)
29
- --drop-model-clips drop the model file's own clips (e.g. Mixamo's 1-frame T-pose)
30
- --rename clipBone=modelBone … bone name overrides for clip binding
31
- -o out.glb output (default: <model>.glb next to the input)
32
- --skeleton <SK.uasset> (Unreal) the skeleton a clip was authored on — by default it is
33
- found in the pack by the object path the clip carries
34
- --content-root <dir> (Unreal) the pack's Content folder, if the search cannot find it
35
- --lod-level N (Unreal) which mesh LOD to read (default 0)
36
- --no-textures (Unreal) keep the grey material slots instead of pulling the
37
- pack's textures (they need an Oodle library — see the README)
38
- --no-normal-flip (Unreal) keep normal maps DirectX-style (UE authors them
39
- green-down; glTF wants green-up, so they are flipped)
40
- --fix run the doctor passes (bone cap, texture normalize) before writing
41
- --ktx2 | --ktx2-hq | --ktx2-uastc textures → KTX2 (needs KTX-Software toktx). Default puts only
42
- colour on ETC1S; -hq also keeps NORMALS on UASTC; -uastc puts
43
- EVERYTHING on UASTC — for HERO assets (first-person weapon,
44
- gloves) where ETC1S blocking is visible at 30 cm. ~8x larger.
45
- --max-size N --target N texture size cap (2048) / bone cap (256)
46
- --fix-scale bake an auto unit fix (cm/mm/inch/0.01-armature → metres)
47
- --scale N | --scale-to M bake an explicit factor / make the largest dimension M metres
48
- --fix-emissive | --unlit drop a self-lit emissive / go flat (see doctor)
49
- --lightmap-uv add a lightmap UV set (see doctor)
50
- --unity-mats <dir> | --no-unity Unity .mat files are read for the REAL textures (metallic/
51
- roughness/ORM/emission/alpha mode) when they sit next to the
52
- FBX — point elsewhere, or switch it off
53
- --unity-index <unity-index.txt> GUID → path table (default: found above the FBX, else .meta files)
54
- --merge-skins b.fbx c.glb … modular characters: move these files' SKINNED meshes onto the
55
- model's skeleton (joints matched by name), drop their skeleton copies
56
- --list-nodes [depth] print the scene's nodes (multi-variant prop files) and exit
57
- --keep <node> … keep only these nodes (+ their subtrees), re-origin the result
58
- (footprint centre at X/Z = 0, lowest point at Y = 0); --no-ground
59
- keeps the file's own placement
60
- --split [dir] one GLB per top-level node, each re-origined (default: <out>/)
61
- mirror <model.glb> [--clips a b …] [--suffix _M] [--axis x|y|z] [-o out.glb]
62
- marks <model.glb> [--stairs <regex>|all|none] [--platforms <regex>|all|none] [--fps N] [--clips a b …] [-o out.glb]
63
- measure every clip's FOOT MARKS (touch spans, still windows, lift-offs,
64
- the staircase of a stair clip) on the model's own skeleton and write
65
- them into the GLB (extras.lecodes.contacts) — for packs that are not
66
- retargeted; retarget writes the same block itself
67
- add MIRRORED copies of clips (left ↔ right): a stop that brakes on
68
- the left foot becomes one that brakes on the right. World-space
69
- deltas from the bind reflected across the sagittal plane, bones
70
- paired by name (Left/Right, L_/R_, .L/.R) and checked against
71
- the bind geometry. Needs the skin (the bind pose) in the file.
72
- unpack <pkg.unitypackage> [-o dir] [--only re …] [--list]
73
- stream a .unitypackage: writes <dir>/unity-index.txt (GUID → path)
74
- and the assets (+ .meta) matching --only (default: all)
75
- retarget <target.glb> --from <donor.fbx|.glb|.uasset> --map <map.json> [options]
76
- drive one rig with ANOTHER rig's clips (different bone
77
- names, chain lengths and axis conventions are all fine)
78
- --map map.json { "root": "Hips", "bones": { targetBone: donorBone },
79
- "align": { bone: "keep" | "direction" | [ refBones ] },
80
- "legs": [ footBones ] } (align/legs optional)
81
- --skeletons print both bone lists and exit (to write that map)
82
- --auto-map [out.json] | --map auto derive the map from the two skeletons' SHAPE — write it, or use it
83
- directly. Quadrupeds: spine/neck/head/tail/legs/ears/jaw chains.
84
- Humanoids: both rigs' AVATARS (see \`avatar\`) joined on the canon
85
- --avatar <a.avatar.json|model.meta> the target's avatar instead of deriving it (a Unity .meta works)
86
- --donor-avatar <…> the donor's
87
- --donor-rig <model.fbx|.glb|.uasset> the rig a skeleton-only clip file (mocap packs: nodes + curves,
88
- no mesh) was animated on — its clips bind to it by bone name
89
- --clips a b … only these donor clips --keep-clips keep the target's own
90
- --align none|bone-direction posture alignment (default bone-direction)
91
- --ik none|legs|hands foot placement against the ground (default legs); hands = the
92
- hands as effectors too (clips where they meet or hold)
93
- --heading-end DEG the baked heading curve must END here (a stop that turns: its idle faces the turn)
94
- --root-motion auto|hips|root|none where the travel lands: a root above the hips takes the horizontal
95
- travel, the hips the bob (auto/root); all on the hips; none = in place
96
- --no-plant no foot planting (each foot is otherwise LOCKED through its still windows)
97
- --foot-stance target|donor the horizontal stance a planted foot keeps: the target's own foot-under-hip
98
- offset + the donor's departure from its bind (default), or the donor's offset
99
- whole — a humanoid bound legs-apart keeps its step width
100
- --stairs <regex>|all|none donor clips to fit a STAIRCASE to (default: names containing "stair")
101
- --platforms <regex>|all|none donor clips with a PLATFORM: two levels, the floor and the top (default
102
- "platform"); a foot slow at any other height (the wall) is no plant
103
- --twist N|off twist helpers' share of their limb's axial turn (default 0.5)
104
- --donor-ref bind|auto|<clip> pose the donor's motion is measured from (auto = mean of its idle/walk)
105
- --fps N --root-scale N --prefix s sampling rate / root-motion scale / clip name prefix
106
- --epsilon M --epsilon-deg D key-reduction tolerances (default 1e-4 m / 0.2°; hips + legs 0.01°)
107
- --body-clearance M a joint the donor has on the floor (a roll's back) keeps this much
108
- over the target's ground beyond the donor's height (default 0.05)
109
- --clip-names n1 n2 … rename the produced clips, positionally
110
- --anim a.anim b.uasset … clip assets to use as the donor's clips: Unity AnimationClips
111
- (packs shipping an empty FBX rig + .anim files) or Unreal
112
- AnimSequences (each on its own skeleton, bound by bone name)
113
- avatar <model.fbx|.glb|.uasset> [--meta model.fbx.meta] [-o model.avatar.json]
114
- a humanoid rig's AVATAR: its bones named in the Unity/VRM canon
115
- (Hips, Spine…, LeftUpperArm…, Left Thumb Proximal…), read off the
116
- skeleton's shape — hips = the joint two ground-reaching chains hang
117
- from, hands = the lateral joints fanning into fingers, head = the
118
- last branch above the arms; twist/roll helpers recorded, not mapped.
119
- --meta checks the result against Unity's own humanoid map (and
120
- imports it verbatim when you trust it more: -o with --meta writes
121
- Unity's). The file is what \`retarget --avatar\` reads
122
- sky <env.exr|.hdr> equirectangular HDR → the TWO cubemaps a scene needs
123
- -o dir where they land (default: beside the input)
124
- --size N edge of the DRAWN sky cubemap (default 512)
125
- --ibl-size N edge of the lighting probe (default 256 — it is only ever
126
- sampled as low-frequency light, so it stays small)
127
- --name n --ibl-name n names without extension (default sky / ibl)
128
- --ibl-only lighting probe only, no drawn sky
129
- Writes sky.ktx (sharp — use it for \`skybox: { texture }\`)
130
- and ibl.ktx (prefiltered + SH — lights the scene). They are
131
- NOT interchangeable: the IBL is blurred by design, so
132
- drawing it as the sky looks soft. Also reports WHERE THE
133
- SUN IS in the environment, so the scene's directional light
134
- can be aimed to match instead of by eye.
135
- Needs filament's cmgen (LECODES_CMGEN / ~/.lecodes/cmgen).
136
-
137
- terrain-pack <layer> [<layer> …] a terrain's splat layers (≤ 4) → <name>.ktx2 per layer + the PACKED
138
- normal maps normals01.ktx2 / normals23.ktx2 (layer XY pairs in
139
- RG/BA — terrain.mat has nine samplers, so normals ship two per
140
- texture). A layer = an albedo file (its normal guessed from the
141
- name, or --normals) or a FOLDER holding *Albedo* + *Normal*
142
- (Unity terrain packs). Prints the Terrain.create fragment
143
- -o dir where they land (default: ./terrain)
144
- --normals n0 n1 … normal map per layer, positionally ("-" = none)
145
- --names a b … layer names → file names (default: from the inputs)
146
- --size N output edge (1024) --quality N UASTC quality 0–4 (1)
147
- --png PNGs instead of KTX2 (no toktx needed)
148
- --flip-y DirectX-style normal maps (green inverted)
149
-
150
- probe <file.fbx|.glb|.uasset> skeleton, clips (+ per-clip root motion / first-frame-vs-rest),
151
- meshes, materials, textures, bind sanity, world size, duplicate names
152
- --filmstrip [out.png] (GLB) CPU-skinned PNG strip of every clip — the fastest "does it play
153
- upright" check, no GPU; --clip <name> for one, --frames N (6),
154
- --view side|front|top|threequarter|lowfront, --bones, --cell px
155
- scene <map.unity> [-o level.json] Unity scene → level JSON: every placed prefab / mesh object with its
156
- world TRS in LeCodes' frame + the prefab table (FBX + --keep node guess)
157
- --filter re --group name --region x0,z0,x1,z1 subset by prefab path/name, scene group, Unity X/Z rect
158
- --handedness x|z|none how Unity's left-handed frame is undone (default x: −x, (x,−y,−z,w))
159
- --include-inactive --index <unity-index.txt>
160
- unreal tex <T_x.uasset> [-o out.png] the SOURCE image out of a texture asset (Oodle-compressed —
161
- see the README); --raw writes it exactly as stored,
162
- --max-size N downscales, --flip-green for a normal map
163
- unreal ls <dir> [--all] what an UNREAL pack holds: every .uasset by class, grouped by the
164
- skeleton it belongs to, with the reason for anything unreadable
165
- (a UE4 package, a mesh with no import data, a clip
166
- whose rig is elsewhere). Reads only the headers — a 800-asset
167
- pack lists in a second. convert/probe/retarget take
168
- .uasset files directly (uncooked UE 5.0-5.7 editor assets:
169
- SkeletalMesh, Skeleton, AnimSequence)
170
-
171
- doctor <in.glb | dir | "*.glb"> [--fix] [--ktx2] [--ktx2-hq] [--fix-metallic] [-o out] [--dump [dir]] [--target N] [--max-size N]
172
- analyze / fix a GLB against creator-gl limits (the old glb-reduce)
173
- [--fix-scale | --scale N | --scale-to M]
174
- scale normalization — always REPORTED, applied only with a flag
175
- [--fix-emissive] drop a self-lit emissive (emissive = base colour → ~2x too bright)
176
- [--unlit] flat base colour, no lighting (KHR_materials_unlit) — for stylised assets
177
- [--lightmap-uv] add a lightmap UV set (TEXCOORD_1, xatlas) — what \`lecodes lightmap bake\`
178
- samples; skinned meshes are skipped. --lightmap-padding N (4),
179
- --lightmap-iterations N (1, more = better charts, slower)
180
- [--lod 0.5,0.2] [--lod-bones] add mesh LOD levels (<name>_LOD1... siblings at these triangle
181
- ratios, same skin; >= 2000-tri meshes only) - creator-gl shows one
182
- by screen size (docs/lod-plan.md); --lod-bones folds fingers /
183
- toes / twists onto their parents in the last level's skin`
184
-
185
- type Parsed = { positionals: string[], flags: Set<string>, values: Map<string, string[]> }
186
- const MULTI = new Set([ "--clips", "--clip-names", "--rename", "--anim", "--only", "--keep", "--merge-skins", "--normals", "--names" ])
187
- const SINGLE = new Set([ "-o", "--clip", "--max-size", "--target", "--dump", "--scale", "--scale-to", "--from", "--map", "--auto-map", "--align", "--ik", "--donor-ref", "--stairs", "--platforms", "--body-clearance", "--epsilon", "--epsilon-deg", "--fps", "--root-scale", "--prefix", "--unity-mats", "--unity-index",
188
- "--skeleton", "--content-root", "--lod-level",
189
- "--avatar", "--donor-avatar", "--meta", "--root-motion", "--twist", "--donor-rig", "--heading-end", "--foot-stance",
190
- "--split", "--list-nodes", "--filmstrip", "--frames", "--view", "--clip", "--cell", "--filter", "--group", "--region", "--handedness", "--index",
191
- "--size", "--ibl-size", "--name", "--ibl-name", "--lightmap-padding", "--lightmap-iterations", "--quality", "--lod", "--suffix", "--axis" ])
192
-
193
- // `--region -10,0,50,60` / `--scale -1`: "-" + digit is a negative number, not the next flag.
194
- const isValue = (s: string): boolean => !s.startsWith("-") || /^-(\d|\.\d)/.test(s)
195
-
196
- const parse = (argv: string[]): Parsed => {
197
- const positionals: string[] = []
198
- const flags = new Set<string>()
199
- const values = new Map<string, string[]>()
200
- for (let i = 0; i < argv.length; i++) {
201
- const a = argv[i]
202
- if (MULTI.has(a)) {
203
- const list: string[] = []
204
- while (i + 1 < argv.length && isValue(argv[i + 1])) list.push(argv[++i])
205
- values.set(a, [ ...(values.get(a) ?? []), ...list ])
206
- } else if (SINGLE.has(a)) {
207
- if (i + 1 < argv.length && isValue(argv[i + 1])) values.set(a, [ argv[++i] ])
208
- else values.set(a, [])
209
- } else if (a.startsWith("-") && !isValue(a)) flags.add(a)
210
- else positionals.push(a)
211
- }
212
- return { positionals, flags, values }
213
- }
214
-
215
- const log = (s: string): void => console.log(s)
216
-
217
- const cmdMarks = async (p: Parsed): Promise<number> => {
218
- const input = p.positionals[0]
219
- if (!input) { log(USAGE); return 2 }
220
- if (!existsSync(input)) { console.error(`✗ not found: ${input}`); return 1 }
221
- const { makeIO, writeGlb } = await import("./fbx/convert")
222
- const { writeMarks } = await import("./retarget/writeMarks")
223
- const { stairsMatcher, describeMarks } = await import("./retarget/marks")
224
- const t0 = performance.now()
225
- const doc = await makeIO().read(input)
226
- const rows = writeMarks(doc, { stairs: stairsMatcher(p.values.get("--stairs")?.[0], p.values.get("--platforms")?.[0]), fps: num(p, "--fps"), clips: p.values.get("--clips"), headingEnd: num(p, "--heading-end"), warn: (w) => log(` ⚠ ${w}`) })
227
- for (const r of rows) log(` 🦶 ${r.clip.padEnd(22)} ${describeMarks(r.marks)}`)
228
- const out = p.values.get("-o")?.[0] ?? input
229
- const glb = await writeGlb(doc)
230
- writeFileSync(out, glb)
231
- log(`💾 wrote ${out} (${rows.length} clips marked, ${((performance.now() - t0) / 1000).toFixed(1)}s)`)
232
- return 0
233
- }
234
-
235
- const cmdBvh = async (p: Parsed): Promise<number> => {
236
- const input = p.positionals[0]
237
- const out = p.values.get("-o")?.[0]
238
- if (!input || !out) { log("usage: bvh <model.glb> --clip <name> [--fps 30] -o out.bvh"); return 2 }
239
- if (!existsSync(input)) { console.error(`✗ not found: ${input}`); return 1 }
240
- const { makeIO } = await import("./fbx/convert")
241
- const { documentToBvh } = await import("./bvh/fromDocument")
242
- const doc = await makeIO().read(input)
243
- const r = documentToBvh(doc, { clip: p.values.get("--clip")?.[0], fps: num(p, "--fps") })
244
- writeFileSync(out, r.text)
245
- log(`💾 wrote ${out} ("${r.report.clip}": ${r.report.joints} joints, ${r.report.frames} frames @ ${r.report.fps} fps)`)
246
- return 0
247
- }
248
-
249
- const cmdConvert = async (p: Parsed): Promise<number> => {
250
- const input = p.positionals[0]
251
- if (!input) { log(USAGE); return 2 }
252
- if (!existsSync(input)) { console.error(`✗ not found: ${input}`); return 1 }
253
- const { convertFbx, mergeClipScene, loadClipSource, makeIO, writeGlb } = await import("./fbx/convert")
254
- const clips = resolveInputs(p.values.get("--clips") ?? [], /\.(fbx|glb|uasset)$/i, false)
255
- const clipNames = p.values.get("--clip-names")
256
- const rename: Record<string, string> = {}
257
- for (const r of p.values.get("--rename") ?? []) { const [ a, b ] = r.split("="); if (a && b) rename[a] = b }
258
- const warn = (w: { level: string, message: string }): void => log(` ${w.level === "warn" ? "⚠" : "ℹ"} ${w.message}`)
259
- const t0 = performance.now()
260
- log(`🔄 ${input}${clips.length ? ` + ${clips.length} clip file(s)` : ""}`)
261
- let doc
262
- let report: { clips: { name: string, source: string, duration: number, tracks: number, bound: number }[] } = { clips: [] }
263
- const unrealOpts = { skeleton: p.values.get("--skeleton")?.[0], contentRoot: p.values.get("--content-root")?.[0], lod: num(p, "--lod-level") }
264
- const unrealTex = { textures: !p.flags.has("--no-textures"), maxSize: num(p, "--max-size"), flipNormalGreen: !p.flags.has("--no-normal-flip") }
265
- if (extname(input).toLowerCase() === ".uasset") {
266
- // Unreal: a SkeletalMesh → mesh + skin + skeleton; a Skeleton or an AnimSequence → that
267
- // skeleton (resolved from the pack for a clip) plus, for a clip, its own animation.
268
- const { convertUnreal } = await import("./unreal/convertAsset")
269
- const r = await convertUnreal(input, { clips, clipNames, rename, dropModelClips: p.flags.has("--drop-model-clips"), warn, ...unrealOpts, ...unrealTex })
270
- doc = r.doc; report = r.report
271
- log(` ${r.info}`)
272
- for (const s of r.textures?.slots ?? []) {
273
- if (!s.material) continue
274
- log(` 🎨 ${s.slot.padEnd(16)} ${s.material}${s.roles.length ? ` ${s.roles.join(" + ")}` : " (no texture wired)"}${s.skipped.length ? ` · not in glTF: ${s.skipped.slice(0, 4).join(", ")}` : ""}`)
275
- }
276
- } else if (extname(input).toLowerCase() === ".bvh") {
277
- // BVH (mocap, Kimodo's SOMA export): a skeleton-only rig — nodes, a skin with inverse binds, no
278
- // mesh — plus the file's motion as one clip (named by --clip-names, else the file's stem)
279
- const { bvhToDocument, describeBvh } = await import("./bvh/toDocument")
280
- const r = bvhToDocument(readFileSync(input, "utf8"), { name: clipNames?.[0] ?? basename(input).replace(/\.bvh$/i, "") })
281
- doc = r.doc
282
- log(` ${describeBvh(r.report)}`)
283
- } else if (extname(input).toLowerCase() === ".fbx") {
284
- const r = await convertFbx(input, {
285
- clips, clipNames, rename, dropModelClips: p.flags.has("--drop-model-clips"), warn, ...unrealOpts,
286
- unity: p.flags.has("--no-unity") ? false : (p.values.get("--unity-mats")?.[0] ?? true),
287
- unityIndex: p.values.get("--unity-index")?.[0],
288
- })
289
- doc = r.doc; report = r.report
290
- if (r.report.unity) {
291
- const { describeUnityReport } = await import("./unity/materials")
292
- for (const l of describeUnityReport(r.report.unity)) log(` ${l}`)
293
- if (r.report.unity.applied.length && r.report.unity.resolver) log(` ℹ textures resolved via ${r.report.unity.resolver}`)
294
- }
295
- } else {
296
- doc = await makeIO().read(input)
297
- for (let i = 0; i < clips.length; i++) {
298
- report.clips.push(...mergeClipScene(doc, await loadClipSource(clips[i], unrealOpts), basename(clips[i]), clipNames?.[i], rename, warn))
299
- }
300
- }
301
- for (const c of report.clips) log(` 🎞 ${c.name.padEnd(24)} ${c.duration.toFixed(2)}s ${c.bound}/${c.tracks} tracks (${c.source})`)
302
-
303
- // --merge-skins: modular pieces (shirt, gloves, boots…) onto this model's skeleton
304
- const mergeFiles = p.values.get("--merge-skins") ?? []
305
- if (mergeFiles.length) {
306
- const { mergeSkins } = await import("./mergeSkins")
307
- const { convertFbx } = await import("./fbx/convert")
308
- const donors = []
309
- for (const f of mergeFiles) {
310
- if (!existsSync(f)) { console.error(`✗ not found: ${f}`); return 1 }
311
- const d = extname(f).toLowerCase() === ".fbx"
312
- ? (await convertFbx(f, { dropModelClips: true, warn, unity: p.flags.has("--no-unity") ? false : (p.values.get("--unity-mats")?.[0] ?? true), unityIndex: p.values.get("--unity-index")?.[0] })).doc
313
- : await makeIO().read(f)
314
- donors.push({ doc: d, label: basename(f) })
315
- }
316
- const mr = await mergeSkins(doc, donors, warn)
317
- for (const d of mr.donors) log(` 🧵 ${d.label.padEnd(24)} ${d.meshes} skinned mesh(es) onto the ${mr.joints}-joint skeleton${d.missingJoints.length ? ` (${d.missingJoints.length} donor joint(s) not in the base${d.lostWeight > 0.01 ? `, ${d.lostWeight.toFixed(1)} weight lost` : ", unweighted"})` : ""}`)
318
- }
319
-
320
- // multi-variant prop files: list / keep / split
321
- if (p.values.has("--list-nodes")) {
322
- const { listNodes } = await import("./props")
323
- const depth = parseInt(p.values.get("--list-nodes")?.[0] ?? "1", 10) || 1
324
- log(` nodes (depth ${depth}) — pick with --keep <name>:`)
325
- for (const n of listNodes(doc, depth)) log(` ${" ".repeat(n.depth)}${n.name || "(unnamed)"} ${n.meshes ? `${n.meshes} mesh · ${n.tris} tris · ${n.size.map((v) => v.toFixed(2)).join("×")} m` : n.children ? `${n.children} children` : "empty"}`)
326
- return 0
327
- }
328
- const keepList = p.values.get("--keep") ?? []
329
- if (keepList.length) {
330
- const { keepNodes } = await import("./props")
331
- const r = await keepNodes(doc, keepList, { ground: !p.flags.has("--no-ground") })
332
- if (r.missing.length) { console.error(`✗ node(s) not in the file: ${r.missing.join(", ")} (--list-nodes prints them)`); return 1 }
333
- log(` ✂ kept ${r.kept.join(", ")} · ${r.size.map((v) => v.toFixed(2)).join("×")} m${p.flags.has("--no-ground") ? "" : ` · re-origined (moved ${r.offset.map((v) => v.toFixed(2)).join(",")})`}`)
334
- }
335
- const wantsDoctor = p.flags.has("--fix") || p.flags.has("--ktx2") || p.flags.has("--ktx2-hq") || p.flags.has("--ktx2-uastc")
336
- || p.flags.has("--fix-scale") || p.values.has("--scale") || p.values.has("--scale-to")
337
- || p.flags.has("--unlit") || p.flags.has("--fix-emissive") || p.flags.has("--lightmap-uv")
338
- if (wantsDoctor) {
339
- const { analyzeDoc, applyPlan, reportPlan } = await import("./doctor/doctor")
340
- const { ensureCodecs } = await import("./doctor/reduceGlbBones")
341
- await ensureCodecs()
342
- const opts = { ktx2: p.flags.has("--ktx2") || p.flags.has("--ktx2-hq") || p.flags.has("--ktx2-uastc"), ktx2Hq: p.flags.has("--ktx2-hq"), ktx2Uastc: p.flags.has("--ktx2-uastc"), maxSize: num(p, "--max-size"), target: num(p, "--target"), ...scaleOpts(p), ...shadingOpts(p), ...lightmapOpts(p) }
343
- const plan = await analyzeDoc(doc, opts)
344
- const rep = reportPlan(plan)
345
- log(rep.text)
346
- const res = await applyPlan(doc, plan, { ...opts, fixMetallic: p.flags.has("--fix-metallic"), progress: (d, t) => process.stdout.write(`\r encoding ktx2… ${d}/${t}${d === t ? "\n" : ""}`) })
347
- if (res.metallicZeroed) log(` zeroed metallic on ${res.metallicZeroed} material(s)`)
348
- if (res.emissiveCleared) log(` cleared self-lit emissive on ${res.emissiveCleared} material(s)`)
349
- if (res.unlit) log(` ${res.unlit} material(s) → unlit (flat base colour, no lighting)`)
350
- if (res.scaled !== 1) log(` baked scale ×${+res.scaled.toPrecision(6)}`)
351
- if (res.lightmapUv) log(` lightmap UV: ${res.lightmapUv.charts} charts over ${res.lightmapUv.primitives} primitive(s) (xatlas ${res.lightmapUv.width}×${res.lightmapUv.height}); vertices ${res.lightmapUv.verticesBefore} → ${res.lightmapUv.verticesAfter}${res.lightmapUv.lodPrimitives ? `; ${res.lightmapUv.lodPrimitives} LOD primitive(s) took level 0's layout by projection (${res.lightmapUv.lodVertices} vertices)` : ''}`)
352
- if (res.lod) {
353
- for (const t of res.lod.tris) log(` lod: ${t.name} ${t.levels.join(' / ')} tris`)
354
- for (const b of res.lod.bones) log(` lod bones: ${b.name} last level ${b.before} → ${b.after} joints`)
355
- for (const k of res.lod.skipped) log(` lod: ${k.name} level ${k.level} dropped — the simplifier reached only ${k.tris} tris (seams / hard surfaces; try a smaller ratio)`)
356
- if (!res.lod.nodes) log(` lod: no mesh with >= 2000 triangles (or the _LOD nodes are already there)`)
357
- }
358
- }
359
- const out = p.values.get("-o")?.[0] ?? join(dirname(input), basename(input, extname(input)) + ".glb")
360
- const { validateGlb, formatReport } = await import("./doctor/validateGlb")
361
- const { probeDocument } = await import("./probe")
362
- if (p.values.has("--split")) {
363
- // one GLB per top-level node (the doctor passes above already ran on the whole document)
364
- const { splitDoc, fileStem } = await import("./props")
365
- const dir = p.values.get("--split")?.[0] ?? (p.values.has("-o") ? out.replace(/\.glb$/i, "") : join(dirname(input), basename(input, extname(input))))
366
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
367
- const parts = await splitDoc(doc, { ground: !p.flags.has("--no-ground") })
368
- let total = 0
369
- for (const part of parts) {
370
- const f = join(dir, fileStem(part.name) + ".glb")
371
- const glb = await writeGlb(part.doc)
372
- writeFileSync(f, glb)
373
- total += glb.byteLength
374
- const v = validateGlb(glb)
375
- log(` ${v.ok ? "✅" : "✗"} ${basename(f).padEnd(40)} ${part.size.map((x) => x.toFixed(2)).join("×")} m ${(glb.byteLength / 1024).toFixed(0)} KB`)
376
- }
377
- log(`💾 wrote ${parts.length} file(s) → ${dir} (${(total / 1024 / 1024).toFixed(2)} MB, ${((performance.now() - t0) / 1000).toFixed(1)}s)`)
378
- return 0
379
- }
380
- const glb = await writeGlb(doc)
381
- const dir = dirname(out)
382
- if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true })
383
- writeFileSync(out, glb)
384
- log(formatReport(validateGlb(glb), out))
385
- log(probeDocument(doc, out).split("\n").slice(1).join("\n"))
386
- log(`💾 wrote ${out} (${(glb.byteLength / 1024 / 1024).toFixed(2)} MB, ${((performance.now() - t0) / 1000).toFixed(1)}s)`)
387
- return 0
388
- }
389
-
390
- const num = (p: Parsed, key: string): number | undefined => { const v = p.values.get(key)?.[0]; return v !== undefined ? parseInt(v, 10) : undefined }
391
- // Scale factors are fractional (--scale 0.01, --scale-to 1.8) — parseInt would silently truncate.
392
- const real = (p: Parsed, key: string): number | undefined => {
393
- const v = p.values.get(key)?.[0]
394
- if (v === undefined) return undefined
395
- const n = Number(v)
396
- if (!Number.isFinite(n) || n <= 0) throw new Error(`${key} needs a positive number, got "${v}"`)
397
- return n
398
- }
399
- const scaleOpts = (p: Parsed) => ({ scale: real(p, "--scale"), scaleTo: real(p, "--scale-to"), fixScale: p.flags.has("--fix-scale") })
400
- const shadingOpts = (p: Parsed) => ({ unlit: p.flags.has("--unlit"), fixEmissive: p.flags.has("--fix-emissive") })
401
- const lightmapOpts = (p: Parsed) => ({ lightmapUv: p.flags.has("--lightmap-uv"), lightmapPadding: num(p, "--lightmap-padding"), lightmapIterations: num(p, "--lightmap-iterations") })
402
- const lodOpts = (p: Parsed) => ({ lod: (p.values.get("--lod") ?? []).flatMap((v) => v.split(",")).map(Number).filter((n) => n > 0 && n < 1), lodBones: p.flags.has("--lod-bones") })
403
-
404
- const cmdRetarget = async (p: Parsed): Promise<number> => {
405
- const input = p.positionals[0]
406
- const donorPath = p.values.get("--from")?.[0]
407
- if (!input || !donorPath) { log(USAGE); return 2 }
408
- for (const f of [ input, donorPath ]) if (!existsSync(f)) { console.error(`✗ not found: ${f}`); return 1 }
409
- const { loadRig, rigFromDocument, checkUniformScale, hasSkin, rebindClips } = await import("./retarget/rig")
410
- const { retarget, bindAccessors, analyzeFacing, facingMismatch } = await import("./retarget/retarget")
411
- const { makeIO, writeGlb } = await import("./fbx/convert")
412
- const { probeDocument } = await import("./probe")
413
-
414
- const doc = await makeIO().read(input)
415
- const target = rigFromDocument(doc, basename(input))
416
- const unrealOpts = { skeleton: p.values.get("--skeleton")?.[0], contentRoot: p.values.get("--content-root")?.[0] }
417
- const loadAny = async (path: string) => {
418
- if (path.toLowerCase().endsWith(".uasset")) return (await import("./unreal/rig")).rigFromUnreal(path, unrealOpts)
419
- if (path.toLowerCase().endsWith(".bvh")) {
420
- const { bvhToDocument, describeBvh } = await import("./bvh/toDocument")
421
- const r = bvhToDocument(readFileSync(path, "utf8"), { name: basename(path).replace(/\.bvh$/i, "") })
422
- log(` ${basename(path)}: ${describeBvh(r.report)}`)
423
- return rigFromDocument(r.doc, basename(path))
424
- }
425
- return path.toLowerCase().endsWith(".fbx") ? loadRig(path) : rigFromDocument(await makeIO().read(path), basename(path))
426
- }
427
- let donor = await loadAny(donorPath)
428
-
429
- // A skeleton-only clip file (a mocap pack's FBX: nodes + curves, no mesh) has no skin to classify
430
- // and no bind to measure from: its clips are bound by bone name onto the rig it was animated on.
431
- const donorRigPath = p.values.get("--donor-rig")?.[0]
432
- if (donorRigPath) {
433
- if (!existsSync(donorRigPath)) { console.error(`✗ not found: ${donorRigPath}`); return 1 }
434
- const r = rebindClips(await loadAny(donorRigPath), donor)
435
- log(` 📎 ${basename(donorPath)}: ${r.bound}/${r.total} bones bound to ${basename(donorRigPath)} by name${Math.abs(r.scale - 1) > 0.01 ? ` · the clip's skeleton is ×${(1 / r.scale).toFixed(2)} the rig's size (rotations taken, lengths the rig's)` : ""}${r.unbound.length ? ` (no bone: ${r.unbound.slice(0, 3).join(", ")}${r.unbound.length > 3 ? ` +${r.unbound.length - 3}` : ""})` : ""}`)
436
- donor = r.rig
437
- } else if (!hasSkin(donor) && !p.flags.has("--skeletons")) {
438
- console.error(`✗ ${basename(donorPath)} carries no skin (a clip-only file) — name the rig it was animated on: --donor-rig <model.fbx|.glb>`)
439
- return 2
440
- }
441
-
442
- // Clip assets that stand in for clips the donor rig file does not carry: Unity `.anim`
443
- // AnimationClips, or Unreal `.uasset` AnimSequences (each on its own skeleton, bound by name).
444
- const animFiles = resolveInputs(p.values.get("--anim") ?? [], /\.(anim|uasset)$/i, false)
445
- if (animFiles.length) {
446
- for (const f of animFiles) if (!existsSync(f)) { console.error(`✗ not found: ${f}`); return 1 }
447
- const unity = animFiles.filter((f) => /\.anim$/i.test(f))
448
- const unreal = animFiles.filter((f) => /\.uasset$/i.test(f))
449
- const attached: import("./retarget/rig").Clip[] = []
450
- const report: { name: string, bound: number, total: number, unbound: string[] }[] = []
451
- if (unity.length) {
452
- const { parseUnityAnim, attachUnityClips } = await import("./retarget/unityAnim")
453
- const parsed = unity.map((f) => parseUnityAnim(readFileSync(f, "utf8"), basename(f).replace(/\.anim$/i, "")))
454
- const r = attachUnityClips(donor, parsed)
455
- attached.push(...r.clips); report.push(...r.report)
456
- }
457
- for (const f of unreal) {
458
- const { loadUnreal } = await import("./unreal/index")
459
- const { attachUnrealClips } = await import("./unreal/rig")
460
- const u = loadUnreal(f, unrealOpts)
461
- if (!u.anim) { console.error(`✗ ${basename(f)}: not an AnimSequence (${u.kind}) — --anim wants animation assets`); return 1 }
462
- const r = attachUnrealClips(donor, u.skeleton, [ u.anim ])
463
- attached.push(...r.clips); report.push(...r.report)
464
- }
465
- donor = { ...donor, clips: [ ...donor.clips, ...attached ] }
466
- for (const r of report) {
467
- log(` 📎 ${r.name.padEnd(20)} ${r.bound}/${r.total} curves bound${r.unbound.length ? ` (no bone: ${r.unbound.slice(0, 3).join(", ")}${r.unbound.length > 3 ? ` +${r.unbound.length - 3}` : ""})` : ""}`)
468
- }
469
- }
470
-
471
- if (p.flags.has("--skeletons")) {
472
- // Depth-first, parents before children — the shape you actually read a skeleton in.
473
- const col = (rig: { names: string[], parents: number[], bind: { fromSkin: boolean[] } }): string[] => {
474
- const kids: number[][] = rig.names.map(() => [])
475
- const roots: number[] = []
476
- rig.parents.forEach((par, i) => (par >= 0 ? kids[par].push(i) : roots.push(i)))
477
- const out: string[] = []
478
- const walk = (i: number, d: number): void => {
479
- out.push(`${" ".repeat(d)}${rig.names[i] || "?"}${rig.bind.fromSkin[i] ? " *" : ""}`)
480
- for (const c of kids[i]) walk(c, d + 1)
481
- }
482
- roots.forEach((r) => walk(r, 0))
483
- return out
484
- }
485
- const a = col(target), b = col(donor)
486
- log(`
487
- ${`TARGET ${basename(input)} (${target.names.length})`.padEnd(46)}DONOR ${basename(donorPath)} (${donor.names.length})`)
488
- log(`${"(* = bound by a skin)".padEnd(46)}(* = bound by a skin)`)
489
- for (let i = 0; i < Math.max(a.length, b.length); i++) log(`${(a[i] ?? "").slice(0, 44).padEnd(46)}${b[i] ?? ""}`)
490
- log(`
491
- donor clips: ${donor.clips.map((c) => `"${c.name}" ${c.duration.toFixed(2)}s`).join(", ") || "none"}`)
492
- const { classifyBiped, describeBiped } = await import("./retarget/humanoid")
493
- for (const [ label, rig ] of [ [ "target", target ], [ "donor", donor ] ] as const) {
494
- try { log(`${label} reads as a humanoid: ${describeBiped(rig, classifyBiped(rig))}`) } catch { /* not a biped — fine */ }
495
- }
496
- return 0
497
- }
498
-
499
- // Avatars: a humanoid rig's bones in the canon — given (`.avatar.json` / Unity `.meta`) or from shape.
500
- const { avatarFromRig, avatarFromUnity, avatarFromFile, parseUnityAvatar, mapAvatars, describeHumanoidMap, describeBiped, classifyBiped, isBiped } = await import("./retarget/humanoid")
501
- type Avatar = import("./retarget/humanoid").Avatar
502
- const loadAvatar = (rig: import("./retarget/rig").Rig, file: string | undefined): Avatar | null => {
503
- if (!file) return isBiped(rig) ? avatarFromRig(rig) : null
504
- if (!existsSync(file)) throw new Error(`not found: ${file}`)
505
- const text = readFileSync(file, "utf8")
506
- return /\.meta$/i.test(file) ? avatarFromUnity(rig, parseUnityAvatar(text)) : avatarFromFile(rig, JSON.parse(text))
507
- }
508
-
509
- type MapSpec = {
510
- root?: string, bones: Record<string, string>, alignTo?: Record<string, string[]>,
511
- align?: Record<string, "keep" | "direction" | string[]>, legs?: string[], ik?: boolean,
512
- donorRef?: "bind" | "auto" | { clip: string, time?: number },
513
- twist?: Record<string, string>,
514
- /** Both avatars of a humanoid map — reloaded so `--map file.json` keeps the humanoid treatment. */
515
- avatars?: { target: import("./retarget/humanoid").AvatarFile, donor: import("./retarget/humanoid").AvatarFile },
516
- }
517
- let humanoid: { target: Avatar, donor: Avatar } | undefined
518
- const mapPath = p.values.get("--map")?.[0]
519
- const autoOut = p.values.get("--auto-map")
520
- let spec: MapSpec
521
- const avatarFile = p.values.get("--avatar")?.[0], donorAvatarFile = p.values.get("--donor-avatar")?.[0]
522
- if (autoOut !== undefined || mapPath === "auto" || avatarFile || donorAvatarFile) {
523
- let tAv: Avatar | null, dAv: Avatar | null
524
- try { tAv = loadAvatar(target, avatarFile); dAv = loadAvatar(donor, donorAvatarFile) } catch (e) { console.error(`✗ ${(e as Error).message}`); return 1 }
525
- if (tAv && dAv) {
526
- // Humanoids: both avatars joined on the canon (src/retarget/humanoid.ts).
527
- const hm = mapAvatars(tAv, dAv)
528
- log(`🧭 humanoid map ${basename(input)} ← ${basename(donorPath)}`)
529
- for (const [ label, rig, file ] of [ [ "target", target, avatarFile ], [ "donor", donor, donorAvatarFile ] ] as const) {
530
- try { log(` ${label}: ${file ? `avatar from ${basename(file)} · ` : ""}${describeBiped(rig, classifyBiped(rig))}`) } catch { log(` ${label}: avatar from ${basename(file ?? "?")} (shape did not classify)`) }
531
- }
532
- for (const l of describeHumanoidMap(tAv, dAv, hm)) log(l)
533
- humanoid = { target: tAv, donor: dAv }
534
- const { avatarToFile } = await import("./retarget/humanoid")
535
- spec = { _comment: `humanoid map ${basename(input)} ← ${basename(donorPath)} — target and donor avatars joined on the Unity/VRM canon; review before trusting`, root: hm.root, bones: hm.map, legs: hm.legs, twist: tAv.twist, avatars: { target: avatarToFile(tAv), donor: avatarToFile(dAv) } } as MapSpec
536
- } else if (tAv || dAv) {
537
- console.error(`✗ ${tAv ? "the donor" : "the target"} does not read as a humanoid while ${tAv ? "the target" : "the donor"} does — a biped↔quadruped retarget needs a hand-written map`)
538
- return 1
539
- } else {
540
- // Quadrupeds: map from skeleton SHAPE (src/retarget/automap.ts) — a starting point the user reads and corrects.
541
- const { autoMap, describeSkeleton } = await import("./retarget/automap")
542
- const am = autoMap(target, donor)
543
- log(`🧭 auto-map ${basename(input)} ← ${basename(donorPath)}`)
544
- log(` target: ${describeSkeleton(target, am.target)}`)
545
- log(` donor: ${describeSkeleton(donor, am.donor)}`)
546
- for (const l of am.lines) log(l)
547
- spec = { _comment: `auto-mapped ${basename(input)} ← ${basename(donorPath)} by skeleton shape — review before trusting`, root: am.root, bones: am.map, legs: am.legs } as MapSpec
548
- }
549
- if (autoOut !== undefined) {
550
- const out = autoOut[0] ?? input.replace(/\.glb$/i, "") + "-from-" + basename(donorPath).replace(/\.[^.]+$/, "") + ".map.json"
551
- writeFileSync(out, JSON.stringify(spec, null, 2) + "\n")
552
- log(`💾 wrote ${out} (${Object.keys(spec.bones).length} bones) — edit it, then: retarget … --map ${basename(out)}`)
553
- return 0
554
- }
555
- } else {
556
- if (!mapPath) { console.error("✗ --map <map.json> is required (--map auto / --auto-map [out.json] to derive one from the skeletons, --skeletons to list them)"); return 2 }
557
- if (!existsSync(mapPath)) { console.error(`✗ not found: ${mapPath}`); return 1 }
558
- spec = JSON.parse(readFileSync(mapPath, "utf8")) as MapSpec
559
- if (!spec.bones) { console.error(`✗ ${mapPath} has no "bones" object`); return 2 }
560
- if (spec.avatars) {
561
- try { humanoid = { target: avatarFromFile(target, spec.avatars.target), donor: avatarFromFile(donor, spec.avatars.donor) } }
562
- catch (e) { log(` ⚠ ${basename(mapPath)} carries avatars that do not fit these rigs (${(e as Error).message}) — plain map`) }
563
- }
564
- }
565
-
566
- checkUniformScale(target); checkUniformScale(donor)
567
- const t0 = performance.now()
568
- log(`🔁 ${basename(input)} ← ${basename(donorPath)}`)
569
-
570
- // A retarget is only meaningful if both rigs agree which way is forward; measure it from the
571
- // skeleton shape (automap.classify), falling back to the mapped head/tail bones, and turn the
572
- // donor to match.
573
- let donorYawDeg = 0
574
- {
575
- let fa: { forward: number[] } | null = null, da: { forward: number[] } | null = null
576
- try {
577
- // A biped faces where its toes point (its long horizontal axis is the ARM SPAN in a T-pose);
578
- // a quadruped faces along its body.
579
- const { classify } = await import("./retarget/automap")
580
- const facingOf = (rig: import("./retarget/rig").Rig): { forward: number[] } => { try { return classifyBiped(rig) } catch { return classify(rig) } }
581
- fa = facingOf(target); da = facingOf(donor)
582
- } catch {
583
- const spine = Object.entries(spec.bones)
584
- const headPair = spine.find(([ t ]) => /head/i.test(t)), tailPair = spine.find(([ t ]) => /tail|root|hip|pelvis/i.test(t))
585
- if (headPair && tailPair) { fa = analyzeFacing(target, [ headPair[0], tailPair[0] ]); da = analyzeFacing(donor, [ headPair[1], tailPair[1] ]) }
586
- }
587
- if (fa && da) {
588
- const off = facingMismatch(fa.forward as [number, number, number], da.forward as [number, number, number])
589
- // Signed yaw about +Y that turns the donor's forward onto the target's.
590
- const signed = Math.atan2(da.forward[2] * fa.forward[0] - da.forward[0] * fa.forward[2], da.forward[0] * fa.forward[0] + da.forward[2] * fa.forward[2]) * 180 / Math.PI
591
- if (off > 2) donorYawDeg = signed
592
- log(` facing: target [${fa.forward.map((x) => x.toFixed(2))}] vs donor [${da.forward.map((x) => x.toFixed(2))}] — ${off.toFixed(1)}° apart${off > 2 ? ` → turning the donor by ${signed.toFixed(1)}°` : ""}`)
593
- }
594
- }
595
- if (target.bind.scale !== 1) log(` ℹ target skin's bind space is ×${+(1 / target.bind.scale).toPrecision(4)} of node space — bind pose rescaled ×${+target.bind.scale.toPrecision(4)} to match`)
596
- if (donor.bind.scale !== 1) log(` ℹ donor skin's bind space is ×${+(1 / donor.bind.scale).toPrecision(4)} of node space — bind pose rescaled ×${+donor.bind.scale.toPrecision(4)} to match`)
597
-
598
- if (!p.flags.has("--keep-clips")) for (const a of doc.getRoot().listAnimations()) a.dispose()
599
- const prefix = p.values.get("--prefix")?.[0] ?? ""
600
- // --clip-names renames the PRODUCED clips positionally, same convention as `convert`.
601
- const names = p.values.get("--clip-names")
602
- let produced = 0
603
- // The avatar's transfer block (the studio's decisions) supplies the defaults; flags override.
604
- const tr = humanoid?.target.transfer
605
- if (tr && Object.keys(tr).length) {
606
- const parts: string[] = []
607
- if (tr.rootMotion) parts.push(`root motion ${tr.rootMotion}`)
608
- if (tr.plant !== undefined) parts.push(tr.plant ? "plant" : "no plant")
609
- if (tr.hands) parts.push("hands IK")
610
- if (tr.twist !== undefined) parts.push(`twist ${tr.twist}`)
611
- for (const s of [ "Left", "Right" ] as const) if (tr.roll?.[s]) parts.push(`${s[0]} roll ${tr.roll[s]! > 0 ? "+" : ""}${tr.roll[s]}°`)
612
- const keep = Object.entries(tr.align ?? {}).filter(([ , v ]) => v === "keep").map(([ k ]) => k), al = Object.entries(tr.align ?? {}).filter(([ , v ]) => v === "align").map(([ k ]) => k)
613
- if (keep.length) parts.push(`keep ${keep.join(", ")}`)
614
- if (al.length) parts.push(`align ${al.join(", ")}`)
615
- log(` avatar transfer: ${parts.join(" · ")}`)
616
- }
617
- const { stairsMatcher } = await import("./retarget/marks")
618
- const rep = retarget(target, donor, spec.bones, spec.root ?? null, {
619
- align: (p.values.get("--align")?.[0] as "none" | "bone-direction" | undefined) ?? "bone-direction",
620
- alignTo: spec.alignTo,
621
- alignBones: spec.align,
622
- donorYawDeg,
623
- donorRef: (() => { const v = p.values.get("--donor-ref")?.[0]; return v === undefined ? spec.donorRef : v === "bind" || v === "auto" ? v : { clip: v } })(),
624
- ik: p.values.get("--ik")?.[0] === "none" || spec.ik === false ? "none" : p.values.get("--ik")?.[0] === "hands" || (p.values.get("--ik") === undefined && tr?.hands) ? "legs+hands" : "legs",
625
- rootMotion: (p.values.get("--root-motion")?.[0] as import("./retarget/retarget").RootMotion | undefined) ?? tr?.rootMotion ?? "auto",
626
- headingEnd: num(p, "--heading-end"),
627
- rootRotation: (p.values.get("--root-rotation")?.[0] as "none" | "yaw" | undefined) ?? "none",
628
- plant: p.flags.has("--no-plant") ? false : tr?.plant ?? true,
629
- footStance: p.values.get("--foot-stance")?.[0] as "target" | "donor" | undefined,
630
- stairs: stairsMatcher(p.values.get("--stairs")?.[0], p.values.get("--platforms")?.[0]),
631
- twist: (() => { const v = p.values.get("--twist")?.[0]; return v === undefined ? (tr?.twist !== undefined ? (tr.twist > 0 ? tr.twist : false) : 0.5) : v === "off" ? false : Number(v) })(),
632
- legs: spec.legs,
633
- fps: num(p, "--fps"),
634
- epsilon: real(p, "--epsilon"),
635
- bodyClearance: real(p, "--body-clearance"),
636
- epsilonDeg: real(p, "--epsilon-deg"),
637
- rootScale: real(p, "--root-scale"),
638
- clips: p.values.get("--clips"),
639
- rename: names?.length || prefix ? (n: string) => prefix + (names?.[produced++] ?? n) : undefined,
640
- humanoid,
641
- warn: (w) => log(` ⚠ ${w}`),
642
- })
643
- if (humanoid) log(` humanoid: torso/legs keep, arms + fingers aligned (≥ 8°)${rep.roll.length ? ` · palm roll ${rep.roll.map((r) => `${r.side[0]} ${r.degrees.toFixed(0)}° (${r.via})`).join(", ")}` : ""}${rep.rootTranslateOnly ? " · root takes the hips' travel only" : ""}`)
644
- if (rep.bindGap.donor.worst > 1e-3) log(` ℹ donor's node pose sits ${rep.bindGap.donor.worst.toFixed(3)}m from its bind pose (worst: ${rep.bindGap.donor.bone}) — anchoring on the bind pose`)
645
- log(` ${rep.mapped.length} bones mapped · ${rep.align.length} direction-aligned (worst ${rep.align[0]?.degrees.toFixed(0) ?? 0}° on ${rep.align[0]?.bone ?? "-"})${rep.underThreshold.length ? ` · ${rep.underThreshold.length} under 8° kept (worst ${rep.underThreshold[0].degrees.toFixed(0)}° on ${rep.underThreshold[0].bone})` : ""} · ${rep.kept.length} keep bind (${rep.kept.join(", ") || "-"})`)
646
- log(` donor reference: ${rep.donorRef} (leg directions ${rep.legDisagreement.toFixed(0)}° apart between the binds) · target binds ${rep.stance ? "in a stance — legs keep it" : "posed — legs aligned to the donor"}`)
647
- log(` root ${rep.root ? `${rep.root.target}←${rep.root.donor} ×${+rep.rootScale.toPrecision(3)} (from ${rep.rootScaleFrom})` : "none"}${rep.ground ? ` · ground target ${rep.ground.target.toFixed(3)}m / donor ${rep.ground.donor.toFixed(3)}m` : ""}`)
648
- if (rep.legs.length) for (const l of rep.legs) log(` ${l.isFoot ? "🦶" : "✋"} ${l.effector.padEnd(14)} ← ${l.donorEffector.padEnd(12)} chain ${l.chain.join("→")}${l.contact !== l.effector ? ` contact ${l.contact}` : ""} ×${l.ratio.toFixed(3)}${l.planted ? ` planted ${l.planted} frames` : ""}${l.shortfall > 1e-4 ? ` ⚠ out of reach by up to ${(l.shortfall * 1000).toFixed(0)}mm` : ""}`)
649
- else if (p.values.get("--ik")?.[0] !== "none" && spec.ik !== false) log(` ℹ no leg chains detected — feet follow FK only (name them in the map: "legs": [ … ])`)
650
- if (rep.root) log(` root motion: ${rep.rootMotion}${rep.hipsBob ? ` · hips carry the ${rep.hipsBob === "donor" ? "donor's own bob" : "vertical bob"}` : ""}`)
651
- if (rep.twist.length) log(` twist helpers: ${rep.twist.map((t) => `${t.bone} (${(t.at * 100).toFixed(0)}% of ${t.of})`).join(", ")}`)
652
- if (rep.bridges.length) log(` bridged (interpolated between mapped bones): ${rep.bridges.join(", ")}`)
653
- if (rep.unmappedTarget.length) log(` unmapped (left at rest): ${rep.unmappedTarget.join(", ")}`)
654
- for (const c of rep.clips) {
655
- const reach = c.events.filter((e) => e.kind === "reach"), below = c.events.filter((e) => e.kind === "below")
656
- log(` 🎞 ${c.name.padEnd(22)} ${c.duration.toFixed(2)}s ${c.frames} frames → ${c.keys} keys${reach.length ? ` ⚠ ${reach.length}× out of reach (${(Math.max(...reach.map((e) => e.value ?? 0)) * 1000).toFixed(0)} mm)` : ""}${below.length ? ` ⚠ ${below.length}× toe under the ground (${(Math.max(...below.map((e) => e.value ?? 0)) * 1000).toFixed(0)} mm)` : ""}`)
657
- }
658
-
659
- bindAccessors(doc)
660
- const out = p.values.get("-o")?.[0] ?? input.replace(/\.glb$/i, ".retargeted.glb")
661
- const dir = dirname(out)
662
- if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true })
663
- const glb = await writeGlb(doc)
664
- writeFileSync(out, glb)
665
- const { validateGlb, formatReport } = await import("./doctor/validateGlb")
666
- log(formatReport(validateGlb(glb), out))
667
- log(probeDocument(doc, out).split("\n").slice(1).join("\n"))
668
- log(`💾 wrote ${out} (${(glb.byteLength / 1024 / 1024).toFixed(2)} MB, ${((performance.now() - t0) / 1000).toFixed(1)}s)`)
669
- return 0
670
- }
671
-
672
- const cmdAvatar = async (p: Parsed): Promise<number> => {
673
- const input = p.positionals[0]
674
- if (!input) { log(USAGE); return 2 }
675
- if (!existsSync(input)) { console.error(`✗ not found: ${input}`); return 1 }
676
- const { loadRig, rigFromDocument } = await import("./retarget/rig")
677
- const { classifyBiped, avatarFromBiped, avatarFromUnity, avatarToFile, parseUnityAvatar, describeBiped, HUMAN_BONES } = await import("./retarget/humanoid")
678
- const { makeIO } = await import("./fbx/convert")
679
- const rig = /\.uasset$/i.test(input) ? (await import("./unreal/rig")).rigFromUnreal(input, { skeleton: p.values.get("--skeleton")?.[0], contentRoot: p.values.get("--content-root")?.[0] })
680
- : /\.fbx$/i.test(input) ? await loadRig(input) : rigFromDocument(await makeIO().read(input), basename(input))
681
- let shape: ReturnType<typeof avatarFromBiped> | null = null
682
- try {
683
- const b = classifyBiped(rig)
684
- shape = avatarFromBiped(rig, b)
685
- log(`🧍 ${basename(input)}: ${describeBiped(rig, b)}`)
686
- const groups: [string, string[]][] = [
687
- [ "torso", [ "Hips", "Spine", "Chest", "UpperChest", "Neck", "Head", "Jaw", "LeftEye", "RightEye" ] ],
688
- [ "left arm", [ "LeftShoulder", "LeftUpperArm", "LeftLowerArm", "LeftHand" ] ], [ "right arm", [ "RightShoulder", "RightUpperArm", "RightLowerArm", "RightHand" ] ],
689
- [ "left leg", [ "LeftUpperLeg", "LeftLowerLeg", "LeftFoot", "LeftToes" ] ], [ "right leg", [ "RightUpperLeg", "RightLowerLeg", "RightFoot", "RightToes" ] ],
690
- ]
691
- type HB = import("./retarget/humanoid").HumanBone
692
- const av = shape
693
- for (const [ g, keys ] of groups) log(` ${g}: ${keys.filter((k) => av.bones[k as HB]).map((k) => `${k}=${av.bones[k as HB]}`).join(" ")}`)
694
- const fingers = HUMAN_BONES.filter((k) => k.includes(" ") && shape!.bones[k])
695
- if (fingers.length) log(` fingers: ${fingers.length} phalanges (${fingers.filter((k) => k.endsWith("Proximal")).map((k) => shape!.bones[k]).join(", ")})`)
696
- if (Object.keys(shape.twist).length) log(` twist helpers: ${Object.entries(shape.twist).map(([ t, of ]) => `${t}→${of}`).join(", ")}`)
697
- if (shape.bridged.length) log(` bridged: ${shape.bridged.join(", ")}`)
698
- if (b.helpers.length) log(` not part of the body (${b.helpers.length}): ${b.helpers.map((i) => rig.names[i]).slice(0, 16).join(", ")}${b.helpers.length > 16 ? " …" : ""}`)
699
- for (const n of shape.notes) log(` ⚠ ${n}`)
700
- } catch (e) {
701
- log(`✗ ${basename(input)} does not read as a humanoid: ${(e as Error).message}`)
702
- }
703
- const metaPath = p.values.get("--meta")?.[0]
704
- let unity: ReturnType<typeof avatarFromUnity> | null = null
705
- if (metaPath) {
706
- if (!existsSync(metaPath)) { console.error(`✗ not found: ${metaPath}`); return 1 }
707
- unity = avatarFromUnity(rig, parseUnityAvatar(readFileSync(metaPath, "utf8")))
708
- for (const n of unity.notes) log(` ⚠ .meta: ${n}`)
709
- if (shape) {
710
- let agree = 0
711
- const differ: string[] = []
712
- for (const k of HUMAN_BONES) {
713
- const a = shape.bones[k], m = unity.bones[k]
714
- if (a === m) { if (a) agree++; continue }
715
- differ.push(`${k}: shape=${a ?? "-"} .meta=${m ?? "-"}`)
716
- }
717
- log(` vs ${basename(metaPath)}: ${agree} bones agree${differ.length ? `, ${differ.length} differ:` : ""}`)
718
- for (const d of differ) log(` ${d}`)
719
- }
720
- }
721
- const chosen = unity ?? shape
722
- if (!chosen) return 1
723
- const out = p.values.get("-o")?.[0]
724
- if (out) {
725
- writeFileSync(out, JSON.stringify(avatarToFile(chosen), null, 2) + "\n")
726
- log(`💾 wrote ${out} (${Object.keys(chosen.bones).length} bones, ${unity ? "Unity's map" : "from shape"}) — retarget … --avatar ${basename(out)}`)
727
- }
728
- return 0
729
- }
730
-
731
- const cmdMirror = async (p: Parsed): Promise<number> => {
732
- const input = p.positionals[0]
733
- if (!input) { log(USAGE); return 2 }
734
- const { makeIO } = await import("./fbx/convert")
735
- const { mirrorClips } = await import("./retarget/mirror")
736
- const io = makeIO()
737
- const doc = await io.read(input)
738
- const clips = p.values.get("--clips")
739
- const suffix = p.values.get("--suffix")?.[0] ?? "_M"
740
- const axis = p.values.get("--axis")?.[0] as "x" | "y" | "z" | undefined
741
- const rep = mirrorClips(doc, { clips: clips && clips.length ? clips : undefined, rename: (n) => `${n}${suffix}`, axis, log })
742
- log(`== mirror across ${rep.axis.toUpperCase()} = 0: ${rep.pairs.length} bone pairs, ${rep.self.length} on the plane; worst pair mismatch ${(rep.worst.error * 100).toFixed(1)} cm (${rep.worst.bone})`)
743
- if (rep.worst.error > 0.05) log(` ⚠ a pair is off by more than 5 cm — the bind pose is not symmetric, or a bone is paired wrongly`)
744
- if (!rep.clips.length) { log(` ✗ no clip matched`); return 1 }
745
- const output = p.values.get("-o")?.[0] ?? input
746
- await io.write(output, doc)
747
- log(`💾 wrote ${output} (+${rep.clips.length} clips)`)
748
- return 0
749
- }
750
-
751
- const cmdUnpack = async (p: Parsed): Promise<number> => {
752
- const pkg = p.positionals[0]
753
- if (!pkg) { log(USAGE); return 2 }
754
- if (!existsSync(pkg)) { console.error(`✗ not found: ${pkg}`); return 1 }
755
- const { indexUnityPackage, writeUnityIndex, readUnityIndex, extractUnityPackage, INDEX_NAME } = await import("./unity/unpack")
756
- const outDir = p.values.get("-o")?.[0] ?? join(dirname(pkg), basename(pkg, extname(pkg)))
757
- const t0 = performance.now()
758
- const indexFile = join(outDir, INDEX_NAME)
759
- let index
760
- if (existsSync(indexFile)) { index = readUnityIndex(indexFile); log(`📇 ${index.size} assets (cached ${indexFile})`) }
761
- else {
762
- log(`📇 indexing ${pkg} …`)
763
- index = await indexUnityPackage(pkg, (n, a) => process.stdout.write(`\r ${n} entries, ${a} assets`))
764
- process.stdout.write("\r")
765
- writeUnityIndex(outDir, index)
766
- log(` ${index.size} assets → ${indexFile}`)
767
- }
768
- const only = (p.values.get("--only") ?? []).map((r) => new RegExp(r, "i"))
769
- if (p.flags.has("--list")) {
770
- for (const [ , path ] of index) if (!only.length || only.some((r) => r.test(path))) log(path)
771
- return 0
772
- }
773
- log(`📦 extracting${only.length ? ` (${only.length} pattern(s))` : ""} → ${outDir}`)
774
- const r = await extractUnityPackage(pkg, index, outDir, { only, progress: (f, b) => process.stdout.write(`\r ${f} files, ${(b / 1e6).toFixed(0)} MB`) })
775
- process.stdout.write("\r")
776
- log(` ${r.files} files written (${(r.bytes / 1e6).toFixed(1)} MB) of ${r.matched} matching, ${((performance.now() - t0) / 1000).toFixed(0)}s`)
777
- return 0
778
- }
779
-
780
- const cmdProbe = async (p: Parsed): Promise<number> => {
781
- const input = p.positionals[0]
782
- if (!input) { log(USAGE); return 2 }
783
- if (!existsSync(input)) { console.error(`✗ not found: ${input}`); return 1 }
784
- const { probeFile } = await import("./probe")
785
- log(await probeFile(input))
786
- if (p.values.has("--filmstrip")) {
787
- if (extname(input).toLowerCase() === ".fbx") { console.error("✗ --filmstrip wants a GLB (convert first)"); return 2 }
788
- const { renderFilmstrip } = await import("./filmstrip")
789
- const { makeIO } = await import("./fbx/convert")
790
- const doc = await makeIO().read(input)
791
- const only = p.values.get("--clip")?.[0]
792
- const clips = doc.getRoot().listAnimations().map((a) => a.getName()).filter((n) => !only || n === only)
793
- if (only && clips.length === 0) { console.error(`✗ no clip "${only}"`); return 1 }
794
- const stem = p.values.get("--filmstrip")?.[0] ?? input.replace(/\.glb$/i, "")
795
- const targets = clips.length ? clips : [ null ]
796
- const opts = { frames: num(p, "--frames") ?? 6, view: (p.values.get("--view")?.[0] ?? "side") as import("./filmstrip").FilmstripView, cell: num(p, "--cell"), bones: p.flags.has("--bones") }
797
- for (const clip of targets) {
798
- const r = renderFilmstrip(doc, { ...opts, clip })
799
- const file = targets.length === 1 && /\.png$/i.test(stem) ? stem : `${stem.replace(/\.png$/i, "")}.${(clip ?? "rest").replace(/[^\w.-]+/g, "_")}.png`
800
- writeFileSync(file, r.png)
801
- log(`🖼 ${file} ${r.label}`)
802
- }
803
- }
804
- return 0
805
- }
806
-
807
- const cmdScene = async (p: Parsed): Promise<number> => {
808
- const input = p.positionals[0]
809
- if (!input) { log(USAGE); return 2 }
810
- if (!existsSync(input)) { console.error(`✗ not found: ${input}`); return 1 }
811
- const { importUnityScene, describeSceneImport } = await import("./unity/scene")
812
- const regionRaw = p.values.get("--region")?.[0]
813
- let region: [number, number, number, number] | undefined
814
- if (regionRaw !== undefined) {
815
- const v = regionRaw.split(",").map(Number)
816
- if (v.length !== 4 || v.some((x) => !Number.isFinite(x))) { console.error(`✗ --region wants x0,z0,x1,z1 (Unity metres), got "${regionRaw}"`); return 2 }
817
- region = [ Math.min(v[0], v[2]), Math.min(v[1], v[3]), Math.max(v[0], v[2]), Math.max(v[1], v[3]) ]
818
- }
819
- const hand = p.values.get("--handedness")?.[0] ?? "x"
820
- if (![ "x", "z", "none" ].includes(hand)) { console.error(`✗ --handedness wants x | z | none`); return 2 }
821
- const t0 = performance.now()
822
- const r = importUnityScene(input, {
823
- indexFile: p.values.get("--index")?.[0],
824
- handedness: hand as "x" | "z" | "none",
825
- filter: p.values.get("--filter")?.[0] ? new RegExp(p.values.get("--filter")![0], "i") : undefined,
826
- group: p.values.get("--group")?.[0],
827
- region,
828
- includeInactive: p.flags.has("--include-inactive"),
829
- })
830
- log(`🗺 ${input}`)
831
- for (const l of describeSceneImport(r)) log(` ${l}`)
832
- const out = p.values.get("-o")?.[0] ?? input.replace(/\.unity$/i, "") + ".level.json"
833
- const dir = dirname(out)
834
- if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true })
835
- writeFileSync(out, JSON.stringify(r, null, 1) + "\n")
836
- log(`💾 wrote ${out} (${r.instances.length} instances, ${((performance.now() - t0) / 1000).toFixed(1)}s) — frame: ${r.frame}`)
837
- return 0
838
- }
839
-
840
- // Resolve positionals to concrete .glb files — accepts plain files, directories, and globs (*.glb,
841
- // dir/**/*.glb). Generated outputs (.ktx2.glb/.fixed.glb) are skipped when EXPANDING a glob/dir, but
842
- // kept if named explicitly (e.g. to --dump one).
843
- const resolveInputs = (args: string[], ext = /\.glb$/i, skipGenerated = true): string[] => {
844
- const out: string[] = []
845
- const keep = (f: string): void => { if (ext.test(f) && !(skipGenerated && /\.(ktx2|fixed)\.glb$/i.test(f))) out.push(f) }
846
- // minimal glob (runs under Node too — the lecodes CLI is a Node binary): a directory, `dir/*.glb`,
847
- // or `dir/**/*.glb`
848
- const walk = (dir: string, recursive: boolean, pattern: RegExp): void => {
849
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
850
- const p = join(dir, entry.name).replace(/\\/g, "/")
851
- if (entry.isDirectory()) { if (recursive) walk(p, true, pattern) }
852
- else if (pattern.test(entry.name)) keep(p)
853
- }
854
- }
855
- const toRegex = (glob: string): RegExp =>
856
- new RegExp("^" + glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i")
857
- for (const raw of args) {
858
- const a = raw.replace(/\\/g, "/")
859
- if (/[*?]/.test(a)) {
860
- const segs = a.split("/")
861
- const last = segs.pop()!
862
- const recursive = segs[segs.length - 1] === "**"
863
- if (recursive) segs.pop()
864
- const dir = segs.join("/") || "."
865
- if (existsSync(dir)) walk(dir, recursive, toRegex(last))
866
- } else if (existsSync(a) && statSync(a).isDirectory()) {
867
- walk(a.replace(/\/+$/, ""), false, ext)
868
- } else out.push(a)
869
- }
870
- return [ ...new Set(out) ]
871
- }
872
-
873
- const cmdDoctor = async (p: Parsed): Promise<number> => {
874
- const files = resolveInputs(p.positionals)
875
- if (files.length === 0) { log(USAGE); return 2 }
876
- const { analyzeDoc, applyPlan, reportPlan, dumpTextures, outputFor, findToktx, checkToktx, kb } = await import("./doctor/doctor")
877
- const { ensureCodecs, makeIO } = await import("./doctor/reduceGlbBones")
878
- const { validateGlb, formatReport } = await import("./doctor/validateGlb")
879
- const useKtx2 = p.flags.has("--ktx2") || p.flags.has("--ktx2-hq") || p.flags.has("--ktx2-uastc")
880
- const fixMetallic = p.flags.has("--fix-metallic")
881
- const sh = shadingOpts(p)
882
- const sc = scaleOpts(p)
883
- const lmo = lightmapOpts(p)
884
- const lod = lodOpts(p)
885
- const wantsScale = sc.fixScale || sc.scale !== undefined || sc.scaleTo !== undefined
886
- const doFix = p.flags.has("--fix") || useKtx2 || fixMetallic || wantsScale || sh.unlit || sh.fixEmissive || lmo.lightmapUv || lod.lod.length > 0
887
- const dumpMode = p.values.has("--dump")
888
- const dumpVal = p.values.get("--dump")?.[0] ?? null
889
- const oVal = p.values.get("-o")?.[0] ?? null
890
- const opts = { ktx2: useKtx2, ktx2Hq: p.flags.has("--ktx2-hq"), ktx2Uastc: p.flags.has("--ktx2-uastc"), maxSize: num(p, "--max-size"), target: num(p, "--target"), ...sc, ...sh, ...lmo, ...lod }
891
- let toktx = ""
892
- if (useKtx2) {
893
- toktx = findToktx()
894
- const ver = checkToktx(toktx)
895
- if (!ver) { console.error(`✗ toktx not found (looked at $KTX_TOKTX, %LOCALAPPDATA%\\Programs\\KTX-Software, PATH).\n Install KTX-Software or set KTX_TOKTX=<path to toktx.exe>.`); return 2 }
896
- log(` using ${ver}`)
897
- }
898
- await ensureCodecs()
899
- const io = makeIO()
900
- if (files.length > 1) log(`Processing ${files.length} files…`)
901
- let exitCode = 0
902
- for (const input of files) {
903
- if (!existsSync(input)) { console.error(`\n✗ not found: ${input}`); exitCode = 1; continue }
904
- const doc = await io.read(input)
905
- if (dumpMode) { await dumpTextures(doc, dumpVal && files.length === 1 ? dumpVal : input.replace(/\.glb$/i, "-textures")); continue }
906
- const plan = await analyzeDoc(doc, opts)
907
- const rep = reportPlan(plan)
908
- log(`\n🔍 ${input}`)
909
- log(rep.text)
910
- log(` ${rep.lossless} lossless · ${rep.lossy} lossy · ${rep.blocking} blocking`)
911
- if (rep.blocking > 0) exitCode = 1
912
- if (!doFix) { if (rep.lossless + rep.lossy > 0) log(` run with --fix or --ktx2 to apply`); continue }
913
- const res = await applyPlan(doc, plan, { ...opts, fixMetallic, toktx: toktx || undefined, progress: (d, t) => process.stdout.write(`\r encoding ktx2… ${d}/${t}${d === t ? "\n" : ""}`) })
914
- if (res.metallicZeroed) log(` zeroed metallic on ${res.metallicZeroed} material(s)`)
915
- if (res.emissiveCleared) log(` cleared self-lit emissive on ${res.emissiveCleared} material(s)`)
916
- if (res.unlit) log(` ${res.unlit} material(s) → unlit (flat base colour, no lighting)`)
917
- if (res.scaled !== 1) log(` baked scale ×${+res.scaled.toPrecision(6)}`)
918
- if (res.lightmapUv) log(` lightmap UV: ${res.lightmapUv.charts} charts over ${res.lightmapUv.primitives} primitive(s) (xatlas ${res.lightmapUv.width}×${res.lightmapUv.height}); vertices ${res.lightmapUv.verticesBefore} → ${res.lightmapUv.verticesAfter}${res.lightmapUv.lodPrimitives ? `; ${res.lightmapUv.lodPrimitives} LOD primitive(s) took level 0's layout by projection (${res.lightmapUv.lodVertices} vertices)` : ''}`)
919
- if (res.lod) {
920
- for (const t of res.lod.tris) log(` lod: ${t.name} ${t.levels.join(' / ')} tris`)
921
- for (const b of res.lod.bones) log(` lod bones: ${b.name} last level ${b.before} → ${b.after} joints`)
922
- for (const k of res.lod.skipped) log(` lod: ${k.name} level ${k.level} dropped — the simplifier reached only ${k.tris} tris (seams / hard surfaces; try a smaller ratio)`)
923
- if (!res.lod.nodes) log(` lod: no mesh with >= 2000 triangles (or the _LOD nodes are already there)`)
924
- }
925
- const output = outputFor(input, oVal, files.length > 1, useKtx2)
926
- // glTF's top-level `scene` is optional and glTF-Transform only writes it when set — strict
927
- // loaders (OGL: `scenes[undefined]`) then load zero roots and draw nothing. Pin the first scene.
928
- const root = doc.getRoot()
929
- if (!root.getDefaultScene() && root.listScenes().length > 0) root.setDefaultScene(root.listScenes()[0])
930
- await io.write(output, doc)
931
- const beforeBytes = statSync(input).size
932
- const after = readFileSync(output)
933
- log(`💾 wrote ${output} (${kb(beforeBytes)} → ${kb(after.byteLength)})`)
934
- log(formatReport(validateGlb(after), output))
935
- }
936
- return exitCode
937
- }
938
-
939
- const cmdSky = async (p: Parsed): Promise<number> => {
940
- const input = p.positionals[0]
941
- if (!input) { console.error("✗ usage: lecodes-assets sky <env.exr|.hdr> [-o dir] [--size N]"); return 2 }
942
- log(`
943
- 🌤 ${input}`)
944
- try {
945
- const res = await buildSky(input, {
946
- outDir: p.values.get("-o")?.[0],
947
- size: num(p, "--size"),
948
- iblSize: num(p, "--ibl-size"),
949
- skyName: p.values.get("--name")?.[0],
950
- iblName: p.values.get("--ibl-name")?.[0],
951
- iblOnly: p.flags.has("--ibl-only"),
952
- log,
953
- })
954
- log(formatSkyReport(res))
955
- return 0
956
- } catch (e) {
957
- console.error(`✗ ${(e as Error).message}`)
958
- return 1
959
- }
960
- }
961
-
962
- const cmdTerrainPack = async (p: Parsed): Promise<number> => {
963
- if (p.positionals.length === 0) { console.error("✗ usage: lecodes-assets terrain-pack <layer> [<layer> …] [-o dir] [--normals n0 n1 …] [--names a b …] [--size N] [--png] [--flip-y]"); return 2 }
964
- const { resolveTerrainLayer, packTerrainTextures } = await import("./terrainPack")
965
- const normals = p.values.get("--normals") ?? []
966
- const names = p.values.get("--names") ?? []
967
- const outDir = resolve(p.values.get("-o")?.[0] ?? "terrain")
968
- try {
969
- const layers = p.positionals.map((input, i) => {
970
- const n = normals[i]
971
- return resolveTerrainLayer(input, n && n !== "-" ? n : undefined, names[i])
972
- })
973
- log(`\n🏔 ${layers.length} layer(s) → ${outDir}`)
974
- const res = await packTerrainTextures(layers, {
975
- outDir, size: num(p, "--size"), png: p.flags.has("--png"), flipY: p.flags.has("--flip-y"),
976
- quality: num(p, "--quality"), log,
977
- })
978
- log(`\n Terrain.create({ …,\n${res.snippet.split("\n").map((l) => " " + l).join("\n")}\n }) (asset() paths relative to the calling file)`)
979
- return 0
980
- } catch (e) {
981
- console.error(`✗ ${(e as Error).message}`)
982
- return 1
983
- }
984
- }
985
-
986
- const cmdUnreal = async (p: Parsed): Promise<number> => {
987
- const [ sub, target ] = p.positionals
988
- if (!target || (sub !== "ls" && sub !== "tex")) { log(USAGE); return 2 }
989
- if (!existsSync(target)) { console.error(`✗ not found: ${target}`); return 1 }
990
- if (sub === "ls") {
991
- const { scanUnreal, formatUnrealListing } = await import("./unreal/list")
992
- log(formatUnrealListing(target, scanUnreal(target), { all: p.flags.has("--all") }))
993
- return 0
994
- }
995
- // `tex`: the source image out of a Texture2D asset, as stored (--raw) or ready for glTF
996
- const { readPackageFile } = await import("./unreal/index")
997
- const { extractTexture } = await import("./unreal/texture")
998
- const img = await extractTexture(readPackageFile(target))
999
- const t = img.texture
1000
- log(`🖼 ${t.name}: ${t.source.width}×${t.source.height} ${t.source.format} ${t.source.compression} · ${t.compressionSettings}${t.srgb ? " · sRGB" : ""}`)
1001
- let data = img.data, mime = img.mimeType
1002
- if (!p.flags.has("--raw") && img.swapRb) {
1003
- const { fixChannels } = await import("./unreal/materials")
1004
- const fixed = await fixChannels(data, mime, { swapRb: true, flipGreen: p.flags.has("--flip-green"), maxSize: num(p, "--max-size") ?? 0 })
1005
- data = fixed.data; mime = fixed.mimeType
1006
- log(` red/blue swapped back (the source is ${t.source.format})${p.flags.has("--flip-green") ? " · normal green flipped" : ""}`)
1007
- }
1008
- const out = p.values.get("-o")?.[0] ?? target.replace(/\.uasset$/i, mime === "image/png" ? ".png" : ".jpg")
1009
- writeFileSync(out, data)
1010
- log(`💾 wrote ${out} (${(data.byteLength / 1024).toFixed(0)} KB, ${mime})`)
1011
- return 0
1012
- }
1013
-
1014
- export const runAssetsCli = async (argv: string[]): Promise<number> => {
1015
- const [ cmd, ...rest ] = argv
1016
- const p = parse(rest)
1017
- switch (cmd) {
1018
- case "convert": return cmdConvert(p)
1019
- case "probe": return cmdProbe(p)
1020
- case "unpack": return cmdUnpack(p)
1021
- case "doctor": return cmdDoctor(p)
1022
- case "retarget": return cmdRetarget(p)
1023
- case "marks": return cmdMarks(p)
1024
- case "avatar": return cmdAvatar(p)
1025
- case "mirror": return cmdMirror(p)
1026
- case "bvh": return cmdBvh(p)
1027
- case "sky": return cmdSky(p)
1028
- case "terrain-pack": return cmdTerrainPack(p)
1029
- case "scene": return cmdScene(p)
1030
- case "unreal": return cmdUnreal(p)
1031
- default: log(USAGE); return cmd ? 2 : 0
1032
- }
1033
- }
1034
-
1035
- // Direct invocation (`bun src/cli.ts …` / the package bin) — `lecodes assets` imports runAssetsCli instead.
1036
- const isMain = typeof process !== "undefined" && process.argv[1] !== undefined && /cli.(ts|js)$/.test(process.argv[1]) && !process.env.LECODES_ASSETS_NO_MAIN
1037
- if (isMain) {
1038
- runAssetsCli(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(`✗ ${e?.message ?? e}`); process.exit(1) })
1039
- }
1
+ #!/usr/bin/env node
2
+ // lecodes-assets — the LeCodes asset pipeline CLI (also reachable as `lecodes assets …`).
3
+ //
4
+ // A command is DATA (`src/commands/<name>.ts`, listed in `src/commands/index.ts`): its flags are
5
+ // declared once, the argv is parsed against that declaration and every help page is rendered from
6
+ // it (`src/cli-kit`). `lecodes assets` for the command list, `lecodes assets <command> --help` for one.
7
+
8
+ import { runProgram } from "./cli-kit"
9
+ import { program } from "./commands"
10
+
11
+ /** Run one command line (the command first). Resolves to the exit code. */
12
+ export const runAssetsCli = (argv: string[]): Promise<number> => runProgram(program, argv)
13
+
14
+ /** The lightmap unwrap of whole GLB files, in place (src/unwrap) — what `lecodes lightmap bake` runs on the GLBs the app
15
+ * references that have no TEXCOORD_1. Loaded on the first call, so the command line stays light. */
16
+ export const unwrapGlbFiles = async (...args: Parameters<typeof import("./unwrap").unwrapGlbFiles>): ReturnType<typeof import("./unwrap").unwrapGlbFiles> =>
17
+ (await import("./unwrap")).unwrapGlbFiles(...args)
18
+ export type { UnwrapFileOptions, UnwrapFileReport } from "./unwrap"
19
+
20
+ // Direct invocation (`bun src/cli.ts …` / the package bin) — `lecodes assets` imports runAssetsCli instead.
21
+ const isMain = typeof process !== "undefined" && process.argv[1] !== undefined && /cli.(ts|js)$/.test(process.argv[1]) && !process.env.LECODES_ASSETS_NO_MAIN
22
+ if (isMain) {
23
+ runAssetsCli(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(`✗ ${e?.message ?? e}`); process.exit(1) })
24
+ }