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
@@ -1,292 +1,26 @@
1
- // Unity materials as the texture source of truth.
1
+ // Unity materials as the texture source of truth for an FBX conversion.
2
2
  //
3
3
  // An FBX exported for Unity usually references only a base colour (often a legacy file name) and a
4
4
  // normal map; the real shading inputs live in the Unity `.mat` next to it — metallic, roughness /
5
5
  // smoothness, occlusion (separate maps or an ORM / MaskMap / MetallicGloss pack), emission, and the
6
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.
7
+ // `lecodes assets unpack`) or the `.meta` sidecars of a real Unity project (guid.ts).
8
8
  //
9
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"
10
+ // prefixes; matFile.ts reads it, a BINARY one through the pack's texture naming convention), and
11
+ // rewrite the glTF material from it (materialWriter.ts). `.mat` files are also looked up across the
12
+ // whole unpacked package through `unity-index.txt`: packs keep shared materials (asphalt, concrete)
13
+ // in a Textures/ tree far from the FBX that uses them. Then the material overrides of the `.prefab`
14
+ // files around the FBX are honoured (prefabs.ts).
15
+
16
+ import { existsSync } from "node:fs"
17
+ import { basename, dirname, resolve } from "node:path"
18
+ import type { Document, Material, Mesh, Node } from "@gltf-transform/core"
19
+ import { findUnityIndex, guidOfPath, indexedMaterialFiles, resolverFromIndex, resolverFromMeta, type GuidResolver } from "./guid"
20
+ import { collectUnityMaterials, materialKey, type UnityMaterial } from "./matFile"
21
+ import { SLOTS, pick, unityMaterialWriter, type Warn } from "./materialWriter"
27
22
  import { collectPrefabOverrides, type PrefabOverride } from "./prefabs"
28
23
 
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
24
  export type ApplyUnityOptions = {
291
25
  /** Folder(s) to look for `.mat` files in (default: the FBX's folder and its parent, recursively). */
292
26
  materialDirs?: string[]
@@ -368,148 +102,7 @@ export const applyUnityMaterials = async (doc: Document, fbxPath: string, opts:
368
102
  return null
369
103
  }
370
104
 
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
- }
105
+ const { applyTo } = unityMaterialWriter(doc, resolver, warn)
513
106
 
514
107
  const appliedFrom = new Map<Material, string>()
