lecodes-assets 0.1.2 → 0.2.1

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 (65) hide show
  1. package/README.md +240 -5
  2. package/dist/cli.js +7757 -683
  3. package/dist/index.js +7866 -675
  4. package/package.json +13 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli.ts +1039 -0
  8. package/src/doctor/doctor.ts +241 -0
  9. package/src/doctor/lightmapUv.ts +452 -0
  10. package/src/doctor/lod.ts +177 -0
  11. package/src/doctor/normalizeScale.ts +220 -0
  12. package/src/doctor/reduceGlbBones.ts +218 -0
  13. package/src/doctor/shading.ts +95 -0
  14. package/src/doctor/transcodeKtx2.ts +124 -0
  15. package/src/doctor/transcodeTextures.ts +101 -0
  16. package/src/doctor/validateGlb.ts +264 -0
  17. package/src/fbx/convert.ts +327 -0
  18. package/src/fbx/read.ts +109 -0
  19. package/src/fbx/toDocument.ts +209 -0
  20. package/src/filmstrip.ts +263 -0
  21. package/src/index.ts +17 -0
  22. package/src/mergeSkins.ts +117 -0
  23. package/src/probe.ts +167 -0
  24. package/src/props.ts +137 -0
  25. package/src/retarget/automap.ts +276 -0
  26. package/src/retarget/ghost.ts +23 -0
  27. package/src/retarget/heading.ts +171 -0
  28. package/src/retarget/humanoid.ts +561 -0
  29. package/src/retarget/inPlace.ts +130 -0
  30. package/src/retarget/marks.ts +581 -0
  31. package/src/retarget/mirror.ts +0 -0
  32. package/src/retarget/retarget.ts +2272 -0
  33. package/src/retarget/rig.ts +542 -0
  34. package/src/retarget/unityAnim.ts +171 -0
  35. package/src/retarget/writeMarks.ts +132 -0
  36. package/src/sky/analyze.ts +136 -0
  37. package/src/sky/cmgen.ts +71 -0
  38. package/src/sky/index.ts +151 -0
  39. package/src/terrainPack.ts +169 -0
  40. package/src/textures/resolveImage.ts +202 -0
  41. package/src/unity/materials.ts +593 -0
  42. package/src/unity/png.ts +75 -0
  43. package/src/unity/prefabs.ts +80 -0
  44. package/src/unity/scene.ts +363 -0
  45. package/src/unity/unpack.ts +130 -0
  46. package/src/unreal/anim.ts +284 -0
  47. package/src/unreal/clipSource.ts +25 -0
  48. package/src/unreal/convert.ts +67 -0
  49. package/src/unreal/convertAsset.ts +81 -0
  50. package/src/unreal/index.ts +114 -0
  51. package/src/unreal/list.ts +113 -0
  52. package/src/unreal/materials.ts +291 -0
  53. package/src/unreal/mesh.ts +205 -0
  54. package/src/unreal/oodle.ts +153 -0
  55. package/src/unreal/package.ts +659 -0
  56. package/src/unreal/probe.ts +144 -0
  57. package/src/unreal/resolve.ts +51 -0
  58. package/src/unreal/rig.ts +149 -0
  59. package/src/unreal/sequencer.ts +193 -0
  60. package/src/unreal/skeleton.ts +74 -0
  61. package/src/unreal/texture.ts +202 -0
  62. package/src/unreal/toDocument.ts +360 -0
  63. package/src/unreal/versions.ts +100 -0
  64. package/src/wasm/fbxReader.js +14 -0
  65. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,593 @@
