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.
- package/README.md +240 -5
- package/dist/cli.js +7757 -683
- package/dist/index.js +7866 -675
- package/package.json +13 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli.ts +1039 -0
- package/src/doctor/doctor.ts +241 -0
- package/src/doctor/lightmapUv.ts +452 -0
- package/src/doctor/lod.ts +177 -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 +124 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +109 -0
- package/src/fbx/toDocument.ts +209 -0
- package/src/filmstrip.ts +263 -0
- package/src/index.ts +17 -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 +171 -0
- package/src/retarget/humanoid.ts +561 -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 +2272 -0
- package/src/retarget/rig.ts +542 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +132 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +151 -0
- package/src/terrainPack.ts +169 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/materials.ts +593 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabs.ts +80 -0
- package/src/unity/scene.ts +363 -0
- package/src/unity/unpack.ts +130 -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 +114 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materials.ts +291 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +659 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +51 -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/texture.ts +202 -0
- package/src/unreal/toDocument.ts +360 -0
- package/src/unreal/versions.ts +100 -0
- package/src/wasm/fbxReader.js +14 -0
- 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 }
|