lecodes-assets 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +561 -449
  2. package/dist/cli.js +5632 -521
  3. package/dist/index.js +5647 -526
  4. package/package.json +13 -3
  5. package/src/bvh/fromDocument.ts +136 -0
  6. package/src/bvh/toDocument.ts +202 -0
  7. package/src/cli.ts +1039 -0
  8. package/src/doctor/doctor.ts +241 -0
  9. package/src/doctor/lightmapUv.ts +452 -0
  10. package/src/doctor/lod.ts +177 -0
  11. package/src/doctor/normalizeScale.ts +220 -0
  12. package/src/doctor/reduceGlbBones.ts +218 -0
  13. package/src/doctor/shading.ts +95 -0
  14. package/src/doctor/transcodeKtx2.ts +124 -0
  15. package/src/doctor/transcodeTextures.ts +101 -0
  16. package/src/doctor/validateGlb.ts +264 -0
  17. package/src/fbx/convert.ts +327 -0
  18. package/src/fbx/read.ts +109 -0
  19. package/src/fbx/toDocument.ts +209 -0
  20. package/src/filmstrip.ts +263 -0
  21. package/src/index.ts +17 -0
  22. package/src/mergeSkins.ts +117 -0
  23. package/src/probe.ts +167 -0
  24. package/src/props.ts +137 -0
  25. package/src/retarget/automap.ts +276 -0
  26. package/src/retarget/ghost.ts +23 -0
  27. package/src/retarget/heading.ts +171 -0
  28. package/src/retarget/humanoid.ts +561 -0
  29. package/src/retarget/inPlace.ts +130 -0
  30. package/src/retarget/marks.ts +581 -0
  31. package/src/retarget/mirror.ts +0 -0
  32. package/src/retarget/retarget.ts +2272 -0
  33. package/src/retarget/rig.ts +542 -0
  34. package/src/retarget/unityAnim.ts +171 -0
  35. package/src/retarget/writeMarks.ts +132 -0
  36. package/src/sky/analyze.ts +136 -0
  37. package/src/sky/cmgen.ts +71 -0
  38. package/src/sky/index.ts +151 -0
  39. package/src/terrainPack.ts +169 -0
  40. package/src/textures/resolveImage.ts +202 -0
  41. package/src/unity/materials.ts +593 -0
  42. package/src/unity/png.ts +75 -0
  43. package/src/unity/prefabs.ts +80 -0
  44. package/src/unity/scene.ts +363 -0
  45. package/src/unity/unpack.ts +130 -0
  46. package/src/unreal/anim.ts +284 -0
  47. package/src/unreal/clipSource.ts +25 -0
  48. package/src/unreal/convert.ts +67 -0
  49. package/src/unreal/convertAsset.ts +81 -0
  50. package/src/unreal/index.ts +114 -0
  51. package/src/unreal/list.ts +113 -0
  52. package/src/unreal/materials.ts +291 -0
  53. package/src/unreal/mesh.ts +205 -0
  54. package/src/unreal/oodle.ts +153 -0
  55. package/src/unreal/package.ts +659 -0
  56. package/src/unreal/probe.ts +144 -0
  57. package/src/unreal/resolve.ts +51 -0
  58. package/src/unreal/rig.ts +149 -0
  59. package/src/unreal/sequencer.ts +193 -0
  60. package/src/unreal/skeleton.ts +74 -0
  61. package/src/unreal/texture.ts +202 -0
  62. package/src/unreal/toDocument.ts +360 -0
  63. package/src/unreal/versions.ts +100 -0
  64. package/src/wasm/fbxReader.js +14 -0
  65. package/src/wasm/lightmapUv.js +14 -0
package/package.json CHANGED
@@ -1,17 +1,27 @@
1
1
  {
2
2
  "name": "lecodes-assets",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "LeCodes asset pipeline: FBX → GLB (ufbx), clip merging by bone name, texture embedding, GLB doctor (bone cap, texture normalize, KTX2).",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "lecodes-assets": "dist/cli.js"
8
8
  },
