lecodes-assets 0.2.0 → 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 +561 -449
  2. package/dist/cli.js +5632 -521
  3. package/dist/index.js +5647 -526
  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,202 @@
1
+ // Texture2D → the SOURCE image the artist imported. An uncooked asset keeps it whole (not the
2
+ // compressed BCn mips a cooked build ships), wrapped in an `FCompressedBuffer`: a big-endian header
3
+ // followed by fixed-size blocks, Oodle-compressed (`oodle.ts` loads the decoder).
4
+ //
5
+ // What comes out is usually a PNG or JPEG file, ready to embed in a GLB as it is. One catch: UE
6
+ // writes a `TSF_BGRA8` source into that PNG straight from BGRA memory, so its red and blue channels
7
+ // are swapped — `FTextureSource.Format` says when, and the swap needs a decode/encode round trip
8
+ // (`../textures/resolveImage`).
9
+ //
10
+ // Verified on UE 5.0 (buffer right after the exports, `payloadTocOffset = -1`) and UE 5.3 (inside
11
+ // the package trailer): same container either way, so it is found by its magic at or after the
12
+ // bulk-data start rather than through the trailer's own table of contents.
13
+
14
+ import { BinaryReader, findExport, readProperties, readObjectProperties, propInt, propEnum, propString, type UePackage } from "./package"
15
+ import { getOodle, OODLE_MISSING_MESSAGE, findOodle, OODLE_NO_RUNTIME_MESSAGE } from "./oodle"
16
+
17
+ /** `b7 75 63 62` — `FCompressedBuffer`'s magic, big-endian like the rest of its header. */
18
+ const COMPRESSED_BUFFER_MAGIC = [ 0xb7, 0x75, 0x63, 0x62 ]
19
+ const HEADER_SIZE = 64 // magic + crc + 4 bytes + blockCount + rawSize + compSize + a 32-byte hash
20
+
21
+ export type CompressedBuffer = {
22
+ offset: number
23
+ /** 0 = stored, 3 = Oodle (the only two seen); anything else is refused by name. */
24
+ method: number
25
+ compressor: number
26
+ blockSize: number
27
+ rawSize: number
28
+ compressedSize: number
29
+ blocks: { offset: number, size: number, rawSize: number }[]
30
+ }
31
+
32
+ /** The header at `offset`, or null when the bytes there are not one. */
33
+ export const readCompressedBuffer = (bytes: Uint8Array, offset: number): CompressedBuffer | null => {
34
+ if (offset + HEADER_SIZE > bytes.byteLength) return null
35
+ for (let i = 0; i < 4; i++) if (bytes[offset + i] !== COMPRESSED_BUFFER_MAGIC[i]) return null
36
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
37
+ const method = bytes[offset + 8], compressor = bytes[offset + 9], blockExp = bytes[offset + 11]
38
+ const blockCount = dv.getUint32(offset + 12)
39
+ const rawSize = Number(dv.getBigUint64(offset + 16))
40
+ const compressedSize = Number(dv.getBigUint64(offset + 24))
41
+ if (blockExp < 8 || blockExp > 30 || blockCount > 1 << 20 || rawSize < 0) return null
42
+ const blockSize = 1 << blockExp
43
+ if (method !== 0 && blockCount !== Math.ceil(rawSize / blockSize)) return null
44
+ const blocks: CompressedBuffer["blocks"] = []
45
+ let o = offset + HEADER_SIZE + (method === 0 ? 0 : blockCount * 4)
46
+ if (method === 0) {
47
+ blocks.push({ offset: offset + HEADER_SIZE, size: rawSize, rawSize })
48
+ } else {
49
+ let written = 0
50
+ for (let i = 0; i < blockCount; i++) {
51
+ const size = dv.getUint32(offset + HEADER_SIZE + i * 4)
52
+ const want = Math.min(blockSize, rawSize - written)
53
+ blocks.push({ offset: o, size, rawSize: want })
54
+ o += size
55
+ written += want
56
+ }
57
+ }
58
+ if (o > bytes.byteLength) return null
59
+ return { offset, method, compressor, blockSize, rawSize, compressedSize, blocks }
60
+ }
61
+
62
+ /** The first `FCompressedBuffer` at or after `from` (default: where a package's bulk data starts). */
63
+ export const findCompressedBuffer = (pkg: UePackage, from = pkg.summary.bulkDataStartOffset): CompressedBuffer | null => {
64
+ const bytes = pkg.bytes
65
+ const start = Math.max(0, Math.min(from, bytes.byteLength))
66
+ for (let i = start; i + HEADER_SIZE <= bytes.byteLength; i++) {
67
+ if (bytes[i] !== COMPRESSED_BUFFER_MAGIC[0]) continue
68
+ const buf = readCompressedBuffer(bytes, i)
69
+ if (buf) return buf
70
+ }
71
+ return null
72
+ }
73
+
74
+ /** Decompress a buffer's blocks. Needs the Oodle decoder unless every block is stored. */
75
+ export const decodeCompressedBuffer = async (pkg: UePackage, buf: CompressedBuffer): Promise<Uint8Array> => {
76
+ const out = new Uint8Array(buf.rawSize)
77
+ const needsOodle = buf.method !== 0 && buf.blocks.some((b) => b.size !== b.rawSize)
78
+ const oodle = needsOodle ? await getOodle() : null
79
+ if (needsOodle && !oodle) throw new Error(findOodle() ? OODLE_NO_RUNTIME_MESSAGE : OODLE_MISSING_MESSAGE)
80
+ if (buf.method !== 0 && buf.method !== 3) throw new Error(`${pkg.label}: compressed buffer method ${buf.method} is not supported (0 = stored, 3 = Oodle)`)
81
+ let written = 0
82
+ for (const b of buf.blocks) {
83
+ const src = pkg.bytes.subarray(b.offset, b.offset + b.size)
84
+ if (src.byteLength !== b.size) throw new Error(`${pkg.label}: compressed block at ${b.offset} runs past the file`)
85
+ out.set(b.size === b.rawSize ? src : oodle!.decompress(src, b.rawSize), written)
86
+ written += b.rawSize
87
+ }
88
+ return out
89
+ }
90
+
91
+ // ---- the texture asset ---------------------------------------------------------------------------
92
+
93
+ /** `ETextureSourceFormat` values that matter here; anything else is reported, not guessed at. */
94
+ export type UeTextureSource = {
95
+ width: number
96
+ height: number
97
+ mips: number
98
+ slices: number
99
+ /** `TSF_BGRA8`, `TSF_G8`, `TSF_RGBA16F`, … (absent in a few 5.0 assets → ""). */
100
+ format: string
101
+ /** `TSCF_PNG` / `TSCF_JPEG` / `TSCF_None`; UE 5.0 says the same thing with `bPNGCompressed`. */
102
+ compression: string
103
+ }
104
+
105
+ export type UeTexture = {
106
+ name: string
107
+ source: UeTextureSource
108
+ /** `TC_Default`, `TC_Normalmap`, `TC_Masks`, … — what the material does with it. */
109
+ compressionSettings: string
110
+ srgb: boolean
111
+ /** The imported file's own path, when the asset still remembers it. */
112
+ sourceFile: string
113
+ }
114
+
115
+ /** The `Texture2D` export's own facts (no payload work). */
116
+ export const readTexture2D = (pkg: UePackage): UeTexture => {
117
+ const found = findExport(pkg, "Texture2D")
118
+ if (!found) throw new Error(`${pkg.label}: no Texture2D export`)
119
+ const { reader: r, props } = readObjectProperties(pkg, found.export)
120
+ const srcTag = props.byName.get("Source")
121
+ const source: UeTextureSource = { width: 0, height: 0, mips: 1, slices: 1, format: "", compression: "" }
122
+ if (srcTag) {
123
+ r.offset = srcTag.data
124
+ const s = readProperties(r, pkg)
125
+ source.width = propInt(r, s.byName.get("SizeX")) ?? 0
126
+ source.height = propInt(r, s.byName.get("SizeY")) ?? 0
127
+ source.mips = propInt(r, s.byName.get("NumMips")) ?? 1
128
+ source.slices = propInt(r, s.byName.get("NumSlices")) ?? 1
129
+ source.format = propEnum(r, pkg.names, s.byName.get("Format")) ?? ""
130
+ source.compression = propEnum(r, pkg.names, s.byName.get("CompressionFormat"))
131
+ ?? (s.byName.get("bPNGCompressed")?.bool ? "TSCF_PNG" : "")
132
+ }
133
+ return {
134
+ name: found.export.objectName,
135
+ source,
136
+ compressionSettings: propEnum(r, pkg.names, props.byName.get("CompressionSettings")) ?? "TC_Default",
137
+ srgb: props.byName.get("SRGB")?.bool ?? true,
138
+ sourceFile: sourceFileOf(pkg, r, props),
139
+ }
140
+ }
141
+
142
+ /** `AssetImportData`'s JSON blob keeps the file the texture was imported from; nice for reports. */
143
+ const sourceFileOf = (pkg: UePackage, r: BinaryReader, props: ReturnType<typeof readProperties>): string => {
144
+ try {
145
+ const tag = props.byName.get("AssetImportData")
146
+ if (!tag) return ""
147
+ const idx = propInt(r, tag) ?? 0
148
+ if (idx <= 0) return ""
149
+ const e = pkg.exports[idx - 1]
150
+ if (!e) return ""
151
+ const { reader: er, props: p } = readObjectProperties(pkg, e)
152
+ const json = propString(er, p.byName.get("SourceData")) ?? ""
153
+ const m = /"RelativeFilename"\s*:\s*"([^"]+)"/.exec(json)
154
+ return m ? m[1] : ""
155
+ } catch { return "" }
156
+ }
157
+
158
+ export type ExtractedImage = {
159
+ /** The source file as it sits in the asset (PNG/JPEG bytes), plus what it is. */
160
+ data: Uint8Array
161
+ mimeType: string
162
+ /** True when the channels are B,G,R and a red/blue swap is owed (`TSF_BGRA8`). */
163
+ swapRb: boolean
164
+ texture: UeTexture
165
+ }
166
+
167
+ const startsWith = (b: Uint8Array, at: number, sig: number[]): boolean => sig.every((v, i) => b[at + i] === v)
168
+ const PNG_SIG = [ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]
169
+ const JPEG_SIG = [ 0xff, 0xd8, 0xff ]
170
+
171
+ /** Where the image file starts inside a decompressed payload (UE writes it verbatim). */
172
+ const imageAt = (payload: Uint8Array): { at: number, mimeType: string } | null => {
173
+ const limit = Math.min(payload.byteLength, 4096)
174
+ for (let i = 0; i < limit; i++) {
175
+ if (startsWith(payload, i, PNG_SIG)) return { at: i, mimeType: "image/png" }
176
+ if (startsWith(payload, i, JPEG_SIG)) return { at: i, mimeType: "image/jpeg" }
177
+ }
178
+ return null
179
+ }
180
+
181
+ /**
182
+ * The image inside a texture asset. Throws with the Oodle message when the decoder is missing —
183
+ * callers that can live without textures should catch it and say so once.
184
+ */
185
+ export const extractTexture = async (pkg: UePackage): Promise<ExtractedImage> => {
186
+ const texture = readTexture2D(pkg)
187
+ const buf = findCompressedBuffer(pkg)
188
+ if (!buf) throw new Error(`${pkg.label}: no source image payload (a texture with its source stripped?)`)
189
+ const payload = await decodeCompressedBuffer(pkg, buf)
190
+ const img = imageAt(payload)
191
+ if (!img) {
192
+ throw new Error(`${pkg.label}: the payload is raw ${texture.source.format || "pixels"} (${texture.source.width}×${texture.source.height}), not a PNG/JPEG — not supported yet`)
193
+ }
194
+ return {
195
+ data: payload.subarray(img.at),
196
+ mimeType: img.mimeType,
197
+ // UE fills a TSF_BGRA8 source straight from BGRA memory, so the file's R and B are the other
198
+ // way round. Formats that name their order (RGBA8, G8, …) need no swap.
199
+ swapRb: /^TSF_BGRA8/i.test(texture.source.format),
200
+ texture,
201
+ }
202
+ }
@@ -0,0 +1,360 @@
1
+ // Unreal skeleton (+ mesh, + clips) → a glTF-Transform Document, in the same conventions the FBX
2
+ // route produces (`src/fbx/toDocument.ts`): metres, Y-up, one Buffer, bones as plain nodes named
3
+ // exactly as Unreal names them (the Animator binds by name, so `_l`/`_r`/`twist`/`ik_*` stay
4
+ // verbatim). The doctor passes, the writer and `probe` then run on it like on any other document.
5
+ //
6
+ // UE's vertex model is wedges (per-corner point + UV) and faces (three wedges + a per-corner
7
+ // normal); glTF wants one vertex array, so normals are accumulated per wedge — one glTF vertex per
8
+ // wedge, exactly what the predecessor Python exporter did.
9
+
10
+ import { Document, type Node, type Material } from "@gltf-transform/core"
11
+ import { fixQuatContinuity, type ConvertWarning } from "../fbx/toDocument"
12
+ import type { UeSkeleton, UeBone } from "./skeleton"
13
+ import type { UeSkeletalMesh } from "./mesh"
14
+ import { keyTimes, type UeAnimSequence } from "./anim"
15
+ import { ueLocalTransform, ueWorldPoint, ueWorldDir, ueRootTranslation, ueTranslation, ueQuat, ueRootQuat, type Quat, type Vec3 } from "./convert"
16
+
17
+ export type UnrealDocumentInput = {
18
+ skeleton: UeSkeleton
19
+ mesh?: UeSkeletalMesh
20
+ clips?: UeAnimSequence[]
21
+ }
22
+
23
+ export type UnrealDocumentOptions = {
24
+ /** Names for `clips`, positionally (default: the AnimSequence's own asset name). */
25
+ clipNames?: (string | undefined)[]
26
+ warn?: (w: ConvertWarning) => void
27
+ }
28
+
29
+ export type UnrealDocumentResult = {
30
+ doc: Document
31
+ /** glTF node per skeleton bone index. */
32
+ nodes: Node[]
33
+ clipNames: string[]
34
+ /** Per clip: how many of its tracks found a bone. */
35
+ bound: { name: string, bound: number, tracks: number, unbound: string[] }[]
36
+ /** Set when the winding check flipped the triangles (the Y mirror reverses handedness). */
37
+ flippedWinding: boolean
38
+ droppedWeight: number
39
+ }
40
+
41
+ const MAX_JOINTS_PER_VERTEX = 4
42
+
43
+ // ---- small matrix helpers (column-major, glTF's layout) -----------------------------------------
44
+
45
+ type Mat4 = number[]
46
+ const matCompose = (t: Vec3, q: Quat, s: Vec3): Mat4 => {
47
+ const [ x, y, z, w ] = q, x2 = x + x, y2 = y + y, z2 = z + z
48
+ const xx = x * x2, xy = x * y2, xz = x * z2, yy = y * y2, yz = y * z2, zz = z * z2, wx = w * x2, wy = w * y2, wz = w * z2
49
+ return [ (1 - (yy + zz)) * s[0], (xy + wz) * s[0], (xz - wy) * s[0], 0,
50
+ (xy - wz) * s[1], (1 - (xx + zz)) * s[1], (yz + wx) * s[1], 0,
51
+ (xz + wy) * s[2], (yz - wx) * s[2], (1 - (xx + yy)) * s[2], 0,
52
+ t[0], t[1], t[2], 1 ]
53
+ }
54
+ const matMul = (a: Mat4, b: Mat4): Mat4 => {
55
+ const o = new Array(16).fill(0)
56
+ for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
57
+ return o
58
+ }
59
+ /** Inverse of a rigid matrix with a uniform scale — all a bone's world transform ever is here. */
60
+ const matInvRigid = (m: Mat4): Mat4 => {
61
+ const s2 = m[0] * m[0] + m[1] * m[1] + m[2] * m[2] || 1
62
+ const r: Mat4 = [ m[0] / s2, m[4] / s2, m[8] / s2, 0, m[1] / s2, m[5] / s2, m[9] / s2, 0, m[2] / s2, m[6] / s2, m[10] / s2, 0, 0, 0, 0, 1 ]
63
+ r[12] = -(r[0] * m[12] + r[4] * m[13] + r[8] * m[14])
64
+ r[13] = -(r[1] * m[12] + r[5] * m[13] + r[9] * m[14])
65
+ r[14] = -(r[2] * m[12] + r[6] * m[13] + r[10] * m[14])
66
+ return r
67
+ }
68
+
69
+ /** Bone world matrices in the glTF frame, from the converted local transforms. */
70
+ export const boneWorldMatrices = (bones: { parent: number, t: number[], q: number[], s: number[] }[]): Mat4[] => {
71
+ const world: Mat4[] = new Array(bones.length)
72
+ const visit = (i: number): Mat4 => {
73
+ const hit = world[i]
74
+ if (hit) return hit
75
+ const b = bones[i]
76
+ const l = ueLocalTransform(b.t, b.q, b.s, b.parent < 0)
77
+ const local = matCompose(l.t, l.r, l.s)
78
+ const m = b.parent >= 0 ? matMul(visit(b.parent), local) : local
79
+ world[i] = m
80
+ return m
81
+ }
82
+ for (let i = 0; i < bones.length; i++) visit(i)
83
+ return world
84
+ }
85
+
86
+ /** Inverse-bind matrices for a bone list — the bind pose IS the reference pose in Unreal. */
87
+ export const boneInverseBindMatrices = (bones: { parent: number, t: number[], q: number[], s: number[] }[]): Mat4[] =>
88
+ boneWorldMatrices(bones).map(matInvRigid)
89
+
90
+ /** A mesh's own bone list as a skeleton (a mesh carries the bind reference itself, §4.3). */
91
+ export const skeletonFromMesh = (mesh: UeSkeletalMesh): UeSkeleton => ({
92
+ name: mesh.skeletonName || mesh.name,
93
+ bones: mesh.bones.map((b): UeBone => ({ name: b.name, parent: b.parent, t: b.t, q: b.q, s: b.s })),
94
+ })
95
+
96
+ // ---- clip tracks -----------------------------------------------------------------------------
97
+
98
+ export type UnrealTrack = {
99
+ /** Index into the skeleton's bones. */
100
+ node: number
101
+ path: "translation" | "rotation" | "scale"
102
+ times: Float32Array
103
+ values: Float32Array
104
+ }
105
+
106
+ export type UnrealClipTracks = {
107
+ tracks: UnrealTrack[]
108
+ /** Bones the clip names that the skeleton does not have. */
109
+ unbound: string[]
110
+ /** How many of the clip's tracks produced at least one channel. */
111
+ bound: number
112
+ }
113
+
114
+ /**
115
+ * A clip's keys in the glTF frame, bound to a skeleton BY BONE NAME. The one place UE keys are
116
+ * converted — both the document builder and the `--clips` merge go through it, so a uasset clip
117
+ * lands identically whether it is written straight out or merged onto another model.
118
+ */
119
+ export const unrealClipTracks = (skeleton: UeSkeleton, clip: UeAnimSequence, warn?: (w: ConvertWarning) => void): UnrealClipTracks => {
120
+ const index = new Map<string, number>()
121
+ skeleton.bones.forEach((b, i) => { if (!index.has(b.name)) index.set(b.name, i) })
122
+ const times = keyTimes(clip)
123
+ const constantTimes = new Float32Array(clip.playLength > 0 ? [ 0, clip.playLength ] : [ 0 ])
124
+ const tracks: UnrealTrack[] = []
125
+ const unbound: string[] = []
126
+ let bound = 0
127
+ for (const tr of clip.tracks) {
128
+ const node = index.get(tr.name)
129
+ if (node === undefined) { unbound.push(tr.name); continue }
130
+ const isRoot = skeleton.bones[node].parent < 0
131
+ let any = false
132
+ const add = (path: UnrealTrack["path"], comps: number, src: Float32Array): void => {
133
+ const n = src.length / comps
134
+ if (n === 0) return
135
+ const constant = n === 1
136
+ if (!constant && n !== times.length) {
137
+ warn?.({ level: "warn", message: `clip '${clip.name}' bone '${tr.name}': ${n} ${path} keys for a ${times.length}-key clip — track dropped` })
138
+ return
139
+ }
140
+ const t = constant ? constantTimes : times
141
+ const values = new Float32Array(t.length * comps)
142
+ for (let k = 0; k < n; k++) {
143
+ const o = k * comps
144
+ const v = path === "translation" ? (isRoot ? ueRootTranslation(src[o], src[o + 1], src[o + 2]) : ueTranslation(src[o], src[o + 1], src[o + 2]))
145
+ : path === "rotation" ? (isRoot ? ueRootQuat(src[o], src[o + 1], src[o + 2], src[o + 3]) : ueQuat(src[o], src[o + 1], src[o + 2], src[o + 3]))
146
+ : [ src[o], src[o + 1], src[o + 2] ]
147
+ values.set(v, o)
148
+ }
149
+ // a constant track is written as two keys so the clip keeps its length on its own
150
+ if (constant) for (let k = 1; k < t.length; k++) values.copyWithin(k * comps, 0, comps)
151
+ if (path === "rotation") fixQuatContinuity(values)
152
+ tracks.push({ node, path, times: t, values })
153
+ any = true
154
+ }
155
+ add("translation", 3, tr.pos)
156
+ add("rotation", 4, tr.rot)
157
+ add("scale", 3, tr.scale)
158
+ if (any) bound++
159
+ }
160
+ return { tracks, unbound, bound }
161
+ }
162
+
163
+ /** Clip extras every route writes, so `probe` can print where a clip came from. */
164
+ export const clipExtras = (clip: UeAnimSequence): Record<string, unknown> => ({
165
+ lecodes: {
166
+ source: "unreal", fps: clip.fps, frames: clip.numFrames, keys: clip.numKeys,
167
+ rootMotion: clip.rootMotion, additive: clip.additive ?? undefined,
168
+ events: clip.events.length ? clip.events.map((e) => ({ name: e.name, time: e.time, kind: e.kind })) : undefined,
169
+ },
170
+ })
171
+
172
+ // ---- the builder ---------------------------------------------------------------------------------
173
+
174
+ export const unrealToDocument = (input: UnrealDocumentInput, opts: UnrealDocumentOptions = {}): UnrealDocumentResult => {
175
+ const warn = opts.warn ?? (() => {})
176
+ const { skeleton, mesh } = input
177
+ const clips = input.clips ?? []
178
+ const doc = new Document()
179
+ const buffer = doc.createBuffer("buffer")
180
+ const scene = doc.createScene("Scene")
181
+
182
+ // ---- bones ----
183
+ const nodes: Node[] = skeleton.bones.map((b) => {
184
+ const l = ueLocalTransform(b.t, b.q, b.s, b.parent < 0)
185
+ return doc.createNode(b.name).setTranslation(l.t).setRotation(l.r).setScale(l.s)
186
+ })
187
+ skeleton.bones.forEach((b, i) => {
188
+ if (b.parent >= 0) nodes[b.parent].addChild(nodes[i])
189
+ else scene.addChild(nodes[i])
190
+ })
191
+ const boneIndex = new Map<string, number>()
192
+ skeleton.bones.forEach((b, i) => { if (!boneIndex.has(b.name)) boneIndex.set(b.name, i) })
193
+
194
+ let flippedWinding = false
195
+ let droppedWeight = 0
196
+
197
+ // ---- mesh + skin ----
198
+ if (mesh) {
199
+ // joints keep the mesh's OWN bone order, so `influences.boneIndex` maps 1:1
200
+ const joints: Node[] = []
201
+ const missing: string[] = []
202
+ for (const b of mesh.bones) {
203
+ const i = boneIndex.get(b.name)
204
+ if (i === undefined) { missing.push(b.name); joints.push(nodes[0]) } else joints.push(nodes[i])
205
+ }
206
+ if (missing.length) warn({ level: "warn", message: `mesh '${mesh.name}': ${missing.length} skin bone(s) are not in the skeleton (${missing.slice(0, 4).join(", ")}) — weights on them land on the root` })
207
+
208
+ // per-point influences, top 4 by weight
209
+ const perPoint: UeSkeletalMesh["influences"][] = Array.from({ length: mesh.points.length / 3 }, () => [])
210
+ for (const inf of mesh.influences) {
211
+ if (inf.point >= 0 && inf.point < perPoint.length) perPoint[inf.point].push(inf)
212
+ }
213
+ const pointJoints = new Uint16Array(perPoint.length * MAX_JOINTS_PER_VERTEX)
214
+ const pointWeights = new Float32Array(perPoint.length * MAX_JOINTS_PER_VERTEX)
215
+ for (let p = 0; p < perPoint.length; p++) {
216
+ const list = perPoint[p].slice().sort((a, b) => b.weight - a.weight)
217
+ const keep = list.slice(0, MAX_JOINTS_PER_VERTEX)
218
+ const total = list.reduce((n, i) => n + i.weight, 0)
219
+ const kept = keep.reduce((n, i) => n + i.weight, 0)
220
+ if (total > 0) droppedWeight += total - kept
221
+ const norm = kept > 0 ? 1 / kept : 0
222
+ keep.forEach((inf, k) => {
223
+ pointJoints[p * MAX_JOINTS_PER_VERTEX + k] = inf.bone >= 0 && inf.bone < joints.length ? inf.bone : 0
224
+ pointWeights[p * MAX_JOINTS_PER_VERTEX + k] = inf.weight * norm
225
+ })
226
+ }
227
+
228
+ // normals: the per-corner TangentZ of every face using a wedge, accumulated
229
+ const wedgeCount = mesh.wedgePoint.length
230
+ const normals = new Float32Array(wedgeCount * 3)
231
+ const faceCount = mesh.faceWedges.length / 3
232
+ for (let f = 0; f < faceCount; f++) {
233
+ for (let c = 0; c < 3; c++) {
234
+ const w = mesh.faceWedges[f * 3 + c]
235
+ normals[w * 3] += mesh.faceNormals[f * 9 + c * 3]
236
+ normals[w * 3 + 1] += mesh.faceNormals[f * 9 + c * 3 + 1]
237
+ normals[w * 3 + 2] += mesh.faceNormals[f * 9 + c * 3 + 2]
238
+ }
239
+ }
240
+
241
+ // The Y mirror reverses handedness, so UE's winding may or may not survive it. Decide from the
242
+ // data — for a sample of faces, does the geometric normal agree with the shading normal? —
243
+ // rather than hiding the answer behind a double-sided material.
244
+ {
245
+ let agree = 0, disagree = 0
246
+ const step = Math.max(1, Math.floor(faceCount / 500))
247
+ for (let f = 0; f < faceCount; f += step) {
248
+ const w = [ mesh.faceWedges[f * 3], mesh.faceWedges[f * 3 + 1], mesh.faceWedges[f * 3 + 2] ]
249
+ const p = w.map((x) => {
250
+ const pt = mesh.wedgePoint[x] * 3
251
+ return ueWorldPoint(mesh.points[pt], mesh.points[pt + 1], mesh.points[pt + 2])
252
+ })
253
+ const n = ueWorldDir(
254
+ mesh.faceNormals[f * 9] + mesh.faceNormals[f * 9 + 3] + mesh.faceNormals[f * 9 + 6],
255
+ mesh.faceNormals[f * 9 + 1] + mesh.faceNormals[f * 9 + 4] + mesh.faceNormals[f * 9 + 7],
256
+ mesh.faceNormals[f * 9 + 2] + mesh.faceNormals[f * 9 + 5] + mesh.faceNormals[f * 9 + 8],
257
+ )
258
+ const e1 = [ p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2] ]
259
+ const e2 = [ p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2] ]
260
+ const cr = [ e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0] ]
261
+ const d = cr[0] * n[0] + cr[1] * n[1] + cr[2] * n[2]
262
+ if (d > 0) agree++
263
+ else if (d < 0) disagree++
264
+ }
265
+ flippedWinding = disagree > agree
266
+ }
267
+
268
+ // materials, one per import-data slot (textures are a later phase: grey PBR, named by the slot)
269
+ const materials: Material[] = (mesh.materials.length ? mesh.materials : [ "material" ]).map((name) => {
270
+ const m = doc.createMaterial(name).setRoughnessFactor(0.75).setMetallicFactor(0).setDoubleSided(false)
271
+ if (/glass/i.test(name)) m.setAlphaMode("BLEND").setBaseColorFactor([ 1, 1, 1, 0.35 ])
272
+ return m
273
+ })
274
+
275
+ const gmesh = doc.createMesh(mesh.name)
276
+ const slots = new Set<number>()
277
+ for (const s of mesh.faceMaterial) slots.add(s)
278
+ for (const slot of [ ...slots ].sort((a, b) => a - b)) {
279
+ // one glTF vertex per wedge this slot uses
280
+ const remap = new Int32Array(wedgeCount).fill(-1)
281
+ const used: number[] = []
282
+ const indices: number[] = []
283
+ for (let f = 0; f < faceCount; f++) {
284
+ if (mesh.faceMaterial[f] !== slot) continue
285
+ const tri = [ mesh.faceWedges[f * 3], mesh.faceWedges[f * 3 + 1], mesh.faceWedges[f * 3 + 2] ]
286
+ if (flippedWinding) { const t = tri[1]; tri[1] = tri[2]; tri[2] = t }
287
+ for (const w of tri) {
288
+ if (remap[w] < 0) { remap[w] = used.length; used.push(w) }
289
+ indices.push(remap[w])
290
+ }
291
+ }
292
+ if (!used.length) continue
293
+ const n = used.length
294
+ const pos = new Float32Array(n * 3), nrm = new Float32Array(n * 3), uv0 = new Float32Array(n * 2)
295
+ const uv1 = mesh.hasUv1 ? new Float32Array(n * 2) : null
296
+ const jnt = new Uint16Array(n * 4), wgt = new Float32Array(n * 4)
297
+ for (let i = 0; i < n; i++) {
298
+ const w = used[i]
299
+ const p = mesh.wedgePoint[w] * 3
300
+ const wp = ueWorldPoint(mesh.points[p], mesh.points[p + 1], mesh.points[p + 2])
301
+ pos.set(wp, i * 3)
302
+ const nw = ueWorldDir(normals[w * 3], normals[w * 3 + 1], normals[w * 3 + 2])
303
+ const len = Math.hypot(nw[0], nw[1], nw[2]) || 1
304
+ nrm[i * 3] = nw[0] / len; nrm[i * 3 + 1] = nw[1] / len; nrm[i * 3 + 2] = nw[2] / len
305
+ uv0[i * 2] = mesh.wedgeUv0[w * 2]; uv0[i * 2 + 1] = mesh.wedgeUv0[w * 2 + 1]
306
+ if (uv1) { uv1[i * 2] = mesh.wedgeUv1[w * 2]; uv1[i * 2 + 1] = mesh.wedgeUv1[w * 2 + 1] }
307
+ const pj = mesh.wedgePoint[w] * MAX_JOINTS_PER_VERTEX
308
+ for (let k = 0; k < 4; k++) { jnt[i * 4 + k] = pointJoints[pj + k]; wgt[i * 4 + k] = pointWeights[pj + k] }
309
+ }
310
+ const prim = doc.createPrimitive()
311
+ prim.setAttribute("POSITION", doc.createAccessor().setType("VEC3").setArray(pos).setBuffer(buffer))
312
+ prim.setAttribute("NORMAL", doc.createAccessor().setType("VEC3").setArray(nrm).setBuffer(buffer))
313
+ prim.setAttribute("TEXCOORD_0", doc.createAccessor().setType("VEC2").setArray(uv0).setBuffer(buffer))
314
+ if (uv1) prim.setAttribute("TEXCOORD_1", doc.createAccessor().setType("VEC2").setArray(uv1).setBuffer(buffer))
315
+ prim.setAttribute("JOINTS_0", doc.createAccessor().setType("VEC4").setArray(jnt).setBuffer(buffer))
316
+ prim.setAttribute("WEIGHTS_0", doc.createAccessor().setType("VEC4").setArray(wgt).setBuffer(buffer))
317
+ const idx = n <= 65535 ? Uint16Array.from(indices) : Uint32Array.from(indices)
318
+ prim.setIndices(doc.createAccessor().setType("SCALAR").setArray(idx).setBuffer(buffer))
319
+ prim.setMaterial(materials[Math.min(slot, materials.length - 1)])
320
+ gmesh.addPrimitive(prim)
321
+ }
322
+
323
+ const ibm = new Float32Array(mesh.bones.length * 16)
324
+ boneInverseBindMatrices(mesh.bones).forEach((m, i) => ibm.set(m, i * 16))
325
+ const skin = doc.createSkin(mesh.name)
326
+ for (const j of joints) skin.addJoint(j)
327
+ skin.setInverseBindMatrices(doc.createAccessor().setType("MAT4").setArray(ibm).setBuffer(buffer))
328
+ const meshNode = doc.createNode(mesh.name).setMesh(gmesh).setSkin(skin)
329
+ scene.addChild(meshNode)
330
+ }
331
+
332
+ // ---- clips ----
333
+ const clipNames: string[] = []
334
+ const bound: UnrealDocumentResult["bound"] = []
335
+ clips.forEach((clip, ci) => {
336
+ let name = opts.clipNames?.[ci] ?? clip.name
337
+ const taken = new Set(doc.getRoot().listAnimations().map((a) => a.getName()))
338
+ if (taken.has(name)) { let k = 2; while (taken.has(`${name} (${k})`)) k++; name = `${name} (${k})` }
339
+ clipNames.push(name)
340
+ const anim = doc.createAnimation(name)
341
+ const interpolation = clip.interpolation === "Step" ? "STEP" : "LINEAR"
342
+ const t = unrealClipTracks(skeleton, clip, warn)
343
+ for (const tr of t.tracks) {
344
+ const comps = tr.path === "rotation" ? 4 : 3
345
+ const sampler = doc.createAnimationSampler()
346
+ .setInput(doc.createAccessor().setType("SCALAR").setArray(tr.times.slice()).setBuffer(buffer))
347
+ .setOutput(doc.createAccessor().setType(comps === 4 ? "VEC4" : "VEC3").setArray(tr.values.slice()).setBuffer(buffer))
348
+ .setInterpolation(interpolation)
349
+ anim.addSampler(sampler)
350
+ anim.addChannel(doc.createAnimationChannel().setTargetNode(nodes[tr.node]).setTargetPath(tr.path).setSampler(sampler))
351
+ }
352
+ anim.setExtras(clipExtras(clip))
353
+ if (t.unbound.length) {
354
+ warn({ level: "warn", message: `clip '${name}': ${t.unbound.length}/${clip.tracks.length} tracks name a bone the skeleton does not have (${t.unbound.slice(0, 5).join(", ")}${t.unbound.length > 5 ? "…" : ""})` })
355
+ }
356
+ bound.push({ name, bound: t.bound, tracks: clip.tracks.length, unbound: t.unbound })
357
+ })
358
+
359
+ return { doc, nodes, clipNames, bound, flippedWinding, droppedWeight }
360
+ }
@@ -0,0 +1,100 @@
1
+ // Package format versions — the ONE place a layout threshold is named. `package.ts` gates every
2
+ // field on these, never on a bare number, so a new engine release is a new constant here plus the
3
+ // fields it added, not a hunt through the reader.
4
+ //
5
+ // A package carries three versions: the legacy file version (the shape of the summary's head),
6
+ // `FileVersionUE4` (frozen at 522 for every UE5 file) and `FileVersionUE5` (what actually moves
7
+ // between releases). Values below are `EUnrealEngineObjectUE5Version` / `EUnrealEngineObjectUE4Version`
8
+ // from ObjectVersion.h, verified on real files of each release (Stairs Set 5.0, soldier 5.3,
9
+ // Roll & Dodge 5.4, AGLS 5.5 / 5.6 / 5.7).
10
+
11
+ /** `FileVersionUE5` — the version that changes between UE5 releases. */
12
+ export const UE5 = {
13
+ INITIAL_VERSION: 1000,
14
+ /** The name table is split: names [0, count) are referenced from export data. */
15
+ NAMES_REFERENCED_FROM_EXPORT_DATA: 1001,
16
+ /** Payload table of contents (virtualised bulk) offset in the summary. */
17
+ PAYLOAD_TOC: 1002,
18
+ /** `bImportOptional` per import, `bGeneratePublicHash` per export. */
19
+ OPTIONAL_RESOURCES: 1003,
20
+ /** Large world coordinates: core types serialise doubles (`FTransform` = 80 bytes). UE 5.0. */
21
+ LARGE_WORLD_COORDINATES: 1004,
22
+ /** Package guid dropped from `FObjectExport`. */
23
+ REMOVE_OBJECT_EXPORT_PACKAGE_GUID: 1005,
24
+ /** `bIsInheritedInstance` per export. */
25
+ TRACK_OBJECT_EXPORT_IS_INHERITED: 1006,
26
+ FSOFTOBJECTPATH_REMOVE_ASSET_PATH_FNAMES: 1007,
27
+ /** Soft object path list count/offset in the summary. */
28
+ ADD_SOFTOBJECTPATH_LIST: 1008,
29
+ /** Bulk/data resource table offset in the summary. UE 5.3. */
30
+ DATA_RESOURCES: 1009,
31
+ /** Script (tagged property) start/end offsets per export — where an object's tag list sits. */
32
+ SCRIPT_SERIALIZATION_OFFSET: 1010,
33
+ /**
34
+ * Every object's tag list opens with a class-serialization-control byte, and every tag (old or
35
+ * new layout) may carry an extension byte. UE 5.4.
36
+ */
37
+ PROPERTY_TAG_EXTENSION_AND_OVERRIDABLE_SERIALIZATION: 1011,
38
+ /**
39
+ * The tag's type is a TREE of names (`ArrayProperty(StructProperty(Vector3f(/Script/CoreUObject)))`)
40
+ * instead of a type name plus per-type extras, and array index / guid / bool live in a flags byte.
41
+ */
42
+ PROPERTY_TAG_COMPLETE_TYPE_NAME: 1012,
43
+ ASSETREGISTRY_PACKAGEBUILDDEPENDENCIES: 1013,
44
+ /** Metadata offset in the summary. UE 5.5. */
45
+ METADATA_SERIALIZATION_OFFSET: 1014,
46
+ /** Verse cell import/export tables in the summary. */
47
+ VERSE_CELLS: 1015,
48
+ /** The summary's guid becomes a 20-byte saved hash, written up front with the header size. */
49
+ PACKAGE_SAVED_HASH: 1016,
50
+ /** UE 5.6. */
51
+ OS_SUB_OBJECT_SHADOW_SERIALIZATION: 1017,
52
+ /** Import type hierarchy table in the summary. UE 5.7. */
53
+ IMPORT_TYPE_HIERARCHIES: 1018,
54
+ } as const
55
+
56
+ /** `FileVersionUE4` thresholds the layouts still key on (all satisfied by 522). */
57
+ export const UE4 = {
58
+ ENGINE_VERSION_OBJECT: 336,
59
+ ADD_STRING_ASSET_REFERENCES_MAP: 384,
60
+ PACKAGE_SUMMARY_HAS_COMPATIBLE_ENGINE_VERSION: 444,
61
+ SERIALIZE_TEXT_IN_PACKAGES: 459,
62
+ COOKED_ASSETS_IN_EDITOR_SUPPORT: 485,
63
+ INNER_ARRAY_TAG_INFO: 500,
64
+ NAME_HASHES_SERIALIZED: 504,
65
+ PRELOAD_DEPENDENCIES_IN_COOKED_EXPORTS: 507,
66
+ TEMPLATE_INDEX_IN_COOKED_EXPORTS: 508,
67
+ ADDED_SEARCHABLE_NAMES: 510,
68
+ BIG_EXPORT_SIZE_FIXES: 511,
69
+ ADDED_PACKAGE_SUMMARY_LOCALIZATION_ID: 516,
70
+ ADDED_PACKAGE_OWNER: 518,
71
+ NON_OUTER_PACKAGE_IMPORT: 520,
72
+ /** What every UE5 package carries — frozen since 4.27. */
73
+ FROZEN: 522,
74
+ } as const
75
+
76
+ /** Legacy file version: the shape of the summary's first fields. */
77
+ export const LEGACY = {
78
+ /** UE4 packages — float transforms, older export layout; not read here. */
79
+ UE4: -7,
80
+ /** UE 5.0 – 5.5: `FileVersionUE5` follows `FileVersionUE4`. */
81
+ UE5: -8,
82
+ /** UE 5.6+: same head (the UE3 field is still written); the differences are versioned below. */
83
+ UE5_6: -9,
84
+ } as const
85
+
86
+ /** The newest `FileVersionUE5` this reader has verified against real files. */
87
+ export const UE5_VERSION_MAX = UE5.IMPORT_TYPE_HIERARCHIES
88
+ /** The oldest — UE 5.0. Earlier UE5 previews are refused. */
89
+ export const UE5_VERSION_MIN = UE5.LARGE_WORLD_COORDINATES
90
+
91
+ /** The engine release a `FileVersionUE5` belongs to, for messages ("UE 5.4" for 1011–1013). */
92
+ export const releaseOfVersion = (ue5: number): string => {
93
+ if (ue5 >= UE5.IMPORT_TYPE_HIERARCHIES) return "5.7"
94
+ if (ue5 >= UE5.OS_SUB_OBJECT_SHADOW_SERIALIZATION) return "5.6"
95
+ if (ue5 >= UE5.METADATA_SERIALIZATION_OFFSET) return "5.5"
96
+ if (ue5 >= UE5.PROPERTY_TAG_EXTENSION_AND_OVERRIDABLE_SERIALIZATION) return "5.4"
97
+ if (ue5 >= UE5.DATA_RESOURCES) return "5.3"
98
+ if (ue5 >= UE5.FSOFTOBJECTPATH_REMOVE_ASSET_PATH_FNAMES) return "5.1"
99
+ return "5.0"
100
+ }