lecodes-assets 0.2.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +18157 -11441
  3. package/dist/index.js +16603 -9878
  4. package/package.json +3 -1
  5. package/src/cli-kit/command.ts +100 -0
  6. package/src/cli-kit/help.ts +72 -0
  7. package/src/cli-kit/index.ts +4 -0
  8. package/src/cli-kit/parse.ts +72 -0
  9. package/src/cli-kit/run.ts +52 -0
  10. package/src/cli.ts +24 -1039
  11. package/src/commands/avatar.ts +77 -0
  12. package/src/commands/bvh.ts +26 -0
  13. package/src/commands/convert.ts +156 -0
  14. package/src/commands/doctor.ts +79 -0
  15. package/src/commands/geometry.ts +45 -0
  16. package/src/commands/index.ts +24 -0
  17. package/src/commands/marks.ts +38 -0
  18. package/src/commands/mirror.ts +35 -0
  19. package/src/commands/probe.ts +43 -0
  20. package/src/commands/retarget/donor.ts +63 -0
  21. package/src/commands/retarget/index.ts +169 -0
  22. package/src/commands/retarget/map.ts +81 -0
  23. package/src/commands/retarget/report.ts +77 -0
  24. package/src/commands/scene.ts +108 -0
  25. package/src/commands/shared.ts +140 -0
  26. package/src/commands/sky.ts +35 -0
  27. package/src/commands/terrainPack.ts +40 -0
  28. package/src/commands/unpack.ts +43 -0
  29. package/src/commands/unreal.ts +109 -0
  30. package/src/commands/unwrap.ts +56 -0
  31. package/src/doctor/cardTrim.ts +288 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/impostor.ts +614 -0
  34. package/src/doctor/merge/accessors.ts +30 -0
  35. package/src/doctor/merge/atlasLayout.ts +94 -0
  36. package/src/doctor/merge/atlasPixels.ts +114 -0
  37. package/src/doctor/merge/bindSpace.ts +59 -0
  38. package/src/doctor/merge/classify.ts +136 -0
  39. package/src/doctor/merge/geometry.ts +167 -0
  40. package/src/doctor/merge/index.ts +98 -0
  41. package/src/doctor/merge/matrix.ts +60 -0
  42. package/src/doctor/merge/plan.ts +88 -0
  43. package/src/doctor/merge/textures.ts +60 -0
  44. package/src/doctor/merge/types.ts +77 -0
  45. package/src/doctor/transcodeKtx2.ts +186 -31
  46. package/src/doctor/transcodeTextures.ts +6 -1
  47. package/src/doctor/validateGlb.ts +2 -2
  48. package/src/doctor/vertexAo.ts +333 -0
  49. package/src/fbx/read.ts +2 -0
  50. package/src/fbx/toDocument.ts +227 -209
  51. package/src/geometry.ts +222 -0
  52. package/src/index.ts +2 -0
  53. package/src/retarget/heading.ts +238 -171
  54. package/src/retarget/humanoid.ts +572 -561
  55. package/src/retarget/retarget.ts +2375 -2272
  56. package/src/retarget/rig.ts +4 -1
  57. package/src/retarget/writeMarks.ts +134 -132
  58. package/src/sky/cmgen.ts +3 -3
  59. package/src/sky/index.ts +57 -8
  60. package/src/terrainPack.ts +61 -22
  61. package/src/unity/colliderFit.ts +287 -0
  62. package/src/unity/fbxModel.ts +194 -0
  63. package/src/unity/foliage.ts +63 -0
  64. package/src/unity/gameObjects.ts +90 -0
  65. package/src/unity/guid.ts +104 -0
  66. package/src/unity/image.ts +58 -0
  67. package/src/unity/kindGlb.ts +364 -0
  68. package/src/unity/lecodesLevel.ts +414 -0
  69. package/src/unity/levelTerrain.ts +125 -0
  70. package/src/unity/matFile.ts +179 -0
  71. package/src/unity/materialSlots.ts +53 -0
  72. package/src/unity/materialWriter.ts +316 -0
  73. package/src/unity/materials.ts +34 -429
  74. package/src/unity/math.ts +82 -0
  75. package/src/unity/prefabTree.ts +285 -0
  76. package/src/unity/prefabs.ts +10 -36
  77. package/src/unity/propertyPath.ts +43 -0
  78. package/src/unity/scene.ts +339 -304
  79. package/src/unity/serialized.ts +242 -0
  80. package/src/unity/terrain.ts +221 -0
  81. package/src/unity/triBudget.ts +77 -0
  82. package/src/unity/triplanar.ts +122 -0
  83. package/src/unity/unityMesh.ts +151 -0
  84. package/src/unity/unpack.ts +24 -5
  85. package/src/unity/yaml.ts +131 -0
  86. package/src/unreal/index.ts +6 -5
  87. package/src/unreal/materialGraph.ts +224 -0
  88. package/src/unreal/materials.ts +117 -9
  89. package/src/unreal/package.ts +30 -12
  90. package/src/unreal/resolve.ts +6 -4
  91. package/src/unreal/sound.ts +128 -0
  92. package/src/unreal/texture.ts +70 -3
  93. package/src/unreal/toDocument.ts +7 -0
  94. package/src/unreal/versions.ts +8 -0
  95. package/src/unwrap/index.ts +167 -0
  96. package/src/unwrap/lightmapUv.ts +761 -0
  97. package/src/wasm/fbxReader.js +1 -1
  98. package/src/wasm/lightmapUv.js +1 -1
  99. package/src/doctor/lightmapUv.ts +0 -452
