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.
Files changed (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,35 @@
1
+ import { bool, CliError, defineCommand, int, missing, str } from "../cli-kit"
2
+ import { log } from "./shared"
3
+
4
+ export default defineCommand({
5
+ name: "sky",
6
+ summary: "Equirectangular HDR → the TWO cubemaps a scene needs (drawn sky + lighting probe)",
7
+ usage: "<env.exr|.hdr>",
8
+ description: `
9
+ Writes sky.ktx (sharp — use it for \`skybox: { texture }\`) and ibl.ktx (prefiltered + SH — lights
10
+ the scene). They are NOT interchangeable: the IBL is blurred by design, so drawing it as the sky
11
+ looks soft. Also reports WHERE THE SUN IS in the environment, so the scene's directional light can
12
+ be aimed to match instead of by eye.
13
+
14
+ Needs filament's cmgen (LECODES_CMGEN / ~/.lecodes/cmgen).`,
15
+ flags: {
16
+ o: str("where they land (default: beside the input)", { value: "dir" }),
17
+ size: int("edge of the DRAWN sky cubemap (default 512)"),
18
+ "ibl-size": int("edge of the lighting probe (default 256 — it is only ever sampled as low-frequency light, so it stays small)"),
19
+ name: str("the sky's file name without extension (default sky)", { value: "n" }),
20
+ "ibl-name": str("the probe's (default ibl)", { value: "n" }),
21
+ "ibl-only": bool("lighting probe only, no drawn sky"),
22
+ "no-sun": bool("take the sun out of the LIGHTING PROBE (the drawn sky keeps it): for a level with a directional sun — the sun in the probe is the same light twice, shadowless"),
23
+ },
24
+ run: async ({ args, flags }) => {
25
+ const input = args[0]
26
+ if (!input) throw missing("the environment map")
27
+ const { buildSky, formatSkyReport } = await import("../sky")
28
+ log(`\n🌤 ${input}`)
29
+ try {
30
+ const res = await buildSky(input, { outDir: flags.o, size: flags.size, iblSize: flags["ibl-size"], skyName: flags.name, iblName: flags["ibl-name"], iblOnly: flags["ibl-only"], noSun: flags["no-sun"], log })
31
+ log(formatSkyReport(res))
32
+ return 0
33
+ } catch (e) { throw new CliError((e as Error).message) }
34
+ },
35
+ })
@@ -0,0 +1,40 @@
1
+ import { resolve } from "node:path"
2
+ import { bool, CliError, defineCommand, int, list, missing, str } from "../cli-kit"
3
+ import { log } from "./shared"
4
+
5
+ export default defineCommand({
6
+ name: "terrain-pack",
7
+ summary: "A terrain's splat layers (≤ 4) → <name>.ktx2 per layer + the PACKED normal maps",
8
+ usage: "<layer|dir> …",
9
+ description: `
10
+ Normals ship two per texture — normals01.ktx2 / normals23.ktx2, layer XY pairs in RG/BA — because
11
+ terrain.mat has nine samplers. A layer = an albedo file (its normal guessed from the name, or
12
+ --normals) or a FOLDER holding *Albedo* + *Normal* (Unity terrain packs). A layer's ROUGHNESS map
13
+ (--roughness, *Roughness*, or a Unity *AlbedoSmoothness* alpha) goes into its albedo's alpha.
14
+ Prints the Terrain.create fragment.`,
15
+ flags: {
16
+ o: str("where they land (default: ./terrain)", { value: "dir" }),
17
+ normals: list(`normal map per layer, positionally ("-" = none)`, { value: "n0 n1 …" }),
18
+ roughness: list(`roughness map per layer, positionally ("-" = none): packed into the layer's albedo ALPHA, which the terrain material multiplies the layer's roughness by (read from the image's green channel: a grey map or a glTF metallicRoughness / ORM one)`, { value: "r0 r1 …" }),
19
+ names: list("layer names → file names (default: from the inputs)", { value: "a b …" }),
20
+ size: int("output edge (1024)"),
21
+ quality: int("UASTC quality 0–4 (1)"),
22
+ png: bool("PNGs instead of KTX2 (no KTX-Software needed)"),
23
+ "flip-y": bool("DirectX-style normal maps (green inverted)"),
24
+ },
25
+ run: async ({ args, flags }) => {
26
+ if (args.length === 0) throw missing("the layers")
27
+ const { resolveTerrainLayer, packTerrainTextures } = await import("../terrainPack")
28
+ const outDir = resolve(flags.o ?? "terrain")
29
+ try {
30
+ const layers = args.map((input, i) => {
31
+ const n = flags.normals[i], r = flags.roughness[i]
32
+ return resolveTerrainLayer(input, n && n !== "-" ? n : undefined, flags.names[i], r && r !== "-" ? r : undefined)
33
+ })
34
+ log(`\n🏔 ${layers.length} layer(s) → ${outDir}`)
35
+ const res = await packTerrainTextures(layers, { outDir, size: flags.size, png: flags.png, flipY: flags["flip-y"], quality: flags.quality, log })
36
+ log(`\n Terrain.create({ …,\n${res.snippet.split("\n").map((l) => " " + l).join("\n")}\n }) (asset() paths relative to the calling file)`)
37
+ return 0
38
+ } catch (e) { throw new CliError((e as Error).message) }
39
+ },
40
+ })
@@ -0,0 +1,43 @@
1
+ import { existsSync } from "node:fs"
2
+ import { basename, dirname, extname, join } from "node:path"
3
+ import { bool, defineCommand, list, missing, notFound, str } from "../cli-kit"
4
+ import { log } from "./shared"
5
+
6
+ export default defineCommand({
7
+ name: "unpack",
8
+ summary: "Stream a .unitypackage: write unity-index.txt (GUID → path) and the matching assets (+ .meta)",
9
+ usage: "<pkg.unitypackage>",
10
+ flags: {
11
+ o: str("where it lands (default: a folder named after the package, beside it)", { value: "dir" }),
12
+ only: list("extract only the asset paths matching these (default: all)", { value: "regex …" }),
13
+ list: bool("print the matching asset paths and exit"),
14
+ },
15
+ run: async ({ args, flags }) => {
16
+ const pkg = args[0]
17
+ if (!pkg) throw missing("the .unitypackage")
18
+ if (!existsSync(pkg)) throw notFound(pkg)
19
+ const { indexUnityPackage, writeUnityIndex, readUnityIndex, extractUnityPackage, INDEX_NAME } = await import("../unity/unpack")
20
+ const outDir = flags.o ?? join(dirname(pkg), basename(pkg, extname(pkg)))
21
+ const t0 = performance.now()
22
+ const indexFile = join(outDir, INDEX_NAME)
23
+ let index
24
+ if (existsSync(indexFile)) { index = readUnityIndex(indexFile); log(`📇 ${index.size} assets (cached ${indexFile})`) }
25
+ else {
26
+ log(`📇 indexing ${pkg} …`)
27
+ index = await indexUnityPackage(pkg, (n, a) => process.stdout.write(`\r ${n} entries, ${a} assets`))
28
+ process.stdout.write("\r")
29
+ writeUnityIndex(outDir, index)
30
+ log(` ${index.size} assets → ${indexFile}`)
31
+ }
32
+ const only = flags.only.map((r) => new RegExp(r, "i"))
33
+ if (flags.list) {
34
+ for (const [ , path ] of index) if (!only.length || only.some((r) => r.test(path))) log(path)
35
+ return 0
36
+ }
37
+ log(`📦 extracting${only.length ? ` (${only.length} pattern(s))` : ""} → ${outDir}`)
38
+ const r = await extractUnityPackage(pkg, index, outDir, { only, progress: (f, b) => process.stdout.write(`\r ${f} files, ${(b / 1e6).toFixed(0)} MB`) })
39
+ process.stdout.write("\r")
40
+ log(` ${r.files} files written (${(r.bytes / 1e6).toFixed(1)} MB) of ${r.matched} matching, ${((performance.now() - t0) / 1000).toFixed(0)}s`)
41
+ return 0
42
+ },
43
+ })
@@ -0,0 +1,109 @@
1
+ import { existsSync, writeFileSync } from "node:fs"
2
+ import { bool, defineCommand, int, missing, notFound, str } from "../cli-kit"
3
+ import { log } from "./shared"
4
+
5
+ const needTarget = (target: string | undefined, what: string): string => {
6
+ if (!target) throw missing(what)
7
+ if (!existsSync(target)) throw notFound(target)
8
+ return target
9
+ }
10
+
11
+ const ls = defineCommand({
12
+ name: "ls",
13
+ summary: "What an UNREAL pack holds: every .uasset by class, grouped by the skeleton it belongs to",
14
+ usage: "<dir>",
15
+ description: `
16
+ With the reason for anything unreadable (a UE4 package, a mesh with no import data, a clip whose
17
+ rig is elsewhere). Reads only the headers — a 800-asset pack lists in a second. convert / probe /
18
+ retarget take .uasset files directly (uncooked UE 5.0-5.7 editor assets: SkeletalMesh, Skeleton,
19
+ AnimSequence).`,
20
+ flags: { all: bool("list every asset, not only the ones this pipeline reads") },
21
+ run: async ({ args, flags }) => {
22
+ const target = needTarget(args[0], "the pack folder")
23
+ const { scanUnreal, formatUnrealListing } = await import("../unreal/list")
24
+ log(formatUnrealListing(target, scanUnreal(target), { all: flags.all }))
25
+ return 0
26
+ },
27
+ })
28
+
29
+ const tex = defineCommand({
30
+ name: "tex",
31
+ summary: "The SOURCE image out of a texture asset (Oodle-compressed — see the README; UE 4.25+ too, zlib bulk data)",
32
+ usage: "<T_x.uasset>",
33
+ flags: {
34
+ o: str("output (default: beside the asset, .png or .jpg as stored)", { value: "out.png" }),
35
+ raw: bool("write it exactly as stored"),
36
+ "max-size": int("downscale to this edge"),
37
+ "flip-green": bool("for a normal map. A PNG source stays lossless"),
38
+ },
39
+ run: async ({ args, flags }) => {
40
+ const target = needTarget(args[0], "the texture asset")
41
+ const { readPackageFile } = await import("../unreal/index")
42
+ const { extractTexture } = await import("../unreal/texture")
43
+ const img = await extractTexture(readPackageFile(target, { ue4: true }))
44
+ const t = img.texture
45
+ log(`🖼 ${t.name}: ${t.source.width}×${t.source.height} ${t.source.format} ${t.source.compression} · ${t.compressionSettings}${t.srgb ? " · sRGB" : ""}`)
46
+ let data = img.data, mime = img.mimeType
47
+ if (!flags.raw && img.swapRb) {
48
+ const { fixChannels } = await import("../unreal/materials")
49
+ const fixed = await fixChannels(data, mime, { swapRb: true, flipGreen: flags["flip-green"], maxSize: flags["max-size"] ?? 0, png: /\.png$/i.test(flags.o ?? "") || mime === "image/png" })
50
+ data = fixed.data; mime = fixed.mimeType
51
+ log(` red/blue swapped back (the source is ${t.source.format})${flags["flip-green"] ? " · normal green flipped" : ""}`)
52
+ }
53
+ const out = flags.o ?? target.replace(/\.uasset$/i, mime === "image/png" ? ".png" : ".jpg")
54
+ writeFileSync(out, data)
55
+ log(`💾 wrote ${out} (${(data.byteLength / 1024).toFixed(0)} KB, ${mime})`)
56
+ return 0
57
+ },
58
+ })
59
+
60
+ const mat = defineCommand({
61
+ name: "mat",
62
+ summary: "A Material's EXPRESSION GRAPH as JSON — for a tool that evaluates what the material computes",
63
+ usage: "<M_x.uasset>",
64
+ description: `
65
+ Its outputs (BaseColor, Opacity, Normal…) and every expression with its edges and settings
66
+ (constants, parameter defaults, texture paths).`,
67
+ flags: { o: str("output (default: printed)", { value: "graph.json" }) },
68
+ run: async ({ args, flags }) => {
69
+ const target = needTarget(args[0], "the material asset")
70
+ const { readPackageFile } = await import("../unreal/index")
71
+ const { readMaterialGraph } = await import("../unreal/materialGraph")
72
+ const graph = readMaterialGraph(readPackageFile(target, { ue4: true }))
73
+ const json = JSON.stringify(graph, null, 2)
74
+ if (!flags.o) { console.log(json); return 0 }
75
+ writeFileSync(flags.o, json)
76
+ log(`💾 ${graph.name}: ${Object.keys(graph.expressions).length} expressions, outputs ${Object.keys(graph.outputs).join(" ")} → ${flags.o}`)
77
+ return 0
78
+ },
79
+ })
80
+
81
+ const wav = defineCommand({
82
+ name: "wav",
83
+ summary: "The imported WAV out of every SoundWave asset (same Oodle buffer as textures)",
84
+ usage: "<Sound dir|.uasset> … -o <dir>",
85
+ flags: {
86
+ o: str("output folder (required)", { value: "<dir>" }),
87
+ ogg: bool("re-encode to mono Vorbis through ffmpeg"),
88
+ stereo: bool("…keeping the channels"),
89
+ quality: int("libvorbis -q (default 5)"),
90
+ },
91
+ run: async ({ args, flags }) => {
92
+ needTarget(args[0], "the sound assets")
93
+ if (!flags.o) throw missing("-o <dir>")
94
+ const { listUassets } = await import("../unreal/list")
95
+ const { exportSounds } = await import("../unreal/sound")
96
+ const { readPackageFile } = await import("../unreal/index")
97
+ const ogg = flags.ogg ? { mono: !flags.stereo, quality: flags.quality } : false as const
98
+ const rows = await exportSounds(args.flatMap((t) => listUassets(t)), flags.o, { ogg, readPackage: readPackageFile, log })
99
+ const total = rows.reduce((s, r) => s + r.bytes, 0)
100
+ log(`💾 ${rows.length} sound${rows.length === 1 ? "" : "s"} → ${flags.o} (${(total / 1024).toFixed(0)} KB${ogg ? ", ogg" : ", wav"})`)
101
+ return rows.length > 0 ? 0 : 1
102
+ },
103
+ })
104
+
105
+ export default defineCommand({
106
+ name: "unreal",
107
+ summary: "Look inside an Unreal pack: list it, pull a texture, a material graph, the sounds",
108
+ commands: [ ls, tex, mat, wav ],
109
+ })
@@ -0,0 +1,56 @@
1
+ import { defineCommand, int, missing, num, str, bool, usageError } from "../cli-kit"
2
+ import { log, resolveInputs, seconds } from "./shared"
3
+
4
+ export default defineCommand({
5
+ name: "unwrap",
6
+ summary: "Lightmap UVs (TEXCOORD_1, xatlas) for `lecodes lightmap bake` — written into the file itself",
7
+ usage: `<in.glb | dir | "*.glb"> …`,
8
+ description: `
9
+ Cuts every static mesh of the file into charts and lays them out in ONE unit square as TEXCOORD_1 — the set the
10
+ lightmap bake traces and the runtime samples. The file is rewritten IN PLACE (a temp file beside it, renamed over
11
+ it) unless -o says where; a file whose primitives all carry TEXCOORD_1 already is skipped (--force unwraps it again).
12
+ Skinned meshes are left as they are (a rig never stays in the baked pose); LOD siblings (<name>_LOD1…) take level
13
+ 0's layout by projection, so every level reads the texels level 0 was baked into.
14
+
15
+ \`lecodes lightmap bake\` runs this itself on the GLBs the app references that have no TEXCOORD_1, so a raw import
16
+ bakes as it is. Run it by hand for what the bake cannot decide: a whole level in one GLB (--groups, --zone).
17
+ The bake re-packs every file's charts at its own density, so only the cutting here matters — the layout padding
18
+ is fixed.`,
19
+ flags: {
20
+ o: str("output file (one input) or folder (several) — default: the input itself", { value: "out" }),
21
+ force: bool("unwrap again files that already carry TEXCOORD_1 (the old set is replaced)"),
22
+ groups: int("split the meshes into this many parts of equal surface (neighbours together), each with a unit square of its own = an atlas rect of its own in the bake: a whole level in ONE GLB is otherwise one rect, which cannot span atlas pages (1)", { group: "Levels in one file" }),
23
+ zone: str("WHERE THE PLAYER WALKS, as boxes in world metres — \"x0,y0,z0,x1,y1,z1[;…]\" (with --groups): what is far from them is unwrapped and baked at a coarser texel, x2 past --zone-near, x4 past twice that, doubling every such distance up to --zone-max (an arena's upper tiers: a 16th of the texels and less); large meshes that span the distances are cut along them", { value: "boxes" }),
24
+ "zone-near": num("the distance to the zone (m) within which the texel is the full one (12)", { positive: true }),
25
+ "zone-max": num("the coarsest texel scale far from the zone, a power of two (4): 16 = 80 cm texels at a 5 cm bake", { positive: true }),
26
+ "zone-up": num("the weight of the vertical distance to the zone (1): 2 = a metre of height counts as two of reach, so what is high above the player goes coarse first", { positive: true }),
27
+ iterations: int("xatlas chart-growing iterations (1; more = better charts, slower)", { group: "Charts" }),
28
+ texel: num("the bake's texel (m) the fixed chart padding is measured in (0.05, the bake's default) — read only where the bake keeps the file's own square (a texture samples TEXCOORD_1, a mesh under two groups)", { value: "M" }),
29
+ },
30
+ examples: [
31
+ "lecodes assets unwrap assets/props",
32
+ `lecodes assets unwrap "assets/school/*.glb" --force`,
33
+ `lecodes assets unwrap assets/arena/Arena.glb --groups 12 --zone "-30,0,-20,30,3,20" --zone-max 16 --zone-up 2`,
34
+ ],
35
+ run: async ({ args, flags }) => {
36
+ const files = resolveInputs(args)
37
+ if (files.length === 0) throw missing("the GLB file(s)")
38
+ if (flags.zone && !(flags.groups && flags.groups > 1)) throw usageError("--zone needs --groups (> 1): a group holds one texel scale")
39
+ const { unwrapGlbFiles, detailUnwrap } = await import("../unwrap")
40
+ const t0 = performance.now()
41
+ if (files.length > 1) log(`Unwrapping ${files.length} files…`)
42
+ const reports = await unwrapGlbFiles(files, {
43
+ out: flags.o, force: flags.force,
44
+ groups: flags.groups, zone: flags.zone, zoneNear: flags["zone-near"], zoneMax: flags["zone-max"], zoneUp: flags["zone-up"],
45
+ iterations: flags.iterations, texel: flags.texel,
46
+ onFile: (r) => {
47
+ const mark = r.status === "unwrapped" ? "✓" : r.status === "failed" ? "✗" : "·"
48
+ log(`${mark} ${r.input} — ${r.summary}${r.status === "skipped" && r.reason?.startsWith("has TEXCOORD_1") ? " (--force unwraps it again)" : ""}`)
49
+ if (r.status !== "skipped") for (const l of detailUnwrap(r)) log(` ${l}`)
50
+ },
51
+ })
52
+ const count = (s: string): number => reports.filter((r) => r.status === s).length
53
+ if (files.length > 1) log(`\n${count("unwrapped")} unwrapped · ${count("skipped")} skipped · ${count("failed")} failed in ${seconds(t0)}`)
54
+ return count("failed") > 0 ? 1 : 0
55
+ },
56
+ })
@@ -0,0 +1,286 @@
1
+ // Card trimming (`lecodes assets doctor --trim-cards`): shrink alpha-MASK quads to their opaque texels.
2
+ //
3
+ // A foliage card is a quad over a leaf/grass texture that is mostly transparent — half or more of
4
+ // every card the GPU rasterises is texels the masked shader then discards, and on a level of
5
+ // thousands of cards that discarded fill is the frame (Twig & Twine's vegetation: 17–20 ms of 33).
6
+ // The fix is geometric and lossless for what is drawn: each quad whose texture coordinates form an
7
+ // axis-aligned rectangle is cut down to the bounding box of the opaque texels inside that rectangle
8
+ // (plus a margin for the mips), its four vertices moved by bilinear interpolation inside the old
9
+ // quad, its UVs set to the new rectangle. Quads with no opaque texel at all are dropped. Anything
10
+ // that is not such a quad (a triangle fan, a bent strip, a vertex shared with another quad) is
11
+ // left alone — a subdivided card keeps its shape.
12
+ //
13
+ // Runs on PNG / JPEG / WebP / TGA / BMP base-colour textures only (KTX2 has no decoder here): in the
14
+ // doctor's order it comes BEFORE the texture passes, so `--ktx2` files trim first and encode after; a
15
+ // GLB that already carries KTX2 is reported and skipped (re-run the import / conversion from the
16
+ // source textures with the flag).
17
+ import type { Accessor, Document, Primitive } from '@gltf-transform/core'
18
+ import { weldPrimitive } from '@gltf-transform/functions'
19
+ import { decodeImage } from '../unity/image'
20
+
21
+ export interface CardTrimOptions {
22
+ /** Trim alpha-MASK quads to their opaque texels (see the header). */
23
+ trimCards?: boolean
24
+ /** Texels kept around the opaque box at the base mip (default 2 — a mip-2 texel each side). */
25
+ trimMargin?: number
26
+ }
27
+ export interface CardTrimPrimPlan { prim: Primitive; name: string; material: string; triangles: number; texture: 'ok' | 'ktx2' | 'none' | 'transform' }
28
+ export interface CardTrimPlan { enabled: boolean; margin: number; prims: CardTrimPrimPlan[] }
29
+ export interface CardTrimResult {
30
+ primitives: number
31
+ quads: number
32
+ trimmed: number
33
+ dropped: number
34
+ /** UV-space area of every quad before / after (1 = the whole texture). */
35
+ areaBefore: number
36
+ areaAfter: number
37
+ skipped: { name: string; reason: string }[]
38
+ }
39
+
40
+ const primTris = (p: Primitive): number => {
41
+ if (p.getMode() !== 4) return 0
42
+ const idx = p.getIndices()
43
+ return Math.floor((idx ? idx.getCount() : (p.getAttribute('POSITION')?.getCount() ?? 0)) / 3)
44
+ }
45
+
46
+ /** Which primitives the pass would touch (no decoding yet — that happens in apply). */
47
+ export function planCardTrim(doc: Document, opts: CardTrimOptions): CardTrimPlan {
48
+ const prims: CardTrimPrimPlan[] = []
49
+ for (const mesh of doc.getRoot().listMeshes()) {
50
+ for (const prim of mesh.listPrimitives()) {
51
+ const mat = prim.getMaterial()
52
+ if (!mat || mat.getAlphaMode() !== 'MASK' || prim.getMode() !== 4) continue
53
+ if (!prim.getAttribute('POSITION') || !prim.getAttribute('TEXCOORD_0')) continue
54
+ const tex = mat.getBaseColorTexture()
55
+ const info = mat.getBaseColorTextureInfo()
56
+ const texture: CardTrimPrimPlan['texture'] = !tex ? 'none'
57
+ : (tex.getMimeType() || '').includes('ktx2') ? 'ktx2'
58
+ : info?.getExtension('KHR_texture_transform') ? 'transform' : 'ok'
59
+ prims.push({ prim, name: mesh.getName() || 'mesh', material: mat.getName() || 'material', triangles: primTris(prim), texture })
60
+ }
61
+ }
62
+ return { enabled: !!opts.trimCards && prims.length > 0, margin: Math.max(0, Math.round(opts.trimMargin ?? 2)), prims }
63
+ }
64
+
65
+ /** Summed-area table over the opaque mask, so any rectangle's opaque count is four reads. */
66
+ class OpaqueTable {
67
+ readonly w: number
68
+ readonly h: number
69
+ private readonly sat: Uint32Array
70
+ constructor(rgba: Uint8Array, w: number, h: number, cutoff: number) {
71
+ this.w = w; this.h = h
72
+ const sat = new Uint32Array((w + 1) * (h + 1))
73
+ const stride = w + 1
74
+ for (let y = 0; y < h; y++) {
75
+ let row = 0
76
+ for (let x = 0; x < w; x++) {
77
+ if (rgba[(y * w + x) * 4 + 3] >= cutoff) row++
78
+ sat[(y + 1) * stride + x + 1] = sat[y * stride + x + 1] + row
79
+ }
80
+ }
81
+ this.sat = sat
82
+ }
83
+ /** Opaque texels in [x0, x1) × [y0, y1) (clamped). */
84
+ count(x0: number, y0: number, x1: number, y1: number): number {
85
+ x0 = Math.max(0, Math.min(this.w, x0)); x1 = Math.max(0, Math.min(this.w, x1))
86
+ y0 = Math.max(0, Math.min(this.h, y0)); y1 = Math.max(0, Math.min(this.h, y1))
87
+ if (x1 <= x0 || y1 <= y0) return 0
88
+ const s = this.w + 1
89
+ return this.sat[y1 * s + x1] - this.sat[y0 * s + x1] - this.sat[y1 * s + x0] + this.sat[y0 * s + x0]
90
+ }
91
+ /** Tight box of the opaque texels inside [x0, x1) × [y0, y1), or null when there are none. */
92
+ bounds(x0: number, y0: number, x1: number, y1: number): [number, number, number, number] | null {
93
+ if (this.count(x0, y0, x1, y1) === 0) return null
94
+ let bx0 = x0, bx1 = x1, by0 = y0, by1 = y1
95
+ while (bx0 < bx1 && this.count(bx0, y0, bx0 + 1, y1) === 0) bx0++
96
+ while (bx1 > bx0 && this.count(bx1 - 1, y0, bx1, y1) === 0) bx1--
97
+ while (by0 < by1 && this.count(x0, by0, x1, by0 + 1) === 0) by0++
98
+ while (by1 > by0 && this.count(x0, by1 - 1, x1, by1) === 0) by1--
99
+ return [ bx0, by0, bx1, by1 ]
100
+ }
101
+ }
102
+
103
+ type Quad = { tris: [number, number]; corners: [number, number, number, number]; u0: number; u1: number; v0: number; v1: number }
104
+
105
+ const EPS = 1e-5
106
+
107
+ /** Pair the triangles into axis-aligned UV quads whose four vertices belong to nobody else. */
108
+ function findQuads(indices: Uint32Array, uv: Float32Array): Quad[] {
109
+ const triCount = indices.length / 3
110
+ const usage = new Map<number, number>()
111
+ for (const i of indices) usage.set(i, (usage.get(i) ?? 0) + 1)
112
+ const byEdge = new Map<string, number[]>()
113
+ for (let t = 0; t < triCount; t++) {
114
+ for (let e = 0; e < 3; e++) {
115
+ const a = indices[t * 3 + e], b = indices[t * 3 + (e + 1) % 3]
116
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`
117
+ const list = byEdge.get(key)
118
+ if (list) list.push(t); else byEdge.set(key, [ t ])
119
+ }
120
+ }
121
+ const taken = new Uint8Array(triCount)
122
+ const quads: Quad[] = []
123
+ for (const [ , tris ] of byEdge) {
124
+ if (tris.length !== 2) continue
125
+ const [ ta, tb ] = tris
126
+ if (taken[ta] || taken[tb]) continue
127
+ const verts = new Set<number>()
128
+ for (const t of [ ta, tb ]) for (let e = 0; e < 3; e++) verts.add(indices[t * 3 + e])
129
+ if (verts.size !== 4) continue
130
+ // every vertex used by these two triangles only (a shared vertex would drag a neighbour along)
131
+ let own = true
132
+ for (const v of verts) {
133
+ let n = 0
134
+ for (const t of [ ta, tb ]) for (let e = 0; e < 3; e++) if (indices[t * 3 + e] === v) n++
135
+ if (usage.get(v) !== n) { own = false; break }
136
+ }
137
+ if (!own) continue
138
+ const vs = [ ...verts ]
139
+ const us = vs.map((v) => uv[v * 2]), vvs = vs.map((v) => uv[v * 2 + 1])
140
+ const u0 = Math.min(...us), u1 = Math.max(...us), v0 = Math.min(...vvs), v1 = Math.max(...vvs)
141
+ if (u1 - u0 < EPS || v1 - v0 < EPS) continue
142
+ // OUTSIDE THE IMAGE = TILED, and the opaque test below cannot read a tiled quad: it clamps the
143
+ // rect to the texture, so a quad at u 12.5..13.2 of a REPEAT-wrapped decal sheet measures the
144
+ // last column of texels, finds nothing opaque and the quad is dropped as an empty card. That is
145
+ // how five `SM_Floor_stripe_*` kinds — 101 lane markings on the range's floor — arrived as GLBs
146
+ // with textures, nodes and not one triangle (2026-09-17). A tiled quad is not a foliage card;
147
+ // leave it alone.
148
+ if (u0 < -EPS || v0 < -EPS || u1 > 1 + EPS || v1 > 1 + EPS) continue
149
+ // each vertex sits on a corner, and every corner is taken once
150
+ const corners: (number | undefined)[] = [ undefined, undefined, undefined, undefined ] // (u0,v0) (u1,v0) (u0,v1) (u1,v1)
151
+ let ok = true
152
+ for (let k = 0; k < 4 && ok; k++) {
153
+ const su = Math.abs(us[k] - u0) < EPS ? 0 : Math.abs(us[k] - u1) < EPS ? 1 : -1
154
+ const sv = Math.abs(vvs[k] - v0) < EPS ? 0 : Math.abs(vvs[k] - v1) < EPS ? 1 : -1
155
+ if (su < 0 || sv < 0) { ok = false; break }
156
+ const c = sv * 2 + su
157
+ if (corners[c] !== undefined) { ok = false; break }
158
+ corners[c] = vs[k]
159
+ }
160
+ if (!ok || corners.some((c) => c === undefined)) continue
161
+ taken[ta] = 1; taken[tb] = 1
162
+ quads.push({ tris: [ ta, tb ], corners: corners as [number, number, number, number], u0, u1, v0, v1 })
163
+ }
164
+ return quads
165
+ }
166
+
167
+ /** Apply the pass to every planned primitive. */
168
+ export async function applyCardTrim(doc: Document, plan: CardTrimPlan): Promise<CardTrimResult> {
169
+ const res: CardTrimResult = { primitives: 0, quads: 0, trimmed: 0, dropped: 0, areaBefore: 0, areaAfter: 0, skipped: [] }
170
+ const tables = new Map<string, Promise<OpaqueTable | null>>()
171
+ for (const p of plan.prims) {
172
+ const mat = p.prim.getMaterial()!
173
+ if (p.texture !== 'ok') {
174
+ res.skipped.push({ name: `${p.name}/${p.material}`, reason: p.texture === 'ktx2' ? 'base colour is KTX2 (no decoder) — trim from the source textures' : p.texture === 'none' ? 'no base colour texture' : 'KHR_texture_transform on the base colour' })
175
+ continue
176
+ }
177
+ const tex = mat.getBaseColorTexture()!
178
+ const cutoff = Math.max(0, Math.min(1, mat.getAlphaCutoff())) * 255 / Math.max(1e-3, mat.getBaseColorFactor()[3])
179
+ const key = `${tex.getName()}#${tex.getImage()?.byteLength ?? 0}#${cutoff.toFixed(2)}`
180
+ let table = tables.get(key)
181
+ if (!table) {
182
+ table = (async () => {
183
+ const bytes = tex.getImage()
184
+ if (!bytes) return null
185
+ const img = await decodeImage(bytes, tex.getURI() || tex.getMimeType() || 'texture.png')
186
+ return img ? new OpaqueTable(img.data, img.width, img.height, cutoff) : null
187
+ })()
188
+ tables.set(key, table)
189
+ }
190
+ const opaque = await table
191
+ if (!opaque) { res.skipped.push({ name: `${p.name}/${p.material}`, reason: 'base colour texture could not be decoded' }); continue }
192
+ const r = trimPrimitive(p.prim, opaque, plan.margin)
193
+ if (!r) continue
194
+ res.primitives++
195
+ res.quads += r.quads; res.trimmed += r.trimmed; res.dropped += r.dropped
196
+ res.areaBefore += r.areaBefore; res.areaAfter += r.areaAfter
197
+ }
198
+ return res
199
+ }
200
+
201
+ function trimPrimitive(prim: Primitive, opaque: OpaqueTable, margin: number): { quads: number; trimmed: number; dropped: number; areaBefore: number; areaAfter: number } | null {
202
+ if (!prim.getIndices()) weldPrimitive(prim) // non-indexed cards: six vertices per quad → four (exact weld in this gltf-transform)
203
+ const idxAcc = prim.getIndices()
204
+ if (!idxAcc) return null
205
+ const indices = Uint32Array.from(idxAcc.getArray() as ArrayLike<number>)
206
+ const uvAcc = prim.getAttribute('TEXCOORD_0')!
207
+ const uv = Float32Array.from(uvAcc.getArray() as ArrayLike<number>)
208
+ const quads = findQuads(indices, uv)
209
+ if (!quads.length) return null
210
+
211
+ // every vertex attribute is rewritten by the same bilinear rule, so gather them once
212
+ const semantics = prim.listSemantics()
213
+ const attrs = semantics.map((s) => { const a = prim.getAttribute(s)!; return { s, acc: a, size: a.getElementSize(), data: Float32Array.from(a.getArray() as ArrayLike<number>) } })
214
+ const dropTri = new Uint8Array(indices.length / 3)
215
+ let trimmed = 0, dropped = 0, areaBefore = 0, areaAfter = 0
216
+ const { w, h } = opaque
217
+ for (const q of quads) {
218
+ areaBefore += (q.u1 - q.u0) * (q.v1 - q.v0)
219
+ const x0 = Math.floor(q.u0 * w), x1 = Math.ceil(q.u1 * w), y0 = Math.floor(q.v0 * h), y1 = Math.ceil(q.v1 * h)
220
+ const box = opaque.bounds(x0, y0, x1, y1)
221
+ if (!box) { dropTri[q.tris[0]] = 1; dropTri[q.tris[1]] = 1; dropped++; continue }
222
+ // the box in UV, widened by the margin, never past the quad's own rectangle
223
+ const nu0 = Math.max(q.u0, (box[0] - margin) / w), nu1 = Math.min(q.u1, (box[2] + margin) / w)
224
+ const nv0 = Math.max(q.v0, (box[1] - margin) / h), nv1 = Math.min(q.v1, (box[3] + margin) / h)
225
+ const newArea = (nu1 - nu0) * (nv1 - nv0)
226
+ areaAfter += newArea
227
+ if (newArea > (q.u1 - q.u0) * (q.v1 - q.v0) * 0.98) continue // nothing worth moving
228
+ // corner k = (sv * 2 + su): new (s, t) inside the old quad, bilinear over the old corners
229
+ const old = attrs.map((a) => q.corners.map((c) => a.data.slice(c * a.size, (c + 1) * a.size)))
230
+ for (let k = 0; k < 4; k++) {
231
+ const su = k & 1, sv = k >> 1
232
+ const s = ((su ? nu1 : nu0) - q.u0) / (q.u1 - q.u0)
233
+ const t = ((sv ? nv1 : nv0) - q.v0) / (q.v1 - q.v0)
234
+ const vtx = q.corners[k]
235
+ attrs.forEach((a, ai) => {
236
+ if (a.s.startsWith('JOINTS')) return // joint indices are not interpolable; the vertex keeps its own
237
+ const [ c00, c10, c01, c11 ] = old[ai]
238
+ for (let i = 0; i < a.size; i++) {
239
+ const top = c00[i] * (1 - s) + c10[i] * s
240
+ const bottom = c01[i] * (1 - s) + c11[i] * s
241
+ a.data[vtx * a.size + i] = top * (1 - t) + bottom * t
242
+ }
243
+ })
244
+ // the UVs are the new rectangle exactly (the bilinear rule lands there too, up to rounding)
245
+ const uvA = attrs.find((a) => a.s === 'TEXCOORD_0')!
246
+ uvA.data[vtx * 2] = su ? nu1 : nu0
247
+ uvA.data[vtx * 2 + 1] = sv ? nv1 : nv0
248
+ }
249
+ trimmed++
250
+ }
251
+ if (!trimmed && !dropped) return { quads: quads.length, trimmed, dropped, areaBefore, areaAfter }
252
+
253
+ // write back: dropped triangles leave the index list, orphaned vertices leave the arrays
254
+ const kept: number[] = []
255
+ for (let t = 0; t < dropTri.length; t++) if (!dropTri[t]) kept.push(indices[t * 3], indices[t * 3 + 1], indices[t * 3 + 2])
256
+ const remap = new Map<number, number>()
257
+ const order: number[] = []
258
+ for (const i of kept) if (!remap.has(i)) { remap.set(i, order.length); order.push(i) }
259
+ const newIndices = kept.map((i) => remap.get(i)!)
260
+ for (const a of attrs) {
261
+ const out = new Float32Array(order.length * a.size)
262
+ order.forEach((src, dst) => { for (let i = 0; i < a.size; i++) out[dst * a.size + i] = a.data[src * a.size + i] })
263
+ const acc = a.acc.clone() as Accessor
264
+ // normalised integer attributes (colours, weights) keep their storage; positions / uvs are float
265
+ acc.setArray(typedLike(a.acc, out) as never)
266
+ prim.setAttribute(a.s, acc)
267
+ }
268
+ const newIdx = idxAcc.clone() as Accessor
269
+ newIdx.setArray(order.length > 65535 ? Uint32Array.from(newIndices) : Uint16Array.from(newIndices))
270
+ prim.setIndices(newIdx)
271
+ return { quads: quads.length, trimmed, dropped, areaBefore, areaAfter }
272
+ }
273
+
274
+ /** The values back in the accessor's own storage type: getArray() hands out the RAW integers of an 8/16-bit
275
+ * (normalised) attribute, the interpolation was linear on them, so they go back rounded, unscaled. */
276
+ function typedLike(acc: Accessor, values: Float32Array): Float32Array | Uint8Array | Uint16Array | Int8Array | Int16Array {
277
+ const t = acc.getComponentType()
278
+ const conv = (C: { from(a: ArrayLike<number>, f: (v: number) => number): any }) => C.from(values, (v) => Math.round(v))
279
+ switch (t) {
280
+ case 5121: return conv(Uint8Array)
281
+ case 5123: return conv(Uint16Array)
282
+ case 5120: return conv(Int8Array)
283
+ case 5122: return conv(Int16Array)
284
+ default: return values
285
+ }
286
+ }