lecodes-assets 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +240 -5
  2. package/dist/cli.js +7757 -683
  3. package/dist/index.js +7866 -675
  4. package/package.json +13 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli.ts +1039 -0
  8. package/src/doctor/doctor.ts +241 -0
  9. package/src/doctor/lightmapUv.ts +452 -0
  10. package/src/doctor/lod.ts +177 -0
  11. package/src/doctor/normalizeScale.ts +220 -0
  12. package/src/doctor/reduceGlbBones.ts +218 -0
  13. package/src/doctor/shading.ts +95 -0
  14. package/src/doctor/transcodeKtx2.ts +124 -0
  15. package/src/doctor/transcodeTextures.ts +101 -0
  16. package/src/doctor/validateGlb.ts +264 -0
  17. package/src/fbx/convert.ts +327 -0
  18. package/src/fbx/read.ts +109 -0
  19. package/src/fbx/toDocument.ts +209 -0
  20. package/src/filmstrip.ts +263 -0
  21. package/src/index.ts +17 -0
  22. package/src/mergeSkins.ts +117 -0
  23. package/src/probe.ts +167 -0
  24. package/src/props.ts +137 -0
  25. package/src/retarget/automap.ts +276 -0
  26. package/src/retarget/ghost.ts +23 -0
  27. package/src/retarget/heading.ts +171 -0
  28. package/src/retarget/humanoid.ts +561 -0
  29. package/src/retarget/inPlace.ts +130 -0
  30. package/src/retarget/marks.ts +581 -0
  31. package/src/retarget/mirror.ts +0 -0
  32. package/src/retarget/retarget.ts +2272 -0
  33. package/src/retarget/rig.ts +542 -0
  34. package/src/retarget/unityAnim.ts +171 -0
  35. package/src/retarget/writeMarks.ts +132 -0
  36. package/src/sky/analyze.ts +136 -0
  37. package/src/sky/cmgen.ts +71 -0
  38. package/src/sky/index.ts +151 -0
  39. package/src/terrainPack.ts +169 -0
  40. package/src/textures/resolveImage.ts +202 -0
  41. package/src/unity/materials.ts +593 -0
  42. package/src/unity/png.ts +75 -0
  43. package/src/unity/prefabs.ts +80 -0
  44. package/src/unity/scene.ts +363 -0
  45. package/src/unity/unpack.ts +130 -0
  46. package/src/unreal/anim.ts +284 -0
  47. package/src/unreal/clipSource.ts +25 -0
  48. package/src/unreal/convert.ts +67 -0
  49. package/src/unreal/convertAsset.ts +81 -0
  50. package/src/unreal/index.ts +114 -0
  51. package/src/unreal/list.ts +113 -0
  52. package/src/unreal/materials.ts +291 -0
  53. package/src/unreal/mesh.ts +205 -0
  54. package/src/unreal/oodle.ts +153 -0
  55. package/src/unreal/package.ts +659 -0
  56. package/src/unreal/probe.ts +144 -0
  57. package/src/unreal/resolve.ts +51 -0
  58. package/src/unreal/rig.ts +149 -0
  59. package/src/unreal/sequencer.ts +193 -0
  60. package/src/unreal/skeleton.ts +74 -0
  61. package/src/unreal/texture.ts +202 -0
  62. package/src/unreal/toDocument.ts +360 -0
  63. package/src/unreal/versions.ts +100 -0
  64. package/src/wasm/fbxReader.js +14 -0
  65. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,113 @@
