lecodes-assets 0.1.2 → 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.
- package/README.md +240 -5
- package/dist/cli.js +7757 -683
- package/dist/index.js +7866 -675
- package/package.json +13 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli.ts +1039 -0
- package/src/doctor/doctor.ts +241 -0
- package/src/doctor/lightmapUv.ts +452 -0
- package/src/doctor/lod.ts +177 -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 +124 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +109 -0
- package/src/fbx/toDocument.ts +209 -0
- package/src/filmstrip.ts +263 -0
- package/src/index.ts +17 -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 +171 -0
- package/src/retarget/humanoid.ts +561 -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 +2272 -0
- package/src/retarget/rig.ts +542 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +132 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +151 -0
- package/src/terrainPack.ts +169 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/materials.ts +593 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabs.ts +80 -0
- package/src/unity/scene.ts +363 -0
- package/src/unity/unpack.ts +130 -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 +114 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materials.ts +291 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +659 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +51 -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/texture.ts +202 -0
- package/src/unreal/toDocument.ts +360 -0
- package/src/unreal/versions.ts +100 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
package/src/props.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Props out of multi-variant files. Asset-store packs ship every LOD, colour set and arrangement of
|
|
2
|
+
// a prop in ONE FBX (`Barrel_v1.FBX` = 15 meshes: Barrel_v1_LD1, _LD2, _S1, _rusty…); a game wants
|
|
3
|
+
// one of them per GLB, sitting on the ground at the origin. `keepNodes` carves the named subtrees
|
|
4
|
+
// out of a Document and re-origins what is left (X/Z centred on its footprint, Y = 0 at the lowest
|
|
5
|
+
// vertex) so a `Model.load` lands the prop exactly where the node is; `splitDoc` does that for every
|
|
6
|
+
// top-level node at once (one Document each). Static geometry only — skinned meshes keep their
|
|
7
|
+
// skeleton's frame and are left where they are.
|
|
8
|
+
|
|
9
|
+
import { Logger, type Document, type Node } from "@gltf-transform/core"
|
|
10
|
+
import { cloneDocument, prune, unpartition } from "@gltf-transform/functions"
|
|
11
|
+
|
|
12
|
+
/** prune() narrates what it removed through the document's logger — not news for a deliberate cut. */
|
|
13
|
+
export const quiet = (doc: Document): Document => doc.setLogger(new Logger(Logger.Verbosity.SILENT))
|
|
14
|
+
|
|
15
|
+
export type Vec3 = [number, number, number]
|
|
16
|
+
export type NodeInfo = { name: string, depth: number, meshes: number, tris: number, size: Vec3, children: number }
|
|
17
|
+
|
|
18
|
+
const IDENTITY = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ]
|
|
19
|
+
const mul = (a: number[], b: number[]): number[] => {
|
|
20
|
+
const o = new Array(16).fill(0)
|
|
21
|
+
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]
|
|
22
|
+
return o
|
|
23
|
+
}
|
|
24
|
+
const xf = (m: number[], x: number, y: number, z: number): Vec3 =>
|
|
25
|
+
[ m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14] ]
|
|
26
|
+
|
|
27
|
+
type Bounds = { lo: Vec3, hi: Vec3, tris: number, meshes: number }
|
|
28
|
+
const emptyBounds = (): Bounds => ({ lo: [ Infinity, Infinity, Infinity ], hi: [ -Infinity, -Infinity, -Infinity ], tris: 0, meshes: 0 })
|
|
29
|
+
const grow = (b: Bounds, p: Vec3): void => { for (let k = 0; k < 3; k++) { if (p[k] < b.lo[k]) b.lo[k] = p[k]; if (p[k] > b.hi[k]) b.hi[k] = p[k] } }
|
|
30
|
+
|
|
31
|
+
/** World-space bounds of a subtree (node transforms applied; skinned primitives measured as-is). */
|
|
32
|
+
export const subtreeBounds = (node: Node, parentWorld: number[] = IDENTITY, into: Bounds = emptyBounds()): Bounds => {
|
|
33
|
+
const world = mul(parentWorld, Array.from(node.getMatrix()))
|
|
34
|
+
const mesh = node.getMesh()
|
|
35
|
+
if (mesh) {
|
|
36
|
+
into.meshes++
|
|
37
|
+
for (const prim of mesh.listPrimitives()) {
|
|
38
|
+
const pos = prim.getAttribute("POSITION")
|
|
39
|
+
if (!pos) continue
|
|
40
|
+
const idx = prim.getIndices()
|
|
41
|
+
into.tris += idx ? idx.getCount() / 3 : pos.getCount() / 3
|
|
42
|
+
const a = pos.getArray()!
|
|
43
|
+
for (let i = 0; i < a.length; i += 3) grow(into, xf(world, a[i], a[i + 1], a[i + 2]))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const c of node.listChildren()) subtreeBounds(c, world, into)
|
|
47
|
+
return into
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sizeOf = (b: Bounds): Vec3 => (b.lo[0] === Infinity ? [ 0, 0, 0 ] : [ b.hi[0] - b.lo[0], b.hi[1] - b.lo[1], b.hi[2] - b.lo[2] ])
|
|
51
|
+
|
|
52
|
+
/** The scene's nodes down to `depth` levels with mesh counts and world sizes — what `--keep` takes. */
|
|
53
|
+
export const listNodes = (doc: Document, depth = 1): NodeInfo[] => {
|
|
54
|
+
const out: NodeInfo[] = []
|
|
55
|
+
const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]
|
|
56
|
+
if (!scene) return out
|
|
57
|
+
const walk = (n: Node, d: number, parentWorld: number[]): void => {
|
|
58
|
+
const b = subtreeBounds(n, parentWorld)
|
|
59
|
+
out.push({ name: n.getName(), depth: d, meshes: b.meshes, tris: b.tris, size: sizeOf(b), children: n.listChildren().length })
|
|
60
|
+
if (d + 1 < depth) {
|
|
61
|
+
const world = mul(parentWorld, Array.from(n.getMatrix()))
|
|
62
|
+
for (const c of n.listChildren()) walk(c, d + 1, world)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const c of scene.listChildren()) walk(c, 0, IDENTITY)
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type KeepResult = { kept: string[], missing: string[], size: Vec3, offset: Vec3 }
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Keep only the named nodes (any depth; their ancestors stay as the path to them, their descendants
|
|
73
|
+
* stay with them), drop everything else, then re-origin: the kept geometry's footprint centre goes to
|
|
74
|
+
* X/Z = 0 and its lowest point to Y = 0 (`ground: false` keeps the file's own placement).
|
|
75
|
+
*/
|
|
76
|
+
export const keepNodes = async (doc: Document, names: string[], opts: { ground?: boolean } = {}): Promise<KeepResult> => {
|
|
77
|
+
const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]
|
|
78
|
+
if (!scene) throw new Error("the file has no scene")
|
|
79
|
+
const wanted = new Set(names)
|
|
80
|
+
const found = new Set<string>()
|
|
81
|
+
const keep = new Set<Node>()
|
|
82
|
+
// a node survives if it is wanted, sits under a wanted node, or leads to one
|
|
83
|
+
const mark = (n: Node, underKept: boolean): boolean => {
|
|
84
|
+
const self = wanted.has(n.getName())
|
|
85
|
+
if (self) found.add(n.getName())
|
|
86
|
+
let leads = false
|
|
87
|
+
for (const c of n.listChildren()) if (mark(c, underKept || self)) leads = true
|
|
88
|
+
const survives = self || underKept || leads
|
|
89
|
+
if (survives) keep.add(n)
|
|
90
|
+
return survives
|
|
91
|
+
}
|
|
92
|
+
for (const c of scene.listChildren()) mark(c, false)
|
|
93
|
+
const doomed: Node[] = []
|
|
94
|
+
scene.traverse((n) => { if (!keep.has(n)) doomed.push(n) })
|
|
95
|
+
// children first: disposing a parent detaches its subtree, which is fine, but keeps the order obvious
|
|
96
|
+
for (const n of doomed.reverse()) n.dispose()
|
|
97
|
+
const missing = names.filter((n) => !found.has(n))
|
|
98
|
+
|
|
99
|
+
const b = emptyBounds()
|
|
100
|
+
for (const c of scene.listChildren()) subtreeBounds(c, IDENTITY, b)
|
|
101
|
+
const offset: Vec3 = [ 0, 0, 0 ]
|
|
102
|
+
if (opts.ground !== false && b.lo[0] !== Infinity) {
|
|
103
|
+
offset[0] = -(b.lo[0] + b.hi[0]) / 2; offset[1] = -b.lo[1]; offset[2] = -(b.lo[2] + b.hi[2]) / 2
|
|
104
|
+
for (const c of scene.listChildren()) {
|
|
105
|
+
const t = c.getTranslation()
|
|
106
|
+
c.setTranslation([ t[0] + offset[0], t[1] + offset[1], t[2] + offset[2] ])
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
await quiet(doc).transform(prune(), unpartition())
|
|
110
|
+
return { kept: [ ...found ], missing, size: sizeOf(b), offset }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One Document per top-level node, each re-origined (see `keepNodes`). Names are de-duplicated with ` (2)`. */
|
|
114
|
+
export const splitDoc = async (doc: Document, opts: { ground?: boolean } = {}): Promise<{ name: string, doc: Document, size: Vec3 }[]> => {
|
|
115
|
+
const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0]
|
|
116
|
+
if (!scene) return []
|
|
117
|
+
const out: { name: string, doc: Document, size: Vec3 }[] = []
|
|
118
|
+
const seen = new Map<string, number>()
|
|
119
|
+
for (const top of scene.listChildren()) {
|
|
120
|
+
const name = top.getName() || "node"
|
|
121
|
+
const n = (seen.get(name) ?? 0) + 1
|
|
122
|
+
seen.set(name, n)
|
|
123
|
+
// keepNodes picks by NAME — a duplicate top-level name gets its siblings too; give it a unique one first
|
|
124
|
+
const unique = n > 1 ? `${name} (${n})` : name
|
|
125
|
+
const copy = cloneDocument(doc)
|
|
126
|
+
const copyScene = copy.getRoot().getDefaultScene() ?? copy.getRoot().listScenes()[0]
|
|
127
|
+
const twin = copyScene.listChildren()[scene.listChildren().indexOf(top)]
|
|
128
|
+
twin.setName(`__split__${unique}`)
|
|
129
|
+
const r = await keepNodes(copy, [ `__split__${unique}` ], opts)
|
|
130
|
+
twin.setName(name)
|
|
131
|
+
out.push({ name: unique, doc: copy, size: r.size })
|
|
132
|
+
}
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** File-system friendly stem for a node name (`Barrel_v1 (2)` → `Barrel_v1-2`). */
|
|
137
|
+
export const fileStem = (name: string): string => name.replace(/\s*\((\d+)\)$/, "-$1").replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "") || "node"
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Automatic bone map for a quadruped retarget — from SKELETON SHAPE, not names.
|
|
2
|
+
//
|
|
3
|
+
// Bone names are unreliable across rigs (`backleg0`, `Knee_L`, `actor:skin_backKnee_L`, `joint17`),
|
|
4
|
+
// but every quadruped skeleton has the same shape: a centreline from the tail through the pelvis
|
|
5
|
+
// and chest to the head, four lateral chains hanging down to the ground, ears/jaw/eyes branching off
|
|
6
|
+
// the head. `classify` reads that structure off the bind pose of one rig — root, spine, neck, head,
|
|
7
|
+
// tail, legs (side + end), ears, jaw — and `autoMap` pairs the two structures chain by chain,
|
|
8
|
+
// matching bones within a chain by normalized arc length (a 6-bone spine against a 3-bone spine maps
|
|
9
|
+
// its ends exactly and the middle to the nearest donor bone; leftover target bones stay unmapped and
|
|
10
|
+
// become bridges in the retarget). The result is a starting map the user reads and corrects, with a
|
|
11
|
+
// report of what was and was not recognised — never a silent guess.
|
|
12
|
+
import type { Rig, Vec3 } from './rig'
|
|
13
|
+
import type { BoneMap } from './retarget'
|
|
14
|
+
|
|
15
|
+
export interface LegInfo { side: 'L' | 'R'; end: 'front' | 'back'; chain: number[] } // hip → … → ball of foot
|
|
16
|
+
|
|
17
|
+
export interface Skeleton {
|
|
18
|
+
root: number
|
|
19
|
+
forward: Vec3 // horizontal unit vector, tail → head
|
|
20
|
+
lateral: Vec3 // unit vector to the rig's LEFT (+x when facing +z)
|
|
21
|
+
size: number // largest bounding extent
|
|
22
|
+
spine: number[] // root … chest (inclusive both ends; length 1 when root == chest)
|
|
23
|
+
chest: number
|
|
24
|
+
neck: number[] // first bone after chest … head (inclusive)
|
|
25
|
+
head: number | null
|
|
26
|
+
jaw: number | null
|
|
27
|
+
ears: [number, number] | null // [left, right]
|
|
28
|
+
tail: number[] // first tail bone … tip
|
|
29
|
+
legs: LegInfo[]
|
|
30
|
+
notes: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sub = (a: Vec3, b: Vec3): Vec3 => [ a[0] - b[0], a[1] - b[1], a[2] - b[2] ]
|
|
34
|
+
const dot = (a: Vec3, b: Vec3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
35
|
+
const len = (v: Vec3): number => Math.hypot(v[0], v[1], v[2])
|
|
36
|
+
|
|
37
|
+
/** Structure of one rig from its bind pose. Works on skinned joints only. */
|
|
38
|
+
export const classify = (rig: Rig): Skeleton => {
|
|
39
|
+
const notes: string[] = []
|
|
40
|
+
const skinned = (i: number): boolean => rig.bind.fromSkin[i]
|
|
41
|
+
const pos = rig.bind.worldPos
|
|
42
|
+
const children: number[][] = rig.names.map(() => [])
|
|
43
|
+
rig.parents.forEach((p, i) => { if (p >= 0) children[p].push(i) })
|
|
44
|
+
const skids = (i: number): number[] => {
|
|
45
|
+
// Skinned descendants reachable without crossing another skinned joint.
|
|
46
|
+
const out: number[] = []
|
|
47
|
+
const q = [ ...children[i] ]
|
|
48
|
+
while (q.length) { const c = q.shift()!; if (skinned(c)) out.push(c); else q.push(...children[c]) }
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
const sparent = (i: number): number => { let p = rig.parents[i]; while (p >= 0 && !skinned(p)) p = rig.parents[p]; return p }
|
|
52
|
+
|
|
53
|
+
// Root: the skinned joint with no skinned ancestor (the first one in topological order).
|
|
54
|
+
const root = rig.order.find((i) => skinned(i) && sparent(i) < 0)
|
|
55
|
+
if (root === undefined) throw new Error(`${rig.label}: no skinned joints`)
|
|
56
|
+
const joints = rig.order.filter(skinned)
|
|
57
|
+
|
|
58
|
+
// Bounds and body frame. Forward = the principal horizontal axis, pointing to the end where the
|
|
59
|
+
// high joints are (head, ears, neck); lateral = up × forward.
|
|
60
|
+
let lo: Vec3 = [ Infinity, Infinity, Infinity ], hi: Vec3 = [ -Infinity, -Infinity, -Infinity ]
|
|
61
|
+
for (const i of joints) for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], pos[i][k]); hi[k] = Math.max(hi[k], pos[i][k]) }
|
|
62
|
+
const size = Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) || 1
|
|
63
|
+
const height = hi[1] - lo[1] || size
|
|
64
|
+
const cx = joints.reduce((a, i) => a + pos[i][0], 0) / joints.length, cz = joints.reduce((a, i) => a + pos[i][2], 0) / joints.length
|
|
65
|
+
let sxx = 0, sxz = 0, szz = 0
|
|
66
|
+
for (const i of joints) { const x = pos[i][0] - cx, z = pos[i][2] - cz; sxx += x * x; sxz += x * z; szz += z * z }
|
|
67
|
+
// Largest-eigenvector of the 2×2 covariance [[sxx, sxz], [sxz, szz]].
|
|
68
|
+
const theta = 0.5 * Math.atan2(2 * sxz, sxx - szz)
|
|
69
|
+
let forward: Vec3 = [ Math.cos(theta), 0, Math.sin(theta) ]
|
|
70
|
+
const top = [ ...joints ].sort((a, b) => pos[b][1] - pos[a][1]).slice(0, Math.max(1, Math.ceil(joints.length * 0.1)))
|
|
71
|
+
const topProj = top.reduce((a, i) => a + dot(sub(pos[i], [ cx, 0, cz ]), forward), 0) / top.length
|
|
72
|
+
if (topProj < 0) forward = [ -forward[0], 0, -forward[2] ]
|
|
73
|
+
const lateral: Vec3 = [ forward[2], 0, -forward[0] ] // up × forward = (0,1,0) × (fx,0,fz) = (fz, 0, -fx)
|
|
74
|
+
const fwd = (i: number): number => dot(sub(pos[i], pos[root]), forward)
|
|
75
|
+
const lat = (i: number): number => dot(sub(pos[i], pos[root]), lateral)
|
|
76
|
+
const centre = (i: number): boolean => Math.abs(lat(i)) < 0.03 * size
|
|
77
|
+
|
|
78
|
+
// Legs: chains that hang down from the body to the ground, off the centreline.
|
|
79
|
+
const legs: LegInfo[] = []
|
|
80
|
+
{
|
|
81
|
+
const leaves = joints.filter((i) => skids(i).length === 0)
|
|
82
|
+
const feet = leaves.filter((i) => pos[i][1] - lo[1] < 0.2 * height && Math.abs(lat(i)) > 0.03 * size)
|
|
83
|
+
const taken = new Set<number>()
|
|
84
|
+
for (const f of feet) {
|
|
85
|
+
// Up to the first centreline joint (the attach point on the body): that is the leg's extent.
|
|
86
|
+
const chain: number[] = []
|
|
87
|
+
let b = f
|
|
88
|
+
while (b >= 0 && !centre(b)) { chain.unshift(b); b = sparent(b) }
|
|
89
|
+
if (b < 0 || chain.length < 3) continue
|
|
90
|
+
if (chain.some((i) => taken.has(i))) continue // toes of a leg already found
|
|
91
|
+
// Ball of the foot: the first joint from the hip at the chain's lowest level.
|
|
92
|
+
const lowest = Math.min(...chain.map((i) => pos[i][1]))
|
|
93
|
+
const ball = chain.findIndex((i) => pos[i][1] - lowest < 0.04 * height)
|
|
94
|
+
const leg = chain.slice(0, Math.max(2, ball) + 1)
|
|
95
|
+
for (const i of leg) taken.add(i)
|
|
96
|
+
legs.push({ side: lat(leg[0]) > 0 ? 'L' : 'R', end: 'back', chain: leg })
|
|
97
|
+
}
|
|
98
|
+
// Front/back by where the leg attaches along the body. Two or four legs expected.
|
|
99
|
+
legs.sort((a, b) => fwd(a.chain[0]) - fwd(b.chain[0]))
|
|
100
|
+
if (legs.length >= 4) { legs[legs.length - 1].end = 'front'; legs[legs.length - 2].end = 'front' }
|
|
101
|
+
else if (legs.length === 3) { legs[2].end = 'front'; notes.push('three legs found — check the map') }
|
|
102
|
+
else if (legs.length === 2) {
|
|
103
|
+
// A biped, or a quadruped whose other pair was not recognised.
|
|
104
|
+
const mid = (fwd(legs[0].chain[0]) + fwd(legs[1].chain[0])) / 2
|
|
105
|
+
const bodyMid = (Math.min(...joints.map(fwd)) + Math.max(...joints.map(fwd))) / 2
|
|
106
|
+
if (mid > bodyMid) for (const l of legs) l.end = 'front'
|
|
107
|
+
notes.push('two legs found')
|
|
108
|
+
} else if (!legs.length) notes.push('no legs recognised')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Chest: the lowest common ancestor of the front legs (or of all legs when there is no front pair).
|
|
112
|
+
const ancestors = (i: number): number[] => { const out: number[] = []; for (let a = i; a >= 0; a = sparent(a)) out.push(a); return out }
|
|
113
|
+
const lca = (list: number[]): number => {
|
|
114
|
+
if (!list.length) return root
|
|
115
|
+
let common = ancestors(list[0])
|
|
116
|
+
for (const i of list.slice(1)) { const set = new Set(ancestors(i)); common = common.filter((a) => set.has(a)) }
|
|
117
|
+
return common[0] ?? root
|
|
118
|
+
}
|
|
119
|
+
const front = legs.filter((l) => l.end === 'front')
|
|
120
|
+
let chest = front.length ? lca(front.map((l) => sparent(l.chain[0]))) : root
|
|
121
|
+
// The hip attach of the back legs should be at/near the root; if the root sits far behind the
|
|
122
|
+
// pelvis (a long "root bone"), the spine still starts at the root.
|
|
123
|
+
const pathUp = (from: number, to: number): number[] => { const out: number[] = []; for (let a = from; a >= 0; a = sparent(a)) { out.unshift(a); if (a === to) break } return out }
|
|
124
|
+
if (!ancestors(chest).includes(root)) chest = root
|
|
125
|
+
const spine = pathUp(chest, root)
|
|
126
|
+
if (spine[0] !== root) { notes.push('chest is not below the root — spine left as root only'); spine.splice(0, spine.length, root) }
|
|
127
|
+
|
|
128
|
+
// Neck/head: from the chest, the centreline path that keeps going forward. The head is the last
|
|
129
|
+
// joint on it that has side branches (ears/eyes/jaw); otherwise the path's end.
|
|
130
|
+
const legJoints = new Set(legs.flatMap((l) => l.chain))
|
|
131
|
+
const tail: number[] = []
|
|
132
|
+
const neck: number[] = []
|
|
133
|
+
let head: number | null = null, jaw: number | null = null, ears: [number, number] | null = null
|
|
134
|
+
{
|
|
135
|
+
const frontKids = (i: number): number[] => skids(i).filter((c) => !legJoints.has(c) && centre(c) && fwd(c) > fwd(i) - 0.01 * size)
|
|
136
|
+
let cur = chest
|
|
137
|
+
const path: number[] = []
|
|
138
|
+
for (let guard = 0; guard < 64; guard++) {
|
|
139
|
+
const ks = frontKids(cur).sort((a, b) => fwd(b) - fwd(a))
|
|
140
|
+
if (!ks.length) break
|
|
141
|
+
cur = ks[0]; path.push(cur)
|
|
142
|
+
}
|
|
143
|
+
if (path.length) {
|
|
144
|
+
// Head: the furthest joint on the path that branches; jaw/ears hang off it.
|
|
145
|
+
let h = path.length - 1
|
|
146
|
+
while (h > 0 && skids(path[h]).length < 2) h--
|
|
147
|
+
if (skids(path[h]).length < 2) h = path.length - 1
|
|
148
|
+
head = path[h]
|
|
149
|
+
neck.push(...path.slice(0, h + 1))
|
|
150
|
+
const kids = skids(head)
|
|
151
|
+
const pair = kids.filter((c) => Math.abs(lat(c)) > 0.02 * size)
|
|
152
|
+
const left = pair.filter((c) => lat(c) > 0).sort((a, b) => pos[b][1] - pos[a][1]), right = pair.filter((c) => lat(c) < 0).sort((a, b) => pos[b][1] - pos[a][1])
|
|
153
|
+
if (left.length && right.length) ears = [ left[0], right[0] ] // the highest lateral pair: ears
|
|
154
|
+
const centreKids = kids.filter((c) => !pair.includes(c))
|
|
155
|
+
// Jaw (or snout): a centreline child heading forward and DOWN, with its own chain preferably.
|
|
156
|
+
const j = centreKids.filter((c) => pos[c][1] < pos[head!][1] - 0.01 * size && fwd(c) > fwd(head!)).sort((a, b) => skids(b).length - skids(a).length)[0]
|
|
157
|
+
if (j !== undefined) jaw = j
|
|
158
|
+
} else notes.push('no neck/head found forward of the chest')
|
|
159
|
+
|
|
160
|
+
// Tail: from the root (or a joint right next to it), the centreline path heading backward. The
|
|
161
|
+
// first tail bone often sits ON the root, so a candidate counts by where its subtree goes.
|
|
162
|
+
const subtreeFwd = (i: number): number => { let s = 0, c = 0; const q = [ i ]; while (q.length) { const j = q.shift()!; s += fwd(j); c++; q.push(...skids(j)) } return s / c }
|
|
163
|
+
const backKids = (i: number): number[] => skids(i).filter((c) => !legJoints.has(c) && !spine.includes(c) && !neck.includes(c) && centre(c) && subtreeFwd(c) < fwd(i) - 0.01 * size)
|
|
164
|
+
let start = root
|
|
165
|
+
let ks = backKids(start)
|
|
166
|
+
if (!ks.length && spine.length > 1) { start = spine[1]; ks = backKids(start) }
|
|
167
|
+
cur = start
|
|
168
|
+
for (let guard = 0; guard < 64 && ks.length; guard++) {
|
|
169
|
+
cur = ks.sort((a, b) => fwd(a) - fwd(b))[0]
|
|
170
|
+
tail.push(cur)
|
|
171
|
+
ks = backKids(cur)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { root, forward, lateral, size, spine, chest, neck, head, jaw, ears, tail, legs, notes }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface AutoMapResult {
|
|
179
|
+
map: BoneMap
|
|
180
|
+
root: string
|
|
181
|
+
legs: string[]
|
|
182
|
+
target: Skeleton
|
|
183
|
+
donor: Skeleton
|
|
184
|
+
lines: string[] // human-readable pairing report
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Map two classified skeletons onto each other. Chains pair by role; bones inside a chain pair by
|
|
189
|
+
* normalized arc length, ends exact, each donor bone used at most once per chain (extra target bones
|
|
190
|
+
* are left out → bridges).
|
|
191
|
+
*/
|
|
192
|
+
export const autoMap = (target: Rig, donor: Rig): AutoMapResult => {
|
|
193
|
+
const T = classify(target), D = classify(donor)
|
|
194
|
+
const map: BoneMap = {}
|
|
195
|
+
const lines: string[] = []
|
|
196
|
+
const name = (rig: Rig, i: number): string => rig.names[i]
|
|
197
|
+
|
|
198
|
+
const arc = (rig: Rig, chain: number[], anchor: number): number[] => {
|
|
199
|
+
// Cumulative length from the chain's anchor joint (the joint it hangs off) through each bone,
|
|
200
|
+
// normalized so the chain's end is 1. A one-bone chain is therefore [1]: it IS the end.
|
|
201
|
+
const out: number[] = []
|
|
202
|
+
let acc = 0, prev = anchor
|
|
203
|
+
for (const c of chain) { acc += len(sub(rig.bind.worldPos[c], rig.bind.worldPos[prev])); out.push(acc); prev = c }
|
|
204
|
+
const total = acc || 1
|
|
205
|
+
return out.map((v) => v / total)
|
|
206
|
+
}
|
|
207
|
+
// Name hints: bones whose names share an anatomical token (knee/Knee_L, backAnkle/Ankle) pair for
|
|
208
|
+
// less — names are unreliable across rigs, but when they do agree they beat arc length.
|
|
209
|
+
const TOKENS = [ 'scapula', 'shoulder', 'elbow', 'wrist', 'finger', 'hand', 'hip', 'thigh', 'knee', 'shin', 'calf', 'ankle', 'toe', 'foot', 'spine', 'chest', 'neck', 'head', 'jaw', 'ear', 'eye', 'tail', 'rump' ]
|
|
210
|
+
// "frontHip/frontKnee/frontAnkle/frontToes/frontRump" is one rig family's name for the front leg:
|
|
211
|
+
// translate to the forelimb words so they meet Shoulder/Elbow/Wrist/Fingers/Scapula.
|
|
212
|
+
const FRONT: Record<string, string> = { hip: 'shoulder', thigh: 'shoulder', knee: 'elbow', shin: 'wrist', ankle: 'wrist', toe: 'finger', foot: 'hand', rump: 'scapula' }
|
|
213
|
+
const tokensOf = (s: string): Set<string> => {
|
|
214
|
+
const l = s.toLowerCase()
|
|
215
|
+
const found = TOKENS.filter((t) => l.includes(t))
|
|
216
|
+
return new Set(/front|fore/.test(l) ? found.map((t) => FRONT[t] ?? t) : found)
|
|
217
|
+
}
|
|
218
|
+
const nameBonus = (t: number, d: number): number => {
|
|
219
|
+
const a = tokensOf(name(target, t)), b = tokensOf(name(donor, d))
|
|
220
|
+
for (const x of a) if (b.has(x)) return 0.2
|
|
221
|
+
return 0
|
|
222
|
+
}
|
|
223
|
+
const pairChain = (role: string, tc: number[], dc: number[], tAnchor: number, dAnchor: number): void => {
|
|
224
|
+
if (!tc.length || !dc.length) { if (tc.length || dc.length) lines.push(` ${role}: ${tc.length ? 'donor' : 'target'} has none — skipped`); return }
|
|
225
|
+
const tu = arc(target, tc, tAnchor), du = arc(donor, dc, dAnchor)
|
|
226
|
+
// Monotone alignment of the two chains (sequence alignment on arc length): pairing costs the
|
|
227
|
+
// arc-length gap, skipping a bone on either side costs GAP, the two ENDS always pair (foot,
|
|
228
|
+
// head, tail tip). Extra target bones are left out and become bridges in the retarget.
|
|
229
|
+
const GAP = 0.12
|
|
230
|
+
const cost = (k: number, j: number): number => Math.abs(tu[k - 1] - du[j - 1]) - nameBonus(tc[k - 1], dc[j - 1])
|
|
231
|
+
const nt = tc.length - 1, nd = dc.length - 1 // ends handled separately
|
|
232
|
+
const Dp: number[][] = Array.from({ length: nt + 1 }, () => new Array(nd + 1).fill(0))
|
|
233
|
+
for (let k = 1; k <= nt; k++) Dp[k][0] = k * GAP
|
|
234
|
+
for (let j = 1; j <= nd; j++) Dp[0][j] = j * GAP
|
|
235
|
+
for (let k = 1; k <= nt; k++) for (let j = 1; j <= nd; j++) {
|
|
236
|
+
Dp[k][j] = Math.min(Dp[k - 1][j - 1] + cost(k, j), Dp[k - 1][j] + GAP, Dp[k][j - 1] + GAP)
|
|
237
|
+
}
|
|
238
|
+
const pairs: [number, number][] = []
|
|
239
|
+
for (let k = nt, j = nd; k > 0 && j > 0;) {
|
|
240
|
+
if (Math.abs(Dp[k][j] - (Dp[k - 1][j - 1] + cost(k, j))) < 1e-12) { pairs.unshift([ tc[k - 1], dc[j - 1] ]); k--; j-- }
|
|
241
|
+
else if (Dp[k][j] === Dp[k - 1][j] + GAP) k--
|
|
242
|
+
else j--
|
|
243
|
+
}
|
|
244
|
+
pairs.push([ tc[tc.length - 1], dc[dc.length - 1] ])
|
|
245
|
+
for (const [ t, d ] of pairs) map[name(target, t)] = name(donor, d)
|
|
246
|
+
lines.push(` ${role}: ${pairs.map(([ t, d ]) => `${name(target, t)}←${name(donor, d)}`).join(', ')}${tc.length > pairs.length ? ` (+${tc.length - pairs.length} target bone(s) bridged)` : ''}`)
|
|
247
|
+
}
|
|
248
|
+
const sparentOf = (rig: Rig) => (i: number): number => { let p = rig.parents[i]; while (p >= 0 && !rig.bind.fromSkin[p]) p = rig.parents[p]; return p }
|
|
249
|
+
const tsp = sparentOf(target), dsp = sparentOf(donor)
|
|
250
|
+
|
|
251
|
+
// The root pairs with the root; the rest of the spine hangs off it.
|
|
252
|
+
map[name(target, T.root)] = name(donor, D.root)
|
|
253
|
+
lines.push(` root: ${name(target, T.root)}←${name(donor, D.root)}`)
|
|
254
|
+
pairChain('spine', T.spine.slice(1), D.spine.slice(1), T.root, D.root)
|
|
255
|
+
pairChain('neck/head', T.neck, D.neck, T.chest, D.chest)
|
|
256
|
+
if (T.jaw !== null && D.jaw !== null) { map[name(target, T.jaw)] = name(donor, D.jaw); lines.push(` jaw: ${name(target, T.jaw)}←${name(donor, D.jaw)}`) }
|
|
257
|
+
if (T.ears && D.ears) { map[name(target, T.ears[0])] = name(donor, D.ears[0]); map[name(target, T.ears[1])] = name(donor, D.ears[1]); lines.push(` ears: ${name(target, T.ears[0])}←${name(donor, D.ears[0])}, ${name(target, T.ears[1])}←${name(donor, D.ears[1])}`) }
|
|
258
|
+
if (T.tail.length && D.tail.length) pairChain('tail', T.tail, D.tail, tsp(T.tail[0]), dsp(D.tail[0]))
|
|
259
|
+
else if (T.tail.length || D.tail.length) lines.push(` tail: ${T.tail.length ? 'donor' : 'target'} has none — skipped`)
|
|
260
|
+
const legNames: string[] = []
|
|
261
|
+
for (const tl of T.legs) {
|
|
262
|
+
const dl = D.legs.find((l) => l.side === tl.side && l.end === tl.end)
|
|
263
|
+
if (!dl) { lines.push(` leg ${tl.end}-${tl.side}: donor has none — skipped`); continue }
|
|
264
|
+
pairChain(`leg ${tl.end}-${tl.side}`, tl.chain, dl.chain, tsp(tl.chain[0]), dsp(dl.chain[0]))
|
|
265
|
+
legNames.push(name(target, tl.chain[tl.chain.length - 1]))
|
|
266
|
+
}
|
|
267
|
+
for (const n of [ ...T.notes.map((s) => `target: ${s}`), ...D.notes.map((s) => `donor: ${s}`) ]) lines.push(` ⚠ ${n}`)
|
|
268
|
+
return { map, root: name(target, T.root), legs: legNames, target: T, donor: D, lines }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** One-line summary of a classified skeleton, for the CLI. */
|
|
272
|
+
export const describeSkeleton = (rig: Rig, s: Skeleton): string => {
|
|
273
|
+
const n = (i: number | null): string => (i === null ? '-' : rig.names[i])
|
|
274
|
+
return `root ${n(s.root)} · spine ${s.spine.length} · neck ${s.neck.length} · head ${n(s.head)} · jaw ${n(s.jaw)} · ears ${s.ears ? `${n(s.ears[0])}/${n(s.ears[1])}` : '-'} · tail ${s.tail.length} · legs ${s.legs.map((l) => `${l.end[0]}${l.side}:${l.chain.length}`).join(' ')} · facing [${s.forward.map((x) => x.toFixed(2)).join(',')}]`
|
|
275
|
+
}
|
|
276
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// A rig's "ghost": the skeleton alone, posed by ONE of its clips — what a viewport overlays on a
|
|
2
|
+
// retargeted character to compare the transfer against the original. Meshes, skins, materials
|
|
3
|
+
// and the other clips go; the node tree (names = bone names) and the chosen animation stay.
|
|
4
|
+
import type { Document } from '@gltf-transform/core'
|
|
5
|
+
import { prune } from '@gltf-transform/functions'
|
|
6
|
+
|
|
7
|
+
export const ghostSkeleton = async (doc: Document, clip: number): Promise<Document> => {
|
|
8
|
+
const root = doc.getRoot()
|
|
9
|
+
for (const n of root.listNodes()) { n.setMesh(null); n.setSkin(null) }
|
|
10
|
+
for (const m of root.listMeshes()) { for (const p of m.listPrimitives()) p.dispose(); m.dispose() }
|
|
11
|
+
for (const s of root.listSkins()) s.dispose()
|
|
12
|
+
root.listAnimations().forEach((a, i) => { if (i !== clip) a.dispose() })
|
|
13
|
+
await doc.transform(prune())
|
|
14
|
+
// One degenerate triangle at the origin: glTF viewers built around meshes (OGL's GLTFLoader) refuse
|
|
15
|
+
// a file with none, and a mesh-less load leaves the node tree without a scene graph to animate.
|
|
16
|
+
const pos = doc.createAccessor('ghost_pos').setType('VEC3').setArray(new Float32Array(9))
|
|
17
|
+
const prim = doc.createPrimitive().setAttribute('POSITION', pos)
|
|
18
|
+
const mesh = doc.createMesh('ghost_mesh').addPrimitive(prim)
|
|
19
|
+
const node = doc.createNode('ghost_mesh').setMesh(mesh)
|
|
20
|
+
const scene = root.listScenes()[0] ?? doc.createScene('ghost')
|
|
21
|
+
scene.addChild(node)
|
|
22
|
+
return doc
|
|
23
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// The clip's HEADING curve — where the character FACES, for the node's turn under root rotation and
|
|
2
|
+
// for the game's course. Degrees, + = toward +X (the character's left when she faces +Z), 0 = the
|
|
3
|
+
// clip's own +Z at frame 0.
|
|
4
|
+
//
|
|
5
|
+
// Neither the body nor the travel alone will do (worked out with the owner, 2026-09-03):
|
|
6
|
+
// - a pelvis carries the STANCE: Kachujin's idle stands 43° off the direction her straight start
|
|
7
|
+
// runs, so every start's pelvis turns 46–49° more than the heading, a stop's pelvis twists back
|
|
8
|
+
// by as much while she is no longer turning, and a right-45 start's pelvis barely moves at all;
|
|
9
|
+
// - the travel cannot tell which way ROUND the body turned before it moved, and it cannot see a
|
|
10
|
+
// strafe (a face held forward while the body goes sideways).
|
|
11
|
+
// So: while the body MOVES, the heading is the smoothed direction of the root's travel, unwrapped
|
|
12
|
+
// to the nearest turn from the previous frame; while it STANDS after having moved, the heading holds;
|
|
13
|
+
// in the LEADING standing phase (a start that turns on the spot) it goes from 0 to the first moving
|
|
14
|
+
// heading following the PELVIS' turn — its sense and its shape (a body that spun the long way round
|
|
15
|
+
// gets its −270, not a +90) — or a smooth ramp when the pelvis did not turn (the right-45 case). A
|
|
16
|
+
// SLOW window in the middle (a pivot the body runs out of again) follows the pelvis too, and the
|
|
17
|
+
// travel takes over in proportion to the frame's moving weight, so a 180 pivot turns the heading
|
|
18
|
+
// through the pivot instead of stepping it the frame the body moves again. A clip tagged `strafe`
|
|
19
|
+
// keeps 0 throughout: the face stays where the animator put it.
|
|
20
|
+
|
|
21
|
+
export type HeadingCurve = { fps: number, deg: number[] }
|
|
22
|
+
|
|
23
|
+
export interface HeadingOptions {
|
|
24
|
+
/** the face stays forward whatever the travel does (a strafe, a backpedal) */
|
|
25
|
+
strafe?: boolean
|
|
26
|
+
/** speed below which a frame says nothing about the heading (m/s, default 0.6 — a turn on the spot sways the
|
|
27
|
+
* root at 0.4–0.8 m/s in every direction before the body goes) */
|
|
28
|
+
still?: number
|
|
29
|
+
/** speed at which a frame counts fully (m/s, default 1.5) */
|
|
30
|
+
moving?: number
|
|
31
|
+
/** the Gaussian's sigma on the travel direction, seconds (default 0.15 — under half a running step) */
|
|
32
|
+
sigma?: number
|
|
33
|
+
/** the pelvis must have turned this much (degrees) by the first moving frame for its curve to shape the
|
|
34
|
+
* leading phase (default 30) */
|
|
35
|
+
minTurn?: number
|
|
36
|
+
/** through a PIVOT (a slow window the body runs out of again) the heading leaves the travel's line for the
|
|
37
|
+
* pelvis' as the speed drops from this (m/s, default 3.5 — a run's pace) to `still`, and comes back the
|
|
38
|
+
* same way; a final stop keeps the travel's line */
|
|
39
|
+
pivotSpeed?: number
|
|
40
|
+
/** the heading the clip must END at (degrees, + = left): a generated STOP THAT TURNS ends in an idle pose pinned
|
|
41
|
+
* facing the turn, while the pelvis rule alone ends at the turn plus the idle-vs-run stance twist (~40° on
|
|
42
|
+
* Kachujin). The difference is blended in from the frame the body first stands after its last fast frame to
|
|
43
|
+
* the last frame — the head (the turn's own frames) is left as the turn bakes it. */
|
|
44
|
+
endAt?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const wrap180 = (d: number): number => { while (d > 180) d -= 360; while (d < -180) d += 360; return d }
|
|
48
|
+
|
|
49
|
+
/** `root`: the root's position per frame ([x, y, z], metres); `pelvisYaw`: the pelvis' world yaw per
|
|
50
|
+
* frame, RADIANS (atan2 of its forward's x over z); `dt` the frame step (seconds). */
|
|
51
|
+
export const headingCurve = (root: ArrayLike<number>[], pelvisYaw: ArrayLike<number>, dt: number, opts: HeadingOptions = {}): HeadingCurve => {
|
|
52
|
+
const n = Math.min(root.length, pelvisYaw.length)
|
|
53
|
+
const fps = dt > 0 ? 1 / dt : 30
|
|
54
|
+
if (n < 2 || dt <= 0 || opts.strafe) return { fps, deg: new Array(Math.max(1, n)).fill(0) }
|
|
55
|
+
const still = opts.still ?? 0.6, moving = opts.moving ?? 1.5, sigma = opts.sigma ?? 0.15, minTurn = opts.minTurn ?? 30, pivotSpeed = opts.pivotSpeed ?? 3.5
|
|
56
|
+
// per frame: the horizontal velocity (central difference) as a unit vector and its moving weight
|
|
57
|
+
const ux = new Float64Array(n), uz = new Float64Array(n), w = new Float64Array(n), wp = new Float64Array(n)
|
|
58
|
+
for (let k = 0; k < n; k++) {
|
|
59
|
+
const a = Math.max(0, k - 1), b = Math.min(n - 1, k + 1)
|
|
60
|
+
const vx = (root[b]![0]! - root[a]![0]!) / ((b - a) * dt), vz = (root[b]![2]! - root[a]![2]!) / ((b - a) * dt)
|
|
61
|
+
const s = Math.hypot(vx, vz)
|
|
62
|
+
w[k] = Math.max(0, Math.min(1, (s - still) / Math.max(1e-6, moving - still)))
|
|
63
|
+
wp[k] = Math.max(0, Math.min(1, (s - still) / Math.max(1e-6, pivotSpeed - still)))
|
|
64
|
+
if (s > 1e-6) { ux[k] = vx / s; uz[k] = vz / s }
|
|
65
|
+
}
|
|
66
|
+
// the pelvis yaw since frame 0, unwrapped (degrees)
|
|
67
|
+
const pelvis = new Float64Array(n)
|
|
68
|
+
for (let k = 1; k < n; k++) pelvis[k] = pelvis[k - 1]! + wrap180((pelvisYaw[k]! - pelvisYaw[k - 1]!) * 180 / Math.PI)
|
|
69
|
+
// the travel direction, Gaussian-smoothed over the weighted unit vectors; hold while standing
|
|
70
|
+
const r = Math.max(1, Math.round(3 * sigma / dt))
|
|
71
|
+
const kern: number[] = []
|
|
72
|
+
for (let i = -r; i <= r; i++) kern.push(Math.exp(-0.5 * (i * dt / sigma) ** 2))
|
|
73
|
+
const deg: number[] = new Array(n).fill(0)
|
|
74
|
+
let prev = 0, prevK = -1, off = 0
|
|
75
|
+
let first = w.findIndex((v) => v >= 0.95)
|
|
76
|
+
if (first < 0) first = w.findIndex((v) => v >= 0.05)
|
|
77
|
+
// a PIVOT: a stretch between two FAST frames (at `pivotSpeed`, a run's pace — the braking into the pivot
|
|
78
|
+
// starts from there) that dips to standing (w < 0.05) — the run-to-run 180 all but stops for ~0.2 s and
|
|
79
|
+
// runs back. Through it the heading follows the PELVIS, graded by speed; a start's leading phase and a
|
|
80
|
+
// stop's braking (no fast frame on the other side) keep their own rules
|
|
81
|
+
const pivot = new Uint8Array(n)
|
|
82
|
+
let lastFast = -1
|
|
83
|
+
for (let k = 0; k < n; k++) {
|
|
84
|
+
if (wp[k]! < 0.95) continue
|
|
85
|
+
if (lastFast >= 0 && k - lastFast > 1) { let dips = false; for (let j = lastFast + 1; j < k; j++) if (w[j]! < 0.05) { dips = true; break }; if (dips) for (let j = lastFast + 1; j < k; j++) pivot[j] = 1 }
|
|
86
|
+
lastFast = k
|
|
87
|
+
}
|
|
88
|
+
// …and a STOP THAT TURNS (`endAt` given — the caller knows the clip ends facing a turn): the stretch after the
|
|
89
|
+
// last fast frame when the clip ends standing and the pelvis went on turning through it (≥ minTurn) — a
|
|
90
|
+
// run-to-run turn's stop, cut in the pivot. No fast frame on the other side, but the heading must leave the
|
|
91
|
+
// travel's line for the pelvis' the same graded way, or it steps 180° the frame the body moves again (the step
|
|
92
|
+
// back into the stance). Gated on `endAt` on purpose: a STRAIGHT stop's pelvis also turns ~40° into the idle's
|
|
93
|
+
// stance, and this rule would bake that twist into its course (it did — every straight stop drifted 40°).
|
|
94
|
+
const tail = new Uint8Array(n)
|
|
95
|
+
if (opts.endAt !== undefined && lastFast >= 0 && lastFast < n - 1 && w[n - 1]! < 0.05 && Math.abs(pelvis[n - 1]! - pelvis[lastFast]!) >= minTurn) for (let j = lastFast + 1; j < n; j++) pivot[j] = tail[j] = 1
|
|
96
|
+
for (let k = 0; k < n; k++) {
|
|
97
|
+
if (w[k]! < 0.05) { deg[k] = pivot[k] && prevK >= 0 ? pelvis[k]! + off : prev; prev = deg[k]!; continue }
|
|
98
|
+
let sx = 0, sz = 0, sw = 0
|
|
99
|
+
for (let i = -r; i <= r; i++) {
|
|
100
|
+
const j = k + i
|
|
101
|
+
if (j < 0 || j >= n) continue
|
|
102
|
+
const c = kern[i + r]! * w[j]!
|
|
103
|
+
sx += c * ux[j]!; sz += c * uz[j]!; sw += c
|
|
104
|
+
}
|
|
105
|
+
let h = prev
|
|
106
|
+
if (sw > 0.05 && Math.hypot(sx, sz) > 1e-6) {
|
|
107
|
+
const a = Math.atan2(sx, sz) * 180 / Math.PI
|
|
108
|
+
// the first moving frame unwraps NEAREST TO THE PELVIS' TURN (the sense the body chose); after
|
|
109
|
+
// that, nearest to the PELVIS' CURRENT YAW plus the stance offset seen at that first frame — a
|
|
110
|
+
// run that turns about (AA_Running_Turn_180: the travel flips within a frame or two while the
|
|
111
|
+
// pelvis turns through 180 over the whole clip) must unwrap the way the body went, and neither
|
|
112
|
+
// the previous heading nor the pelvis' turn since the previous frame can tell (it baked +190
|
|
113
|
+
// for a pelvis that turned -180). The offset absorbs the stance twist (up to ~50 deg), well
|
|
114
|
+
// under the 180 that separates the two candidates.
|
|
115
|
+
const ref = k === first && Math.abs(pelvis[first]!) >= minTurn ? pelvis[first]!
|
|
116
|
+
: prevK >= 0 ? pelvis[k]! + off : prev
|
|
117
|
+
// …and through a PIVOT the travel's say is the speed's: the heading leaves the travel's line for the
|
|
118
|
+
// pelvis' as the body brakes into the pivot and returns the same way, not by a 180 step the frame
|
|
119
|
+
// the body moves again (the node snapped 180 in one frame there and the body visibly shifted — the
|
|
120
|
+
// run-to-run 180; the pelvis leads the travel by ~60 deg into a pivot, so any switch between the two
|
|
121
|
+
// references is a step unless it is graded by speed). A final stop keeps the travel's line.
|
|
122
|
+
// (in a stop's tail the body creeps and steps back: its smoothed travel is the mean of directions that
|
|
123
|
+
// disagree — the travel's say is scaled by that mean's length, its confidence, so a creeping body's
|
|
124
|
+
// garbage direction cannot nick the curve; the run-to-run turns keep their tuned shape untouched)
|
|
125
|
+
const conf = tail[k] ? Math.min(1, Math.hypot(sx, sz) / sw) : 1
|
|
126
|
+
h = ref + (pivot[k] && k !== first ? wp[k]! * conf : 1) * wrap180(a - ref)
|
|
127
|
+
if (k === first) off = h - pelvis[first]!
|
|
128
|
+
}
|
|
129
|
+
deg[k] = h
|
|
130
|
+
prev = h
|
|
131
|
+
prevK = k
|
|
132
|
+
}
|
|
133
|
+
// the LEADING standing phase: from 0 to the first moving heading — shaped by the pelvis' own turn when
|
|
134
|
+
// it turned (a turn on the spot: the sense and the pace are the body's), a smooth ramp when it did not
|
|
135
|
+
if (first > 1) {
|
|
136
|
+
const target = deg[first]!
|
|
137
|
+
const bodyTurn = pelvis[first]!
|
|
138
|
+
for (let k = 0; k < first; k++) {
|
|
139
|
+
let e: number
|
|
140
|
+
if (Math.abs(bodyTurn) >= minTurn) e = Math.max(0, Math.min(1, pelvis[k]! / bodyTurn))
|
|
141
|
+
else { const u = k / first; e = u * u * (3 - 2 * u) }
|
|
142
|
+
deg[k] = target * e
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// the END pINNED (`endAt`): the difference to it blended in from the first standing frame after the last fast
|
|
146
|
+
// frame (the pivot's bottom — the turn's own frames before it stay as the turn bakes them) to the last frame
|
|
147
|
+
if (opts.endAt !== undefined && n > 1) {
|
|
148
|
+
let k0 = lastFast >= 0 ? lastFast + 1 : 0
|
|
149
|
+
while (k0 < n - 1 && w[k0]! >= 0.05) k0++
|
|
150
|
+
const c = opts.endAt - deg[n - 1]!
|
|
151
|
+
const span = Math.max(1, n - 1 - k0)
|
|
152
|
+
for (let k = k0; k < n; k++) { const u = Math.min(1, (k - k0) / span); deg[k] = deg[k]! + c * u * u * (3 - 2 * u) }
|
|
153
|
+
deg[n - 1] = opts.endAt
|
|
154
|
+
}
|
|
155
|
+
// a clip that does not turn (a run loop, an idle, a stop, a straight start) is pinned to 0 outright: its
|
|
156
|
+
// wobble is the gait's sway, and integrated by root rotation it drifted the node +-1..3 deg per run
|
|
157
|
+
const maxAbs = deg.reduce((m, v) => Math.max(m, Math.abs(v!)), 0)
|
|
158
|
+
if (Math.abs(deg[n - 1]!) < 5 && maxAbs < 8) deg.fill(0)
|
|
159
|
+
for (let k = 0; k < n; k++) deg[k] = Math.round(deg[k]! * 100) / 100
|
|
160
|
+
return { fps, deg }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The curve's value at `t` (seconds), linearly interpolated; past the end, the last value. */
|
|
164
|
+
export const headingAt = (h: HeadingCurve, t: number): number => {
|
|
165
|
+
const f = t * h.fps
|
|
166
|
+
const i = Math.floor(f)
|
|
167
|
+
if (i < 0) return h.deg[0] ?? 0
|
|
168
|
+
if (i >= h.deg.length - 1) return h.deg[h.deg.length - 1] ?? 0
|
|
169
|
+
const u = f - i
|
|
170
|
+
return h.deg[i]! + (h.deg[i + 1]! - h.deg[i]!) * u
|
|
171
|
+
}
|