lecodes-assets 2.4.10 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +712 -33
- package/dist/index.js +712 -33
- package/package.json +1 -1
- package/src/commands/doctor.ts +15 -3
- package/src/commands/scene.ts +6 -1
- package/src/commands/shared.ts +20 -1
- package/src/doctor/cardTrim.ts +2 -0
- package/src/doctor/impostor.ts +614 -0
- package/src/doctor/transcodeKtx2.ts +8 -4
- package/src/doctor/transcodeTextures.ts +6 -1
- package/src/doctor/vertexAo.ts +16 -4
- package/src/unity/gameObjects.ts +13 -4
- package/src/unity/lecodesLevel.ts +38 -5
- package/src/unity/levelTerrain.ts +16 -7
- package/src/unity/materialWriter.ts +11 -1
- package/src/unity/scene.ts +16 -2
package/package.json
CHANGED
package/src/commands/doctor.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
2
|
-
import { bool, CliError, defineCommand, missing, str } from "../cli-kit"
|
|
3
|
-
import { doctorFlags, doctorOptions, ktx2Progress, log, logDoctorResult, resolveInputs, wantsDoctorFix, wantsKtx2 } from "./shared"
|
|
2
|
+
import { bool, CliError, defineCommand, int, missing, str } from "../cli-kit"
|
|
3
|
+
import { doctorFlags, doctorOptions, impostorFlag, ktx2Progress, log, logDoctorResult, resolveInputs, wantsDoctorFix, wantsKtx2 } from "./shared"
|
|
4
4
|
|
|
5
5
|
export default defineCommand({
|
|
6
6
|
name: "doctor",
|
|
@@ -15,6 +15,8 @@ export default defineCommand({
|
|
|
15
15
|
...doctorFlags,
|
|
16
16
|
lod: str("add mesh LOD levels (<name>_LOD1… siblings at these triangle ratios, same skin; >= 2000-tri meshes only) — creator-gl shows one by screen size (docs/lod-plan.md)", { value: "0.5,0.2", group: "LOD" }),
|
|
17
17
|
"lod-bones": bool("fold fingers / toes / twists onto their parents in the last level's skin"),
|
|
18
|
+
impostor: str("bake the level-0 geometry into an impostor atlas (albedo + hull normal / AO) and add it as a LOD level, one camera-facing quad (<name>_LOD<k>, k = --impostor-level, never past the first missing level; the levels from k up are replaced). PNG / JPEG base colours only — run it with --ktx2, not after it; the atlases skip --max-size. <A>x<E>[@<px>] = A azimuths × E elevation rows (0°, 90°/E, …; 8x2@256), oct<N>[@<px>] = the N×N hemi-octahedron (steep views too); bare = 12x3; px = a frame's pixels (default 256 for a plant ≥ 8 m, 128 under)", { value: "<A>x<E>[@px]|oct<N>[@px]", bare: "12x3" }),
|
|
19
|
+
"impostor-level": int("the LOD slot the quad takes (default 2: shown under 0.12 of the viewport height; 1 = under 0.30)"),
|
|
18
20
|
},
|
|
19
21
|
examples: [
|
|
20
22
|
"lecodes assets doctor hero.glb",
|
|
@@ -29,7 +31,8 @@ export default defineCommand({
|
|
|
29
31
|
const { validateGlb, formatReport } = await import("../doctor/validateGlb")
|
|
30
32
|
const useKtx2 = wantsKtx2(flags)
|
|
31
33
|
const lod = (flags.lod ?? "").split(",").map(Number).filter((n) => n > 0 && n < 1)
|
|
32
|
-
const
|
|
34
|
+
const impostor = impostorFlag(flags.impostor, flags["impostor-level"])
|
|
35
|
+
const doFix = wantsDoctorFix(flags) || flags["fix-metallic"] || lod.length > 0 || !!impostor
|
|
33
36
|
const opts = { ...doctorOptions(flags), lod, lodBones: flags["lod-bones"] }
|
|
34
37
|
const ktxEncoder = useKtx2 ? findKtxEncoder() : null
|
|
35
38
|
if (useKtx2) {
|
|
@@ -44,6 +47,15 @@ export default defineCommand({
|
|
|
44
47
|
if (!existsSync(input)) { console.error(`\n✗ not found: ${input}`); exitCode = 1; continue }
|
|
45
48
|
const doc = await io.read(input)
|
|
46
49
|
if (given.has("dump")) { await dumpTextures(doc, flags.dump && files.length === 1 ? flags.dump : input.replace(/\.glb$/i, "-textures")); continue }
|
|
50
|
+
// before the analysis: the plan then sees the atlases (KTX2 encodes them) and not the levels the quad replaced
|
|
51
|
+
if (impostor) {
|
|
52
|
+
const { bakeImpostor, impostorLabel } = await import("../doctor/impostor")
|
|
53
|
+
try {
|
|
54
|
+
const imp = await bakeImpostor(doc, { ...impostor, log })
|
|
55
|
+
if (imp) log(` impostor: ${imp.node} · ${impostorLabel(imp)} · r ${imp.radius.toFixed(2)} m · ${imp.triangles} triangles in ${imp.ms} ms`)
|
|
56
|
+
else log(` impostor: no level-0 triangles in ${input}`)
|
|
57
|
+
} catch (e) { console.error(`\n✗ ${input}: ${(e as Error).message}`); exitCode = 1; continue }
|
|
58
|
+
}
|
|
47
59
|
const plan = await analyzeDoc(doc, opts)
|
|
48
60
|
const rep = reportPlan(plan)
|
|
49
61
|
log(`\n🔍 ${input}`)
|
package/src/commands/scene.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { dirname } from "node:path"
|
|
3
3
|
import { bool, defineCommand, int, missing, notFound, num, str, usageError } from "../cli-kit"
|
|
4
|
-
import { log, regexFlag, seconds } from "./shared"
|
|
4
|
+
import { impostorFlag, log, regexFlag, seconds } from "./shared"
|
|
5
5
|
|
|
6
6
|
/** `x0,z0,x1,z1` (any corner order) → [minX, minZ, maxX, maxZ]. */
|
|
7
7
|
const parseDropAt = (s: string | undefined): [number, number, number][] | undefined => {
|
|
@@ -48,6 +48,8 @@ export default defineCommand({
|
|
|
48
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
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
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
|
+
impostor: str("foliage kinds ≥ 4 m tall get their far LOD level as ONE camera-facing quad over an atlas of views (albedo + hull normal / AO), replacing the pack's billboard; the atlases skip --max-size. <A>x<E>[@<px>] = A azimuths × E elevation rows (0°, 90°/E, …; 8x2@256), oct<N>[@<px>] = the N×N hemi-octahedron (steep views too); bare = 12x3; px = a frame's pixels (default 256 for a plant ≥ 8 m, 128 under)", { value: "<A>x<E>[@px]|oct<N>[@px]", bare: "12x3" }),
|
|
52
|
+
"impostor-level": int("the LOD slot the quad takes (default 2: shown under 0.12 of the viewport height; 1 = under 0.30, the pack's LOD1 goes; never past the first missing level)"),
|
|
51
53
|
"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
54
|
filter: str("keep only instances whose kind name / scene group / node name matches", { value: "<re>", group: "Subset" }),
|
|
53
55
|
"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;…>" }),
|
|
@@ -55,6 +57,7 @@ export default defineCommand({
|
|
|
55
57
|
group: str("only instances under a scene group whose path contains this", { value: "name" }),
|
|
56
58
|
region: str("only instances inside this Unity X/Z rectangle", { value: "x0,z0,x1,z1" }),
|
|
57
59
|
"no-trees": bool("skip the terrain's painted trees"),
|
|
60
|
+
"terrain-res": num("the heightfield's largest resolution, samples a side — a finer Unity heightmap is read every k-th sample (Chernobyl's 4097² over 500 m: 12 cm cells, 117 MB, 33 M navmesh triangles; 1025 = half-metre cells; default 1025)", { value: "<n>" }),
|
|
58
61
|
"no-details": bool("skip the terrain's detail meshes"),
|
|
59
62
|
"include-inactive": bool("import inactive GameObjects too"),
|
|
60
63
|
handedness: str("how Unity's left-handed frame is undone (default x: −x, (x, −y, −z, w))", { value: "x|none", group: "Source" }),
|
|
@@ -86,6 +89,8 @@ export default defineCommand({
|
|
|
86
89
|
project: flags.project, name: flags.name, force: flags.force, maxSize: flags["max-size"] ?? 1024, ktx2: !flags.png, log,
|
|
87
90
|
foliage: regexFlag(flags.foliage), noFoliage: flags["no-foliage"], noSun: flags["no-sun"],
|
|
88
91
|
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"],
|
|
92
|
+
impostor: impostorFlag(flags.impostor, flags["impostor-level"]),
|
|
93
|
+
terrainRes: flags["terrain-res"] ?? 1025,
|
|
89
94
|
// the invocation, for the scene file's header (quote what a shell would need quoted)
|
|
90
95
|
command: "lecodes assets scene " + argv.map((a) => (/[\s"'|<>^&();]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(" "),
|
|
91
96
|
})
|
package/src/commands/shared.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
5
5
|
import { join } from "node:path"
|
|
6
|
-
import { bool, int, num, str, type FlagValues } from "../cli-kit"
|
|
6
|
+
import { bool, int, num, str, usageError, type FlagValues } from "../cli-kit"
|
|
7
7
|
import type { ApplyResult, DoctorOptions } from "../doctor/doctor"
|
|
8
8
|
|
|
9
9
|
export const log = (s: string): void => console.log(s)
|
|
@@ -117,5 +117,24 @@ export const resolveInputs = (args: string[], ext = /\.glb$/i, skipGenerated = t
|
|
|
117
117
|
return [ ...new Set(out) ]
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** `--impostor <A>x<E>[@<px>]` (the grid of A azimuths × E elevation rows: "12x3", "8x2@256") or `oct<N>[@<px>]` (the
|
|
121
|
+
* hemi-octahedron: "oct8", "oct8@128"), bare = 12x3, + `--impostor-level` → the bake's options (rows 0 = the
|
|
122
|
+
* octahedron, cols = N), or undefined when absent. */
|
|
123
|
+
export const impostorFlag = (v: string | undefined, level?: number): { cols: number, rows: number, px?: number, level?: number } | undefined => {
|
|
124
|
+
if (v === undefined) return undefined
|
|
125
|
+
if (level !== undefined && (!Number.isInteger(level) || level < 1 || level > 3)) throw usageError(`--impostor-level wants 1, 2 or 3 — got "${level}"`)
|
|
126
|
+
const s = v.trim() || "12x3"
|
|
127
|
+
const bad = (why: string): Error => usageError(`--impostor wants <A>x<E>[@<px>] (azimuths ≥ 3, rows 1..8) or oct<N>[@<px>] (N 2..64), px 32..1024 a multiple of 4 — e.g. 12x3, 8x2@256, oct8@128 — got "${v}" (${why})`)
|
|
128
|
+
const grid = /^(\d+)x(\d+)(?:@(\d+))?$/i.exec(s), oct = /^oct(\d+)(?:@(\d+))?$/i.exec(s)
|
|
129
|
+
if (!grid && !oct) throw bad("not either form")
|
|
130
|
+
const cols = Number(grid ? grid[1] : oct![1]), rows = grid ? Number(grid[2]) : 0
|
|
131
|
+
const pxs = grid ? grid[3] : oct![2], px = pxs ? Number(pxs) : undefined
|
|
132
|
+
if (grid && (cols < 3 || cols > 64)) throw bad("azimuths 3..64")
|
|
133
|
+
if (grid && (rows < 1 || rows > 8)) throw bad("rows 1..8")
|
|
134
|
+
if (oct && (cols < 2 || cols > 64)) throw bad("N 2..64")
|
|
135
|
+
if (px !== undefined && (px < 32 || px > 1024 || px % 4 !== 0)) throw bad("px 32..1024, a multiple of 4")
|
|
136
|
+
return { cols, rows, px, level }
|
|
137
|
+
}
|
|
138
|
+
|
|
120
139
|
/** `--x <regex>` as a case-insensitive RegExp, or undefined. */
|
|
121
140
|
export const regexFlag = (v: string | undefined): RegExp | undefined => (v ? new RegExp(v, "i") : undefined)
|
package/src/doctor/cardTrim.ts
CHANGED
|
@@ -50,6 +50,8 @@ export function planCardTrim(doc: Document, opts: CardTrimOptions): CardTrimPlan
|
|
|
50
50
|
for (const prim of mesh.listPrimitives()) {
|
|
51
51
|
const mat = prim.getMaterial()
|
|
52
52
|
if (!mat || mat.getAlphaMode() !== 'MASK' || prim.getMode() !== 4) continue
|
|
53
|
+
// an impostor quad (doctor/impostor.ts) is a MASK quad over an atlas of views, not a card: its corners are ids
|
|
54
|
+
if ((mat.getExtras() as { lecodes?: { impostor?: unknown } } | null)?.lecodes?.impostor !== undefined) continue
|
|
53
55
|
if (!prim.getAttribute('POSITION') || !prim.getAttribute('TEXCOORD_0')) continue
|
|
54
56
|
const tex = mat.getBaseColorTexture()
|
|
55
57
|
const info = mat.getBaseColorTextureInfo()
|