1
+ // Unity materials as the texture source of truth.
2
+ //
3
+ // An FBX exported for Unity usually references only a base colour (often a legacy file name) and a
4
+ // normal map; the real shading inputs live in the Unity `.mat` next to it — metallic, roughness /
5
+ // smoothness, occlusion (separate maps or an ORM / MaskMap / MetallicGloss pack), emission, and the
6
+ // alpha mode. Textures are referenced by GUID, resolved through a `unity-index.txt` (written by
7
+ // `lecodes assets unpack`) or the `.meta` sidecars of a real Unity project.
8
+ //
9
+ // What this does: for every FBX material, find the `.mat` of the same name (modulo M_/MI_/MAT_
10
+ // prefixes), read its slots, and rewrite the glTF material — base colour, normal, emissive, alpha
11
+ // mode, and ONE metallic-roughness(+occlusion) texture packed the glTF way (R = AO, G = roughness,
12
+ // B = metallic), re-encoding Unity's layouts (Standard `_MetallicGlossMap` R=metal A=smooth, HDRP/URP
13
+ // `_MaskMap` R=metal G=AO A=smooth, separate Metallic / Roughness / Smoothness / AO maps, or a ready
14
+ // `_ORM`), plus the shader's own SCALE on those maps (`_RoughnessScale` / `_MetallicScale` -> the
15
+ // glTF factors, which multiply the same way). Only YAML-serialised materials are fully readable (Unity ≥ 5 with "Force Text"); a
16
+ // BINARY `.mat` (many asset-store packs ship those) falls back to the pack's naming convention — its
17
+ // slots are inferred from the textures next to it (`<Name>.tga`, `<Name>_NM.tga`, `_MS`, `_AO`,
18
+ // `_E` …, see `inferBinaryMaterial`) and the report says so. `.mat` files are also looked up across
19
+ // the whole unpacked package through `unity-index.txt`: packs keep shared materials (asphalt,
20
+ // concrete) in a Textures/ tree far from the FBX that uses them.
21
+
22
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
23
+ import { basename, dirname, join, resolve } from "node:path"
24
+ import type { Document, Material, Texture } from "@gltf-transform/core"
25
+ import { sniffMime, decodeTga, decodeBmp, encodePng, type Rgba } from "../textures/resolveImage"
26
+ import { decodePng } from "./png"
27
+ import { collectPrefabOverrides, type PrefabOverride } from "./prefabs"
28
+
29
+ export type UnityMaterial = {
30
+ name: string
31
+ file: string
32
+ /** Shader asset path (resolved) or the builtin id. */
33
+ shader: string
34
+ /** Texture slot → GUID (only slots that reference a texture). */
35
+ textures: Record<string, string>
36
+ floats: Record<string, number>
37
+ colors: Record<string, [number, number, number, number]>
38
+ keywords: string[]
39
+ }
40
+
41
+ type Warn = (w: { level: "warn" | "info", message: string }) => void
42
+
43
+ // ---- .mat parsing (YAML) ------------------------------------------------------------------------
44
+
45
+ /** Parse a YAML-serialised Unity material. Returns null for binary serialisation / not a material. */
46
+ export const parseUnityMaterial = (text: string, file: string): UnityMaterial | null => {
47
+ if (!text.startsWith("%YAML") || !/\nMaterial:\n/.test(text)) return null
48
+ const block = text.slice(text.indexOf("\nMaterial:\n"))
49
+ const name = /\n\s*m_Name: (.*)/.exec(block)?.[1]?.trim() || basename(file, ".mat")
50
+ const sh = /m_Shader: \{fileID: (-?\d+)(?:, guid: ([0-9a-f]{32}))?/.exec(text)
51
+ const shader = sh ? (sh[2] ? `guid:${sh[2]}` : `builtin:${sh[1]}`) : ""
52
+ const textures: Record<string, string> = {}
53
+ const texRe = /\n\s+- (\w+):\n\s+m_Texture: \{fileID: -?\d+, guid: ([0-9a-f]{32}), type: \d+\}/g
54
+ for (let m = texRe.exec(text); m; m = texRe.exec(text)) textures[m[1]] = m[2]
55
+ const floats: Record<string, number> = {}
56
+ const floatSection = /m_Floats:\n([\s\S]*?)\n\s+m_Colors:/.exec(text)?.[1] ?? ""
57
+ const fRe = /- (\w+): (-?[\d.eE+-]+)/g
58
+ for (let m = fRe.exec(floatSection); m; m = fRe.exec(floatSection)) floats[m[1]] = Number(m[2])
59
+ const colors: Record<string, [number, number, number, number]> = {}
60
+ const cRe = /- (\w+): \{r: (-?[\d.eE+-]+), g: (-?[\d.eE+-]+), b: (-?[\d.eE+-]+), a: (-?[\d.eE+-]+)\}/g
61
+ for (let m = cRe.exec(text); m; m = cRe.exec(text)) colors[m[1]] = [ Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]) ]
62
+ const keywords = (/m_ShaderKeywords: (.*)/.exec(text)?.[1] ?? "").trim().split(/\s+/).filter(Boolean)
63
+ return { name, file, shader, textures, floats, colors, keywords }
64
+ }
65
+
66
+ export const isBinaryUnityAsset = (bytes: Uint8Array): boolean => !(bytes[0] === 0x25 && bytes[1] === 0x59 && bytes[2] === 0x41) // "%YA"
67
+
68
+ // ---- locating materials + resolving GUIDs ----------------------------------------------------------
69
+
70
+ const walkFiles = (dir: string, depth: number, out: string[], pred: (name: string) => boolean): void => {
71
+ if (depth < 0 || !existsSync(dir)) return
72
+ let entries
73
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
74
+ for (const e of entries) {
75
+ const p = join(dir, e.name)
76
+ if (e.isDirectory()) { if (e.name !== "node_modules" && !e.name.startsWith(".")) walkFiles(p, depth - 1, out, pred) }
77
+ else if (pred(e.name)) out.push(p)
78
+ }
79
+ }
80
+
81
+ /** Normalise a material name for matching: case, and the M_/MI_/MAT_ prefixes packs sprinkle. */
82
+ export const materialKey = (n: string): string => n.trim().toLowerCase().replace(/^(mi|m|mat|mtl)_/, "").replace(/\.\d+$/, "")
83
+
84
+ /** Texture references an inferred material carries: a pseudo-GUID naming the file directly. */
85
+ export const PATH_GUID = "path:"
86
+ const IMAGE_RE = /\.(png|jpe?g|tga|bmp|tiff?|psd|exr)$/i
87
+ /** Suffix conventions (after the material name, case-insensitive) → Unity slot. The base colour is
88
+ * the bare name or one of the albedo suffixes. */
89
+ const SUFFIX_SLOTS: [RegExp, string][] = [
90
+ [ /^$|^_(d|bc|albedo|diffuse|diff|basecolor|base|col|color)$/i, "_MainTex" ],
91
+ [ /^_(nm|n|nrm|normal|normalmap|norm)$/i, "_BumpMap" ],
92
+ [ /^_(ms|metallicsmoothness|metallicgloss|mg)$/i, "_MetallicGlossMap" ],
93
+ [ /^_(m|metallic|metal|met)$/i, "_Metallic" ],
94
+ [ /^_(r|rough|roughness)$/i, "_Roughness" ],
95
+ [ /^_(s|smoothness|gloss)$/i, "_Smoothness" ],
96
+ [ /^_(ao|occlusion|ambientocclusion)$/i, "_OcclusionMap" ],
97
+ [ /^_(e|em|emission|emissive|emit)$/i, "_EmissionMap" ],
98
+ [ /^_(orm)$/i, "_ORM" ],
99
+ [ /^_(mask|maskmap)$/i, "_MaskMap" ],
100
+ ]
101
+
102
+ /**
103
+ * A binary-serialised `.mat` can't be parsed, but asset-store packs name their textures after the
104
+ * material: `Seamless_asphalt_v1.mat` next to `Source/Seamless_asphalt_v1.tga` + `…_NM.tga`. Scan the
105
+ * .mat's folder (and its subfolders, two deep) for images whose stem is the material name plus a
106
+ * known suffix and build a material whose texture refs point at those files (`path:` pseudo-GUIDs,
107
+ * resolved without an index). Null when no base colour is found — a wrong guess is worse than none.
108
+ */
109
+ export const inferBinaryMaterial = (file: string): UnityMaterial | null => {
110
+ const name = basename(file, ".mat")
111
+ const dir = dirname(file)
112
+ const images: string[] = []
113
+ walkFiles(dir, 2, images, (n) => IMAGE_RE.test(n))
114
+ const textures: Record<string, string> = {}
115
+ const lname = name.toLowerCase()
116
+ for (const img of images.sort()) {
117
+ const stem = basename(img).replace(/\.[^.]+$/, "")
118
+ if (!stem.toLowerCase().startsWith(lname)) continue
119
+ const suffix = stem.slice(name.length)
120
+ const slot = SUFFIX_SLOTS.find(([ re ]) => re.test(suffix))?.[1]
121
+ if (slot && !textures[slot]) textures[slot] = PATH_GUID + img
122
+ }
123
+ if (!textures._MainTex) return null
124
+ return { name, file, shader: "binary", textures, floats: {}, colors: {}, keywords: [] }
125
+ }
126
+
127
+ /** Every readable `.mat` under `dirs` (recursive) plus `extraFiles`, keyed by normalised name.
128
+ * Binary ones are listed in `binary`; those whose textures could be inferred from their neighbours
129
+ * (`inferBinaryMaterial`) are ALSO used and listed in `inferred`. */
130
+ export const collectUnityMaterials = (dirs: string[], depth = 6, extraFiles: string[] = []): { byKey: Map<string, UnityMaterial>, byFile: Map<string, UnityMaterial>, binary: string[], inferred: string[] } => {
131
+ const files: string[] = []
132
+ for (const d of dirs) walkFiles(d, depth, files, (n) => /\.mat$/i.test(n))
133
+ const seen = new Set(files.map((f) => resolve(f)))
134
+ for (const f of extraFiles) { const a = resolve(f); if (!seen.has(a) && existsSync(a)) { seen.add(a); files.push(a) } }
135
+ const byKey = new Map<string, UnityMaterial>()
136
+ // Keyed by ABSOLUTE PATH as well: `materialKey` strips the M_/MI_ prefix, so a pack that ships a
137
+ // real `Materials/M_Wall.mat` beside the FBX's placeholder `Assets/Materials/MI_Wall.mat` collapses
138
+ // both onto one key and whichever was walked first wins. A prefab override names its material by
139
+ // GUID — an exact file — and must be looked up as one.
140
+ const byFile = new Map<string, UnityMaterial>()
141
+ const binary: string[] = []
142
+ const inferred: string[] = []
143
+ const put = (m: UnityMaterial, f: string) => {
144
+ byFile.set(resolve(f), m)
145
+ const k = materialKey(m.name)
146
+ if (!byKey.has(k)) byKey.set(k, m)
147
+ const fk = materialKey(basename(f, ".mat"))
148
+ if (!byKey.has(fk)) byKey.set(fk, m)
149
+ }
150
+ for (const f of files) {
151
+ const bytes = new Uint8Array(readFileSync(f))
152
+ if (isBinaryUnityAsset(bytes)) {
153
+ binary.push(f)
154
+ const m = inferBinaryMaterial(f)
155
+ if (m) { inferred.push(f); put(m, f) }
156
+ continue
157
+ }
158
+ const m = parseUnityMaterial(new TextDecoder().decode(bytes), f)
159
+ if (!m) continue
160
+ put(m, f)
161
+ }
162
+ return { byKey, byFile, binary, inferred }
163
+ }
164
+
165
+ /** Every `.mat` the index knows (absolute paths) — shared materials live far from the FBX. */
166
+ export const indexedMaterialFiles = (indexFile: string): string[] => {
167
+ const root = dirname(indexFile)
168
+ const out: string[] = []
169
+ for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) {
170
+ const t = line.indexOf("\t")
171
+ if (t > 0 && /\.mat$/i.test(line)) out.push(join(root, line.slice(t + 1).trim()))
172
+ }
173
+ return out
174
+ }
175
+
176
+ export type GuidResolver = { resolve: (guid: string) => string | null, source: string }
177
+
178
+ /** Find `unity-index.txt` (written by `lecodes assets unpack`) walking up from `start`. */
179
+ export const findUnityIndex = (start: string): string | null => {
180
+ let dir = resolve(start)
181
+ for (let i = 0; i < 12; i++) {
182
+ const f = join(dir, "unity-index.txt")
183
+ if (existsSync(f)) return f
184
+ const up = dirname(dir)
185
+ if (up === dir) break
186
+ dir = up
187
+ }
188
+ return null
189
+ }
190
+
191
+ /** The GUID the index lists for an absolute asset path (the FBX itself), or null. */
192
+ export const guidOfPath = (indexFile: string, absPath: string): string | null => {
193
+ const root = resolve(dirname(indexFile))
194
+ const want = resolve(absPath).toLowerCase().replace(/\\/g, "/")
195
+ for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) {
196
+ const t = line.indexOf("\t")
197
+ if (t < 0) continue
198
+ if (resolve(root, line.slice(t + 1).trim()).toLowerCase().replace(/\\/g, "/") === want) return line.slice(0, t)
199
+ }
200
+ return null
201
+ }
202
+
203
+ /** GUID → absolute path through an index file (`<guid>\t<path relative to the index's folder>`). */
204
+ export const resolverFromIndex = (indexFile: string): GuidResolver => {
205
+ const root = dirname(indexFile)
206
+ const map = new Map<string, string>()
207
+ for (const line of readFileSync(indexFile, "utf8").split(/\r?\n/)) {
208
+ const t = line.indexOf("\t")
209
+ if (t > 0) map.set(line.slice(0, t), line.slice(t + 1).trim())
210
+ }
211
+ return { source: indexFile, resolve: (g) => { const p = map.get(g); if (!p) return null; const abs = join(root, p); return existsSync(abs) ? abs : null } }
212
+ }
213
+
214
+ /** GUID → path through `.meta` sidecars: scans up to the folder that holds `Assets` (or `depthUp` levels). */
215
+ export const resolverFromMeta = (start: string, depthUp = 6): GuidResolver | null => {
216
+ let root = resolve(start)
217
+ for (let i = 0; i < depthUp; i++) {
218
+ if (existsSync(join(root, "Assets")) || basename(root) === "Assets") break
219
+ const up = dirname(root)
220
+ if (up === root) break
221
+ root = up
222
+ }
223
+ const metas: string[] = []
224
+ walkFiles(root, 12, metas, (n) => n.endsWith(".meta"))
225
+ if (metas.length === 0) return null
226
+ const map = new Map<string, string>()
227
+ for (const f of metas) {
228
+ const head = readFileSync(f, "utf8").slice(0, 300)
229
+ const g = /\nguid: ([0-9a-f]{32})/.exec(head)?.[1]
230
+ if (g) map.set(g, f.slice(0, -5))
231
+ }
232
+ return { source: `${metas.length} .meta files under ${root}`, resolve: (g) => { const p = map.get(g); return p && existsSync(p) ? p : null } }
233
+ }
234
+
235
+ // ---- textures: decode / pack / encode -------------------------------------------------------------
236
+
237
+ export const decodeImage = async (bytes: Uint8Array, hint: string): Promise<Rgba | null> => {
238
+ const mime = sniffMime(bytes, hint)
239
+ if (mime === "image/png") { const r = decodePng(bytes); if (r) return r }
240
+ if (mime === "image/x-tga") return decodeTga(bytes)
241
+ if (mime === "image/bmp") return decodeBmp(bytes)
242
+ try {
243
+ const sharp = (await import("sharp")).default
244
+ const { data, info } = await sharp(Buffer.from(bytes)).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
245
+ return { width: info.width, height: info.height, data: new Uint8Array(data) }
246
+ } catch { return null }
247
+ }
248
+
249
+ /** Nearest-neighbour sample of one channel at (u, v) in 0..1. */
250
+ const sampleCh = (img: Rgba, ch: number, x: number, y: number, w: number, h: number): number => {
251
+ const sx = Math.min(img.width - 1, Math.floor((x + 0.5) * img.width / w))
252
+ const sy = Math.min(img.height - 1, Math.floor((y + 0.5) * img.height / h))
253
+ return img.data[(sy * img.width + sx) * 4 + ch]
254
+ }
255
+
256
+ export type PackInput = { img: Rgba, channel: number, invert?: boolean }
257
+ /** Pack occlusion / roughness / metallic sources into one RGB image (glTF layout). Missing = constant. */
258
+ export const packOrm = (ao: PackInput | null, rough: PackInput | null, metal: PackInput | null, defaults = { ao: 255, rough: 255, metal: 0 }): Rgba => {
259
+ const srcs = [ ao, rough, metal ].filter((s): s is PackInput => !!s)
260
+ const w = Math.max(1, ...srcs.map((s) => s.img.width)), h = Math.max(1, ...srcs.map((s) => s.img.height))
261
+ const out = new Uint8Array(w * h * 4)
262
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
263
+ const d = (y * w + x) * 4
264
+ 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 }
265
+ out[d] = get(ao, defaults.ao); out[d + 1] = get(rough, defaults.rough); out[d + 2] = get(metal, defaults.metal); out[d + 3] = 255
266
+ }
267
+ return { width: w, height: h, data: out }
268
+ }
269
+
270
+ // ---- slot vocabulary --------------------------------------------------------------------------------
271
+
272
+ // `_Main*` is the vertex-PAINT convention: a shadergraph that blends a base layer with per-vertex-
273
+ // colour layers (R/G/B channels) names the base one "Main". Nothing here can run the graph, so the
274
+ // main layer IS the material — far better than the white nothing those slot names used to produce.
275
+ const SLOTS = {
276
+ base: [ "_BaseMap", "_MainTex", "_BaseColor", "_BaseColorMap", "_BC", "_Albedo", "_AlbedoMap", "_Diffuse", "_DiffuseMap", "_Color", "_MainBC" ],
277
+ normal: [ "_BumpMap", "_NormalMap", "_Normal", "_NormalTex", "_MainNormal" ],
278
+ orm: [ "_ORM", "_ORMMap", "_OcclusionRoughnessMetallic", "_ORMTex", "_MainORM" ],
279
+ maskMap: [ "_MaskMap" ],
280
+ metallicGloss: [ "_MetallicGlossMap" ],
281
+ metallic: [ "_Metallic", "_MetallicMap", "_MetallicTex", "_MetalMap" ],
282
+ roughness: [ "_Roughness", "_RoughnessMap", "_RoughnessTex" ],
283
+ smoothness: [ "_Smoothness", "_SmoothnessMap", "_SmoothnessTex", "_Gloss", "_GlossMap" ],
284
+ occlusion: [ "_OcclusionMap", "_Occlusion", "_AO", "_AOMap", "_AmbientOcclusion" ],
285
+ emission: [ "_EmissionMap", "_Emission", "_Emissive", "_EmissiveMap", "_EmissiveColorMap" ],
286
+ alpha: [ "_Alpha", "_Opacity", "_OpacityMap", "_AlphaMap", "_OpacityMask" ],
287
+ }
288
+ const pick = (m: UnityMaterial, names: string[]): string | null => { for (const n of names) { const g = m.textures[n]; if (g) return g } return null }
289
+
290
+ export type ApplyUnityOptions = {
291
+ /** Folder(s) to look for `.mat` files in (default: the FBX's folder and its parent, recursively). */
292
+ materialDirs?: string[]
293
+ /** `unity-index.txt` to resolve GUIDs with (default: found walking up from the FBX; else `.meta` sidecars). */
294
+ indexFile?: string
295
+ /** Honour the material overrides of the `.prefab` files around the FBX (default true) — packs assign
296
+ * the real material on the prefab's MeshRenderer and leave the FBX with placeholders. */
297
+ prefabs?: boolean
298
+ /** Folders to read `.prefab` files from (default: the FBX's folder and its parent, 3 deep). */
299
+ prefabDirs?: string[]
300
+ warn?: Warn
301
+ }
302
+
303
+ export type ApplyUnityReport = {
304
+ /** glTF material name → what was applied (slots[0] notes a non-exact match). */
305
+ applied: { material: string, mat: string, slots: string[] }[]
306
+ unmatched: string[]
307
+ binary: string[]
308
+ /** Binary .mat files whose slots were inferred from their sibling textures. */
309
+ inferred: string[]
310
+ /** Nodes re-materialed from a prefab's MeshRenderer (slots listed once per distinct .mat). */
311
+ overrides: { node: string, mat: string, slots: string[] }[]
312
+ resolver: string | null
313
+ }
314
+
315
+ /** Rewrite the Document's materials from the Unity `.mat` files that match them by name. */
316
+ export const applyUnityMaterials = async (doc: Document, fbxPath: string, opts: ApplyUnityOptions = {}): Promise<ApplyUnityReport> => {
317
+ const warn = opts.warn ?? (() => {})
318
+ const fbxDir = dirname(resolve(fbxPath))
319
+ const report: ApplyUnityReport = { applied: [], unmatched: [], binary: [], inferred: [], overrides: [], resolver: null }
320
+ // Only a Unity CONTEXT makes the .mat scan worthwhile: an unpacked package (unity-index.txt above
321
+ // the FBX) or a real Unity project (the FBX's own .meta sidecar). Without one the scan of the parent
322
+ // folder only finds unrelated projects' materials (a Mixamo folder next to a Unity export) and the
323
+ // .meta resolver would crawl a whole home directory looking for `Assets`.
324
+ const indexFile = opts.indexFile ?? findUnityIndex(fbxDir)
325
+ const inProject = existsSync(fbxPath + ".meta")
326
+ if (!indexFile && !inProject && !opts.materialDirs) return report
327
+ const dirs = opts.materialDirs ?? [ fbxDir, dirname(fbxDir) ]
328
+ // the FBX's neighbourhood first (wins on a name clash), then every .mat the package index lists
329
+ const { byKey, byFile, binary, inferred } = collectUnityMaterials(dirs, 6, indexFile ? indexedMaterialFiles(indexFile) : [])
330
+ report.binary = binary
331
+ report.inferred = inferred
332
+ const mats = doc.getRoot().listMaterials()
333
+ if (byKey.size === 0) return report
334
+
335
+ let resolver: GuidResolver | null = indexFile ? resolverFromIndex(indexFile) : null
336
+ if (!resolver && (inProject || opts.materialDirs)) resolver = resolverFromMeta(fbxDir)
337
+ // inferred materials reference files directly — they need no GUID resolver
338
+ if (!resolver && inferred.length) resolver = { source: "inferred from sibling textures", resolve: () => null }
339
+ if (!resolver) {
340
+ warn({ level: "warn", message: `Unity materials found but no way to resolve their texture GUIDs — need a unity-index.txt (lecodes assets unpack) above the FBX or the .meta sidecars of a Unity project` })
341
+ return report
342
+ }
343
+ report.resolver = resolver.source
344
+
345
+ // name matching: exact (modulo prefixes) → one key contains the other (MI_Tshirt ↔ M_Shirt) → the
346
+ // FBX material's base texture and a .mat's base slot point at the same file (MI_Skin ↔ M_Body via
347
+ // TX_Body_BaseColor) — packs rename materials freely but rarely duplicate a texture
348
+ const allMats = [ ...new Set(byKey.values()) ]
349
+ const stemOf = (f: string): string => basename(f).replace(/\.[^.]+$/, "").toLowerCase()
350
+ const matByStem = new Map<string, UnityMaterial>()
351
+ for (const um of allMats) {
352
+ const g = pick(um, SLOTS.base)
353
+ const f = g ? resolver.resolve(g) : null
354
+ if (f && !matByStem.has(stemOf(f))) matByStem.set(stemOf(f), um)
355
+ }
356
+ const matchMaterial = (gm: Material): { um: UnityMaterial, how: string } | null => {
357
+ const k = materialKey(gm.getName())
358
+ const exact = byKey.get(k)
359
+ if (exact) return { um: exact, how: "name" }
360
+ if (k.length >= 4) {
361
+ const part = allMats.find((um) => { const mk = materialKey(um.name); return mk.length >= 4 && (mk.includes(k) || k.includes(mk)) })
362
+ if (part) return { um: part, how: `name ~ ${part.name}` }
363
+ }
364
+ const base = gm.getBaseColorTexture()
365
+ const stem = base ? stemOf(base.getName() || base.getURI() || "") : ""
366
+ const byTex = stem ? matByStem.get(stem) : undefined
367
+ if (byTex) return { um: byTex, how: `base texture ${stem}` }
368
+ return null
369
+ }
370
+
371
+ const texCache = new Map<string, Texture | null>()
372
+ const imgCache = new Map<string, Rgba | null>()
373
+ const loadBytes = (guid: string): { bytes: Uint8Array, file: string } | null => {
374
+ const f = guid.startsWith(PATH_GUID) ? guid.slice(PATH_GUID.length) : resolver!.resolve(guid)
375
+ if (!f) { warn({ level: "warn", message: `texture ${guid}: not in the index / not extracted — skipped` }); return null }
376
+ return { bytes: new Uint8Array(readFileSync(f)), file: f }
377
+ }
378
+ const texFromGuid = async (guid: string): Promise<Texture | null> => {
379
+ if (texCache.has(guid)) return texCache.get(guid)!
380
+ const src = loadBytes(guid)
381
+ let tex: Texture | null = null
382
+ if (src) {
383
+ const mime = sniffMime(src.bytes, src.file)
384
+ let data = src.bytes, outMime: "image/png" | "image/jpeg" = "image/png"
385
+ if (mime === "image/png" || mime === "image/jpeg") outMime = mime
386
+ else {
387
+ const img = await decodeImage(src.bytes, src.file)
388
+ if (!img) { warn({ level: "warn", message: `texture ${basename(src.file)}: unsupported format (${mime}) — skipped` }); texCache.set(guid, null); return null }
389
+ data = encodePng(img)
390
+ }
391
+ tex = doc.createTexture(basename(src.file).replace(/\.[^.]+$/, "")).setImage(data).setMimeType(outMime)
392
+ }
393
+ texCache.set(guid, tex)
394
+ return tex
395
+ }
396
+ const imgFromGuid = async (guid: string): Promise<Rgba | null> => {
397
+ if (imgCache.has(guid)) return imgCache.get(guid)!
398
+ const src = loadBytes(guid)
399
+ const img = src ? await decodeImage(src.bytes, src.file) : null
400
+ if (src && !img) warn({ level: "warn", message: `texture ${basename(src.file)}: could not decode for packing — skipped` })
401
+ imgCache.set(guid, img)
402
+ return img
403
+ }
404
+ const nameOf = (guid: string): string => { const f = guid.startsWith(PATH_GUID) ? guid.slice(PATH_GUID.length) : resolver!.resolve(guid); return f ? basename(f) : guid }
405
+
406
+ /** Rewrite one glTF material from a Unity material; returns the slot summary. */
407
+ const applyTo = async (gm: Material, um: UnityMaterial, how: string): Promise<string[]> => {
408
+ const slots: string[] = how === "name" ? [] : [ `(matched by ${how})` ]
409
+ if (um.shader === "binary") slots.push("(binary .mat — slots inferred from sibling textures)")
410
+ // base colour (+ the FBX's base when the .mat has none — keep whatever is there)
411
+ const baseG = pick(um, SLOTS.base)
412
+ if (baseG) { const t = await texFromGuid(baseG); if (t) { gm.setBaseColorTexture(t); slots.push(`base=${nameOf(baseG)}`) } }
413
+ const baseColor = um.colors._BaseColor ?? um.colors._Color
414
+ if (baseColor) gm.setBaseColorFactor([ baseColor[0], baseColor[1], baseColor[2], gm.getBaseColorFactor()[3] ])
415
+ const nG = pick(um, SLOTS.normal)
416
+ if (nG) { const t = await texFromGuid(nG); if (t) { gm.setNormalTexture(t); gm.setNormalScale(um.floats._BumpScale ?? 1); slots.push(`normal=${nameOf(nG)}`) } }
417
+
418
+ // metallic / roughness / occlusion
419
+ const ormG = pick(um, SLOTS.orm), maskG = pick(um, SLOTS.maskMap), mgG = pick(um, SLOTS.metallicGloss)
420
+ const metG = pick(um, SLOTS.metallic), roughG = pick(um, SLOTS.roughness), smoothG = pick(um, SLOTS.smoothness), aoG = pick(um, SLOTS.occlusion)
421
+ // A shader's SCALE on top of the maps. glTF's metallicFactor / roughnessFactor multiply their
422
+ // texture exactly the way these do, so they carry over one-to-one — and dropping them is not a
423
+ // cosmetic loss: KINEMATION's weapon shader ships `_RoughnessScale: 0.65`, so a gun imported
424
+ // without it renders 54 % rougher than the artist set it, which reads as plastic instead of
425
+ // gunmetal. Every weapon in that pack sets 0.6-0.8; only characters leave it at 1.
426
+ // Names differ per shader, so accept the ones seen in the wild.
427
+ const scaleOf = (...keys: string[]): number => {
428
+ for (const k of keys) { const v = um.floats[k]; if (v !== undefined && v >= 0 && v <= 1) return v }
429
+ return 1
430
+ }
431
+ const roughScale = scaleOf("_RoughnessScale", "_RoughnessMultiplier", "_RoughnessStrength")
432
+ const metalScale = scaleOf("_MetallicScale", "_MetallicMultiplier", "_MetallicStrength")
433
+ const setMr = (t: Texture, withAo: boolean, label: string): void => {
434
+ gm.setMetallicRoughnessTexture(t).setMetallicFactor(metalScale).setRoughnessFactor(roughScale)
435
+ if (withAo) gm.setOcclusionTexture(t).setOcclusionStrength(um.floats._OcclusionStrength ?? 1)
436
+ if (roughScale !== 1 || metalScale !== 1) {
437
+ slots.push(`scale metallic=${metalScale} roughness=${roughScale}`)
438
+ }
439
+ slots.push(label)
440
+ }
441
+ if (ormG) {
442
+ const t = await texFromGuid(ormG)
443
+ if (t) setMr(t, true, `orm=${nameOf(ormG)}`)
444
+ } else if (maskG || mgG || metG || roughG || smoothG || aoG) {
445
+ // pack: R = AO, G = roughness, B = metallic
446
+ let ao: PackInput | null = null, rough: PackInput | null = null, metal: PackInput | null = null
447
+ const parts: string[] = []
448
+ if (maskG) { const img = await imgFromGuid(maskG); if (img) { metal = { img, channel: 0 }; ao = { img, channel: 1 }; rough = { img, channel: 3, invert: true }; parts.push(`MaskMap ${nameOf(maskG)}`) } }
449
+ if (mgG) { const img = await imgFromGuid(mgG); if (img) { metal = { img, channel: 0 }; rough = { img, channel: 3, invert: true }; parts.push(`MetallicGloss ${nameOf(mgG)}`) } }
450
+ if (metG) { const img = await imgFromGuid(metG); if (img) { metal = { img, channel: 0 }; parts.push(`metallic ${nameOf(metG)}`) } }
451
+ if (roughG) { const img = await imgFromGuid(roughG); if (img) { rough = { img, channel: 0 }; parts.push(`roughness ${nameOf(roughG)}`) } }
452
+ else if (smoothG) { const img = await imgFromGuid(smoothG); if (img) { rough = { img, channel: 0, invert: true }; parts.push(`smoothness ${nameOf(smoothG)}`) } }
453
+ if (aoG) { const img = await imgFromGuid(aoG); if (img) { ao = { img, channel: 0 }; parts.push(`ao ${nameOf(aoG)}`) } }
454
+ if (metal || rough || ao) {
455
+ // scalar fallbacks for the channels no map covers
456
+ const smooth = um.floats._Smoothness ?? um.floats._Glossiness
457
+ const dflt = { ao: 255, rough: Math.round((1 - (smooth ?? 0.5)) * 255), metal: Math.round((um.floats._Metallic ?? 0) * 255) }
458
+ const packed = packOrm(ao, rough, metal, dflt)
459
+ const t = doc.createTexture(`${um.name}_ORM`).setImage(encodePng(packed)).setMimeType("image/png")
460
+ setMr(t, !!ao, `packed ORM ← ${parts.join(" + ")}`)
461
+ }
462
+ } else {
463
+ // no maps: scalar metallic / roughness from the material
464
+ if (um.floats._Metallic !== undefined) gm.setMetallicFactor(um.floats._Metallic)
465
+ const smooth = um.floats._Smoothness ?? um.floats._Glossiness
466
+ if (smooth !== undefined) gm.setRoughnessFactor(1 - smooth)
467
+ if (um.floats._Metallic !== undefined || smooth !== undefined) slots.push(`metallic=${gm.getMetallicFactor().toFixed(2)} roughness=${gm.getRoughnessFactor().toFixed(2)}`)
468
+ }
469
+
470
+ // emission: only when the material actually emits (keyword or a non-black colour)
471
+ const emG = pick(um, SLOTS.emission)
472
+ // `_EmissiveColor` FIRST: HDRP's Lit shader emits by that one and leaves the Standard shader's
473
+ // `_EmissionColor` sitting in the property block at white, so reading `_EmissionColor` made every
474
+ // surface of an HDRP pack a self-lit white card — a whole imported level rendered as blank
475
+ // cardboard. URP / Standard materials carry no `_EmissiveColor`, so they are unaffected.
476
+ const emC = um.colors._EmissiveColor ?? um.colors._EmissionColor
477
+ // An explicitly BLACK _EmissionColor is a veto, not a missing value: Unity multiplies the
478
+ // emission map by that colour, so black means the surface does not emit however many maps are
479
+ // bound. Without this the "a map and no keywords, so assume it emits" fallback below fires on
480
+ // custom-shader materials that keep a texture in the slot and switch it off with the colour —
481
+ // which lit a whole rifle body through a magazine's emission map.
482
+ const emissionVetoed = emC !== undefined && emC[0] + emC[1] + emC[2] <= 0.01
483
+ const emits = um.keywords.includes("_EMISSION")
484
+ || (emC ? emC[0] + emC[1] + emC[2] > 0.01 : false)
485
+ || (!!emG && !um.keywords.length && !emissionVetoed)
486
+ if (emG && emits) {
487
+ const t = await texFromGuid(emG)
488
+ if (t) { gm.setEmissiveTexture(t).setEmissiveFactor(emC && emC[0] + emC[1] + emC[2] > 0.01 ? [ Math.min(1, emC[0]), Math.min(1, emC[1]), Math.min(1, emC[2]) ] : [ 1, 1, 1 ]); slots.push(`emissive=${nameOf(emG)}`) }
489
+ } else if (emC && emits && !emG) gm.setEmissiveFactor([ Math.min(1, emC[0]), Math.min(1, emC[1]), Math.min(1, emC[2]) ])
490
+ else if (!emits) {
491
+ // The Unity material is the authority and it says this surface does NOT emit — so CLEAR what
492
+ // the FBX bound (toDocument.ts binds fbx emissiveTex with factor 1,1,1 whenever the mesh file
493
+ // names one). DCC exports routinely carry an emission slot the Unity material later overrode
494
+ // to none; leaving it produced a rifle body glowing through a magazine's emission map, which
495
+ // only became visible once the textures stopped being mushy.
496
+ if (gm.getEmissiveTexture()) { gm.setEmissiveTexture(null); slots.push("emissive=off") }
497
+ const ef = gm.getEmissiveFactor()
498
+ if (ef[0] + ef[1] + ef[2] > 0) gm.setEmissiveFactor([ 0, 0, 0 ])
499
+ }
500
+
501
+ // alpha: URP `_Surface` 1 = transparent, `_AlphaClip` 1 = cutout; Standard `_Mode` 1 cutout, 2/3 blend
502
+ const mode = um.floats._Mode, surface = um.floats._Surface, clip = um.floats._AlphaClip
503
+ const cutout = clip === 1 || mode === 1 || um.keywords.includes("_ALPHATEST_ON")
504
+ const blend = surface === 1 || mode === 2 || mode === 3 || um.keywords.includes("_ALPHABLEND_ON") || um.keywords.includes("_ALPHAPREMULTIPLY_ON")
505
+ if (cutout) { gm.setAlphaMode("MASK").setAlphaCutoff(um.floats._Cutoff ?? 0.5); slots.push("cutout") }
506
+ else if (blend) { gm.setAlphaMode("BLEND"); slots.push("blend") }
507
+ else if (gm.getAlphaMode() !== "OPAQUE" && !pick(um, SLOTS.alpha)) gm.setAlphaMode("OPAQUE")
508
+ const cull = um.floats._Cull
509
+ if (cull === 0) gm.setDoubleSided(true)
510
+
511
+ return slots
512
+ }
513
+
514
+ const appliedFrom = new Map<Material, string>()
515
+ for (const gm of mats) {
516
+ const hit = matchMaterial(gm)
517
+ if (!hit) { report.unmatched.push(gm.getName()); continue }
518
+ const slots = await applyTo(gm, hit.um, hit.how)
519
+ appliedFrom.set(gm, hit.um.file)
520
+ report.applied.push({ material: gm.getName(), mat: basename(hit.um.file), slots })
521
+ }
522
+
523
+ // prefab overrides: a prefab's MeshRenderer names the real material for a node whose FBX material
524
+ // is a placeholder ("17 - Default"). Only prefabs showing THIS FBX's mesh count (by GUID, when the
525
+ // index knows the FBX). The override is applied per primitive; a mesh shared by several nodes
526
+ // takes the first node's override.
527
+ if (opts.prefabs !== false && resolver.source !== "inferred from sibling textures") {
528
+ const fbxGuid = indexFile ? guidOfPath(indexFile, fbxPath) : null
529
+ const overrides = collectPrefabOverrides(opts.prefabDirs ?? [ fbxDir, dirname(fbxDir) ])
530
+ .filter((o) => !fbxGuid || !o.meshGuid || o.meshGuid === fbxGuid)
531
+ const byNode = new Map<string, PrefabOverride>()
532
+ for (const o of overrides) if (!byNode.has(o.node)) byNode.set(o.node, o)
533
+ const matCache = new Map<string, Material>()
534
+ // the FBX importer hangs the mesh on an unnamed child of the named node — take the subtree
535
+ const meshesUnder = (n: import("@gltf-transform/core").Node): import("@gltf-transform/core").Mesh[] => {
536
+ const out: import("@gltf-transform/core").Mesh[] = []
537
+ const walk = (x: import("@gltf-transform/core").Node): void => { const m = x.getMesh(); if (m) out.push(m); x.listChildren().forEach(walk) }
538
+ walk(n)
539
+ return out
540
+ }
541
+ for (const node of doc.getRoot().listNodes()) {
542
+ const o = byNode.get(node.getName())
543
+ if (!o) continue
544
+ const prims = meshesUnder(node).flatMap((m) => m.listPrimitives())
545
+ for (let i = 0; i < prims.length; i++) {
546
+ const g = o.materials[i]
547
+ if (!g) continue
548
+ const matFile = resolver.resolve(g)
549
+ if (!matFile) continue
550
+ // by PATH first — the GUID named one exact .mat, and the name key cannot tell
551
+ // `Materials/M_VP_01.mat` from the FBX's placeholder `Assets/Materials/MI_VP_01.mat`
552
+ const um = byFile.get(resolve(matFile)) ?? byKey.get(materialKey(basename(matFile, ".mat")))
553
+ if (!um) continue
554
+ const current = prims[i].getMaterial()
555
+ if (current && appliedFrom.get(current) === um.file) continue // the FBX material already matched this .mat
556
+ let gm = matCache.get(um.file)
557
+ if (!gm) {
558
+ gm = doc.createMaterial(um.name)
559
+ const slots = await applyTo(gm, um, "name")
560
+ matCache.set(um.file, gm)
561
+ appliedFrom.set(gm, um.file)
562
+ report.overrides.push({ node: node.getName(), mat: basename(um.file), slots })
563
+ } else report.overrides.push({ node: node.getName(), mat: basename(um.file), slots: [] })
564
+ prims[i].setMaterial(gm)
565
+ }
566
+ }
567
+ }
568
+ return report
569
+ }
570
+
571
+ /** Drop textures no material references any more (the FBX's legacy ones after a .mat rewrite). */
572
+ export const pruneUnusedTextures = (doc: Document): number => {
573
+ let n = 0
574
+ for (const t of doc.getRoot().listTextures()) {
575
+ if (t.listParents().every((p) => p.propertyType === "Root")) { t.dispose(); n++ }
576
+ }
577
+ return n
578
+ }
579
+
580
+ export const describeUnityReport = (r: ApplyUnityReport): string[] => {
581
+ const lines: string[] = []
582
+ for (const a of r.applied) lines.push(`🎨 ${a.material} ← ${a.mat}: ${a.slots.join(", ") || "(no changes)"}`)
583
+ const byMat = new Map<string, { nodes: string[], slots: string[] }>()
584
+ for (const o of r.overrides) { const e = byMat.get(o.mat) ?? { nodes: [], slots: [] }; e.nodes.push(o.node); if (o.slots.length) e.slots = o.slots; byMat.set(o.mat, e) }
585
+ for (const [ mat, e ] of byMat) lines.push(`🎨 prefab override ← ${mat}: ${e.slots.join(", ") || "(shared)"} — ${e.nodes.length} node${e.nodes.length === 1 ? "" : "s"} (${e.nodes.slice(0, 4).join(", ")}${e.nodes.length > 4 ? ", …" : ""})`)
586
+ if (r.unmatched.length && r.applied.length) lines.push(`ℹ no Unity material for: ${r.unmatched.join(", ")} (FBX references kept)`)
587
+ const skipped = r.binary.filter((b) => !r.inferred.includes(b))
588
+ if (r.inferred.length) lines.push(`ℹ ${r.inferred.length} binary-serialised .mat file(s) read by texture naming convention (slots inferred from the files next to them)`)
589
+ if (skipped.length) lines.push(`ℹ ${skipped.length} binary-serialised .mat file(s) skipped — no textures named after them (Unity: Edit ▸ Project Settings ▸ Editor ▸ Asset Serialization = Force Text) — e.g. ${basename(skipped[0])}`)
590
+ return lines
591
+ }
592
+
593
+ export type { Material }