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,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
|
+
}
|