lecodes-assets 0.2.0 → 2.4.10

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 (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,399 @@
1
+ // Material slots → glTF materials with their textures.
2
+ //
3
+ // The route is exact, not guessed: a mesh's slot names come from its import data, the material
4
+ // assets it uses come from its IMPORT TABLE, and a `MaterialInstanceConstant` lists its textures as
5
+ // `TextureParameterValues` — a parameter name plus the Texture2D asset it points at. What is NOT
6
+ // knowable without evaluating the material graph is what each parameter DOES, so a texture's role
7
+ // is read from three agreeing signals: the parameter's name (`BaseColor Map`, `Normal`), the
8
+ // asset's suffix (`_D`, `_N`, `_ORM`) and the texture's own compression setting (`TC_Normalmap`,
9
+ // `TC_Masks`). Anything that does not land in glTF's five slots (masks, tangent maps, detail
10
+ // layers) is reported and left out rather than wired somewhere plausible.
11
+ //
12
+ // Scalar/vector parameters (tints, roughness powers) are NOT applied: whether `SkinTone` multiplies
13
+ // the base colour or drives something else is a property of the master material's graph. Gear comes
14
+ // out in its neutral colours, as it did in the predecessor Python exporter.
15
+
16
+ import { basename, dirname } from "node:path"
17
+ import type { Document, Material, Texture } from "@gltf-transform/core"
18
+ import type { ConvertWarning } from "../fbx/toDocument"
19
+ import { BinaryReader, findExport, readArrayHeader, readFName, readProperties, readObjectProperties, resolveObject, classOf, type UePackage } from "./package"
20
+ import { readPackageFile } from "./index"
21
+ import { resolveObjectFile } from "./resolve"
22
+ import { extractTexture, readTexture2D, type UeTexture } from "./texture"
23
+ import { evaluateConstant, readMaterialGraph, type MaterialGraph } from "./materialGraph"
24
+ import { findOodle } from "./oodle"
25
+
26
+ export type TextureRole = "base" | "normal" | "orm" | "other"
27
+
28
+ export type UeTextureParam = {
29
+ /** The material parameter's name (`BaseColor Map`, `Base Texture`, `Normal`). */
30
+ param: string
31
+ /** `/Game/…/T_F_Arms_D` — the Texture2D asset. */
32
+ path: string
33
+ asset: string
34
+ role: TextureRole
35
+ }
36
+
37
+ // A material names more textures than glTF has slots for, and several of them look like the one you
38
+ // want: the UE5 mannequin's material has BOTH `Normal` (the normal map) and `BNormal` (a bent
39
+ // normal), and picking whichever came first put the wrong one on the mesh. So candidates are SCORED
40
+ // and the best one wins — an exact parameter name beats an asset suffix, which beats the texture's
41
+ // compression setting, and a parameter that names something glTF has no slot for scores nothing.
42
+ const NOT_A_MAP = /bent|bnormal|detail|aniso|tangent|mask|msk|paint|emis|opacity|packed|_msr|specular|subsurface|scatter|fuzz|sheen|clear ?coat|cccc|ccr/i
43
+
44
+ const SCORES: Record<Exclude<TextureRole, "other">, { re: RegExp, on: "param" | "asset" | "compression", score: number }[]> = {
45
+ base: [
46
+ { re: /^(base ?colou?r|base ?tex(ture)?|albedo|diffuse|colou?r ?map)( map)?$/i, on: "param", score: 100 },
47
+ { re: /_(d|bc|albedo|basecolor|diff)$/i, on: "asset", score: 90 },
48
+ { re: /(base ?colou?r|albedo)$/i, on: "asset", score: 85 },
49
+ { re: /base ?colou?r|albedo|diffuse/i, on: "param", score: 80 },
50
+ ],
51
+ normal: [
52
+ { re: /^(normal|normal ?map|nrm)$/i, on: "param", score: 100 },
53
+ { re: /_n$/i, on: "asset", score: 90 },
54
+ { re: /^normal/i, on: "param", score: 85 },
55
+ { re: /^TC_Normalmap$/i, on: "compression", score: 50 },
56
+ ],
57
+ orm: [
58
+ { re: /orm|occlusion.?roughness.?metallic/i, on: "param", score: 100 },
59
+ { re: /_orm$/i, on: "asset", score: 90 },
60
+ ],
61
+ }
62
+
63
+ /** How well a texture fits a glTF slot: 0 = not a candidate. */
64
+ export const scoreTexture = (role: Exclude<TextureRole, "other">, param: string, asset: string, compression = ""): number => {
65
+ if (NOT_A_MAP.test(param) || NOT_A_MAP.test(asset)) return 0
66
+ let best = 0
67
+ for (const rule of SCORES[role]) {
68
+ const subject = rule.on === "param" ? param : rule.on === "asset" ? asset : compression
69
+ if (subject && rule.re.test(subject)) best = Math.max(best, rule.score)
70
+ }
71
+ // a mask texture never becomes a normal map, whatever its name suggests
72
+ if (role === "normal" && compression === "TC_Masks") return 0
73
+ return best
74
+ }
75
+
76
+ /** The slot a texture fits best — what the report prints; the wiring uses `scoreTexture` directly. */
77
+ export const classifyTexture = (param: string, asset: string, compression?: string): TextureRole => {
78
+ let role: TextureRole = "other", best = 0
79
+ for (const r of [ "base", "normal", "orm" ] as const) {
80
+ const score = scoreTexture(r, param, asset, compression)
81
+ if (score > best) { best = score; role = r }
82
+ }
83
+ return role
84
+ }
85
+
86
+ /** A material asset's `TextureParameterValues`, classified. */
87
+ export const readMaterialTextures = (pkg: UePackage): UeTextureParam[] => {
88
+ const found = findExport(pkg, "MaterialInstanceConstant") ?? findExport(pkg, "Material")
89
+ if (!found) return []
90
+ const { reader: r, props } = readObjectProperties(pkg, found.export)
91
+ const tag = props.byName.get("TextureParameterValues")
92
+ if (!tag) return []
93
+ const out: UeTextureParam[] = []
94
+ const { count } = readArrayHeader(r, pkg, tag)
95
+ for (let i = 0; i < count; i++) {
96
+ const el = readProperties(r, pkg)
97
+ const after = r.offset
98
+ let param = ""
99
+ let path = "", asset = ""
100
+ const info = el.byName.get("ParameterInfo")
101
+ if (info) {
102
+ r.offset = info.data
103
+ const inner = readProperties(r, pkg)
104
+ const n = inner.byName.get("Name")
105
+ if (n) { r.offset = n.data; param = readFName(r, pkg.names) }
106
+ }
107
+ const value = el.byName.get("ParameterValue")
108
+ if (value) {
109
+ r.offset = value.data
110
+ const ref = resolveObject(pkg, r.i32())
111
+ if (ref) { path = ref.packagePath; asset = ref.name }
112
+ }
113
+ if (path) out.push({ param, path, asset, role: classifyTexture(param, asset) })
114
+ r.offset = after
115
+ }
116
+ return out
117
+ }
118
+
119
+ /** One entry of a `SkeletalMesh`'s own material array: which asset the SLOT actually uses. */
120
+ export type UeSkeletalMaterial = { slot: string, imported: string, path: string, name: string }
121
+
122
+ /**
123
+ * The mesh's OWN per-slot material assignment (`TArray<FSkeletalMaterial>` from
124
+ * `USkeletalMesh::Serialize`, right after the tagged properties). This is the only exact answer:
125
+ * a slot's NAME is what the FBX import left behind (`MI_Pants_ColorA`), while the material the
126
+ * artist later put in that slot can be a different asset entirely (`MI_Pants_CamoA`) — matching by
127
+ * name alone leaves those slots grey.
128
+ *
129
+ * The array's offset is not fixed (the header between the properties and it has changed across 5.x),
130
+ * so the start is FOUND rather than assumed: a candidate offset must parse into a whole array whose
131
+ * every entry resolves to a Material object and whose flags are booleans. Nothing is returned unless
132
+ * that holds, and the caller falls back to matching by name.
133
+ */
134
+ export const readSkeletalMaterials = (pkg: UePackage): UeSkeletalMaterial[] => {
135
+ const found = findExport(pkg, "SkeletalMesh")
136
+ if (!found) return []
137
+ let start: number
138
+ let data: Uint8Array
139
+ try {
140
+ const { reader } = readObjectProperties(pkg, found.export)
141
+ start = reader.offset
142
+ data = reader.data
143
+ } catch { return [] }
144
+ const end = found.export.serialOffset + found.export.serialSize
145
+ for (let off = start; off < start + 128; off++) {
146
+ const r = new BinaryReader(data, off, pkg.label)
147
+ try {
148
+ const count = r.i32()
149
+ if (count < 1 || count > 256) continue
150
+ const rows: UeSkeletalMaterial[] = []
151
+ for (let i = 0; i < count; i++) {
152
+ // a slot may hold NO material (index 0 — UE draws its default there); that is still a row
153
+ const index = r.i32()
154
+ const ref = index === 0 ? { packagePath: "", name: "", className: "Material" } : resolveObject(pkg, index)
155
+ if (!ref || !/Material/.test(ref.className)) throw new Error("not a material")
156
+ const slot = readFName(r, pkg.names)
157
+ const hasImported = r.i32()
158
+ if (hasImported !== 0 && hasImported !== 1) throw new Error("not a bool")
159
+ const imported = hasImported ? readFName(r, pkg.names) : ""
160
+ // FMeshUVChannelInfo: bInitialized, bOverrideDensities, LocalUVDensities[4]
161
+ const init = r.i32(), override = r.i32()
162
+ if (init > 1 || init < 0 || override > 1 || override < 0) throw new Error("not a UV channel info")
163
+ r.skip(16)
164
+ rows.push({ slot, imported, path: ref.packagePath, name: ref.name })
165
+ }
166
+ if (r.offset > end) continue
167
+ return rows
168
+ } catch { /* not the array — keep scanning */ }
169
+ }
170
+ return []
171
+ }
172
+
173
+ const normalize = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, "")
174
+
175
+ /**
176
+ * Which material asset each mesh slot uses. The mesh's import table names the materials it
177
+ * references; slots are matched to them by name — exact, then normalised, then (when the counts
178
+ * agree and nothing else did) in order, which is how the assets line up in practice.
179
+ */
180
+ export const resolveSlotMaterials = (pkg: UePackage, slots: string[]): (string | null)[] => {
181
+ const out: (string | null)[] = slots.map<string | null>(() => null)
182
+
183
+ // exact first: the mesh's own slot → material array, matched by slot name (a mesh can carry more
184
+ // entries than the import data has slots — an unused section — so index order is not enough)
185
+ const own = readSkeletalMaterials(pkg)
186
+ if (own.length) {
187
+ const byName = new Map<string, string>()
188
+ // an EMPTY slot (no material assigned) is left for the name match below rather than taken as ""
189
+ for (const m of own) {
190
+ if (m.path) for (const key of [ m.imported, m.slot ]) if (key) byName.set(normalize(key), m.path)
191
+ }
192
+ slots.forEach((s, i) => { out[i] = byName.get(normalize(s)) ?? null })
193
+ const matched = out.filter((v) => v !== null).length
194
+ // no name agreed at all (an import whose slots were renamed) — the array IS the slot order
195
+ if (!matched && own.length === slots.length) own.forEach((m, i) => { out[i] = m.path })
196
+ if (out.every((v) => v !== null)) return out
197
+ }
198
+
199
+ const refs: { name: string, path: string }[] = []
200
+ for (let i = 0; i < pkg.imports.length; i++) {
201
+ const imp = pkg.imports[i]
202
+ if (imp.className !== "MaterialInstanceConstant" && imp.className !== "Material") continue
203
+ const path = resolveObject(pkg, -i - 1)?.packagePath ?? ""
204
+ if (path && !refs.some((r) => r.path === path)) refs.push({ name: imp.objectName, path })
205
+ }
206
+ const left = refs.filter((r) => !out.includes(r.path))
207
+ const take = (i: number, r: { name: string, path: string }): void => { out[i] = r.path; left.splice(left.indexOf(r), 1) }
208
+ slots.forEach((s, i) => { if (out[i]) return; const hit = left.find((r) => r.name.toLowerCase() === s.toLowerCase()); if (hit) take(i, hit) })
209
+ slots.forEach((s, i) => { if (!out[i]) { const hit = left.find((r) => normalize(r.name) === normalize(s)); if (hit) take(i, hit) } })
210
+ slots.forEach((s, i) => {
211
+ if (out[i] || !left.length) return
212
+ const n = normalize(s)
213
+ const hit = left.find((r) => normalize(r.name).includes(n) || n.includes(normalize(r.name)))
214
+ if (hit) take(i, hit)
215
+ })
216
+ const anyMatched = out.some((v) => v !== null)
217
+ if (!anyMatched && refs.length === slots.length) refs.forEach((r, i) => { out[i] = r.path })
218
+ return out
219
+ }
220
+
221
+ export type TextureOptions = {
222
+ /** Last-resort lookup for an object path, for hosts with no pack on disk (the studio's flat
223
+ * upload store): `/Game/…/MI_Manny_01` → whichever uploaded file carries that asset. */
224
+ resolveAsset?: (objectPath: string) => string | null
225
+ /** Longest edge kept; larger sources are downscaled (needs `sharp`). Default 2048, 0 = no cap. */
226
+ maxSize?: number
227
+ /** UE authors normal maps DirectX-style (green down); glTF wants OpenGL (green up). */
228
+ flipNormalGreen?: boolean
229
+ contentRoot?: string
230
+ warn?: (w: ConvertWarning) => void
231
+ }
232
+
233
+ export type AppliedTextures = {
234
+ /** Per slot: which roles were wired. */
235
+ slots: { slot: string, material: string | null, roles: string[], skipped: string[] }[]
236
+ textures: number
237
+ /** Set when nothing could be read because the Oodle library is missing. */
238
+ oodleMissing: boolean
239
+ }
240
+
241
+ /** Decode → fix channels → re-encode. Uses `sharp` when it is installed, else the package's own
242
+ * PNG codec (correct, just slower and without the resize). */
243
+ export const fixChannels = async (data: Uint8Array, mime: string, opts: { swapRb: boolean, flipGreen: boolean, maxSize: number, png?: boolean }): Promise<{ data: Uint8Array, mimeType: string }> => {
244
+ const { swapRb, flipGreen, maxSize } = opts
245
+ if (!swapRb && !flipGreen && !maxSize) return { data, mimeType: mime }
246
+ try {
247
+ const sharp = (await import("sharp")).default
248
+ let img = sharp(Buffer.from(data), { limitInputPixels: false, unlimited: true })
249
+ const meta = await img.metadata()
250
+ if (maxSize && Math.max(meta.width ?? 0, meta.height ?? 0) > maxSize) img = img.resize(maxSize, maxSize, { fit: "inside" })
251
+ const { data: raw, info } = await img.raw().toBuffer({ resolveWithObject: true })
252
+ const ch = info.channels
253
+ for (let i = 0; i < raw.length; i += ch) {
254
+ if (swapRb) { const t = raw[i]; raw[i] = raw[i + 2]; raw[i + 2] = t }
255
+ if (flipGreen && ch > 1) raw[i + 1] = 255 - raw[i + 1]
256
+ }
257
+ let usesAlpha = false
258
+ if (ch === 4) for (let i = 3; i < raw.length; i += 4) if (raw[i] !== 255) { usesAlpha = true; break }
259
+ // An opaque image goes to JPEG (what a GLB wants to embed) unless the caller asked for a
260
+ // lossless file — `unreal tex`, where the image is a SOURCE for further work (a normal map
261
+ // headed for an atlas has no business picking up JPEG blocks on the way).
262
+ const lossless = usesAlpha || opts.png === true
263
+ const out = sharp(raw, { raw: { width: info.width, height: info.height, channels: ch } })
264
+ const encoded = lossless
265
+ ? await (usesAlpha ? out : out.removeAlpha()).png({ compressionLevel: 9 }).toBuffer()
266
+ : await out.removeAlpha().jpeg({ quality: 92, chromaSubsampling: "4:4:4" }).toBuffer()
267
+ return { data: new Uint8Array(encoded), mimeType: lossless ? "image/png" : "image/jpeg" }
268
+ } catch {
269
+ // no sharp: the channel fix still has to happen, so do it on the package's own PNG codec
270
+ const { decodePng } = await import("../unity/png")
271
+ const { encodePng } = await import("../textures/resolveImage")
272
+ const img = mime === "image/png" ? decodePng(data) : null
273
+ if (!img) return { data, mimeType: mime }
274
+ for (let i = 0; i < img.data.length; i += 4) {
275
+ if (swapRb) { const t = img.data[i]; img.data[i] = img.data[i + 2]; img.data[i + 2] = t }
276
+ if (flipGreen) img.data[i + 1] = 255 - img.data[i + 1]
277
+ }
278
+ return { data: encodePng(img), mimeType: "image/png" }
279
+ }
280
+ }
281
+
282
+ /** The constant-valued pins of a plain Material (null for an instance or an unreadable package). */
283
+ const constantPins = (file: string): { base?: number[], metallic?: number, roughness?: number, emissive?: number[] } | null => {
284
+ let graph: MaterialGraph
285
+ try { graph = readMaterialGraph(readPackageFile(file)) } catch { return null }
286
+ const pin = (name: string): number[] | undefined => evaluateConstant(graph, graph.outputs[name]) ?? undefined
287
+ // an UNCONNECTED pin is the engine's default (Metallic 0, Roughness 0.5), not "unknown" — but a
288
+ // pin wired to something this cannot evaluate (a texture) stays undecided
289
+ const or = (name: string, dflt: number): number | undefined => (graph.outputs[name] ? pin(name)?.[0] : dflt)
290
+ return { base: pin("BaseColor"), metallic: or("Metallic", 0), roughness: or("Roughness", 0.5), emissive: pin("EmissiveColor") }
291
+ }
292
+
293
+ /**
294
+ * Put the pack's textures on a document's materials. The document must already carry one material
295
+ * per mesh slot, named after the slot (that is what `unrealToDocument` builds).
296
+ */
297
+ export const applyUnrealTextures = async (doc: Document, meshPath: string, slots: string[], opts: TextureOptions = {}): Promise<AppliedTextures> => {
298
+ const warn = opts.warn ?? (() => {})
299
+ const maxSize = opts.maxSize ?? 2048
300
+ const flipNormals = opts.flipNormalGreen ?? true
301
+ const report: AppliedTextures = { slots: [], textures: 0, oodleMissing: false }
302
+ const meshPkg = readPackageFile(meshPath)
303
+ /** An object path → a file: the pack's own layout first, then whatever the host can offer. */
304
+ const find = (objectPath: string, from = dirname(meshPath)): string | null =>
305
+ resolveObjectFile(objectPath, from, opts.contentRoot)?.file ?? opts.resolveAsset?.(objectPath) ?? null
306
+ const materials = doc.getRoot().listMaterials()
307
+ const assets = resolveSlotMaterials(meshPkg, slots)
308
+ const cache = new Map<string, Texture | null>()
309
+
310
+ const loadTexture = async (path: string, role: TextureRole, texAsset: UeTexture | null, file: string): Promise<Texture | null> => {
311
+ const key = `${path}|${role}`
312
+ const hit = cache.get(key)
313
+ if (hit !== undefined) return hit
314
+ let tex: Texture | null = null
315
+ try {
316
+ const img = await extractTexture(readPackageFile(file))
317
+ const prepared = await fixChannels(img.data, img.mimeType, {
318
+ swapRb: img.swapRb,
319
+ flipGreen: role === "normal" && flipNormals,
320
+ maxSize,
321
+ })
322
+ tex = doc.createTexture(img.texture.name).setImage(prepared.data).setMimeType(prepared.mimeType)
323
+ report.textures++
324
+ } catch (e) {
325
+ const msg = (e as Error).message
326
+ if (/Oodle/i.test(msg)) { report.oodleMissing = true; tex = null } else warn({ level: "warn", message: `texture ${basename(file)}: ${msg}` })
327
+ }
328
+ void texAsset
329
+ cache.set(key, tex)
330
+ return tex
331
+ }
332
+
333
+ for (let i = 0; i < slots.length; i++) {
334
+ const slot = slots[i]
335
+ const assetPath = assets[i]
336
+ const material: Material | undefined = materials[i]
337
+ const row = { slot, material: assetPath ? assetPath.split("/").pop() ?? null : null, roles: [] as string[], skipped: [] as string[] }
338
+ report.slots.push(row)
339
+ if (!assetPath || !material) continue
340
+ const matFile = find(assetPath)
341
+ if (!matFile) { warn({ level: "info", message: `material ${row.material}: asset not found next to the mesh — slot '${slot}' keeps its default` }); continue }
342
+
343
+ let params: UeTextureParam[]
344
+ try { params = readMaterialTextures(readPackageFile(matFile)) } catch (e) { warn({ level: "warn", message: `material ${row.material}: ${(e as Error).message}` }); continue }
345
+
346
+ // the texture asset's own compression setting is the third vote on what a parameter is for
347
+ const resolved = params.map((p) => {
348
+ const file = find(p.path, dirname(matFile))
349
+ let texture: UeTexture | null = null
350
+ if (file) { try { texture = readTexture2D(readPackageFile(file)) } catch { texture = null } }
351
+ return { ...p, file, texture, role: classifyTexture(p.param, p.asset, texture?.compressionSettings) }
352
+ })
353
+
354
+ // a material whose ONLY texture says nothing about its role (`Param`, `Texture` — this pack's
355
+ // eyes and eyelashes) is a base colour: there is nothing else it could be, and the alternative
356
+ // is a grey slot. A name that reads as a mask or a map glTF has no slot for is still left out.
357
+ const lone = resolved.filter((p) => p.file && !NOT_A_MAP.test(p.param) && !NOT_A_MAP.test(p.asset))
358
+ const onlyBase = lone.length === 1 && lone[0].role === "other" ? lone[0] : null
359
+
360
+ for (const role of [ "base", "normal", "orm" ] as const) {
361
+ const pick = resolved
362
+ .filter((p) => p.file)
363
+ .map((p) => ({ p, score: scoreTexture(role, p.param, p.asset, p.texture?.compressionSettings) }))
364
+ .filter((c) => c.score > 0)
365
+ .sort((a, b) => b.score - a.score)[0]?.p
366
+ ?? (role === "base" ? onlyBase : null)
367
+ if (!pick) continue
368
+ const tex = await loadTexture(pick.path, role, pick.texture, pick.file!)
369
+ if (!tex) continue
370
+ if (role === "base") {
371
+ material.setBaseColorTexture(tex).setBaseColorFactor([ 1, 1, 1, 1 ])
372
+ material.getBaseColorTexture()?.setName(pick.asset)
373
+ } else if (role === "normal") {
374
+ material.setNormalTexture(tex)
375
+ } else {
376
+ // UE packs occlusion/roughness/metallic into R/G/B — glTF reads exactly the same channels
377
+ material.setMetallicRoughnessTexture(tex).setOcclusionTexture(tex).setMetallicFactor(1).setRoughnessFactor(1)
378
+ }
379
+ row.roles.push(role)
380
+ }
381
+ // A pin no texture reached gets what the graph computes when it is all NUMBERS — a pack whose
382
+ // materials carry no textures at all (KINEMATION's FPS Animation Pack weapons: `M_Finish` is a
383
+ // VectorParameter colour, a Constant metallic and a Constant roughness) otherwise came out as 14
384
+ // identical grey slots. Only a plain Material has a graph; an instance keeps its defaults.
385
+ const pins = constantPins(matFile)
386
+ if (pins) {
387
+ if (pins.base && !row.roles.includes("base")) { material.setBaseColorFactor([ pins.base[0], pins.base[1] ?? pins.base[0], pins.base[2] ?? pins.base[0], 1 ]); row.roles.push("base=const") }
388
+ if (!row.roles.includes("orm")) {
389
+ if (pins.metallic !== undefined) { material.setMetallicFactor(Math.min(1, Math.max(0, pins.metallic))); row.roles.push("metallic=const") }
390
+ if (pins.roughness !== undefined) { material.setRoughnessFactor(Math.min(1, Math.max(0, pins.roughness))); row.roles.push("roughness=const") }
391
+ }
392
+ if (pins.emissive && pins.emissive.some((c) => c > 0)) { material.setEmissiveFactor([ pins.emissive[0], pins.emissive[1] ?? pins.emissive[0], pins.emissive[2] ?? pins.emissive[0] ].map((c) => Math.min(1, c)) as [ number, number, number ]); row.roles.push("emissive=const") }
393
+ }
394
+ row.skipped = resolved.filter((p) => p.role === "other" && p !== onlyBase).map((p) => `${p.param || p.asset}`)
395
+ }
396
+ if (report.oodleMissing) warn({ level: "info", message: `textures skipped — no Oodle decoder (${findOodle() ? "found the library but could not call it" : "set LECODES_OODLE"})` })
397
+ void classOf
398
+ return report
399
+ }
@@ -0,0 +1,205 @@
1
+ // SkeletalMesh → geometry + skin + bones, out of the EDITOR IMPORT DATA (`FSkeletalMeshImportData`
2
+ // — what the FBX importer saw), which a `SkeletalMeshEditorData` child export keeps as bulk data.
3
+ // That is the same source the render data is built from, and the only one that still carries the
4
+ // skin influences and the source bone list.
5
+ //
6
+ // A mesh whose LODs were generated in-engine has no import data at all (an 84-byte payload, header
7
+ // only); the UE4 mannequin of the Stairs pack is one. Such a mesh cannot be exported by this path —
8
+ // `lodCount` reports which LODs have a source (§8).
9
+ //
10
+ // The vertex model is UE's: POINTS carry position and skin weights, WEDGES are per-corner
11
+ // (point + UV + colour), FACES name three wedges plus a per-corner normal. glTF wants one array of
12
+ // vertices, so `toDocument` de-duplicates wedges per material slot.
13
+
14
+ import {
15
+ BinaryReader, findExport, classOf, readObjectProperties, readBulkHeader, readBulkPayload,
16
+ bulkIsCompressed, propObject, resolveObject, type UePackage, type BulkDataHeader,
17
+ } from "./package"
18
+ import { skipObjectGuid } from "./skeleton"
19
+
20
+ export type UeMeshBone = {
21
+ name: string
22
+ parent: number
23
+ /** LOCAL transform, UE frame, centimetres (floats here, unlike the USkeleton's doubles). */
24
+ t: [ number, number, number ]
25
+ q: [ number, number, number, number ]
26
+ s: [ number, number, number ]
27
+ }
28
+
29
+ export type UeInfluence = { weight: number, point: number, bone: number }
30
+
31
+ export type UeSkeletalMesh = {
32
+ name: string
33
+ /** Skeleton asset this mesh is bound to (`/Game/…/SK_Mannequin`), when it names one. */
34
+ skeletonPath: string
35
+ skeletonName: string
36
+ /** Which LOD was read, and what the file holds. */
37
+ lod: number
38
+ lods: { index: number, bytes: number, compressed: boolean, hasSource: boolean }[]
39
+ /** version of the import-data blob (3 = UE 5.0, 5 = UE 5.3). */
40
+ version: number
41
+ /** n × 3 floats, UE frame, cm — the skeleton's bind (= reference) space. */
42
+ points: Float32Array
43
+ /** per wedge: point index, uv0, uv1 (NaN-free zeros when absent), material slot. */
44
+ wedgePoint: Uint32Array
45
+ wedgeUv0: Float32Array
46
+ wedgeUv1: Float32Array
47
+ hasUv1: boolean
48
+ /** per face: 3 wedge indices, material slot, 3 corner normals (UE frame). */
49
+ faceWedges: Uint32Array
50
+ faceMaterial: Uint8Array
51
+ faceNormals: Float32Array
52
+ influences: UeInfluence[]
53
+ materials: string[]
54
+ bones: UeMeshBone[]
55
+ }
56
+
57
+ const BULK_EMPTY_MAX = 100 // a generated LOD's "import data" is a header and nothing else
58
+
59
+ /** `FRawSkeletalMeshBulkData` list of the `SkeletalMeshEditorData` export. */
60
+ const readEditorDataLods = (pkg: UePackage, editorExportIndex: number): { header: BulkDataHeader }[] => {
61
+ const e = pkg.exports[editorExportIndex]
62
+ const { reader: r } = readObjectProperties(pkg, e) // empty tag list (just `None`)
63
+ skipObjectGuid(r)
64
+ const lodCount = r.i32()
65
+ if (lodCount < 0 || lodCount > 64) throw new Error(`${pkg.label}: ${lodCount} LODs in ${e.objectName} — not a mesh editor-data export`)
66
+ const out: { header: BulkDataHeader }[] = []
67
+ for (let i = 0; i < lodCount; i++) {
68
+ r.u8(); r.u8() // geo / skinning import versions
69
+ const header = readBulkHeader(r)
70
+ r.skip(16) // guid
71
+ r.i32() // bGuidIsHash
72
+ out.push({ header })
73
+ }
74
+ return out
75
+ }
76
+
77
+ /** `FSkeletalMeshImportData` — the arrays sit back to back with an i32 count in front of each. */
78
+ const parseImportData = (blob: Uint8Array, label: string): Omit<UeSkeletalMesh, "name" | "skeletonPath" | "skeletonName" | "lod" | "lods"> => {
79
+ const r = new BinaryReader(blob, 0, `${label} import data`)
80
+ const version = r.i32()
81
+ r.skip(32) // 8 more header ints (flags: normals, tangents, …)
82
+
83
+ const faceCount = r.i32()
84
+ const faceWedges = new Uint32Array(faceCount * 3)
85
+ const faceMaterial = new Uint8Array(faceCount)
86
+ const faceNormals = new Float32Array(faceCount * 9)
87
+ {
88
+ const base = r.offset
89
+ if (faceCount < 0 || base + faceCount * 127 > blob.byteLength) throw new Error(`${label}: ${faceCount} faces do not fit the import data (${blob.byteLength} B)`)
90
+ const v = r.view
91
+ for (let i = 0; i < faceCount; i++) {
92
+ const o = base + i * 127
93
+ faceMaterial[i] = blob[o]
94
+ faceWedges[i * 3] = v.getUint32(o + 7, true)
95
+ faceWedges[i * 3 + 1] = v.getUint32(o + 11, true)
96
+ faceWedges[i * 3 + 2] = v.getUint32(o + 15, true)
97
+ for (let k = 0; k < 9; k++) faceNormals[i * 9 + k] = v.getFloat32(o + 91 + k * 4, true) // TangentZ per corner
98
+ }
99
+ r.offset = base + faceCount * 127
100
+ }
101
+
102
+ const influenceCount = r.i32()
103
+ const influences: UeInfluence[] = new Array(influenceCount)
104
+ {
105
+ const base = r.offset
106
+ if (influenceCount < 0 || base + influenceCount * 12 > blob.byteLength) throw new Error(`${label}: ${influenceCount} influences do not fit the import data`)
107
+ const v = r.view
108
+ for (let i = 0; i < influenceCount; i++) {
109
+ const o = base + i * 12
110
+ influences[i] = { weight: v.getFloat32(o, true), point: v.getInt32(o + 4, true), bone: v.getInt32(o + 8, true) }
111
+ }
112
+ r.offset = base + influenceCount * 12
113
+ }
114
+
115
+ const materialCount = r.i32()
116
+ const materials: string[] = []
117
+ for (let i = 0; i < materialCount; i++) materials.push(r.fstring())
118
+
119
+ const pointCount = r.i32()
120
+ const points = new Float32Array(pointCount * 3)
121
+ for (let i = 0; i < points.length; i++) points[i] = r.f32()
122
+
123
+ const mapCount = r.i32()
124
+ r.skip(mapCount * 4) // PointToRawMap
125
+
126
+ const boneCount = r.i32()
127
+ const bones: UeMeshBone[] = new Array(boneCount)
128
+ for (let i = 0; i < boneCount; i++) {
129
+ const name = r.fstring()
130
+ r.u32() // flags
131
+ r.i32() // numChildren
132
+ const parent = r.i32()
133
+ const q = r.floats(4), t = r.floats(3), s = r.floats(3)
134
+ bones[i] = { name, parent, t: [ t[0], t[1], t[2] ], q: [ q[0], q[1], q[2], q[3] ], s: [ s[0], s[1], s[2] ] }
135
+ }
136
+
137
+ const wedgeCount = r.i32()
138
+ const wedgePoint = new Uint32Array(wedgeCount)
139
+ const wedgeUv0 = new Float32Array(wedgeCount * 2)
140
+ const wedgeUv1 = new Float32Array(wedgeCount * 2)
141
+ let hasUv1 = false
142
+ {
143
+ const base = r.offset
144
+ if (wedgeCount < 0 || base + wedgeCount * 42 > blob.byteLength) throw new Error(`${label}: ${wedgeCount} wedges do not fit the import data`)
145
+ const v = r.view
146
+ for (let i = 0; i < wedgeCount; i++) {
147
+ const o = base + i * 42
148
+ wedgePoint[i] = v.getUint32(o, true)
149
+ wedgeUv0[i * 2] = v.getFloat32(o + 10, true)
150
+ wedgeUv0[i * 2 + 1] = v.getFloat32(o + 14, true)
151
+ const u1 = v.getFloat32(o + 18, true), v1 = v.getFloat32(o + 22, true)
152
+ wedgeUv1[i * 2] = u1; wedgeUv1[i * 2 + 1] = v1
153
+ if (!hasUv1 && (u1 !== 0 || v1 !== 0)) hasUv1 = true
154
+ }
155
+ r.offset = base + wedgeCount * 42
156
+ }
157
+
158
+ for (const [ i, w ] of faceWedges.entries()) {
159
+ if (w >= wedgeCount) throw new Error(`${label}: face ${Math.floor(i / 3)} names wedge ${w} of ${wedgeCount}`)
160
+ }
161
+ return { version, points, wedgePoint, wedgeUv0, wedgeUv1, hasUv1, faceWedges, faceMaterial, faceNormals, influences, materials, bones }
162
+ }
163
+
164
+ export type ReadMeshOptions = { lod?: number }
165
+
166
+ /** The `SkeletalMesh` export of a package (an `SKM_*.uasset`), at one LOD (default 0). */
167
+ export const readSkeletalMesh = (pkg: UePackage, opts: ReadMeshOptions = {}): UeSkeletalMesh => {
168
+ const found = findExport(pkg, "SkeletalMesh")
169
+ if (!found) throw new Error(`${pkg.label}: no SkeletalMesh export (exports: ${pkg.exports.map((e) => `${e.objectName}:${classOf(pkg, e)}`).join(", ")})`)
170
+ const { reader: r, props } = readObjectProperties(pkg, found.export)
171
+ const skelRef = resolveObject(pkg, propObject(r, props.byName.get("Skeleton")) ?? 0)
172
+
173
+ const editorRef = resolveObject(pkg, propObject(r, props.byName.get("MeshEditorDataObject")) ?? 0)
174
+ const editorIndex = editorRef?.kind === "export" ? editorRef.index : pkg.exports.findIndex((e) => classOf(pkg, e) === "SkeletalMeshEditorData")
175
+ if (editorIndex < 0) {
176
+ throw new Error(`${pkg.label}: the mesh keeps no editor import data (a cooked or engine-generated mesh) — only its skeleton can be read`)
177
+ }
178
+ const lodsRaw = readEditorDataLods(pkg, editorIndex)
179
+ const lods = lodsRaw.map((l, i) => ({ index: i, bytes: l.header.elementCount, compressed: bulkIsCompressed(l.header), hasSource: l.header.elementCount > BULK_EMPTY_MAX }))
180
+ const want = opts.lod ?? 0
181
+ const chosen = lods[want]
182
+ if (!chosen) throw new Error(`${pkg.label}: no LOD ${want} (the mesh has ${lods.length})`)
183
+ if (!chosen.hasSource) {
184
+ const alt = lods.filter((l) => l.hasSource).map((l) => l.index)
185
+ throw new Error(`${pkg.label}: LOD ${want} has no source geometry (${chosen.bytes} B — an engine-generated LOD)${alt.length ? `; LOD ${alt.join("/")} do${alt.length > 1 ? "" : "es"}` : " and neither does any other LOD, so this mesh cannot be exported"}`)
186
+ }
187
+ const blob = readBulkPayload(pkg, lodsRaw[want].header)
188
+ const data = parseImportData(blob, pkg.label)
189
+ return {
190
+ name: found.export.objectName,
191
+ skeletonPath: skelRef?.packagePath ?? "",
192
+ skeletonName: skelRef?.name ?? "",
193
+ lod: want, lods,
194
+ ...data,
195
+ }
196
+ }
197
+
198
+ /** Bounding box of the points, UE frame, centimetres. */
199
+ export const meshBounds = (m: UeSkeletalMesh): { min: number[], max: number[] } => {
200
+ const min = [ Infinity, Infinity, Infinity ], max = [ -Infinity, -Infinity, -Infinity ]
201
+ for (let i = 0; i < m.points.length; i += 3) {
202
+ for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], m.points[i + k]); max[k] = Math.max(max[k], m.points[i + k]) }
203
+ }
204
+ return { min, max }
205
+ }