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
package/src/filmstrip.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Software-skinned mesh filmstrip — the real silhouette of a GLB clip with no browser, no GPU, no
|
|
2
|
+
// engine and no native dependency. Evaluates the animation, skins the vertices on the CPU, flat-
|
|
3
|
+
// shades and z-buffers the triangles into one PNG strip (own encoder). This is the sanity check for
|
|
4
|
+
// `convert --clips`, `retarget` and `--merge-skins`: "does the character actually walk upright" is
|
|
5
|
+
// not a question a numeric check answers. `probe --filmstrip` and tools/filmstrip.ts both call it.
|
|
6
|
+
|
|
7
|
+
import { type Document } from "@gltf-transform/core"
|
|
8
|
+
import { rigFromDocument, qnorm, qslerp, type Pose, type Quat, type Vec3 } from "./retarget/rig"
|
|
9
|
+
import { encodePng } from "./textures/resolveImage"
|
|
10
|
+
|
|
11
|
+
export type FilmstripView = "side" | "front" | "top" | "threequarter" | "lowfront"
|
|
12
|
+
export type FilmstripOptions = {
|
|
13
|
+
/** clip name; null/undefined = the rest pose (one frame) */
|
|
14
|
+
clip?: string | null
|
|
15
|
+
frames?: number
|
|
16
|
+
view?: FilmstripView
|
|
17
|
+
/** pixels per frame (default 240) */
|
|
18
|
+
cell?: number
|
|
19
|
+
/** overlay the skeleton (joints + bones) */
|
|
20
|
+
bones?: boolean
|
|
21
|
+
/** the rest frame shows the BIND pose (from the IBMs) rather than the node TRS */
|
|
22
|
+
bind?: boolean
|
|
23
|
+
/** reference frames from other documents, drawn first at the same projection */
|
|
24
|
+
refs?: { doc: Document, clip?: string | null, time?: number, label?: string }[]
|
|
25
|
+
/** RGB of the mesh */
|
|
26
|
+
color?: [number, number, number]
|
|
27
|
+
}
|
|
28
|
+
export type FilmstripResult = { png: Uint8Array, width: number, height: number, label: string, duration: number }
|
|
29
|
+
|
|
30
|
+
const matMul = (a: number[], b: number[]): number[] => {
|
|
31
|
+
const o = new Array(16).fill(0)
|
|
32
|
+
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]
|
|
33
|
+
return o
|
|
34
|
+
}
|
|
35
|
+
const compose = (t: Vec3, q: Quat, s: Vec3): number[] => {
|
|
36
|
+
const [ x, y, z, w ] = q, x2 = x + x, y2 = y + y, z2 = z + z
|
|
37
|
+
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
|
|
38
|
+
return [ (1 - (yy + zz)) * s[0], (xy + wz) * s[0], (xz - wy) * s[0], 0, (xy - wz) * s[1], (1 - (xx + zz)) * s[1], (yz + wx) * s[1], 0,
|
|
39
|
+
(xz + wy) * s[2], (yz - wx) * s[2], (1 - (xx + yy)) * s[2], 0, t[0], t[1], t[2], 1 ]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Frame = { pos: Float32Array, tris: ArrayLike<number> }[]
|
|
43
|
+
type Bone = { a: Vec3, b: Vec3 }
|
|
44
|
+
|
|
45
|
+
/** Everything needed to pose + skin one document: rig, channels, skinned mesh nodes. */
|
|
46
|
+
const prepare = (doc: Document, clipName: string | null | undefined, label: string) => {
|
|
47
|
+
const rig: any = rigFromDocument(doc, label)
|
|
48
|
+
const anim = clipName ? doc.getRoot().listAnimations().find((a) => a.getName() === clipName) ?? null : null
|
|
49
|
+
if (clipName && !anim) throw new Error(`no clip "${clipName}" in ${label} (has: ${doc.getRoot().listAnimations().map((a) => a.getName()).join(", ") || "none"})`)
|
|
50
|
+
const chans = (anim?.listChannels() ?? []).map((ch) => ({
|
|
51
|
+
node: rig.nodes.indexOf(ch.getTargetNode()!), path: ch.getTargetPath(),
|
|
52
|
+
times: ch.getSampler()!.getInput()!.getArray()!, vals: ch.getSampler()!.getOutput()!.getArray()!,
|
|
53
|
+
})).filter((c) => c.node >= 0)
|
|
54
|
+
let duration = 0
|
|
55
|
+
for (const c of chans) duration = Math.max(duration, c.times[c.times.length - 1])
|
|
56
|
+
const poseAt = (t: number, base: Pose): Pose => {
|
|
57
|
+
const p: Pose = { t: base.t.map((v: Vec3) => [ ...v ] as Vec3), r: base.r.map((v: Quat) => [ ...v ] as Quat), s: base.s.map((v: Vec3) => [ ...v ] as Vec3) }
|
|
58
|
+
for (const c of chans) {
|
|
59
|
+
const n = c.path === "rotation" ? 4 : 3, last = c.times.length - 1
|
|
60
|
+
let i = 0; while (i < last && c.times[i + 1] < t) i++
|
|
61
|
+
const u = i < last ? Math.max(0, Math.min(1, (t - c.times[i]) / (c.times[i + 1] - c.times[i] || 1))) : 0
|
|
62
|
+
const a = Array.from(c.vals.slice(i * n, i * n + n)) as number[]
|
|
63
|
+
const b = i < last ? Array.from(c.vals.slice((i + 1) * n, (i + 1) * n + n)) as number[] : a
|
|
64
|
+
const v = n === 4 ? qslerp(a as Quat, b as Quat, u) : a.map((x, k) => x + (b[k] - x) * u)
|
|
65
|
+
if (c.path === "rotation") p.r[c.node] = qnorm(v as Quat)
|
|
66
|
+
else if (c.path === "translation") p.t[c.node] = v as Vec3
|
|
67
|
+
else p.s[c.node] = v as Vec3
|
|
68
|
+
}
|
|
69
|
+
return p
|
|
70
|
+
}
|
|
71
|
+
const worldMats = (pose: Pose): number[][] => {
|
|
72
|
+
const out: number[][] = new Array(rig.names.length)
|
|
73
|
+
for (const i of rig.order) {
|
|
74
|
+
const local = compose(pose.t[i], pose.r[i], pose.s[i])
|
|
75
|
+
out[i] = rig.parents[i] >= 0 ? matMul(out[rig.parents[i]], local) : local
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
// every skinned mesh node (a modular character has several); static meshes go through their node matrix
|
|
80
|
+
const skinnedNodes = rig.nodes.filter((n: any) => n.getMesh() && n.getSkin())
|
|
81
|
+
const staticNodes = rig.nodes.filter((n: any) => n.getMesh() && !n.getSkin())
|
|
82
|
+
if (skinnedNodes.length === 0 && staticNodes.length === 0) throw new Error(`${label}: no mesh to draw`)
|
|
83
|
+
const skinned = (pose: Pose): Frame => {
|
|
84
|
+
const wm = worldMats(pose)
|
|
85
|
+
const out: Frame = []
|
|
86
|
+
for (const meshNode of skinnedNodes) {
|
|
87
|
+
const skin = meshNode.getSkin(), ibmAcc = skin.getInverseBindMatrices()
|
|
88
|
+
const jointIdx = skin.listJoints().map((j: any) => rig.nodes.indexOf(j))
|
|
89
|
+
const mats = jointIdx.map((n: number, j: number) => matMul(wm[n], ibmAcc.getElement(j, [])))
|
|
90
|
+
for (const prim of meshNode.getMesh().listPrimitives()) {
|
|
91
|
+
const P = prim.getAttribute("POSITION")?.getArray(), J = prim.getAttribute("JOINTS_0")?.getArray(), W = prim.getAttribute("WEIGHTS_0")?.getArray()
|
|
92
|
+
if (!P || !J || !W) continue
|
|
93
|
+
const count = prim.getAttribute("POSITION").getCount()
|
|
94
|
+
const o = new Float32Array(count * 3)
|
|
95
|
+
for (let v = 0; v < count; v++) {
|
|
96
|
+
let x = 0, y = 0, z = 0
|
|
97
|
+
for (let k = 0; k < 4; k++) {
|
|
98
|
+
const w = W[v * 4 + k]
|
|
99
|
+
if (!w) continue
|
|
100
|
+
const m = mats[J[v * 4 + k]]
|
|
101
|
+
if (!m) continue
|
|
102
|
+
const px = P[v * 3], py = P[v * 3 + 1], pz = P[v * 3 + 2]
|
|
103
|
+
x += w * (m[0] * px + m[4] * py + m[8] * pz + m[12]); y += w * (m[1] * px + m[5] * py + m[9] * pz + m[13]); z += w * (m[2] * px + m[6] * py + m[10] * pz + m[14])
|
|
104
|
+
}
|
|
105
|
+
o[v * 3] = x; o[v * 3 + 1] = y; o[v * 3 + 2] = z
|
|
106
|
+
}
|
|
107
|
+
out.push({ pos: o, tris: prim.getIndices()?.getArray() ?? seq(count) })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const meshNode of staticNodes) {
|
|
111
|
+
const m = wm[rig.nodes.indexOf(meshNode)]
|
|
112
|
+
for (const prim of meshNode.getMesh().listPrimitives()) {
|
|
113
|
+
const P = prim.getAttribute("POSITION")?.getArray()
|
|
114
|
+
if (!P) continue
|
|
115
|
+
const count = prim.getAttribute("POSITION").getCount(), o = new Float32Array(count * 3)
|
|
116
|
+
for (let v = 0; v < count; v++) {
|
|
117
|
+
const px = P[v * 3], py = P[v * 3 + 1], pz = P[v * 3 + 2]
|
|
118
|
+
o[v * 3] = m[0] * px + m[4] * py + m[8] * pz + m[12]; o[v * 3 + 1] = m[1] * px + m[5] * py + m[9] * pz + m[13]; o[v * 3 + 2] = m[2] * px + m[6] * py + m[10] * pz + m[14]
|
|
119
|
+
}
|
|
120
|
+
out.push({ pos: o, tris: prim.getIndices()?.getArray() ?? seq(count) })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
const bonesOf = (pose: Pose): Bone[] => {
|
|
126
|
+
const wm = worldMats(pose)
|
|
127
|
+
const out: Bone[] = []
|
|
128
|
+
for (let i = 0; i < rig.names.length; i++) {
|
|
129
|
+
const p = rig.parents[i]
|
|
130
|
+
if (p < 0 || !rig.bind.fromSkin[i]) continue
|
|
131
|
+
out.push({ a: [ wm[p][12], wm[p][13], wm[p][14] ], b: [ wm[i][12], wm[i][13], wm[i][14] ] })
|
|
132
|
+
}
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
return { rig, anim, duration, poseAt, skinned, bonesOf }
|
|
136
|
+
}
|
|
137
|
+
const seq = (n: number): Uint32Array => { const a = new Uint32Array(n); for (let i = 0; i < n; i++) a[i] = i; return a }
|
|
138
|
+
|
|
139
|
+
// 3×5 glyphs for the time labels ("0.25s") and the header — enough without a font library.
|
|
140
|
+
const FONT: Record<string, number[]> = {
|
|
141
|
+
"0": [ 7, 5, 5, 5, 7 ], "1": [ 2, 6, 2, 2, 7 ], "2": [ 7, 1, 7, 4, 7 ], "3": [ 7, 1, 7, 1, 7 ], "4": [ 5, 5, 7, 1, 1 ],
|
|
142
|
+
"5": [ 7, 4, 7, 1, 7 ], "6": [ 7, 4, 7, 5, 7 ], "7": [ 7, 1, 1, 1, 1 ], "8": [ 7, 5, 7, 5, 7 ], "9": [ 7, 5, 7, 1, 7 ],
|
|
143
|
+
".": [ 0, 0, 0, 0, 2 ], "s": [ 0, 3, 4, 2, 6 ], " ": [ 0, 0, 0, 0, 0 ], "-": [ 0, 0, 7, 0, 0 ], ":": [ 0, 2, 0, 2, 0 ], "@": [ 7, 5, 7, 4, 7 ],
|
|
144
|
+
}
|
|
145
|
+
const drawText = (img: Uint8Array, W: number, x: number, y: number, text: string, rgb: [number, number, number], scale = 2): void => {
|
|
146
|
+
for (const ch of text.toLowerCase()) {
|
|
147
|
+
const g = FONT[ch] ?? FONT[" "]
|
|
148
|
+
for (let r = 0; r < 5; r++) for (let c = 0; c < 3; c++) if (g[r] & (4 >> c)) {
|
|
149
|
+
for (let sy = 0; sy < scale; sy++) for (let sx = 0; sx < scale; sx++) {
|
|
150
|
+
const px = x + c * scale + sx, py = y + r * scale + sy
|
|
151
|
+
const i = (py * W + px) * 4
|
|
152
|
+
if (i >= 0 && i + 3 < img.length) { img[i] = rgb[0]; img[i + 1] = rgb[1]; img[i + 2] = rgb[2] }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
x += 4 * scale
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const drawLine = (img: Uint8Array, W: number, H: number, x0: number, y0: number, x1: number, y1: number, rgb: [number, number, number]): void => {
|
|
159
|
+
const n = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))))
|
|
160
|
+
for (let i = 0; i <= n; i++) {
|
|
161
|
+
const x = Math.round(x0 + (x1 - x0) * i / n), y = Math.round(y0 + (y1 - y0) * i / n)
|
|
162
|
+
if (x < 0 || y < 0 || x >= W || y >= H) continue
|
|
163
|
+
const o = (y * W + x) * 4
|
|
164
|
+
img[o] = rgb[0]; img[o + 1] = rgb[1]; img[o + 2] = rgb[2]
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const renderFilmstrip = (doc: Document, opts: FilmstripOptions = {}): FilmstripResult => {
|
|
169
|
+
const VIEW = opts.view ?? "side"
|
|
170
|
+
const main = prepare(doc, opts.clip, "model")
|
|
171
|
+
const N = main.anim ? Math.max(1, Math.floor(opts.frames ?? 8)) : 1 // the rest pose is one frame
|
|
172
|
+
const restPose: Pose = opts.bind ? main.rig.bind.local : main.rig.rest
|
|
173
|
+
const times = Array.from({ length: N }, (_, i) => (main.anim ? (i / Math.max(1, N - 1)) * main.duration : 0))
|
|
174
|
+
const frames: Frame[] = times.map((t) => main.skinned(main.anim ? main.poseAt(t, main.rig.rest) : restPose))
|
|
175
|
+
const boneLines: Bone[][] = opts.bones ? times.map((t) => main.bonesOf(main.anim ? main.poseAt(t, main.rig.rest) : restPose)) : []
|
|
176
|
+
const labels = times.map((t) => (main.anim ? `${t.toFixed(2)}s` : "rest"))
|
|
177
|
+
// reference frames (other files / clips) first, same projection
|
|
178
|
+
for (const ref of [ ...(opts.refs ?? []) ].reverse()) {
|
|
179
|
+
const r = prepare(ref.doc, ref.clip, ref.label ?? "ref")
|
|
180
|
+
frames.unshift(r.skinned(r.anim ? r.poseAt(ref.time ?? 0, r.rig.rest) : r.rig.rest))
|
|
181
|
+
if (opts.bones) boneLines.unshift([])
|
|
182
|
+
labels.unshift(ref.label ?? (ref.clip ? `${ref.clip}@${ref.time ?? 0}` : "ref"))
|
|
183
|
+
}
|
|
184
|
+
const NF = frames.length
|
|
185
|
+
|
|
186
|
+
// one projection for the whole strip, from the union of every frame's bounds
|
|
187
|
+
const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
|
|
188
|
+
for (const f of frames) for (const p of f) for (let v = 0; v < p.pos.length; v += 3)
|
|
189
|
+
for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i], p.pos[v + i]); hi[i] = Math.max(hi[i], p.pos[v + i]) }
|
|
190
|
+
if (!Number.isFinite(lo[0])) { lo[0] = lo[1] = lo[2] = -1; hi[0] = hi[1] = hi[2] = 1 }
|
|
191
|
+
|
|
192
|
+
const CELL = opts.cell ?? 240, PAD = 10, HEAD = 20
|
|
193
|
+
const bases: Record<string, { r: Vec3, u: Vec3, d: Vec3 }> = {
|
|
194
|
+
side: { r: [ 0, 0, 1 ], u: [ 0, 1, 0 ], d: [ -1, 0, 0 ] },
|
|
195
|
+
front: { r: [ 1, 0, 0 ], u: [ 0, 1, 0 ], d: [ 0, 0, -1 ] },
|
|
196
|
+
top: { r: [ 0, 0, 1 ], u: [ 1, 0, 0 ], d: [ 0, -1, 0 ] },
|
|
197
|
+
threequarter: { r: [ 0.7, 0, 0.71 ], u: [ -0.2, 0.96, 0.2 ], d: [ -0.68, -0.28, 0.68 ] },
|
|
198
|
+
lowfront: { r: [ 1, 0, 0 ], u: [ 0, 0.87, -0.5 ], d: [ 0, 0.5, -0.87 ] },
|
|
199
|
+
}
|
|
200
|
+
const B = bases[VIEW] ?? bases.side
|
|
201
|
+
const dot = (a: Vec3, b: ArrayLike<number>): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
202
|
+
const corners: Vec3[] = []
|
|
203
|
+
for (let i = 0; i < 8; i++) corners.push([ i & 1 ? hi[0] : lo[0], i & 2 ? hi[1] : lo[1], i & 4 ? hi[2] : lo[2] ])
|
|
204
|
+
const ru = corners.map((c) => [ dot(B.r, c), dot(B.u, c) ])
|
|
205
|
+
const rlo = [ Math.min(...ru.map((p) => p[0])), Math.min(...ru.map((p) => p[1])) ]
|
|
206
|
+
const rhi = [ Math.max(...ru.map((p) => p[0])), Math.max(...ru.map((p) => p[1])) ]
|
|
207
|
+
const k = (CELL - 2 * PAD) / Math.max(rhi[0] - rlo[0], rhi[1] - rlo[1], 1e-9)
|
|
208
|
+
const sx = (ox: number, q: ArrayLike<number>): number => ox + PAD + (dot(B.r, q) - rlo[0]) * k + (CELL - 2 * PAD - (rhi[0] - rlo[0]) * k) / 2
|
|
209
|
+
const sy = (q: ArrayLike<number>): number => HEAD + CELL - PAD - (dot(B.u, q) - rlo[1]) * k
|
|
210
|
+
|
|
211
|
+
const W = CELL * NF, H = CELL + HEAD
|
|
212
|
+
const img = new Uint8Array(W * H * 4)
|
|
213
|
+
for (let i = 0; i < W * H; i++) { img[i * 4] = 17; img[i * 4 + 1] = 21; img[i * 4 + 2] = 28; img[i * 4 + 3] = 255 }
|
|
214
|
+
const depth = new Float32Array(W * H).fill(Infinity)
|
|
215
|
+
const light: Vec3 = [ -0.4, 0.75, 0.53 ]
|
|
216
|
+
const base = opts.color ?? [ 214, 126, 58 ]
|
|
217
|
+
frames.forEach((frame, f) => {
|
|
218
|
+
const ox = f * CELL
|
|
219
|
+
for (const { pos, tris } of frame) {
|
|
220
|
+
for (let t = 0; t + 2 < tris.length; t += 3) {
|
|
221
|
+
const p: number[][] = []
|
|
222
|
+
for (let c = 0; c < 3; c++) { const v = tris[t + c] * 3; p.push([ pos[v], pos[v + 1], pos[v + 2] ]) }
|
|
223
|
+
const e1 = [ p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2] ], e2 = [ p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2] ]
|
|
224
|
+
const nx = e1[1] * e2[2] - e1[2] * e2[1], ny = e1[2] * e2[0] - e1[0] * e2[2], nz = e1[0] * e2[1] - e1[1] * e2[0]
|
|
225
|
+
const nl = Math.hypot(nx, ny, nz) || 1
|
|
226
|
+
const shade = 0.22 + 0.78 * Math.abs((nx / nl) * light[0] + (ny / nl) * light[1] + (nz / nl) * light[2])
|
|
227
|
+
const col = [ Math.round(base[0] * shade), Math.round(base[1] * shade), Math.round(base[2] * shade) ]
|
|
228
|
+
const sp = p.map((q) => [ sx(ox, q), sy(q), -dot(B.d, q) ])
|
|
229
|
+
const minX = Math.max(ox, Math.floor(Math.min(sp[0][0], sp[1][0], sp[2][0]))), maxX = Math.min(ox + CELL - 1, Math.ceil(Math.max(sp[0][0], sp[1][0], sp[2][0])))
|
|
230
|
+
const minY = Math.max(HEAD, Math.floor(Math.min(sp[0][1], sp[1][1], sp[2][1]))), maxY = Math.min(H - 1, Math.ceil(Math.max(sp[0][1], sp[1][1], sp[2][1])))
|
|
231
|
+
const d = (sp[1][0] - sp[0][0]) * (sp[2][1] - sp[0][1]) - (sp[2][0] - sp[0][0]) * (sp[1][1] - sp[0][1])
|
|
232
|
+
if (Math.abs(d) < 1e-12) continue
|
|
233
|
+
for (let y = minY; y <= maxY; y++) for (let x = minX; x <= maxX; x++) {
|
|
234
|
+
const px = x + 0.5, py = y + 0.5
|
|
235
|
+
const w0 = ((sp[1][0] - px) * (sp[2][1] - py) - (sp[2][0] - px) * (sp[1][1] - py)) / d
|
|
236
|
+
const w1 = ((sp[2][0] - px) * (sp[0][1] - py) - (sp[0][0] - px) * (sp[2][1] - py)) / d
|
|
237
|
+
const w2 = 1 - w0 - w1
|
|
238
|
+
if (w0 < 0 || w1 < 0 || w2 < 0) continue
|
|
239
|
+
const z = w0 * sp[0][2] + w1 * sp[1][2] + w2 * sp[2][2]
|
|
240
|
+
const i = y * W + x
|
|
241
|
+
if (z >= depth[i]) continue
|
|
242
|
+
depth[i] = z
|
|
243
|
+
img[i * 4] = col[0]; img[i * 4 + 1] = col[1]; img[i * 4 + 2] = col[2]
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
// overlays: cell borders, bones, labels
|
|
249
|
+
for (let f = 0; f < NF; f++) {
|
|
250
|
+
const ox = f * CELL
|
|
251
|
+
drawLine(img, W, H, ox, HEAD, ox, H - 1, [ 35, 44, 56 ])
|
|
252
|
+
drawLine(img, W, H, ox, HEAD, ox + CELL - 1, HEAD, [ 35, 44, 56 ])
|
|
253
|
+
for (const { a, b } of boneLines[f] ?? []) {
|
|
254
|
+
drawLine(img, W, H, sx(ox, a), sy(a), sx(ox, b), sy(b), [ 127, 224, 255 ])
|
|
255
|
+
const bx = Math.round(sx(ox, b)), by = Math.round(sy(b))
|
|
256
|
+
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (bx + dx >= 0 && by + dy >= 0 && bx + dx < W && by + dy < H) { const o = ((by + dy) * W + bx + dx) * 4; img[o] = 255; img[o + 1] = 255; img[o + 2] = 255 }
|
|
257
|
+
}
|
|
258
|
+
drawText(img, W, ox + 6, HEAD + 6, labels[f], [ 140, 150, 170 ])
|
|
259
|
+
}
|
|
260
|
+
const label = `${main.anim ? main.anim.getName() : "rest pose"} · ${VIEW} view · ${NF} frames`
|
|
261
|
+
drawText(img, W, 6, 5, main.anim ? `${main.duration.toFixed(2)}s - ${N}` : "-", [ 136, 170, 170 ]) // the 3×5 font has digits, '.', 's', '-' only
|
|
262
|
+
return { png: encodePng({ width: W, height: H, data: img }), width: W, height: H, label, duration: main.duration }
|
|
263
|
+
}
|
package/src/geometry.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// geometry — a GLB's triangles as ONE plain vertex buffer the SDK's `Geometry` can hold
|
|
2
|
+
// (`lecodes assets geometry casing.glb` → casing.geometry.json → `Geometry.load(asset(...))`).
|
|
3
|
+
// Made for small debris the particle system draws per particle (shell casings, rubble, coins):
|
|
4
|
+
// every mesh node's world transform is baked in, primitives are merged, and the result is what
|
|
5
|
+
// `Geometry` carries — positions, normals, one UV set, uint16 indices. Materials, skins and
|
|
6
|
+
// animations are dropped on purpose: the particle material is the SDK's.
|
|
7
|
+
|
|
8
|
+
import type { Document, Node, Primitive } from "@gltf-transform/core"
|
|
9
|
+
|
|
10
|
+
export type GeometryFile = {
|
|
11
|
+
format: "lecodes-geometry"
|
|
12
|
+
version: 1
|
|
13
|
+
/** xyz per vertex, metres, Y-up. */
|
|
14
|
+
vertices: number[]
|
|
15
|
+
/** xyz per vertex, unit length. */
|
|
16
|
+
normals: number[]
|
|
17
|
+
/** uv per vertex (zeros when the source has no TEXCOORD_0). */
|
|
18
|
+
uv: number[]
|
|
19
|
+
/** Triangle list, 3 per face, counter-clockwise front faces. */
|
|
20
|
+
indices: number[]
|
|
21
|
+
bounds: { min: [number, number, number], max: [number, number, number] }
|
|
22
|
+
/** What went in — for the reader's eyes only. */
|
|
23
|
+
source?: { file?: string, nodes: string[] }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ExtractOptions = {
|
|
27
|
+
/** Take only this node's subtree (name, exact match). Default: every mesh in the scene. */
|
|
28
|
+
node?: string
|
|
29
|
+
/** Re-origin the result: `center` = bounds centre at 0, `bottom` = footprint centre at X/Z = 0
|
|
30
|
+
* and the lowest point at Y = 0. Default: keep the file's placement. */
|
|
31
|
+
origin?: "keep" | "center" | "bottom"
|
|
32
|
+
/** Uniform scale applied after the origin move (a casing modelled in cm → 0.01). */
|
|
33
|
+
scale?: number
|
|
34
|
+
/** Significant digits written to the file (default 6). */
|
|
35
|
+
precision?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ExtractReport = {
|
|
39
|
+
nodes: string[]
|
|
40
|
+
vertices: number
|
|
41
|
+
triangles: number
|
|
42
|
+
size: [number, number, number]
|
|
43
|
+
warnings: string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The SDK caps a Geometry's index buffer at uint16, and the particle vertex stream at ~1 M
|
|
47
|
+
* vertices per system — a debris mesh past this is the wrong asset for the job anyway. */
|
|
48
|
+
export const MAX_VERTICES = 65535
|
|
49
|
+
|
|
50
|
+
type Mat4 = number[]
|
|
51
|
+
|
|
52
|
+
const mul3 = (m: Mat4, x: number, y: number, z: number, w: number): [number, number, number] => [
|
|
53
|
+
m[0] * x + m[4] * y + m[8] * z + m[12] * w,
|
|
54
|
+
m[1] * x + m[5] * y + m[9] * z + m[13] * w,
|
|
55
|
+
m[2] * x + m[6] * y + m[10] * z + m[14] * w,
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
/** Inverse-transpose of the upper 3×3 (row-major 3×3 for `mulN`), null when singular. */
|
|
59
|
+
const normalMatrix = (m: Mat4): number[] | null => {
|
|
60
|
+
const a = m[0], b = m[4], c = m[8], d = m[1], e = m[5], f = m[9], g = m[2], h = m[6], i = m[10]
|
|
61
|
+
const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
|
|
62
|
+
if (Math.abs(det) < 1e-20) return null
|
|
63
|
+
const inv = [
|
|
64
|
+
(e * i - f * h) / det, (c * h - b * i) / det, (b * f - c * e) / det,
|
|
65
|
+
(f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det,
|
|
66
|
+
(d * h - e * g) / det, (b * g - a * h) / det, (a * e - b * d) / det,
|
|
67
|
+
]
|
|
68
|
+
// transpose(inverse): n' = inv^T · n → n'[r] = Σ inv[c*3 + r] · n[c]
|
|
69
|
+
return [ inv[0], inv[3], inv[6], inv[1], inv[4], inv[7], inv[2], inv[5], inv[8] ]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const det3 = (m: Mat4): number =>
|
|
73
|
+
m[0] * (m[5] * m[10] - m[6] * m[9]) - m[4] * (m[1] * m[10] - m[2] * m[9]) + m[8] * (m[1] * m[6] - m[2] * m[5])
|
|
74
|
+
|
|
75
|
+
const meshNodes = (doc: Document, only?: string): { nodes: Node[], missing: boolean } => {
|
|
76
|
+
const all = doc.getRoot().listNodes()
|
|
77
|
+
if (!only) return { nodes: all.filter((n) => n.getMesh()), missing: false }
|
|
78
|
+
const root = all.find((n) => n.getName() === only)
|
|
79
|
+
if (!root) return { nodes: [], missing: true }
|
|
80
|
+
const out: Node[] = []
|
|
81
|
+
root.traverse((n) => { if (n.getMesh()) out.push(n) })
|
|
82
|
+
return { nodes: out, missing: false }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const round = (v: number, precision: number): number => {
|
|
86
|
+
const r = Number(v.toPrecision(precision))
|
|
87
|
+
return r === 0 ? 0 : r // no "-0" in the file
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Smooth, area-weighted vertex normals for a primitive that ships none. */
|
|
91
|
+
const computeNormals = (pos: Float32Array, idx: Uint32Array): Float32Array => {
|
|
92
|
+
const n = new Float32Array(pos.length)
|
|
93
|
+
for (let t = 0; t < idx.length; t += 3) {
|
|
94
|
+
const a = idx[t] * 3, b = idx[t + 1] * 3, c = idx[t + 2] * 3
|
|
95
|
+
const ux = pos[b] - pos[a], uy = pos[b + 1] - pos[a + 1], uz = pos[b + 2] - pos[a + 2]
|
|
96
|
+
const vx = pos[c] - pos[a], vy = pos[c + 1] - pos[a + 1], vz = pos[c + 2] - pos[a + 2]
|
|
97
|
+
const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
|
|
98
|
+
for (const k of [ a, b, c ]) { n[k] += nx; n[k + 1] += ny; n[k + 2] += nz }
|
|
99
|
+
}
|
|
100
|
+
for (let i = 0; i < n.length; i += 3) {
|
|
101
|
+
const l = Math.hypot(n[i], n[i + 1], n[i + 2]) || 1
|
|
102
|
+
n[i] /= l; n[i + 1] /= l; n[i + 2] /= l
|
|
103
|
+
}
|
|
104
|
+
return n
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const primitiveIndices = (prim: Primitive, count: number): Uint32Array => {
|
|
108
|
+
const acc = prim.getIndices()
|
|
109
|
+
if (acc) return Uint32Array.from(acc.getArray() as ArrayLike<number>)
|
|
110
|
+
const seq = new Uint32Array(count)
|
|
111
|
+
for (let i = 0; i < count; i++) seq[i] = i
|
|
112
|
+
return seq
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The merged, world-space triangle buffer of `doc` (or of one node's subtree). */
|
|
116
|
+
export const extractGeometry = (doc: Document, opts: ExtractOptions = {}): { data: GeometryFile, report: ExtractReport } => {
|
|
117
|
+
const precision = opts.precision ?? 6
|
|
118
|
+
const warnings: string[] = []
|
|
119
|
+
const { nodes, missing } = meshNodes(doc, opts.node)
|
|
120
|
+
if (missing) throw new Error(`no node "${opts.node}" in the file (--list-nodes prints them)`)
|
|
121
|
+
if (nodes.length === 0) throw new Error(opts.node ? `node "${opts.node}" has no mesh` : "the file has no mesh")
|
|
122
|
+
|
|
123
|
+
const V: number[] = [], N: number[] = [], UV: number[] = [], I: number[] = []
|
|
124
|
+
const used: string[] = []
|
|
125
|
+
for (const node of nodes) {
|
|
126
|
+
const mesh = node.getMesh()!
|
|
127
|
+
const world = Array.from(node.getWorldMatrix()) as Mat4
|
|
128
|
+
const nm = normalMatrix(world)
|
|
129
|
+
if (!nm) { warnings.push(`${node.getName() || "(unnamed)"}: zero-scale transform, skipped`); continue }
|
|
130
|
+
const flip = det3(world) < 0
|
|
131
|
+
if (node.getSkin()) warnings.push(`${node.getName() || "(unnamed)"}: skinned — baked in its bind pose`)
|
|
132
|
+
let took = false
|
|
133
|
+
for (const prim of mesh.listPrimitives()) {
|
|
134
|
+
const mode = prim.getMode()
|
|
135
|
+
if (mode !== 4) { warnings.push(`${node.getName() || "(unnamed)"}: primitive mode ${mode} (not TRIANGLES) skipped`); continue }
|
|
136
|
+
const posAcc = prim.getAttribute("POSITION")
|
|
137
|
+
if (!posAcc) continue
|
|
138
|
+
const pos = Float32Array.from(posAcc.getArray() as ArrayLike<number>)
|
|
139
|
+
const count = pos.length / 3
|
|
140
|
+
const idx = primitiveIndices(prim, count)
|
|
141
|
+
const nrmAcc = prim.getAttribute("NORMAL")
|
|
142
|
+
const nrm = nrmAcc ? Float32Array.from(nrmAcc.getArray() as ArrayLike<number>) : computeNormals(pos, idx)
|
|
143
|
+
const uvAcc = prim.getAttribute("TEXCOORD_0")
|
|
144
|
+
const uv = uvAcc ? Float32Array.from(uvAcc.getArray() as ArrayLike<number>) : new Float32Array(count * 2)
|
|
145
|
+
if (uvAcc && uvAcc.getNormalized()) warnings.push(`${node.getName() || "(unnamed)"}: TEXCOORD_0 is a normalized integer set, read as floats`)
|
|
146
|
+
const base = V.length / 3
|
|
147
|
+
for (let i = 0; i < count; i++) {
|
|
148
|
+
const p = mul3(world, pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2], 1)
|
|
149
|
+
V.push(p[0], p[1], p[2])
|
|
150
|
+
const nx = nrm[i * 3], ny = nrm[i * 3 + 1], nz = nrm[i * 3 + 2]
|
|
151
|
+
let tx = nm[0] * nx + nm[1] * ny + nm[2] * nz
|
|
152
|
+
let ty = nm[3] * nx + nm[4] * ny + nm[5] * nz
|
|
153
|
+
let tz = nm[6] * nx + nm[7] * ny + nm[8] * nz
|
|
154
|
+
const l = Math.hypot(tx, ty, tz) || 1
|
|
155
|
+
tx /= l; ty /= l; tz /= l
|
|
156
|
+
N.push(tx, ty, tz)
|
|
157
|
+
UV.push(uv[i * 2], uv[i * 2 + 1])
|
|
158
|
+
}
|
|
159
|
+
for (let t = 0; t < idx.length; t += 3) {
|
|
160
|
+
// a mirrored transform turns the winding inside out — swap two corners to keep front faces
|
|
161
|
+
if (flip) I.push(base + idx[t], base + idx[t + 2], base + idx[t + 1])
|
|
162
|
+
else I.push(base + idx[t], base + idx[t + 1], base + idx[t + 2])
|
|
163
|
+
}
|
|
164
|
+
took = true
|
|
165
|
+
}
|
|
166
|
+
if (took) used.push(node.getName() || "(unnamed)")
|
|
167
|
+
}
|
|
168
|
+
const vertexCount = V.length / 3
|
|
169
|
+
if (vertexCount === 0) throw new Error("no triangles to take")
|
|
170
|
+
if (vertexCount > MAX_VERTICES) throw new Error(`${vertexCount} vertices — a Geometry holds at most ${MAX_VERTICES} (uint16 indices). Pick one node (--node) or decimate the asset first`)
|
|
171
|
+
|
|
172
|
+
// origin + scale, then bounds
|
|
173
|
+
const min: [number, number, number] = [ Infinity, Infinity, Infinity ], max: [number, number, number] = [ -Infinity, -Infinity, -Infinity ]
|
|
174
|
+
const bounds = (): void => {
|
|
175
|
+
min.fill(Infinity); max.fill(-Infinity)
|
|
176
|
+
for (let i = 0; i < V.length; i += 3) for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], V[i + k]); max[k] = Math.max(max[k], V[i + k]) }
|
|
177
|
+
}
|
|
178
|
+
bounds()
|
|
179
|
+
const origin = opts.origin ?? "keep"
|
|
180
|
+
if (origin !== "keep") {
|
|
181
|
+
const dx = (min[0] + max[0]) / 2, dz = (min[2] + max[2]) / 2
|
|
182
|
+
const dy = origin === "center" ? (min[1] + max[1]) / 2 : min[1]
|
|
183
|
+
for (let i = 0; i < V.length; i += 3) { V[i] -= dx; V[i + 1] -= dy; V[i + 2] -= dz }
|
|
184
|
+
}
|
|
185
|
+
const scale = opts.scale ?? 1
|
|
186
|
+
if (scale !== 1) for (let i = 0; i < V.length; i++) V[i] *= scale
|
|
187
|
+
bounds()
|
|
188
|
+
|
|
189
|
+
const data: GeometryFile = {
|
|
190
|
+
format: "lecodes-geometry",
|
|
191
|
+
version: 1,
|
|
192
|
+
vertices: V.map((v) => round(v, precision)),
|
|
193
|
+
normals: N.map((v) => round(v, 4)),
|
|
194
|
+
uv: UV.map((v) => round(v, precision)),
|
|
195
|
+
indices: I,
|
|
196
|
+
bounds: { min: min.map((v) => round(v, precision)) as typeof min, max: max.map((v) => round(v, precision)) as typeof max },
|
|
197
|
+
source: { nodes: used },
|
|
198
|
+
}
|
|
199
|
+
const report: ExtractReport = {
|
|
200
|
+
nodes: used, vertices: vertexCount, triangles: I.length / 3,
|
|
201
|
+
size: [ max[0] - min[0], max[1] - min[1], max[2] - min[2] ], warnings,
|
|
202
|
+
}
|
|
203
|
+
return { data, report }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Compact JSON: one number list per line, so the file diffs and reads like an asset, not a dump. */
|
|
207
|
+
export const formatGeometryFile = (g: GeometryFile): string => {
|
|
208
|
+
const list = (a: number[]): string => `[${a.join(",")}]`
|
|
209
|
+
return [
|
|
210
|
+
"{",
|
|
211
|
+
` "format": ${JSON.stringify(g.format)},`,
|
|
212
|
+
` "version": ${g.version},`,
|
|
213
|
+
` "bounds": ${JSON.stringify(g.bounds)},`,
|
|
214
|
+
...(g.source ? [ ` "source": ${JSON.stringify(g.source)},` ] : []),
|
|
215
|
+
` "vertices": ${list(g.vertices)},`,
|
|
216
|
+
` "normals": ${list(g.normals)},`,
|
|
217
|
+
` "uv": ${list(g.uv)},`,
|
|
218
|
+
` "indices": ${list(g.indices)}`,
|
|
219
|
+
"}",
|
|
220
|
+
"",
|
|
221
|
+
].join("\n")
|
|
222
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// lecodes-assets — programmatic API. CLI: src/cli.ts (`lecodes-assets …` / `lecodes assets …`).
|
|
2
|
+
export { mirrorClips, partnerName, type MirrorOptions, type MirrorReport } from './retarget/mirror'
|
|
3
|
+
export { readFbx, type FbxScene } from "./fbx/read"
|
|
4
|
+
export { toDocument, type ToDocumentOptions, type ConvertWarning } from "./fbx/toDocument"
|
|
5
|
+
export { convertFbx, mergeClipScene, writeGlb, makeIO, type ConvertOptions, type ConvertReport, type ConvertResult } from "./fbx/convert"
|
|
6
|
+
export { probeFile, probeFbx, probeDocument } from "./probe"
|
|
7
|
+
export { analyzeDoc, applyPlan, reportPlan, dumpTextures, type DoctorPlan, type DoctorOptions } from "./doctor/doctor"
|
|
8
|
+
export { validateGlb, formatReport } from "./doctor/validateGlb"
|
|
9
|
+
export { unwrapGlbFile, unwrapGlbFiles, unwrapDocument, planUnwrap, applyUnwrap, type UnwrapOptions, type UnwrapFileOptions, type UnwrapFileReport, type UnwrapResult } from "./unwrap"
|
|
10
|
+
export { planMerge, applyMerge, findKtxTool, type MergeOptions, type MergePlan, type MergeResult } from "./doctor/merge"
|
|
11
|
+
export { measureScale, applyScale, sceneBounds, type ScaleInfo } from "./doctor/normalizeScale"
|
|
12
|
+
export { loadRig, rigFromFbx, rigFromDocument, checkUniformScale, type Rig, type DocRig } from "./retarget/rig"
|
|
13
|
+
export { retarget, analyzeFacing, facingMismatch, bindAccessors, type BoneMap, type RetargetOptions, type RetargetReport, type AlignPolicy, type DonorRef, type LegReport, type ClipContacts, type ContactSpan, type RetargetEvent } from "./retarget/retarget"
|
|
14
|
+
export { makeInPlace, speedProfile, yawProfile, type InPlaceResult, type InPlaceOptions } from "./retarget/inPlace"
|
|
15
|
+
export { detectMarks, fitStaircase, readMarks, stairsMatcher, describeMarks, type ClipMarks, type MarkSpan, type Stairs, type FootTrack, type MarksInput } from "./retarget/marks"
|
|
16
|
+
export { writeMarks, type WriteMarksOptions } from "./retarget/writeMarks"
|
|
17
|
+
export { autoMap, classify, describeSkeleton, type AutoMapResult, type Skeleton } from "./retarget/automap"
|
|
18
|
+
export { packTerrainTextures, resolveTerrainLayer, type TerrainPackLayer, type TerrainPackOptions, type TerrainPackResult } from "./terrainPack"
|
|
19
|
+
export { runAssetsCli } from "./cli"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Modular characters: the body, the shirt, the gloves and the boots each come as their OWN skinned
|
|
2
|
+
// FBX over the same skeleton. `mergeSkins` moves the donors' skinned meshes onto the base skeleton
|
|
3
|
+
// (joints matched by name — exact, then the part after the last ':'/'|', the Animator's own rule)
|
|
4
|
+
// and drops the donors' skeleton copies entirely. Leaving those copies in is the classic silent
|
|
5
|
+
// failure: every bone name exists twice, a name-bound Animator finds the unskinned copy first, and
|
|
6
|
+
// the mesh freezes in its rest pose. See docs/animation-plan.md §2.9.
|
|
7
|
+
|
|
8
|
+
import { Logger, type Document, type Node, type Skin } from "@gltf-transform/core"
|
|
9
|
+
import { mergeDocuments, prune, unpartition } from "@gltf-transform/functions"
|
|
10
|
+
|
|
11
|
+
export type MergeSkinsWarning = { level: "warn" | "info", message: string }
|
|
12
|
+
export type MergeSkinsReport = {
|
|
13
|
+
/** per donor: meshes moved, joints that had no counterpart in the base, total skin weight lost to them */
|
|
14
|
+
donors: { label: string, meshes: number, missingJoints: string[], lostWeight: number }[]
|
|
15
|
+
joints: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const shortName = (n: string): string => n.slice(n.search(/[^:|]*$/))
|
|
19
|
+
|
|
20
|
+
const findSkinnedNode = (doc: Document): Node | null => {
|
|
21
|
+
let found: Node | null = null
|
|
22
|
+
for (const scene of doc.getRoot().listScenes()) scene.traverse((n) => { if (!found && n.getSkin() && n.getMesh()) found = n })
|
|
23
|
+
return found
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Merge the skinned meshes of `donors` into `base` (mutated). Donor documents are consumed. */
|
|
27
|
+
export const mergeSkins = async (base: Document, donors: { doc: Document, label: string }[], warn: (w: MergeSkinsWarning) => void = () => {}): Promise<MergeSkinsReport> => {
|
|
28
|
+
const baseSkin: Skin | undefined = base.getRoot().listSkins()[0]
|
|
29
|
+
if (!baseSkin) throw new Error("the base model has no skin — --merge-skins needs a skinned base (the body)")
|
|
30
|
+
const baseJoints = baseSkin.listJoints()
|
|
31
|
+
const byName = new Map<string, number>(), byShort = new Map<string, number>()
|
|
32
|
+
baseJoints.forEach((j, i) => {
|
|
33
|
+
const n = j.getName()
|
|
34
|
+
if (!byName.has(n)) byName.set(n, i)
|
|
35
|
+
const s = shortName(n)
|
|
36
|
+
if (!byShort.has(s)) byShort.set(s, i)
|
|
37
|
+
})
|
|
38
|
+
const baseScene = base.getRoot().getDefaultScene() ?? base.getRoot().listScenes()[0]
|
|
39
|
+
const baseMeshNode = findSkinnedNode(base)
|
|
40
|
+
const homeParent = baseMeshNode?.getParentNode() ?? null
|
|
41
|
+
const report: MergeSkinsReport = { donors: [], joints: baseJoints.length }
|
|
42
|
+
|
|
43
|
+
for (const { doc: donor, label } of donors) {
|
|
44
|
+
const donorSkins = donor.getRoot().listSkins()
|
|
45
|
+
if (donorSkins.length === 0) { warn({ level: "warn", message: `${label}: no skin — nothing to merge (static meshes attach at runtime instead)` }); continue }
|
|
46
|
+
// donor joint index → base joint index, per donor skin
|
|
47
|
+
const remaps = new Map<Skin, number[]>()
|
|
48
|
+
const missing = new Set<string>()
|
|
49
|
+
for (const s of donorSkins) {
|
|
50
|
+
remaps.set(s, s.listJoints().map((j) => {
|
|
51
|
+
const n = j.getName()
|
|
52
|
+
const hit = byName.get(n) ?? byShort.get(shortName(n))
|
|
53
|
+
if (hit === undefined) missing.add(n)
|
|
54
|
+
return hit ?? -1
|
|
55
|
+
}))
|
|
56
|
+
}
|
|
57
|
+
const donorMeshNodes: Node[] = []
|
|
58
|
+
for (const scene of donor.getRoot().listScenes()) scene.traverse((n) => { if (n.getSkin() && n.getMesh()) donorMeshNodes.push(n) })
|
|
59
|
+
const donorScenes = donor.getRoot().listScenes()
|
|
60
|
+
|
|
61
|
+
const map = mergeDocuments(base, donor)
|
|
62
|
+
let lostWeight = 0
|
|
63
|
+
for (const src of donorMeshNodes) {
|
|
64
|
+
const node = map.get(src) as Node | undefined
|
|
65
|
+
if (!node) continue
|
|
66
|
+
const remap = remaps.get(src.getSkin()!)!
|
|
67
|
+
for (const prim of node.getMesh()!.listPrimitives()) {
|
|
68
|
+
const J = prim.getAttribute("JOINTS_0"), W = prim.getAttribute("WEIGHTS_0")
|
|
69
|
+
if (!J) continue
|
|
70
|
+
const ja = J.getArray()!, wa = W?.getArray() ?? null
|
|
71
|
+
const wNorm = W ? (W.getComponentType() === 5126 ? 1 : W.getComponentType() === 5121 ? 255 : 65535) : 1
|
|
72
|
+
for (let i = 0; i < ja.length; i++) {
|
|
73
|
+
const m = remap[ja[i]]
|
|
74
|
+
if (m >= 0) { ja[i] = m; continue }
|
|
75
|
+
if (wa) { lostWeight += wa[i] / wNorm; wa[i] = 0 }
|
|
76
|
+
ja[i] = 0
|
|
77
|
+
}
|
|
78
|
+
J.setArray(ja)
|
|
79
|
+
if (wa) W!.setArray(wa)
|
|
80
|
+
}
|
|
81
|
+
node.setSkin(baseSkin)
|
|
82
|
+
// keep the mesh node's own TRS; it is re-homed next to the base skinned mesh
|
|
83
|
+
if (homeParent) homeParent.addChild(node)
|
|
84
|
+
else baseScene.addChild(node)
|
|
85
|
+
}
|
|
86
|
+
// drop the donor's skeleton copy: every merged node that is not one of the re-homed mesh nodes
|
|
87
|
+
const kept = new Set(donorMeshNodes.map((n) => map.get(n)))
|
|
88
|
+
for (const s of donorSkins) { const ms = map.get(s) as Skin | undefined; if (ms && ms !== baseSkin) ms.dispose() }
|
|
89
|
+
for (const ds of donorScenes) {
|
|
90
|
+
const scene = map.get(ds)
|
|
91
|
+
if (!scene) continue
|
|
92
|
+
const doomed: Node[] = []
|
|
93
|
+
for (const root of (scene as typeof baseScene).listChildren()) root.traverse((n) => { if (!kept.has(n)) doomed.push(n) })
|
|
94
|
+
for (const n of doomed.reverse()) n.dispose()
|
|
95
|
+
if (scene !== baseScene) (scene as typeof baseScene).dispose()
|
|
96
|
+
}
|
|
97
|
+
if (lostWeight > 0.5) warn({ level: "warn", message: `${label}: ${lostWeight.toFixed(1)} of skin weight sat on bones the base skeleton lacks (${[ ...missing ].slice(0, 5).join(", ")}${missing.size > 5 ? "…" : ""}) — those vertices now follow the root` })
|
|
98
|
+
report.donors.push({ label, meshes: donorMeshNodes.length, missingJoints: [ ...missing ], lostWeight })
|
|
99
|
+
}
|
|
100
|
+
await base.setLogger(new Logger(Logger.Verbosity.SILENT)).transform(prune(), unpartition())
|
|
101
|
+
return report
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Node names that occur more than once, and whether a skin joint is shadowed by a plain node of the same name. */
|
|
105
|
+
export const duplicateNodeNames = (doc: Document): { name: string, count: number, shadowsJoint: boolean }[] => {
|
|
106
|
+
const joints = new Set(doc.getRoot().listSkins().flatMap((s) => s.listJoints()))
|
|
107
|
+
const seen = new Map<string, { count: number, joint: number, plain: number }>()
|
|
108
|
+
for (const n of doc.getRoot().listNodes()) {
|
|
109
|
+
const name = n.getName()
|
|
110
|
+
if (!name) continue
|
|
111
|
+
const e = seen.get(name) ?? { count: 0, joint: 0, plain: 0 }
|
|
112
|
+
e.count++
|
|
113
|
+
if (joints.has(n)) e.joint++; else e.plain++
|
|
114
|
+
seen.set(name, e)
|
|
115
|
+
}
|
|
116
|
+
return [ ...seen ].filter(([ , e ]) => e.count > 1).map(([ name, e ]) => ({ name, count: e.count, shadowsJoint: e.joint > 0 && e.plain > 0 }))
|
|
117
|
+
}
|