lecodes-assets 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +561 -449
- package/dist/cli.js +5632 -521
- package/dist/index.js +5647 -526
- package/package.json +13 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli.ts +1039 -0
- package/src/doctor/doctor.ts +241 -0
- package/src/doctor/lightmapUv.ts +452 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +124 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +109 -0
- package/src/fbx/toDocument.ts +209 -0
- package/src/filmstrip.ts +263 -0
- package/src/index.ts +17 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +171 -0
- package/src/retarget/humanoid.ts +561 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2272 -0
- package/src/retarget/rig.ts +542 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +132 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +151 -0
- package/src/terrainPack.ts +169 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/materials.ts +593 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabs.ts +80 -0
- package/src/unity/scene.ts +363 -0
- package/src/unity/unpack.ts +130 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +114 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materials.ts +291 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +659 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +51 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/texture.ts +202 -0
- package/src/unreal/toDocument.ts +360 -0
- package/src/unreal/versions.ts +100 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,1039 @@
|
|
|
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
|
+
}
|