lecodes-assets 0.2.0 → 2.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -0
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -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 +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -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 +238 -0
- package/src/retarget/humanoid.ts +572 -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 +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +149 -0
- package/src/unity/yaml.ts +131 -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 +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -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/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { CliError, usageError, type Command, type Flags } from "./command"
|
|
2
|
+
import { PROGRAM, renderCommand, renderRoot } from "./help"
|
|
3
|
+
import { nearestFlag, parseArgs } from "./parse"
|
|
4
|
+
|
|
5
|
+
// The tree walk: `unreal tex x.uasset -o y.png` → the `tex` command under `unreal`, its flags read
|
|
6
|
+
// against its own declaration. stdout = the result, stderr = diagnostics (errors, warnings).
|
|
7
|
+
|
|
8
|
+
export type Program = { title: string, commands: Command<any>[] }
|
|
9
|
+
|
|
10
|
+
const isHelp = (a: string | undefined): boolean => a === "--help" || a === "-h" || a === "help"
|
|
11
|
+
|
|
12
|
+
export const runProgram = async (program: Program, argv: string[]): Promise<number> => {
|
|
13
|
+
const [ first ] = argv
|
|
14
|
+
if (first === undefined || isHelp(first)) { console.log(renderRoot(program.title, program.commands)); return 0 }
|
|
15
|
+
const top = program.commands.find((c) => c.name === first)
|
|
16
|
+
if (!top) {
|
|
17
|
+
console.error(`✗ unknown command "${first}"\n`)
|
|
18
|
+
console.log(renderRoot(program.title, program.commands))
|
|
19
|
+
return 2
|
|
20
|
+
}
|
|
21
|
+
let cmd: Command<any> = top
|
|
22
|
+
const path = [ first ]
|
|
23
|
+
let rest = argv.slice(1)
|
|
24
|
+
// descend while the next word names a subcommand
|
|
25
|
+
for (;;) {
|
|
26
|
+
const sub: Command<any> | undefined = cmd.commands?.find((c: Command<any>) => c.name === rest[0])
|
|
27
|
+
if (!sub) break
|
|
28
|
+
cmd = sub
|
|
29
|
+
path.push(rest[0])
|
|
30
|
+
rest = rest.slice(1)
|
|
31
|
+
}
|
|
32
|
+
if (rest.some((a) => a === "--help" || a === "-h")) { console.log(renderCommand(cmd, path)); return 0 }
|
|
33
|
+
if (!cmd.run) { console.log(renderCommand(cmd, path)); return rest.length ? 2 : 0 }
|
|
34
|
+
try {
|
|
35
|
+
const spec = (cmd.flags ?? {}) as Flags
|
|
36
|
+
const parsed = parseArgs(spec, rest)
|
|
37
|
+
// an unknown flag is an error, not a warning: whether it takes a value cannot be known, so the word
|
|
38
|
+
// after it would be read as a positional (a second input file)
|
|
39
|
+
if (parsed.unknown.length) {
|
|
40
|
+
const notes = parsed.unknown.map((u) => { const near = nearestFlag(spec, u); return near ? `${u} (did you mean ${near}?)` : u })
|
|
41
|
+
throw usageError(`unknown flag${notes.length > 1 ? "s" : ""}: ${notes.join(", ")} — ${PROGRAM} ${path.join(" ")} --help lists them`)
|
|
42
|
+
}
|
|
43
|
+
return await cmd.run({ args: parsed.args, flags: parsed.flags, given: parsed.given, argv: rest, path })
|
|
44
|
+
} catch (e) {
|
|
45
|
+
const err = e as Error
|
|
46
|
+
console.error(`✗ ${err?.message ?? e}`)
|
|
47
|
+
if (!(e instanceof CliError)) { if (process.env.LECODES_DEBUG && err?.stack) console.error(err.stack); return 1 }
|
|
48
|
+
// a wrong command line gets the command's own page, not the whole manual
|
|
49
|
+
if (e.showHelp) console.log(`\n${renderCommand(cmd, path)}`)
|
|
50
|
+
return e.code
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lecodes-assets — the LeCodes asset pipeline CLI (also reachable as `lecodes assets …`).
|
|
3
|
+
//
|
|
4
|
+
// A command is DATA (`src/commands/<name>.ts`, listed in `src/commands/index.ts`): its flags are
|
|
5
|
+
// declared once, the argv is parsed against that declaration and every help page is rendered from
|
|
6
|
+
// it (`src/cli-kit`). `lecodes assets` for the command list, `lecodes assets <command> --help` for one.
|
|
7
|
+
|
|
8
|
+
import { runProgram } from "./cli-kit"
|
|
9
|
+
import { program } from "./commands"
|
|
10
|
+
|
|
11
|
+
/** Run one command line (the command first). Resolves to the exit code. */
|
|
12
|
+
export const runAssetsCli = (argv: string[]): Promise<number> => runProgram(program, argv)
|
|
13
|
+
|
|
14
|
+
/** The lightmap unwrap of whole GLB files, in place (src/unwrap) — what `lecodes lightmap bake` runs on the GLBs the app
|
|
15
|
+
* references that have no TEXCOORD_1. Loaded on the first call, so the command line stays light. */
|
|
16
|
+
export const unwrapGlbFiles = async (...args: Parameters<typeof import("./unwrap").unwrapGlbFiles>): ReturnType<typeof import("./unwrap").unwrapGlbFiles> =>
|
|
17
|
+
(await import("./unwrap")).unwrapGlbFiles(...args)
|
|
18
|
+
export type { UnwrapFileOptions, UnwrapFileReport } from "./unwrap"
|
|
19
|
+
|
|
20
|
+
// Direct invocation (`bun src/cli.ts …` / the package bin) — `lecodes assets` imports runAssetsCli instead.
|
|
21
|
+
const isMain = typeof process !== "undefined" && process.argv[1] !== undefined && /cli.(ts|js)$/.test(process.argv[1]) && !process.env.LECODES_ASSETS_NO_MAIN
|
|
22
|
+
if (isMain) {
|
|
23
|
+
runAssetsCli(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(`✗ ${e?.message ?? e}`); process.exit(1) })
|
|
24
|
+
}
|
|
@@ -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,67 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import { bool, CliError, defineCommand, missing, str } from "../cli-kit"
|
|
3
|
+
import { doctorFlags, doctorOptions, 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
|
+
},
|
|
19
|
+
examples: [
|
|
20
|
+
"lecodes assets doctor hero.glb",
|
|
21
|
+
"lecodes assets doctor hero.glb --fix --ktx2",
|
|
22
|
+
`lecodes assets doctor "props/**/*.glb" --ktx2 --max-size 1024 -o out/`,
|
|
23
|
+
],
|
|
24
|
+
run: async ({ args, flags, given }) => {
|
|
25
|
+
const files = resolveInputs(args)
|
|
26
|
+
if (files.length === 0) throw missing("the GLB file(s)")
|
|
27
|
+
const { analyzeDoc, applyPlan, reportPlan, dumpTextures, outputFor, findKtxEncoder, KTX_ENCODER_MISSING, kb } = await import("../doctor/doctor")
|
|
28
|
+
const { ensureCodecs, makeIO } = await import("../doctor/reduceGlbBones")
|
|
29
|
+
const { validateGlb, formatReport } = await import("../doctor/validateGlb")
|
|
30
|
+
const useKtx2 = wantsKtx2(flags)
|
|
31
|
+
const lod = (flags.lod ?? "").split(",").map(Number).filter((n) => n > 0 && n < 1)
|
|
32
|
+
const doFix = wantsDoctorFix(flags) || flags["fix-metallic"] || lod.length > 0
|
|
33
|
+
const opts = { ...doctorOptions(flags), lod, lodBones: flags["lod-bones"] }
|
|
34
|
+
const ktxEncoder = useKtx2 ? findKtxEncoder() : null
|
|
35
|
+
if (useKtx2) {
|
|
36
|
+
if (!ktxEncoder) throw new CliError(KTX_ENCODER_MISSING, 2)
|
|
37
|
+
log(` using ${ktxEncoder.version}${ktxEncoder.kind === "ktx" ? " (ktx create)" : ""}`)
|
|
38
|
+
}
|
|
39
|
+
await ensureCodecs()
|
|
40
|
+
const io = makeIO()
|
|
41
|
+
if (files.length > 1) log(`Processing ${files.length} files…`)
|
|
42
|
+
let exitCode = 0
|
|
43
|
+
for (const input of files) {
|
|
44
|
+
if (!existsSync(input)) { console.error(`\n✗ not found: ${input}`); exitCode = 1; continue }
|
|
45
|
+
const doc = await io.read(input)
|
|
46
|
+
if (given.has("dump")) { await dumpTextures(doc, flags.dump && files.length === 1 ? flags.dump : input.replace(/\.glb$/i, "-textures")); continue }
|
|
47
|
+
const plan = await analyzeDoc(doc, opts)
|
|
48
|
+
const rep = reportPlan(plan)
|
|
49
|
+
log(`\n🔍 ${input}`)
|
|
50
|
+
log(rep.text)
|
|
51
|
+
log(` ${rep.lossless} lossless · ${rep.lossy} lossy · ${rep.blocking} blocking`)
|
|
52
|
+
if (rep.blocking > 0) exitCode = 1
|
|
53
|
+
if (!doFix) { if (rep.lossless + rep.lossy > 0) log(` run with --fix or --ktx2 to apply`); continue }
|
|
54
|
+
logDoctorResult(await applyPlan(doc, plan, { ...opts, fixMetallic: flags["fix-metallic"], ktxEncoder: ktxEncoder ?? undefined, progress: ktx2Progress }))
|
|
55
|
+
const output = outputFor(input, flags.o ?? null, files.length > 1, useKtx2)
|
|
56
|
+
// glTF's top-level `scene` is optional and glTF-Transform only writes it when set — strict
|
|
57
|
+
// loaders (OGL: `scenes[undefined]`) then load zero roots and draw nothing. Pin the first scene.
|
|
58
|
+
const root = doc.getRoot()
|
|
59
|
+
if (!root.getDefaultScene() && root.listScenes().length > 0) root.setDefaultScene(root.listScenes()[0])
|
|
60
|
+
await io.write(output, doc)
|
|
61
|
+
const after = readFileSync(output)
|
|
62
|
+
log(`💾 wrote ${output} (${kb(statSync(input).size)} → ${kb(after.byteLength)})`)
|
|
63
|
+
log(formatReport(validateGlb(after), output))
|
|
64
|
+
}
|
|
65
|
+
return exitCode
|
|
66
|
+
},
|
|
67
|
+
})
|
|
@@ -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
|
+
})
|