lecodes-assets 0.2.1 → 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 +203 -26
- package/dist/cli.js +17948 -11911
- package/dist/index.js +17525 -11479
- package/package.json +3 -1
- 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 -1039
- 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 +94 -30
- 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/transcodeKtx2.ts +179 -28
- package/src/doctor/validateGlb.ts +2 -2
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/read.ts +2 -0
- package/src/fbx/toDocument.ts +227 -209
- package/src/geometry.ts +222 -0
- package/src/index.ts +2 -0
- package/src/retarget/heading.ts +238 -171
- package/src/retarget/humanoid.ts +572 -561
- package/src/retarget/retarget.ts +2375 -2272
- package/src/retarget/rig.ts +4 -1
- package/src/retarget/writeMarks.ts +134 -132
- package/src/sky/cmgen.ts +3 -3
- package/src/sky/index.ts +57 -8
- package/src/terrainPack.ts +61 -22
- 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 +34 -429
- package/src/unity/math.ts +82 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +10 -36
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +325 -304
- 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 +24 -5
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/index.ts +6 -5
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +117 -9
- package/src/unreal/package.ts +30 -12
- package/src/unreal/resolve.ts +6 -4
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +70 -3
- package/src/unreal/toDocument.ts +7 -0
- package/src/unreal/versions.ts +8 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +1 -1
- package/src/wasm/lightmapUv.js +1 -1
- package/src/doctor/lightmapUv.ts +0 -452
package/src/retarget/heading.ts
CHANGED
|
@@ -1,171 +1,238 @@
|
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
let
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
deg
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
/** the clip is a LOOP (its last frame is its first, a cycle on): the travel direction is smoothed PERIODICALLY —
|
|
46
|
+
* the sequence continued past either end by the loop's own turn — so the curve is the clean ramp the motion is.
|
|
47
|
+
* Clamped at the ends like a one-shot, a 0.6 s arc loop lost ~7° at each end of its 38° (the Gaussian's
|
|
48
|
+
* truncated half averages the inside only) and the engine turned the node by 23° a pass, the travel by 38:
|
|
49
|
+
* she crabbed 15° a cycle (ArcL4 on Kachujin, 2026-09-05). No leading / tail / pinned-end rules: a loop has
|
|
50
|
+
* no standing phase. */
|
|
51
|
+
loop?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const wrap180 = (d: number): number => { while (d > 180) d -= 360; while (d < -180) d += 360; return d }
|
|
55
|
+
|
|
56
|
+
/** `root`: the root's position per frame ([x, y, z], metres); `pelvisYaw`: the pelvis' world yaw per
|
|
57
|
+
* frame, RADIANS (atan2 of its forward's x over z); `dt` the frame step (seconds). */
|
|
58
|
+
export const headingCurve = (root: ArrayLike<number>[], pelvisYaw: ArrayLike<number>, dt: number, opts: HeadingOptions = {}): HeadingCurve => {
|
|
59
|
+
const n = Math.min(root.length, pelvisYaw.length)
|
|
60
|
+
const fps = dt > 0 ? 1 / dt : 30
|
|
61
|
+
if (n < 2 || dt <= 0 || opts.strafe) return { fps, deg: new Array(Math.max(1, n)).fill(0) }
|
|
62
|
+
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
|
|
63
|
+
// per frame: the horizontal velocity (central difference) as a unit vector and its moving weight
|
|
64
|
+
const ux = new Float64Array(n), uz = new Float64Array(n), w = new Float64Array(n), wp = new Float64Array(n)
|
|
65
|
+
for (let k = 0; k < n; k++) {
|
|
66
|
+
const a = Math.max(0, k - 1), b = Math.min(n - 1, k + 1)
|
|
67
|
+
const vx = (root[b]![0]! - root[a]![0]!) / ((b - a) * dt), vz = (root[b]![2]! - root[a]![2]!) / ((b - a) * dt)
|
|
68
|
+
const s = Math.hypot(vx, vz)
|
|
69
|
+
w[k] = Math.max(0, Math.min(1, (s - still) / Math.max(1e-6, moving - still)))
|
|
70
|
+
wp[k] = Math.max(0, Math.min(1, (s - still) / Math.max(1e-6, pivotSpeed - still)))
|
|
71
|
+
if (s > 1e-6) { ux[k] = vx / s; uz[k] = vz / s }
|
|
72
|
+
}
|
|
73
|
+
if (opts.loop) return loopHeading(ux, uz, w, n, dt, sigma, fps)
|
|
74
|
+
// the pelvis yaw since frame 0, unwrapped (degrees)
|
|
75
|
+
const pelvis = new Float64Array(n)
|
|
76
|
+
for (let k = 1; k < n; k++) pelvis[k] = pelvis[k - 1]! + wrap180((pelvisYaw[k]! - pelvisYaw[k - 1]!) * 180 / Math.PI)
|
|
77
|
+
// the travel direction, Gaussian-smoothed over the weighted unit vectors; hold while standing
|
|
78
|
+
const r = Math.max(1, Math.round(3 * sigma / dt))
|
|
79
|
+
const kern: number[] = []
|
|
80
|
+
for (let i = -r; i <= r; i++) kern.push(Math.exp(-0.5 * (i * dt / sigma) ** 2))
|
|
81
|
+
const deg: number[] = new Array(n).fill(0)
|
|
82
|
+
let prev = 0, prevK = -1, off = 0
|
|
83
|
+
let first = w.findIndex((v) => v >= 0.95)
|
|
84
|
+
if (first < 0) first = w.findIndex((v) => v >= 0.05)
|
|
85
|
+
// a PIVOT: a stretch between two FAST frames (at `pivotSpeed`, a run's pace — the braking into the pivot
|
|
86
|
+
// starts from there) that dips to standing (w < 0.05) — the run-to-run 180 all but stops for ~0.2 s and
|
|
87
|
+
// runs back. Through it the heading follows the PELVIS, graded by speed; a start's leading phase and a
|
|
88
|
+
// stop's braking (no fast frame on the other side) keep their own rules
|
|
89
|
+
const pivot = new Uint8Array(n)
|
|
90
|
+
let lastFast = -1
|
|
91
|
+
for (let k = 0; k < n; k++) {
|
|
92
|
+
if (wp[k]! < 0.95) continue
|
|
93
|
+
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 }
|
|
94
|
+
lastFast = k
|
|
95
|
+
}
|
|
96
|
+
// …and a STOP THAT TURNS (`endAt` given — the caller knows the clip ends facing a turn): the stretch after the
|
|
97
|
+
// last fast frame when the clip ends standing and the pelvis went on turning through it (≥ minTurn) — a
|
|
98
|
+
// run-to-run turn's stop, cut in the pivot. No fast frame on the other side, but the heading must leave the
|
|
99
|
+
// travel's line for the pelvis' the same graded way, or it steps 180° the frame the body moves again (the step
|
|
100
|
+
// back into the stance). Gated on `endAt` on purpose: a STRAIGHT stop's pelvis also turns ~40° into the idle's
|
|
101
|
+
// stance, and this rule would bake that twist into its course (it did — every straight stop drifted 40°).
|
|
102
|
+
// (…and only when the END the caller asks for actually lies a turn away from the travel's line at the last fast
|
|
103
|
+
// frame: `endAt` is given for EVERY generated clip since 2026-09-05, a straight stop's included, and its pelvis
|
|
104
|
+
// turns ~40° into the stance as well — that alone made a straight stop's tail follow the pelvis to 54° and back)
|
|
105
|
+
const tail = new Uint8Array(n)
|
|
106
|
+
const travelAtFast = lastFast >= 0 ? Math.atan2(ux[lastFast]!, uz[lastFast]!) * 180 / Math.PI : 0
|
|
107
|
+
const endTurns = opts.endAt !== undefined && Math.abs(wrap180(opts.endAt - travelAtFast)) >= minTurn
|
|
108
|
+
if (endTurns && 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
|
|
109
|
+
// A FINAL STOP'S TAIL: after the last frame of confident motion (w ≥ 0.95, `moving`) with no such frame after it,
|
|
110
|
+
// the body only creeps and steps into its stance at 0.6–2 m/s in whatever direction the settle takes — that is
|
|
111
|
+
// not travel, and read as travel it turned a straight stop's course 5.5° (KM_STM on Kachujin, 2026-09-05: the
|
|
112
|
+
// node turned while she stood, the feet visibly rotating, and every following start began 5.5° off). The tail
|
|
113
|
+
// HOLDS the heading of the last confident frame; `endAt` (below) then brings it to where the clip must end.
|
|
114
|
+
// A pivot-tail (a turn stop cut in its pivot, `tail[]`) keeps its pelvis-graded rule.
|
|
115
|
+
let lastMoving = -1
|
|
116
|
+
for (let k = 0; k < n; k++) if (w[k]! >= 0.95) lastMoving = k
|
|
117
|
+
for (let k = 0; k < n; k++) {
|
|
118
|
+
if (w[k]! < 0.05) { deg[k] = pivot[k] && prevK >= 0 ? pelvis[k]! + off : prev; prev = deg[k]!; continue }
|
|
119
|
+
if (lastMoving >= 0 && k > lastMoving && !pivot[k]) { deg[k] = prev; continue }
|
|
120
|
+
let sx = 0, sz = 0, sw = 0
|
|
121
|
+
for (let i = -r; i <= r; i++) {
|
|
122
|
+
const j = k + i
|
|
123
|
+
if (j < 0 || j >= n) continue
|
|
124
|
+
const c = kern[i + r]! * w[j]!
|
|
125
|
+
sx += c * ux[j]!; sz += c * uz[j]!; sw += c
|
|
126
|
+
}
|
|
127
|
+
let h = prev
|
|
128
|
+
if (sw > 0.05 && Math.hypot(sx, sz) > 1e-6) {
|
|
129
|
+
const a = Math.atan2(sx, sz) * 180 / Math.PI
|
|
130
|
+
// the first moving frame unwraps NEAREST TO THE PELVIS' TURN (the sense the body chose); after
|
|
131
|
+
// that, nearest to the PELVIS' CURRENT YAW plus the stance offset seen at that first frame — a
|
|
132
|
+
// run that turns about (AA_Running_Turn_180: the travel flips within a frame or two while the
|
|
133
|
+
// pelvis turns through 180 over the whole clip) must unwrap the way the body went, and neither
|
|
134
|
+
// the previous heading nor the pelvis' turn since the previous frame can tell (it baked +190
|
|
135
|
+
// for a pelvis that turned -180). The offset absorbs the stance twist (up to ~50 deg), well
|
|
136
|
+
// under the 180 that separates the two candidates.
|
|
137
|
+
const ref = k === first && Math.abs(pelvis[first]!) >= minTurn ? pelvis[first]!
|
|
138
|
+
: prevK >= 0 ? pelvis[k]! + off : prev
|
|
139
|
+
// …and through a PIVOT the travel's say is the speed's: the heading leaves the travel's line for the
|
|
140
|
+
// pelvis' as the body brakes into the pivot and returns the same way, not by a 180 step the frame
|
|
141
|
+
// the body moves again (the node snapped 180 in one frame there and the body visibly shifted — the
|
|
142
|
+
// run-to-run 180; the pelvis leads the travel by ~60 deg into a pivot, so any switch between the two
|
|
143
|
+
// references is a step unless it is graded by speed). A final stop keeps the travel's line.
|
|
144
|
+
// (in a stop's tail the body creeps and steps back: its smoothed travel is the mean of directions that
|
|
145
|
+
// disagree — the travel's say is scaled by that mean's length, its confidence, so a creeping body's
|
|
146
|
+
// garbage direction cannot nick the curve; the run-to-run turns keep their tuned shape untouched)
|
|
147
|
+
const conf = tail[k] ? Math.min(1, Math.hypot(sx, sz) / sw) : 1
|
|
148
|
+
h = ref + (pivot[k] && k !== first ? wp[k]! * conf : 1) * wrap180(a - ref)
|
|
149
|
+
if (k === first) off = h - pelvis[first]!
|
|
150
|
+
}
|
|
151
|
+
deg[k] = h
|
|
152
|
+
prev = h
|
|
153
|
+
prevK = k
|
|
154
|
+
}
|
|
155
|
+
// the LEADING standing phase: from 0 to the first moving heading — shaped by the pelvis' own turn when
|
|
156
|
+
// it turned (a turn on the spot: the sense and the pace are the body's), a smooth ramp when it did not
|
|
157
|
+
if (first > 1) {
|
|
158
|
+
const target = deg[first]!
|
|
159
|
+
const bodyTurn = pelvis[first]!
|
|
160
|
+
for (let k = 0; k < first; k++) {
|
|
161
|
+
let e: number
|
|
162
|
+
if (Math.abs(bodyTurn) >= minTurn) e = Math.max(0, Math.min(1, pelvis[k]! / bodyTurn))
|
|
163
|
+
else { const u = k / first; e = u * u * (3 - 2 * u) }
|
|
164
|
+
deg[k] = target * e
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// the END pINNED (`endAt`): the difference to it blended in from the first standing frame after the last fast
|
|
168
|
+
// frame (the pivot's bottom — the turn's own frames before it stay as the turn bakes them) to the last frame
|
|
169
|
+
if (opts.endAt !== undefined && n > 1) {
|
|
170
|
+
let k0 = lastFast >= 0 ? lastFast + 1 : 0
|
|
171
|
+
while (k0 < n - 1 && w[k0]! >= 0.05) k0++
|
|
172
|
+
// a clip that ENDS MOVING (a start, a run turn) has no standing tail to blend over: pinned on its last frame
|
|
173
|
+
// alone the difference was a 1–3° step in one frame, right at the seam into the run. Spread it over the
|
|
174
|
+
// last 0.5 s of motion instead.
|
|
175
|
+
if (k0 >= n - 1) k0 = Math.max(first > 0 ? first : 0, n - 1 - Math.round(0.5 / dt))
|
|
176
|
+
// …and the end is taken NEAREST ON THE CIRCLE: a 135 that went the long way round (−225) asked to end at
|
|
177
|
+
// +135 must not be handed 360° of extra turn over its tail
|
|
178
|
+
const c = wrap180(opts.endAt - deg[n - 1]!)
|
|
179
|
+
const span = Math.max(1, n - 1 - k0)
|
|
180
|
+
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) }
|
|
181
|
+
}
|
|
182
|
+
// a clip that does not turn (a run loop, an idle, a stop, a straight start) is pinned to 0 outright: its
|
|
183
|
+
// wobble is the gait's sway, and integrated by root rotation it drifted the node +-1..3 deg per run
|
|
184
|
+
const maxAbs = deg.reduce((m, v) => Math.max(m, Math.abs(v!)), 0)
|
|
185
|
+
if (Math.abs(deg[n - 1]!) < 5 && maxAbs < 8) deg.fill(0)
|
|
186
|
+
for (let k = 0; k < n; k++) deg[k] = Math.round(deg[k]! * 100) / 100
|
|
187
|
+
return { fps, deg }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A LOOP's heading: the unwrapped travel angle, continued past either end by the loop's turn (the period is
|
|
191
|
+
* n − 1 frames — the last frame repeats the first), Gaussian-smoothed over that periodic sequence. A loop that
|
|
192
|
+
* does not turn (a straight run, |turn| < 5°) is pinned to 0 like a one-shot: its wobble is the gait's sway. */
|
|
193
|
+
const loopHeading = (ux: Float64Array, uz: Float64Array, w: Float64Array, n: number, dt: number, sigma: number, fps: number): HeadingCurve => {
|
|
194
|
+
const period = n - 1
|
|
195
|
+
// the raw angle per frame, unwrapped from the previous (a still frame keeps the previous angle)
|
|
196
|
+
const ang = new Float64Array(n)
|
|
197
|
+
for (let k = 0; k < n; k++) {
|
|
198
|
+
const has = Math.hypot(ux[k]!, uz[k]!) > 1e-6
|
|
199
|
+
const a = has ? Math.atan2(ux[k]!, uz[k]!) * 180 / Math.PI : 0
|
|
200
|
+
ang[k] = k === 0 ? a : ang[k - 1]! + (has ? wrap180(a - ang[k - 1]!) : 0)
|
|
201
|
+
}
|
|
202
|
+
// the loop's turn: the angle a cycle on is the angle now plus this (the last frame IS the first, so its
|
|
203
|
+
// direction is the first's turned by the loop; the raw end-to-end difference says how much)
|
|
204
|
+
const turn = ang[n - 1]! - ang[0]!
|
|
205
|
+
if (Math.abs(turn) < 5) return { fps, deg: new Array(n).fill(0) }
|
|
206
|
+
const at = (j: number): { a: number, w: number } => {
|
|
207
|
+
let cyc = 0
|
|
208
|
+
while (j < 0) { j += period; cyc-- }
|
|
209
|
+
while (j >= period) { j -= period; cyc++ }
|
|
210
|
+
return { a: ang[j]! + cyc * turn, w: w[j]! }
|
|
211
|
+
}
|
|
212
|
+
const r = Math.max(1, Math.round(3 * sigma / dt))
|
|
213
|
+
const deg: number[] = new Array(n).fill(0)
|
|
214
|
+
// (the sequence is unwrapped and continued continuously, so this is a plain weighted mean of angles)
|
|
215
|
+
for (let k = 0; k < n; k++) {
|
|
216
|
+
let s = 0, sw = 0
|
|
217
|
+
for (let i = -r; i <= r; i++) {
|
|
218
|
+
const c = Math.exp(-0.5 * (i * dt / sigma) ** 2)
|
|
219
|
+
const p = at(k + i)
|
|
220
|
+
s += c * p.w * p.a; sw += c * p.w
|
|
221
|
+
}
|
|
222
|
+
deg[k] = sw > 1e-6 ? s / sw : ang[k]!
|
|
223
|
+
}
|
|
224
|
+
// the last frame is the first, a turn on — exactly, so the engine's seam (heading(end) − heading(0)) is the turn
|
|
225
|
+
deg[n - 1] = deg[0]! + turn
|
|
226
|
+
for (let k = 0; k < n; k++) deg[k] = Math.round(deg[k]! * 100) / 100
|
|
227
|
+
return { fps, deg }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The curve's value at `t` (seconds), linearly interpolated; past the end, the last value. */
|
|
231
|
+
export const headingAt = (h: HeadingCurve, t: number): number => {
|
|
232
|
+
const f = t * h.fps
|
|
233
|
+
const i = Math.floor(f)
|
|
234
|
+
if (i < 0) return h.deg[0] ?? 0
|
|
235
|
+
if (i >= h.deg.length - 1) return h.deg[h.deg.length - 1] ?? 0
|
|
236
|
+
const u = f - i
|
|
237
|
+
return h.deg[i]! + (h.deg[i + 1]! - h.deg[i]!) * u
|
|
238
|
+
}
|