1
+ // `unreal ls <dir>` — what a pack holds, before converting anything. Reads only the summary, the
2
+ // name table and the tables (no bulk data, no tracks), so a 796-package pack lists in well under a
3
+ // second; a clip's skeleton comes from its imports, which is enough to group clips by rig.
4
+ //
5
+ // Assets the reader cannot handle are listed with their reason instead of being skipped — the point
6
+ // of listing a pack is to find out what is usable.
7
+
8
+ import { readdirSync, readFileSync, statSync } from "node:fs"
9
+ import { dirname, join, relative, resolve } from "node:path"
10
+ import { resolveObjectFile } from "./resolve"
11
+ import { readPackage, engineLabel, classOf, assetExport } from "./package"
12
+ import { skeletonPathOf } from "./index"
13
+ import { dataModelKind, packageKind } from "./anim"
14
+
15
+ export type UnrealAssetRow = {
16
+ file: string
17
+ kind: ReturnType<typeof packageKind>
18
+ engine: string
19
+ /** The `/Game/...` path of the skeleton a clip or mesh names. */
20
+ skeleton: string
21
+ dataModel: string
22
+ note: string
23
+ }
24
+
25
+ export const listUassets = (dir: string): string[] => {
26
+ const out: string[] = []
27
+ const walk = (d: string): void => {
28
+ for (const e of readdirSync(d, { withFileTypes: true })) {
29
+ const p = join(d, e.name)
30
+ if (e.isDirectory()) walk(p)
31
+ else if (/\.uasset$/i.test(e.name)) out.push(p)
32
+ }
33
+ }
34
+ if (statSync(dir).isDirectory()) walk(dir)
35
+ else out.push(dir)
36
+ return out.sort()
37
+ }
38
+
39
+ /**
40
+ * A pack's assets, grouped by the skeleton they belong to. A `Skeleton` asset does not know its own
41
+ * `/Game/...` path in UE 5.0 files (`packageName` is "None" there), so it is matched to the path its
42
+ * clips name by resolving that path back to a file — the same walk-up `--clips` uses.
43
+ */
44
+ export const scanUnreal = (dir: string): UnrealAssetRow[] => {
45
+ const rows = scanPackages(dir)
46
+ const byFile = new Map(rows.map((r) => [ resolve(r.file), r ]))
47
+ // one referencing file per skeleton path, captured before anything is re-keyed below
48
+ const referrer = new Map<string, string>()
49
+ for (const r of rows) if (r.skeleton.startsWith("/") && !referrer.has(r.skeleton)) referrer.set(r.skeleton, r.file)
50
+ for (const [ path, from ] of referrer) {
51
+ const found = resolveObjectFile(path, dirname(from))
52
+ const row = found ? byFile.get(resolve(found.file)) : undefined
53
+ if (row) row.skeleton = path
54
+ }
55
+ return rows
56
+ }
57
+
58
+ const scanPackages = (dir: string): UnrealAssetRow[] =>
59
+ listUassets(dir).map((file): UnrealAssetRow => {
60
+ try {
61
+ const pkg = readPackage(new Uint8Array(readFileSync(file)), file)
62
+ const kind = packageKind(pkg)
63
+ const model = dataModelKind(pkg)
64
+ return {
65
+ file, kind, engine: engineLabel(pkg.summary),
66
+ // a UE 5.0 package does not carry its own path ("None") — `scanUnreal` fills it in
67
+ skeleton: kind === "Skeleton" ? (pkg.summary.packageName.startsWith("/") ? pkg.summary.packageName : "") : skeletonPathOf(pkg),
68
+ dataModel: model === "none" ? "" : model,
69
+ // the class of the export the package IS (an AnimMontage, not the AnimDataModel beside it)
70
+ note: kind === "other" ? (() => { const a = assetExport(pkg); return a ? classOf(pkg, a) : "?" })() : "",
71
+ }
72
+ } catch (e) {
73
+ return { file, kind: "other", engine: "?", skeleton: "", dataModel: "", note: (e as Error).message.replace(/^.*?: /, "") }
74
+ }
75
+ })
76
+
77
+ export const formatUnrealListing = (dir: string, rows: UnrealAssetRow[], opts: { all?: boolean } = {}): string => {
78
+ const lines: string[] = []
79
+ const usable = rows.filter((r) => r.kind !== "other" && r.kind !== "Texture2D")
80
+ lines.push(`${dir}: ${rows.length} package(s) — ${usable.length} readable by this pipeline`)
81
+ const engines = new Map<string, number>()
82
+ for (const r of rows) engines.set(r.engine, (engines.get(r.engine) ?? 0) + 1)
83
+ lines.push(` engines: ${[ ...engines ].map(([ e, n ]) => `${e} ×${n}`).join(", ")}`)
84
+
85
+ // grouped by skeleton, so a pack's rigs and the clips on them read as a unit
86
+ const bySkeleton = new Map<string, UnrealAssetRow[]>()
87
+ for (const r of usable) {
88
+ const key = r.skeleton || "(no skeleton named)"
89
+ const list = bySkeleton.get(key) ?? []
90
+ list.push(r)
91
+ bySkeleton.set(key, list)
92
+ }
93
+ for (const [ skel, list ] of [ ...bySkeleton ].sort((a, b) => b[1].length - a[1].length)) {
94
+ const counts = { AnimSequence: 0, SkeletalMesh: 0, Skeleton: 0, Texture2D: 0 } as Record<string, number>
95
+ for (const r of list) counts[r.kind]++
96
+ const blocked = list.filter((r) => r.note)
97
+ lines.push(` ${skel}: ${counts.Skeleton} skeleton(s), ${counts.SkeletalMesh} mesh(es), ${counts.AnimSequence} clip(s)${blocked.length ? ` ⚠ ${blocked.length} not readable (${blocked[0].note})` : ""}`)
98
+ for (const r of list) {
99
+ if (!opts.all && r.kind === "AnimSequence" && !r.note && list.length > 12) continue
100
+ lines.push(` ${r.kind.padEnd(13)} ${relative(dir, r.file).replace(/\\/g, "/")}${r.note ? ` ⚠ ${r.note}` : ""}`)
101
+ }
102
+ if (!opts.all && counts.AnimSequence > 0 && list.length > 12) lines.push(` (${counts.AnimSequence} clip(s) not listed — pass --all)`)
103
+ }
104
+ const textures = rows.filter((r) => r.kind === "Texture2D")
105
+ if (textures.length) lines.push(` textures: ${textures.length} Texture2D asset(s)`)
106
+ const other = rows.filter((r) => r.kind === "other")
107
+ if (other.length) {
108
+ const kinds = new Map<string, number>()
109
+ for (const r of other) kinds.set(r.note || "?", (kinds.get(r.note || "?") ?? 0) + 1)
110
+ lines.push(` other: ${[ ...kinds ].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([ k, n ]) => `${k} ×${n}`).join(", ")}`)
111
+ }
112
+ return lines.join("\n")
113
+ }
@@ -0,0 +1,291 @@
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 { 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 { findOodle } from "./oodle"
24
+
25
+ export type TextureRole = "base" | "normal" | "orm" | "other"
26
+
27
+ export type UeTextureParam = {
28
+ /** The material parameter's name (`BaseColor Map`, `Base Texture`, `Normal`). */
29
+ param: string
30
+ /** `/Game/…/T_F_Arms_D` — the Texture2D asset. */
31
+ path: string
32
+ asset: string
33
+ role: TextureRole
34
+ }
35
+
36
+ // A material names more textures than glTF has slots for, and several of them look like the one you
37
+ // want: the UE5 mannequin's material has BOTH `Normal` (the normal map) and `BNormal` (a bent
38
+ // normal), and picking whichever came first put the wrong one on the mesh. So candidates are SCORED
39
+ // and the best one wins — an exact parameter name beats an asset suffix, which beats the texture's
40
+ // compression setting, and a parameter that names something glTF has no slot for scores nothing.
41
+ 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
42
+
43
+ const SCORES: Record<Exclude<TextureRole, "other">, { re: RegExp, on: "param" | "asset" | "compression", score: number }[]> = {
44
+ base: [
45
+ { re: /^(base ?colou?r|base ?tex(ture)?|albedo|diffuse|colou?r ?map)( map)?$/i, on: "param", score: 100 },
46
+ { re: /_(d|bc|albedo|basecolor|diff)$/i, on: "asset", score: 90 },
47
+ { re: /base ?colou?r|albedo|diffuse/i, on: "param", score: 80 },
48
+ ],
49
+ normal: [
50
+ { re: /^(normal|normal ?map|nrm)$/i, on: "param", score: 100 },
51
+ { re: /_n$/i, on: "asset", score: 90 },
52
+ { re: /^normal/i, on: "param", score: 85 },
53
+ { re: /^TC_Normalmap$/i, on: "compression", score: 50 },
54
+ ],
55
+ orm: [
56
+ { re: /orm|occlusion.?roughness.?metallic/i, on: "param", score: 100 },
57
+ { re: /_orm$/i, on: "asset", score: 90 },
58
+ ],
59
+ }
60
+
61
+ /** How well a texture fits a glTF slot: 0 = not a candidate. */
62
+ export const scoreTexture = (role: Exclude<TextureRole, "other">, param: string, asset: string, compression = ""): number => {
63
+ if (NOT_A_MAP.test(param) || NOT_A_MAP.test(asset)) return 0
64
+ let best = 0
65
+ for (const rule of SCORES[role]) {
66
+ const subject = rule.on === "param" ? param : rule.on === "asset" ? asset : compression
67
+ if (subject && rule.re.test(subject)) best = Math.max(best, rule.score)
68
+ }
69
+ // a mask texture never becomes a normal map, whatever its name suggests
70
+ if (role === "normal" && compression === "TC_Masks") return 0
71
+ return best
72
+ }
73
+
74
+ /** The slot a texture fits best — what the report prints; the wiring uses `scoreTexture` directly. */
75
+ export const classifyTexture = (param: string, asset: string, compression?: string): TextureRole => {
76
+ let role: TextureRole = "other", best = 0
77
+ for (const r of [ "base", "normal", "orm" ] as const) {
78
+ const score = scoreTexture(r, param, asset, compression)
79
+ if (score > best) { best = score; role = r }
80
+ }
81
+ return role
82
+ }
83
+
84
+ /** A material asset's `TextureParameterValues`, classified. */
85
+ export const readMaterialTextures = (pkg: UePackage): UeTextureParam[] => {
86
+ const found = findExport(pkg, "MaterialInstanceConstant") ?? findExport(pkg, "Material")
87
+ if (!found) return []
88
+ const { reader: r, props } = readObjectProperties(pkg, found.export)
89
+ const tag = props.byName.get("TextureParameterValues")
90
+ if (!tag) return []
91
+ const out: UeTextureParam[] = []
92
+ const { count } = readArrayHeader(r, pkg, tag)
93
+ for (let i = 0; i < count; i++) {
94
+ const el = readProperties(r, pkg)
95
+ const after = r.offset
96
+ let param = ""
97
+ let path = "", asset = ""
98
+ const info = el.byName.get("ParameterInfo")
99
+ if (info) {
100
+ r.offset = info.data
101
+ const inner = readProperties(r, pkg)
102
+ const n = inner.byName.get("Name")
103
+ if (n) { r.offset = n.data; param = readFName(r, pkg.names) }
104
+ }
105
+ const value = el.byName.get("ParameterValue")
106
+ if (value) {
107
+ r.offset = value.data
108
+ const ref = resolveObject(pkg, r.i32())
109
+ if (ref) { path = ref.packagePath; asset = ref.name }
110
+ }
111
+ if (path) out.push({ param, path, asset, role: classifyTexture(param, asset) })
112
+ r.offset = after
113
+ }
114
+ return out
115
+ }
116
+
117
+ const normalize = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, "")
118
+
119
+ /**
120
+ * Which material asset each mesh slot uses. The mesh's import table names the materials it
121
+ * references; slots are matched to them by name — exact, then normalised, then (when the counts
122
+ * agree and nothing else did) in order, which is how the assets line up in practice.
123
+ */
124
+ export const resolveSlotMaterials = (pkg: UePackage, slots: string[]): (string | null)[] => {
125
+ const refs: { name: string, path: string }[] = []
126
+ for (let i = 0; i < pkg.imports.length; i++) {
127
+ const imp = pkg.imports[i]
128
+ if (imp.className !== "MaterialInstanceConstant" && imp.className !== "Material") continue
129
+ const path = resolveObject(pkg, -i - 1)?.packagePath ?? ""
130
+ if (path && !refs.some((r) => r.path === path)) refs.push({ name: imp.objectName, path })
131
+ }
132
+ const out: (string | null)[] = slots.map<string | null>(() => null)
133
+ const left = [ ...refs ]
134
+ const take = (i: number, r: { name: string, path: string }): void => { out[i] = r.path; left.splice(left.indexOf(r), 1) }
135
+ slots.forEach((s, i) => { const hit = left.find((r) => r.name.toLowerCase() === s.toLowerCase()); if (hit) take(i, hit) })
136
+ slots.forEach((s, i) => { if (!out[i]) { const hit = left.find((r) => normalize(r.name) === normalize(s)); if (hit) take(i, hit) } })
137
+ slots.forEach((s, i) => {
138
+ if (out[i] || !left.length) return
139
+ const n = normalize(s)
140
+ const hit = left.find((r) => normalize(r.name).includes(n) || n.includes(normalize(r.name)))
141
+ if (hit) take(i, hit)
142
+ })
143
+ const anyMatched = out.some((v) => v !== null)
144
+ if (!anyMatched && refs.length === slots.length) refs.forEach((r, i) => { out[i] = r.path })
145
+ return out
146
+ }
147
+
148
+ export type TextureOptions = {
149
+ /** Last-resort lookup for an object path, for hosts with no pack on disk (the studio's flat
150
+ * upload store): `/Game/…/MI_Manny_01` → whichever uploaded file carries that asset. */
151
+ resolveAsset?: (objectPath: string) => string | null
152
+ /** Longest edge kept; larger sources are downscaled (needs `sharp`). Default 2048, 0 = no cap. */
153
+ maxSize?: number
154
+ /** UE authors normal maps DirectX-style (green down); glTF wants OpenGL (green up). */
155
+ flipNormalGreen?: boolean
156
+ contentRoot?: string
157
+ warn?: (w: ConvertWarning) => void
158
+ }
159
+
160
+ export type AppliedTextures = {
161
+ /** Per slot: which roles were wired. */
162
+ slots: { slot: string, material: string | null, roles: string[], skipped: string[] }[]
163
+ textures: number
164
+ /** Set when nothing could be read because the Oodle library is missing. */
165
+ oodleMissing: boolean
166
+ }
167
+
168
+ /** Decode → fix channels → re-encode. Uses `sharp` when it is installed, else the package's own
169
+ * PNG codec (correct, just slower and without the resize). */
170
+ export const fixChannels = async (data: Uint8Array, mime: string, opts: { swapRb: boolean, flipGreen: boolean, maxSize: number }): Promise<{ data: Uint8Array, mimeType: string }> => {
171
+ const { swapRb, flipGreen, maxSize } = opts
172
+ if (!swapRb && !flipGreen && !maxSize) return { data, mimeType: mime }
173
+ try {
174
+ const sharp = (await import("sharp")).default
175
+ let img = sharp(Buffer.from(data), { limitInputPixels: false, unlimited: true })
176
+ const meta = await img.metadata()
177
+ if (maxSize && Math.max(meta.width ?? 0, meta.height ?? 0) > maxSize) img = img.resize(maxSize, maxSize, { fit: "inside" })
178
+ const { data: raw, info } = await img.raw().toBuffer({ resolveWithObject: true })
179
+ const ch = info.channels
180
+ for (let i = 0; i < raw.length; i += ch) {
181
+ if (swapRb) { const t = raw[i]; raw[i] = raw[i + 2]; raw[i + 2] = t }
182
+ if (flipGreen && ch > 1) raw[i + 1] = 255 - raw[i + 1]
183
+ }
184
+ let usesAlpha = false
185
+ if (ch === 4) for (let i = 3; i < raw.length; i += 4) if (raw[i] !== 255) { usesAlpha = true; break }
186
+ const out = sharp(raw, { raw: { width: info.width, height: info.height, channels: ch } })
187
+ const encoded = usesAlpha
188
+ ? await out.png({ compressionLevel: 9 }).toBuffer()
189
+ : await out.removeAlpha().jpeg({ quality: 92, chromaSubsampling: "4:4:4" }).toBuffer()
190
+ return { data: new Uint8Array(encoded), mimeType: usesAlpha ? "image/png" : "image/jpeg" }
191
+ } catch {
192
+ // no sharp: the channel fix still has to happen, so do it on the package's own PNG codec
193
+ const { decodePng } = await import("../unity/png")
194
+ const { encodePng } = await import("../textures/resolveImage")
195
+ const img = mime === "image/png" ? decodePng(data) : null
196
+ if (!img) return { data, mimeType: mime }
197
+ for (let i = 0; i < img.data.length; i += 4) {
198
+ if (swapRb) { const t = img.data[i]; img.data[i] = img.data[i + 2]; img.data[i + 2] = t }
199
+ if (flipGreen) img.data[i + 1] = 255 - img.data[i + 1]
200
+ }
201
+ return { data: encodePng(img), mimeType: "image/png" }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Put the pack's textures on a document's materials. The document must already carry one material
207
+ * per mesh slot, named after the slot (that is what `unrealToDocument` builds).
208
+ */
209
+ export const applyUnrealTextures = async (doc: Document, meshPath: string, slots: string[], opts: TextureOptions = {}): Promise<AppliedTextures> => {
210
+ const warn = opts.warn ?? (() => {})
211
+ const maxSize = opts.maxSize ?? 2048
212
+ const flipNormals = opts.flipNormalGreen ?? true
213
+ const report: AppliedTextures = { slots: [], textures: 0, oodleMissing: false }
214
+ const meshPkg = readPackageFile(meshPath)
215
+ /** An object path → a file: the pack's own layout first, then whatever the host can offer. */
216
+ const find = (objectPath: string, from = dirname(meshPath)): string | null =>
217
+ resolveObjectFile(objectPath, from, opts.contentRoot)?.file ?? opts.resolveAsset?.(objectPath) ?? null
218
+ const materials = doc.getRoot().listMaterials()
219
+ const assets = resolveSlotMaterials(meshPkg, slots)
220
+ const cache = new Map<string, Texture | null>()
221
+
222
+ const loadTexture = async (path: string, role: TextureRole, texAsset: UeTexture | null, file: string): Promise<Texture | null> => {
223
+ const key = `${path}|${role}`
224
+ const hit = cache.get(key)
225
+ if (hit !== undefined) return hit
226
+ let tex: Texture | null = null
227
+ try {
228
+ const img = await extractTexture(readPackageFile(file))
229
+ const prepared = await fixChannels(img.data, img.mimeType, {
230
+ swapRb: img.swapRb,
231
+ flipGreen: role === "normal" && flipNormals,
232
+ maxSize,
233
+ })
234
+ tex = doc.createTexture(img.texture.name).setImage(prepared.data).setMimeType(prepared.mimeType)
235
+ report.textures++
236
+ } catch (e) {
237
+ const msg = (e as Error).message
238
+ if (/Oodle/i.test(msg)) { report.oodleMissing = true; tex = null } else warn({ level: "warn", message: `texture ${basename(file)}: ${msg}` })
239
+ }
240
+ void texAsset
241
+ cache.set(key, tex)
242
+ return tex
243
+ }
244
+
245
+ for (let i = 0; i < slots.length; i++) {
246
+ const slot = slots[i]
247
+ const assetPath = assets[i]
248
+ const material: Material | undefined = materials[i]
249
+ const row = { slot, material: assetPath ? assetPath.split("/").pop() ?? null : null, roles: [] as string[], skipped: [] as string[] }
250
+ report.slots.push(row)
251
+ if (!assetPath || !material) continue
252
+ const matFile = find(assetPath)
253
+ if (!matFile) { warn({ level: "info", message: `material ${row.material}: asset not found next to the mesh — slot '${slot}' keeps its default` }); continue }
254
+
255
+ let params: UeTextureParam[]
256
+ try { params = readMaterialTextures(readPackageFile(matFile)) } catch (e) { warn({ level: "warn", message: `material ${row.material}: ${(e as Error).message}` }); continue }
257
+
258
+ // the texture asset's own compression setting is the third vote on what a parameter is for
259
+ const resolved = params.map((p) => {
260
+ const file = find(p.path, dirname(matFile))
261
+ let texture: UeTexture | null = null
262
+ if (file) { try { texture = readTexture2D(readPackageFile(file)) } catch { texture = null } }
263
+ return { ...p, file, texture, role: classifyTexture(p.param, p.asset, texture?.compressionSettings) }
264
+ })
265
+
266
+ for (const role of [ "base", "normal", "orm" ] as const) {
267
+ const pick = resolved
268
+ .filter((p) => p.file)
269
+ .map((p) => ({ p, score: scoreTexture(role, p.param, p.asset, p.texture?.compressionSettings) }))
270
+ .filter((c) => c.score > 0)
271
+ .sort((a, b) => b.score - a.score)[0]?.p
272
+ if (!pick) continue
273
+ const tex = await loadTexture(pick.path, role, pick.texture, pick.file!)
274
+ if (!tex) continue
275
+ if (role === "base") {
276
+ material.setBaseColorTexture(tex).setBaseColorFactor([ 1, 1, 1, 1 ])
277
+ material.getBaseColorTexture()?.setName(pick.asset)
278
+ } else if (role === "normal") {
279
+ material.setNormalTexture(tex)
280
+ } else {
281
+ // UE packs occlusion/roughness/metallic into R/G/B — glTF reads exactly the same channels
282
+ material.setMetallicRoughnessTexture(tex).setOcclusionTexture(tex).setMetallicFactor(1).setRoughnessFactor(1)
283
+ }
284
+ row.roles.push(role)
285
+ }
286
+ row.skipped = resolved.filter((p) => p.role === "other").map((p) => `${p.param || p.asset}`)
287
+ }
288
+ if (report.oodleMissing) warn({ level: "info", message: `textures skipped — no Oodle decoder (${findOodle() ? "found the library but could not call it" : "set LECODES_OODLE"})` })
289
+ void classOf
290
+ return report
291
+ }
@@ -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
+ }