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.
- package/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -0
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +238 -0
- package/src/retarget/humanoid.ts +572 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +149 -0
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- 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
|
+
}
|