lecodes-assets 0.2.0 → 2.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +738 -449
  2. package/dist/cli.js +19760 -8612
  3. package/dist/index.js +19334 -8167
  4. package/package.json +15 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli-kit/command.ts +100 -0
  8. package/src/cli-kit/help.ts +72 -0
  9. package/src/cli-kit/index.ts +4 -0
  10. package/src/cli-kit/parse.ts +72 -0
  11. package/src/cli-kit/run.ts +52 -0
  12. package/src/cli.ts +24 -0
  13. package/src/commands/avatar.ts +77 -0
  14. package/src/commands/bvh.ts +26 -0
  15. package/src/commands/convert.ts +156 -0
  16. package/src/commands/doctor.ts +67 -0
  17. package/src/commands/geometry.ts +45 -0
  18. package/src/commands/index.ts +24 -0
  19. package/src/commands/marks.ts +38 -0
  20. package/src/commands/mirror.ts +35 -0
  21. package/src/commands/probe.ts +43 -0
  22. package/src/commands/retarget/donor.ts +63 -0
  23. package/src/commands/retarget/index.ts +169 -0
  24. package/src/commands/retarget/map.ts +81 -0
  25. package/src/commands/retarget/report.ts +77 -0
  26. package/src/commands/scene.ts +103 -0
  27. package/src/commands/shared.ts +121 -0
  28. package/src/commands/sky.ts +35 -0
  29. package/src/commands/terrainPack.ts +40 -0
  30. package/src/commands/unpack.ts +43 -0
  31. package/src/commands/unreal.ts +109 -0
  32. package/src/commands/unwrap.ts +56 -0
  33. package/src/doctor/cardTrim.ts +286 -0
  34. package/src/doctor/doctor.ts +305 -0
  35. package/src/doctor/lod.ts +177 -0
  36. package/src/doctor/merge/accessors.ts +30 -0
  37. package/src/doctor/merge/atlasLayout.ts +94 -0
  38. package/src/doctor/merge/atlasPixels.ts +114 -0
  39. package/src/doctor/merge/bindSpace.ts +59 -0
  40. package/src/doctor/merge/classify.ts +136 -0
  41. package/src/doctor/merge/geometry.ts +167 -0
  42. package/src/doctor/merge/index.ts +98 -0
  43. package/src/doctor/merge/matrix.ts +60 -0
  44. package/src/doctor/merge/plan.ts +88 -0
  45. package/src/doctor/merge/textures.ts +60 -0
  46. package/src/doctor/merge/types.ts +77 -0
  47. package/src/doctor/normalizeScale.ts +220 -0
  48. package/src/doctor/reduceGlbBones.ts +218 -0
  49. package/src/doctor/shading.ts +95 -0
  50. package/src/doctor/transcodeKtx2.ts +275 -0
  51. package/src/doctor/transcodeTextures.ts +101 -0
  52. package/src/doctor/validateGlb.ts +264 -0
  53. package/src/doctor/vertexAo.ts +321 -0
  54. package/src/fbx/convert.ts +327 -0
  55. package/src/fbx/read.ts +111 -0
  56. package/src/fbx/toDocument.ts +227 -0
  57. package/src/filmstrip.ts +263 -0
  58. package/src/geometry.ts +222 -0
  59. package/src/index.ts +19 -0
  60. package/src/mergeSkins.ts +117 -0
  61. package/src/probe.ts +167 -0
  62. package/src/props.ts +137 -0
  63. package/src/retarget/automap.ts +276 -0
  64. package/src/retarget/ghost.ts +23 -0
  65. package/src/retarget/heading.ts +238 -0
  66. package/src/retarget/humanoid.ts +572 -0
  67. package/src/retarget/inPlace.ts +130 -0
  68. package/src/retarget/marks.ts +581 -0
  69. package/src/retarget/mirror.ts +0 -0
  70. package/src/retarget/retarget.ts +2375 -0
  71. package/src/retarget/rig.ts +545 -0
  72. package/src/retarget/unityAnim.ts +171 -0
  73. package/src/retarget/writeMarks.ts +134 -0
  74. package/src/sky/analyze.ts +136 -0
  75. package/src/sky/cmgen.ts +71 -0
  76. package/src/sky/index.ts +200 -0
  77. package/src/terrainPack.ts +208 -0
  78. package/src/textures/resolveImage.ts +202 -0
  79. package/src/unity/colliderFit.ts +287 -0
  80. package/src/unity/fbxModel.ts +194 -0
  81. package/src/unity/foliage.ts +63 -0
  82. package/src/unity/gameObjects.ts +81 -0
  83. package/src/unity/guid.ts +104 -0
  84. package/src/unity/image.ts +58 -0
  85. package/src/unity/kindGlb.ts +364 -0
  86. package/src/unity/lecodesLevel.ts +381 -0
  87. package/src/unity/levelTerrain.ts +116 -0
  88. package/src/unity/matFile.ts +179 -0
  89. package/src/unity/materialSlots.ts +53 -0
  90. package/src/unity/materialWriter.ts +306 -0
  91. package/src/unity/materials.ts +198 -0
  92. package/src/unity/math.ts +82 -0
  93. package/src/unity/png.ts +75 -0
  94. package/src/unity/prefabTree.ts +285 -0
  95. package/src/unity/prefabs.ts +54 -0
  96. package/src/unity/propertyPath.ts +43 -0
  97. package/src/unity/scene.ts +384 -0
  98. package/src/unity/serialized.ts +242 -0
  99. package/src/unity/terrain.ts +221 -0
  100. package/src/unity/triBudget.ts +77 -0
  101. package/src/unity/triplanar.ts +122 -0
  102. package/src/unity/unityMesh.ts +151 -0
  103. package/src/unity/unpack.ts +149 -0
  104. package/src/unity/yaml.ts +131 -0
  105. package/src/unreal/anim.ts +284 -0
  106. package/src/unreal/clipSource.ts +25 -0
  107. package/src/unreal/convert.ts +67 -0
  108. package/src/unreal/convertAsset.ts +81 -0
  109. package/src/unreal/index.ts +115 -0
  110. package/src/unreal/list.ts +113 -0
  111. package/src/unreal/materialGraph.ts +224 -0
  112. package/src/unreal/materials.ts +399 -0
  113. package/src/unreal/mesh.ts +205 -0
  114. package/src/unreal/oodle.ts +153 -0
  115. package/src/unreal/package.ts +677 -0
  116. package/src/unreal/probe.ts +144 -0
  117. package/src/unreal/resolve.ts +53 -0
  118. package/src/unreal/rig.ts +149 -0
  119. package/src/unreal/sequencer.ts +193 -0
  120. package/src/unreal/skeleton.ts +74 -0
  121. package/src/unreal/sound.ts +128 -0
  122. package/src/unreal/texture.ts +269 -0
  123. package/src/unreal/toDocument.ts +367 -0
  124. package/src/unreal/versions.ts +108 -0
  125. package/src/unwrap/index.ts +167 -0
  126. package/src/unwrap/lightmapUv.ts +761 -0
  127. package/src/wasm/fbxReader.js +14 -0
  128. package/src/wasm/lightmapUv.js +14 -0
