lecodes-assets 0.2.1 → 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.
Files changed (99) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +18157 -11441
  3. package/dist/index.js +16603 -9878
  4. package/package.json +3 -1
  5. package/src/cli-kit/command.ts +100 -0
  6. package/src/cli-kit/help.ts +72 -0
  7. package/src/cli-kit/index.ts +4 -0
  8. package/src/cli-kit/parse.ts +72 -0
  9. package/src/cli-kit/run.ts +52 -0
  10. package/src/cli.ts +24 -1039
  11. package/src/commands/avatar.ts +77 -0
  12. package/src/commands/bvh.ts +26 -0
  13. package/src/commands/convert.ts +156 -0
  14. package/src/commands/doctor.ts +79 -0
  15. package/src/commands/geometry.ts +45 -0
  16. package/src/commands/index.ts +24 -0
  17. package/src/commands/marks.ts +38 -0
  18. package/src/commands/mirror.ts +35 -0
  19. package/src/commands/probe.ts +43 -0
  20. package/src/commands/retarget/donor.ts +63 -0
  21. package/src/commands/retarget/index.ts +169 -0
  22. package/src/commands/retarget/map.ts +81 -0
  23. package/src/commands/retarget/report.ts +77 -0
  24. package/src/commands/scene.ts +108 -0
  25. package/src/commands/shared.ts +140 -0
  26. package/src/commands/sky.ts +35 -0
  27. package/src/commands/terrainPack.ts +40 -0
  28. package/src/commands/unpack.ts +43 -0
  29. package/src/commands/unreal.ts +109 -0
  30. package/src/commands/unwrap.ts +56 -0
  31. package/src/doctor/cardTrim.ts +288 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/impostor.ts +614 -0
  34. package/src/doctor/merge/accessors.ts +30 -0
  35. package/src/doctor/merge/atlasLayout.ts +94 -0
  36. package/src/doctor/merge/atlasPixels.ts +114 -0
  37. package/src/doctor/merge/bindSpace.ts +59 -0
  38. package/src/doctor/merge/classify.ts +136 -0
  39. package/src/doctor/merge/geometry.ts +167 -0
  40. package/src/doctor/merge/index.ts +98 -0
  41. package/src/doctor/merge/matrix.ts +60 -0
  42. package/src/doctor/merge/plan.ts +88 -0
  43. package/src/doctor/merge/textures.ts +60 -0
  44. package/src/doctor/merge/types.ts +77 -0
  45. package/src/doctor/transcodeKtx2.ts +186 -31
  46. package/src/doctor/transcodeTextures.ts +6 -1
  47. package/src/doctor/validateGlb.ts +2 -2
  48. package/src/doctor/vertexAo.ts +333 -0
  49. package/src/fbx/read.ts +2 -0
  50. package/src/fbx/toDocument.ts +227 -209
  51. package/src/geometry.ts +222 -0
  52. package/src/index.ts +2 -0
  53. package/src/retarget/heading.ts +238 -171
  54. package/src/retarget/humanoid.ts +572 -561
  55. package/src/retarget/retarget.ts +2375 -2272
  56. package/src/retarget/rig.ts +4 -1
  57. package/src/retarget/writeMarks.ts +134 -132
  58. package/src/sky/cmgen.ts +3 -3
  59. package/src/sky/index.ts +57 -8
  60. package/src/terrainPack.ts +61 -22
  61. package/src/unity/colliderFit.ts +287 -0
  62. package/src/unity/fbxModel.ts +194 -0
  63. package/src/unity/foliage.ts +63 -0
  64. package/src/unity/gameObjects.ts +90 -0
  65. package/src/unity/guid.ts +104 -0
  66. package/src/unity/image.ts +58 -0
  67. package/src/unity/kindGlb.ts +364 -0
  68. package/src/unity/lecodesLevel.ts +414 -0
  69. package/src/unity/levelTerrain.ts +125 -0
  70. package/src/unity/matFile.ts +179 -0
  71. package/src/unity/materialSlots.ts +53 -0
  72. package/src/unity/materialWriter.ts +316 -0
  73. package/src/unity/materials.ts +34 -429
  74. package/src/unity/math.ts +82 -0
  75. package/src/unity/prefabTree.ts +285 -0
  76. package/src/unity/prefabs.ts +10 -36
  77. package/src/unity/propertyPath.ts +43 -0
  78. package/src/unity/scene.ts +339 -304
  79. package/src/unity/serialized.ts +242 -0
  80. package/src/unity/terrain.ts +221 -0
  81. package/src/unity/triBudget.ts +77 -0
  82. package/src/unity/triplanar.ts +122 -0
  83. package/src/unity/unityMesh.ts +151 -0
  84. package/src/unity/unpack.ts +24 -5
  85. package/src/unity/yaml.ts +131 -0
  86. package/src/unreal/index.ts +6 -5
  87. package/src/unreal/materialGraph.ts +224 -0
  88. package/src/unreal/materials.ts +117 -9
  89. package/src/unreal/package.ts +30 -12
  90. package/src/unreal/resolve.ts +6 -4
  91. package/src/unreal/sound.ts +128 -0
  92. package/src/unreal/texture.ts +70 -3
  93. package/src/unreal/toDocument.ts +7 -0
  94. package/src/unreal/versions.ts +8 -0
  95. package/src/unwrap/index.ts +167 -0
  96. package/src/unwrap/lightmapUv.ts +761 -0
  97. package/src/wasm/fbxReader.js +1 -1
  98. package/src/wasm/lightmapUv.js +1 -1
  99. package/src/doctor/lightmapUv.ts +0 -452
