lecodes-assets 0.2.0 → 2.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -0
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +238 -0
- package/src/retarget/humanoid.ts +572 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +149 -0
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -0,0 +1,367 @@
|
|
|
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
|
+
let unweighted = 0
|
|
197
|
+
|
|
198
|
+
// ---- mesh + skin ----
|
|
199
|
+
if (mesh) {
|
|
200
|
+
// joints keep the mesh's OWN bone order, so `influences.boneIndex` maps 1:1
|
|
201
|
+
const joints: Node[] = []
|
|
202
|
+
const missing: string[] = []
|
|
203
|
+
for (const b of mesh.bones) {
|
|
204
|
+
const i = boneIndex.get(b.name)
|
|
205
|
+
if (i === undefined) { missing.push(b.name); joints.push(nodes[0]) } else joints.push(nodes[i])
|
|
206
|
+
}
|
|
207
|
+
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` })
|
|
208
|
+
|
|
209
|
+
// per-point influences, top 4 by weight
|
|
210
|
+
const perPoint: UeSkeletalMesh["influences"][] = Array.from({ length: mesh.points.length / 3 }, () => [])
|
|
211
|
+
for (const inf of mesh.influences) {
|
|
212
|
+
if (inf.point >= 0 && inf.point < perPoint.length) perPoint[inf.point].push(inf)
|
|
213
|
+
}
|
|
214
|
+
const pointJoints = new Uint16Array(perPoint.length * MAX_JOINTS_PER_VERTEX)
|
|
215
|
+
const pointWeights = new Float32Array(perPoint.length * MAX_JOINTS_PER_VERTEX)
|
|
216
|
+
for (let p = 0; p < perPoint.length; p++) {
|
|
217
|
+
const list = perPoint[p].slice().sort((a, b) => b.weight - a.weight)
|
|
218
|
+
const keep = list.slice(0, MAX_JOINTS_PER_VERTEX)
|
|
219
|
+
const total = list.reduce((n, i) => n + i.weight, 0)
|
|
220
|
+
const kept = keep.reduce((n, i) => n + i.weight, 0)
|
|
221
|
+
if (total > 0) droppedWeight += total - kept
|
|
222
|
+
// A point with NO influence is bound to the ROOT bone — what UE's importer does with every
|
|
223
|
+
// unweighted vertex. Left at weight 0 it skins to the origin: the FPS Animation Pack's MX16A4
|
|
224
|
+
// keeps its whole barrel and handguard unweighted (14 088 of 15 696 points in one section),
|
|
225
|
+
// and those collapsed triangles took the desktop host down inside the next UI draw.
|
|
226
|
+
if (kept <= 0) { pointJoints[p * MAX_JOINTS_PER_VERTEX] = 0; pointWeights[p * MAX_JOINTS_PER_VERTEX] = 1; unweighted++; continue }
|
|
227
|
+
const norm = kept > 0 ? 1 / kept : 0
|
|
228
|
+
keep.forEach((inf, k) => {
|
|
229
|
+
pointJoints[p * MAX_JOINTS_PER_VERTEX + k] = inf.bone >= 0 && inf.bone < joints.length ? inf.bone : 0
|
|
230
|
+
pointWeights[p * MAX_JOINTS_PER_VERTEX + k] = inf.weight * norm
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
if (unweighted) warn({ level: "info", message: `mesh '${mesh.name}': ${unweighted} of ${perPoint.length} points carry no skin weight — bound to the root bone '${mesh.bones[0]?.name ?? "?"}', as UE's importer does` })
|
|
234
|
+
|
|
235
|
+
// normals: the per-corner TangentZ of every face using a wedge, accumulated
|
|
236
|
+
const wedgeCount = mesh.wedgePoint.length
|
|
237
|
+
const normals = new Float32Array(wedgeCount * 3)
|
|
238
|
+
const faceCount = mesh.faceWedges.length / 3
|
|
239
|
+
for (let f = 0; f < faceCount; f++) {
|
|
240
|
+
for (let c = 0; c < 3; c++) {
|
|
241
|
+
const w = mesh.faceWedges[f * 3 + c]
|
|
242
|
+
normals[w * 3] += mesh.faceNormals[f * 9 + c * 3]
|
|
243
|
+
normals[w * 3 + 1] += mesh.faceNormals[f * 9 + c * 3 + 1]
|
|
244
|
+
normals[w * 3 + 2] += mesh.faceNormals[f * 9 + c * 3 + 2]
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The Y mirror reverses handedness, so UE's winding may or may not survive it. Decide from the
|
|
249
|
+
// data — for a sample of faces, does the geometric normal agree with the shading normal? —
|
|
250
|
+
// rather than hiding the answer behind a double-sided material.
|
|
251
|
+
{
|
|
252
|
+
let agree = 0, disagree = 0
|
|
253
|
+
const step = Math.max(1, Math.floor(faceCount / 500))
|
|
254
|
+
for (let f = 0; f < faceCount; f += step) {
|
|
255
|
+
const w = [ mesh.faceWedges[f * 3], mesh.faceWedges[f * 3 + 1], mesh.faceWedges[f * 3 + 2] ]
|
|
256
|
+
const p = w.map((x) => {
|
|
257
|
+
const pt = mesh.wedgePoint[x] * 3
|
|
258
|
+
return ueWorldPoint(mesh.points[pt], mesh.points[pt + 1], mesh.points[pt + 2])
|
|
259
|
+
})
|
|
260
|
+
const n = ueWorldDir(
|
|
261
|
+
mesh.faceNormals[f * 9] + mesh.faceNormals[f * 9 + 3] + mesh.faceNormals[f * 9 + 6],
|
|
262
|
+
mesh.faceNormals[f * 9 + 1] + mesh.faceNormals[f * 9 + 4] + mesh.faceNormals[f * 9 + 7],
|
|
263
|
+
mesh.faceNormals[f * 9 + 2] + mesh.faceNormals[f * 9 + 5] + mesh.faceNormals[f * 9 + 8],
|
|
264
|
+
)
|
|
265
|
+
const e1 = [ p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2] ]
|
|
266
|
+
const e2 = [ p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2] ]
|
|
267
|
+
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] ]
|
|
268
|
+
const d = cr[0] * n[0] + cr[1] * n[1] + cr[2] * n[2]
|
|
269
|
+
if (d > 0) agree++
|
|
270
|
+
else if (d < 0) disagree++
|
|
271
|
+
}
|
|
272
|
+
flippedWinding = disagree > agree
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// materials, one per import-data slot (textures are a later phase: grey PBR, named by the slot)
|
|
276
|
+
const materials: Material[] = (mesh.materials.length ? mesh.materials : [ "material" ]).map((name) => {
|
|
277
|
+
const m = doc.createMaterial(name).setRoughnessFactor(0.75).setMetallicFactor(0).setDoubleSided(false)
|
|
278
|
+
if (/glass/i.test(name)) m.setAlphaMode("BLEND").setBaseColorFactor([ 1, 1, 1, 0.35 ])
|
|
279
|
+
return m
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const gmesh = doc.createMesh(mesh.name)
|
|
283
|
+
const slots = new Set<number>()
|
|
284
|
+
for (const s of mesh.faceMaterial) slots.add(s)
|
|
285
|
+
for (const slot of [ ...slots ].sort((a, b) => a - b)) {
|
|
286
|
+
// one glTF vertex per wedge this slot uses
|
|
287
|
+
const remap = new Int32Array(wedgeCount).fill(-1)
|
|
288
|
+
const used: number[] = []
|
|
289
|
+
const indices: number[] = []
|
|
290
|
+
for (let f = 0; f < faceCount; f++) {
|
|
291
|
+
if (mesh.faceMaterial[f] !== slot) continue
|
|
292
|
+
const tri = [ mesh.faceWedges[f * 3], mesh.faceWedges[f * 3 + 1], mesh.faceWedges[f * 3 + 2] ]
|
|
293
|
+
if (flippedWinding) { const t = tri[1]; tri[1] = tri[2]; tri[2] = t }
|
|
294
|
+
for (const w of tri) {
|
|
295
|
+
if (remap[w] < 0) { remap[w] = used.length; used.push(w) }
|
|
296
|
+
indices.push(remap[w])
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!used.length) continue
|
|
300
|
+
const n = used.length
|
|
301
|
+
const pos = new Float32Array(n * 3), nrm = new Float32Array(n * 3), uv0 = new Float32Array(n * 2)
|
|
302
|
+
const uv1 = mesh.hasUv1 ? new Float32Array(n * 2) : null
|
|
303
|
+
const jnt = new Uint16Array(n * 4), wgt = new Float32Array(n * 4)
|
|
304
|
+
for (let i = 0; i < n; i++) {
|
|
305
|
+
const w = used[i]
|
|
306
|
+
const p = mesh.wedgePoint[w] * 3
|
|
307
|
+
const wp = ueWorldPoint(mesh.points[p], mesh.points[p + 1], mesh.points[p + 2])
|
|
308
|
+
pos.set(wp, i * 3)
|
|
309
|
+
const nw = ueWorldDir(normals[w * 3], normals[w * 3 + 1], normals[w * 3 + 2])
|
|
310
|
+
const len = Math.hypot(nw[0], nw[1], nw[2]) || 1
|
|
311
|
+
nrm[i * 3] = nw[0] / len; nrm[i * 3 + 1] = nw[1] / len; nrm[i * 3 + 2] = nw[2] / len
|
|
312
|
+
uv0[i * 2] = mesh.wedgeUv0[w * 2]; uv0[i * 2 + 1] = mesh.wedgeUv0[w * 2 + 1]
|
|
313
|
+
if (uv1) { uv1[i * 2] = mesh.wedgeUv1[w * 2]; uv1[i * 2 + 1] = mesh.wedgeUv1[w * 2 + 1] }
|
|
314
|
+
const pj = mesh.wedgePoint[w] * MAX_JOINTS_PER_VERTEX
|
|
315
|
+
for (let k = 0; k < 4; k++) { jnt[i * 4 + k] = pointJoints[pj + k]; wgt[i * 4 + k] = pointWeights[pj + k] }
|
|
316
|
+
}
|
|
317
|
+
const prim = doc.createPrimitive()
|
|
318
|
+
prim.setAttribute("POSITION", doc.createAccessor().setType("VEC3").setArray(pos).setBuffer(buffer))
|
|
319
|
+
prim.setAttribute("NORMAL", doc.createAccessor().setType("VEC3").setArray(nrm).setBuffer(buffer))
|
|
320
|
+
prim.setAttribute("TEXCOORD_0", doc.createAccessor().setType("VEC2").setArray(uv0).setBuffer(buffer))
|
|
321
|
+
if (uv1) prim.setAttribute("TEXCOORD_1", doc.createAccessor().setType("VEC2").setArray(uv1).setBuffer(buffer))
|
|
322
|
+
prim.setAttribute("JOINTS_0", doc.createAccessor().setType("VEC4").setArray(jnt).setBuffer(buffer))
|
|
323
|
+
prim.setAttribute("WEIGHTS_0", doc.createAccessor().setType("VEC4").setArray(wgt).setBuffer(buffer))
|
|
324
|
+
const idx = n <= 65535 ? Uint16Array.from(indices) : Uint32Array.from(indices)
|
|
325
|
+
prim.setIndices(doc.createAccessor().setType("SCALAR").setArray(idx).setBuffer(buffer))
|
|
326
|
+
prim.setMaterial(materials[Math.min(slot, materials.length - 1)])
|
|
327
|
+
gmesh.addPrimitive(prim)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const ibm = new Float32Array(mesh.bones.length * 16)
|
|
331
|
+
boneInverseBindMatrices(mesh.bones).forEach((m, i) => ibm.set(m, i * 16))
|
|
332
|
+
const skin = doc.createSkin(mesh.name)
|
|
333
|
+
for (const j of joints) skin.addJoint(j)
|
|
334
|
+
skin.setInverseBindMatrices(doc.createAccessor().setType("MAT4").setArray(ibm).setBuffer(buffer))
|
|
335
|
+
const meshNode = doc.createNode(mesh.name).setMesh(gmesh).setSkin(skin)
|
|
336
|
+
scene.addChild(meshNode)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---- clips ----
|
|
340
|
+
const clipNames: string[] = []
|
|
341
|
+
const bound: UnrealDocumentResult["bound"] = []
|
|
342
|
+
clips.forEach((clip, ci) => {
|
|
343
|
+
let name = opts.clipNames?.[ci] ?? clip.name
|
|
344
|
+
const taken = new Set(doc.getRoot().listAnimations().map((a) => a.getName()))
|
|
345
|
+
if (taken.has(name)) { let k = 2; while (taken.has(`${name} (${k})`)) k++; name = `${name} (${k})` }
|
|
346
|
+
clipNames.push(name)
|
|
347
|
+
const anim = doc.createAnimation(name)
|
|
348
|
+
const interpolation = clip.interpolation === "Step" ? "STEP" : "LINEAR"
|
|
349
|
+
const t = unrealClipTracks(skeleton, clip, warn)
|
|
350
|
+
for (const tr of t.tracks) {
|
|
351
|
+
const comps = tr.path === "rotation" ? 4 : 3
|
|
352
|
+
const sampler = doc.createAnimationSampler()
|
|
353
|
+
.setInput(doc.createAccessor().setType("SCALAR").setArray(tr.times.slice()).setBuffer(buffer))
|
|
354
|
+
.setOutput(doc.createAccessor().setType(comps === 4 ? "VEC4" : "VEC3").setArray(tr.values.slice()).setBuffer(buffer))
|
|
355
|
+
.setInterpolation(interpolation)
|
|
356
|
+
anim.addSampler(sampler)
|
|
357
|
+
anim.addChannel(doc.createAnimationChannel().setTargetNode(nodes[tr.node]).setTargetPath(tr.path).setSampler(sampler))
|
|
358
|
+
}
|
|
359
|
+
anim.setExtras(clipExtras(clip))
|
|
360
|
+
if (t.unbound.length) {
|
|
361
|
+
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 ? "…" : ""})` })
|
|
362
|
+
}
|
|
363
|
+
bound.push({ name, bound: t.bound, tracks: clip.tracks.length, unbound: t.unbound })
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
return { doc, nodes, clipNames, bound, flippedWinding, droppedWeight }
|
|
367
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
/** A struct array's payload opens with an inner tag naming the struct. */
|
|
64
|
+
INNER_ARRAY_TAG_INFO: 500,
|
|
65
|
+
/** A property tag may carry a property guid (a has-guid byte after the extras). */
|
|
66
|
+
PROPERTY_GUID_IN_PROPERTY_TAG: 503,
|
|
67
|
+
NAME_HASHES_SERIALIZED: 504,
|
|
68
|
+
PRELOAD_DEPENDENCIES_IN_COOKED_EXPORTS: 507,
|
|
69
|
+
TEMPLATE_INDEX_IN_COOKED_EXPORTS: 508,
|
|
70
|
+
ADDED_SEARCHABLE_NAMES: 510,
|
|
71
|
+
BIG_EXPORT_SIZE_FIXES: 511,
|
|
72
|
+
ADDED_PACKAGE_SUMMARY_LOCALIZATION_ID: 516,
|
|
73
|
+
ADDED_PACKAGE_OWNER: 518,
|
|
74
|
+
NON_OUTER_PACKAGE_IMPORT: 520,
|
|
75
|
+
/** What every UE5 package carries — frozen since 4.27. */
|
|
76
|
+
FROZEN: 522,
|
|
77
|
+
} as const
|
|
78
|
+
|
|
79
|
+
/** Legacy file version: the shape of the summary's first fields. */
|
|
80
|
+
export const LEGACY = {
|
|
81
|
+
/** UE4 packages — float transforms, older export layout; not read here. */
|
|
82
|
+
UE4: -7,
|
|
83
|
+
/** UE 5.0 – 5.5: `FileVersionUE5` follows `FileVersionUE4`. */
|
|
84
|
+
UE5: -8,
|
|
85
|
+
/** UE 5.6+: same head (the UE3 field is still written); the differences are versioned below. */
|
|
86
|
+
UE5_6: -9,
|
|
87
|
+
} as const
|
|
88
|
+
|
|
89
|
+
/** The oldest `FileVersionUE4` a UE4 package may carry when a reader opts in (`{ ue4: true }`):
|
|
90
|
+
* `ADDED_PACKAGE_OWNER`, UE 4.25 — the "Bullet Impact VFX" pack, whose "UE_5.0" download is 4.25
|
|
91
|
+
* packages throughout. */
|
|
92
|
+
export const UE4_VERSION_MIN = 459
|
|
93
|
+
|
|
94
|
+
/** The newest `FileVersionUE5` this reader has verified against real files. */
|
|
95
|
+
export const UE5_VERSION_MAX = UE5.IMPORT_TYPE_HIERARCHIES
|
|
96
|
+
/** The oldest — UE 5.0. Earlier UE5 previews are refused. */
|
|
97
|
+
export const UE5_VERSION_MIN = UE5.LARGE_WORLD_COORDINATES
|
|
98
|
+
|
|
99
|
+
/** The engine release a `FileVersionUE5` belongs to, for messages ("UE 5.4" for 1011–1013). */
|
|
100
|
+
export const releaseOfVersion = (ue5: number): string => {
|
|
101
|
+
if (ue5 >= UE5.IMPORT_TYPE_HIERARCHIES) return "5.7"
|
|
102
|
+
if (ue5 >= UE5.OS_SUB_OBJECT_SHADOW_SERIALIZATION) return "5.6"
|
|
103
|
+
if (ue5 >= UE5.METADATA_SERIALIZATION_OFFSET) return "5.5"
|
|
104
|
+
if (ue5 >= UE5.PROPERTY_TAG_EXTENSION_AND_OVERRIDABLE_SERIALIZATION) return "5.4"
|
|
105
|
+
if (ue5 >= UE5.DATA_RESOURCES) return "5.3"
|
|
106
|
+
if (ue5 >= UE5.FSOFTOBJECTPATH_REMOVE_ASSET_PATH_FNAMES) return "5.1"
|
|
107
|
+
return "5.0"
|
|
108
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// The lightmap unwrap of whole GLB files — `lecodes assets unwrap` (commands/unwrap.ts) and `lecodes lightmap bake`'s
|
|
2
|
+
// pre-step (the CLI reaches it through `unwrapGlbFiles` in src/cli.ts). The geometry work is ./lightmapUv.ts.
|
|
3
|
+
//
|
|
4
|
+
// IN PLACE, not `<name>.fixed.glb` like the doctor's passes: scenes reference the original file, so a fixed copy next
|
|
5
|
+
// to it was one the owner copied over the original by hand (and the copies stayed). A file whose every primitive the
|
|
6
|
+
// unwrap would lay out already carries TEXCOORD_1 is left alone (`force` re-unwraps it). The write is atomic: a temp
|
|
7
|
+
// file beside the target, renamed over it — a failed or killed run leaves the original as it was.
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
10
|
+
import { basename, dirname, join } from "node:path"
|
|
11
|
+
import type { Document, Material, TextureInfo } from "@gltf-transform/core"
|
|
12
|
+
import { ensureCodecs, makeIO } from "../doctor/reduceGlbBones"
|
|
13
|
+
import { applyUnwrap, planNeedsUnwrap, planUnwrap, type UnwrapOptions, type UnwrapPlan, type UnwrapResult } from "./lightmapUv"
|
|
14
|
+
|
|
15
|
+
export { applyUnwrap, planNeedsUnwrap, planUnwrap, DEFAULT_UNWRAP_TEXEL, UNWRAP_PADDING } from "./lightmapUv"
|
|
16
|
+
export type { UnwrapGroup, UnwrapOptions, UnwrapPlan, UnwrapResult } from "./lightmapUv"
|
|
17
|
+
|
|
18
|
+
export interface UnwrapFileOptions extends UnwrapOptions {
|
|
19
|
+
/** unwrap again even when every primitive already carries TEXCOORD_1 */
|
|
20
|
+
force?: boolean
|
|
21
|
+
/** write here instead of over the input */
|
|
22
|
+
output?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UnwrapFileReport {
|
|
26
|
+
input: string
|
|
27
|
+
/** where the unwrapped file went; null when nothing was written */
|
|
28
|
+
output: string | null
|
|
29
|
+
status: "unwrapped" | "skipped" | "failed"
|
|
30
|
+
/** why it was skipped or failed */
|
|
31
|
+
reason?: string
|
|
32
|
+
/** one line: what the unwrap made of the file (describeUnwrap) */
|
|
33
|
+
summary: string
|
|
34
|
+
result?: UnwrapResult
|
|
35
|
+
/** primitives the unwrap leaves as they are (skinned, not a triangle list, …) */
|
|
36
|
+
kept: { name: string, reason: string }[]
|
|
37
|
+
/** TEXCOORD_1 sets the unwrap replaced (`force`, or a file that had them on some primitives only) */
|
|
38
|
+
replaced: number
|
|
39
|
+
warnings: string[]
|
|
40
|
+
ms: number
|
|
41
|
+
bytesBefore: number
|
|
42
|
+
bytesAfter: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const message = (e: unknown): string => (e instanceof Error ? e.message : String(e))
|
|
46
|
+
|
|
47
|
+
/** Materials of the planned primitives with a texture on TEXCOORD_1 (an AO map on the second set): they would sample the
|
|
48
|
+
* lightmap layout once the set is replaced. */
|
|
49
|
+
const uv1Samplers = (plan: UnwrapPlan): string[] => {
|
|
50
|
+
const names = new Set<string>()
|
|
51
|
+
const infos = (m: Material): (TextureInfo | null)[] => [
|
|
52
|
+
m.getBaseColorTextureInfo(), m.getNormalTextureInfo(), m.getOcclusionTextureInfo(), m.getEmissiveTextureInfo(), m.getMetallicRoughnessTextureInfo(),
|
|
53
|
+
]
|
|
54
|
+
for (const p of [ ...plan.prims, ...plan.lods ]) {
|
|
55
|
+
const m = p.prim.getMaterial()
|
|
56
|
+
if (m && infos(m).some((i) => i?.getTexCoord() === 1)) names.add(m.getName() || "material")
|
|
57
|
+
}
|
|
58
|
+
return [ ...names ]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Write `bytes` to `target` through a temp file beside it, renamed over it. */
|
|
62
|
+
const writeAtomic = (target: string, bytes: Uint8Array): void => {
|
|
63
|
+
const dir = dirname(target)
|
|
64
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
65
|
+
const tmp = join(dir, `.${basename(target)}.unwrap-${process.pid}.tmp`)
|
|
66
|
+
try {
|
|
67
|
+
writeFileSync(tmp, bytes)
|
|
68
|
+
renameSync(tmp, target)
|
|
69
|
+
} catch (e) {
|
|
70
|
+
try { unlinkSync(tmp) } catch { /* never written */ }
|
|
71
|
+
throw e
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Unwrap one document in memory (no file): the plan's verdict, and the result when it did. */
|
|
76
|
+
export async function unwrapDocument(doc: Document, opts: UnwrapFileOptions = {}): Promise<Omit<UnwrapFileReport, "input" | "output" | "summary" | "ms" | "bytesBefore" | "bytesAfter">> {
|
|
77
|
+
const plan = planUnwrap(doc, opts)
|
|
78
|
+
const kept = plan.skipped
|
|
79
|
+
if (plan.prims.length === 0) return { status: "skipped", reason: kept.length ? "nothing to unwrap" : "no meshes", kept, replaced: 0, warnings: [] }
|
|
80
|
+
if (!opts.force && !planNeedsUnwrap(plan)) {
|
|
81
|
+
return { status: "skipped", reason: `has TEXCOORD_1 on its ${plan.prims.length + plan.lods.length} primitive(s)`, kept, replaced: 0, warnings: [] }
|
|
82
|
+
}
|
|
83
|
+
const replaced = plan.prims.filter((p) => p.hasUv1).length + plan.lods.filter((l) => l.hasUv1).length
|
|
84
|
+
const warnings: string[] = []
|
|
85
|
+
const samplers = replaced ? uv1Samplers(plan) : []
|
|
86
|
+
if (samplers.length) warnings.push(`${samplers.join(", ")} sample${samplers.length === 1 ? "s" : ""} a texture on TEXCOORD_1 — it now reads the lightmap layout`)
|
|
87
|
+
const result = await applyUnwrap(doc, plan)
|
|
88
|
+
// glTF's top-level `scene` is optional and glTF-Transform only writes it when set; strict loaders then load no roots.
|
|
89
|
+
const root = doc.getRoot()
|
|
90
|
+
if (!root.getDefaultScene() && root.listScenes().length > 0) root.setDefaultScene(root.listScenes()[0])
|
|
91
|
+
return { status: "unwrapped", result, kept, replaced, warnings }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Unwrap one GLB file: in place unless `output` names another file. Never throws for the file's sake — a failure is the
|
|
95
|
+
* report's status. */
|
|
96
|
+
export async function unwrapGlbFile(input: string, opts: UnwrapFileOptions = {}): Promise<UnwrapFileReport> {
|
|
97
|
+
const t0 = performance.now()
|
|
98
|
+
const rep: UnwrapFileReport = { input, output: null, status: "failed", summary: "", kept: [], replaced: 0, warnings: [], ms: 0, bytesBefore: 0, bytesAfter: 0 }
|
|
99
|
+
const done = (): UnwrapFileReport => { rep.ms = performance.now() - t0; rep.summary = describeUnwrap(rep); return rep }
|
|
100
|
+
if (!existsSync(input)) { rep.reason = "not found"; return done() }
|
|
101
|
+
rep.bytesBefore = statSync(input).size
|
|
102
|
+
await ensureCodecs()
|
|
103
|
+
const io = makeIO()
|
|
104
|
+
let doc: Document
|
|
105
|
+
try { doc = await io.read(input) } catch (e) { rep.reason = `unreadable: ${message(e)}`; return done() }
|
|
106
|
+
try {
|
|
107
|
+
Object.assign(rep, await unwrapDocument(doc, opts))
|
|
108
|
+
} catch (e) {
|
|
109
|
+
rep.status = "failed"
|
|
110
|
+
rep.reason = message(e)
|
|
111
|
+
return done()
|
|
112
|
+
}
|
|
113
|
+
if (rep.status !== "unwrapped") return done()
|
|
114
|
+
const output = opts.output ?? input
|
|
115
|
+
try {
|
|
116
|
+
const bytes = await io.writeBinary(doc)
|
|
117
|
+
writeAtomic(output, bytes)
|
|
118
|
+
rep.output = output
|
|
119
|
+
rep.bytesAfter = bytes.byteLength
|
|
120
|
+
} catch (e) {
|
|
121
|
+
rep.status = "failed"
|
|
122
|
+
rep.reason = `not written: ${message(e)}`
|
|
123
|
+
}
|
|
124
|
+
return done()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Where `-o` sends an input: none = in place; one input and a path that is not a folder = that file; else the folder. */
|
|
128
|
+
export const unwrapOutputFor = (input: string, out: string | undefined, batch: boolean): string | undefined => {
|
|
129
|
+
if (!out) return undefined
|
|
130
|
+
const norm = out.replace(/\\/g, "/")
|
|
131
|
+
const asDir = batch || norm.endsWith("/") || (existsSync(norm) && statSync(norm).isDirectory())
|
|
132
|
+
return asDir ? join(norm, basename(input)).replace(/\\/g, "/") : out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Unwrap several GLB files one after the other (xatlas is single-threaded wasm; memory stays one file's). `out` as `-o`;
|
|
136
|
+
* `onFile` hears each report as it lands. */
|
|
137
|
+
export async function unwrapGlbFiles(files: string[], opts: UnwrapFileOptions & { out?: string, onFile?: (r: UnwrapFileReport) => void } = {}): Promise<UnwrapFileReport[]> {
|
|
138
|
+
const { out, onFile, ...rest } = opts
|
|
139
|
+
const reports: UnwrapFileReport[] = []
|
|
140
|
+
for (const input of files) {
|
|
141
|
+
const r = await unwrapGlbFile(input, { ...rest, output: unwrapOutputFor(input, out, files.length > 1) ?? rest.output })
|
|
142
|
+
reports.push(r)
|
|
143
|
+
onFile?.(r)
|
|
144
|
+
}
|
|
145
|
+
return reports
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const kb = (n: number): string => `${Math.round(n / 1024)} KB`
|
|
149
|
+
|
|
150
|
+
/** One line per file: what the unwrap made of it. */
|
|
151
|
+
export const describeUnwrap = (r: UnwrapFileReport): string => {
|
|
152
|
+
if (r.status !== "unwrapped" || !r.result) return `${r.status === "failed" ? "failed" : "skipped"}: ${r.reason ?? "?"}`
|
|
153
|
+
const res = r.result
|
|
154
|
+
const lod = res.lodPrimitives ? `; ${res.lodPrimitives} LOD primitive(s) by projection` : ""
|
|
155
|
+
const groups = res.groups.length ? `, ${res.groups.length} groups${res.splitNodes ? ` (${res.splitNodes} node(s) cut)` : ""}` : ""
|
|
156
|
+
return `${res.charts} charts over ${res.primitives} primitive(s)${groups}, vertices ${res.verticesBefore} → ${res.verticesAfter} (+${res.verticesAfter - res.verticesBefore} on seams)${lod}${r.replaced ? `; ${r.replaced} TEXCOORD_1 set(s) replaced` : ""}, ${(r.ms / 1000).toFixed(1)} s`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The detail lines under a file's line: groups, what was left as it was, warnings, where it went. */
|
|
160
|
+
export const detailUnwrap = (r: UnwrapFileReport): string[] => {
|
|
161
|
+
const lines: string[] = []
|
|
162
|
+
r.result?.groups.forEach((g, k) => lines.push(`group ${k}: ${g.primitives} primitive(s), ${g.area.toFixed(0)} m², ${g.charts} charts${g.texelScale > 1 ? `, texel ×${g.texelScale}` : ""}`))
|
|
163
|
+
if (r.kept.length) lines.push(`left as is: ${r.kept.slice(0, 6).map((s) => `${s.name} (${s.reason})`).join(", ")}${r.kept.length > 6 ? ` … ${r.kept.length - 6} more` : ""}`)
|
|
164
|
+
for (const w of r.warnings) lines.push(`⚠ ${w}`)
|
|
165
|
+
if (r.output) lines.push(`${r.output === r.input ? "written in place" : `written to ${r.output}`} (${kb(r.bytesBefore)} → ${kb(r.bytesAfter)})`)
|
|
166
|
+
return lines
|
|
167
|
+
}
|