@@ -0,0 +1,77 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { basename } from "node:path"
3
+ import { defineCommand, missing, notFound, str } from "../cli-kit"
4
+ import type { HumanBone } from "../retarget/humanoid"
5
+ import { log, unrealFlags } from "./shared"
6
+
7
+ const GROUPS: [string, HumanBone[]][] = [
8
+ [ "torso", [ "Hips", "Spine", "Chest", "UpperChest", "Neck", "Head", "Jaw", "LeftEye", "RightEye" ] ],
9
+ [ "left arm", [ "LeftShoulder", "LeftUpperArm", "LeftLowerArm", "LeftHand" ] ], [ "right arm", [ "RightShoulder", "RightUpperArm", "RightLowerArm", "RightHand" ] ],
10
+ [ "left leg", [ "LeftUpperLeg", "LeftLowerLeg", "LeftFoot", "LeftToes" ] ], [ "right leg", [ "RightUpperLeg", "RightLowerLeg", "RightFoot", "RightToes" ] ],
11
+ ]
12
+
13
+ export default defineCommand({
14
+ name: "avatar",
15
+ summary: "A humanoid rig's AVATAR: its bones named in the Unity/VRM canon, read off the skeleton's shape",
16
+ usage: "<model.fbx|.glb|.uasset>",
17
+ description: `
18
+ Hips = the joint two ground-reaching chains hang from, hands = the lateral joints fanning into
19
+ fingers, head = the last branch above the arms; twist/roll helpers recorded, not mapped.
20
+
21
+ --meta checks the result against Unity's own humanoid map (and imports it verbatim when you trust
22
+ it more: -o with --meta writes Unity's). The file is what \`retarget --avatar\` reads.`,
23
+ flags: {
24
+ meta: str("Unity's humanoid map to compare with / import", { value: "model.fbx.meta" }),
25
+ o: str("write the avatar", { value: "model.avatar.json" }),
26
+ ...unrealFlags,
27
+ },
28
+ run: async ({ args, flags }) => {
29
+ const input = args[0]
30
+ if (!input) throw missing("the model file")
31
+ if (!existsSync(input)) throw notFound(input)
32
+ const { loadRig, rigFromDocument } = await import("../retarget/rig")
33
+ const { classifyBiped, avatarFromBiped, avatarFromUnity, avatarToFile, parseUnityAvatar, describeBiped, HUMAN_BONES } = await import("../retarget/humanoid")
34
+ const { makeIO } = await import("../fbx/convert")
35
+ const rig = /\.uasset$/i.test(input) ? (await import("../unreal/rig")).rigFromUnreal(input, { skeleton: flags.skeleton, contentRoot: flags["content-root"] })
36
+ : /\.fbx$/i.test(input) ? await loadRig(input) : rigFromDocument(await makeIO().read(input), basename(input))
37
+ let shape: ReturnType<typeof avatarFromBiped> | null = null
38
+ try {
39
+ const b = classifyBiped(rig)
40
+ const av = shape = avatarFromBiped(rig, b)
41
+ log(`🧍 ${basename(input)}: ${describeBiped(rig, b)}`)
42
+ for (const [ g, keys ] of GROUPS) log(` ${g}: ${keys.filter((k) => av.bones[k]).map((k) => `${k}=${av.bones[k]}`).join(" ")}`)
43
+ const fingers = HUMAN_BONES.filter((k) => k.includes(" ") && av.bones[k])
44
+ if (fingers.length) log(` fingers: ${fingers.length} phalanges (${fingers.filter((k) => k.endsWith("Proximal")).map((k) => av.bones[k]).join(", ")})`)
45
+ if (Object.keys(av.twist).length) log(` twist helpers: ${Object.entries(av.twist).map(([ t, of ]) => `${t}→${of}`).join(", ")}`)
46
+ if (av.bridged.length) log(` bridged: ${av.bridged.join(", ")}`)
47
+ 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 ? " …" : ""}`)
48
+ for (const n of av.notes) log(` ⚠ ${n}`)
49
+ } catch (e) {
50
+ log(`✗ ${basename(input)} does not read as a humanoid: ${(e as Error).message}`)
51
+ }
52
+ let unity: ReturnType<typeof avatarFromUnity> | null = null
53
+ if (flags.meta) {
54
+ if (!existsSync(flags.meta)) throw notFound(flags.meta)
55
+ unity = avatarFromUnity(rig, parseUnityAvatar(readFileSync(flags.meta, "utf8")))
56
+ for (const n of unity.notes) log(` ⚠ .meta: ${n}`)
57
+ if (shape) {
58
+ let agree = 0
59
+ const differ: string[] = []
60
+ for (const k of HUMAN_BONES) {
61
+ const a = shape.bones[k], m = unity.bones[k]
62
+ if (a === m) { if (a) agree++; continue }
63
+ differ.push(`${k}: shape=${a ?? "-"} .meta=${m ?? "-"}`)
64
+ }
65
+ log(` vs ${basename(flags.meta)}: ${agree} bones agree${differ.length ? `, ${differ.length} differ:` : ""}`)
66
+ for (const d of differ) log(` ${d}`)
67
+ }
68
+ }
69
+ const chosen = unity ?? shape
70
+ if (!chosen) return 1
71
+ if (flags.o) {
72
+ writeFileSync(flags.o, JSON.stringify(avatarToFile(chosen), null, 2) + "\n")
73
+ log(`💾 wrote ${flags.o} (${Object.keys(chosen.bones).length} bones, ${unity ? "Unity's map" : "from shape"}) — retarget … --avatar ${basename(flags.o)}`)
74
+ }
75
+ return 0
76
+ },
77
+ })
@@ -0,0 +1,26 @@
1
+ import { existsSync, writeFileSync } from "node:fs"
2
+ import { defineCommand, int, missing, notFound, str } from "../cli-kit"
3
+ import { log } from "./shared"
4
+
5
+ export default defineCommand({
6
+ name: "bvh",
7
+ summary: "One clip of a GLB as a Kimodo-style BVH",
8
+ usage: "<model.glb> -o out.bvh",
9
+ flags: {
10
+ clip: str("the clip (default: the first)", { value: "<name>" }),
11
+ fps: int("sampling rate (30)"),
12
+ o: str("output (required)", { value: "out.bvh" }),
13
+ },
14
+ run: async ({ args, flags }) => {
15
+ const input = args[0]
16
+ if (!input) throw missing("the GLB")
17
+ if (!flags.o) throw missing("-o out.bvh")
18
+ if (!existsSync(input)) throw notFound(input)
19
+ const { makeIO } = await import("../fbx/convert")
20
+ const { documentToBvh } = await import("../bvh/fromDocument")
21
+ const r = documentToBvh(await makeIO().read(input), { clip: flags.clip, fps: flags.fps })
22
+ writeFileSync(flags.o, r.text)
23
+ log(`💾 wrote ${flags.o} ("${r.report.clip}": ${r.report.joints} joints, ${r.report.frames} frames @ ${r.report.fps} fps)`)
24
+ return 0
25
+ },
26
+ })
@@ -0,0 +1,156 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { basename, dirname, extname, join } from "node:path"
3
+ import type { Document } from "@gltf-transform/core"
4
+ import { bool, defineCommand, int, list, missing, notFound, CliError, str } from "../cli-kit"
5
+ import { doctorFlags, doctorOptions, ktx2Progress, log, logDoctorResult, megabytes, resolveInputs, seconds, unrealFlags, wantsDoctorFix, warnLine } from "./shared"
6
+
7
+ type ClipReport = { clips: { name: string, source: string, duration: number, tracks: number, bound: number }[] }
8
+
9
+ export default defineCommand({
10
+ name: "convert",
11
+ summary: "FBX, an existing GLB, an UNREAL asset or a BVH → a LeCodes-ready GLB",
12
+ usage: "<model.fbx|.glb|.uasset|.bvh>",
13
+ flags: {
14
+ o: str("output (default: <model>.glb next to the input)", { value: "out.glb" }),
15
+ clips: list("merge these files' animations into the model, bound by BONE NAME (FBX or Unreal AnimSequence; globs and folders expand)", { value: "a.fbx b.uasset …", group: "Clips" }),
16
+ "clip-names": list("names for --clips (default: stack name, or the file name when generic)", { value: "n1 n2 …" }),
17
+ "drop-model-clips": bool("drop the model file's own clips (e.g. Mixamo's 1-frame T-pose)"),
18
+ rename: list("bone name overrides for clip binding", { value: "clipBone=modelBone …" }),
19
+ "merge-skins": list("modular characters: move these files' SKINNED meshes onto the model's skeleton (joints matched by name), drop their skeleton copies", { value: "b.fbx c.glb …" }),
20
+ "unity-mats": str("Unity .mat files are read for the REAL textures (metallic / roughness / ORM / emission / alpha mode) when they sit next to the FBX — point elsewhere with this", { value: "<dir>", group: "Unity materials" }),
21
+ "no-unity": bool("…or switch it off"),
22
+ "unity-index": str("GUID → path table (default: found above the FBX, else .meta files)", { value: "<unity-index.txt>" }),
23
+ ...unrealFlags,
24
+ "lod-level": int("(Unreal) which mesh LOD to read (default 0)"),
25
+ "no-textures": bool("(Unreal) keep the grey material slots instead of pulling the pack's textures (they need an Oodle library — see the README)"),
26
+ "no-normal-flip": bool("(Unreal) keep normal maps DirectX-style (UE authors them green-down; glTF wants green-up, so they are flipped)"),
27
+ "list-nodes": int("print the scene's nodes (multi-variant prop files) and exit", { value: "depth", bare: 1, group: "Multi-variant prop files" }),
28
+ keep: list("keep only these nodes (+ their subtrees), re-origin the result (footprint centre at X/Z = 0, lowest point at Y = 0)", { value: "<node> …" }),
29
+ "no-ground": bool("…keep the file's own placement instead"),
30
+ split: str("one GLB per top-level node, each re-origined (default: <out>/)", { value: "dir", bare: "" }),
31
+ ...doctorFlags,
32
+ },
33
+ examples: [
34
+ "lecodes assets convert hero.fbx --clips anims/*.fbx --drop-model-clips -o hero.glb",
35
+ "lecodes assets convert props.fbx --list-nodes 2",
36
+ "lecodes assets convert gun.fbx --fix --ktx2-uastc --scale-to 0.9",
37
+ ],
38
+ run: async ({ args, flags, given }) => {
39
+ const input = args[0]
40
+ if (!input) throw missing("the model file")
41
+ if (!existsSync(input)) throw notFound(input)
42
+ const { convertFbx, mergeClipScene, loadClipSource, makeIO, writeGlb } = await import("../fbx/convert")
43
+ const clips = resolveInputs(flags.clips, /\.(fbx|glb|uasset)$/i, false)
44
+ const clipNames = flags["clip-names"].length ? flags["clip-names"] : undefined
45
+ const rename: Record<string, string> = {}
46
+ for (const r of flags.rename) { const [ a, b ] = r.split("="); if (a && b) rename[a] = b }
47
+ const t0 = performance.now()
48
+ log(`🔄 ${input}${clips.length ? ` + ${clips.length} clip file(s)` : ""}`)
49
+
50
+ const unrealOpts = { skeleton: flags.skeleton, contentRoot: flags["content-root"], lod: flags["lod-level"] }
51
+ const unity = { unity: flags["no-unity"] ? false as const : (flags["unity-mats"] ?? true), unityIndex: flags["unity-index"] }
52
+ const ext = extname(input).toLowerCase()
53
+ let doc: Document
54
+ let report: ClipReport = { clips: [] }
55
+ if (ext === ".uasset") {
56
+ // Unreal: a SkeletalMesh → mesh + skin + skeleton; a Skeleton or an AnimSequence → that
57
+ // skeleton (resolved from the pack for a clip) plus, for a clip, its own animation.
58
+ const { convertUnreal } = await import("../unreal/convertAsset")
59
+ const r = await convertUnreal(input, { clips, clipNames, rename, dropModelClips: flags["drop-model-clips"], warn: warnLine, ...unrealOpts, textures: !flags["no-textures"], maxSize: flags["max-size"], flipNormalGreen: !flags["no-normal-flip"] })
60
+ doc = r.doc; report = r.report
61
+ log(` ${r.info}`)
62
+ for (const s of r.textures?.slots ?? []) {
63
+ if (!s.material) continue
64
+ 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(", ")}` : ""}`)
65
+ }
66
+ } else if (ext === ".bvh") {
67
+ // BVH (mocap, Kimodo's SOMA export): a skeleton-only rig — nodes, a skin with inverse binds, no
68
+ // mesh — plus the file's motion as one clip (named by --clip-names, else the file's stem)
69
+ const { bvhToDocument, describeBvh } = await import("../bvh/toDocument")
70
+ const r = bvhToDocument(readFileSync(input, "utf8"), { name: clipNames?.[0] ?? basename(input).replace(/\.bvh$/i, "") })
71
+ doc = r.doc
72
+ log(` ${describeBvh(r.report)}`)
73
+ } else if (ext === ".fbx") {
74
+ const r = await convertFbx(input, { clips, clipNames, rename, dropModelClips: flags["drop-model-clips"], warn: warnLine, ...unrealOpts, ...unity })
75
+ doc = r.doc; report = r.report
76
+ if (r.report.unity) {
77
+ const { describeUnityReport } = await import("../unity/materials")
78
+ for (const l of describeUnityReport(r.report.unity)) log(` ${l}`)
79
+ if (r.report.unity.applied.length && r.report.unity.resolver) log(` ℹ textures resolved via ${r.report.unity.resolver}`)
80
+ }
81
+ } else {
82
+ doc = await makeIO().read(input)
83
+ for (let i = 0; i < clips.length; i++) report.clips.push(...mergeClipScene(doc, await loadClipSource(clips[i], unrealOpts), basename(clips[i]), clipNames?.[i], rename, warnLine))
84
+ }
85
+ for (const c of report.clips) log(` 🎞 ${c.name.padEnd(24)} ${c.duration.toFixed(2)}s ${c.bound}/${c.tracks} tracks (${c.source})`)
86
+
87
+ // --merge-skins: modular pieces (shirt, gloves, boots…) onto this model's skeleton
88
+ if (flags["merge-skins"].length) {
89
+ const { mergeSkins } = await import("../mergeSkins")
90
+ const donors = []
91
+ for (const f of flags["merge-skins"]) {
92
+ if (!existsSync(f)) throw notFound(f)
93
+ const d = extname(f).toLowerCase() === ".fbx" ? (await convertFbx(f, { dropModelClips: true, warn: warnLine, ...unity })).doc : await makeIO().read(f)
94
+ donors.push({ doc: d, label: basename(f) })
95
+ }
96
+ const mr = await mergeSkins(doc, donors, warnLine)
97
+ 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"})` : ""}`)
98
+ }
99
+
100
+ // multi-variant prop files: list / keep / split
101
+ const ground = !flags["no-ground"]
102
+ if (given.has("list-nodes")) {
103
+ const { listNodes } = await import("../props")
104
+ const depth = flags["list-nodes"] || 1
105
+ log(` nodes (depth ${depth}) — pick with --keep <name>:`)
106
+ 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"}`)
107
+ return 0
108
+ }
109
+ if (flags.keep.length) {
110
+ const { keepNodes } = await import("../props")
111
+ const r = await keepNodes(doc, flags.keep, { ground })
112
+ if (r.missing.length) throw new CliError(`node(s) not in the file: ${r.missing.join(", ")} (--list-nodes prints them)`)
113
+ log(` ✂ kept ${r.kept.join(", ")} · ${r.size.map((v) => v.toFixed(2)).join("×")} m${ground ? ` · re-origined (moved ${r.offset.map((v) => v.toFixed(2)).join(",")})` : ""}`)
114
+ }
115
+
116
+ if (wantsDoctorFix(flags)) {
117
+ const { analyzeDoc, applyPlan, reportPlan } = await import("../doctor/doctor")
118
+ const { ensureCodecs } = await import("../doctor/reduceGlbBones")
119
+ await ensureCodecs()
120
+ const opts = doctorOptions(flags)
121
+ const plan = await analyzeDoc(doc, opts)
122
+ log(reportPlan(plan).text)
123
+ logDoctorResult(await applyPlan(doc, plan, { ...opts, fixMetallic: flags["fix-metallic"], progress: ktx2Progress }))
124
+ }
125
+
126
+ const stem = join(dirname(input), basename(input, extname(input)))
127
+ const out = flags.o ?? `${stem}.glb`
128
+ const { validateGlb, formatReport } = await import("../doctor/validateGlb")
129
+ if (given.has("split")) {
130
+ // one GLB per top-level node (the doctor passes above already ran on the whole document)
131
+ const { splitDoc, fileStem } = await import("../props")
132
+ const dir = flags.split || (flags.o ? out.replace(/\.glb$/i, "") : stem)
133
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
134
+ const parts = await splitDoc(doc, { ground })
135
+ let total = 0
136
+ for (const part of parts) {
137
+ const f = join(dir, fileStem(part.name) + ".glb")
138
+ const glb = await writeGlb(part.doc)
139
+ writeFileSync(f, glb)
140
+ total += glb.byteLength
141
+ log(` ${validateGlb(glb).ok ? "✅" : "✗"} ${basename(f).padEnd(40)} ${part.size.map((x) => x.toFixed(2)).join("×")} m ${(glb.byteLength / 1024).toFixed(0)} KB`)
142
+ }
143
+ log(`💾 wrote ${parts.length} file(s) → ${dir} (${megabytes(total)}, ${seconds(t0)})`)
144
+ return 0
145
+ }
146
+ const glb = await writeGlb(doc)
147
+ const dir = dirname(out)
148
+ if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true })
149
+ writeFileSync(out, glb)
150
+ const { probeDocument } = await import("../probe")
151
+ log(formatReport(validateGlb(glb), out))
152
+ log(probeDocument(doc, out).split("\n").slice(1).join("\n"))
153
+ log(`💾 wrote ${out} (${megabytes(glb.byteLength)}, ${seconds(t0)})`)
154
+ return 0
155
+ },
156
+ })
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs"
2
+ import { bool, CliError, defineCommand, int, missing, str } from "../cli-kit"
3
+ import { doctorFlags, doctorOptions, impostorFlag, ktx2Progress, log, logDoctorResult, resolveInputs, wantsDoctorFix, wantsKtx2 } from "./shared"
4
+
5
+ export default defineCommand({
6
+ name: "doctor",
7
+ summary: "Analyze / fix a GLB against creator-gl limits (bone cap, textures, KTX2, scale, shading, merge, LOD)",
8
+ usage: `<in.glb | dir | "*.glb"> …`,
9
+ description: `
10
+ Without a fixing flag the file is only REPORTED on (lossless / lossy / blocking findings; exit 1
11
+ when something blocks). Any flag that changes the file writes <in>.fixed.glb / <in>.ktx2.glb, or -o.`,
12
+ flags: {
13
+ o: str("output file (one input) or folder (several)", { value: "out" }),
14
+ dump: str("write the file's textures out instead (default: <in>-textures/)", { value: "dir", bare: "" }),
15
+ ...doctorFlags,
16
+ lod: str("add mesh LOD levels (<name>_LOD1… siblings at these triangle ratios, same skin; >= 2000-tri meshes only) — creator-gl shows one by screen size (docs/lod-plan.md)", { value: "0.5,0.2", group: "LOD" }),
17
+ "lod-bones": bool("fold fingers / toes / twists onto their parents in the last level's skin"),
18
+ impostor: str("bake the level-0 geometry into an impostor atlas (albedo + hull normal / AO) and add it as a LOD level, one camera-facing quad (<name>_LOD<k>, k = --impostor-level, never past the first missing level; the levels from k up are replaced). PNG / JPEG base colours only — run it with --ktx2, not after it; the atlases skip --max-size. <A>x<E>[@<px>] = A azimuths × E elevation rows (0°, 90°/E, …; 8x2@256), oct<N>[@<px>] = the N×N hemi-octahedron (steep views too); bare = 12x3; px = a frame's pixels (default 256 for a plant ≥ 8 m, 128 under)", { value: "<A>x<E>[@px]|oct<N>[@px]", bare: "12x3" }),
19
+ "impostor-level": int("the LOD slot the quad takes (default 2: shown under 0.12 of the viewport height; 1 = under 0.30)"),
20
+ },
21
+ examples: [
22
+ "lecodes assets doctor hero.glb",
23
+ "lecodes assets doctor hero.glb --fix --ktx2",
24
+ `lecodes assets doctor "props/**/*.glb" --ktx2 --max-size 1024 -o out/`,
25
+ ],
26
+ run: async ({ args, flags, given }) => {
27
+ const files = resolveInputs(args)
28
+ if (files.length === 0) throw missing("the GLB file(s)")
29
+ const { analyzeDoc, applyPlan, reportPlan, dumpTextures, outputFor, findKtxEncoder, KTX_ENCODER_MISSING, kb } = await import("../doctor/doctor")
30
+ const { ensureCodecs, makeIO } = await import("../doctor/reduceGlbBones")
31
+ const { validateGlb, formatReport } = await import("../doctor/validateGlb")
32
+ const useKtx2 = wantsKtx2(flags)
33
+ const lod = (flags.lod ?? "").split(",").map(Number).filter((n) => n > 0 && n < 1)
34
+ const impostor = impostorFlag(flags.impostor, flags["impostor-level"])
35
+ const doFix = wantsDoctorFix(flags) || flags["fix-metallic"] || lod.length > 0 || !!impostor
36
+ const opts = { ...doctorOptions(flags), lod, lodBones: flags["lod-bones"] }
37
+ const ktxEncoder = useKtx2 ? findKtxEncoder() : null
38
+ if (useKtx2) {
39
+ if (!ktxEncoder) throw new CliError(KTX_ENCODER_MISSING, 2)
40
+ log(` using ${ktxEncoder.version}${ktxEncoder.kind === "ktx" ? " (ktx create)" : ""}`)
41
+ }
42
+ await ensureCodecs()
43
+ const io = makeIO()
44
+ if (files.length > 1) log(`Processing ${files.length} files…`)
45
+ let exitCode = 0
46
+ for (const input of files) {
47
+ if (!existsSync(input)) { console.error(`\n✗ not found: ${input}`); exitCode = 1; continue }
48
+ const doc = await io.read(input)
49
+ if (given.has("dump")) { await dumpTextures(doc, flags.dump && files.length === 1 ? flags.dump : input.replace(/\.glb$/i, "-textures")); continue }
50
+ // before the analysis: the plan then sees the atlases (KTX2 encodes them) and not the levels the quad replaced
51
+ if (impostor) {
52
+ const { bakeImpostor, impostorLabel } = await import("../doctor/impostor")
53
+ try {
54
+ const imp = await bakeImpostor(doc, { ...impostor, log })
55
+ if (imp) log(` impostor: ${imp.node} · ${impostorLabel(imp)} · r ${imp.radius.toFixed(2)} m · ${imp.triangles} triangles in ${imp.ms} ms`)
56
+ else log(` impostor: no level-0 triangles in ${input}`)
57
+ } catch (e) { console.error(`\n✗ ${input}: ${(e as Error).message}`); exitCode = 1; continue }
58
+ }
59
+ const plan = await analyzeDoc(doc, opts)
60
+ const rep = reportPlan(plan)
61
+ log(`\n🔍 ${input}`)
62
+ log(rep.text)
63
+ log(` ${rep.lossless} lossless · ${rep.lossy} lossy · ${rep.blocking} blocking`)
64
+ if (rep.blocking > 0) exitCode = 1
65
+ if (!doFix) { if (rep.lossless + rep.lossy > 0) log(` run with --fix or --ktx2 to apply`); continue }
66
+ logDoctorResult(await applyPlan(doc, plan, { ...opts, fixMetallic: flags["fix-metallic"], ktxEncoder: ktxEncoder ?? undefined, progress: ktx2Progress }))
67
+ const output = outputFor(input, flags.o ?? null, files.length > 1, useKtx2)
68
+ // glTF's top-level `scene` is optional and glTF-Transform only writes it when set — strict
69
+ // loaders (OGL: `scenes[undefined]`) then load zero roots and draw nothing. Pin the first scene.
70
+ const root = doc.getRoot()
71
+ if (!root.getDefaultScene() && root.listScenes().length > 0) root.setDefaultScene(root.listScenes()[0])
72
+ await io.write(output, doc)
73
+ const after = readFileSync(output)
74
+ log(`💾 wrote ${output} (${kb(statSync(input).size)} → ${kb(after.byteLength)})`)
75
+ log(formatReport(validateGlb(after), output))
76
+ }
77
+ return exitCode
78
+ },
79
+ })
@@ -0,0 +1,45 @@
1
+ import { existsSync, writeFileSync } from "node:fs"
2
+ import { basename, extname } from "node:path"
3
+ import { defineCommand, int, missing, notFound, num, str, usageError } from "../cli-kit"
4
+ import { log } from "./shared"
5
+
6
+ export default defineCommand({
7
+ name: "geometry",
8
+ summary: "A GLB's triangles as ONE plain vertex buffer for the SDK's `Geometry`",
9
+ usage: "<model.glb>",
10
+ description: `
11
+ \`Geometry.load(asset('x.geometry.json'))\` — debris the particle system draws per particle (shell
12
+ casings, rubble, coins). Every mesh node's world transform is baked in, primitives merged;
13
+ materials, skins and clips are dropped. <= 65535 vertices (uint16 indices).`,
14
+ flags: {
15
+ node: str("only this node's subtree (--list-nodes prints them)", { value: "<name>" }),
16
+ origin: str("re-origin: bounds centre at 0 / footprint centre at X/Z = 0, lowest at Y = 0", { value: "center|bottom" }),
17
+ scale: num("uniform scale after the origin move (cm-modelled → 0.01)", { positive: true }),
18
+ o: str("output (default: <model>.geometry.json next to the input)", { value: "out.geometry.json" }),
19
+ "list-nodes": int("print the nodes and exit", { value: "depth", bare: 1 }),
20
+ },
21
+ run: async ({ args, flags, given }) => {
22
+ const input = args[0]
23
+ if (!input) throw missing("the GLB")
24
+ if (!existsSync(input)) throw notFound(input)
25
+ if (extname(input).toLowerCase() !== ".glb") throw usageError("geometry wants a GLB (convert first)")
26
+ const { makeIO } = await import("../fbx/convert")
27
+ const doc = await makeIO().read(input)
28
+ if (given.has("list-nodes")) {
29
+ const { listNodes } = await import("../props")
30
+ for (const r of listNodes(doc, flags["list-nodes"] ?? 1)) log(`${" ".repeat(r.depth)}${r.name} ${r.meshes} mesh(es) · ${r.tris} tris · ${r.size.map((v) => v.toFixed(3)).join("×")} m`)
31
+ return 0
32
+ }
33
+ const origin = flags.origin
34
+ if (origin !== undefined && origin !== "center" && origin !== "bottom") throw usageError(`--origin wants center or bottom, got "${origin}"`)
35
+ const { extractGeometry, formatGeometryFile } = await import("../geometry")
36
+ const { data, report } = extractGeometry(doc, { node: flags.node, origin, scale: flags.scale })
37
+ data.source = { file: basename(input), nodes: report.nodes }
38
+ const out = flags.o ?? input.replace(/\.glb$/i, "") + ".geometry.json"
39
+ const text = formatGeometryFile(data)
40
+ writeFileSync(out, text)
41
+ for (const w of report.warnings) log(`⚠ ${w}`)
42
+ log(`💾 wrote ${out} (${(text.length / 1024).toFixed(1)} KB · ${report.vertices} verts / ${report.triangles} tris · ${report.size.map((v) => (v * 1000).toFixed(1)).join("×")} mm · nodes: ${report.nodes.join(", ")})`)
43
+ return 0
44
+ },
45
+ })
@@ -0,0 +1,24 @@
1
+ // Every command, in the order the help lists them. A command is one file here (a folder when it has
2
+ // helpers of its own); the flags several share live in shared.ts.
3
+
4
+ import type { Program } from "../cli-kit"
5
+ import avatar from "./avatar"
6
+ import bvh from "./bvh"
7
+ import convert from "./convert"
8
+ import doctor from "./doctor"
9
+ import geometry from "./geometry"
10
+ import marks from "./marks"
11
+ import mirror from "./mirror"
12
+ import probe from "./probe"
13
+ import retarget from "./retarget"
14
+ import scene from "./scene"
15
+ import sky from "./sky"
16
+ import terrainPack from "./terrainPack"
17
+ import unpack from "./unpack"
18
+ import unreal from "./unreal"
19
+ import unwrap from "./unwrap"
20
+
21
+ export const program: Program = {
22
+ title: "lecodes-assets — the LeCodes asset pipeline: FBX / Unreal / Unity → GLB, the GLB doctor, retargeting",
23
+ commands: [ convert, probe, doctor, unwrap, retarget, avatar, mirror, marks, bvh, geometry, unpack, scene, unreal, sky, terrainPack ],
24
+ }
@@ -0,0 +1,38 @@
1
+ import { existsSync, writeFileSync } from "node:fs"
2
+ import { bool, defineCommand, int, list, missing, notFound, num, str } from "../cli-kit"
3
+ import { log, seconds } from "./shared"
4
+
5
+ export default defineCommand({
6
+ name: "marks",
7
+ summary: "Measure every clip's FOOT MARKS and write them into the GLB (extras.lecodes.contacts)",
8
+ usage: "<model.glb>",
9
+ description: `
10
+ Touch spans, still windows, lift-offs, the staircase of a stair clip — measured on the model's
11
+ own skeleton. For packs that are not retargeted; \`retarget\` writes the same block itself.`,
12
+ flags: {
13
+ stairs: str(`clips to fit a STAIRCASE to (default: names containing "stair")`, { value: "<regex>|all|none" }),
14
+ platforms: str(`clips with a PLATFORM: two levels, the floor and the top (default "platform")`, { value: "<regex>|all|none" }),
15
+ fps: int("sampling rate"),
16
+ clips: list("only these clips", { value: "a b …" }),
17
+ "heading-end": num("the baked heading curve must END here", { value: "DEG" }),
18
+ loop: bool("the --clips are LOOPS (last frame = first): the heading is smoothed periodically"),
19
+ o: str("output (default: in place)", { value: "out.glb" }),
20
+ },
21
+ run: async ({ args, flags }) => {
22
+ const input = args[0]
23
+ if (!input) throw missing("the GLB")
24
+ if (!existsSync(input)) throw notFound(input)
25
+ const { makeIO, writeGlb } = await import("../fbx/convert")
26
+ const { writeMarks } = await import("../retarget/writeMarks")
27
+ const { stairsMatcher, describeMarks } = await import("../retarget/marks")
28
+ const t0 = performance.now()
29
+ const doc = await makeIO().read(input)
30
+ const clips = flags.clips.length ? flags.clips : undefined
31
+ const rows = writeMarks(doc, { stairs: stairsMatcher(flags.stairs, flags.platforms), fps: flags.fps, clips, headingEnd: flags["heading-end"], loops: flags.loop ? clips : undefined, warn: (w) => log(` ⚠ ${w}`) })
32
+ for (const r of rows) log(` 🦶 ${r.clip.padEnd(22)} ${describeMarks(r.marks)}`)
33
+ const out = flags.o ?? input
34
+ writeFileSync(out, await writeGlb(doc))
35
+ log(`💾 wrote ${out} (${rows.length} clips marked, ${seconds(t0)})`)
36
+ return 0
37
+ },
38
+ })
@@ -0,0 +1,35 @@
1
+ import { defineCommand, list, missing, str } from "../cli-kit"
2
+ import { log } from "./shared"
3
+
4
+ export default defineCommand({
5
+ name: "mirror",
6
+ summary: "Add MIRRORED copies of clips (left ↔ right)",
7
+ usage: "<model.glb>",
8
+ description: `
9
+ A stop that brakes on the left foot becomes one that brakes on the right. World-space deltas from
10
+ the bind reflected across the sagittal plane, bones paired by name (Left/Right, L_/R_, .L/.R) and
11
+ checked against the bind geometry. Needs the skin (the bind pose) in the file.`,
12
+ flags: {
13
+ clips: list("only these clips (default: all)", { value: "a b …" }),
14
+ suffix: str("appended to the mirrored clip's name (_M)", { value: "_M" }),
15
+ axis: str("the mirror plane's normal (default: measured from the bind)", { value: "x|y|z" }),
16
+ o: str("output (default: in place)", { value: "out.glb" }),
17
+ },
18
+ run: async ({ args, flags }) => {
19
+ const input = args[0]
20
+ if (!input) throw missing("the GLB")
21
+ const { makeIO } = await import("../fbx/convert")
22
+ const { mirrorClips } = await import("../retarget/mirror")
23
+ const io = makeIO()
24
+ const doc = await io.read(input)
25
+ const suffix = flags.suffix ?? "_M"
26
+ const rep = mirrorClips(doc, { clips: flags.clips.length ? flags.clips : undefined, rename: (n) => `${n}${suffix}`, axis: flags.axis as "x" | "y" | "z" | undefined, log })
27
+ 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})`)
28
+ 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`)
29
+ if (!rep.clips.length) { log(` ✗ no clip matched`); return 1 }
30
+ const output = flags.o ?? input
31
+ await io.write(output, doc)
32
+ log(`💾 wrote ${output} (+${rep.clips.length} clips)`)
33
+ return 0
34
+ },
35
+ })
@@ -0,0 +1,43 @@
1
+ import { existsSync, writeFileSync } from "node:fs"
2
+ import { extname } from "node:path"
3
+ import { bool, CliError, defineCommand, int, missing, notFound, str, usageError } from "../cli-kit"
4
+ import type { FilmstripView } from "../filmstrip"
5
+ import { log } from "./shared"
6
+
7
+ export default defineCommand({
8
+ name: "probe",
9
+ summary: "What a file holds: skeleton, clips (+ per-clip root motion), meshes, materials, textures, bind sanity, world size",
10
+ usage: "<file.fbx|.glb|.uasset>",
11
+ flags: {
12
+ filmstrip: str(`(GLB) CPU-skinned PNG strip of every clip — the fastest "does it play upright" check, no GPU`, { value: "out.png", bare: "", group: "Filmstrip" }),
13
+ clip: str("only this clip", { value: "<name>" }),
14
+ frames: int("frames per strip (6)"),
15
+ view: str("camera", { value: "side|front|top|threequarter|lowfront" }),
16
+ bones: bool("draw the skeleton over the mesh"),
17
+ cell: int("cell size", { value: "px" }),
18
+ },
19
+ run: async ({ args, flags, given }) => {
20
+ const input = args[0]
21
+ if (!input) throw missing("the file to probe")
22
+ if (!existsSync(input)) throw notFound(input)
23
+ const { probeFile } = await import("../probe")
24
+ log(await probeFile(input))
25
+ if (!given.has("filmstrip")) return 0
26
+ if (extname(input).toLowerCase() === ".fbx") throw usageError("--filmstrip wants a GLB (convert first)")
27
+ const { renderFilmstrip } = await import("../filmstrip")
28
+ const { makeIO } = await import("../fbx/convert")
29
+ const doc = await makeIO().read(input)
30
+ const clips = doc.getRoot().listAnimations().map((a) => a.getName()).filter((n) => !flags.clip || n === flags.clip)
31
+ if (flags.clip && clips.length === 0) throw new CliError(`no clip "${flags.clip}"`)
32
+ const stem = flags.filmstrip || input.replace(/\.glb$/i, "")
33
+ const targets = clips.length ? clips : [ null ]
34
+ const opts = { frames: flags.frames ?? 6, view: (flags.view ?? "side") as FilmstripView, cell: flags.cell, bones: flags.bones }
35
+ for (const clip of targets) {
36
+ const r = renderFilmstrip(doc, { ...opts, clip })
37
+ const file = targets.length === 1 && /\.png$/i.test(stem) ? stem : `${stem.replace(/\.png$/i, "")}.${(clip ?? "rest").replace(/[^\w.-]+/g, "_")}.png`
38
+ writeFileSync(file, r.png)
39
+ log(`🖼 ${file} ${r.label}`)
40
+ }
41
+ return 0
42
+ },
43
+ })
@@ -0,0 +1,63 @@
1
+ // Loading the rigs of a retarget: any of the formats, a skeleton-only clip file bound onto the rig it
2
+ // was animated on, and clip ASSETS (Unity .anim / Unreal AnimSequence) attached to the donor.
3
+
4
+ import { existsSync, readFileSync } from "node:fs"
5
+ import { basename } from "node:path"
6
+ import { CliError, notFound } from "../../cli-kit"
7
+ import type { Clip, Rig } from "../../retarget/rig"
8
+ import { log, resolveInputs } from "../shared"
9
+
10
+ export type UnrealLocation = { skeleton?: string, contentRoot?: string }
11
+
12
+ const unboundNote = (names: string[]): string => `${names.slice(0, 3).join(", ")}${names.length > 3 ? ` +${names.length - 3}` : ""}`
13
+
14
+ export const loadAnyRig = async (path: string, unreal: UnrealLocation): Promise<Rig> => {
15
+ const { loadRig, rigFromDocument } = await import("../../retarget/rig")
16
+ const lower = path.toLowerCase()
17
+ if (lower.endsWith(".uasset")) return (await import("../../unreal/rig")).rigFromUnreal(path, unreal)
18
+ if (lower.endsWith(".bvh")) {
19
+ const { bvhToDocument, describeBvh } = await import("../../bvh/toDocument")
20
+ const r = bvhToDocument(readFileSync(path, "utf8"), { name: basename(path).replace(/\.bvh$/i, "") })
21
+ log(` ${basename(path)}: ${describeBvh(r.report)}`)
22
+ return rigFromDocument(r.doc, basename(path))
23
+ }
24
+ if (lower.endsWith(".fbx")) return loadRig(path)
25
+ const { makeIO } = await import("../../fbx/convert")
26
+ return rigFromDocument(await makeIO().read(path), basename(path))
27
+ }
28
+
29
+ /** A skeleton-only clip file (a mocap pack's FBX: nodes + curves, no mesh) has no skin to classify
30
+ * and no bind to measure from: its clips are bound by bone name onto the rig it was animated on. */
31
+ export const bindToDonorRig = async (donor: Rig, donorPath: string, rigPath: string, unreal: UnrealLocation): Promise<Rig> => {
32
+ if (!existsSync(rigPath)) throw notFound(rigPath)
33
+ const { rebindClips } = await import("../../retarget/rig")
34
+ const r = rebindClips(await loadAnyRig(rigPath, unreal), donor)
35
+ log(` 📎 ${basename(donorPath)}: ${r.bound}/${r.total} bones bound to ${basename(rigPath)} 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: ${unboundNote(r.unbound)})` : ""}`)
36
+ return r.rig
37
+ }
38
+
39
+ /** Clip assets that stand in for clips the donor rig file does not carry: Unity `.anim`
40
+ * AnimationClips, or Unreal `.uasset` AnimSequences (each on its own skeleton, bound by name). */
41
+ export const attachClipAssets = async (donor: Rig, files: string[], unreal: UnrealLocation): Promise<Rig> => {
42
+ const animFiles = resolveInputs(files, /\.(anim|uasset)$/i, false)
43
+ if (!animFiles.length) return donor
44
+ for (const f of animFiles) if (!existsSync(f)) throw notFound(f)
45
+ const attached: Clip[] = []
46
+ const report: { name: string, bound: number, total: number, unbound: string[] }[] = []
47
+ const unity = animFiles.filter((f) => /\.anim$/i.test(f))
48
+ if (unity.length) {
49
+ const { parseUnityAnim, attachUnityClips } = await import("../../retarget/unityAnim")
50
+ const r = attachUnityClips(donor, unity.map((f) => parseUnityAnim(readFileSync(f, "utf8"), basename(f).replace(/\.anim$/i, ""))))
51
+ attached.push(...r.clips); report.push(...r.report)
52
+ }
53
+ for (const f of animFiles.filter((x) => /\.uasset$/i.test(x))) {
54
+ const { loadUnreal } = await import("../../unreal/index")
55
+ const { attachUnrealClips } = await import("../../unreal/rig")
56
+ const u = loadUnreal(f, unreal)
57
+ if (!u.anim) throw new CliError(`${basename(f)}: not an AnimSequence (${u.kind}) — --anim wants animation assets`)
58
+ const r = attachUnrealClips(donor, u.skeleton, [ u.anim ])
59
+ attached.push(...r.clips); report.push(...r.report)
60
+ }
61
+ for (const r of report) log(` 📎 ${r.name.padEnd(20)} ${r.bound}/${r.total} curves bound${r.unbound.length ? ` (no bone: ${unboundNote(r.unbound)})` : ""}`)
62
+ return { ...donor, clips: [ ...donor.clips, ...attached ] }
63
+ }