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,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
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { basename, dirname } from "node:path"
|
|
3
|
+
import { bool, defineCommand, int, list, missing, notFound, num, str, usageError } from "../../cli-kit"
|
|
4
|
+
import type { RootMotion } from "../../retarget/retarget"
|
|
5
|
+
import type { Rig } from "../../retarget/rig"
|
|
6
|
+
import { log, megabytes, seconds, unrealFlags } from "../shared"
|
|
7
|
+
import { attachClipAssets, bindToDonorRig, loadAnyRig } from "./donor"
|
|
8
|
+
import { deriveMap, readMap, type MapSpec, type ResolvedMap } from "./map"
|
|
9
|
+
import { printReport, printScaleNotes, printSkeletons, printTransfer } from "./report"
|
|
10
|
+
|
|
11
|
+
/** A retarget is only meaningful if both rigs agree which way is forward: measure it from the
|
|
12
|
+
* skeleton shape, falling back to the mapped head/tail bones, and return the signed yaw about +Y
|
|
13
|
+
* that turns the donor's forward onto the target's (0 when they already agree within 2Β°). */
|
|
14
|
+
const measureDonorYaw = async (target: Rig, donor: Rig, spec: MapSpec): Promise<number> => {
|
|
15
|
+
const { analyzeFacing, facingMismatch } = await import("../../retarget/retarget")
|
|
16
|
+
let fa: { forward: number[] } | null = null, da: { forward: number[] } | null = null
|
|
17
|
+
try {
|
|
18
|
+
// A biped faces where its toes point (its long horizontal axis is the ARM SPAN in a T-pose);
|
|
19
|
+
// a quadruped faces along its body.
|
|
20
|
+
const { classify } = await import("../../retarget/automap")
|
|
21
|
+
const { classifyBiped } = await import("../../retarget/humanoid")
|
|
22
|
+
const facingOf = (rig: Rig): { forward: number[] } => { try { return classifyBiped(rig) } catch { return classify(rig) } }
|
|
23
|
+
fa = facingOf(target); da = facingOf(donor)
|
|
24
|
+
} catch {
|
|
25
|
+
const pairs = Object.entries(spec.bones)
|
|
26
|
+
const head = pairs.find(([ t ]) => /head/i.test(t)), tail = pairs.find(([ t ]) => /tail|root|hip|pelvis/i.test(t))
|
|
27
|
+
if (head && tail) { fa = analyzeFacing(target, [ head[0], tail[0] ]); da = analyzeFacing(donor, [ head[1], tail[1] ]) }
|
|
28
|
+
}
|
|
29
|
+
if (!fa || !da) return 0
|
|
30
|
+
const off = facingMismatch(fa.forward as [number, number, number], da.forward as [number, number, number])
|
|
31
|
+
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
|
|
32
|
+
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)}Β°` : ""}`)
|
|
33
|
+
return off > 2 ? signed : 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default defineCommand({
|
|
37
|
+
name: "retarget",
|
|
38
|
+
summary: "Drive one rig with ANOTHER rig's clips (different bone names, chain lengths and axis conventions are all fine)",
|
|
39
|
+
usage: "<target.glb> --from <donor.fbx|.glb|.uasset|.bvh>",
|
|
40
|
+
flags: {
|
|
41
|
+
from: str("the donor: the rig whose clips are taken", { value: "<donor>" }),
|
|
42
|
+
o: str("output (default: <target>.retargeted.glb)", { value: "out.glb" }),
|
|
43
|
+
map: str(`{ "root": "Hips", "bones": { targetBone: donorBone }, "align": { bone: "keep" | "direction" | "heading" | [ refBones ] }, "legs": [ footBones ] } (align / legs optional) β or "auto" to derive it and use it directly`, { value: "map.json|auto", group: "The bone map" }),
|
|
44
|
+
"auto-map": str("derive the map from the two skeletons' SHAPE and WRITE it. Quadrupeds: spine / neck / head / tail / legs / ears / jaw chains. Humanoids: both rigs' AVATARS (see `avatar`) joined on the canon", { value: "out.json", bare: "" }),
|
|
45
|
+
skeletons: bool("print both bone lists and exit (to write that map)"),
|
|
46
|
+
avatar: str("the target's avatar instead of deriving it (a Unity .meta works)", { value: "<a.avatar.json|model.meta>" }),
|
|
47
|
+
"donor-avatar": str("the donor's", { value: "<β¦>" }),
|
|
48
|
+
"donor-rig": str("the rig a skeleton-only clip file (mocap packs: nodes + curves, no mesh) was animated on β its clips bind to it by bone name", { value: "<model.fbx|.glb|.uasset>", group: "The donor's clips" }),
|
|
49
|
+
anim: list("clip assets to use as the donor's clips: Unity AnimationClips (packs shipping an empty FBX rig + .anim files) or Unreal AnimSequences (each on its own skeleton, bound by bone name)", { value: "a.anim b.uasset β¦" }),
|
|
50
|
+
clips: list("only these donor clips", { value: "a b β¦" }),
|
|
51
|
+
"keep-clips": bool("keep the target's own clips"),
|
|
52
|
+
"clip-names": list("rename the produced clips, positionally", { value: "n1 n2 β¦" }),
|
|
53
|
+
prefix: str("clip name prefix", { value: "s" }),
|
|
54
|
+
...unrealFlags,
|
|
55
|
+
align: str("posture alignment (default bone-direction)", { value: "none|bone-direction", group: "Transfer" }),
|
|
56
|
+
ik: str("foot placement against the ground (default legs); hands = the hands as effectors too (clips where they meet or hold)", { value: "none|legs|hands" }),
|
|
57
|
+
"donor-ref": str("pose the donor's motion is measured from (auto = mean of its idle/walk)", { value: "bind|auto|<clip>" }),
|
|
58
|
+
"root-motion": str("where the travel lands: a root above the hips takes the horizontal travel, the hips the bob (auto/root); all on the hips; none = in place", { value: "auto|hips|root|none" }),
|
|
59
|
+
"root-rotation": str("what the root takes of the hips' turning (default none)", { value: "none|yaw" }),
|
|
60
|
+
"root-scale": num("root-motion scale", { positive: true }),
|
|
61
|
+
"heading-end": num("the baked heading curve must END here (a stop that turns: its idle faces the turn)", { value: "DEG" }),
|
|
62
|
+
loop: bool("the clips are LOOPS (last frame = first, a cycle on): the heading smoothed periodically β an arc loop's curve is then the clean ramp it runs"),
|
|
63
|
+
"no-plant": bool("no foot planting (each foot is otherwise LOCKED through its still windows)"),
|
|
64
|
+
stand: num("a target bound with the knees bent (more crouched than the donor's bind) stands up by K of the difference (0 = as bound, default; 1 = the donor's extension)", { value: "K" }),
|
|
65
|
+
"stand-clips": str("β¦in the clips matching this (default: all)", { value: "REGEX" }),
|
|
66
|
+
"min-knee": num("a SOFT limit on how straight a knee may get, degrees of bend (default none): a donor that locks its push-off leg otherwise lands the knee at exactly 0 for a few frames β a click; 12β14 also keeps it clear of the runtime's own reach clamp", { value: "DEG" }),
|
|
67
|
+
"foot-stance": str("the horizontal stance a planted foot keeps: the target's own foot-under-hip offset + the donor's departure from its bind (default), or the donor's offset whole, and each foot turned to the donor's HEADING (its own bind pitch kept) β a rig bound legs-apart / toes-out stands as the donor does", { value: "target|donor" }),
|
|
68
|
+
stairs: str(`donor clips to fit a STAIRCASE to (default: names containing "stair")`, { value: "<regex>|all|none" }),
|
|
69
|
+
platforms: str(`donor clips with a PLATFORM: two levels, the floor and the top (default "platform"); a foot slow at any other height (the wall) is no plant`, { value: "<regex>|all|none" }),
|
|
70
|
+
twist: str("twist helpers' share of their limb's axial turn (default 0.5)", { value: "N|off" }),
|
|
71
|
+
"body-clearance": num("a joint the donor has on the floor (a roll's back) keeps this much over the target's ground beyond the donor's height (default 0.05)", { value: "M", positive: true }),
|
|
72
|
+
fps: int("sampling rate", { group: "Sampling" }),
|
|
73
|
+
epsilon: num("key-reduction tolerance, metres (default 1e-4)", { value: "M", positive: true }),
|
|
74
|
+
"epsilon-deg": num("β¦and degrees (default 0.2Β°; hips + legs 0.01Β°)", { value: "D", positive: true }),
|
|
75
|
+
},
|
|
76
|
+
examples: [
|
|
77
|
+
"lecodes assets retarget fox.glb --from Wolf_Animation.fbx --skeletons",
|
|
78
|
+
"lecodes assets retarget fox.glb --from Wolf_Animation.fbx --auto-map fox-from-wolf.map.json",
|
|
79
|
+
"lecodes assets retarget hero.glb --from Walking.fbx --map auto --clip-names walk -o hero-walk.glb",
|
|
80
|
+
],
|
|
81
|
+
run: async ({ args, flags, given }) => {
|
|
82
|
+
const input = args[0], donorPath = flags.from
|
|
83
|
+
if (!input) throw missing("the target GLB")
|
|
84
|
+
if (!donorPath) throw missing("--from <donor>")
|
|
85
|
+
for (const f of [ input, donorPath ]) if (!existsSync(f)) throw notFound(f)
|
|
86
|
+
const { rigFromDocument, checkUniformScale, hasSkin } = await import("../../retarget/rig")
|
|
87
|
+
const { retarget, bindAccessors } = await import("../../retarget/retarget")
|
|
88
|
+
const { makeIO, writeGlb } = await import("../../fbx/convert")
|
|
89
|
+
|
|
90
|
+
const doc = await makeIO().read(input)
|
|
91
|
+
const target = rigFromDocument(doc, basename(input))
|
|
92
|
+
const unreal = { skeleton: flags.skeleton, contentRoot: flags["content-root"] }
|
|
93
|
+
let donor = await loadAnyRig(donorPath, unreal)
|
|
94
|
+
if (flags["donor-rig"]) donor = await bindToDonorRig(donor, donorPath, flags["donor-rig"], unreal)
|
|
95
|
+
else if (!hasSkin(donor) && !flags.skeletons) throw usageError(`${basename(donorPath)} carries no skin (a clip-only file) β name the rig it was animated on: --donor-rig <model.fbx|.glb>`)
|
|
96
|
+
donor = await attachClipAssets(donor, flags.anim, unreal)
|
|
97
|
+
|
|
98
|
+
if (flags.skeletons) { await printSkeletons(target, input, donor, donorPath); return 0 }
|
|
99
|
+
|
|
100
|
+
const derive = given.has("auto-map") || flags.map === "auto" || !!flags.avatar || !!flags["donor-avatar"]
|
|
101
|
+
const { spec, humanoid }: ResolvedMap = derive
|
|
102
|
+
? await deriveMap({ rig: target, file: input, avatar: flags.avatar }, { rig: donor, file: donorPath, avatar: flags["donor-avatar"] })
|
|
103
|
+
: await readMap(flags.map, target, donor)
|
|
104
|
+
if (given.has("auto-map")) {
|
|
105
|
+
const out = flags["auto-map"] || input.replace(/\.glb$/i, "") + "-from-" + basename(donorPath).replace(/\.[^.]+$/, "") + ".map.json"
|
|
106
|
+
writeFileSync(out, JSON.stringify(spec, null, 2) + "\n")
|
|
107
|
+
log(`πΎ wrote ${out} (${Object.keys(spec.bones).length} bones) β edit it, then: retarget β¦ --map ${basename(out)}`)
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
checkUniformScale(target); checkUniformScale(donor)
|
|
112
|
+
const t0 = performance.now()
|
|
113
|
+
log(`π ${basename(input)} β ${basename(donorPath)}`)
|
|
114
|
+
const donorYawDeg = await measureDonorYaw(target, donor, spec)
|
|
115
|
+
printScaleNotes(target, donor)
|
|
116
|
+
|
|
117
|
+
if (!flags["keep-clips"]) for (const a of doc.getRoot().listAnimations()) a.dispose()
|
|
118
|
+
// The avatar's transfer block (the studio's decisions) supplies the defaults; flags override.
|
|
119
|
+
const tr = humanoid?.target.transfer
|
|
120
|
+
printTransfer(tr)
|
|
121
|
+
const prefix = flags.prefix ?? ""
|
|
122
|
+
const names = flags["clip-names"]
|
|
123
|
+
let produced = 0
|
|
124
|
+
const ikOff = flags.ik === "none" || spec.ik === false
|
|
125
|
+
const { stairsMatcher } = await import("../../retarget/marks")
|
|
126
|
+
const rep = retarget(target, donor, spec.bones, spec.root ?? null, {
|
|
127
|
+
align: (flags.align as "none" | "bone-direction" | undefined) ?? "bone-direction",
|
|
128
|
+
alignTo: spec.alignTo,
|
|
129
|
+
alignBones: spec.align,
|
|
130
|
+
donorYawDeg,
|
|
131
|
+
donorRef: flags["donor-ref"] === undefined ? spec.donorRef : flags["donor-ref"] === "bind" || flags["donor-ref"] === "auto" ? flags["donor-ref"] : { clip: flags["donor-ref"] },
|
|
132
|
+
ik: ikOff ? "none" : flags.ik === "hands" || (flags.ik === undefined && tr?.hands) ? "legs+hands" : "legs",
|
|
133
|
+
rootMotion: (flags["root-motion"] as RootMotion | undefined) ?? tr?.rootMotion ?? "auto",
|
|
134
|
+
headingEnd: flags["heading-end"],
|
|
135
|
+
loop: flags.loop,
|
|
136
|
+
rootRotation: (flags["root-rotation"] as "none" | "yaw" | undefined) ?? "none",
|
|
137
|
+
plant: flags["no-plant"] ? false : tr?.plant ?? true,
|
|
138
|
+
footStance: flags["foot-stance"] as "target" | "donor" | undefined,
|
|
139
|
+
minKnee: flags["min-knee"],
|
|
140
|
+
stand: flags.stand !== undefined ? ((k, re) => (name: string) => (re.test(name) ? k : 0))(flags.stand, new RegExp(flags["stand-clips"] ?? ".", "i")) : undefined,
|
|
141
|
+
stairs: stairsMatcher(flags.stairs, flags.platforms),
|
|
142
|
+
twist: flags.twist === undefined ? (tr?.twist !== undefined ? (tr.twist > 0 ? tr.twist : false) : 0.5) : flags.twist === "off" ? false : Number(flags.twist),
|
|
143
|
+
legs: spec.legs,
|
|
144
|
+
fps: flags.fps,
|
|
145
|
+
epsilon: flags.epsilon,
|
|
146
|
+
bodyClearance: flags["body-clearance"],
|
|
147
|
+
epsilonDeg: flags["epsilon-deg"],
|
|
148
|
+
rootScale: flags["root-scale"],
|
|
149
|
+
clips: flags.clips.length ? flags.clips : undefined,
|
|
150
|
+
rename: names.length || prefix ? (n: string) => prefix + (names[produced++] ?? n) : undefined,
|
|
151
|
+
humanoid,
|
|
152
|
+
warn: (w) => log(` β ${w}`),
|
|
153
|
+
})
|
|
154
|
+
printReport(rep, { humanoid: !!humanoid, ikWanted: !ikOff })
|
|
155
|
+
|
|
156
|
+
bindAccessors(doc)
|
|
157
|
+
const out = flags.o ?? input.replace(/\.glb$/i, ".retargeted.glb")
|
|
158
|
+
const dir = dirname(out)
|
|
159
|
+
if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
160
|
+
const glb = await writeGlb(doc)
|
|
161
|
+
writeFileSync(out, glb)
|
|
162
|
+
const { validateGlb, formatReport } = await import("../../doctor/validateGlb")
|
|
163
|
+
const { probeDocument } = await import("../../probe")
|
|
164
|
+
log(formatReport(validateGlb(glb), out))
|
|
165
|
+
log(probeDocument(doc, out).split("\n").slice(1).join("\n"))
|
|
166
|
+
log(`πΎ wrote ${out} (${megabytes(glb.byteLength)}, ${seconds(t0)})`)
|
|
167
|
+
return 0
|
|
168
|
+
},
|
|
169
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// WHICH target bone follows WHICH donor bone: a map file, or one derived from the two rigs β
|
|
2
|
+
// humanoids through their avatars (both joined on the Unity/VRM canon), everything else from
|
|
3
|
+
// skeleton SHAPE (retarget/automap.ts: a starting point the user reads and corrects).
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
6
|
+
import { basename } from "node:path"
|
|
7
|
+
import { CliError, notFound, usageError } from "../../cli-kit"
|
|
8
|
+
import type { Avatar, AvatarFile } from "../../retarget/humanoid"
|
|
9
|
+
import type { Rig } from "../../retarget/rig"
|
|
10
|
+
import { log } from "../shared"
|
|
11
|
+
|
|
12
|
+
export type MapSpec = {
|
|
13
|
+
_comment?: string
|
|
14
|
+
root?: string
|
|
15
|
+
bones: Record<string, string>
|
|
16
|
+
alignTo?: Record<string, string[]>
|
|
17
|
+
align?: Record<string, "keep" | "direction" | "heading" | string[]>
|
|
18
|
+
legs?: string[]
|
|
19
|
+
ik?: boolean
|
|
20
|
+
donorRef?: "bind" | "auto" | { clip: string, time?: number }
|
|
21
|
+
twist?: Record<string, string>
|
|
22
|
+
/** Both avatars of a humanoid map β reloaded so `--map file.json` keeps the humanoid treatment. */
|
|
23
|
+
avatars?: { target: AvatarFile, donor: AvatarFile }
|
|
24
|
+
}
|
|
25
|
+
export type Humanoid = { target: Avatar, donor: Avatar }
|
|
26
|
+
export type ResolvedMap = { spec: MapSpec, humanoid?: Humanoid }
|
|
27
|
+
|
|
28
|
+
type Side = { rig: Rig, file: string, avatar?: string }
|
|
29
|
+
|
|
30
|
+
/** A humanoid rig's bones in the canon β given (`.avatar.json` / Unity `.meta`) or read off its shape. */
|
|
31
|
+
const loadAvatar = async (rig: Rig, file: string | undefined): Promise<Avatar | null> => {
|
|
32
|
+
const { avatarFromRig, avatarFromUnity, avatarFromFile, parseUnityAvatar, isBiped } = await import("../../retarget/humanoid")
|
|
33
|
+
if (!file) return isBiped(rig) ? avatarFromRig(rig) : null
|
|
34
|
+
if (!existsSync(file)) throw notFound(file)
|
|
35
|
+
const text = readFileSync(file, "utf8")
|
|
36
|
+
return /\.meta$/i.test(file) ? avatarFromUnity(rig, parseUnityAvatar(text)) : avatarFromFile(rig, JSON.parse(text))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Derive the map from the rigs themselves. */
|
|
40
|
+
export const deriveMap = async (target: Side, donor: Side): Promise<ResolvedMap> => {
|
|
41
|
+
const tAv = await loadAvatar(target.rig, target.avatar), dAv = await loadAvatar(donor.rig, donor.avatar)
|
|
42
|
+
const title = `${basename(target.file)} β ${basename(donor.file)}`
|
|
43
|
+
if (tAv && dAv) {
|
|
44
|
+
const { mapAvatars, describeHumanoidMap, describeBiped, classifyBiped, avatarToFile } = await import("../../retarget/humanoid")
|
|
45
|
+
const hm = mapAvatars(tAv, dAv)
|
|
46
|
+
log(`π§ humanoid map ${title}`)
|
|
47
|
+
for (const [ label, side ] of [ [ "target", target ], [ "donor", donor ] ] as const) {
|
|
48
|
+
try { log(` ${label}: ${side.avatar ? `avatar from ${basename(side.avatar)} Β· ` : ""}${describeBiped(side.rig, classifyBiped(side.rig))}`) }
|
|
49
|
+
catch { log(` ${label}: avatar from ${basename(side.avatar ?? "?")} (shape did not classify)`) }
|
|
50
|
+
}
|
|
51
|
+
for (const l of describeHumanoidMap(tAv, dAv, hm)) log(l)
|
|
52
|
+
return {
|
|
53
|
+
humanoid: { target: tAv, donor: dAv },
|
|
54
|
+
spec: { _comment: `humanoid map ${title} β 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) } },
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (tAv || dAv) throw new CliError(`${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`)
|
|
58
|
+
const { autoMap, describeSkeleton } = await import("../../retarget/automap")
|
|
59
|
+
const am = autoMap(target.rig, donor.rig)
|
|
60
|
+
log(`π§ auto-map ${title}`)
|
|
61
|
+
log(` target: ${describeSkeleton(target.rig, am.target)}`)
|
|
62
|
+
log(` donor: ${describeSkeleton(donor.rig, am.donor)}`)
|
|
63
|
+
for (const l of am.lines) log(l)
|
|
64
|
+
return { spec: { _comment: `auto-mapped ${title} by skeleton shape β review before trusting`, root: am.root, bones: am.map, legs: am.legs } }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Read a hand-written (or previously derived) map; its avatars, when it carries them, keep the humanoid treatment. */
|
|
68
|
+
export const readMap = async (mapPath: string | undefined, target: Rig, donor: Rig): Promise<ResolvedMap> => {
|
|
69
|
+
if (!mapPath) throw usageError("--map <map.json> is required (--map auto / --auto-map [out.json] to derive one from the skeletons, --skeletons to list them)")
|
|
70
|
+
if (!existsSync(mapPath)) throw notFound(mapPath)
|
|
71
|
+
const spec = JSON.parse(readFileSync(mapPath, "utf8")) as MapSpec
|
|
72
|
+
if (!spec.bones) throw usageError(`${mapPath} has no "bones" object`)
|
|
73
|
+
if (!spec.avatars) return { spec }
|
|
74
|
+
try {
|
|
75
|
+
const { avatarFromFile } = await import("../../retarget/humanoid")
|
|
76
|
+
return { spec, humanoid: { target: avatarFromFile(target, spec.avatars.target), donor: avatarFromFile(donor, spec.avatars.donor) } }
|
|
77
|
+
} catch (e) {
|
|
78
|
+
log(` β ${basename(mapPath)} carries avatars that do not fit these rigs (${(e as Error).message}) β plain map`)
|
|
79
|
+
return { spec }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// What a retarget prints: the two skeletons side by side (--skeletons), the avatar's transfer
|
|
2
|
+
// defaults, and the run's report.
|
|
3
|
+
|
|
4
|
+
import { basename } from "node:path"
|
|
5
|
+
import type { Avatar } from "../../retarget/humanoid"
|
|
6
|
+
import type { retarget } from "../../retarget/retarget"
|
|
7
|
+
import type { Rig } from "../../retarget/rig"
|
|
8
|
+
import { log } from "../shared"
|
|
9
|
+
|
|
10
|
+
type Report = ReturnType<typeof retarget>
|
|
11
|
+
|
|
12
|
+
/** Depth-first, parents before children β the shape you actually read a skeleton in. */
|
|
13
|
+
const skeletonColumn = (rig: Rig): string[] => {
|
|
14
|
+
const kids: number[][] = rig.names.map(() => [])
|
|
15
|
+
const roots: number[] = []
|
|
16
|
+
rig.parents.forEach((par, i) => (par >= 0 ? kids[par].push(i) : roots.push(i)))
|
|
17
|
+
const out: string[] = []
|
|
18
|
+
const walk = (i: number, d: number): void => {
|
|
19
|
+
out.push(`${" ".repeat(d)}${rig.names[i] || "?"}${rig.bind.fromSkin[i] ? " *" : ""}`)
|
|
20
|
+
for (const c of kids[i]) walk(c, d + 1)
|
|
21
|
+
}
|
|
22
|
+
roots.forEach((r) => walk(r, 0))
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const printSkeletons = async (target: Rig, targetFile: string, donor: Rig, donorFile: string): Promise<void> => {
|
|
27
|
+
const a = skeletonColumn(target), b = skeletonColumn(donor)
|
|
28
|
+
log(`\n${`TARGET ${basename(targetFile)} (${target.names.length})`.padEnd(46)}DONOR ${basename(donorFile)} (${donor.names.length})`)
|
|
29
|
+
log(`${"(* = bound by a skin)".padEnd(46)}(* = bound by a skin)`)
|
|
30
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) log(`${(a[i] ?? "").slice(0, 44).padEnd(46)}${b[i] ?? ""}`)
|
|
31
|
+
log(`\ndonor clips: ${donor.clips.map((c) => `"${c.name}" ${c.duration.toFixed(2)}s`).join(", ") || "none"}`)
|
|
32
|
+
const { classifyBiped, describeBiped } = await import("../../retarget/humanoid")
|
|
33
|
+
for (const [ label, rig ] of [ [ "target", target ], [ "donor", donor ] ] as const) {
|
|
34
|
+
try { log(`${label} reads as a humanoid: ${describeBiped(rig, classifyBiped(rig))}`) } catch { /* not a biped β fine */ }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The avatar's transfer block (the studio's decisions) supplies the defaults; flags override. */
|
|
39
|
+
export const printTransfer = (tr: Avatar["transfer"]): void => {
|
|
40
|
+
if (!tr || !Object.keys(tr).length) return
|
|
41
|
+
const parts: string[] = []
|
|
42
|
+
if (tr.rootMotion) parts.push(`root motion ${tr.rootMotion}`)
|
|
43
|
+
if (tr.plant !== undefined) parts.push(tr.plant ? "plant" : "no plant")
|
|
44
|
+
if (tr.hands) parts.push("hands IK")
|
|
45
|
+
if (tr.twist !== undefined) parts.push(`twist ${tr.twist}`)
|
|
46
|
+
for (const s of [ "Left", "Right" ] as const) if (tr.roll?.[s]) parts.push(`${s[0]} roll ${tr.roll[s]! > 0 ? "+" : ""}${tr.roll[s]}Β°`)
|
|
47
|
+
const byVerdict = (v: string): string[] => Object.entries(tr.align ?? {}).filter(([ , x ]) => x === v).map(([ k ]) => k)
|
|
48
|
+
const keep = byVerdict("keep"), al = byVerdict("align")
|
|
49
|
+
if (keep.length) parts.push(`keep ${keep.join(", ")}`)
|
|
50
|
+
if (al.length) parts.push(`align ${al.join(", ")}`)
|
|
51
|
+
log(` avatar transfer: ${parts.join(" Β· ")}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const printScaleNotes = (target: Rig, donor: Rig): void => {
|
|
55
|
+
for (const [ label, rig ] of [ [ "target", target ], [ "donor", donor ] ] as const) {
|
|
56
|
+
if (rig.bind.scale !== 1) log(` βΉ ${label} skin's bind space is Γ${+(1 / rig.bind.scale).toPrecision(4)} of node space β bind pose rescaled Γ${+rig.bind.scale.toPrecision(4)} to match`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const printReport = (rep: Report, opts: { humanoid: boolean, ikWanted: boolean }): void => {
|
|
61
|
+
if (opts.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" : ""}`)
|
|
62
|
+
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`)
|
|
63
|
+
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(", ") || "-"})`)
|
|
64
|
+
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"}`)
|
|
65
|
+
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` : ""}`)
|
|
66
|
+
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` : ""}`)
|
|
67
|
+
else if (opts.ikWanted) log(` βΉ no leg chains detected β feet follow FK only (name them in the map: "legs": [ β¦ ])`)
|
|
68
|
+
if (rep.root) log(` root motion: ${rep.rootMotion}${rep.hipsBob ? ` Β· hips carry the ${rep.hipsBob === "donor" ? "donor's own bob" : "vertical bob"}` : ""}`)
|
|
69
|
+
if (rep.twist.length) log(` twist helpers: ${rep.twist.map((t) => `${t.bone} (${(t.at * 100).toFixed(0)}% of ${t.of})`).join(", ")}`)
|
|
70
|
+
if (rep.bridges.length) log(` bridged (interpolated between mapped bones): ${rep.bridges.join(", ")}`)
|
|
71
|
+
if (rep.unmappedTarget.length) log(` unmapped (left at rest): ${rep.unmappedTarget.join(", ")}`)
|
|
72
|
+
for (const c of rep.clips) {
|
|
73
|
+
const worst = (kind: string): { n: number, mm: string } => { const e = c.events.filter((x) => x.kind === kind); return { n: e.length, mm: (Math.max(0, ...e.map((x) => x.value ?? 0)) * 1000).toFixed(0) } }
|
|
74
|
+
const reach = worst("reach"), below = worst("below")
|
|
75
|
+
log(` π ${c.name.padEnd(22)} ${c.duration.toFixed(2)}s ${c.frames} frames β ${c.keys} keys${reach.n ? ` β ${reach.n}Γ out of reach (${reach.mm} mm)` : ""}${below.n ? ` β ${below.n}Γ toe under the ground (${below.mm} mm)` : ""}`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import { bool, defineCommand, int, missing, notFound, num, str, usageError } from "../cli-kit"
|
|
4
|
+
import { log, regexFlag, seconds } from "./shared"
|
|
5
|
+
|
|
6
|
+
/** `x0,z0,x1,z1` (any corner order) β [minX, minZ, maxX, maxZ]. */
|
|
7
|
+
const parseDropAt = (s: string | undefined): [number, number, number][] | undefined => {
|
|
8
|
+
if (!s) return undefined
|
|
9
|
+
return s.split(";").filter((p) => p.trim()).map((p) => {
|
|
10
|
+
const v = p.split(",").map(Number)
|
|
11
|
+
if (v.length !== 3 || v.some((n) => !Number.isFinite(n))) throw usageError(`--drop-at wants x,y,z;x,y,z β got "${p}"`)
|
|
12
|
+
return [ v[0], v[1], v[2] ] as [number, number, number]
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const parseRegion = (raw: string | undefined): [number, number, number, number] | undefined => {
|
|
17
|
+
if (raw === undefined) return undefined
|
|
18
|
+
const v = raw.split(",").map(Number)
|
|
19
|
+
if (v.length !== 4 || v.some((x) => !Number.isFinite(x))) throw usageError(`--region wants x0,z0,x1,z1 (Unity metres), got "${raw}"`)
|
|
20
|
+
return [ Math.min(v[0], v[2]), Math.min(v[1], v[3]), Math.max(v[0], v[2]), Math.max(v[1], v[3]) ]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default defineCommand({
|
|
24
|
+
name: "scene",
|
|
25
|
+
summary: "Unity scene β level JSON, or (--project) GLBs + scene files inside a LeCodes project",
|
|
26
|
+
usage: "<map.unity>",
|
|
27
|
+
description: `
|
|
28
|
+
Nested prefabs are expanded with their overrides into KINDS (meshes + materials + LOD levels +
|
|
29
|
+
colliders), one placement per instance in LeCodes' frame; the terrain (binary TerrainData read)
|
|
30
|
+
with its painted trees + detail meshes as placements; the lights.
|
|
31
|
+
|
|
32
|
+
With --project it is built INTO a LeCodes project: one GLB per kind (assets/<map>/), a prefab
|
|
33
|
+
scene file per kind with its colliders (src/<map>/), the terrain (.terrain + packed layers) and
|
|
34
|
+
src/<map>.scene.ts placing everything. The command line is written into that file's header:
|
|
35
|
+
re-import with the same line, the flags carry decisions the file cannot show.`,
|
|
36
|
+
flags: {
|
|
37
|
+
o: str("the level JSON (default: <map>.level.json beside the scene)", { value: "level.json" }),
|
|
38
|
+
project: str("build into this LeCodes project instead", { value: "<dir>", group: "Into a project" }),
|
|
39
|
+
name: str("map name: folder + scene file stem (default: the Unity scene's name, lower-cased)", { value: "map" }),
|
|
40
|
+
"max-size": int("texture size cap (1024)"),
|
|
41
|
+
png: bool("PNG textures, no KTX2"),
|
|
42
|
+
force: bool("reconvert every GLB (default: only the kinds that changed)"),
|
|
43
|
+
"no-sun": bool("leave the pack's sun out of the scene file: the level owns the directional light"),
|
|
44
|
+
foliage: str("which kinds wear the vegetation tier ('foliage: true': wind, touch bending, per-copy tint): exactly the kind names matching this. Default: kinds with a material in a Vegetation/Plants/Grass/Trees folder or named so (Grass_01, M_Bush_Set) β never by alpha mode; the verdict is printed", { value: "<re>" }),
|
|
45
|
+
"no-foliage": bool("β¦or none"),
|
|
46
|
+
"no-lightmap": str("kinds whose name matches take 'lightmap: false' β no rect in the bake, lit in real time. Written for LAMP FIXTURES (the bulb sits inside its housing, so the fixture's own faces come out the brightest texels in the level) and for anything the game moves", { value: "<re>" }),
|
|
47
|
+
"collider-fit": num(`metres a pack's BOX collider face may stand off the geometry under it before the kind takes a mesh collider instead (default 0.015). A box is an approximation: the shooting range's wall module is flat at Β±0.100 m while its box was fitted to the Β±0.138 m of its edge trim, so bullets stopped 3.8 cm in front of the wall and their holes and sparks landed in mid-air. 0 = every box with any gap goes to a mesh, a big number = keep the pack's boxes whatever they cover`, { value: "<m>" }),
|
|
48
|
+
"no-shadows": str("kinds whose name matches take 'castShadows: false': a pack's BACKGROUND MOUNTAINS are 2 km meshes a kilometre out that Unity's shadow distance never reaches, and here their shadow lies across the whole map", { value: "<re>" }),
|
|
49
|
+
"no-colliders": bool("write NO colliders: the kinds are pure visuals, nothing gets a Physics body. For a viewer, or a level whose 78 000 placements would otherwise be 20 000 Jolt bodies before the game adds one of its own"),
|
|
50
|
+
"max-tris": int("triangles a kind may have before the import simplifies it (default 50000, 0 = no cap): a photogrammetry scan otherwise arrives as a million-triangle prop AND mesh collider"),
|
|
51
|
+
"no-trim-cards": bool("skip the alpha-card trim the import otherwise runs (it can empty a kind whose decal atlas is mostly clear)"),
|
|
52
|
+
filter: str("keep only instances whose kind name / scene group / node name matches", { value: "<re>", group: "Subset" }),
|
|
53
|
+
"drop-at": str(`leave OUT the placements standing at these points ("x,y,z;x,y,z", the lecodes frame the scene file prints, within 10 cm): the one instance of a kind the game moves while its twins stay put β the range's five lane targets, which Unity names like the sixth on a shelf`, { value: "<x,y,z;β¦>" }),
|
|
54
|
+
drop: str("leave every instance whose kind / group / node name matches OUT of the scene (and its kind out of the import): props a pack authors sunk into the geometry they decorate", { value: "<re>" }),
|
|
55
|
+
group: str("only instances under a scene group whose path contains this", { value: "name" }),
|
|
56
|
+
region: str("only instances inside this Unity X/Z rectangle", { value: "x0,z0,x1,z1" }),
|
|
57
|
+
"no-trees": bool("skip the terrain's painted trees"),
|
|
58
|
+
"no-details": bool("skip the terrain's detail meshes"),
|
|
59
|
+
"include-inactive": bool("import inactive GameObjects too"),
|
|
60
|
+
handedness: str("how Unity's left-handed frame is undone (default x: βx, (x, βy, βz, w))", { value: "x|none", group: "Source" }),
|
|
61
|
+
index: str("GUID β path table (default: found above the scene, else .meta sidecars)", { value: "<unity-index.txt>" }),
|
|
62
|
+
},
|
|
63
|
+
examples: [
|
|
64
|
+
`lecodes assets scene Outdoor.unity --project . --name school --force`,
|
|
65
|
+
`lecodes assets scene LV_Showcase.unity --project . --drop Sign --no-lightmap lamp`,
|
|
66
|
+
`lecodes assets scene Demo.unity --region 0,0,120,120 -o demo.level.json`,
|
|
67
|
+
],
|
|
68
|
+
run: async ({ args, flags, argv }) => {
|
|
69
|
+
const input = args[0]
|
|
70
|
+
if (!input) throw missing("the .unity scene")
|
|
71
|
+
if (!existsSync(input)) throw notFound(input)
|
|
72
|
+
const region = parseRegion(flags.region)
|
|
73
|
+
const hand = flags.handedness ?? "x"
|
|
74
|
+
if (hand !== "x" && hand !== "none") throw usageError("--handedness wants x | none")
|
|
75
|
+
const { importUnityScene, describeSceneImport } = await import("../unity/scene")
|
|
76
|
+
const t0 = performance.now()
|
|
77
|
+
const r = await importUnityScene(input, {
|
|
78
|
+
indexFile: flags.index, handedness: hand, filter: regexFlag(flags.filter), drop: regexFlag(flags.drop), dropAt: parseDropAt(flags["drop-at"]), group: flags.group, region,
|
|
79
|
+
includeInactive: flags["include-inactive"], noTrees: flags["no-trees"], noDetails: flags["no-details"],
|
|
80
|
+
})
|
|
81
|
+
log(`πΊ ${input}`)
|
|
82
|
+
for (const l of describeSceneImport(r)) log(` ${l}`)
|
|
83
|
+
if (flags.project) {
|
|
84
|
+
const { writeLeCodesLevel } = await import("../unity/lecodesLevel")
|
|
85
|
+
const res = await writeLeCodesLevel(r, {
|
|
86
|
+
project: flags.project, name: flags.name, force: flags.force, maxSize: flags["max-size"] ?? 1024, ktx2: !flags.png, log,
|
|
87
|
+
foliage: regexFlag(flags.foliage), noFoliage: flags["no-foliage"], noSun: flags["no-sun"],
|
|
88
|
+
noLightmap: regexFlag(flags["no-lightmap"]), noTrimCards: flags["no-trim-cards"], colliderFit: flags["collider-fit"], noColliders: flags["no-colliders"], noShadows: regexFlag(flags["no-shadows"]), maxTris: flags["max-tris"],
|
|
89
|
+
// the invocation, for the scene file's header (quote what a shell would need quoted)
|
|
90
|
+
command: "lecodes assets scene " + argv.map((a) => (/[\s"'|<>^&();]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(" "),
|
|
91
|
+
})
|
|
92
|
+
log(`πΎ ${res.scene} β ${res.kinds} kinds (${res.converted} converted, ${res.reused} up to date), ${r.instances.length} placements, ${seconds(t0)}`)
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
const out = flags.o ?? input.replace(/\.unity$/i, "") + ".level.json"
|
|
96
|
+
const dir = dirname(out)
|
|
97
|
+
if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
98
|
+
const { terrainData: _, ...json } = r
|
|
99
|
+
writeFileSync(out, JSON.stringify(json, null, 1) + "\n")
|
|
100
|
+
log(`πΎ wrote ${out} (${r.instances.length} instances, ${seconds(t0)}) β frame: ${r.frame}`)
|
|
101
|
+
return 0
|
|
102
|
+
},
|
|
103
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// What several commands share: flag groups (spread into `flags:`), the doctor's options read off
|
|
2
|
+
// them, the doctor's result printed, and input expansion.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import { bool, int, num, str, type FlagValues } from "../cli-kit"
|
|
7
|
+
import type { ApplyResult, DoctorOptions } from "../doctor/doctor"
|
|
8
|
+
|
|
9
|
+
export const log = (s: string): void => console.log(s)
|
|
10
|
+
export const warnLine = (w: { level: string, message: string }): void => log(` ${w.level === "warn" ? "β " : "βΉ"} ${w.message}`)
|
|
11
|
+
export const seconds = (t0: number): string => `${((performance.now() - t0) / 1000).toFixed(1)}s`
|
|
12
|
+
export const megabytes = (bytes: number): string => `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
|
13
|
+
|
|
14
|
+
// ---- flag groups ------------------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** Where an Unreal asset's skeleton / pack is found. */
|
|
17
|
+
export const unrealFlags = {
|
|
18
|
+
skeleton: str("(Unreal) the skeleton a clip was authored on β by default it is found in the pack by the object path the clip carries", { value: "<SK.uasset>", group: "Unreal" }),
|
|
19
|
+
"content-root": str("(Unreal) the pack's Content folder, if the search cannot find it", { value: "<dir>" }),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The doctor passes `convert` and `doctor` both run. */
|
|
23
|
+
export const doctorFlags = {
|
|
24
|
+
fix: bool("run the doctor passes (bone cap, texture normalize) before writing", { group: "Doctor passes" }),
|
|
25
|
+
ktx2: bool("textures β KTX2 (needs KTX-Software: toktx, or ktx β₯ 4.4): colour on ETC1S, every DATA map (normal, metal/rough, occlusion) on UASTC"),
|
|
26
|
+
"ktx2-hq": bool("the same as --ktx2"),
|
|
27
|
+
"ktx2-uastc": bool("EVERYTHING on UASTC β for HERO assets (first-person weapon, gloves) where ETC1S blocking is visible at 30 cm. ~8x larger"),
|
|
28
|
+
"max-size": int("texture size cap (2048)"),
|
|
29
|
+
target: int("bone cap (256)"),
|
|
30
|
+
"fix-metallic": bool("zero metallic on materials that carry it without an MR texture (they may render BLACK without IBL)"),
|
|
31
|
+
"fix-scale": bool("bake an auto unit fix (cm/mm/inch/0.01-armature β metres) β scale is always REPORTED, applied only with a flag"),
|
|
32
|
+
scale: num("bake an explicit factor", { positive: true }),
|
|
33
|
+
"scale-to": num("make the largest dimension M metres", { value: "M", positive: true }),
|
|
34
|
+
"fix-emissive": bool("drop a self-lit emissive (emissive = base colour β ~2x too bright)"),
|
|
35
|
+
unlit: bool("flat base colour, no lighting (KHR_materials_unlit) β for stylised assets"),
|
|
36
|
+
"trim-cards": bool("cut every alpha-MASK quad (a foliage card) to the box of its opaque texels and drop empty ones β less discarded fill per card; PNG/JPEG base colour only, so run it with --ktx2 rather than after it"),
|
|
37
|
+
"trim-margin": int("texels kept around a trimmed card (2)"),
|
|
38
|
+
merge: bool("fewer draw calls: every part's primitive into ONE per material class (alpha mode + cutoff, double-sided, unlit) with ONE material whose base / normal / ORM / emissive maps are ATLASES of the parts' maps (factors baked in, UVs remapped); the parts' skins become one skin, a static part under a bone is skinned to it, a plain node's transform is baked. Node-animated parts and morph targets stay as they are. KTX2 parts decode through KTX-Software's ktx tool (next to toktx). The atlas is written PNG and goes through the texture passes like any map (--ktx2 encodes it)"),
|
|
39
|
+
"atlas-size": int("atlas edge cap (2048): the smallest power of two that holds every part's map at full size, else the tiles shrink to fit BY SURFACE AREA β every part at the same texels per metre (a torso keeps its map, a buckle gets a small tile), none past its native size; --max-size does not apply to the atlas"),
|
|
40
|
+
"atlas-uniform": bool("β¦or shrink every tile by the same factor instead"),
|
|
41
|
+
"atlas-pad": int("edge-replication gutter per tile (8 texels)"),
|
|
42
|
+
"merge-exclude": str("parts (node names) to leave out β the ones the game hides or swaps", { value: "<regex>" }),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type DoctorFlagValues = FlagValues<typeof doctorFlags>
|
|
46
|
+
|
|
47
|
+
export const wantsKtx2 = (f: DoctorFlagValues): boolean => f.ktx2 || f["ktx2-hq"] || f["ktx2-uastc"]
|
|
48
|
+
export const wantsScale = (f: DoctorFlagValues): boolean => f["fix-scale"] || f.scale !== undefined || f["scale-to"] !== undefined
|
|
49
|
+
|
|
50
|
+
/** Any flag that CHANGES the file (so `doctor` writes one, and `convert` runs the doctor at all). */
|
|
51
|
+
export const wantsDoctorFix = (f: DoctorFlagValues): boolean =>
|
|
52
|
+
f.fix || wantsKtx2(f) || wantsScale(f) || f.unlit || f["fix-emissive"] || f["trim-cards"] || f.merge
|
|
53
|
+
|
|
54
|
+
export const doctorOptions = (f: DoctorFlagValues): DoctorOptions => ({
|
|
55
|
+
ktx2: wantsKtx2(f), ktx2Hq: f["ktx2-hq"], ktx2Uastc: f["ktx2-uastc"], maxSize: f["max-size"], target: f.target,
|
|
56
|
+
scale: f.scale, scaleTo: f["scale-to"], fixScale: f["fix-scale"],
|
|
57
|
+
unlit: f.unlit, fixEmissive: f["fix-emissive"],
|
|
58
|
+
trimCards: f["trim-cards"], trimMargin: f["trim-margin"],
|
|
59
|
+
merge: f.merge, atlasSize: f["atlas-size"], atlasPad: f["atlas-pad"], mergeExclude: f["merge-exclude"], atlasUniform: f["atlas-uniform"],
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
export const ktx2Progress = (done: number, total: number): void => { process.stdout.write(`\r encoding ktx2β¦ ${done}/${total}${done === total ? "\n" : ""}`) }
|
|
63
|
+
|
|
64
|
+
/** What `applyPlan` did, one line per pass that did anything. */
|
|
65
|
+
export const logDoctorResult = (res: ApplyResult): void => {
|
|
66
|
+
if (res.metallicZeroed) log(` zeroed metallic on ${res.metallicZeroed} material(s)`)
|
|
67
|
+
if (res.bareFixed) log(` gave ${res.bareFixed} material-less primitive(s) a white dielectric material`)
|
|
68
|
+
if (res.emissiveCleared) log(` cleared self-lit emissive on ${res.emissiveCleared} material(s)`)
|
|
69
|
+
if (res.unlit) log(` ${res.unlit} material(s) β unlit (flat base colour, no lighting)`)
|
|
70
|
+
if (res.scaled !== 1) log(` baked scale Γ${+res.scaled.toPrecision(6)}`)
|
|
71
|
+
if (res.merge) {
|
|
72
|
+
log(` merge: ${res.merge.primitivesBefore} primitive(s) β ${res.merge.primitivesAfter} draw call(s)`)
|
|
73
|
+
for (const g of res.merge.groups) log(` ${g.label}: ${g.primitives} primitives / ${g.materials} material(s) β ${g.vertices} vertices, ${g.triangles} triangles${g.atlas ? `, atlas ${g.atlas}` : ""}${g.joints ? `, ${g.joints} joints` : ""}`)
|
|
74
|
+
if (res.merge.skipped.length) log(` skipped: ${res.merge.skipped.slice(0, 6).map((s) => `${s.name} (${s.reason})`).join(", ")}${res.merge.skipped.length > 6 ? "β¦" : ""}`)
|
|
75
|
+
}
|
|
76
|
+
const cards = res.cards
|
|
77
|
+
if (cards) log(` cards: ${cards.trimmed} quad(s) trimmed, ${cards.dropped} empty quad(s) dropped of ${cards.quads} over ${cards.primitives} primitive(s) β card area ${cards.areaBefore > 0 ? Math.round(100 * cards.areaAfter / cards.areaBefore) : 100}% of before${cards.skipped.length ? `; skipped ${cards.skipped.map((s) => `${s.name} (${s.reason})`).slice(0, 3).join(", ")}` : ""}`)
|
|
78
|
+
if (res.lod) {
|
|
79
|
+
for (const t of res.lod.tris) log(` lod: ${t.name} ${t.levels.join(" / ")} tris`)
|
|
80
|
+
for (const b of res.lod.bones) log(` lod bones: ${b.name} last level ${b.before} β ${b.after} joints`)
|
|
81
|
+
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)`)
|
|
82
|
+
if (!res.lod.nodes) log(` lod: no mesh with >= 2000 triangles (or the _LOD nodes are already there)`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- inputs -------------------------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** Positionals β concrete files: plain files, directories, and globs (`*.glb`, `dir/**\/*.glb`).
|
|
89
|
+
* Generated outputs (.ktx2.glb / .fixed.glb) are skipped when EXPANDING a glob or a directory, but
|
|
90
|
+
* kept when named explicitly (e.g. to --dump one). A minimal glob of our own: this also runs under
|
|
91
|
+
* Node, where the lecodes CLI is a plain binary. */
|
|
92
|
+
export const resolveInputs = (args: string[], ext = /\.glb$/i, skipGenerated = true): string[] => {
|
|
93
|
+
const out: string[] = []
|
|
94
|
+
const keep = (f: string): void => { if (ext.test(f) && !(skipGenerated && /\.(ktx2|fixed)\.glb$/i.test(f))) out.push(f) }
|
|
95
|
+
const walk = (dir: string, recursive: boolean, pattern: RegExp): void => {
|
|
96
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
97
|
+
const p = join(dir, entry.name).replace(/\\/g, "/")
|
|
98
|
+
if (entry.isDirectory()) { if (recursive) walk(p, true, pattern) }
|
|
99
|
+
else if (pattern.test(entry.name)) keep(p)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const toRegex = (glob: string): RegExp =>
|
|
103
|
+
new RegExp("^" + glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i")
|
|
104
|
+
for (const raw of args) {
|
|
105
|
+
const a = raw.replace(/\\/g, "/")
|
|
106
|
+
if (/[*?]/.test(a)) {
|
|
107
|
+
const segs = a.split("/")
|
|
108
|
+
const last = segs.pop()!
|
|
109
|
+
const recursive = segs[segs.length - 1] === "**"
|
|
110
|
+
if (recursive) segs.pop()
|
|
111
|
+
const dir = segs.join("/") || "."
|
|
112
|
+
if (existsSync(dir)) walk(dir, recursive, toRegex(last))
|
|
113
|
+
} else if (existsSync(a) && statSync(a).isDirectory()) {
|
|
114
|
+
walk(a.replace(/\/+$/, ""), false, ext)
|
|
115
|
+
} else out.push(a)
|
|
116
|
+
}
|
|
117
|
+
return [ ...new Set(out) ]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `--x <regex>` as a case-insensitive RegExp, or undefined. */
|
|
121
|
+
export const regexFlag = (v: string | undefined): RegExp | undefined => (v ? new RegExp(v, "i") : undefined)
|