515
108
  for (const gm of mats) {
@@ -532,15 +125,28 @@ export const applyUnityMaterials = async (doc: Document, fbxPath: string, opts:
532
125
  for (const o of overrides) if (!byNode.has(o.node)) byNode.set(o.node, o)
533
126
  const matCache = new Map<string, Material>()
534
127
  // 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) }
128
+ const meshesUnder = (n: Node): Mesh[] => {
129
+ const out: Mesh[] = []
130
+ const walk = (x: Node): void => { const m = x.getMesh(); if (m) out.push(m); x.listChildren().forEach(walk) }
538
131
  walk(n)
539
132
  return out
540
133
  }
541
- for (const node of doc.getRoot().listNodes()) {
542
- const o = byNode.get(node.getName())
543
- if (!o) continue
134
+ const targets: [ Node, PrefabOverride ][] = []
135
+ for (const node of doc.getRoot().listNodes()) { const o = byNode.get(node.getName()); if (o) targets.push([ node, o ]) }
136
+ // a SINGLE-mesh prop whose prefab GameObject is named after the prefab, not the FBX node
137
+ // (`P_stone_fence` showing `stone_fence_LP.014`): no name matches, but the prefab names THIS FBX
138
+ // by GUID and there is only one mesh it could mean. The prefab named like the FBX wins over
139
+ // its variants (`P_Tent_1` over `P_Tent_1B`).
140
+ if (!targets.length && fbxGuid) {
141
+ const meshNodes = doc.getRoot().listNodes().filter((n) => n.getMesh())
142
+ const mine = overrides.filter((o) => o.meshGuid === fbxGuid)
143
+ if (meshNodes.length === 1 && mine.length) {
144
+ const stem = basename(fbxPath).replace(/\.fbx$/i, "").toLowerCase()
145
+ const o = mine.find((x) => basename(x.prefab, ".prefab").toLowerCase().replace(/^p_/, "") === stem) ?? mine[0]!
146
+ targets.push([ meshNodes[0]!, o ])
147
+ }
148
+ }
149
+ for (const [ node, o ] of targets) {
544
150
  const prims = meshesUnder(node).flatMap((m) => m.listPrimitives())
545
151
  for (let i = 0; i < prims.length; i++) {
546
152
  const g = o.materials[i]
@@ -590,4 +196,3 @@ export const describeUnityReport = (r: ApplyUnityReport): string[] => {
590
196
  return lines
591
197
  }
592
198
 
593
- export type { Material }
@@ -0,0 +1,82 @@
1
+ // The Unity importer's linear algebra: column-major 4×4 matrices, (x, y, z, w) quaternions, and the
2
+ // ONE frame conversion everything here shares.
3
+ //
4
+ // Frame: Unity is left-handed (+Z forward); glTF / LeCodes are right-handed. Unity's FBX importer
5
+ // mirrors X, so undoing it is a conjugation by S = diag(−1, 1, 1): position (−x, y, z), quaternion
6
+ // (x, −y, −z, w), scale unchanged. It is applied to placements AND inside kinds (part matrices), so a
7
+ // kind's meshes — the FBX's own geometry, which is Unity's mesh mirrored back — land where Unity
8
+ // drew them.
9
+
10
+ import type { Quat, Vec3 } from "./yaml"
11
+
12
+ export type Mat4 = number[] // column-major
13
+ export type TRS = { t: Vec3, r: Quat, s: Vec3 }
14
+
15
+ export const I4 = (): Mat4 => [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]
16
+
17
+ export const mmul = (a: Mat4, b: Mat4): Mat4 => {
18
+ const o = new Array(16).fill(0)
19
+ for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
20
+ return o
21
+ }
22
+
23
+ export const scaleMatrix = (s: number): Mat4 => [ s, 0, 0, 0, 0, s, 0, 0, 0, 0, s, 0, 0, 0, 0, 1 ]
24
+
25
+ export const trsMatrix = (t: Vec3, q: Quat, s: Vec3): Mat4 => {
26
+ const [ x, y, z, w ] = q
27
+ const xx = x * x, yy = y * y, zz = z * z, xy = x * y, xz = x * z, yz = y * z, wx = w * x, wy = w * y, wz = w * z
28
+ return [
29
+ (1 - 2 * (yy + zz)) * s[0], 2 * (xy + wz) * s[0], 2 * (xz - wy) * s[0], 0,
30
+ 2 * (xy - wz) * s[1], (1 - 2 * (xx + zz)) * s[1], 2 * (yz + wx) * s[1], 0,
31
+ 2 * (xz + wy) * s[2], 2 * (yz - wx) * s[2], (1 - 2 * (xx + yy)) * s[2], 0,
32
+ t[0], t[1], t[2], 1,
33
+ ]
34
+ }
35
+
36
+ /** Conjugate by the X mirror: S·M·S with S = diag(−1, 1, 1). */
37
+ export const mirrorX = (m: Mat4): Mat4 => m.map((v, i) => ((Math.floor(i / 4) === 0) !== (i % 4 === 0) ? -v : v))
38
+
39
+ /** The same conjugation on a quaternion and on a position. */
40
+ export const mirrorQuat = (q: number[]): Quat => [ q[0], -q[1], -q[2], q[3] ]
41
+ export const mirrorPos = (p: number[]): Vec3 => [ -p[0], p[1], p[2] ]
42
+
43
+ export const decompose = (m: Mat4): TRS => {
44
+ const sx = Math.hypot(m[0], m[1], m[2]), sy = Math.hypot(m[4], m[5], m[6]), sz0 = Math.hypot(m[8], m[9], m[10])
45
+ const det = m[0] * (m[5] * m[10] - m[6] * m[9]) - m[4] * (m[1] * m[10] - m[2] * m[9]) + m[8] * (m[1] * m[6] - m[2] * m[5])
46
+ const sz = det < 0 ? -sz0 : sz0
47
+ const r00 = m[0] / sx, r10 = m[1] / sx, r20 = m[2] / sx, r01 = m[4] / sy, r11 = m[5] / sy, r21 = m[6] / sy, r02 = m[8] / sz, r12 = m[9] / sz, r22 = m[10] / sz
48
+ const tr = r00 + r11 + r22
49
+ let q: Quat
50
+ if (tr > 0) { const s = Math.sqrt(tr + 1) * 2; q = [ (r21 - r12) / s, (r02 - r20) / s, (r10 - r01) / s, s / 4 ] }
51
+ else if (r00 > r11 && r00 > r22) { const s = Math.sqrt(1 + r00 - r11 - r22) * 2; q = [ s / 4, (r01 + r10) / s, (r02 + r20) / s, (r21 - r12) / s ] }
52
+ else if (r11 > r22) { const s = Math.sqrt(1 + r11 - r00 - r22) * 2; q = [ (r01 + r10) / s, s / 4, (r12 + r21) / s, (r02 - r20) / s ] }
53
+ else { const s = Math.sqrt(1 + r22 - r00 - r11) * 2; q = [ (r02 + r20) / s, (r12 + r21) / s, s / 4, (r10 - r01) / s ] }
54
+ const l = Math.hypot(...q) || 1
55
+ return { t: [ m[12], m[13], m[14] ], r: q.map((v) => v / l) as Quat, s: [ sx, sy, sz ] }
56
+ }
57
+
58
+ /** Hamilton product a · b (apply b, then a). */
59
+ export const qmul = (a: number[], b: number[]): Quat => [
60
+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
61
+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
62
+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
63
+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
64
+ ]
65
+
66
+ const DEG = 180 / Math.PI
67
+ /** YXZ euler angles (DEGREES) from a unit quaternion — the SDK's `Node.eulerAngles` convention. */
68
+ export const eulerYXZ = (q: number[]): [number, number, number] => {
69
+ const [ x, y, z, w ] = q
70
+ const x2 = x + x, y2 = y + y, z2 = z + z
71
+ const xx = x * x2, xy = x * y2, xz = x * z2, yy = y * y2, yz = y * z2, zz = z * z2, wx = w * x2, wy = w * y2, wz = w * z2
72
+ const m13 = xz + wy, m21 = xy + wz, m22 = 1 - (xx + zz), m23 = yz - wx, m31 = xz - wy, m33 = 1 - (xx + yy), m11 = 1 - (yy + zz)
73
+ const ex = Math.asin(-Math.max(-1, Math.min(1, m23)))
74
+ let ey: number, ez: number
75
+ if (Math.abs(m23) < 0.9999999) { ey = Math.atan2(m13, m33); ez = Math.atan2(m21, m22) } else { ey = Math.atan2(-m31, m11); ez = 0 }
76
+ return [ ex * DEG, ey * DEG, ez * DEG ]
77
+ }
78
+
79
+ /** Rounded for output (and `+ 0` so a −0 never reaches a file). */
80
+ export const r6 = (v: number): number => Math.round(v * 1e6) / 1e6 + 0
81
+ export const r3 = (v: number): number => Math.round(v * 1000) / 1000 + 0
82
+ export const r2 = (v: number): number => Math.round(v * 100) / 100 + 0