@@ -0,0 +1,104 @@
1
+ // GUID → file. Every reference inside a Unity project is a GUID; the path behind it comes from a
2
+ // `unity-index.txt` (written by `lecodes assets unpack`) or the `.meta` sidecars of a real project.
3
+
4
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
5
+ import { basename, dirname, join, resolve } from "node:path"
6
+
7
+ export const walkFiles = (dir: string, depth: number, out: string[], pred: (name: string) => boolean): void => {
8
+ if (depth < 0 || !existsSync(dir)) return
9
+ let entries
10
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
11
+ for (const e of entries) {
12
+ const p = join(dir, e.name)
13
+ if (e.isDirectory()) { if (e.name !== "node_modules" && !e.name.startsWith(".")) walkFiles(p, depth - 1, out, pred) }
14
+ else if (pred(e.name)) out.push(p)
15
+ }
16
+ }
17
+
18
+ export type GuidResolver = { resolve: (guid: string) => string | null, source: string }
19
+
20
+ /** Find `unity-index.txt` (written by `lecodes assets unpack`) walking up from `start`. */
21
+ export const findUnityIndex = (start: string): string | null => {
22
+ let dir = resolve(start)
23
+ for (let i = 0; i < 12; i++) {
24
+ const f = join(dir, "unity-index.txt")
25
+ if (existsSync(f)) return f
26
+ const up = dirname(dir)
27
+ if (up === dir) break
28
+ dir = up
29
+ }
30
+ return null
31
+ }
32
+
33
+ /** The GUID the index lists for an absolute asset path (the FBX itself), or null. */
34
+ export const guidOfPath = (indexFile: string, absPath: string): string | null => {
35
+ const root = resolve(dirname(indexFile))
36
+ const want = resolve(absPath).toLowerCase().replace(/\\/g, "/")
37
+ for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) {
38
+ const t = line.indexOf("\t")
39
+ if (t < 0) continue
40
+ if (resolve(root, line.slice(t + 1).trim()).toLowerCase().replace(/\\/g, "/") === want) return line.slice(0, t)
41
+ }
42
+ return null
43
+ }
44
+
45
+ /** GUID → absolute path through an index file (`<guid>\t<path relative to the index's folder>`). */
46
+ export const resolverFromIndex = (indexFile: string): GuidResolver => {
47
+ const root = dirname(indexFile)
48
+ const map = new Map<string, string>()
49
+ for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) {
50
+ const t = line.indexOf("\t")
51
+ if (t > 0) map.set(line.slice(0, t), line.slice(t + 1).trim())
52
+ }
53
+ return { source: indexFile, resolve: (g) => { const p = map.get(g); if (!p) return null; const abs = join(root, p); return existsSync(abs) ? abs : null } }
54
+ }
55
+
56
+ /** GUID → path through `.meta` sidecars: scans up to the folder that holds `Assets` (or `depthUp` levels). */
57
+ export const resolverFromMeta = (start: string, depthUp = 6): GuidResolver | null => {
58
+ // THE PROJECT'S `Assets` HAS NO `.meta` OF ITS OWN — every folder inside it does. Without that test
59
+ // the first folder called `assets` on the way up won (a case-insensitive disk makes `Assets` match it
60
+ // too): Peremena's `Models/assets/` (a Sketchfab download) cut the index down to Models/, and the
61
+ // walls and ceiling lost their materials — `Materials/` was never scanned.
62
+ const isProjectAssets = (dir: string): boolean => /^assets$/i.test(basename(dir)) && !existsSync(`${dir}.meta`)
63
+ const holdsProjectAssets = (dir: string): boolean => {
64
+ try { return readdirSync(dir).some((n) => /^assets$/i.test(n) && isProjectAssets(join(dir, n))) } catch { return false }
65
+ }
66
+ let root = resolve(start)
67
+ for (let i = 0; i < depthUp; i++) {
68
+ if (isProjectAssets(root) || holdsProjectAssets(root)) break
69
+ const up = dirname(root)
70
+ if (up === root) break
71
+ root = up
72
+ }
73
+ const metas: string[] = []
74
+ walkFiles(root, 12, metas, (n) => n.endsWith(".meta"))
75
+ if (metas.length === 0) return null
76
+ const map = new Map<string, string>()
77
+ for (const f of metas) {
78
+ const head = readFileSync(f, "utf8").slice(0, 300)
79
+ const g = /\nguid: ([0-9a-f]{32})/.exec(head)?.[1]
80
+ if (g) map.set(g, f.slice(0, -5))
81
+ }
82
+ return { source: `${metas.length} .meta files under ${root}`, resolve: (g) => { const p = map.get(g); return p && existsSync(p) ? p : null } }
83
+ }
84
+
85
+ /** Every `.mat` the index knows (absolute paths) — shared materials live far from the FBX. */
86
+ export const indexedMaterialFiles = (indexFile: string): string[] => {
87
+ const root = dirname(indexFile)
88
+ const out: string[] = []
89
+ for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) {
90
+ const t = line.indexOf("\t")
91
+ if (t > 0 && /\.mat$/i.test(line)) out.push(join(root, line.slice(t + 1).trim()))
92
+ }
93
+ return out
94
+ }
95
+
96
+ /** The resolver for a scene / prefab file: the index found walking up from it (or the one given),
97
+ * else the Unity project's `.meta` sidecars. */
98
+ export const resolverFor = (file: string, indexFile?: string | null): GuidResolver => {
99
+ const idx = indexFile === undefined ? findUnityIndex(dirname(resolve(file))) : indexFile
100
+ if (idx && existsSync(idx)) return resolverFromIndex(idx)
101
+ const r = resolverFromMeta(dirname(resolve(file)), 12)
102
+ if (!r) throw new Error(`no unity-index.txt above ${file} and no .meta sidecars — cannot resolve GUIDs`)
103
+ return r
104
+ }
@@ -0,0 +1,58 @@
1
+ // Texture pixels for the material pass: decode whatever a pack ships, pack glTF's ORM layout.
2
+
3
+ import { sniffMime, decodeTga, decodeBmp, type Rgba } from "../textures/resolveImage"
4
+ import { decodePng } from "./png"
5
+
6
+ export const decodeImage = async (bytes: Uint8Array, hint: string): Promise<Rgba | null> => {
7
+ const mime = sniffMime(bytes, hint)
8
+ if (mime === "image/png") { const r = decodePng(bytes); if (r) return r }
9
+ if (mime === "image/x-tga") return decodeTga(bytes)
10
+ if (mime === "image/bmp") return decodeBmp(bytes)
11
+ try {
12
+ const sharp = (await import("sharp")).default
13
+ const { data, info } = await sharp(Buffer.from(bytes)).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
14
+ return { width: info.width, height: info.height, data: new Uint8Array(data) }
15
+ } catch { return null }
16
+ }
17
+
18
+ /** Nearest-neighbour sample of one channel at (u, v) in 0..1. */
19
+ const sampleCh = (img: Rgba, ch: number, x: number, y: number, w: number, h: number): number => {
20
+ const sx = Math.min(img.width - 1, Math.floor((x + 0.5) * img.width / w))
21
+ const sy = Math.min(img.height - 1, Math.floor((y + 0.5) * img.height / h))
22
+ return img.data[(sy * img.width + sx) * 4 + ch]
23
+ }
24
+
25
+ export type PackInput = { img: Rgba, channel: number, invert?: boolean }
26
+ /** Pack occlusion / roughness / metallic sources into one RGB image (glTF layout). Missing = constant. */
27
+ export const packOrm = (ao: PackInput | null, rough: PackInput | null, metal: PackInput | null, defaults = { ao: 255, rough: 255, metal: 0 }): Rgba => {
28
+ const srcs = [ ao, rough, metal ].filter((s): s is PackInput => !!s)
29
+ const w = Math.max(1, ...srcs.map((s) => s.img.width)), h = Math.max(1, ...srcs.map((s) => s.img.height))
30
+ const out = new Uint8Array(w * h * 4)
31
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
32
+ const d = (y * w + x) * 4
33
+ const get = (s: PackInput | null, dflt: number): number => { if (!s) return dflt; const v = sampleCh(s.img, s.channel, x, y, w, h); return s.invert ? 255 - v : v }
34
+ out[d] = get(ao, defaults.ao); out[d + 1] = get(rough, defaults.rough); out[d + 2] = get(metal, defaults.metal); out[d + 3] = 255
35
+ }
36
+ return { width: w, height: h, data: out }
37
+ }
38
+
39
+ /** Does this image's ALPHA channel carry a shape, or is it the flat 255 a decoder invents? Sampled —
40
+ * a leaf card is ~87 % clear, so a coarse stride sees it instantly and a false positive needs a
41
+ * meaningful part of the image to be see-through. */
42
+ export const alphaIsKeyed = (img: Rgba): boolean => {
43
+ const n = img.width * img.height
44
+ const step = Math.max(1, Math.floor(n / 4096))
45
+ let clear = 0, seen = 0
46
+ for (let i = 0; i < n; i += step) { seen++; if (img.data[i * 4 + 3] < 250) clear++ }
47
+ return seen > 0 && clear / seen > 0.02
48
+ }
49
+
50
+ /** The mean alpha of an image (0..1), sampled on a coarse grid — what a mask map says as one number. Null when undecodable. */
51
+ export const meanAlpha = async (bytes: Uint8Array, hint: string): Promise<number | null> => {
52
+ const img = await decodeImage(bytes, hint)
53
+ if (!img) return null
54
+ const step = Math.max(1, Math.floor(Math.sqrt((img.width * img.height) / 4096)))
55
+ let sum = 0, n = 0
56
+ for (let y = 0; y < img.height; y += step) for (let x = 0; x < img.width; x += step) { sum += img.data[(y * img.width + x) * 4 + 3]; n++ }
57
+ return n ? sum / n / 255 : null
58
+ }
@@ -0,0 +1,364 @@
1
+ // One scene KIND (scene.ts: a fully expanded prefab — mesh parts, materials per submesh, LOD levels)
2
+ // → one glTF Document: the meshes pulled out of their FBX files, each on a node carrying the part's
3
+ // kind-local transform, the FBX importer's `globalScale` and the FBX's own geometric-transform helper,
4
+ // materials written from the Unity `.mat` files the MeshRenderer names (by GUID — no name guessing),
5
+ // LOD parts named `<part>_LOD<n>` so creator-gl's LOD pass groups them.
6
+ //
7
+ // Mesh identity: a MeshFilter names `{fileID, guid}` — the FBX's GUID and the Unity object id the FBX
8
+ // `.meta` names after the NODE that holds the mesh. That node's geometry in ufbx's frame (metres,
9
+ // right-handed Y-up) is Unity's mesh mirrored back on X, i.e. exactly what the X-mirrored kind frame
10
+ // expects; `globalScale` is the only factor left.
11
+
12
+ import { basename, dirname } from "node:path"
13
+ import { readFileSync } from "node:fs"
14
+ import { Document, type Material, type Mesh, type Node } from "@gltf-transform/core"
15
+ import { mergeDocuments, prune } from "@gltf-transform/functions"
16
+ import { readFbx } from "../fbx/read"
17
+ import { toDocument } from "../fbx/toDocument"
18
+ import { quiet } from "../props"
19
+ import { readFbxMeta, type FbxMeta } from "./fbxModel"
20
+ import { readUnityMeshAsset } from "./unityMesh"
21
+ import type { GuidResolver } from "./guid"
22
+ import { I4, mmul, scaleMatrix, type Mat4 } from "./math"
23
+ import { collectUnityMaterials, materialKey, parseUnityMaterial, windProfile, type UnityMaterial } from "./matFile"
24
+ import { pairSlots } from "./materialSlots"
25
+ import { unityMaterialWriter } from "./materialWriter"
26
+ import { projectTriplanar, triplanarOf } from "./triplanar"
27
+ import type { KindPart, SceneKind } from "./scene"
28
+
29
+ type FbxSource = { doc: Document, meta: FbxMeta, byName: Map<string, Node>, path: string, sole?: Node | null }
30
+
31
+ /** The file's ONLY mesh-bearing node, or null when it has none or more than one (cached per file). */
32
+ const soleMeshNode = (src: FbxSource): Node | undefined => {
33
+ if (src.sole === undefined) {
34
+ const hit: Node[] = []
35
+ for (const n of src.doc.getRoot().listNodes()) {
36
+ if (n.getMesh()) { hit.push(n); continue }
37
+ const kid = n.listChildren().find((c) => c.getMesh())
38
+ if (kid && !n.getMesh()) hit.push(n)
39
+ }
40
+ // the MESH either way: a parent counts by its child's mesh, not by the child node — mixing the two
41
+ // made every file whose mesh node has a parent (a Sketchfab export: root/…/Plane_0/Object_4) "two meshes"
42
+ const meshes = new Set(hit.map((n) => n.getMesh() ?? n.listChildren().find((c) => c.getMesh())?.getMesh()))
43
+ src.sole = meshes.size === 1 ? hit[0] : null
44
+ }
45
+ return src.sole ?? undefined
46
+ }
47
+
48
+ export type KindGlbContext = {
49
+ resolver: GuidResolver
50
+ /** The folders whose `.mat` files answer `name:<material>` refs (FBX materials not remapped). */
51
+ materialDirs: string[]
52
+ warn: (msg: string) => void
53
+ }
54
+
55
+ /** One kind's document under construction, with what is shared between its parts. */
56
+ type KindDoc = {
57
+ doc: Document
58
+ writer: ReturnType<typeof unityMaterialWriter>
59
+ /** per source FBX: its document merged in (source property → copy) */
60
+ merged: Map<string, Map<unknown, unknown>>
61
+ /** the FBX nodes that came along, dropped at the end */
62
+ imported: Set<Node>
63
+ materials: Map<string, Material>
64
+ meshes: Map<string, Mesh>
65
+ }
66
+
67
+ /** THE LOD CHAIN IS GROUPED BY BASE NAME, so every level of one mesh must share it — and a vegetation
68
+ * pack breaks that by convention: Unity's tree exporter calls the last level `Billboard_<Tree>` beside
69
+ * `<Tree>`, `<Tree>_LOD1`, `<Tree>_LOD2`. Left alone the billboard becomes a chain of ITS OWN whose
70
+ * only member is level 3, so it is selected at every distance and drawn OVER the tree it is supposed
71
+ * to replace: Chernobyl's forest wore flat white cards at point-blank range. The prefix goes when the
72
+ * part is a LOD member. */
73
+ /**
74
+ * The node names a renderer's GameObject could be standing for when the FBX `.meta` id table is empty
75
+ * (a pack routinely ships `internalIDToNameTable: []`), most literal first. Unity names a DUPLICATED
76
+ * object `Book (17)` while the node in the file is still `Book`; a renderer that sits on the prefab
77
+ * ROOT has no path of its own, and the prefab is named after the model with a pack's decoration —
78
+ * NatureManufacture's terrain-detail prefabs are `Prefab_heath_A_01_Unity_Terrain` over
79
+ * `heath_A_01.fbx`, whose meshes are `heath_A_01_LOD0` / `_LOD1`; a scene's loose renderer IS named
80
+ * like its node (`mountain_background_02_LOD1`), only the kind name carries it.
81
+ */
82
+ export const meshNameCandidates = (hint?: string, kindName?: string): string[] => {
83
+ const out: string[] = []
84
+ const add = (s: string | undefined): void => { if (s && !out.includes(s)) out.push(s) }
85
+ for (const raw of [ hint, kindName ]) {
86
+ if (!raw) continue
87
+ add(raw)
88
+ const bare = raw.replace(/ \(\d+\)$/, "")
89
+ add(bare)
90
+ const plain = bare.replace(/^(Prefab|P|PF|PREFAB|SM|PRB)_/i, "").replace(/_(Unity_Terrain|Terrain|Prefab|VS)$/i, "")
91
+ add(plain)
92
+ add(`${plain}_LOD0`)
93
+ }
94
+ return out
95
+ }
96
+
97
+ const partNodeName = (part: KindPart, kindName: string): string => {
98
+ const raw = (part.path.split("/").pop() || kindName).replace(/_LOD\d+$/i, "")
99
+ return part.lod > 0 ? `${raw.replace(/^Billboard_/i, "")}_LOD${part.lod}` : raw
100
+ }
101
+
102
+ export class KindGlbBuilder {
103
+ private fbx = new Map<string, Promise<FbxSource | null>>()
104
+ private mats = new Map<string, UnityMaterial | null>()
105
+ private byKey: Map<string, UnityMaterial> | null = null
106
+ constructor(readonly ctx: KindGlbContext) {}
107
+
108
+ private source(guid: string): Promise<FbxSource | null> {
109
+ let p = this.fbx.get(guid)
110
+ if (!p) {
111
+ p = (async () => {
112
+ const path = this.ctx.resolver.resolve(guid)
113
+ if (!path) { this.ctx.warn(`mesh source ${guid}: not found`); return null }
114
+ // A MESH SAVED BY UNITY (`<Tree>_Mesh_s.asset`, the hand-made LOD3 cross of NatureManufacture's
115
+ // trees) is a one-node source of its own: `4300000` is the fileID Unity gives the mesh in a
116
+ // mesh asset, and the node carries the asset's name for the name / sole-mesh fallbacks.
117
+ if (/\.asset$/i.test(path)) {
118
+ try {
119
+ const um = readUnityMeshAsset(path)
120
+ if (!um) { this.ctx.warn(`${basename(path)}: not a readable mesh asset (binary, compressed or empty)`); return null }
121
+ const node = um.doc.getRoot().listNodes()[0]
122
+ const meta: FbxMeta = { globalScale: 1, names: new Map([ [ "4300000", um.name ] ]), ids: new Map([ [ `43:${um.name}`, "4300000" ] ]), materials: new Map(), addColliders: false, bakeAxisConversion: false }
123
+ return { doc: um.doc, meta, byName: new Map([ [ um.name, node ] ]), path, sole: node }
124
+ } catch (e) { this.ctx.warn(`${basename(path)}: ${(e as Error).message}`); return null }
125
+ }
126
+ try {
127
+ const scene = await readFbx(new Uint8Array(readFileSync(path)))
128
+ const { doc } = await toDocument(scene, { baseDir: dirname(path), defaultClipName: basename(path), warn: () => {}, extraUvSets: true })
129
+ for (const a of doc.getRoot().listAnimations()) a.dispose()
130
+ const byName = new Map<string, Node>()
131
+ for (const n of doc.getRoot().listNodes()) if (!byName.has(n.getName())) byName.set(n.getName(), n)
132
+ return { doc, meta: readFbxMeta(path + ".meta"), byName, path }
133
+ } catch (e) { this.ctx.warn(`${basename(path)}: ${(e as Error).message}`); return null }
134
+ })()
135
+ this.fbx.set(guid, p)
136
+ }
137
+ return p
138
+ }
139
+
140
+ private material(ref: string): UnityMaterial | null {
141
+ if (this.mats.has(ref)) return this.mats.get(ref)!
142
+ let um: UnityMaterial | null = null
143
+ if (ref.startsWith("name:")) {
144
+ this.byKey ??= collectUnityMaterials(this.ctx.materialDirs, 8).byKey
145
+ um = this.byKey.get(materialKey(ref.slice(5))) ?? null
146
+ } else if (ref) {
147
+ const path = this.ctx.resolver.resolve(ref)
148
+ if (path) { try { um = parseUnityMaterial(readFileSync(path, "utf8"), path) } catch { um = null } }
149
+ if (!um && path) this.ctx.warn(`material ${basename(path)}: unreadable (binary?) — the FBX's own kept`)
150
+ }
151
+ // A miss here is SILENT and expensive: the primitive keeps no material at all (the FBX's own is
152
+ // pruned with the rest of its document), so the kind renders as glTF's default white metal.
153
+ if (!um) this.ctx.warn(`material ${ref}: no .mat found — the part keeps no material`)
154
+ this.mats.set(ref, um)
155
+ return um
156
+ }
157
+
158
+ /** Every material of every part, in part order (null = unreadable) — what the level writer's foliage
159
+ * verdict reads (`unity/foliage.ts`: the material's folder and name, never its alpha mode). */
160
+ materialsOf(kind: SceneKind): (UnityMaterial | null)[] {
161
+ const out: (UnityMaterial | null)[] = []
162
+ for (const part of kind.parts) for (const ref of part.materials) out.push(this.material(ref))
163
+ return out
164
+ }
165
+
166
+ /** The mesh node of an FBX for a Unity mesh ref, with the transform from that node to its mesh
167
+ * (a geometric-transform helper child when ufbx made one).
168
+ *
169
+ * A MeshFilter names its mesh `{fileID, guid}`, and turning that NUMBER into a node name needs the
170
+ * FBX `.meta`'s own id table — which a pack routinely ships EMPTY (`internalIDToNameTable: []`;
171
+ * Chernobyl's every model does, and not one of its 648 kinds could be built). So when the id says
172
+ * nothing, fall back to the part's own NODE NAME: Unity names the GameObject it instantiates after
173
+ * the FBX node it came from, so `Pine_01_LOD0` in the prefab is `Pine_01_LOD0` in the file. It is a
174
+ * strict improvement — the id is tried first and a renamed node just fails the way it did before. */
175
+ private meshFor(src: FbxSource, fileID: string, hint?: string, kindName?: string): { mesh: Mesh, local: Mat4 } | null {
176
+ const name = fileID.startsWith("name:") ? fileID.slice(5) : src.meta.names.get(fileID)
177
+ // ...and when the part has no path either — the renderer sits on the prefab ROOT, which is how
178
+ // every single-mesh prop in a kit is built — there is still one unambiguous answer if the file
179
+ // holds exactly ONE mesh: that mesh.
180
+ let node = name !== undefined ? src.byName.get(name) : undefined
181
+ for (const c of meshNameCandidates(hint, kindName)) { if (node) break; node = src.byName.get(c) }
182
+ // the file's LOD chain of that base name — a detail prefab's one renderer means "the plant", and
183
+ // the plant is `heath_A_01_LOD0` (the finest level; the coarser ones are the same plant)
184
+ if (!node) {
185
+ for (const c of meshNameCandidates(hint, kindName)) {
186
+ const chain = [ ...src.byName.entries() ].filter(([ n ]) => n.replace(/_LOD\d+$/i, "") === c && /_LOD\d+$/i.test(n)).sort(([ a ], [ b ]) => a.localeCompare(b))
187
+ if (chain.length) { node = chain[0][1]; break }
188
+ }
189
+ }
190
+ node ??= soleMeshNode(src)
191
+ if (!node) return null
192
+ if (node.getMesh()) return { mesh: node.getMesh()!, local: I4() }
193
+ for (const c of node.listChildren()) if (c.getMesh()) return { mesh: c.getMesh()!, local: Array.from(c.getMatrix()) }
194
+ return null
195
+ }
196
+
197
+ /** The materials of one mesh: each primitive takes the slot that names it (materialSlots.ts) — by
198
+ * NAME, never by position. A slot is known by the FBX material it names, the one this model's
199
+ * `.meta` remaps its GUID from, or — when the `.meta` remaps nothing — the `.mat`'s own name. */
200
+ private async assignMaterials(mesh: Mesh, part: KindPart, src: FbxSource, out: KindDoc): Promise<void> {
201
+ const prims = mesh.listPrimitives()
202
+ const byGuid = new Map<string, string>()
203
+ for (const [ n, g ] of src.meta.materials) if (!byGuid.has(g)) byGuid.set(g, n)
204
+ const slotName = part.materials.map((r) => (r.startsWith("name:") ? r.slice(5) : byGuid.get(r) ?? this.material(r)?.name ?? null))
205
+ const slotFor = pairSlots(prims.map((pr) => pr.getMaterial()?.getName() ?? ""), slotName)
206
+ for (let i = 0; i < prims.length; i++) {
207
+ const ref = part.materials[slotFor[i] >= 0 ? slotFor[i] : Math.min(i, part.materials.length - 1)]
208
+ if (!ref) continue
209
+ const um = this.material(ref)
210
+ // THE DETAIL LAYER ON ANOTHER UV SET (NatureManufacture's bark shader): albedo / normal / ORM are
211
+ // `lerp(main(uv0), detail(uv3 × tiling), mask.a)`, and the pines' mask is white everywhere — uv0 covers the
212
+ // roots alone, the whole trunk is the detail bark on the third UV set. glTF has one UV per texture slot,
213
+ // so the primitive takes the detail textures on TEXCOORD_0 = that set × tiling.
214
+ const detail = um ? await this.detailLayer(um) : null
215
+ const set = detail ? prims[i].getAttribute(`TEXCOORD_${detail.set}`) : null
216
+ const useDetail = detail !== null && set !== null
217
+ const key = useDetail ? `${ref}\u0000detail` : ref
218
+ let gm = out.materials.get(key)
219
+ if (!gm && um) {
220
+ gm = out.doc.createMaterial(um.name)
221
+ await out.writer.applyTo(gm, useDetail ? detail!.um : um, "name")
222
+ const wind = windProfile(um)
223
+ if (wind) {
224
+ // merged: applyTo may have put the GI switch (`emissiveBake`) there already
225
+ const extras = (gm.getExtras() ?? {}) as Record<string, unknown>
226
+ gm.setExtras({ ...extras, lecodes: { ...((extras.lecodes as Record<string, unknown> | undefined) ?? {}), wind } })
227
+ }
228
+ out.materials.set(key, gm)
229
+ }
230
+ if (!gm) continue
231
+ if (useDetail) {
232
+ const [ sx, sy, ox, oy ] = detail!.st
233
+ const src = set!, n = src.getCount(), uv = new Float32Array(new ArrayBuffer(n * 8)), v: number[] = []
234
+ // glTF v runs top-down (the reader flipped it): Unity's v' = v·sy + oy on the un-flipped value
235
+ for (let k = 0; k < n; k++) { src.getElement(k, v); uv[k * 2] = v[0] * sx + ox; uv[k * 2 + 1] = 1 - ((1 - v[1]) * sy + oy) }
236
+ prims[i].setAttribute("TEXCOORD_0", out.doc.createAccessor().setType("VEC2").setArray(uv).setBuffer(out.doc.getRoot().listBuffers()[0]))
237
+ }
238
+ prims[i].setMaterial(gm)
239
+ }
240
+ }
241
+
242
+ private detailCache = new Map<string, Promise<{ set: number, st: [number, number, number, number], um: UnityMaterial } | null>>()
243
+
244
+ /** A material whose surface IS its detail layer: `_DETALUSEUV3_ON` (the detail sampled on Unity's uv3 = set 2)
245
+ * with a `_DetailAlbedoMap`, under a `_DetailMask` whose alpha is white (a black or missing mask = the
246
+ * main layer alone, Unity's default; a mixed mask is logged and the main layer kept). */
247
+ private detailLayer(um: UnityMaterial): Promise<{ set: number, st: [number, number, number, number], um: UnityMaterial } | null> {
248
+ let p = this.detailCache.get(um.file)
249
+ if (!p) {
250
+ p = (async () => {
251
+ if (!um.keywords.includes("_DETALUSEUV3_ON") || !um.textures._DetailAlbedoMap) return null
252
+ const maskGuid = um.textures._DetailMask
253
+ const maskPath = maskGuid ? this.ctx.resolver.resolve(maskGuid) : null
254
+ if (!maskPath) return null
255
+ const { meanAlpha } = await import("./image")
256
+ const a = await meanAlpha(new Uint8Array(readFileSync(maskPath)), maskPath)
257
+ if (a === null) return null
258
+ if (a < 0.97) { if (a > 0.03) this.ctx.warn(`${um.name}: detail mask ${basename(maskPath)} is mixed (${a.toFixed(2)}) — main layer kept`); return null }
259
+ const textures: Record<string, string> = { ...um.textures, _MainTex: um.textures._DetailAlbedoMap }
260
+ for (const k of Object.keys(textures)) if (/^_(MaskMap|BaseColorMap|NormalMap|BumpMap|MetalicRAOGSmothnessA)\d*$/.test(k)) delete textures[k]
261
+ if (um.textures._DetailNormalMap) textures._BumpMap = um.textures._DetailNormalMap
262
+ if (um.textures._DetailMetalicRAOGSmothnessA) textures._MetalicRAOGSmothnessA = um.textures._DetailMetalicRAOGSmothnessA
263
+ const floats = { ...um.floats, _BumpScale: um.floats._DetailNormalMapScale ?? um.floats._BumpScale ?? 1 }
264
+ return { set: 2, st: um.tiling?._DetailAlbedoMap ?? [ 1, 1, 0, 0 ], um: { ...um, textures, floats } }
265
+ })()
266
+ this.detailCache.set(um.file, p)
267
+ }
268
+ return p
269
+ }
270
+
271
+ /** The part's mesh inside the kind's document, wearing the part's materials: one Mesh per (mesh,
272
+ * material list) — parts sharing a mesh with other materials get a clone. */
273
+ private async partMesh(part: KindPart, src: FbxSource, found: Mesh, out: KindDoc): Promise<Mesh> {
274
+ let map = out.merged.get(part.mesh.guid)
275
+ if (!map) {
276
+ map = mergeDocuments(out.doc, src.doc) as Map<unknown, unknown>
277
+ out.merged.set(part.mesh.guid, map)
278
+ for (const n of src.doc.getRoot().listNodes()) { const t = map.get(n) as Node | undefined; if (t) out.imported.add(t) }
279
+ }
280
+ const meshId = `${part.mesh.guid}:${part.mesh.fileID}:`
281
+ const key = meshId + part.materials.join(",")
282
+ let mesh = out.meshes.get(key)
283
+ if (!mesh) {
284
+ const base = map.get(found) as Mesh
285
+ mesh = [ ...out.meshes.keys() ].some((k) => k.startsWith(meshId)) ? base.clone() : base
286
+ await this.assignMaterials(mesh, part, src, out)
287
+ out.meshes.set(key, mesh)
288
+ }
289
+ return mesh
290
+ }
291
+
292
+ /** Primitives whose material samples TRIPLANAR (a Shader Graph, triplanar.ts) get the projection baked
293
+ * into their UVs. The UVs depend on the part's matrix, so such a mesh becomes this part's own copy. */
294
+ private triplanarUVs(mesh: Mesh, matrix: Mat4, out: KindDoc): Mesh {
295
+ const refOf = new Map<Material, string>()
296
+ for (const [ ref, gm ] of out.materials) refOf.set(gm, ref)
297
+ const plan = mesh.listPrimitives().map((p) => {
298
+ // the cache key may carry the detail-layer suffix (assignMaterials) — the .mat is the ref before it
299
+ const ref = p.getMaterial() ? refOf.get(p.getMaterial()!)?.split("\u0000")[0] : undefined
300
+ const um = ref ? this.material(ref) : null
301
+ return um ? triplanarOf(um, this.ctx.resolver) : null
302
+ })
303
+ if (!plan.some(Boolean)) return mesh
304
+ const own = out.doc.createMesh(mesh.getName())
305
+ mesh.listPrimitives().forEach((p, i) => {
306
+ const t = plan[i]
307
+ const q = t ? p.clone() : p
308
+ if (t) projectTriplanar(out.doc, q, t.space === "object" ? I4() : matrix, t.tile)
309
+ own.addPrimitive(q)
310
+ })
311
+ return own
312
+ }
313
+
314
+ async build(kind: SceneKind): Promise<{ doc: Document, parts: number, missing: string[] }> {
315
+ const doc = quiet(new Document())
316
+ doc.createBuffer()
317
+ const scene = doc.createScene(kind.name)
318
+ const root = doc.createNode(kind.name)
319
+ scene.addChild(root)
320
+ const out: KindDoc = { doc, writer: unityMaterialWriter(doc, this.ctx.resolver), merged: new Map(), imported: new Set(), materials: new Map(), meshes: new Map() }
321
+ const missing: string[] = []
322
+ let parts = 0
323
+ for (const part of kind.parts) {
324
+ const where = part.path || kind.name
325
+ const src = await this.source(part.mesh.guid)
326
+ if (!src) { missing.push(`${where}: mesh file ${part.mesh.guid}`); continue }
327
+ const hit = this.meshFor(src, part.mesh.fileID, part.path.split("/").pop() || undefined, kind.name)
328
+ if (!hit) { missing.push(`${where}: mesh ${src.meta.names.get(part.mesh.fileID) ?? part.mesh.fileID} not in ${basename(src.path)}`); continue }
329
+ // A MESH WITH NO VERTICES IS NOT A MESH. Packs ship them — the Modular Shooting Range's floor
330
+ // stripes carry five LOD levels of which the last is empty — and writing one out is not merely
331
+ // wasteful: filament's gltfio walks the primitive in `ResourceLoader::loadResources` and takes
332
+ // the whole host down with an access violation, before a single frame. Skipped like a missing
333
+ // mesh, and reported, because a silently thinner LOD chain is its own bug.
334
+ if (!hit.mesh.listPrimitives().some((p) => (p.getAttribute("POSITION")?.getCount() ?? 0) > 0)) {
335
+ missing.push(`${where}: mesh "${hit.mesh.getName() || "?"}" has no vertices — skipped`)
336
+ continue
337
+ }
338
+ let mesh = await this.partMesh(part, src, hit.mesh, out)
339
+ // Unity bakes the geometric transform into the mesh, THEN scales it: part · S(globalScale) · helper
340
+ const m = mmul(mmul(part.matrix, scaleMatrix(src.meta.globalScale)), hit.local)
341
+ mesh = this.triplanarUVs(mesh, m, out)
342
+ root.addChild(doc.createNode(partNodeName(part, kind.name)).setMesh(mesh).setMatrix(m as never))
343
+ parts++
344
+ }
345
+ // the FBX's further UV sets were shader inputs (read for the detail layer above); TEXCOORD_1 is the lightmap set
346
+ for (const m of doc.getRoot().listMeshes()) for (const p of m.listPrimitives()) for (const k of [ 1, 2, 3 ]) { const a = p.getAttribute(`TEXCOORD_${k}`); if (a) { p.setAttribute(`TEXCOORD_${k}`, null); if (a.listParents().length <= 1) a.dispose() } }
347
+ // drop everything the FBX documents brought besides the meshes / materials we used
348
+ for (const s of doc.getRoot().listScenes()) if (s !== scene) s.dispose()
349
+ for (const n of out.imported) n.dispose()
350
+ await doc.transform(prune({ keepLeaves: false }))
351
+ // ONE BUFFER. `mergeDocuments` brings each source FBX's own buffer along, so a kind whose parts
352
+ // come from SEVERAL model files ends up with several — and a GLB may hold at most one, which
353
+ // glTF-Transform reports as "GLB must have 0-1 buffers" from inside writeBinary. The school's
354
+ // kinds each drew from a single FBX and never hit it; Chernobyl assembles prefabs out of parts
355
+ // from different models and dies partway through the level.
356
+ const buffers = doc.getRoot().listBuffers()
357
+ if (buffers.length > 1) {
358
+ const keep = buffers[0]
359
+ for (const a of doc.getRoot().listAccessors()) a.setBuffer(keep)
360
+ for (const b of buffers.slice(1)) b.dispose()
361
+ }
362
+ return { doc, parts, missing }
363
+ }
364
+ }