@@ -0,0 +1,284 @@
1
+ // AnimSequence → tracks. The raw, uncompressed bone curves an editor asset keeps in its
2
+ // `AnimDataModel` (§4.2): one track per animated bone, keyed by bone NAME, local space, UE frame,
3
+ // centimetres. `NumberOfKeys` = `NumberOfFrames` + 1 — key i sits at i / fps and the last key at
4
+ // `PlayLength`; a track with a single key is constant for the whole clip.
5
+ //
6
+ // Two data models exist in the wild and both are read here. `AnimDataModel` (the FBX-import
7
+ // default) keeps bone tracks; `AnimationSequencerDataModel` (UE 5.2+, a clip authored or edited in
8
+ // Sequencer) keeps the same motion as MovieScene ControlRig channels — `sequencer.ts` evaluates
9
+ // those onto the same grid, so callers never learn which one a file used.
10
+
11
+ import {
12
+ classOf, readProperties, readObjectProperties, readArrayHeader, readFloatArray,
13
+ propFloat, propInt, propName, propObject, propEnum, resolveObject, findExport,
14
+ type PropertyTag, type UePackage, type BinaryReader,
15
+ } from "./package"
16
+ import { readSequencerSection, rotatorToQuat, sampleChannel } from "./sequencer"
17
+
18
+ export type UeTrack = {
19
+ /** Bone name — the only binding key; `boneIndex` is the skeleton's own index, a cross-check. */
20
+ name: string
21
+ boneIndex: number
22
+ /** n × 3 (cm), n × 4 (x,y,z,w), n × 3 — each either `keys` long or 1 (constant), or empty. */
23
+ pos: Float32Array
24
+ rot: Float32Array
25
+ scale: Float32Array
26
+ }
27
+
28
+ export type UeClipEvent = { name: string, time: number, kind: "marker" | "notify" }
29
+
30
+ export type UeAnimSequence = {
31
+ name: string
32
+ fps: number
33
+ numFrames: number
34
+ numKeys: number
35
+ playLength: number
36
+ tracks: UeTrack[]
37
+ /** `/Game/…/SK_Mannequin` — the skeleton asset this clip was authored on (§6.3 resolves it). */
38
+ skeletonPath: string
39
+ skeletonName: string
40
+ rootMotion: boolean
41
+ /** `AAT_None` → null; anything else means the poses are DELTAS against a base pose. */
42
+ additive: string | null
43
+ interpolation: "Linear" | "Step"
44
+ rateScale: number
45
+ events: UeClipEvent[]
46
+ }
47
+
48
+ /** What an AnimSequence says about itself, tracks aside (`readAnimMeta`). */
49
+ export type UeAnimMeta = {
50
+ name: string
51
+ export: NonNullable<ReturnType<typeof findExport>>
52
+ props: ReturnType<typeof readProperties>
53
+ /** Positioned reader over the export — the caller may dig further into `props`. */
54
+ reader: BinaryReader
55
+ skeletonPath: string
56
+ skeletonName: string
57
+ rootMotion: boolean
58
+ additive: string | null
59
+ interpolation: "Linear" | "Step"
60
+ rateScale: number
61
+ events: UeClipEvent[]
62
+ sequenceLength: number
63
+ targetFps: number | undefined
64
+ dataModel: DataModelKind
65
+ }
66
+
67
+ const timeOfKey = (i: number, fps: number, numKeys: number, playLength: number): number =>
68
+ (numKeys <= 1 ? 0 : Math.min(i / fps, playLength))
69
+
70
+ /** Key times of a clip: i / fps, the last one clamped to PlayLength. */
71
+ export const keyTimes = (a: UeAnimSequence): Float32Array => {
72
+ const out = new Float32Array(a.numKeys)
73
+ for (let i = 0; i < a.numKeys; i++) out[i] = timeOfKey(i, a.fps, a.numKeys, a.playLength)
74
+ return out
75
+ }
76
+
77
+ /** `FrameRate` is a TAGGED struct — Denominator is omitted when it is 1. */
78
+ const readFrameRate = (r: BinaryReader, pkg: UePackage, tag: PropertyTag | undefined): number | undefined => {
79
+ if (!tag) return undefined
80
+ r.offset = tag.data
81
+ const p = readProperties(r, pkg)
82
+ const num = propInt(r, p.byName.get("Numerator"))
83
+ const den = propInt(r, p.byName.get("Denominator")) ?? 1
84
+ return num && den ? num / den : undefined
85
+ }
86
+
87
+ /** Array of TAGGED structs: `{ name, time }` pairs out of AnimSyncMarker / AnimNotifyEvent. */
88
+ const readEvents = (r: BinaryReader, pkg: UePackage, tag: PropertyTag | undefined, kind: "marker" | "notify"): UeClipEvent[] => {
89
+ if (!tag) return []
90
+ const { names } = pkg
91
+ const out: UeClipEvent[] = []
92
+ const { count } = readArrayHeader(r, pkg, tag)
93
+ for (let i = 0; i < count; i++) {
94
+ const p = readProperties(r, pkg)
95
+ const after = r.offset
96
+ const name = propName(r, names, p.byName.get("MarkerName")) ?? propName(r, names, p.byName.get("NotifyName")) ?? `${kind}${i}`
97
+ // AnimSyncMarker keeps `Time`; an AnimNotifyEvent's time is its linked value (older assets:
98
+ // DisplayTime). Whichever is present wins; the events are informational either way.
99
+ const time = propFloat(r, p.byName.get("Time")) ?? propFloat(r, p.byName.get("LinkValue")) ?? propFloat(r, p.byName.get("DisplayTime_DEPRECATED")) ?? 0
100
+ if (name !== "None") out.push({ name, time, kind })
101
+ r.offset = after
102
+ }
103
+ return out
104
+ }
105
+
106
+ /** One `RawAnimSequenceTrack` (tagged since UE5): PosKeys / RotKeys / ScaleKeys. */
107
+ const readTrackData = (r: BinaryReader, pkg: UePackage, tag: PropertyTag): { pos: Float32Array, rot: Float32Array, scale: Float32Array } => {
108
+ r.offset = tag.data
109
+ const p = readProperties(r, pkg)
110
+ const grab = (key: string, comps: number): Float32Array => {
111
+ const t = p.byName.get(key)
112
+ if (!t) return new Float32Array(0)
113
+ const a = readFloatArray(r, pkg, t)
114
+ if (a.comps !== comps) throw new Error(`${r.label}: ${key} holds ${a.comps}-component keys, expected ${comps}`)
115
+ return a.values
116
+ }
117
+ return { pos: grab("PosKeys", 3), rot: grab("RotKeys", 4), scale: grab("ScaleKeys", 3) }
118
+ }
119
+
120
+ /**
121
+ * A track whose keys are all the same value, collapsed to the one key UE would normally have
122
+ * stored for it. Some packs write a static pose as N identical keys with a key count that does
123
+ * not match the model's (the Archer pack's aim-offset cells: 2 identical keys, `NumberOfKeys 3`),
124
+ * and a constant track is a constant track whatever the count says. Anything that actually varies
125
+ * is left alone, so a real count mismatch still reaches the check below.
126
+ */
127
+ const collapseConstant = (a: Float32Array, comps: number): Float32Array => {
128
+ const n = a.length / comps
129
+ if (n < 2) return a
130
+ for (let k = comps; k < a.length; k++) if (Math.abs(a[k] - a[k % comps]) > 1e-6) return a
131
+ return a.slice(0, comps)
132
+ }
133
+
134
+ export type DataModelKind = "AnimDataModel" | "AnimationSequencerDataModel" | "none"
135
+
136
+ /** Which data model an AnimSequence uses — what decides whether it can be read at all. */
137
+ export const dataModelKind = (pkg: UePackage): DataModelKind => {
138
+ for (const e of pkg.exports) {
139
+ const c = classOf(pkg, e)
140
+ if (c === "AnimDataModel" || c === "AnimationSequencerDataModel") return c
141
+ }
142
+ return "none"
143
+ }
144
+
145
+ /**
146
+ * A Sequencer-authored clip: the motion sits in a MovieScene ControlRig section instead of bone
147
+ * tracks (`sequencer.ts`). The channels are evaluated on the clip's own frame grid, which produces
148
+ * exactly what `AnimDataModel` hands over — same bones, same key times, same units — so nothing
149
+ * downstream can tell the two apart.
150
+ */
151
+ const sequencerAnimSequence = (pkg: UePackage, meta: UeAnimMeta): UeAnimSequence => {
152
+ let section: ReturnType<typeof readSequencerSection>
153
+ try { section = readSequencerSection(pkg) } catch (e) {
154
+ throw new Error(`${pkg.label}: ${(e as Error).message}. ${SEQUENCER_MODEL_MESSAGE}`)
155
+ }
156
+ const fps = section.displayRate || meta.targetFps || 30
157
+ const playLength = meta.sequenceLength
158
+ const numFrames = Math.max(0, Math.round(playLength * fps))
159
+ const numKeys = numFrames + 1
160
+ const tracks: UeTrack[] = []
161
+ for (const c of section.controls) {
162
+ const animated = [ ...c.translation, ...c.rotation, ...c.scale ].some((ch) => ch && ch.times.length > 0)
163
+ const keys = animated ? numKeys : 1
164
+ const pos = new Float32Array(keys * 3), rot = new Float32Array(keys * 4), scale = new Float32Array(keys * 3)
165
+ for (let k = 0; k < keys; k++) {
166
+ const time = k / fps
167
+ for (let axis = 0; axis < 3; axis++) {
168
+ const t = c.translation[axis], s = c.scale[axis]
169
+ pos[k * 3 + axis] = t ? sampleChannel(t, time, 0) : 0
170
+ scale[k * 3 + axis] = s ? sampleChannel(s, time, 1) : 1
171
+ }
172
+ // rotation channels are an FRotator in degrees: [0] roll (X), [1] pitch (Y), [2] yaw (Z)
173
+ const e = [ 0, 1, 2 ].map((axis) => (c.rotation[axis] ? sampleChannel(c.rotation[axis]!, time, 0) : 0))
174
+ const q = rotatorToQuat(e[0], e[1], e[2])
175
+ rot.set(q, k * 4)
176
+ }
177
+ tracks.push({ name: c.bone, boneIndex: -1, pos, rot, scale })
178
+ }
179
+ return {
180
+ name: meta.name, fps, numFrames, numKeys, playLength,
181
+ tracks,
182
+ skeletonPath: meta.skeletonPath, skeletonName: meta.skeletonName,
183
+ rootMotion: meta.rootMotion, additive: meta.additive, interpolation: meta.interpolation,
184
+ rateScale: meta.rateScale, events: meta.events,
185
+ }
186
+ }
187
+
188
+ export const SEQUENCER_MODEL_MESSAGE =
189
+ "this clip uses the Sequencer data model, and its ControlRig section could not be read. " +
190
+ "Re-save the asset in UE with 'Animation Sequencer Data Model' disabled, or export it to FBX."
191
+
192
+ /**
193
+ * Everything an AnimSequence says about ITSELF — length, rate, root motion, sync markers, the
194
+ * skeleton it belongs to — without touching the data model that holds the tracks. Kept separate
195
+ * because a Sequencer-model clip's metadata is perfectly readable even though its motion is not:
196
+ * `probe` can then say what the clip is before saying why its tracks cannot be had.
197
+ */
198
+ export const readAnimMeta = (pkg: UePackage): UeAnimMeta => {
199
+ const found = findExport(pkg, "AnimSequence")
200
+ if (!found) throw new Error(`${pkg.label}: no AnimSequence export (exports: ${pkg.exports.map((e) => `${e.objectName}:${classOf(pkg, e)}`).join(", ")})`)
201
+ const { reader: r, props: seq } = readObjectProperties(pkg, found.export)
202
+ const skelRef = resolveObject(pkg, propObject(r, seq.byName.get("Skeleton")) ?? 0)
203
+ const additiveRaw = propEnum(r, pkg.names, seq.byName.get("AdditiveAnimType"))
204
+ const interpRaw = propEnum(r, pkg.names, seq.byName.get("Interpolation"))
205
+ return {
206
+ name: found.export.objectName,
207
+ export: found,
208
+ props: seq,
209
+ reader: r,
210
+ skeletonPath: skelRef?.packagePath ?? "",
211
+ skeletonName: skelRef?.name ?? "",
212
+ rootMotion: seq.byName.get("bEnableRootMotion")?.bool ?? false,
213
+ additive: additiveRaw && !/AAT_None|^0$/.test(additiveRaw) ? additiveRaw : null,
214
+ interpolation: interpRaw && /Step/i.test(interpRaw) ? "Step" : "Linear",
215
+ rateScale: propFloat(r, seq.byName.get("RateScale")) ?? 1,
216
+ events: [
217
+ ...readEvents(r, pkg, seq.byName.get("AuthoredSyncMarkers"), "marker"),
218
+ ...readEvents(r, pkg, seq.byName.get("Notifies"), "notify"),
219
+ ].sort((a, b) => a.time - b.time),
220
+ sequenceLength: propFloat(r, seq.byName.get("SequenceLength")) ?? 0,
221
+ targetFps: readFrameRate(r, pkg, seq.byName.get("TargetFrameRate")),
222
+ dataModel: dataModelKind(pkg),
223
+ }
224
+ }
225
+
226
+ /** The `AnimSequence` export of a package (an `A_*.uasset`), with its raw tracks. */
227
+ export const readAnimSequence = (pkg: UePackage): UeAnimSequence => {
228
+ const meta = readAnimMeta(pkg)
229
+ const { reader: r, props: seq, rootMotion, additive, interpolation, rateScale, events, sequenceLength, targetFps } = meta
230
+
231
+ // Which data model: the DataModel property points at the export that holds the tracks; a 5.3
232
+ // sequencer asset instead carries DataModelInterface and a MovieScene export.
233
+ const modelRef = resolveObject(pkg, propObject(r, seq.byName.get("DataModel")) ?? 0)
234
+ const kind = meta.dataModel
235
+ if (kind === "AnimationSequencerDataModel") return sequencerAnimSequence(pkg, meta)
236
+ const model = modelRef?.kind === "export" ? pkg.exports[modelRef.index] : findExport(pkg, "AnimDataModel")?.export
237
+ if (!model) throw new Error(`${pkg.label}: the AnimSequence names no readable data model (found ${kind})`)
238
+
239
+ const { reader: mr, props: m } = readObjectProperties(pkg, model)
240
+ const playLength = propFloat(mr, m.byName.get("PlayLength")) ?? sequenceLength
241
+ const numFrames = propInt(mr, m.byName.get("NumberOfFrames")) ?? 0
242
+ const numKeys = propInt(mr, m.byName.get("NumberOfKeys")) ?? numFrames + 1
243
+ const fps = readFrameRate(mr, pkg, m.byName.get("FrameRate")) ?? targetFps ?? (playLength > 0 ? numFrames / playLength : 30)
244
+
245
+ const tracks: UeTrack[] = []
246
+ const tracksTag = m.byName.get("BoneAnimationTracks")
247
+ if (tracksTag) {
248
+ const { count } = readArrayHeader(mr, pkg, tracksTag)
249
+ for (let i = 0; i < count; i++) {
250
+ const el = readProperties(mr, pkg)
251
+ const after = mr.offset
252
+ const name = propName(mr, pkg.names, el.byName.get("Name")) ?? ""
253
+ const boneIndex = propInt(mr, el.byName.get("BoneTreeIndex")) ?? -1
254
+ const data = el.byName.get("InternalTrackData")
255
+ const keys = data ? readTrackData(mr, pkg, data) : { pos: new Float32Array(0), rot: new Float32Array(0), scale: new Float32Array(0) }
256
+ for (const [ key, comps, field ] of [ [ "PosKeys", 3, "pos" ], [ "RotKeys", 4, "rot" ], [ "ScaleKeys", 3, "scale" ] ] as const) {
257
+ let arr = keys[field]
258
+ let n = arr.length / comps
259
+ // an off-count track that never actually varies is a constant one written long-hand
260
+ if (n !== 0 && n !== 1 && n !== numKeys) { arr = collapseConstant(arr, comps); n = arr.length / comps; keys[field] = arr }
261
+ if (n !== 0 && n !== 1 && n !== numKeys) {
262
+ throw new Error(`${pkg.label}: bone "${name}" has ${n} ${key} for a ${numKeys}-key clip — unexpected key count`)
263
+ }
264
+ }
265
+ if (name) tracks.push({ name, boneIndex, ...keys })
266
+ mr.offset = after
267
+ }
268
+ }
269
+
270
+ return {
271
+ name: meta.name,
272
+ fps, numFrames, numKeys, playLength: playLength || (numFrames && fps ? numFrames / fps : 0),
273
+ tracks,
274
+ skeletonPath: meta.skeletonPath,
275
+ skeletonName: meta.skeletonName,
276
+ rootMotion, additive, interpolation, rateScale, events,
277
+ }
278
+ }
279
+
280
+ /** Cheap classification for `probe` / listings: what an export table says a package holds. */
281
+ export const packageKind = (pkg: UePackage): "AnimSequence" | "SkeletalMesh" | "Skeleton" | "Texture2D" | "other" => {
282
+ for (const want of [ "AnimSequence", "SkeletalMesh", "Skeleton", "Texture2D" ] as const) if (findExport(pkg, want)) return want
283
+ return "other"
284
+ }
@@ -0,0 +1,25 @@
1
+ // An Unreal AnimSequence as a `ClipSource` — the shape `mergeClipScene` merges into an existing
2
+ // model by bone name. Same conversion as the document builder uses (`unrealClipTracks`), so a
3
+ // `.uasset` clip lands identically whether it is written out on its own skeleton or merged onto a
4
+ // character that came from somewhere else.
5
+ //
6
+ // `isRoot` is false for every node: it marks ufbx's synthetic scene root, which Unreal data has
7
+ // none of — UE's `root` is a real bone whose track must be merged like any other.
8
+
9
+ import type { ClipSource } from "../fbx/convert"
10
+ import type { UeSkeleton } from "./skeleton"
11
+ import type { UeAnimSequence } from "./anim"
12
+ import { unrealClipTracks, clipExtras } from "./toDocument"
13
+ import { ueLocalTransform } from "./convert"
14
+
15
+ export const clipSourceFromUnreal = (skeleton: UeSkeleton, clip: UeAnimSequence): ClipSource => {
16
+ const { tracks } = unrealClipTracks(skeleton, clip)
17
+ return {
18
+ nodes: skeleton.bones.map((b) => {
19
+ const l = ueLocalTransform(b.t, b.q, b.s, b.parent < 0)
20
+ return { name: b.name, parent: b.parent, t: l.t, r: l.r, s: l.s, isRoot: false }
21
+ }),
22
+ animations: [ { name: clip.name, duration: clip.playLength, tracks } ],
23
+ extras: () => clipExtras(clip),
24
+ }
25
+ }
@@ -0,0 +1,67 @@
1
+ // UE frame → glTF frame. The ONE place the axis/unit convention lives.
2
+ //
3
+ // Unreal is centimetres, X-forward / Y-right / Z-up, LEFT-handed; glTF is metres, Y-up,
4
+ // right-handed. The recipe below is not a free choice: a GLB made from a `.uasset` has to be
5
+ // interchangeable with the GLB the FBX route makes from the same pack's FBX sources, because both
6
+ // feed `--clips` merging by bone name and the retargeter. UE's FBX exporter mirrors Y (left-handed
7
+ // → right-handed Z-up) and ufbx then turns Z-up into Y-up by rotating the root −90° about X, so
8
+ // that is exactly what is reproduced here:
9
+ //
10
+ // S = 0.01 (cm → m). M = mirror Y. Rx = −90° about X, qRx = (−√½, 0, 0, √½).
11
+ // non-root bone (local transform and every local animation key):
12
+ // t' = ( tx, −ty, tz ) · S
13
+ // q' = ( −qx, qy, −qz, qw ) // M q M⁻¹ for an improper M: axis mirrored, angle negated
14
+ // root bone (local = world):
15
+ // t' = Rx · ( tx, −ty, tz ) · S = ( tx, tz, ty ) · S
16
+ // q' = qRx ⊗ ( −qx, qy, −qz, qw )
17
+ // mesh points / normals (already in the skeleton's world = bind space):
18
+ // p' = ( px, pz, py ) · S ; n' = ( nx, nz, ny )
19
+ //
20
+ // Verified numerically against the FBX route on all 267 channels of a real clip: worst translation
21
+ // error 1.1e-7 m, worst rotation 0.30° (the FBX route's own key reduction). docs/unreal-assets-plan.md §5.
22
+
23
+ export type Vec3 = [ number, number, number ]
24
+ export type Quat = [ number, number, number, number ]
25
+
26
+ /** cm → m. */
27
+ export const UE_SCALE = 0.01
28
+
29
+ const R2 = Math.SQRT1_2
30
+ /** −90° about X: the Z-up → Y-up turn, applied to root bones only. */
31
+ export const Q_ZUP_TO_YUP: Quat = [ -R2, 0, 0, R2 ]
32
+
33
+ /** Hamilton product (x, y, z, w). */
34
+ export const qmul = (a: Quat, b: Quat): Quat => [
35
+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
36
+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
37
+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
38
+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
39
+ ]
40
+
41
+ /** A UE translation in a bone's LOCAL frame → glTF, metres. */
42
+ export const ueTranslation = (x: number, y: number, z: number): Vec3 => [ x * UE_SCALE, -y * UE_SCALE, z * UE_SCALE ]
43
+
44
+ /** A UE rotation in a bone's LOCAL frame → glTF (the Y mirror flips the axis and the angle). */
45
+ export const ueQuat = (x: number, y: number, z: number, w: number): Quat => [ -x, y, -z, w ]
46
+
47
+ /** A ROOT bone's translation → glTF: the mirror, then the Z-up → Y-up turn. */
48
+ export const ueRootTranslation = (x: number, y: number, z: number): Vec3 => [ x * UE_SCALE, z * UE_SCALE, y * UE_SCALE ]
49
+
50
+ /** A ROOT bone's rotation → glTF. */
51
+ export const ueRootQuat = (x: number, y: number, z: number, w: number): Quat => qmul(Q_ZUP_TO_YUP, ueQuat(x, y, z, w))
52
+
53
+ /** A point/vector already in the skeleton's WORLD (bind) space → glTF, metres. */
54
+ export const ueWorldPoint = (x: number, y: number, z: number): Vec3 => [ x * UE_SCALE, z * UE_SCALE, y * UE_SCALE ]
55
+
56
+ /** A direction already in the skeleton's WORLD space (a normal) → glTF; length preserved. */
57
+ export const ueWorldDir = (x: number, y: number, z: number): Vec3 => [ x, z, y ]
58
+
59
+ /** UE scale is per-axis and handedness-free. */
60
+ export const ueScale = (x: number, y: number, z: number): Vec3 => [ x, y, z ]
61
+
62
+ /** Local transform of one bone (root or not) → glTF. */
63
+ export const ueLocalTransform = (t: Vec3 | number[], q: Quat | number[], s: Vec3 | number[], isRoot: boolean): { t: Vec3, r: Quat, s: Vec3 } => ({
64
+ t: isRoot ? ueRootTranslation(t[0], t[1], t[2]) : ueTranslation(t[0], t[1], t[2]),
65
+ r: isRoot ? ueRootQuat(q[0], q[1], q[2], q[3]) : ueQuat(q[0], q[1], q[2], q[3]),
66
+ s: ueScale(s[0], s[1], s[2]),
67
+ })
@@ -0,0 +1,81 @@
1
+ // `convert <x.uasset>` — the asset-level entry point, the counterpart of `convertFbx`.
2
+ //
3
+ // SkeletalMesh → mesh + skin + its own bone list
4
+ // Skeleton → the skeleton alone
5
+ // AnimSequence → the skeleton it was authored on (found in the pack, §6.3) + that clip
6
+ //
7
+ // `--clips` files (FBX or `.uasset`) are merged onto the result by bone name through the same
8
+ // `mergeClipScene` the FBX route uses.
9
+
10
+ import { basename } from "node:path"
11
+ import type { Document } from "@gltf-transform/core"
12
+ import { mergeClipScene, loadClipSource, type ConvertReport } from "../fbx/convert"
13
+ import type { ConvertWarning } from "../fbx/toDocument"
14
+ import { loadUnreal, type LoadUnrealOptions } from "./index"
15
+ import { unrealToDocument } from "./toDocument"
16
+ import { meshBounds } from "./mesh"
17
+ import { applyUnrealTextures } from "./materials"
18
+
19
+ export type ConvertUnrealOptions = LoadUnrealOptions & {
20
+ clips?: string[]
21
+ clipNames?: string[]
22
+ rename?: Record<string, string>
23
+ dropModelClips?: boolean
24
+ /** Pull the pack's textures onto the materials (default: on when a decoder is available). */
25
+ textures?: boolean
26
+ /** Longest texture edge kept (default 2048, 0 = the source's own size). */
27
+ maxSize?: number
28
+ /** UE authors normal maps DirectX-style; glTF wants OpenGL. Default: flip. */
29
+ flipNormalGreen?: boolean
30
+ /** Last-resort object-path lookup for hosts with no pack on disk (see `TextureOptions`). */
31
+ resolveAsset?: (objectPath: string) => string | null
32
+ warn?: (w: ConvertWarning) => void
33
+ }
34
+
35
+ export type ConvertUnrealResult = {
36
+ doc: Document
37
+ report: { clips: ConvertReport["clips"] }
38
+ /** One line for the CLI: what was read and where its skeleton came from. */
39
+ info: string
40
+ textures?: Awaited<ReturnType<typeof applyUnrealTextures>>
41
+ }
42
+
43
+ export const convertUnreal = async (path: string, opts: ConvertUnrealOptions = {}): Promise<ConvertUnrealResult> => {
44
+ const warn = opts.warn ?? (() => {})
45
+ const u = loadUnreal(path, opts)
46
+ const own = u.anim && !opts.dropModelClips ? [ u.anim ] : []
47
+ const r = unrealToDocument({ skeleton: u.skeleton, mesh: u.mesh, clips: own }, { warn })
48
+
49
+ const clips: ConvertReport["clips"] = r.bound.map((b, i) => ({
50
+ name: b.name, source: basename(path), duration: own[i]?.playLength ?? 0, tracks: b.tracks, bound: b.bound,
51
+ }))
52
+ for (let i = 0; i < (opts.clips ?? []).length; i++) {
53
+ const file = opts.clips![i]
54
+ const source = await loadClipSource(file, { skeleton: opts.skeleton, contentRoot: opts.contentRoot })
55
+ clips.push(...mergeClipScene(r.doc, source, basename(file), opts.clipNames?.[i], opts.rename, warn))
56
+ }
57
+
58
+ // Textures: the pack's material instances name them, and the source images sit Oodle-compressed
59
+ // inside the texture assets (see `materials.ts` / `oodle.ts`). Without a decoder the materials
60
+ // stay the grey slots the document was built with — the geometry is unaffected either way.
61
+ let textures: Awaited<ReturnType<typeof applyUnrealTextures>> | undefined
62
+ if (u.mesh && opts.textures !== false && u.mesh.materials.length) {
63
+ textures = await applyUnrealTextures(r.doc, path, u.mesh.materials, {
64
+ maxSize: opts.maxSize, flipNormalGreen: opts.flipNormalGreen, contentRoot: opts.contentRoot, resolveAsset: opts.resolveAsset, warn,
65
+ })
66
+ }
67
+
68
+ const parts = [ `${u.engine} · ${u.kind === "anim" ? "AnimSequence" : u.kind === "mesh" ? "SkeletalMesh" : "Skeleton"} · skeleton ${u.skeleton.name} (${u.skeleton.bones.length} bones)` ]
69
+ if (u.skeletonFile !== path) parts.push(`from ${basename(u.skeletonFile)}`)
70
+ if (u.mesh) {
71
+ const b = meshBounds(u.mesh)
72
+ parts.push(`LOD ${u.mesh.lod} of ${u.mesh.lods.length} · ${u.mesh.faceWedges.length / 3} tris · ${((b.max[2] - b.min[2]) / 100).toFixed(2)} m tall`)
73
+ if (r.flippedWinding) parts.push("winding flipped (mirror)")
74
+ const points = u.mesh.points.length / 3
75
+ if (r.droppedWeight > 0.005 * points) parts.push(`skin trimmed to 4 bones/vertex (${(100 * r.droppedWeight / points).toFixed(1)} % of weight redistributed)`)
76
+ }
77
+ if (textures?.textures) parts.push(`${textures.textures} texture(s) from ${textures.slots.filter((s) => s.roles.length).length} material instance(s)`)
78
+ if (u.anim?.additive) parts.push(`ADDITIVE (${u.anim.additive}) — poses are deltas`)
79
+ if (u.anim?.rootMotion) parts.push("root motion")
80
+ return { doc: r.doc, report: { clips }, info: parts.join(" · "), textures }
81
+ }
@@ -0,0 +1,115 @@
1
+ // One call for the CLI: a `.uasset` path in, everything the pipeline needs out — the asset's kind,
2
+ // its skeleton (resolved from the pack's Content folder when the file is a clip), the clip, the
3
+ // mesh. Skeletons are cached per path because 42 clips of a pack share one.
4
+
5
+ import { readFileSync } from "node:fs"
6
+ import { basename, dirname } from "node:path"
7
+ import { readPackage, engineLabel, classOf, importPackagePath, type UePackage, type ReadPackageOptions } from "./package"
8
+ import { readSkeleton, type UeSkeleton } from "./skeleton"
9
+ import { readAnimSequence, dataModelKind, packageKind, type UeAnimSequence, type DataModelKind } from "./anim"
10
+ import { readSkeletalMesh, type UeSkeletalMesh } from "./mesh"
11
+ import { resolveObjectFile, searchedRoots } from "./resolve"
12
+ import { skeletonFromMesh } from "./toDocument"
13
+
14
+ export type UnrealKind = "anim" | "skeleton" | "mesh"
15
+
16
+ export type LoadUnrealOptions = {
17
+ /** Explicit skeleton asset (`SK_*.uasset`, or a `SKM_*.uasset` whose bone list is used). */
18
+ skeleton?: string
19
+ /** The pack's Content folder, when the walk-up search cannot find it. */
20
+ contentRoot?: string
21
+ /** Mesh LOD to read (default 0). */
22
+ lod?: number
23
+ }
24
+
25
+ export type LoadedUnreal = {
26
+ kind: UnrealKind
27
+ path: string
28
+ package: UePackage
29
+ engine: string
30
+ dataModel: DataModelKind
31
+ skeleton: UeSkeleton
32
+ /** Where the skeleton came from — this file, or the asset an object path named. */
33
+ skeletonFile: string
34
+ anim?: UeAnimSequence
35
+ mesh?: UeSkeletalMesh
36
+ }
37
+
38
+ const packageCache = new Map<string, UePackage>()
39
+ const skeletonCache = new Map<string, UeSkeleton>()
40
+
41
+ export const readPackageFile = (path: string, opts: ReadPackageOptions = {}): UePackage => {
42
+ const key = opts.ue4 ? `${path}\0ue4` : path
43
+ const hit = packageCache.get(key)
44
+ if (hit) return hit
45
+ const pkg = readPackage(new Uint8Array(readFileSync(path)), basename(path), opts)
46
+ packageCache.set(key, pkg)
47
+ return pkg
48
+ }
49
+
50
+ /** A skeleton out of a `Skeleton` asset, or out of a `SkeletalMesh`'s own bone list. */
51
+ export const readSkeletonFile = (path: string): UeSkeleton => {
52
+ const hit = skeletonCache.get(path)
53
+ if (hit) return hit
54
+ const pkg = readPackageFile(path)
55
+ const kind = packageKind(pkg)
56
+ const sk = kind === "Skeleton" ? readSkeleton(pkg)
57
+ : kind === "SkeletalMesh" ? skeletonFromMesh(readSkeletalMesh(pkg))
58
+ : (() => { throw new Error(`${basename(path)}: not a Skeleton or SkeletalMesh asset (${pkg.exports.map((e) => classOf(pkg, e)).join(", ")})`) })()
59
+ skeletonCache.set(path, sk)
60
+ return sk
61
+ }
62
+
63
+ export const loadUnreal = (path: string, opts: LoadUnrealOptions = {}): LoadedUnreal => {
64
+ const pkg = readPackageFile(path)
65
+ const engine = engineLabel(pkg.summary)
66
+ const dataModel = dataModelKind(pkg)
67
+ const kind = packageKind(pkg)
68
+
69
+ if (kind === "AnimSequence") {
70
+ const anim = readAnimSequence(pkg)
71
+ let skeletonFile = opts.skeleton
72
+ if (!skeletonFile) {
73
+ const found = anim.skeletonPath ? resolveObjectFile(anim.skeletonPath, dirname(path), opts.contentRoot) : null
74
+ if (!found) {
75
+ throw new Error(
76
+ `${basename(path)}: cannot find the skeleton asset "${anim.skeletonPath || "(unnamed)"}" this clip was authored on. ` +
77
+ `Looked for it in ${searchedRoots(dirname(path)).slice(0, 4).map((d) => `${d}${anim.skeletonPath.replace("/Game", "")}.uasset`).join(", ")}… ` +
78
+ `— point at it with --skeleton <SK_*.uasset>, or name the pack's Content folder with --content-root <dir>.`,
79
+ )
80
+ }
81
+ skeletonFile = found.file
82
+ }
83
+ return { kind: "anim", path, package: pkg, engine, dataModel, skeleton: readSkeletonFile(skeletonFile), skeletonFile, anim }
84
+ }
85
+
86
+ if (kind === "SkeletalMesh") {
87
+ const mesh = readSkeletalMesh(pkg, { lod: opts.lod })
88
+ return { kind: "mesh", path, package: pkg, engine, dataModel, skeleton: skeletonFromMesh(mesh), skeletonFile: path, mesh }
89
+ }
90
+
91
+ if (kind === "Skeleton") {
92
+ return { kind: "skeleton", path, package: pkg, engine, dataModel, skeleton: readSkeleton(pkg), skeletonFile: path }
93
+ }
94
+
95
+ throw new Error(`${basename(path)}: holds no skeleton, mesh or animation (${engine}; exports: ${pkg.exports.map((e) => `${e.objectName}:${classOf(pkg, e)}`).slice(0, 6).join(", ")})`)
96
+ }
97
+
98
+ export const isUasset = (path: string): boolean => /\.uasset$/i.test(path)
99
+
100
+ /**
101
+ * The `/Game/...` path of the skeleton an asset belongs to, read off the IMPORT TABLE alone — no
102
+ * geometry, no tracks. A clip and the meshes bound to one rig all name the same path, which is what
103
+ * groups a pack (and what lets a flat file store find the skeleton a dropped clip needs).
104
+ */
105
+ export const skeletonPathOf = (pkg: UePackage): string => {
106
+ for (let i = 0; i < pkg.imports.length; i++) if (pkg.imports[i].className === "Skeleton") return importPackagePath(pkg, i)
107
+ return pkg.summary.packageName.startsWith("/") ? pkg.summary.packageName : ""
108
+ }
109
+
110
+ export { readPackage, engineLabel, classOf } from "./package"
111
+ export { readSkeleton, type UeSkeleton, type UeBone } from "./skeleton"
112
+ export { readAnimSequence, readAnimMeta, packageKind, dataModelKind, keyTimes, type UeAnimSequence, type UeAnimMeta, type UeTrack } from "./anim"
113
+ export { readSkeletalMesh, meshBounds, type UeSkeletalMesh } from "./mesh"
114
+ export { unrealToDocument, skeletonFromMesh, boneWorldMatrices, type UnrealDocumentResult } from "./toDocument"
115
+ export { resolveObjectFile, objectPathToFile } from "./resolve"