9
9
  "exports": {
10
- ".": { "types": "./src/index.ts", "default": "./dist/index.js" },
11
- "./cli": { "types": "./src/cli.ts", "default": "./dist/cli.js" }
10
+ ".": {
11
+ "types": "./src/index.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./cli": {
15
+ "types": "./src/cli.ts",
16
+ "default": "./dist/cli.js"
17
+ },
18
+ "./fbx/*": "./src/fbx/*.ts",
19
+ "./retarget/*": "./src/retarget/*.ts",
20
+ "./unreal/*": "./src/unreal/*.ts"
12
21
  },
13
22
  "files": [
14
23
  "dist",
24
+ "src",
15
25
  "README.md"
16
26
  ],
17
27
  "engines": {
@@ -0,0 +1,136 @@
1
+ // glTF document → BVH: one clip of a skeleton (a rig the BVH reader made, or any rig whose nodes ARE
2
+ // the joints) written in the layout Kimodo reads back (`kimodo_convert x.bvh x.npz
3
+ // --bvh_standard_tpose`): a zero `Root` with six channels over the root joint (six channels), every
4
+ // other joint three ZYX rotation channels, no End Sites, centimetres, the rest pose = the nodes'
5
+ // rest translations (their offsets) with identity rotations — i.e. the file's rest is whatever the
6
+ // document's rest is (the standard T-pose for a rig read from Kimodo's `--bvh_standard_tpose` export).
7
+ // The clip is resampled at `fps` (nlerp between keys).
8
+
9
+ import type { Document, Node } from "@gltf-transform/core"
10
+
11
+ type Quat = [number, number, number, number]
12
+ type Vec3 = [number, number, number]
13
+
14
+ export type BvhWriteOptions = {
15
+ /** the clip to write (default: the document's first) */
16
+ clip?: string
17
+ /** frames per second of the output (default 30 — Kimodo's rate) */
18
+ fps?: number
19
+ /** metres per BVH unit (default 0.01: centimetres) */
20
+ unit?: number
21
+ /** the wrapper root's name over the skeleton root (default "Root"); "" = none, the skeleton root is the BVH root */
22
+ wrapperRoot?: string
23
+ }
24
+
25
+ const nlerp = (a: Quat, b: Quat, t: number): Quat => {
26
+ const d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]
27
+ const s = d < 0 ? -1 : 1
28
+ const q: Quat = [ a[0] + (s * b[0] - a[0]) * t, a[1] + (s * b[1] - a[1]) * t, a[2] + (s * b[2] - a[2]) * t, a[3] + (s * b[3] - a[3]) * t ]
29
+ const n = Math.hypot(q[0], q[1], q[2], q[3]) || 1
30
+ return [ q[0] / n, q[1] / n, q[2] / n, q[3] / n ]
31
+ }
32
+
33
+ /** Z·Y·X Euler angles (degrees, in channel order Zrotation Yrotation Xrotation) of a unit quaternion:
34
+ * R = Rz(γ)·Ry(β)·Rx(α) → β = asin(−R20), α = atan2(R21, R22), γ = atan2(R10, R00). */
35
+ const eulerZYX = (q: Quat): [number, number, number] => {
36
+ const [ x, y, z, w ] = q
37
+ const r00 = 1 - 2 * (y * y + z * z), r10 = 2 * (x * y + z * w), r20 = 2 * (x * z - y * w)
38
+ const r21 = 2 * (y * z + x * w), r22 = 1 - 2 * (x * x + y * y)
39
+ const b = Math.asin(Math.max(-1, Math.min(1, -r20)))
40
+ const D = 180 / Math.PI
41
+ if (Math.abs(r20) > 0.999999) {
42
+ // gimbal lock: fold everything into γ
43
+ const r01 = 2 * (x * y - z * w), r11 = 1 - 2 * (x * x + z * z)
44
+ return [ Math.atan2(-r01, r11) * D, b * D, 0 ]
45
+ }
46
+ return [ Math.atan2(r10, r00) * D, b * D, Math.atan2(r21, r22) * D ]
47
+ }
48
+
49
+ const f = (v: number): string => {
50
+ const s = (Math.abs(v) < 5e-7 ? 0 : v).toFixed(6)
51
+ return s.replace(/\.?0+$/, "") || "0"
52
+ }
53
+
54
+ export const documentToBvh = (doc: Document, opts: BvhWriteOptions = {}): { text: string, report: { clip: string, joints: number, frames: number, fps: number } } => {
55
+ const fps = opts.fps ?? 30
56
+ const U = opts.unit ?? 0.01
57
+ const root = doc.getRoot()
58
+ const anim = opts.clip ? root.listAnimations().find((a) => a.getName() === opts.clip) : root.listAnimations()[0]
59
+ if (!anim) throw new Error(opts.clip ? `no clip "${opts.clip}" (have ${root.listAnimations().map((a) => a.getName()).join(", ")})` : "no animation in the document")
60
+
61
+ // the skeleton: the joints of the first skin if there is one, else every node under the scene
62
+ const skin = root.listSkins()[0]
63
+ const joints = skin ? skin.listJoints() : root.listNodes().filter((n) => !n.getMesh())
64
+ const jointSet = new Set(joints)
65
+ const skelRoots = joints.filter((n) => { const p = n.getParentNode(); return !p || !jointSet.has(p) })
66
+ if (skelRoots.length !== 1) throw new Error(`the skeleton must have one root joint (found ${skelRoots.map((n) => n.getName()).join(", ") || "none"})`)
67
+ const skelRoot = skelRoots[0]
68
+
69
+ // tracks: per joint, the sampler for rotation / translation
70
+ const tracks = new Map<Node, { rot?: { t: Float32Array, v: Float32Array }, pos?: { t: Float32Array, v: Float32Array } }>()
71
+ for (const ch of anim.listChannels()) {
72
+ const node = ch.getTargetNode(), s = ch.getSampler()
73
+ if (!node || !s || !jointSet.has(node)) continue
74
+ const t = s.getInput()?.getArray() as Float32Array | undefined, v = s.getOutput()?.getArray() as Float32Array | undefined
75
+ if (!t || !v) continue
76
+ const rec = tracks.get(node) ?? {}
77
+ if (ch.getTargetPath() === "rotation") rec.rot = { t, v }
78
+ else if (ch.getTargetPath() === "translation") rec.pos = { t, v }
79
+ tracks.set(node, rec)
80
+ }
81
+ let duration = 0
82
+ for (const r of tracks.values()) for (const k of [ r.rot, r.pos ]) if (k) duration = Math.max(duration, k.t[k.t.length - 1])
83
+ const frames = Math.max(1, Math.round(duration * fps) + 1)
84
+
85
+ const sample = (t: Float32Array, v: Float32Array, n: number, time: number, mix: (a: number[], b: number[], k: number) => number[]): number[] => {
86
+ if (time <= t[0]) return Array.from(v.subarray(0, n))
87
+ const last = t.length - 1
88
+ if (time >= t[last]) return Array.from(v.subarray(last * n, last * n + n))
89
+ let i = 0
90
+ while (i < last && t[i + 1] <= time) i++
91
+ const k = (time - t[i]) / Math.max(1e-9, t[i + 1] - t[i])
92
+ return mix(Array.from(v.subarray(i * n, i * n + n)), Array.from(v.subarray((i + 1) * n, (i + 1) * n + n)), k)
93
+ }
94
+ const mixV = (a: number[], b: number[], k: number): number[] => a.map((x, i) => x + (b[i] - x) * k)
95
+ const mixQ = (a: number[], b: number[], k: number): number[] => nlerp(a as Quat, b as Quat, k)
96
+
97
+ // the hierarchy
98
+ const order: Node[] = []
99
+ const lines: string[] = [ "HIERARCHY" ]
100
+ const wrapper = opts.wrapperRoot ?? "Root"
101
+ const emit = (n: Node, depth: number, isRoot: boolean): void => {
102
+ order.push(n)
103
+ const pad = " ".repeat(depth)
104
+ const o = n.getTranslation()
105
+ lines.push(`${pad}${isRoot && !wrapper ? "ROOT" : "JOINT"} ${n.getName()}`)
106
+ lines.push(`${pad}{`)
107
+ lines.push(`${pad} OFFSET ${f(o[0] / U)} ${f(o[1] / U)} ${f(o[2] / U)}`)
108
+ lines.push(`${pad} CHANNELS ${isRoot ? "6 Xposition Yposition Zposition " : "3 "}Zrotation Yrotation Xrotation`)
109
+ for (const c of n.listChildren()) if (jointSet.has(c)) emit(c, depth + 1, false)
110
+ lines.push(`${pad}}`)
111
+ }
112
+ if (wrapper) {
113
+ lines.push(`ROOT ${wrapper}`, "{", " OFFSET 0.0 0.0 0.0", " CHANNELS 6 Xposition Yposition Zposition Zrotation Yrotation Xrotation")
114
+ emit(skelRoot, 1, true)
115
+ lines.push("}")
116
+ } else emit(skelRoot, 0, true)
117
+
118
+ lines.push("MOTION", `Frames: ${frames}`, `Frame Time: ${1 / fps}`)
119
+ for (let fi = 0; fi < frames; fi++) {
120
+ const time = fi / fps
121
+ const row: string[] = []
122
+ if (wrapper) row.push("0.0 0.0 0.0 0.0 0.0 0.0")
123
+ order.forEach((n, i) => {
124
+ const tr = tracks.get(n)
125
+ if (i === 0) {
126
+ const p = tr?.pos ? sample(tr.pos.t, tr.pos.v, 3, time, mixV) : Array.from(n.getTranslation())
127
+ row.push(f(p[0] / U), f(p[1] / U), f(p[2] / U))
128
+ }
129
+ const q = (tr?.rot ? sample(tr.rot.t, tr.rot.v, 4, time, mixQ) : Array.from(n.getRotation())) as Quat
130
+ const [ z, y, x ] = eulerZYX(q)
131
+ row.push(f(z), f(y), f(x))
132
+ })
133
+ lines.push(row.join(" "))
134
+ }
135
+ return { text: lines.join("\n") + "\n", report: { clip: anim.getName(), joints: order.length, frames, fps } }
136
+ }
@@ -0,0 +1,202 @@
1
+ // BVH → glTF document: a skeleton-only rig (nodes + a skin with inverse binds, no mesh) carrying the
2
+ // file's motion as one animation clip — what `retarget --from <clip>.bvh` and `convert <clip>.bvh`
3
+ // read. Written for Kimodo's SOMA export (`kimodo_gen --bvh --bvh_standard_tpose` / `kimodo_convert
4
+ // … .bvh --bvh_standard_tpose`: centimetres, Y-up, a zero `Root` above `Hips`, ZYX Euler channels,
5
+ // the rest pose = the standard T-pose), but the parser is plain BVH.
6
+ //
7
+ // Conventions taken: OFFSET = the joint's rest translation; a joint's own position channels REPLACE
8
+ // its offset (bvhio / Kimodo: the T-pose file's Hips channels equal its offset); rotation channels
9
+ // compose in the listed order (Zrotation Yrotation Xrotation → R = Rz·Ry·Rx); End Sites become
10
+ // `<parent>_End` leaves outside the skin. A zero-offset root with a single child (Kimodo's `Root`
11
+ // above `Hips`) is folded into that child unless `keepRoot`.
12
+
13
+ import { Document } from "@gltf-transform/core"
14
+
15
+ type Quat = [number, number, number, number]
16
+ type Vec3 = [number, number, number]
17
+ type Joint = { name: string, parent: number, offset: Vec3, channels: string[], end: boolean }
18
+
19
+ export type BvhOptions = {
20
+ /** the clip's name (default: the file's stem) */
21
+ name?: string
22
+ /** keep a zero-offset single-child root joint instead of folding it into its child */
23
+ keepRoot?: boolean
24
+ /** the file's length unit in metres (default 0.01: centimetres, the mocap convention) */
25
+ unit?: number
26
+ }
27
+
28
+ export type BvhReport = {
29
+ joints: number
30
+ skinJoints: number
31
+ folded?: string
32
+ frames: number
33
+ fps: number
34
+ duration: number
35
+ rotationTracks: number
36
+ translationTracks: number
37
+ /** the hips' height range and horizontal travel, metres */
38
+ hipsY: [number, number]
39
+ travel: number
40
+ }
41
+
42
+ const qmul = (a: Quat, b: Quat): Quat => [
43
+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
44
+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
45
+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
46
+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
47
+ ]
48
+ const qaxis = (axis: string, deg: number): Quat => {
49
+ const h = deg * Math.PI / 360, s = Math.sin(h), c = Math.cos(h)
50
+ return axis === "X" ? [ s, 0, 0, c ] : axis === "Y" ? [ 0, s, 0, c ] : [ 0, 0, s, c ]
51
+ }
52
+ const qrot = (q: Quat, v: Vec3): Vec3 => {
53
+ const [ x, y, z, w ] = q
54
+ const ix = w * v[0] + y * v[2] - z * v[1], iy = w * v[1] + z * v[0] - x * v[2], iz = w * v[2] + x * v[1] - y * v[0], iw = -x * v[0] - y * v[1] - z * v[2]
55
+ return [ ix * w + iw * -x + iy * -z - iz * -y, iy * w + iw * -y + iz * -x - ix * -z, iz * w + iw * -z + ix * -y - iy * -x ]
56
+ }
57
+
58
+ export const parseBvh = (text: string): { joints: Joint[], frames: number, frameTime: number, rows: number[][] } => {
59
+ const motionAt = text.indexOf("MOTION")
60
+ if (motionAt < 0) throw new Error("BVH: no MOTION section")
61
+ const hier = text.slice(0, motionAt).replace(/\r/g, "").split(/\s+/).filter(Boolean)
62
+ const joints: Joint[] = []
63
+ const stack: number[] = []
64
+ let i = 0
65
+ const next = (): string => hier[i++]
66
+ while (i < hier.length) {
67
+ const tok = next()
68
+ if (tok === "HIERARCHY") continue
69
+ if (tok === "ROOT" || tok === "JOINT") {
70
+ joints.push({ name: next(), parent: stack.length ? stack[stack.length - 1] : -1, offset: [ 0, 0, 0 ], channels: [], end: false })
71
+ } else if (tok === "End") {
72
+ next() // Site
73
+ joints.push({ name: `${joints[stack[stack.length - 1]].name}_End`, parent: stack[stack.length - 1], offset: [ 0, 0, 0 ], channels: [], end: true })
74
+ } else if (tok === "{") {
75
+ stack.push(joints.length - 1)
76
+ } else if (tok === "}") {
77
+ stack.pop()
78
+ } else if (tok === "OFFSET") {
79
+ joints[joints.length - 1].offset = [ +next(), +next(), +next() ]
80
+ } else if (tok === "CHANNELS") {
81
+ const n = +next()
82
+ for (let k = 0; k < n; k++) joints[joints.length - 1].channels.push(next())
83
+ }
84
+ }
85
+ const motion = text.slice(motionAt).replace(/\r/g, "").split("\n")
86
+ const frames = +(motion.find((l) => l.startsWith("Frames:"))?.split(":")[1] ?? 0)
87
+ const frameTime = +(motion.find((l) => l.startsWith("Frame Time:"))?.split(":")[1] ?? 1 / 30)
88
+ const dataStart = motion.findIndex((l) => l.startsWith("Frame Time:")) + 1
89
+ const rows = motion.slice(dataStart).map((l) => l.trim()).filter(Boolean).slice(0, frames).map((l) => l.split(/\s+/).map(Number))
90
+ const channelCount = joints.reduce((n, j) => n + j.channels.length, 0)
91
+ if (rows.length !== frames || rows.some((r) => r.length !== channelCount)) throw new Error(`BVH: motion data mismatch — ${rows.length}/${frames} frames, ${channelCount} channels per frame expected`)
92
+ return { joints, frames, frameTime, rows }
93
+ }
94
+
95
+ export const bvhToDocument = (text: string, opts: BvhOptions = {}): { doc: Document, report: BvhReport } => {
96
+ const { joints, frames, frameTime, rows } = parseBvh(text)
97
+ const U = opts.unit ?? 0.01
98
+ const clipName = opts.name ?? "bvh"
99
+
100
+ // per frame, per joint: local translation (m) and rotation
101
+ const local = rows.map((row) => {
102
+ let c = 0
103
+ return joints.map((j) => {
104
+ const t: Vec3 = [ j.offset[0] * U, j.offset[1] * U, j.offset[2] * U ]
105
+ let q: Quat = [ 0, 0, 0, 1 ]
106
+ let hasPos = false
107
+ for (const ch of j.channels) {
108
+ const v = row[c++]
109
+ if (ch === "Xposition") { t[0] = v * U; hasPos = true } else if (ch === "Yposition") { t[1] = v * U; hasPos = true } else if (ch === "Zposition") { t[2] = v * U; hasPos = true }
110
+ else if (ch.endsWith("rotation")) q = qmul(q, qaxis(ch[0], v))
111
+ }
112
+ return { t, q, hasPos }
113
+ })
114
+ })
115
+
116
+ // fold a zero-offset, single-child root into its child (Kimodo's `Root` above `Hips`)
117
+ let dropped = -1
118
+ let folded: string | undefined
119
+ if (!opts.keepRoot && joints[0].parent === -1 && joints.filter((j) => j.parent === 0).length === 1 && joints[0].offset.every((v) => v === 0)) {
120
+ const child = joints.findIndex((j) => j.parent === 0)
121
+ for (const f of local) {
122
+ const r = f[0], h = f[child]
123
+ const t = qrot(r.q, h.t)
124
+ f[child] = { t: [ r.t[0] + t[0], r.t[1] + t[1], r.t[2] + t[2] ], q: qmul(r.q, h.q), hasPos: true }
125
+ }
126
+ joints[child].parent = -1
127
+ dropped = 0
128
+ folded = `${joints[0].name} → ${joints[child].name}`
129
+ }
130
+
131
+ const doc = new Document()
132
+ const buffer = doc.createBuffer()
133
+ const scene = doc.createScene("Scene")
134
+ const nodes = joints.map((j, i) => i === dropped ? null : doc.createNode(j.name).setTranslation([ j.offset[0] * U, j.offset[1] * U, j.offset[2] * U ]))
135
+ const worldRest: Vec3[] = joints.map(() => [ 0, 0, 0 ])
136
+ joints.forEach((j, i) => {
137
+ if (i === dropped) return
138
+ const p = j.parent
139
+ const pw = p >= 0 && p !== dropped ? worldRest[p] : [ 0, 0, 0 ]
140
+ worldRest[i] = [ pw[0] + j.offset[0] * U, pw[1] + j.offset[1] * U, pw[2] + j.offset[2] * U ]
141
+ if (p >= 0 && p !== dropped) nodes[p]!.addChild(nodes[i]!); else scene.addChild(nodes[i]!)
142
+ })
143
+
144
+ // the skin: every non-end joint, inverse binds from the rest pose (offsets only — pure translations)
145
+ const armature = doc.createNode("Armature")
146
+ scene.addChild(armature)
147
+ const skin = doc.createSkin("bvh")
148
+ const skinJoints = joints.map((_, i) => i).filter((i) => i !== dropped && !joints[i].end)
149
+ const ibm = new Float32Array(skinJoints.length * 16)
150
+ skinJoints.forEach((ji, k) => {
151
+ const o = k * 16
152
+ ibm[o] = 1; ibm[o + 5] = 1; ibm[o + 10] = 1; ibm[o + 15] = 1
153
+ ibm[o + 12] = -worldRest[ji][0]; ibm[o + 13] = -worldRest[ji][1]; ibm[o + 14] = -worldRest[ji][2]
154
+ skin.addJoint(nodes[ji]!)
155
+ })
156
+ skin.setInverseBindMatrices(doc.createAccessor("ibm").setType("MAT4").setArray(ibm).setBuffer(buffer))
157
+ skin.setSkeleton(nodes[joints.findIndex((j, i) => i !== dropped && (j.parent === -1 || j.parent === dropped))]!)
158
+ armature.setSkin(skin)
159
+
160
+ // the clip
161
+ const anim = doc.createAnimation(clipName)
162
+ const times = new Float32Array(frames)
163
+ for (let f = 0; f < frames; f++) times[f] = f * frameTime
164
+ const timeAcc = doc.createAccessor("time").setType("SCALAR").setArray(times).setBuffer(buffer)
165
+ let rotationTracks = 0, translationTracks = 0
166
+ joints.forEach((j, i) => {
167
+ if (i === dropped || j.end) return
168
+ if (j.channels.some((c) => c.endsWith("rotation"))) {
169
+ const out = new Float32Array(frames * 4)
170
+ let prev: Quat = [ 0, 0, 0, 1 ]
171
+ for (let f = 0; f < frames; f++) {
172
+ let q = local[f][i].q
173
+ if (q[0] * prev[0] + q[1] * prev[1] + q[2] * prev[2] + q[3] * prev[3] < 0) q = [ -q[0], -q[1], -q[2], -q[3] ]
174
+ out.set(q, f * 4); prev = q
175
+ }
176
+ const s = doc.createAnimationSampler().setInput(timeAcc).setOutput(doc.createAccessor().setType("VEC4").setArray(out).setBuffer(buffer)).setInterpolation("LINEAR")
177
+ anim.addSampler(s).addChannel(doc.createAnimationChannel().setTargetNode(nodes[i]!).setTargetPath("rotation").setSampler(s))
178
+ rotationTracks++
179
+ }
180
+ if (local.some((f) => f[i].hasPos)) {
181
+ const out = new Float32Array(frames * 3)
182
+ for (let f = 0; f < frames; f++) out.set(local[f][i].t, f * 3)
183
+ const s = doc.createAnimationSampler().setInput(timeAcc).setOutput(doc.createAccessor().setType("VEC3").setArray(out).setBuffer(buffer)).setInterpolation("LINEAR")
184
+ anim.addSampler(s).addChannel(doc.createAnimationChannel().setTargetNode(nodes[i]!).setTargetPath("translation").setSampler(s))
185
+ translationTracks++
186
+ }
187
+ })
188
+
189
+ const hipsI = Math.max(0, joints.findIndex((j, i) => i !== dropped && (j.parent === -1 || j.parent === dropped)))
190
+ const hipY = local.map((f) => f[hipsI].t[1])
191
+ const a = local[0][hipsI].t, b = local[frames - 1][hipsI].t
192
+ return {
193
+ doc,
194
+ report: {
195
+ joints: joints.length - (dropped >= 0 ? 1 : 0), skinJoints: skinJoints.length, folded, frames, fps: 1 / frameTime, duration: frames * frameTime,
196
+ rotationTracks, translationTracks, hipsY: [ Math.min(...hipY), Math.max(...hipY) ], travel: Math.hypot(b[0] - a[0], b[2] - a[2]),
197
+ },
198
+ }
199
+ }
200
+
201
+ export const describeBvh = (r: BvhReport): string =>
202
+ `${r.joints} joints (${r.skinJoints} in the skin${r.folded ? `, ${r.folded} folded` : ""}), ${r.frames} frames @ ${r.fps.toFixed(1)} fps = ${r.duration.toFixed(2)} s · ${r.rotationTracks} rotation + ${r.translationTracks} translation tracks · hips y ${r.hipsY[0].toFixed(3)}…${r.hipsY[1].toFixed(3)} m · travel ${r.travel.toFixed(2)} m`