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,144 @@
1
+ // `probe <x.uasset>` — what an Unreal asset holds, before converting anything: engine version,
2
+ // class, data model, the skeleton (and WHICH FILE it was found in), a clip's keys/rate/root travel,
3
+ // a mesh's LODs, counts and size. Anything the reader cannot handle is printed as the reason, not
4
+ // thrown — the point of probing a pack is to find out which assets are usable.
5
+
6
+ import { basename, dirname } from "node:path"
7
+ import { engineLabel, classOf, type UePackage } from "./package"
8
+ import { readSkeleton, rootBones, type UeSkeleton } from "./skeleton"
9
+ import { readAnimSequence, readAnimMeta, dataModelKind, packageKind, type UeAnimSequence } from "./anim"
10
+ import { readSkeletalMesh, meshBounds, type UeSkeletalMesh } from "./mesh"
11
+ import { readPackageFile, readSkeletonFile } from "./index"
12
+ import { resolveObjectFile } from "./resolve"
13
+ import { UE_SCALE } from "./convert"
14
+ import { readTexture2D, findCompressedBuffer } from "./texture"
15
+ import { findOodle } from "./oodle"
16
+ import { resolveSlotMaterials, readMaterialTextures, classifyTexture } from "./materials"
17
+
18
+ const f = (n: number, d = 2): string => n.toFixed(d)
19
+
20
+ /** Where a clip's root ends up travelling, in the glTF frame (X right, Y up, Z forward). */
21
+ export const rootTravel = (anim: UeAnimSequence, skeleton: UeSkeleton): { size: [ number, number, number ], up: number, fwd: number } | null => {
22
+ const roots = new Set(rootBones(skeleton).map((i) => skeleton.bones[i].name))
23
+ const track = anim.tracks.find((t) => roots.has(t.name) && t.pos.length >= 6)
24
+ if (!track) return null
25
+ const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
26
+ for (let i = 0; i < track.pos.length; i += 3) {
27
+ for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], track.pos[i + k]); hi[k] = Math.max(hi[k], track.pos[i + k]) }
28
+ }
29
+ const n = track.pos.length / 3
30
+ // UE root local = world; the glTF frame turns (x, y, z)cm into (x, z, y)m
31
+ const size: [ number, number, number ] = [ (hi[0] - lo[0]) * UE_SCALE, (hi[2] - lo[2]) * UE_SCALE, (hi[1] - lo[1]) * UE_SCALE ]
32
+ return { size, up: (track.pos[(n - 1) * 3 + 2] - track.pos[2]) * UE_SCALE, fwd: (track.pos[(n - 1) * 3 + 1] - track.pos[1]) * UE_SCALE }
33
+ }
34
+
35
+ const skeletonLine = (sk: UeSkeleton, file: string, from: string): string => {
36
+ const roots = rootBones(sk).map((i) => sk.bones[i].name)
37
+ return ` skeleton ${sk.name} (${sk.bones.length} bones, root${roots.length > 1 ? "s" : ""}: ${roots.join(", ") || "none"})${file !== from ? ` ← ${basename(file)}` : ""}`
38
+ }
39
+
40
+ const animLines = (anim: UeAnimSequence, sk: UeSkeleton): string[] => {
41
+ const out: string[] = []
42
+ const travel = rootTravel(anim, sk)
43
+ out.push(` ${anim.tracks.length}/${sk.bones.length} bones animated · ${anim.numKeys} keys @ ${+anim.fps.toFixed(3)} fps · ${f(anim.playLength, 3)} s · ${anim.interpolation.toLowerCase()} interpolation${anim.rateScale !== 1 ? ` · rate ×${anim.rateScale}` : ""}`)
44
+ out.push(` root motion: ${travel ? `travels ${f(travel.size[0])}×${f(travel.size[1])}×${f(travel.size[2])} m (up ${f(travel.up)}, fwd ${f(travel.fwd)})` : "root not animated"} · bEnableRootMotion ${anim.rootMotion ? "ON" : "off"}`)
45
+ if (anim.additive) out.push(` ⚠ ADDITIVE clip (${anim.additive}) — its poses are DELTAS against a base pose, not a playable pose`)
46
+ if (anim.events.length) out.push(` events ${anim.events.length}: ${anim.events.slice(0, 8).map((e) => `${e.name}@${f(e.time, 2)}s`).join(", ")}${anim.events.length > 8 ? "…" : ""}`)
47
+ const missing = anim.tracks.filter((t) => !sk.bones.some((b) => b.name === t.name)).map((t) => t.name)
48
+ if (missing.length) out.push(` ⚠ ${missing.length} track(s) name a bone this skeleton lacks: ${missing.slice(0, 6).join(", ")}`)
49
+ return out
50
+ }
51
+
52
+ const meshLines = (mesh: UeSkeletalMesh): string[] => {
53
+ const b = meshBounds(mesh)
54
+ const dims = [ (b.max[0] - b.min[0]) * UE_SCALE, (b.max[2] - b.min[2]) * UE_SCALE, (b.max[1] - b.min[1]) * UE_SCALE ]
55
+ return [
56
+ ` LODs ${mesh.lods.length}: ${mesh.lods.map((l) => `${l.index}${l.hasSource ? `=${(l.bytes / 1e6).toFixed(1)}MB${l.compressed ? " zlib" : " raw"}` : "=no source"}`).join(", ")} (reading LOD ${mesh.lod}, import-data v${mesh.version})`,
57
+ ` ${mesh.faceWedges.length / 3} tris · ${mesh.points.length / 3} points / ${mesh.wedgePoint.length} wedges · ${mesh.influences.length} influences · ${mesh.bones.length} bones · size ${dims.map((d) => f(d, 3)).join("×")} m${mesh.hasUv1 ? " · UV1" : ""}`,
58
+ ` materials ${mesh.materials.length}: ${mesh.materials.join(", ") || "none"}`,
59
+ ]
60
+ }
61
+
62
+ /** Per slot: the material asset it uses and the textures that material names. */
63
+ const materialLines = (pkg: UePackage, slots: string[], meshPath: string): string[] => {
64
+ const out: string[] = []
65
+ const assets = resolveSlotMaterials(pkg, slots)
66
+ slots.forEach((slot, i) => {
67
+ const assetPath = assets[i]
68
+ if (!assetPath) { out.push(` ${slot}: no material asset referenced`); return }
69
+ const file = resolveObjectFile(assetPath, dirname(meshPath))
70
+ const name = assetPath.split("/").pop()
71
+ if (!file) { out.push(` ${slot} ← ${name} (asset not found next to the mesh)`); return }
72
+ try {
73
+ const params = readMaterialTextures(readPackageFile(file.file))
74
+ const roles = params.map((t) => `${t.asset}${classifyTexture(t.param, t.asset) !== "other" ? ` [${classifyTexture(t.param, t.asset)}]` : ""}`)
75
+ out.push(` ${slot} ← ${name}: ${roles.join(", ") || "no textures"}`)
76
+ } catch (e) { out.push(` ${slot} ← ${name}: ${(e as Error).message}`) }
77
+ })
78
+ return out
79
+ }
80
+
81
+ export const probeUnreal = async (path: string): Promise<string> => {
82
+ const lines: string[] = []
83
+ let pkg: UePackage
84
+ try { pkg = readPackageFile(path) } catch (e) { return `UASSET ${path}\n ✗ ${(e as Error).message}` }
85
+ const kind = packageKind(pkg)
86
+ void findCompressedBuffer
87
+ const model = dataModelKind(pkg)
88
+ lines.push(`UASSET ${path}`)
89
+ lines.push(` ${engineLabel(pkg.summary)} (ue4 ${pkg.summary.ue4}, ue5 ${pkg.summary.ue5}) · ${kind === "other" ? pkg.exports.map((e) => classOf(pkg, e)).slice(0, 4).join(", ") : kind}${model !== "none" ? ` · data model ${model}` : ""}`)
90
+
91
+ let skeleton: UeSkeleton | undefined
92
+ let anim: UeAnimSequence | undefined
93
+ let mesh: UeSkeletalMesh | undefined
94
+ try {
95
+ if (kind === "AnimSequence") {
96
+ anim = readAnimSequence(pkg)
97
+ const found = anim.skeletonPath ? resolveObjectFile(anim.skeletonPath, dirname(path)) : null
98
+ lines.push(` authored on ${anim.skeletonName || "?"} (${anim.skeletonPath || "no skeleton named"})`)
99
+ if (found) { skeleton = readSkeletonFile(found.file); lines.push(skeletonLine(skeleton, found.file, path)) }
100
+ else lines.push(` ⚠ that skeleton asset is not next to this file — pass --skeleton <SK_*.uasset> to convert it`)
101
+ if (skeleton) lines.push(...animLines(anim, skeleton))
102
+ } else if (kind === "SkeletalMesh") {
103
+ mesh = readSkeletalMesh(pkg)
104
+ skeleton = { name: mesh.skeletonName || mesh.name, bones: mesh.bones.map((b) => ({ name: b.name, parent: b.parent, t: b.t, q: b.q, s: b.s })) }
105
+ lines.push(` bound to ${mesh.skeletonName || "?"} (${mesh.skeletonPath || "no skeleton named"})`)
106
+ lines.push(...meshLines(mesh))
107
+ lines.push(...materialLines(pkg, mesh.materials, path))
108
+ } else if (kind === "Texture2D") {
109
+ const t = readTexture2D(pkg)
110
+ lines.push(` ${t.source.width}×${t.source.height} · ${t.source.format || "format?"} · ${t.source.compression || "uncompressed"} · ${t.compressionSettings}${t.srgb ? " · sRGB" : ""}${t.sourceFile ? ` · from ${t.sourceFile}` : ""}`)
111
+ const buf = findCompressedBuffer(pkg)
112
+ lines.push(buf
113
+ ? ` source payload: ${(buf.rawSize / 1e6).toFixed(1)} MB in ${buf.blocks.length} block(s), ${buf.method === 0 ? "stored" : buf.method === 3 ? "Oodle" : `method ${buf.method}`}${buf.method === 3 ? ` (${findOodle() ? "decoder found" : "NO decoder — set LECODES_OODLE"})` : ""}`
114
+ : " ⚠ no source payload in this package")
115
+ return lines.join("\n")
116
+ } else if (kind === "Skeleton") {
117
+ skeleton = readSkeleton(pkg)
118
+ lines.push(skeletonLine(skeleton, path, path))
119
+ } else {
120
+ lines.push(` nothing this pipeline reads (exports: ${pkg.exports.map((e) => `${e.objectName}:${classOf(pkg, e)}`).slice(0, 6).join(", ")})`)
121
+ }
122
+ } catch (e) {
123
+ lines.push(` ✗ ${(e as Error).message}`)
124
+ // A Sequencer-model clip (or any clip whose tracks fail) still describes itself: length, rate,
125
+ // root motion and the sync markers all live on the AnimSequence, not in the data model.
126
+ if (kind === "AnimSequence") {
127
+ try {
128
+ const meta = readAnimMeta(pkg)
129
+ lines.push(` the clip itself: ${f(meta.sequenceLength, 3)} s${meta.targetFps ? ` @ ${+meta.targetFps.toFixed(3)} fps` : ""} · authored on ${meta.skeletonName || "?"} (${meta.skeletonPath || "unnamed"})${meta.rootMotion ? " · root motion ON" : ""}${meta.additive ? ` · ADDITIVE (${meta.additive})` : ""}`)
130
+ if (meta.events.length) lines.push(` events ${meta.events.length}: ${meta.events.slice(0, 8).map((x) => `${x.kind === "marker" ? "sync" : "notify"} ${x.name}@${f(x.time, 2)}s`).join(", ")}${meta.events.length > 8 ? "…" : ""}`)
131
+ } catch { /* the export itself is unreadable — the message above already says so */ }
132
+ }
133
+ return lines.join("\n")
134
+ }
135
+
136
+ // …and what it would convert to, through the normal GLB probe
137
+ if (skeleton) {
138
+ const { unrealToDocument } = await import("./toDocument")
139
+ const { probeDocument } = await import("../probe")
140
+ const r = unrealToDocument({ skeleton, mesh, clips: anim ? [ anim ] : [] })
141
+ lines.push(...probeDocument(r.doc, path).split("\n").slice(1))
142
+ }
143
+ return lines.join("\n")
144
+ }
@@ -0,0 +1,53 @@
1
+ // Finding the file an object path names. An AnimSequence knows its skeleton only as
2
+ // `/Game/StairsSet/Demo/Mannequins/Meshes/SK_Mannequin`, where `/Game/` is the pack's Content
3
+ // folder — which is not marked in any way on disk. So: turn the path into a relative file name and
4
+ // walk up from the clip until a directory answers to it. The first one that does IS the content root.
5
+
6
+ import { existsSync, statSync } from "node:fs"
7
+ import { dirname, join, resolve, sep } from "node:path"
8
+
9
+ export const GAME_PREFIX = "/Game/"
10
+
11
+ /** `/Game/A/B/Name` → `A/B/Name.uasset`. A PLUGIN's content mounts at `/<PluginName>/` instead of
12
+ * `/Game/` (KINEMATION's FPS Animation Pack: `/FPSAnimationPack/Meshes/…/SK_Mannequin`), and its
13
+ * Content folder on disk is just as unmarked — so any mount point is stripped the same way. */
14
+ export const objectPathToFile = (objectPath: string): string | null => {
15
+ const m = /^\/[^/]+\/(.+)$/.exec(objectPath)
16
+ if (!m) return null
17
+ return `${m[1].replace(/\//g, sep)}.uasset`
18
+ }
19
+
20
+ export type ResolveResult = { file: string, contentRoot: string } | null
21
+
22
+ /**
23
+ * The `.uasset` an object path points at, searched from `fromDir` upwards.
24
+ * `contentRoot` short-circuits the search (`--content-root`).
25
+ */
26
+ export const resolveObjectFile = (objectPath: string, fromDir: string, contentRoot?: string): ResolveResult => {
27
+ const rel = objectPathToFile(objectPath)
28
+ if (!rel) return null
29
+ if (contentRoot) {
30
+ const f = join(contentRoot, rel)
31
+ return existsSync(f) ? { file: f, contentRoot } : null
32
+ }
33
+ let dir = resolve(fromDir)
34
+ for (;;) {
35
+ const f = join(dir, rel)
36
+ if (existsSync(f) && statSync(f).isFile()) return { file: f, contentRoot: dir }
37
+ const up = dirname(dir)
38
+ if (up === dir) return null
39
+ dir = up
40
+ }
41
+ }
42
+
43
+ /** The directories `resolveObjectFile` looked in — for the error message when it finds nothing. */
44
+ export const searchedRoots = (fromDir: string): string[] => {
45
+ const out: string[] = []
46
+ let dir = resolve(fromDir)
47
+ for (;;) {
48
+ out.push(dir)
49
+ const up = dirname(dir)
50
+ if (up === dir) return out
51
+ dir = up
52
+ }
53
+ }
@@ -0,0 +1,149 @@
1
+ // An Unreal asset as a retargeter `Rig` (src/retarget/rig.ts) — built directly, not through a
2
+ // Document, so `retarget --from SKM_Manny.uasset` costs one file read.
3
+ //
4
+ // In Unreal the REFERENCE POSE IS THE BIND POSE, so bind == rest exactly; the inverse-bind matrices
5
+ // handed to `makeRig` are just the inverses of the rest world transforms. What they really carry is
6
+ // WHICH BONES THE SKIN USES: the classifiers read `fromSkin` as "part of the body", and a UE rig is
7
+ // full of bones no vertex is weighted to — `ik_foot_l`, `ik_hand_gun`, `interaction`,
8
+ // `center_of_mass`. With a mesh at hand those come out unskinned, exactly as they should; with only
9
+ // a `Skeleton` asset there is nothing to tell them apart, so every bone counts.
10
+
11
+ import { makeRig, qnorm, qslerp, type Clip, type Pose, type Quat, type Rig, type Vec3 } from "../retarget/rig"
12
+ import { boneInverseBindMatrices, unrealClipTracks, type UnrealTrack } from "./toDocument"
13
+ import { ueLocalTransform } from "./convert"
14
+ import type { UeSkeleton } from "./skeleton"
15
+ import type { UeSkeletalMesh } from "./mesh"
16
+ import type { UeAnimSequence } from "./anim"
17
+ import { loadUnreal, type LoadUnrealOptions } from "./index"
18
+
19
+ const sampleTrack = (tr: UnrealTrack, time: number): number[] => {
20
+ const n = tr.path === "rotation" ? 4 : 3
21
+ const last = tr.times.length - 1
22
+ const at = (i: number): number[] => Array.from(tr.values.subarray(i * n, i * n + n))
23
+ if (time <= tr.times[0]) return at(0)
24
+ if (time >= tr.times[last]) return at(last)
25
+ let i = 0
26
+ while (i < last && tr.times[i + 1] < time) i++
27
+ const u = (time - tr.times[i]) / (tr.times[i + 1] - tr.times[i] || 1)
28
+ const a = at(i), b = at(i + 1)
29
+ if (n === 4) return qslerp(a as Quat, b as Quat, u)
30
+ return a.map((v, k) => v + (b[k] - v) * u)
31
+ }
32
+
33
+ /**
34
+ * Add the bones that lie INSIDE the skinned subtree — a skinned ancestor above, a skinned bone
35
+ * somewhere below — to the body set.
36
+ *
37
+ * The UE5 mannequin weights its limbs to the twist helpers (`thigh_twist_01_l`, `upperarm_twist_02_r`)
38
+ * and leaves `thigh_l` / `upperarm_l` themselves without a single vertex. Taking "has weights"
39
+ * literally then cuts the legs and arms out of the body, and the humanoid classifier — which looks
40
+ * for a pelvis with two chains reaching the ground — reads the rig as not a biped at all. A bone
41
+ * between two skinned bones is part of the body whatever the weights say; a bone with nothing
42
+ * skinned below it (`ik_foot_root`, `ik_hand_gun`, `weapon_l`, `interaction`, `center_of_mass`) or
43
+ * nothing skinned above it (`root`) is not, which is exactly what those bones should read as.
44
+ */
45
+ export const fillSkinnedInterior = (parents: number[], skinned: Set<number>): void => {
46
+ const order: number[] = [] // parents before children
47
+ const seen = new Set<number>()
48
+ const visit = (i: number): void => { if (seen.has(i)) return; if (parents[i] >= 0) visit(parents[i]); seen.add(i); order.push(i) }
49
+ parents.forEach((_, i) => visit(i))
50
+ const above = new Array(parents.length).fill(false)
51
+ for (const i of order) above[i] = parents[i] >= 0 && (skinned.has(parents[i]) || above[parents[i]])
52
+ const below = new Array(parents.length).fill(false)
53
+ for (let k = order.length - 1; k >= 0; k--) {
54
+ const i = order[k], p = parents[i]
55
+ if (p >= 0 && (skinned.has(i) || below[i])) below[p] = true
56
+ }
57
+ for (const i of order) if (!skinned.has(i) && above[i] && below[i]) skinned.add(i)
58
+ }
59
+
60
+ export type UnrealRigInput = {
61
+ skeleton: UeSkeleton
62
+ /** When present, only the bones it weights count as "part of the body". */
63
+ mesh?: UeSkeletalMesh
64
+ clips?: UeAnimSequence[]
65
+ }
66
+
67
+ export const rigFromUnrealData = (label: string, input: UnrealRigInput): Rig => {
68
+ const { skeleton, mesh } = input
69
+ const names = skeleton.bones.map((b) => b.name)
70
+ const parents = skeleton.bones.map((b) => b.parent)
71
+ const local = skeleton.bones.map((b) => ueLocalTransform(b.t, b.q, b.s, b.parent < 0))
72
+ const rest: Pose = { t: local.map((l) => [ ...l.t ] as Vec3), r: local.map((l) => [ ...l.r ] as Quat), s: local.map((l) => [ ...l.s ] as Vec3) }
73
+
74
+ let skinned: Set<number> | null = null
75
+ if (mesh) {
76
+ const byName = new Map(names.map((n, i) => [ n, i ]))
77
+ skinned = new Set<number>()
78
+ for (const inf of mesh.influences) {
79
+ if (inf.weight <= 0) continue
80
+ const bone = mesh.bones[inf.bone]
81
+ const i = bone ? byName.get(bone.name) : undefined
82
+ if (i !== undefined) skinned.add(i)
83
+ }
84
+ fillSkinnedInterior(parents, skinned)
85
+ }
86
+ const ibm = boneInverseBindMatrices(skeleton.bones)
87
+ const ibmByNode = new Map<number, number[]>()
88
+ ibm.forEach((m, i) => { if (!skinned || skinned.has(i)) ibmByNode.set(i, m) })
89
+
90
+ const makeClips = (base: Pose): Clip[] => (input.clips ?? []).map((clip) => {
91
+ const { tracks } = unrealClipTracks(skeleton, clip)
92
+ return {
93
+ name: clip.name,
94
+ duration: clip.playLength,
95
+ sample: (time: number): Pose => {
96
+ const pose: Pose = { t: base.t.map((v) => [ ...v ] as Vec3), r: base.r.map((v) => [ ...v ] as Quat), s: base.s.map((v) => [ ...v ] as Vec3) }
97
+ for (const tr of tracks) {
98
+ const v = sampleTrack(tr, time)
99
+ if (tr.path === "translation") pose.t[tr.node] = v as Vec3
100
+ else if (tr.path === "rotation") pose.r[tr.node] = qnorm(v as Quat)
101
+ else pose.s[tr.node] = v as Vec3
102
+ }
103
+ return pose
104
+ },
105
+ }
106
+ })
107
+
108
+ return makeRig(label, names, parents, rest, makeClips, ibmByNode)
109
+ }
110
+
111
+ /** `retarget --from x.uasset` / `--donor-rig x.uasset`: a mesh, a skeleton, or a clip + its skeleton. */
112
+ export const rigFromUnreal = (path: string, opts: LoadUnrealOptions = {}): Rig => {
113
+ const u = loadUnreal(path, opts)
114
+ return rigFromUnrealData(path.split(/[\\/]/).pop() ?? path, { skeleton: u.skeleton, mesh: u.mesh, clips: u.anim ? [ u.anim ] : [] })
115
+ }
116
+
117
+ /** `retarget --anim a.uasset b.uasset`: clips bound BY BONE NAME onto a donor rig loaded elsewhere. */
118
+ export const attachUnrealClips = (rig: Rig, skeleton: UeSkeleton, clips: UeAnimSequence[]): { clips: Clip[], report: { name: string, bound: number, total: number, unbound: string[] }[] } => {
119
+ const out: Clip[] = []
120
+ const report: { name: string, bound: number, total: number, unbound: string[] }[] = []
121
+ const base = rig.bind.local
122
+ for (const clip of clips) {
123
+ // convert on the CLIP's own skeleton, then move each track onto the rig's bone of that name
124
+ const { tracks, unbound } = unrealClipTracks(skeleton, clip)
125
+ const bound: UnrealTrack[] = []
126
+ const missing = new Set<string>(unbound)
127
+ for (const tr of tracks) {
128
+ const i = rig.index.get(skeleton.bones[tr.node].name)
129
+ if (i === undefined) { missing.add(skeleton.bones[tr.node].name); continue }
130
+ bound.push({ ...tr, node: i })
131
+ }
132
+ report.push({ name: clip.name, bound: new Set(bound.map((t) => t.node)).size, total: clip.tracks.length, unbound: [ ...missing ] })
133
+ out.push({
134
+ name: clip.name,
135
+ duration: clip.playLength,
136
+ sample: (time: number): Pose => {
137
+ const pose: Pose = { t: base.t.map((v) => [ ...v ] as Vec3), r: base.r.map((v) => [ ...v ] as Quat), s: base.s.map((v) => [ ...v ] as Vec3) }
138
+ for (const tr of bound) {
139
+ const v = sampleTrack(tr, time)
140
+ if (tr.path === "translation") pose.t[tr.node] = v as Vec3
141
+ else if (tr.path === "rotation") pose.r[tr.node] = qnorm(v as Quat)
142
+ else pose.s[tr.node] = v as Vec3
143
+ }
144
+ return pose
145
+ },
146
+ })
147
+ }
148
+ return { clips: out, report }
149
+ }
@@ -0,0 +1,193 @@
1
+ // The OTHER data model: `AnimationSequencerDataModel`, what UE 5.2+ writes when a clip is authored
2
+ // or edited in Sequencer through a Control Rig. The motion is not bone tracks but a MovieScene
3
+ // section — `MovieSceneControlRigParameterSection.TransformParameterNamesAndCurves`, one element per
4
+ // FK control (`pelvis_CONTROL`), each holding nine `FMovieSceneFloatChannel`s: 3 translation,
5
+ // 3 rotation (EULER, degrees, `FRotator` order), 3 scale.
6
+ //
7
+ // A control's channels carry the bone's LOCAL transform in UE units, absolutely — not a delta
8
+ // against the reference pose — so once the channels are evaluated on a key grid the result is the
9
+ // same thing `AnimDataModel` hands over, and everything downstream (the coordinate recipe, clip
10
+ // merging, retargeting) is unchanged.
11
+ //
12
+ // `FMovieSceneFloatChannel` is serialized natively (no property tags), verified byte-for-byte
13
+ // against real files — the sizes below account for every byte of all 89 controls of a UE 5.3 clip:
14
+ //
15
+ // u8 PreInfinityExtrap, u8 PostInfinityExtrap
16
+ // BulkSerialize<FFrameNumber> i32 elemSize(4), i32 count, count × i32 frame numbers
17
+ // BulkSerialize<FMovieSceneFloatValue> i32 elemSize(28), i32 count, count × 28 B value + tangents
18
+ // f32 DefaultValue, i32 bHasDefaultValue, i32 TickNumerator, i32 TickDenominator, i32 (unused)
19
+ //
20
+ // A channel with no keys is a CONSTANT: its `DefaultValue` is the bone's own local component, which
21
+ // is why an unanimated bone still comes out in the right place.
22
+
23
+ import { BinaryReader, classOf, readObjectProperties, readArrayHeader, readFName, readProperties, propInt, type UePackage } from "./package"
24
+
25
+ /** `ERichCurveInterpMode`. */
26
+ export const RCIM_LINEAR = 0, RCIM_CONSTANT = 1, RCIM_CUBIC = 2
27
+
28
+ export type MovieSceneChannel = {
29
+ /** Frame numbers, in the channel's own tick rate. */
30
+ times: Int32Array
31
+ values: Float32Array
32
+ arriveTangent: Float32Array
33
+ leaveTangent: Float32Array
34
+ interp: Uint8Array
35
+ defaultValue: number
36
+ hasDefault: boolean
37
+ tickRate: number
38
+ /** Bytes the channel actually consumed — the caller checks it against the property's size. */
39
+ size: number
40
+ }
41
+
42
+ const VALUE_SIZE = 28
43
+
44
+ /** One `FMovieSceneFloatChannel` at `at`. */
45
+ export const readFloatChannel = (bytes: Uint8Array, at: number, label = "channel"): MovieSceneChannel => {
46
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
47
+ let o = at + 2 // pre/post infinity extrapolation
48
+ const timeElem = dv.getInt32(o, true), timeCount = dv.getInt32(o + 4, true); o += 8
49
+ if (timeElem !== 4 || timeCount < 0 || o + timeCount * 4 > bytes.byteLength) {
50
+ throw new Error(`${label}: bad frame-number array (element ${timeElem}, count ${timeCount})`)
51
+ }
52
+ const times = new Int32Array(timeCount)
53
+ for (let i = 0; i < timeCount; i++) times[i] = dv.getInt32(o + i * 4, true)
54
+ o += timeCount * 4
55
+ const valueElem = dv.getInt32(o, true), valueCount = dv.getInt32(o + 4, true); o += 8
56
+ if (valueElem !== VALUE_SIZE || valueCount !== timeCount) {
57
+ throw new Error(`${label}: ${valueCount} values of ${valueElem} B for ${timeCount} keys — not a float channel`)
58
+ }
59
+ const values = new Float32Array(valueCount)
60
+ const arriveTangent = new Float32Array(valueCount)
61
+ const leaveTangent = new Float32Array(valueCount)
62
+ const interp = new Uint8Array(valueCount)
63
+ for (let i = 0; i < valueCount; i++) {
64
+ const b = o + i * VALUE_SIZE
65
+ values[i] = dv.getFloat32(b, true)
66
+ arriveTangent[i] = dv.getFloat32(b + 4, true)
67
+ leaveTangent[i] = dv.getFloat32(b + 8, true)
68
+ interp[i] = bytes[b + 24]
69
+ }
70
+ o += valueCount * VALUE_SIZE
71
+ const defaultValue = dv.getFloat32(o, true)
72
+ const hasDefault = dv.getInt32(o + 4, true) !== 0
73
+ const num = dv.getInt32(o + 8, true), den = dv.getInt32(o + 12, true)
74
+ o += 20 // + the trailing i32
75
+ return { times, values, arriveTangent, leaveTangent, interp, defaultValue, hasDefault, tickRate: den ? num / den : 0, size: o - at }
76
+ }
77
+
78
+ /**
79
+ * The channel's value at a time in SECONDS. Keys are held before the first and after the last (the
80
+ * clip's own extrapolation is not applied — a baked grid never leaves the range). Cubic segments are
81
+ * evaluated as the bezier UE builds from the tangents; weighted tangents are approximated by the
82
+ * unweighted curve, which is what a baked mocap clip's keys are anyway.
83
+ */
84
+ export const sampleChannel = (c: MovieSceneChannel, time: number, fallback = 0): number => {
85
+ const n = c.times.length
86
+ if (n === 0) return c.hasDefault ? c.defaultValue : fallback
87
+ const rate = c.tickRate || 1
88
+ const first = c.times[0] / rate, last = c.times[n - 1] / rate
89
+ if (time <= first) return c.values[0]
90
+ if (time >= last) return c.values[n - 1]
91
+ let i = 0
92
+ while (i < n - 1 && c.times[i + 1] / rate < time) i++
93
+ const t0 = c.times[i] / rate, t1 = c.times[i + 1] / rate
94
+ const span = t1 - t0
95
+ const u = span > 0 ? (time - t0) / span : 0
96
+ const v0 = c.values[i], v1 = c.values[i + 1]
97
+ if (c.interp[i] === RCIM_CONSTANT) return v0
98
+ if (c.interp[i] !== RCIM_CUBIC) return v0 + (v1 - v0) * u
99
+ // UE's cubic: control points a third of the interval along each tangent (value per second)
100
+ const p1 = v0 + (c.leaveTangent[i] * span) / 3
101
+ const p2 = v1 - (c.arriveTangent[i + 1] * span) / 3
102
+ const w = 1 - u
103
+ return w * w * w * v0 + 3 * w * w * u * p1 + 3 * w * u * u * p2 + u * u * u * v1
104
+ }
105
+
106
+ export type SequencerControl = {
107
+ /** `pelvis_CONTROL`. */
108
+ control: string
109
+ /** …and the bone it drives. */
110
+ bone: string
111
+ translation: (MovieSceneChannel | null)[]
112
+ rotation: (MovieSceneChannel | null)[]
113
+ scale: (MovieSceneChannel | null)[]
114
+ }
115
+
116
+ export type SequencerSection = {
117
+ controls: SequencerControl[]
118
+ /** Frames per second of the MovieScene's own timeline (`DisplayRate`). */
119
+ displayRate: number
120
+ tickRate: number
121
+ }
122
+
123
+ const frameRate = (r: BinaryReader, pkg: UePackage, props: ReturnType<typeof readProperties>, key: string): number | undefined => {
124
+ const tag = props.byName.get(key)
125
+ if (!tag) return undefined
126
+ r.offset = tag.data
127
+ const p = readProperties(r, pkg)
128
+ const num = propInt(r, p.byName.get("Numerator")), den = propInt(r, p.byName.get("Denominator")) ?? 1
129
+ return num && den ? num / den : undefined
130
+ }
131
+
132
+ /** The MovieScene's own rates — the frame grid a Sequencer clip is authored on. */
133
+ export const readMovieSceneRates = (pkg: UePackage): { displayRate: number, tickRate: number } => {
134
+ const idx = pkg.exports.findIndex((e) => classOf(pkg, e) === "MovieScene")
135
+ if (idx < 0) return { displayRate: 30, tickRate: 0 }
136
+ const { reader: r, props } = readObjectProperties(pkg, pkg.exports[idx])
137
+ return {
138
+ displayRate: frameRate(r, pkg, props, "DisplayRate") ?? 30,
139
+ tickRate: frameRate(r, pkg, props, "TickResolution") ?? 0,
140
+ }
141
+ }
142
+
143
+ /** Every FK control of the package's ControlRig section, with its nine channels. */
144
+ export const readSequencerSection = (pkg: UePackage): SequencerSection => {
145
+ const idx = pkg.exports.findIndex((e) => classOf(pkg, e) === "MovieSceneControlRigParameterSection")
146
+ if (idx < 0) throw new Error(`${pkg.label}: no MovieSceneControlRigParameterSection — not a Sequencer clip`)
147
+ const rates = readMovieSceneRates(pkg)
148
+ const { reader: r, props } = readObjectProperties(pkg, pkg.exports[idx])
149
+ const tag = props.byName.get("TransformParameterNamesAndCurves")
150
+ if (!tag) throw new Error(`${pkg.label}: the ControlRig section holds no transform curves (a curve-only or space-switch section?)`)
151
+ const { count } = readArrayHeader(r, pkg, tag)
152
+ const controls: SequencerControl[] = []
153
+ for (let i = 0; i < count; i++) {
154
+ const el = readProperties(r, pkg)
155
+ const after = r.offset
156
+ let control = ""
157
+ const nameTag = el.byName.get("ParameterName")
158
+ if (nameTag) { r.offset = nameTag.data; control = readFName(r, pkg.names) }
159
+ const slots: Record<string, (MovieSceneChannel | null)[]> = { Translation: [ null, null, null ], Rotation: [ null, null, null ], Scale: [ null, null, null ] }
160
+ for (const t of el.list) {
161
+ const slot = slots[t.name]
162
+ if (!slot || t.type !== "StructProperty" || t.arrayIndex < 0 || t.arrayIndex > 2) continue
163
+ const ch = readFloatChannel(pkg.bytes, t.data, `${pkg.label}:${control}.${t.name}[${t.arrayIndex}]`)
164
+ if (ch.size !== t.size) throw new Error(`${pkg.label}: ${control}.${t.name}[${t.arrayIndex}] read ${ch.size} of ${t.size} bytes — channel layout mismatch`)
165
+ slot[t.arrayIndex] = ch
166
+ }
167
+ if (control) {
168
+ controls.push({
169
+ control, bone: control.replace(/_CONTROL$/i, ""),
170
+ translation: slots.Translation, rotation: slots.Rotation, scale: slots.Scale,
171
+ })
172
+ }
173
+ r.offset = after
174
+ }
175
+ return { controls, ...rates }
176
+ }
177
+
178
+ /**
179
+ * `FRotator` (Roll = X, Pitch = Y, Yaw = Z, degrees) → the quaternion UE builds from it. Straight
180
+ * out of `FRotator::Quaternion()` — the signs are not a convention to pick, they are that function.
181
+ */
182
+ export const rotatorToQuat = (roll: number, pitch: number, yaw: number): [ number, number, number, number ] => {
183
+ const half = Math.PI / 360 // degrees → radians, halved
184
+ const sp = Math.sin(pitch * half), cp = Math.cos(pitch * half)
185
+ const sy = Math.sin(yaw * half), cy = Math.cos(yaw * half)
186
+ const sr = Math.sin(roll * half), cr = Math.cos(roll * half)
187
+ return [
188
+ cr * sp * sy - sr * cp * cy,
189
+ -cr * sp * cy - sr * cp * sy,
190
+ cr * cp * sy - sr * sp * cy,
191
+ cr * cp * cy + sr * sp * sy,
192
+ ]
193
+ }
@@ -0,0 +1,74 @@
1
+ // USkeleton → bones. A UE skeleton asset carries the reference skeleton: bone names, parents and
2
+ // the ref pose. In Unreal THE REFERENCE POSE IS THE BIND POSE (skinned meshes are bound to it), so
3
+ // a rig built from one has `bind = rest` and needs no inverse-bind matrices to be meaningful.
4
+ //
5
+ // Layout (§4.1): the tagged part (BoneTree, Sockets, …) is skipped, then the native
6
+ // `FReferenceSkeleton`: bone infos (name, parent, editor export name), then one `FTransform` per
7
+ // bone — LOCAL, UE frame, centimetres, doubles since ue5 1004 (LWC).
8
+
9
+ import { BinaryReader, findExport, readFName, readObjectProperties, type UePackage } from "./package"
10
+
11
+ export type UeBone = {
12
+ name: string
13
+ parent: number
14
+ /** LOCAL transform in the UE frame, centimetres. */
15
+ t: [ number, number, number ]
16
+ q: [ number, number, number, number ]
17
+ s: [ number, number, number ]
18
+ }
19
+
20
+ export type UeSkeleton = {
21
+ /** The asset's name (`SK_Mannequin`). */
22
+ name: string
23
+ bones: UeBone[]
24
+ }
25
+
26
+ /**
27
+ * After a UObject's tag list comes an `i32` "has object guid" flag (always 0 in these packages).
28
+ * Some classes do not write it, so a non-0/1 value is treated as the class's own data and rewound.
29
+ */
30
+ export const skipObjectGuid = (r: BinaryReader): void => {
31
+ const at = r.offset
32
+ const v = r.i32()
33
+ if (v === 0) return
34
+ if (v === 1) { r.skip(16); return }
35
+ r.offset = at
36
+ }
37
+
38
+ /** `FReferenceSkeleton` (editor build) at the reader's position. */
39
+ export const readReferenceSkeleton = (r: BinaryReader, names: string[], label: string): UeBone[] => {
40
+ const boneCount = r.i32()
41
+ if (boneCount < 0 || boneCount > 65536) throw new Error(`${label}: implausible bone count ${boneCount} at ${r.offset - 4}`)
42
+ const bones: UeBone[] = new Array(boneCount)
43
+ for (let i = 0; i < boneCount; i++) {
44
+ const name = readFName(r, names)
45
+ const parent = r.i32()
46
+ r.fstring() // exportName (editor only)
47
+ if (parent < -1 || parent >= boneCount) throw new Error(`${label}: bone ${i} ("${name}") has parent ${parent}`)
48
+ bones[i] = { name, parent, t: [ 0, 0, 0 ], q: [ 0, 0, 0, 1 ], s: [ 1, 1, 1 ] }
49
+ }
50
+ const poseCount = r.i32()
51
+ if (poseCount !== boneCount) throw new Error(`${label}: reference pose has ${poseCount} transforms for ${boneCount} bones`)
52
+ for (let i = 0; i < poseCount; i++) {
53
+ const q = r.doubles(4) // FTransform: rotation, translation, scale
54
+ const t = r.doubles(3)
55
+ const s = r.doubles(3)
56
+ bones[i].q = [ q[0], q[1], q[2], q[3] ]
57
+ bones[i].t = [ t[0], t[1], t[2] ]
58
+ bones[i].s = [ s[0], s[1], s[2] ]
59
+ }
60
+ return bones
61
+ }
62
+
63
+ /** The `Skeleton` export of a package (a `SK_*.uasset`). */
64
+ export const readSkeleton = (pkg: UePackage): UeSkeleton => {
65
+ const found = findExport(pkg, "Skeleton")
66
+ if (!found) throw new Error(`${pkg.label}: no Skeleton export (classes: ${pkg.exports.map((e) => e.objectName).join(", ")})`)
67
+ const { reader: r } = readObjectProperties(pkg, found.export) // BoneTree, Sockets, PreviewSkeletalMesh, …
68
+ skipObjectGuid(r)
69
+ const bones = readReferenceSkeleton(r, pkg.names, pkg.label)
70
+ return { name: found.export.objectName, bones }
71
+ }
72
+
73
+ /** Bones with no parent — `root` for a UE rig, but a skeleton may carry several. */
74
+ export const rootBones = (sk: UeSkeleton): number[] => sk.bones.map((b, i) => (b.parent < 0 ? i : -1)).filter((i) => i >= 0)