lecodes-sdk 0.19.1 → 0.20.0

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.
@@ -1,670 +1,736 @@
1
- // The engine-facing core behind Animator / Layer / Loop / Playback. NOT exported from the SDK: the
2
- // public classes are thin views over the records here. The engine (creator-anim) runs the whole
3
- // per-frame loop — sources, INERTIALIZED transitions, blend spaces, root motion — and this file only
4
- // binds clips, issues play / stop / blend calls and routes the engine's slot events back to playbacks.
5
- //
6
- // A layer shows exactly ONE source at a time: its one-shot, its loop, or nothing. A transition is not
7
- // a crossfade — the engine records the offset between the pose it SHOWED and the pose the new source
8
- // shows, and decays that offset away over `fade`. So the SDK keeps no fade bookkeeping and no slot
9
- // pool: a slot is simply a clip bound to a layer, restartable at will (docs/animation-v2-plan.md).
10
-
11
- import { AnimationClip } from "./AnimationClip"
12
- import { Loop } from "./Loop"
13
- import { Layer } from "./Layer"
14
- import { Playback } from "./Playback"
15
- import { Vec3 } from "../../math/vec"
16
- import type { Node } from "../Node"
17
-
18
- /** A position on a blend axis (1D) or plane (2D). */
19
- export type BlendPosition = number | readonly [number, number]
20
-
21
- /** The transition time (seconds) a play / playLoop without `fade` uses. */
22
- export const DEFAULT_FADE = 0.2
23
-
24
- export type PlayOptions = {
25
- /** Transition seconds — how long the difference to the pose the layer SHOWED takes to decay
26
- * (default 0.2; `0` = cut). Also the transition back at the end unless `fadeOut` overrides it. */
27
- fade?: number
28
- /** Transition seconds for the way in only (overrides `fade`). */
29
- fadeIn?: number
30
- /** One-shots: the transition BACK at the end (overrides `fade`). It starts this long before the
31
- * clip ends — while it still plays — so the hand-over lands on the last pose, not after it. On a
32
- * layer with no loop to return to, giving `fadeOut` releases the layer at the end instead of
33
- * holding the last frame. */
34
- fadeOut?: number
35
- /** Playback rate for this clip (default 1). */
36
- speed?: number
37
- /** Rewind even if the clip is already playing (default: rewind only if it isn't). */
38
- restart?: boolean
39
- /** Enter the clip at a point in the GAIT CYCLE instead of at its start: `'match'` = the phase the
40
- * layer shows right now (the walk's left foot is down → the turn clip starts where its left foot
41
- * is down too, so nothing slides), or a number 0–1. Clips without a gait cycle ignore it. */
42
- phase?: number | "match"
43
- /** TURN WARP (degrees, + = left): the node turns this much in total over the clip instead of what the
44
- * clip's baked heading says — its turn curve scaled, so the difference grows where the clip turns and
45
- * pivots about the planted foot. A 45° start played with `turn: 60` turns 60; played with `turn: 45`
46
- * it lands on 45 exactly whatever the clip's own run-off. Needs `rootMotion` + `rootRotation` and a
47
- * clip with a baked heading (`lecodes assets marks` / `retarget`); a clip that turns under 10° is
48
- * left alone. Ignored by hosts without it. */
49
- turn?: number
50
- }
51
-
52
- export type StopOptions = { fade?: number }
53
-
54
- export type LoopOptions = {
55
- /** Transition seconds from whatever the layer shows — its previous loop, or a one-shot still
56
- * playing (default 0.2; `0` = cut). */
57
- fade?: number
58
- /** Playback rate of the loop's clips (default 1). */
59
- speed?: number
60
- /** Where in the CYCLE to start, 0–1, or `'match'` = where the layer is now (what a gait loop taking
61
- * over from a start / turn clip wants). The members share one clock, so this is every member's phase. */
62
- phase?: number | "match"
63
- }
64
- /** What a layer loops: one clip (name / index), or a blend space `{ name: position }`. */
65
- export type LoopDef = string | number | Record<string, BlendPosition>
66
-
67
- /** One clip the layer shows this frame — `layer.active` / `anim.active` (a debug overlay's list). */
68
- export type ActiveClip = {
69
- name: string
70
- clip: AnimationClip
71
- /** contribution to the pose, 0–1: the shown one-shot 1, a loop member its blend share */
72
- weight: number
73
- /** clock (s) */
74
- time: number
75
- /** a member of the layer's loop (else the layer's one-shot) */
76
- loop: boolean
77
- }
78
-
79
- export type LayerOptions = {
80
- /** Bone name(s): the layer drives only these subtrees (`'Spine1'` = upper body). Default: the whole rig. */
81
- mask?: string | string[]
82
- /** Blend each clip's DELTA from its own first frame on top of the layers below (lean, breathe, recoil)
83
- * instead of overriding them. */
84
- additive?: boolean
85
- /** Layer contribution 0–1 (default 1). */
86
- weight?: number
87
- }
88
-
89
- export type ClipEventHandler = (clip: string, layer: Layer) => void
90
- /** A foot planted: which one, and where in the world it landed. */
91
- export type StepHandler = (side: "left" | "right", position: Vec3) => void
92
-
93
- /** What the engine measured on a clip once it was bound to this skeleton (`anim.curves('Walk')`).
94
- * The root curves are of the clip's ROOT bone — its travel is what root motion would move. */
95
- export type ClipCurves = {
96
- /** total horizontal root travel, metres */
97
- travel: number
98
- /** mean root speed over the clip, m/s */
99
- speed: number
100
- /** total root yaw, radians (+ = left) */
101
- turn: number
102
- /** the root barely moves — an in-place clip */
103
- inPlace: boolean
104
- /** the clip has a gait cycle (feet planting in turn) — `phaseAt` is meaningful */
105
- hasPhase: boolean
106
- /** When each foot TOUCHES the ground, seconds — the phase anchor. `still` is the window inside it
107
- * where the foot is genuinely STATIONARY (a touching foot still rolls for its first beats, and a
108
- * stop take's final plant touches long before the body has braked); absent = never still.
109
- * `at` = where the foot is at the touch's start ([x, y, z], model space). */
110
- contacts: { side: "left" | "right", from: number, to: number, at: [number, number, number], still?: { from: number, to: number } }[]
111
- /** the HANDS on the ground (a cartwheel, a roll, a vault) — from the clip's marks in the GLB; empty when none */
112
- hands: { side: "left" | "right", from: number, to: number, at: [number, number, number], still?: { from: number, to: number } }[]
113
- /** A CLIMBING clip's tread levels (a stair or slope take): the model-space heights its feet plant
114
- * at, sorted ascending, with the riser (median level spacing — the step height the clip was
115
- * authored for). Absent on a flat clip. */
116
- treads?: { riser: number, levels: number[] }
117
- /** the gait phase at `time`: 0 at a left-foot-down, 0.5 at a right-foot-down; -1 without a cycle */
118
- phaseAt(time: number): number
119
- /** cumulative root travel (m) at `time` */
120
- travelAt(time: number): number
121
- /** cumulative root yaw (rad, + = left) at `time` */
122
- turnAt(time: number): number
123
- /** the root's own speed at `time`, m/s — how fast the clip is moving the body right there */
124
- speedAt(time: number): number
125
- /** the unit travel direction at `time` ([x, z], model space, held through stills) — integrate
126
- * direction × d(travel) to reconstruct the root's 2D path (a treadmill display, a turn's arc) */
127
- directionAt(time: number): [number, number]
128
- /** the first time the clip has turned `yaw` radians (+ = left): where to enter a turn a body has
129
- * already begun, so the clip continues the move instead of restarting it */
130
- timeAtTurn(yaw: number): number
131
- /** How far this clip's pose is from `target`'s, in metres — the joint distance plus the velocity
132
- * difference over a tenth of a second, the planted foot weighted most. That difference is exactly
133
- * what a hand-over hands the transition to decay away, so it says what switching to `target` would
134
- * cost at `time`. The two are lined up by cycle PHASE (a clip that continues the walk) or by TIME
135
- * (`align: 'time'` — two clips that both begin from standing share no cycle). */
136
- fitTo(target: string | number, time: number, options?: { align?: "phase" | "time" }): number | undefined
137
- /** The earliest moment (seconds) at which handing over to `target` costs no more than `within`
138
- * metres — a start's last steps ARE the walk, so this is where it stops being worth playing.
139
- * `atContact` (default true) snaps to the next foot-down: a switch under a planted foot is the one
140
- * the eye forgives. -1 = the clip never gets that close. */
141
- exitTo(target: string | number, options?: { within?: number, atContact?: boolean, align?: "phase" | "time" }): number
142
- /** The knee's bend plane at `time`, predicted from the skeleton's calibrated hinge axis carried by
143
- * this clip's own thigh rotation — continuous even where the leg is straight (where a plane
144
- * derived from positions is pure noise). `pole` = unit direction from the hip→ankle line toward
145
- * the knee (a two-bone solver's bend direction), `normal` = the plane's normal; both in the model
146
- * pose's frame. Undefined = no leg chain, or no knee motion bound to calibrate from. */
147
- kneePoleAt(side: "left" | "right", time: number): { pole: [number, number, number], normal: [number, number, number] } | undefined
148
- /** The clip's baked physics at `time` (unit body mass, model space): `com` = the body's center of
149
- * mass (biomechanical segment fractions on the classified bones), `velocity` = its velocity
150
- * (= linear momentum per kg), `angular` = the angular momentum about the COM (m²/s per kg,
151
- * low-passed ~0.5 s — the REGULATED component, e.g. a turn's sustained yaw; the per-stride
152
- * pitch/roll exchange between the limbs and the trunk is filtered out),
153
- * `support` = per-foot load [left, right] — contact-gated, split by where the COM stands between
154
- * the feet, scaled by the vertical force proxy (above 1 on a landing impact, 0 in flight).
155
- * A model, not a measurement — meant for ranking transitions and display, both sides of any
156
- * comparison sharing the same model. Undefined = nothing classified on this skeleton. */
157
- physicsAt(time: number): { com: [number, number, number], velocity: [number, number, number], angular: [number, number, number], support: [number, number] } | undefined
158
- /** The clip's MATCHING FEATURE ROW at `time` — a fresh Float32Array(37) in the clip's heading
159
- * frame at that time (x lateral, + left; y up; z forward): 0–5 feet positions relative to the
160
- * pelvis' ground point, 6–11 feet velocities, 12–14 pelvis velocity, 15 pelvis height, 16–18
161
- * COM velocity, 19–20 support L/R, 21–22 contact phase L/R, 23 yaw angular momentum, 24–31 the
162
- * clip's own path 0.3/0.6/1.0/1.5 s ahead as (lateral, forward) pairs, 32–35 facing change at
163
- * those horizons (rad, + = left), 36 cyclic flag. What a trajectory-and-state matcher compares.
164
- * Undefined = no leg chain. */
165
- featuresAt(time: number): Float32Array | undefined
166
- /** The take is a LOOP by measurement: its seam is continuous (feet back where they started
167
- * relative to the root, no turn). Starts, stops and turns are not. */
168
- cyclic: boolean
169
- }
170
-
171
- /** The calibrated knee hinge-axis report (`anim.kneeAxis('left')`): the axis in the thigh's local
172
- * frame, and the calibration's own confidence — the angular spread of its measurements (radians;
173
- * a real knee comes in at a few degrees) over `samples` consecutive-frame deltas of the pack.
174
- * `plant` = the calibrated PLANT HEIGHTS (model space): where the ankle / toe actually sit under
175
- * full weight, the median of the pack's still-window lows — absent when nothing plants. */
176
- export type KneeAxisReport = { axis: [number, number, number], spreadMean: number, spreadMax: number, samples: number, plant?: { ankleY: number, toeY: number } }
177
-
178
- // ---- records -------------------------------------------------------------------------------------
179
-
180
- /** A clip bound to a layer — one per (clip, layer), reused by every play. `playback` = the most recent
181
- * one-shot on it (`playing` while it still owns the layer; kept after that so its handle can still be
182
- * read — a restart re-enters the same slot with a new record). */
183
- export type SlotRec = { name: string, clip: AnimationClip, slot: number, layer: LayerRec, inBlend: boolean, playback?: PlaybackRec }
184
- export type PlaybackRec = { slot: SlotRec, playing: boolean, done: Promise<boolean>, resolve: (natural: boolean) => void }
185
- export type LayerRec = { index: number, view: Layer, slots: SlotRec[], loop?: LoopRec, weight: number, mask: string, additive: boolean }
186
- export type LoopRec = { layer: LayerRec, members: { name: string, slot: SlotRec }[], value: BlendPosition, view: Loop, dead: boolean }
187
-
188
- const handlers = new Map<number, (slot: number, type: number) => void>()
189
- const stepHandlers = new Map<number, (side: number, x: number, y: number, z: number) => void>()
190
- let eventsInstalled = false
191
- const ensureEvents = (): void => {
192
- if (eventsInstalled) return
193
- eventsInstalled = true
194
- _creator.setOnAnimatorEvent((animatorId, slot, type) => handlers.get(animatorId)?.(slot, type))
195
- _creator.setOnAnimatorStep((animatorId, side, x, y, z) => stepHandlers.get(animatorId)?.(side, x, y, z))
196
- }
197
-
198
- const settled = (slot: SlotRec): PlaybackRec => {
199
- let resolve = (_n: boolean): void => {}
200
- const done = new Promise<boolean>((r) => { resolve = r })
201
- resolve(false)
202
- return { slot, playing: false, done, resolve }
203
- }
204
-
205
- const curveInfo = new Float32Array(4)
206
- const kneeOut = new Float32Array(8)
207
- const physOut = new Float32Array(11)
208
-
209
- export class Core {
210
- id = 0
211
- /** Added clips by name (`addClip`) — the lookup index; an added name overrides an embedded one. */
212
- readonly table = new Map<string, AnimationClip>()
213
- readonly layers: LayerRec[] = []
214
- speed = 1
215
- private _embedded?: AnimationClip[]
216
- private readonly _added: { name: string, clip: AnimationClip }[] = []
217
- private _list?: AnimationClip[]
218
- private readonly _listeners = new Map<string, Set<ClipEventHandler>>()
219
-
220
- readonly node: Node
221
- constructor(node: Node) { this.node = node; this.layers.push(this.newLayer()) }
222
-
223
- // ---- clips ----
224
- embedded(): AnimationClip[] {
225
- if (!this._embedded) {
226
- this._embedded = AnimationClip._ofSet(_creator.getGlbClipSet(this.node.id))
227
- const seen = new Set<string>()
228
- for (const c of this._embedded) {
229
- if (seen.has(c.name)) console.warn(`Animator: ${this.node.name || "the node"} has several clips named '${c.name}' — by name you get the first; reach the others by index (anim.clip(n))`)
230
- seen.add(c.name)
231
- }
232
- }
233
- return this._embedded
234
- }
235
- /** The ordered clip list: the GLB's clips in file order (an added clip of the same name takes the
236
- * embedded one's place), then the remaining added clips in insertion order. Cached until addClip. */
237
- clips(): AnimationClip[] {
238
- if (this._list) return this._list
239
- const out: AnimationClip[] = []
240
- const placed = new Set<string>()
241
- for (const c of this.embedded()) {
242
- const override = this.table.get(c.name)
243
- if (override && !placed.has(c.name)) { out.push(override); placed.add(c.name) }
244
- else out.push(c)
245
- }
246
- for (const a of this._added) if (!placed.has(a.name)) { out.push(a.clip); placed.add(a.name) }
247
- return this._list = out
248
- }
249
- addClip(name: string, clip: AnimationClip): void {
250
- const i = this._added.findIndex((a) => a.name === name)
251
- if (i >= 0) this._added[i] = { name, clip }
252
- else this._added.push({ name, clip })
253
- this.table.set(name, clip)
254
- this._list = undefined
255
- }
256
- /** A clip by name (added first, then embedded), or by index into `clips()` (none = the first). */
257
- resolveClip(ref: string | number | undefined): [string, AnimationClip] | undefined {
258
- if (ref === undefined || typeof ref === "number") { const c = this.clips()[ref ?? 0]; return c ? [ c.name, c ] : undefined }
259
- const c = this.table.get(ref) ?? this.embedded().find((e) => e.name === ref)
260
- return c ? [ ref, c ] : undefined
261
- }
262
- private clipNames(): string { return this.clips().map((c) => c.name).join(", ") || "(empty)" }
263
-
264
- // ---- lifecycle ----
265
- /** Create the native animator on demand. False = nothing to animate (warned). */
266
- ensure(): boolean {
267
- if (this.id) return true
268
- if (this.embedded().length === 0 && this.table.size === 0) { console.warn(`Animator: no clips on ${this.node.name || "the node"} — nothing to play`); return false }
269
- ensureEvents()
270
- this.id = _creator.animatorCreate(this.node.id)
271
- if (!this.id) { console.warn("Animator: node has no transform hierarchy"); return false }
272
- handlers.set(this.id, (slot, type) => this.onEvent(slot, type))
273
- stepHandlers.set(this.id, (side, x, y, z) => this.onStep(side, x, y, z))
274
- if (this._feet) _creator.animatorSetFeet(this.id, this._feet[0], this._feet[1])
275
- if (this._warp) _creator.animatorSetWarpParams(this.id, this._warp)
276
- if (this._feetParams && _creator.animatorSetFeetParams) _creator.animatorSetFeetParams(this.id, this._feetParams)
277
- if (this.speed !== 1) _creator.animatorSetGlobal(this.id, this.speed, false)
278
- for (const L of this.layers) if (L.index > 0) this.pushLayer(L)
279
- return true
280
- }
281
- destroy(): void {
282
- for (const L of this.layers) this.settleLayer(L)
283
- if (this.id) { _creator.animatorDestroy(this.id); handlers.delete(this.id); stepHandlers.delete(this.id); this.id = 0 }
284
- for (const L of this.layers) { L.slots = []; if (L.loop) { L.loop.dead = true; L.loop = undefined } }
285
- }
286
-
287
- // ---- layers ----
288
- newLayer(options?: LayerOptions): LayerRec {
289
- const rec: LayerRec = { index: this.layers.length, view: undefined as unknown as Layer, slots: [], weight: options?.weight ?? 1, mask: "", additive: options?.additive ?? false }
290
- if (options?.mask !== undefined) rec.mask = Array.isArray(options.mask) ? options.mask.join("\n") : options.mask
291
- rec.view = new Layer(rec, this)
292
- return rec
293
- }
294
- addLayer(options: LayerOptions): Layer {
295
- if (this.layers.length >= 255) { console.warn("Animator: too many layers"); return this.layers[this.layers.length - 1].view }
296
- const rec = this.newLayer(options)
297
- this.layers.push(rec)
298
- if (this.id) this.pushLayer(rec)
299
- return rec.view
300
- }
301
- /** Arm the layer's next transition with a wind-up: see `Layer.anticipate`. */
302
- anticipate(L: LayerRec, amount: number): void { if (this.id) _creator.animatorSetLayerAnticipation?.(this.id, L.index, amount) }
303
-
304
- pushLayer(L: LayerRec): void {
305
- if (!this.id) return
306
- for (const m of L.mask.split("\n")) if (m && !this.node.bone(m)) console.warn(`Animator: layer mask bone '${m}' not found under ${this.node.name || "the node"}`)
307
- _creator.animatorSetLayer(this.id, L.index, L.weight, L.additive, L.mask)
308
- }
309
-
310
- // ---- slots ----
311
- /** The layer's slot for the clip — bound on first use, reused after that. */
312
- slotFor(L: LayerRec, name: string, clip: AnimationClip): SlotRec | undefined {
313
- const have = L.slots.find((s) => s.name === name && s.clip === clip)
314
- if (have) return have
315
- const slot = _creator.animatorBind(this.id, clip._set, clip._index, L.index)
316
- if (slot < 0) { console.warn(`Animator: could not bind clip '${name}'`); return undefined }
317
- const bound = _creator.animatorBoundTracks(this.id, slot)
318
- if (clip.trackCount > 0 && bound === 0) console.warn(`Animator: '${name}' — no track matched a node name under ${this.node.name || "the node"} (different rig?)`)
319
- else if (bound < clip.trackCount) console.warn(`Animator: '${name}' — ${clip.trackCount - bound}/${clip.trackCount} tracks unbound (bone names not found)`)
320
- const rec: SlotRec = { name, clip, slot, layer: L, inBlend: false }
321
- L.slots.push(rec)
322
- return rec
323
- }
324
-
325
- /** The base layer's native slot for a clip, bound on first ask (-1 = no such clip / no animator). */
326
- slotIndex(ref: string | number): number {
327
- const found = this.resolveClip(ref)
328
- if (!found || !this.ensure()) return -1
329
- return this.slotFor(this.layers[0], found[0], found[1])?.slot ?? -1
330
- }
331
-
332
- // ---- play / stop ----
333
- play(L: LayerRec, ref: string | number | undefined, o: PlayOptions): Playback {
334
- const found = this.resolveClip(ref)
335
- if (!found) {
336
- console.warn(`Animator: no clip ${ref === undefined ? "to play" : JSON.stringify(ref)} on ${this.node.name || "the node"} — table: ${this.clipNames()}`)
337
- return new Playback(settled({ name: "", clip: AnimationClip._none(), slot: -1, layer: L, inBlend: false }), this)
338
- }
339
- const [name, clip] = found
340
- if (!this.ensure()) return new Playback(settled({ name, clip, slot: -1, layer: L, inBlend: false }), this)
341
- const s = this.slotFor(L, name, clip)
342
- if (!s) return new Playback(settled({ name, clip, slot: -1, layer: L, inBlend: false }), this)
343
- if (s.inBlend) { console.warn(`Animator: '${name}' is part of this layer's loop — use playLoop() to change what it rests on`); return new Playback(settled(s), this) }
344
- const fadeIn = o.fadeIn ?? o.fade ?? DEFAULT_FADE
345
- const fadeOut = o.fadeOut ?? o.fade ?? DEFAULT_FADE
346
- const phase = this.entryPhase(L, o.phase) // read BEFORE the play: what the layer shows now
347
- this.settleLayer(L) // the layer had one source: whatever it was, this play takes over from it
348
- let resolve = (_n: boolean): void => {}
349
- const done = new Promise<boolean>((r) => { resolve = r })
350
- const rec: PlaybackRec = { slot: s, playing: true, done, resolve }
351
- s.playback = rec
352
- _creator.animatorPlay(this.id, s.slot, false, o.speed ?? 1, fadeIn, fadeOut, o.restart ?? false)
353
- if (o.turn !== undefined) _creator.animatorSlotSetTurn?.(this.id, s.slot, o.turn * Math.PI / 180, true)
354
- if (phase !== undefined) _creator.animatorSeekPhase(this.id, s.slot, phase)
355
- return new Playback(rec, this)
356
- }
357
- /** A one-shot on the layer hasn't handed over yet. */
358
- busy(L: LayerRec): boolean { return L.slots.some((s) => s.playback?.playing === true) }
359
- settle(p: PlaybackRec, natural: boolean): void {
360
- p.playing = false
361
- p.resolve(natural)
362
- }
363
- /** Resolve every pending playback on the layer as cut short. */
364
- private settleLayer(L: LayerRec): void { for (const s of L.slots) if (s.playback?.playing) this.settle(s.playback, false) }
365
- stopSlot(s: SlotRec, fade: number): void {
366
- if (s.playback?.playing) this.settle(s.playback, false)
367
- if (this.id && s.slot >= 0) _creator.animatorStop(this.id, s.layer.index, s.slot, fade)
368
- }
369
- stopLayer(L: LayerRec, fade: number): void {
370
- this.settleLayer(L)
371
- if (L.loop) { L.loop.dead = true; L.loop = undefined }
372
- if (this.id) _creator.animatorStop(this.id, L.index, -1, fade)
373
- }
374
- stopAll(fade: number): void { for (const L of this.layers) this.stopLayer(L, fade) }
375
-
376
- // ---- loop ----
377
- loop(L: LayerRec, def: LoopDef, o: LoopOptions): Loop | undefined {
378
- if (!this.ensure()) return undefined
379
- const fade = o.fade ?? DEFAULT_FADE
380
- const phase = this.entryPhase(L, o.phase) // read BEFORE the members replace the layer's source
381
- if (L.loop) { L.loop.dead = true; for (const m of L.loop.members) m.slot.inBlend = false; L.loop = undefined }
382
- this.settleLayer(L) // a one-shot on the layer is cut short by the new loop
383
- const space: Record<string, BlendPosition> = typeof def === "object" ? def : {}
384
- if (typeof def !== "object") {
385
- const found = this.resolveClip(def)
386
- if (!found) { console.warn(`Animator: no clip ${JSON.stringify(def)} to loop — table: ${this.clipNames()}`); return undefined }
387
- space[found[0]] = 0
388
- }
389
- const rec: LoopRec = { layer: L, members: [], value: 0, view: undefined as unknown as Loop, dead: false }
390
- rec.view = new Loop(rec, this)
391
- const slots: number[] = [], positions: number[] = []
392
- const is2D = Object.values(space).some((p) => typeof p !== "number")
393
- for (const [name, at] of Object.entries(space)) {
394
- const found = this.resolveClip(name)
395
- if (!found) { console.warn(`Animator: loop clip '${name}' is not in the clip table`); continue }
396
- const s = this.slotFor(L, name, found[1])
397
- if (!s) continue
398
- s.inBlend = true
399
- rec.members.push({ name, slot: s })
400
- slots.push(s.slot)
401
- if (is2D) positions.push(typeof at === "number" ? at : at[0], typeof at === "number" ? 0 : at[1])
402
- else positions.push(at as number)
403
- }
404
- L.loop = rec
405
- _creator.animatorSetBlend(this.id, L.index, is2D ? 2 : 1, new Uint16Array(slots), new Float32Array(positions), fade, o.speed ?? 1)
406
- if (phase !== undefined) this.setLoopPhase(rec, phase)
407
- return rec.view
408
- }
409
- /** The phase the layer shows, 0–1 through the gait cycle (-1 = nothing / no cycle). */
410
- layerPhase(L: LayerRec): number { return this.id ? _creator.animatorLayerPhase(this.id, L.index) : -1 }
411
- /** The entry phase of a play / playLoop: a number as given, `'match'` = what the layer shows now
412
- * (undefined when it shows no cycle — then the clip starts at its own beginning). */
413
- private entryPhase(L: LayerRec, want: number | "match" | undefined): number | undefined {
414
- if (want === undefined) return undefined
415
- if (typeof want === "number") return want
416
- if (!this.id) return undefined
417
- const p = this.layerPhase(L)
418
- return p < 0 ? undefined : p
419
- }
420
- /** The loop's phase through its cycle, 0–1 (the members share one clock). */
421
- loopPhase(b: LoopRec): number {
422
- if (b.dead || b.layer.loop !== b) return 0
423
- const p = this.layerPhase(b.layer)
424
- return p < 0 ? 0 : p
425
- }
426
- setLoopPhase(b: LoopRec, phase: number): void {
427
- const m = b.members[0]
428
- if (!m || b.dead || !this.id) return
429
- _creator.animatorSeekPhase(this.id, m.slot.slot, phase) // seeking one member moves the group
430
- }
431
- setLoopValue(b: LoopRec, v: BlendPosition): void {
432
- if (b.dead) { console.warn("Animator: this loop was replaced — use the object the latest playLoop() returned"); return }
433
- b.value = v
434
- if (!this.id) return
435
- const [x, y] = typeof v === "number" ? [ v, 0 ] : [ v[0], v[1] ]
436
- _creator.animatorSetBlendValue(this.id, b.layer.index, x, y)
437
- }
438
- /** What the layer shows this frame: its source — the one-shot, or the loop's members with their
439
- * blend shares — in binding order (stable rows). */
440
- active(L: LayerRec): ActiveClip[] {
441
- const out: ActiveClip[] = []
442
- for (const s of L.slots) {
443
- const weight = this.slotWeight(s)
444
- if (weight <= 0) continue
445
- out.push({ name: s.name, clip: s.clip, weight, time: this.slotTime(s), loop: s.inBlend })
446
- }
447
- return out
448
- }
449
- slotWeight(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorGetSlotWeight(this.id, s.slot) : 0 }
450
- /** The slot's gait phase now, wrapped into 0–1 (-1 without a cycle). */
451
- slotPhase(s: SlotRec): number {
452
- if (!this.id || s.slot < 0) return -1
453
- const p = _creator.animatorSlotCurveAt(this.id, s.slot, 0, -1)
454
- return p < 0 ? -1 : p - Math.floor(p)
455
- }
456
- /** A baked curve of the slot at its current time: 1 travel (m) / 2 yaw (rad) / 3 speed (m/s). */
457
- slotCurveAt(s: SlotRec, which: 1 | 2 | 3): number { return this.id && s.slot >= 0 ? _creator.animatorSlotCurveAt(this.id, s.slot, which, -1) : 0 }
458
- /** The clip's total root yaw, radians (+ = left). */
459
- slotTurn(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorSlotTurn(this.id, s.slot) : 0 }
460
- slotTime(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorGetSlotTime(this.id, s.slot) : 0 }
461
- seek(s: SlotRec, time: number): void { if (this.id && s.slot >= 0) _creator.animatorSeek(this.id, s.slot, time) }
462
-
463
- /** Scrub: set a bound clip's playhead directly, seconds — an inspector's tool. */
464
- seekTime(ref: string | number, time: number): void {
465
- const found = this.resolveClip(ref)
466
- if (!found || !this.ensure()) return
467
- const s = this.slotFor(this.layers[0], found[0], found[1])
468
- if (s) this.seek(s, time)
469
- }
470
- /** Re-aim a playing clip's TURN WARP (see `PlayOptions.turn`): the clip's baked heading is scaled so that the
471
- * whole clip turns `deg` — set mid-play, the scale applies to what is still to come, so a controller that
472
- * wants a different heading while a turn plays asks for (the clip's total × what it still needs / what
473
- * the clip still turns). Undefined `deg` switches the warp off. */
474
- setTurnOf(ref: string | number, deg: number | undefined): void {
475
- const found = this.resolveClip(ref)
476
- if (!found || !this.ensure()) return
477
- const s = this.slotFor(this.layers[0], found[0], found[1])
478
- if (s) _creator.animatorSlotSetTurn?.(this.id, s.slot, (deg ?? 0) * Math.PI / 180, deg !== undefined)
479
- }
480
- /** A bound clip's current playhead, seconds (-1 = not bound). */
481
- timeOf(ref: string | number): number {
482
- const found = this.resolveClip(ref)
483
- if (!found || !this.ensure()) return -1
484
- const s = this.slotFor(this.layers[0], found[0], found[1])
485
- return s ? this.slotTime(s) : -1
486
- }
487
-
488
- // ---- contacts, phase, root curves ----
489
- /** What the engine measured on the clip once it was bound to this skeleton — binds it if needed. */
490
- curves(ref: string | number): ClipCurves | undefined {
491
- const found = this.resolveClip(ref)
492
- if (!found || !this.ensure()) return undefined
493
- const s = this.slotFor(this.layers[0], found[0], found[1])
494
- if (!s) return undefined
495
- const id = this.id, slot = s.slot
496
- _creator.animatorSlotCurveInfo(id, slot, curveInfo)
497
- const spans = _creator.animatorSlotContacts(id, slot, new Float32Array(0))
498
- const buf = new Float32Array(spans * 8) // stride 8: side, from, to, at xyz, stillFrom, stillTo
499
- if (spans > 0) _creator.animatorSlotContacts(id, slot, buf)
500
- const contacts: ClipCurves["contacts"] = [], hands: ClipCurves["hands"] = []
501
- for (let i = 0; i < spans; i++) {
502
- const still = buf[i * 8 + 6]! >= 0 ? { from: buf[i * 8 + 6]!, to: buf[i * 8 + 7]! } : undefined
503
- // side 0 / 1 = the feet, 2 / 3 = the hands
504
- const code = Math.round(buf[i * 8]!)
505
- ;(code >= 2 ? hands : contacts).push({ side: code % 2 === 0 ? "left" : "right", from: buf[i * 8 + 1]!, to: buf[i * 8 + 2]!,
506
- at: [ buf[i * 8 + 3]!, buf[i * 8 + 4]!, buf[i * 8 + 5]! ], still })
507
- }
508
- const nTreads = _creator.animatorSlotTreads(id, slot, new Float32Array(0))
509
- let treads: ClipCurves["treads"]
510
- if (nTreads > 0) {
511
- const tBuf = new Float32Array(nTreads + 1) // [0] = riser, then the levels
512
- _creator.animatorSlotTreads(id, slot, tBuf)
513
- treads = { riser: tBuf[0]!, levels: Array.from(tBuf.subarray(1)) }
514
- }
515
- const featuresAt = (time: number): Float32Array | undefined => {
516
- const row = new Float32Array(37)
517
- return _creator.animatorSlotFeatures(id, slot, time, row) ? row : undefined
518
- }
519
- const first = featuresAt(0)
520
- return {
521
- travel: curveInfo[1], speed: curveInfo[2], turn: _creator.animatorSlotTurn(id, slot),
522
- inPlace: curveInfo[3] > 0.5, hasPhase: _creator.animatorSlotCurveAt(id, slot, 0, 0) >= 0, contacts, hands, treads,
523
- featuresAt, cyclic: !!first && first[36]! > 0.5,
524
- fitTo: (target: string | number, time: number, options: { align?: "phase" | "time" } = {}) => {
525
- const c = this.fitCurve(s, target, options.align === "time" ? 1 : 0)
526
- if (!c) return undefined
527
- const f = Math.max(0, Math.min(c.values.length - 1, time / c.dt))
528
- const k = Math.floor(f), u = f - k
529
- return k + 1 >= c.values.length ? c.values[c.values.length - 1] : c.values[k] + (c.values[k + 1] - c.values[k]) * u
530
- },
531
- exitTo: (target: string | number, options: { within?: number, atContact?: boolean, align?: "phase" | "time" } = {}) =>
532
- this.fitExit(s, target, options.within ?? 0.05, options.atContact ?? true, options.align === "time" ? 1 : 0),
533
- phaseAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 0, time),
534
- travelAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 1, time),
535
- turnAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 2, time),
536
- speedAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 3, time),
537
- directionAt: (time: number) => [ _creator.animatorSlotCurveAt(id, slot, 4, time), _creator.animatorSlotCurveAt(id, slot, 5, time) ] as [number, number],
538
- timeAtTurn: (yaw: number) => _creator.animatorSlotTimeAtTurn(id, slot, yaw),
539
- kneePoleAt: (side: "left" | "right", time: number) => {
540
- const ok = _creator.animatorSlotKneePole(id, slot, side === "left" ? 0 : 1, time, kneeOut)
541
- if (!ok) return undefined
542
- return { pole: [ kneeOut[0]!, kneeOut[1]!, kneeOut[2]! ] as [number, number, number], normal: [ kneeOut[3]!, kneeOut[4]!, kneeOut[5]! ] as [number, number, number] }
543
- },
544
- physicsAt: (time: number) => {
545
- const ok = _creator.animatorSlotPhysics(id, slot, time, physOut)
546
- if (!ok) return undefined
547
- return {
548
- com: [ physOut[0]!, physOut[1]!, physOut[2]! ] as [number, number, number],
549
- velocity: [ physOut[3]!, physOut[4]!, physOut[5]! ] as [number, number, number],
550
- angular: [ physOut[6]!, physOut[7]!, physOut[8]! ] as [number, number, number],
551
- support: [ physOut[9]!, physOut[10]! ] as [number, number],
552
- }
553
- },
554
- }
555
- }
556
- /** The skeleton's calibrated knee hinge axis for a side, with its confidence report — measured once
557
- * over every bound clip's knee rotation track. Undefined = no leg chain / no knee motion bound. */
558
- kneeAxis(side: "left" | "right"): KneeAxisReport | undefined {
559
- if (!this.ensure()) return undefined
560
- const ok = _creator.animatorKneeAxis(this.id, side === "left" ? 0 : 1, kneeOut)
561
- if (!ok) return undefined
562
- const r: KneeAxisReport = { axis: [ kneeOut[0]!, kneeOut[1]!, kneeOut[2]! ], spreadMean: kneeOut[3]!, spreadMax: kneeOut[4]!, samples: kneeOut[5]! }
563
- if (kneeOut[6]! > -1e8) r.plant = { ankleY: kneeOut[6]!, toeY: kneeOut[7]! }
564
- return r
565
- }
566
- /** STEP WARP v2 (the warp rewrite's knobs): `stride` scales each foot's travel-direction offset
567
- * from its hip — the step shortens or lengthens, uniformly through stance and swing; `lift` is
568
- * METRES added to each foot's height, gated to the swing by the contact marks (0 = neutral,
569
- * negative = a shuffle — no probing), and half of what it adds raises the pelvis so the body
570
- * steps higher with the foot; `pitch` (degrees, + = toes up) rotates each foot about its
571
- * own lateral axis — a slope's foot rotation; `slope` (degrees, + = ascending) is the INVISIBLE
572
- * STAIRCASE: foot heights follow the incline (leading foot higher) and the feet auto-pitch by
573
- * the same angle — pair it with raising the character by tan(slope) × the stride-scaled clip
574
- * travel (`curves(clip).travelAt`), which holds every planted foot's world height constant on
575
- * its own tread. Solved in the calibrated knee hinge plane with a SOFT reach (a leg at its limit
576
- * keeps a residual knee bend instead of popping against the clamp), and whenever a leg would
577
- * overreach — a descent, a long stride — the pelvis lowers by exactly the excess (weighted by
578
- * the contact marks, spring-followed; zero when nothing overreaches, so an ascent or a shorter
579
- * stride is untouched). Omit / null = stage off. */
580
- setStepWarp(options?: { stride?: number, lift?: number, pitch?: number, slope?: number } | null): void {
581
- if (!this.ensure()) return
582
- _creator.animatorSetStepWarp(this.id, !!options, options?.stride ?? 1, options?.lift ?? 0, options?.pitch ?? 0, options?.slope ?? 0)
583
- }
584
- /** A slot for a clip on the base layer, bound on demand — what the pair-wise measures address. */
585
- private slotOf(ref: string | number): SlotRec | undefined {
586
- const found = this.resolveClip(ref)
587
- if (!found || !this.ensure()) return undefined
588
- return this.slotFor(this.layers[0], found[0], found[1])
589
- }
590
- /** The baked distance curve between two clips, cached per pair (the engine caches the bake itself;
591
- * this keeps the samples on the JS side so a controller can read them per frame without copying). */
592
- private readonly _fit = new Map<string, { dt: number, values: Float32Array }>()
593
- private fitCurve(src: SlotRec, target: string | number, align: 0 | 1): { dt: number, values: Float32Array } | undefined {
594
- const t = this.slotOf(target)
595
- if (!t) return undefined
596
- const key = `${src.slot}>${t.slot}:${align}`
597
- const have = this._fit.get(key)
598
- if (have) return have
599
- const n = _creator.animatorSlotFit(this.id, src.slot, t.slot, align, new Float32Array(0))
600
- if (n === 0) return undefined
601
- const values = new Float32Array(n)
602
- _creator.animatorSlotFit(this.id, src.slot, t.slot, align, values)
603
- _creator.animatorSlotCurveInfo(this.id, src.slot, curveInfo)
604
- const curve = { dt: curveInfo[0] || 1 / 60, values }
605
- this._fit.set(key, curve)
606
- return curve
607
- }
608
- /** The earliest moment handing `src` over to `target` costs no more than `within` metres. */
609
- private fitExit(src: SlotRec, target: string | number, within: number, atContact: boolean, align: 0 | 1): number {
610
- const t = this.slotOf(target)
611
- return t ? _creator.animatorSlotExit(this.id, src.slot, t.slot, within, atContact, align) : -1
612
- }
613
-
614
- /** The contact bones, '
615
- '-joined per side ("" = classify by name). */
616
- private _feet?: [string, string]
617
- setFeet(left: string, right: string): void {
618
- this._feet = [ left, right ]
619
- if (this.id) _creator.animatorSetFeet(this.id, left, right)
620
- }
621
- /** Stride / orientation warping, as the engine's fixed-order parameter array. */
622
- private _warp?: Float32Array
623
- setWarp(params: Float32Array): void {
624
- this._warp = params
625
- if (this.id) _creator.animatorSetWarpParams(this.id, params)
626
- }
627
- /** The feet stage (lock / ground IK), as the engine's fixed-order parameter array. */
628
- private _feetParams?: Float32Array
629
- setFeetParams(params: Float32Array): void {
630
- this._feetParams = params
631
- if (this.id && _creator.animatorSetFeetParams) _creator.animatorSetFeetParams(this.id, params)
632
- }
633
- /** One foot's lock state after this frame's evaluation into `out` (8 floats); false = no such foot
634
- * or no feet stage on this host. */
635
- footState(side: 0 | 1, out: Float32Array): boolean {
636
- return this.id !== 0 && _creator.animatorFootState !== undefined && _creator.animatorFootState(this.id, side, out)
637
- }
638
- private readonly _steps = new Set<StepHandler>()
639
- onStepHandler(cb: StepHandler): void { this._steps.add(cb) }
640
- offStepHandler(cb: StepHandler): void { this._steps.delete(cb) }
641
- private onStep(side: number, x: number, y: number, z: number): void {
642
- if (this._steps.size === 0) return
643
- const at = new Vec3(x, y, z)
644
- for (const cb of [ ...this._steps ]) cb(side === 0 ? "left" : "right", at)
645
- }
646
-
647
- // ---- props ----
648
- setSpeed(v: number): void { this.speed = v; if (this.id) _creator.animatorSetGlobal(this.id, v, false) }
649
- setRootMotion(on: boolean, rotation = false): void { if (this.ensure()) _creator.animatorSetRootMotion(this.id, on ? "*" : "", (on ? 2 : 0) | (on && rotation ? 4 : 0)) }
650
-
651
- // ---- events ----
652
- on(event: string, cb: ClipEventHandler): void { (this._listeners.get(event) ?? this._listeners.set(event, new Set()).get(event)!).add(cb) }
653
- off(event: string, cb: ClipEventHandler): void { this._listeners.get(event)?.delete(cb) }
654
- /** Native slot event: 0 completed / 1 loop / 2 settled (no longer a source) / 3 hand-over / 4+i clip event i. */
655
- private onEvent(slotIndex: number, type: number): void {
656
- let s: SlotRec | undefined
657
- for (const L of this.layers) { s = L.slots.find((x) => x.slot === slotIndex); if (s) break }
658
- if (!s) return
659
- if (type >= 4) {
660
- const name = s.clip._events[type - 4]?.name
661
- const cbs = name ? this._listeners.get(name) : undefined
662
- if (cbs) for (const cb of [ ...cbs ]) cb(s.name, s.layer.view)
663
- return
664
- }
665
- const p = s.playback
666
- if (!p?.playing) return
667
- if (type === 3) this.settle(p, true) // hand-over: the clip is over; what the app starts now takes over from it
668
- else if (type === 2) this.settle(p, false) // dropped by the engine without the SDK asking (backstop)
669
- }
670
- }
1
+ // The engine-facing core behind Animator / Layer / Loop / Playback. NOT exported from the SDK: the
2
+ // public classes are thin views over the records here. The engine (creator-anim) runs the whole
3
+ // per-frame loop — sources, INERTIALIZED transitions, blend spaces, root motion — and this file only
4
+ // binds clips, issues play / stop / blend calls and routes the engine's slot events back to playbacks.
5
+ //
6
+ // A layer shows exactly ONE source at a time: its one-shot, its loop, or nothing. A transition is not
7
+ // a crossfade — the engine records the offset between the pose it SHOWED and the pose the new source
8
+ // shows, and decays that offset away over `fade`. So the SDK keeps no fade bookkeeping and no slot
9
+ // pool: a slot is simply a clip bound to a layer, restartable at will (docs/animation-v2-plan.md).
10
+
11
+ import { AnimationClip } from "./AnimationClip"
12
+ import { Loop } from "./Loop"
13
+ import { Layer } from "./Layer"
14
+ import { Playback } from "./Playback"
15
+ import { Vec3 } from "../../math/vec"
16
+ import type { Node } from "../Node"
17
+
18
+ /** A position on a blend axis (1D) or plane (2D). */
19
+ export type BlendPosition = number | readonly [number, number]
20
+
21
+ /** The transition time (seconds) a play / playLoop without `fade` uses. */
22
+ export const DEFAULT_FADE = 0.2
23
+
24
+ export type PlayOptions = {
25
+ /** Transition seconds — how long the difference to the pose the layer SHOWED takes to decay
26
+ * (default 0.2; `0` = cut). Also the transition back at the end unless `fadeOut` overrides it. */
27
+ fade?: number
28
+ /** Transition seconds for the way in only (overrides `fade`). */
29
+ fadeIn?: number
30
+ /** One-shots: the transition BACK at the end (overrides `fade`). It starts this long before the
31
+ * clip ends — while it still plays — so the hand-over lands on the last pose, not after it. On a
32
+ * layer with no loop to return to, giving `fadeOut` releases the layer at the end instead of
33
+ * holding the last frame. */
34
+ fadeOut?: number
35
+ /** Playback rate for this clip (default 1). */
36
+ speed?: number
37
+ /** Rewind even if the clip is already playing (default: rewind only if it isn't). */
38
+ restart?: boolean
39
+ /** Enter the clip at a point in the GAIT CYCLE instead of at its start: `'match'` = the phase the
40
+ * layer shows right now (the walk's left foot is down → the turn clip starts where its left foot
41
+ * is down too, so nothing slides), or a number 0–1. Clips without a gait cycle ignore it. */
42
+ phase?: number | "match"
43
+ /** TURN WARP (degrees, + = left): the node turns this much in total over the clip instead of what the
44
+ * clip's baked heading says — its turn curve scaled, so the difference grows where the clip turns and
45
+ * pivots about the planted foot. A 45° start played with `turn: 60` turns 60; played with `turn: 45`
46
+ * it lands on 45 exactly whatever the clip's own run-off. Needs `rootMotion` + `rootRotation` and a
47
+ * clip with a baked heading (`lecodes assets marks` / `retarget`); a clip that turns under 10° is
48
+ * left alone. Ignored by hosts without it. */
49
+ turn?: number
50
+ }
51
+
52
+ export type StopOptions = { fade?: number }
53
+
54
+ export type LoopOptions = {
55
+ /** Transition seconds from whatever the layer shows — its previous loop, or a one-shot still
56
+ * playing (default 0.2; `0` = cut). */
57
+ fade?: number
58
+ /** Playback rate of the loop's clips (default 1). */
59
+ speed?: number
60
+ /** Where in the CYCLE to start, 0–1, or `'match'` = where the layer is now (what a gait loop taking
61
+ * over from a start / turn clip wants). The members share one clock, so this is every member's phase. */
62
+ phase?: number | "match"
63
+ }
64
+ /** A blend member with its CYCLE given explicitly: `at` = its position, `offset` = its gait phase at t = 0
65
+ * (0 = a left-foot-down, 0.5 = a right-foot-down, 0–1), `cycles` = how many gait cycles the clip holds
66
+ * (a two-stride loop: 2). Both are measured offline from the clip's foot marks. Members given as a
67
+ * bare position run in normalized time and have no cycle (nothing phase-matches to them). */
68
+ export type BlendMember = { at: BlendPosition, offset?: number, cycles?: number }
69
+ /** What a layer loops: one clip (name / index), or a blend space `{ name: position | { at, offset, cycles } }`. */
70
+ export type LoopDef = string | number | Record<string, BlendPosition | BlendMember>
71
+
72
+ /** One clip the layer shows this frame — `layer.active` / `anim.active` (a debug overlay's list). */
73
+ export type ActiveClip = {
74
+ name: string
75
+ clip: AnimationClip
76
+ /** contribution to the pose, 0–1: the shown one-shot 1, a loop member its blend share */
77
+ weight: number
78
+ /** clock (s) */
79
+ time: number
80
+ /** a member of the layer's loop (else the layer's one-shot) */
81
+ loop: boolean
82
+ }
83
+
84
+ export type LayerOptions = {
85
+ /** Bone name(s): the layer drives only these subtrees (`'Spine1'` = upper body). Default: the whole rig. */
86
+ mask?: string | string[]
87
+ /** Blend each clip's DELTA from its own first frame on top of the layers below (lean, breathe, recoil)
88
+ * instead of overriding them. */
89
+ additive?: boolean
90
+ /** Layer contribution 0–1 (default 1). */
91
+ weight?: number
92
+ }
93
+
94
+ export type ClipEventHandler = (clip: string, layer: Layer) => void
95
+ /** A foot planted: which one, and where in the world it landed. */
96
+ export type StepHandler = (side: "left" | "right", position: Vec3) => void
97
+
98
+ /** A loop's GAIT CYCLE as two numbers — what a blend member's `{ offset, cycles }` wants: φ(t) = offset +
99
+ * cycles · t / duration, 0 = a left-foot-down. `residual` = the worst foot-down's distance from that line
100
+ * (cycles; a clean loop sits under 0.03), `steps` = the foot-downs it was fitted through. */
101
+ export type ClipCycle = { offset: number, cycles: number, residual: number, steps: number }
102
+ /** A loop's cycle found by POSE against a reference loop (`clipInfo(clip).alignTo(ref)`): the `{ offset,
103
+ * cycles }` under which it shows the reference's pose at the reference's phase. `score` = the fit at that
104
+ * alignment (metres, the joint distance + velocity measure), `margin` = how much worse the runner-up
105
+ * alignment ≥ 0.2 cycle away is — near 0 means ambiguous (a mirror pair, an in-place idle). */
106
+ export type ClipAlign = { offset: number, cycles: number, score: number, margin: number }
107
+
108
+ /** What the engine measured on a clip once it was bound to this skeleton (`anim.clipInfo('Walk')`).
109
+ * The root curves are of the clip's ROOT bone — its travel is what root motion would move. */
110
+ export type ClipInfo = {
111
+ /** total horizontal root travel, metres */
112
+ travel: number
113
+ /** mean root speed over the clip, m/s */
114
+ speed: number
115
+ /** total root yaw, radians (+ = left) */
116
+ turn: number
117
+ /** the root barely moves — an in-place clip */
118
+ inPlace: boolean
119
+ /** The gait cycle fitted through the foot contacts (a foot already down at t = 0 is not a step) —
120
+ * the numbers a blend member takes as `{ offset, cycles }`. Undefined: no steps. */
121
+ cycle(): ClipCycle | undefined
122
+ /** This loop's cycle found by POSE against `reference` (its cycle given, else `reference`'s `cycle()`,
123
+ * else offset 0 / one cycle — the answer is then relative to the reference's own time). Reads no marks:
124
+ * a markless loop (swimming, breathing, an upper-body sway) gets a cycle, and a marked one gets a
125
+ * second opinion — a mirrored anchor shows as a 0.5 disagreement with `cycle()`. Undefined: nothing to compare. */
126
+ alignTo(reference: string | number, cycle?: { offset: number, cycles: number }): ClipAlign | undefined
127
+ /** the clip has a gait cycle (feet planting in turn) — `phaseAt` is meaningful */
128
+ hasPhase: boolean
129
+ /** When each foot TOUCHES the ground, seconds — the phase anchor. `still` is the window inside it
130
+ * where the foot is genuinely STATIONARY (a touching foot still rolls for its first beats, and a
131
+ * stop take's final plant touches long before the body has braked); absent = never still.
132
+ * `at` = where the foot is at the touch's start ([x, y, z], model space). */
133
+ contacts: { side: "left" | "right", from: number, to: number, at: [number, number, number], still?: { from: number, to: number } }[]
134
+ /** the HANDS on the ground (a cartwheel, a roll, a vault) — from the clip's marks in the GLB; empty when none */
135
+ hands: { side: "left" | "right", from: number, to: number, at: [number, number, number], still?: { from: number, to: number } }[]
136
+ /** A CLIMBING clip's tread levels (a stair or slope take): the model-space heights its feet plant
137
+ * at, sorted ascending, with the riser (median level spacing — the step height the clip was
138
+ * authored for). Absent on a flat clip. */
139
+ treads?: { riser: number, levels: number[] }
140
+ /** the gait phase at `time`: 0 at a left-foot-down, 0.5 at a right-foot-down; -1 without a cycle */
141
+ phaseAt(time: number): number
142
+ /** cumulative root travel (m) at `time` */
143
+ travelAt(time: number): number
144
+ /** cumulative root yaw (rad, + = left) at `time` */
145
+ turnAt(time: number): number
146
+ /** the root's own speed at `time`, m/s — how fast the clip is moving the body right there */
147
+ speedAt(time: number): number
148
+ /** the unit travel direction at `time` ([x, z], model space, held through stills) — integrate
149
+ * direction × d(travel) to reconstruct the root's 2D path (a treadmill display, a turn's arc) */
150
+ directionAt(time: number): [number, number]
151
+ /** the first time the clip has turned `yaw` radians (+ = left): where to enter a turn a body has
152
+ * already begun, so the clip continues the move instead of restarting it */
153
+ timeAtTurn(yaw: number): number
154
+ /** How far this clip's pose is from `target`'s, in metres — the joint distance plus the velocity
155
+ * difference over a tenth of a second, the planted foot weighted most. That difference is exactly
156
+ * what a hand-over hands the transition to decay away, so it says what switching to `target` would
157
+ * cost at `time`. The two are lined up by cycle PHASE (a clip that continues the walk) or by TIME
158
+ * (`align: 'time'` — two clips that both begin from standing share no cycle). */
159
+ fitTo(target: string | number, time: number, options?: { align?: "phase" | "time" }): number | undefined
160
+ /** The earliest moment (seconds) at which handing over to `target` costs no more than `within`
161
+ * metres — a start's last steps ARE the walk, so this is where it stops being worth playing.
162
+ * `atContact` (default true) snaps to the next foot-down: a switch under a planted foot is the one
163
+ * the eye forgives. -1 = the clip never gets that close. */
164
+ exitTo(target: string | number, options?: { within?: number, atContact?: boolean, align?: "phase" | "time" }): number
165
+ /** The knee's bend plane at `time`, predicted from the skeleton's calibrated hinge axis carried by
166
+ * this clip's own thigh rotation — continuous even where the leg is straight (where a plane
167
+ * derived from positions is pure noise). `pole` = unit direction from the hip→ankle line toward
168
+ * the knee (a two-bone solver's bend direction), `normal` = the plane's normal; both in the model
169
+ * pose's frame. Undefined = no leg chain, or no knee motion bound to calibrate from. */
170
+ kneePoleAt(side: "left" | "right", time: number): { pole: [number, number, number], normal: [number, number, number] } | undefined
171
+ /** The clip's baked physics at `time` (unit body mass, model space): `com` = the body's center of
172
+ * mass (biomechanical segment fractions on the classified bones), `velocity` = its velocity
173
+ * (= linear momentum per kg), `angular` = the angular momentum about the COM (m²/s per kg,
174
+ * low-passed ~0.5 s — the REGULATED component, e.g. a turn's sustained yaw; the per-stride
175
+ * pitch/roll exchange between the limbs and the trunk is filtered out),
176
+ * `support` = per-foot load [left, right] — contact-gated, split by where the COM stands between
177
+ * the feet, scaled by the vertical force proxy (above 1 on a landing impact, 0 in flight).
178
+ * A model, not a measurement — meant for ranking transitions and display, both sides of any
179
+ * comparison sharing the same model. Undefined = nothing classified on this skeleton. */
180
+ physicsAt(time: number): { com: [number, number, number], velocity: [number, number, number], angular: [number, number, number], support: [number, number] } | undefined
181
+ /** The clip's MATCHING FEATURE ROW at `time` — a fresh Float32Array(37) in the clip's heading
182
+ * frame at that time (x lateral, + left; y up; z forward): 0–5 feet positions relative to the
183
+ * pelvis' ground point, 6–11 feet velocities, 12–14 pelvis velocity, 15 pelvis height, 16–18
184
+ * COM velocity, 19–20 support L/R, 21–22 contact phase L/R, 23 yaw angular momentum, 24–31 the
185
+ * clip's own path 0.3/0.6/1.0/1.5 s ahead as (lateral, forward) pairs, 32–35 facing change at
186
+ * those horizons (rad, + = left), 36 cyclic flag. What a trajectory-and-state matcher compares.
187
+ * Undefined = no leg chain. */
188
+ featuresAt(time: number): Float32Array | undefined
189
+ /** The take is a LOOP by measurement: its seam is continuous (feet back where they started
190
+ * relative to the root, no turn). Starts, stops and turns are not. */
191
+ cyclic: boolean
192
+ }
193
+
194
+ /** The calibrated knee hinge-axis report (`anim.feet.kneeAxis('left')`): the axis in the thigh's local
195
+ * frame, and the calibration's own confidence — the angular spread of its measurements (radians;
196
+ * a real knee comes in at a few degrees) over `samples` consecutive-frame deltas of the pack.
197
+ * `plant` = the calibrated PLANT HEIGHTS (model space): where the ankle / toe actually sit under
198
+ * full weight, the median of the pack's still-window lows — absent when nothing plants. */
199
+ export type KneeAxisReport = { axis: [number, number, number], spreadMean: number, spreadMax: number, samples: number, plant?: { ankleY: number, toeY: number } }
200
+
201
+ // ---- records -------------------------------------------------------------------------------------
202
+
203
+ /** A clip bound to a layer — one per (clip, layer), reused by every play. `playback` = the most recent
204
+ * one-shot on it (`playing` while it still owns the layer; kept after that so its handle can still be
205
+ * read — a restart re-enters the same slot with a new record). */
206
+ export type SlotRec = { name: string, clip: AnimationClip, slot: number, layer: LayerRec, inBlend: boolean, playback?: PlaybackRec }
207
+ export type PlaybackRec = { slot: SlotRec, playing: boolean, done: Promise<boolean>, resolve: (natural: boolean) => void }
208
+ export type LayerRec = { index: number, view: Layer, slots: SlotRec[], loop?: LoopRec, weight: number, mask: string, additive: boolean }
209
+ export type LoopRec = { layer: LayerRec, members: { name: string, slot: SlotRec }[], value: BlendPosition, view: Loop, dead: boolean }
210
+
211
+ const handlers = new Map<number, (slot: number, type: number) => void>()
212
+ const stepHandlers = new Map<number, (side: number, x: number, y: number, z: number) => void>()
213
+ let eventsInstalled = false
214
+ const ensureEvents = (): void => {
215
+ if (eventsInstalled) return
216
+ eventsInstalled = true
217
+ _creator.setOnAnimatorEvent((animatorId, slot, type) => handlers.get(animatorId)?.(slot, type))
218
+ _creator.setOnAnimatorStep((animatorId, side, x, y, z) => stepHandlers.get(animatorId)?.(side, x, y, z))
219
+ }
220
+
221
+ const settled = (slot: SlotRec): PlaybackRec => {
222
+ let resolve = (_n: boolean): void => {}
223
+ const done = new Promise<boolean>((r) => { resolve = r })
224
+ resolve(false)
225
+ return { slot, playing: false, done, resolve }
226
+ }
227
+
228
+ const curveInfo = new Float32Array(4)
229
+ const kneeOut = new Float32Array(8)
230
+ const physOut = new Float32Array(11)
231
+
232
+ export class Core {
233
+ id = 0
234
+ /** Added clips by name (`addClip`) — the lookup index; an added name overrides an embedded one. */
235
+ readonly table = new Map<string, AnimationClip>()
236
+ readonly layers: LayerRec[] = []
237
+ speed = 1
238
+ private _embedded?: AnimationClip[]
239
+ private readonly _added: { name: string, clip: AnimationClip }[] = []
240
+ private _list?: AnimationClip[]
241
+ private readonly _listeners = new Map<string, Set<ClipEventHandler>>()
242
+
243
+ readonly node: Node
244
+ constructor(node: Node) { this.node = node; this.layers.push(this.newLayer()) }
245
+
246
+ // ---- clips ----
247
+ embedded(): AnimationClip[] {
248
+ if (!this._embedded) {
249
+ this._embedded = AnimationClip._ofSet(_creator.getGlbClipSet(this.node.id))
250
+ const seen = new Set<string>()
251
+ for (const c of this._embedded) {
252
+ if (seen.has(c.name)) console.warn(`Animator: ${this.node.name || "the node"} has several clips named '${c.name}' — by name you get the first; reach the others by index (anim.clip(n))`)
253
+ seen.add(c.name)
254
+ }
255
+ }
256
+ return this._embedded
257
+ }
258
+ /** The ordered clip list: the GLB's clips in file order (an added clip of the same name takes the
259
+ * embedded one's place), then the remaining added clips in insertion order. Cached until addClip. */
260
+ clips(): AnimationClip[] {
261
+ if (this._list) return this._list
262
+ const out: AnimationClip[] = []
263
+ const placed = new Set<string>()
264
+ for (const c of this.embedded()) {
265
+ const override = this.table.get(c.name)
266
+ if (override && !placed.has(c.name)) { out.push(override); placed.add(c.name) }
267
+ else out.push(c)
268
+ }
269
+ for (const a of this._added) if (!placed.has(a.name)) { out.push(a.clip); placed.add(a.name) }
270
+ return this._list = out
271
+ }
272
+ addClip(name: string, clip: AnimationClip): void {
273
+ const i = this._added.findIndex((a) => a.name === name)
274
+ if (i >= 0) this._added[i] = { name, clip }
275
+ else this._added.push({ name, clip })
276
+ this.table.set(name, clip)
277
+ this._list = undefined
278
+ }
279
+ /** A clip by name (added first, then embedded), or by index into `clips()` (none = the first). */
280
+ resolveClip(ref: string | number | undefined): [string, AnimationClip] | undefined {
281
+ if (ref === undefined || typeof ref === "number") { const c = this.clips()[ref ?? 0]; return c ? [ c.name, c ] : undefined }
282
+ const c = this.table.get(ref) ?? this.embedded().find((e) => e.name === ref)
283
+ return c ? [ ref, c ] : undefined
284
+ }
285
+ private clipNames(): string { return this.clips().map((c) => c.name).join(", ") || "(empty)" }
286
+
287
+ // ---- lifecycle ----
288
+ /** Create the native animator on demand. False = nothing to animate (warned). */
289
+ ensure(): boolean {
290
+ if (this.id) return true
291
+ if (this.embedded().length === 0 && this.table.size === 0) { console.warn(`Animator: no clips on ${this.node.name || "the node"} — nothing to play`); return false }
292
+ ensureEvents()
293
+ this.id = _creator.animatorCreate(this.node.id)
294
+ if (!this.id) { console.warn("Animator: node has no transform hierarchy"); return false }
295
+ handlers.set(this.id, (slot, type) => this.onEvent(slot, type))
296
+ stepHandlers.set(this.id, (side, x, y, z) => this.onStep(side, x, y, z))
297
+ if (this._feet) _creator.animatorSetFeet(this.id, this._feet[0], this._feet[1])
298
+ if (this._warp) _creator.animatorSetWarpParams(this.id, this._warp)
299
+ if (this._feetParams && _creator.animatorSetFeetParams) _creator.animatorSetFeetParams(this.id, this._feetParams)
300
+ if (this.speed !== 1) _creator.animatorSetGlobal(this.id, this.speed, false)
301
+ for (const L of this.layers) if (L.index > 0) this.pushLayer(L)
302
+ return true
303
+ }
304
+ destroy(): void {
305
+ for (const L of this.layers) this.settleLayer(L)
306
+ if (this.id) { _creator.animatorDestroy(this.id); handlers.delete(this.id); stepHandlers.delete(this.id); this.id = 0 }
307
+ for (const L of this.layers) { L.slots = []; if (L.loop) { L.loop.dead = true; L.loop = undefined } }
308
+ }
309
+
310
+ // ---- layers ----
311
+ newLayer(options?: LayerOptions): LayerRec {
312
+ const rec: LayerRec = { index: this.layers.length, view: undefined as unknown as Layer, slots: [], weight: options?.weight ?? 1, mask: "", additive: options?.additive ?? false }
313
+ if (options?.mask !== undefined) rec.mask = Array.isArray(options.mask) ? options.mask.join("\n") : options.mask
314
+ rec.view = new Layer(rec, this)
315
+ return rec
316
+ }
317
+ addLayer(options: LayerOptions): Layer {
318
+ if (this.layers.length >= 255) { console.warn("Animator: too many layers"); return this.layers[this.layers.length - 1].view }
319
+ const rec = this.newLayer(options)
320
+ this.layers.push(rec)
321
+ if (this.id) this.pushLayer(rec)
322
+ return rec.view
323
+ }
324
+ /** Arm the layer's next transition with a wind-up: see `Layer.anticipate`. */
325
+ anticipate(L: LayerRec, amount: number): void { if (this.id) _creator.animatorSetLayerAnticipation?.(this.id, L.index, amount) }
326
+
327
+ pushLayer(L: LayerRec): void {
328
+ if (!this.id) return
329
+ for (const m of L.mask.split("\n")) if (m && !this.node.bone(m)) console.warn(`Animator: layer mask bone '${m}' not found under ${this.node.name || "the node"}`)
330
+ _creator.animatorSetLayer(this.id, L.index, L.weight, L.additive, L.mask)
331
+ }
332
+
333
+ // ---- slots ----
334
+ /** The layer's slot for the clip — bound on first use, reused after that. */
335
+ slotFor(L: LayerRec, name: string, clip: AnimationClip): SlotRec | undefined {
336
+ const have = L.slots.find((s) => s.name === name && s.clip === clip)
337
+ if (have) return have
338
+ const slot = _creator.animatorBind(this.id, clip._set, clip._index, L.index)
339
+ if (slot < 0) { console.warn(`Animator: could not bind clip '${name}'`); return undefined }
340
+ const bound = _creator.animatorBoundTracks(this.id, slot)
341
+ if (clip.trackCount > 0 && bound === 0) console.warn(`Animator: '${name}' — no track matched a node name under ${this.node.name || "the node"} (different rig?)`)
342
+ else if (bound < clip.trackCount) console.warn(`Animator: '${name}' — ${clip.trackCount - bound}/${clip.trackCount} tracks unbound (bone names not found)`)
343
+ const rec: SlotRec = { name, clip, slot, layer: L, inBlend: false }
344
+ L.slots.push(rec)
345
+ return rec
346
+ }
347
+
348
+ /** The base layer's native slot for a clip, bound on first ask (-1 = no such clip / no animator). */
349
+ slotIndex(ref: string | number): number {
350
+ const found = this.resolveClip(ref)
351
+ if (!found || !this.ensure()) return -1
352
+ return this.slotFor(this.layers[0], found[0], found[1])?.slot ?? -1
353
+ }
354
+
355
+ // ---- play / stop ----
356
+ play(L: LayerRec, ref: string | number | undefined, o: PlayOptions): Playback {
357
+ const found = this.resolveClip(ref)
358
+ if (!found) {
359
+ console.warn(`Animator: no clip ${ref === undefined ? "to play" : JSON.stringify(ref)} on ${this.node.name || "the node"} — table: ${this.clipNames()}`)
360
+ return new Playback(settled({ name: "", clip: AnimationClip._none(), slot: -1, layer: L, inBlend: false }), this)
361
+ }
362
+ const [name, clip] = found
363
+ if (!this.ensure()) return new Playback(settled({ name, clip, slot: -1, layer: L, inBlend: false }), this)
364
+ const s = this.slotFor(L, name, clip)
365
+ if (!s) return new Playback(settled({ name, clip, slot: -1, layer: L, inBlend: false }), this)
366
+ if (s.inBlend) { console.warn(`Animator: '${name}' is part of this layer's loop — use playLoop() to change what it rests on`); return new Playback(settled(s), this) }
367
+ const fadeIn = o.fadeIn ?? o.fade ?? DEFAULT_FADE
368
+ const fadeOut = o.fadeOut ?? o.fade ?? DEFAULT_FADE
369
+ const phase = this.entryPhase(L, o.phase) // read BEFORE the play: what the layer shows now
370
+ this.settleLayer(L) // the layer had one source: whatever it was, this play takes over from it
371
+ let resolve = (_n: boolean): void => {}
372
+ const done = new Promise<boolean>((r) => { resolve = r })
373
+ const rec: PlaybackRec = { slot: s, playing: true, done, resolve }
374
+ s.playback = rec
375
+ _creator.animatorPlay(this.id, s.slot, false, o.speed ?? 1, fadeIn, fadeOut, o.restart ?? false)
376
+ if (o.turn !== undefined) _creator.animatorSlotSetTurn?.(this.id, s.slot, o.turn * Math.PI / 180, true)
377
+ if (phase !== undefined) _creator.animatorSeekPhase(this.id, s.slot, phase)
378
+ return new Playback(rec, this)
379
+ }
380
+ /** A one-shot on the layer hasn't handed over yet. */
381
+ busy(L: LayerRec): boolean { return L.slots.some((s) => s.playback?.playing === true) }
382
+ settle(p: PlaybackRec, natural: boolean): void {
383
+ p.playing = false
384
+ p.resolve(natural)
385
+ }
386
+ /** Resolve every pending playback on the layer as cut short. */
387
+ private settleLayer(L: LayerRec): void { for (const s of L.slots) if (s.playback?.playing) this.settle(s.playback, false) }
388
+ stopSlot(s: SlotRec, fade: number): void {
389
+ if (s.playback?.playing) this.settle(s.playback, false)
390
+ if (this.id && s.slot >= 0) _creator.animatorStop(this.id, s.layer.index, s.slot, fade)
391
+ }
392
+ stopLayer(L: LayerRec, fade: number): void {
393
+ this.settleLayer(L)
394
+ if (L.loop) { L.loop.dead = true; L.loop = undefined }
395
+ if (this.id) _creator.animatorStop(this.id, L.index, -1, fade)
396
+ }
397
+ stopAll(fade: number): void { for (const L of this.layers) this.stopLayer(L, fade) }
398
+
399
+ // ---- loop ----
400
+ loop(L: LayerRec, def: LoopDef, o: LoopOptions): Loop | undefined {
401
+ if (!this.ensure()) return undefined
402
+ const fade = o.fade ?? DEFAULT_FADE
403
+ const phase = this.entryPhase(L, o.phase) // read BEFORE the members replace the layer's source
404
+ if (L.loop) { L.loop.dead = true; for (const m of L.loop.members) m.slot.inBlend = false; L.loop = undefined }
405
+ this.settleLayer(L) // a one-shot on the layer is cut short by the new loop
406
+ const space: Record<string, BlendPosition | BlendMember> = typeof def === "object" ? def : {}
407
+ if (typeof def !== "object") {
408
+ const found = this.resolveClip(def)
409
+ if (!found) { console.warn(`Animator: no clip ${JSON.stringify(def)} to loop — table: ${this.clipNames()}`); return undefined }
410
+ space[found[0]] = 0
411
+ }
412
+ const rec: LoopRec = { layer: L, members: [], value: 0, view: undefined as unknown as Loop, dead: false }
413
+ rec.view = new Loop(rec, this)
414
+ const slots: number[] = [], positions: number[] = [], phases: number[] = []
415
+ const posOf = (m: BlendPosition | BlendMember): BlendPosition => typeof m === "object" && !Array.isArray(m) ? (m as BlendMember).at : m as BlendPosition
416
+ const is2D = Object.values(space).some((m) => typeof posOf(m) !== "number")
417
+ for (const [name, m] of Object.entries(space)) {
418
+ const found = this.resolveClip(name)
419
+ if (!found) { console.warn(`Animator: loop clip '${name}' is not in the clip table`); continue }
420
+ const s = this.slotFor(L, name, found[1])
421
+ if (!s) continue
422
+ s.inBlend = true
423
+ rec.members.push({ name, slot: s })
424
+ slots.push(s.slot)
425
+ const at = posOf(m)
426
+ if (is2D) positions.push(typeof at === "number" ? at : at[0], typeof at === "number" ? 0 : at[1])
427
+ else positions.push(at as number)
428
+ // (offset, cycles) — cycles 0 = normalized time, no cycle
429
+ const cyc = typeof m === "object" && !Array.isArray(m) ? (m as BlendMember) : undefined
430
+ phases.push(cyc?.cycles && cyc.cycles > 0 ? (cyc.offset ?? 0) : 0, cyc?.cycles && cyc.cycles > 0 ? cyc.cycles : 0)
431
+ }
432
+ L.loop = rec
433
+ _creator.animatorSetBlend(this.id, L.index, is2D ? 2 : 1, new Uint16Array(slots), new Float32Array(positions), fade, o.speed ?? 1, new Float32Array(phases))
434
+ if (phase !== undefined) this.setLoopPhase(rec, phase)
435
+ return rec.view
436
+ }
437
+ /** The phase the layer shows, 0–1 through the gait cycle (-1 = nothing / no cycle). */
438
+ layerPhase(L: LayerRec): number { return this.id ? _creator.animatorLayerPhase(this.id, L.index) : -1 }
439
+ /** The entry phase of a play / playLoop: a number as given, `'match'` = what the layer shows now
440
+ * (undefined when it shows no cycle — then the clip starts at its own beginning). */
441
+ private entryPhase(L: LayerRec, want: number | "match" | undefined): number | undefined {
442
+ if (want === undefined) return undefined
443
+ if (typeof want === "number") return want
444
+ if (!this.id) return undefined
445
+ const p = this.layerPhase(L)
446
+ return p < 0 ? undefined : p
447
+ }
448
+ /** The loop's phase through its cycle, 0–1 (the members share one clock). */
449
+ loopPhase(b: LoopRec): number {
450
+ if (b.dead || b.layer.loop !== b) return 0
451
+ const p = this.layerPhase(b.layer)
452
+ return p < 0 ? 0 : p
453
+ }
454
+ setLoopPhase(b: LoopRec, phase: number): void {
455
+ const m = b.members[0]
456
+ if (!m || b.dead || !this.id) return
457
+ _creator.animatorSeekPhase(this.id, m.slot.slot, phase) // seeking one member moves the group
458
+ }
459
+ setLoopValue(b: LoopRec, v: BlendPosition): void {
460
+ if (b.dead) { console.warn("Animator: this loop was replaced — use the object the latest playLoop() returned"); return }
461
+ b.value = v
462
+ if (!this.id) return
463
+ const [x, y] = typeof v === "number" ? [ v, 0 ] : [ v[0], v[1] ]
464
+ _creator.animatorSetBlendValue(this.id, b.layer.index, x, y)
465
+ }
466
+ /** What the layer shows this frame: its source — the one-shot, or the loop's members with their
467
+ * blend shares — in binding order (stable rows). */
468
+ active(L: LayerRec): ActiveClip[] {
469
+ const out: ActiveClip[] = []
470
+ for (const s of L.slots) {
471
+ const weight = this.slotWeight(s)
472
+ if (weight <= 0) continue
473
+ out.push({ name: s.name, clip: s.clip, weight, time: this.slotTime(s), loop: s.inBlend })
474
+ }
475
+ return out
476
+ }
477
+ slotWeight(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorGetSlotWeight(this.id, s.slot) : 0 }
478
+ /** The slot's gait phase now, wrapped into 0–1 (-1 without a cycle). */
479
+ slotPhase(s: SlotRec): number {
480
+ if (!this.id || s.slot < 0) return -1
481
+ const p = _creator.animatorSlotCurveAt(this.id, s.slot, 0, -1)
482
+ return p < 0 ? -1 : p - Math.floor(p)
483
+ }
484
+ /** A baked curve of the slot at its current time: 1 travel (m) / 2 yaw (rad) / 3 speed (m/s). */
485
+ slotCurveAt(s: SlotRec, which: 1 | 2 | 3): number { return this.id && s.slot >= 0 ? _creator.animatorSlotCurveAt(this.id, s.slot, which, -1) : 0 }
486
+ /** The clip's total root yaw, radians (+ = left). */
487
+ slotTurn(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorSlotTurn(this.id, s.slot) : 0 }
488
+ slotTime(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorGetSlotTime(this.id, s.slot) : 0 }
489
+ seek(s: SlotRec, time: number): void { if (this.id && s.slot >= 0) _creator.animatorSeek(this.id, s.slot, time) }
490
+
491
+ /** Scrub: set a bound clip's playhead directly, seconds — an inspector's tool. */
492
+ seekTime(ref: string | number, time: number): void {
493
+ const found = this.resolveClip(ref)
494
+ if (!found || !this.ensure()) return
495
+ const s = this.slotFor(this.layers[0], found[0], found[1])
496
+ if (s) this.seek(s, time)
497
+ }
498
+ /** Re-aim a playing clip's TURN WARP (see `PlayOptions.turn`): the clip's baked heading is scaled so that the
499
+ * whole clip turns `deg` — set mid-play, the scale applies to what is still to come, so a controller that
500
+ * wants a different heading while a turn plays asks for (the clip's total × what it still needs / what
501
+ * the clip still turns). Undefined `deg` switches the warp off. */
502
+ setTurnOf(ref: string | number, deg: number | undefined): void {
503
+ const found = this.resolveClip(ref)
504
+ if (!found || !this.ensure()) return
505
+ const s = this.slotFor(this.layers[0], found[0], found[1])
506
+ if (s) _creator.animatorSlotSetTurn?.(this.id, s.slot, (deg ?? 0) * Math.PI / 180, deg !== undefined)
507
+ }
508
+ /** A bound clip's current playhead, seconds (-1 = not bound). */
509
+ timeOf(ref: string | number): number {
510
+ const found = this.resolveClip(ref)
511
+ if (!found || !this.ensure()) return -1
512
+ const s = this.slotFor(this.layers[0], found[0], found[1])
513
+ return s ? this.slotTime(s) : -1
514
+ }
515
+
516
+ // ---- contacts, phase, root curves ----
517
+ // The linear gait cycle through a clip's foot-downs — the bake's anchor rule (the first step: left → 0,
518
+ // right → 0.5; +0.5 per change of foot, +1 for the same foot again) fitted with the cycle count fixed at
519
+ // steps / 2 (every foot lands once per cycle): offset = the mean residual, wrapped. A span starting at
520
+ // t = 0 is a foot already down, not a step — unless the foot is airborne at the clip's end (the seam of a
521
+ // loop is its landing) while the clip does not start with both feet down.
522
+ /** What the engine measured on the clip once it was bound to this skeleton — binds it if needed. */
523
+ clipInfo(ref: string | number): ClipInfo | undefined {
524
+ const found = this.resolveClip(ref)
525
+ if (!found || !this.ensure()) return undefined
526
+ const s = this.slotFor(this.layers[0], found[0], found[1])
527
+ if (!s) return undefined
528
+ const id = this.id, slot = s.slot
529
+ _creator.animatorSlotCurveInfo(id, slot, curveInfo)
530
+ const spans = _creator.animatorSlotContacts(id, slot, new Float32Array(0))
531
+ const buf = new Float32Array(spans * 8) // stride 8: side, from, to, at xyz, stillFrom, stillTo
532
+ if (spans > 0) _creator.animatorSlotContacts(id, slot, buf)
533
+ const contacts: ClipInfo["contacts"] = [], hands: ClipInfo["hands"] = []
534
+ for (let i = 0; i < spans; i++) {
535
+ const still = buf[i * 8 + 6]! >= 0 ? { from: buf[i * 8 + 6]!, to: buf[i * 8 + 7]! } : undefined
536
+ // side 0 / 1 = the feet, 2 / 3 = the hands
537
+ const code = Math.round(buf[i * 8]!)
538
+ ;(code >= 2 ? hands : contacts).push({ side: code % 2 === 0 ? "left" : "right", from: buf[i * 8 + 1]!, to: buf[i * 8 + 2]!,
539
+ at: [ buf[i * 8 + 3]!, buf[i * 8 + 4]!, buf[i * 8 + 5]! ], still })
540
+ }
541
+ const nTreads = _creator.animatorSlotTreads(id, slot, new Float32Array(0))
542
+ let treads: ClipInfo["treads"]
543
+ if (nTreads > 0) {
544
+ const tBuf = new Float32Array(nTreads + 1) // [0] = riser, then the levels
545
+ _creator.animatorSlotTreads(id, slot, tBuf)
546
+ treads = { riser: tBuf[0]!, levels: Array.from(tBuf.subarray(1)) }
547
+ }
548
+ const featuresAt = (time: number): Float32Array | undefined => {
549
+ const row = new Float32Array(37)
550
+ return _creator.animatorSlotFeatures(id, slot, time, row) ? row : undefined
551
+ }
552
+ const first = featuresAt(0)
553
+ return {
554
+ travel: curveInfo[1], speed: curveInfo[2], turn: _creator.animatorSlotTurn(id, slot),
555
+ inPlace: curveInfo[3] > 0.5, hasPhase: _creator.animatorSlotCurveAt(id, slot, 0, 0) >= 0, contacts, hands, treads,
556
+ featuresAt, cyclic: !!first && first[36]! > 0.5,
557
+ fitTo: (target: string | number, time: number, options: { align?: "phase" | "time" } = {}) => {
558
+ const c = this.fitCurve(s, target, options.align === "time" ? 1 : 0)
559
+ if (!c) return undefined
560
+ const f = Math.max(0, Math.min(c.values.length - 1, time / c.dt))
561
+ const k = Math.floor(f), u = f - k
562
+ return k + 1 >= c.values.length ? c.values[c.values.length - 1] : c.values[k] + (c.values[k + 1] - c.values[k]) * u
563
+ },
564
+ exitTo: (target: string | number, options: { within?: number, atContact?: boolean, align?: "phase" | "time" } = {}) =>
565
+ this.fitExit(s, target, options.within ?? 0.05, options.atContact ?? true, options.align === "time" ? 1 : 0),
566
+ cycle: () => fitCycle(contacts, found[1].duration),
567
+ alignTo: (reference: string | number, cycle?: { offset: number, cycles: number }) => {
568
+ const r = this.slotOf(reference)
569
+ if (!r) return undefined
570
+ const rc = cycle ?? this.clipInfo(reference)?.cycle() ?? { offset: 0, cycles: 1 }
571
+ const out = new Float32Array(4)
572
+ if (!_creator.animatorSlotAlign(id, r.slot, slot, rc.offset, rc.cycles, out)) return undefined
573
+ return { offset: out[0]!, cycles: out[1]!, score: out[2]!, margin: out[3]! }
574
+ },
575
+ phaseAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 0, time),
576
+ travelAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 1, time),
577
+ turnAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 2, time),
578
+ speedAt: (time: number) => _creator.animatorSlotCurveAt(id, slot, 3, time),
579
+ directionAt: (time: number) => [ _creator.animatorSlotCurveAt(id, slot, 4, time), _creator.animatorSlotCurveAt(id, slot, 5, time) ] as [number, number],
580
+ timeAtTurn: (yaw: number) => _creator.animatorSlotTimeAtTurn(id, slot, yaw),
581
+ kneePoleAt: (side: "left" | "right", time: number) => {
582
+ const ok = _creator.animatorSlotKneePole(id, slot, side === "left" ? 0 : 1, time, kneeOut)
583
+ if (!ok) return undefined
584
+ return { pole: [ kneeOut[0]!, kneeOut[1]!, kneeOut[2]! ] as [number, number, number], normal: [ kneeOut[3]!, kneeOut[4]!, kneeOut[5]! ] as [number, number, number] }
585
+ },
586
+ physicsAt: (time: number) => {
587
+ const ok = _creator.animatorSlotPhysics(id, slot, time, physOut)
588
+ if (!ok) return undefined
589
+ return {
590
+ com: [ physOut[0]!, physOut[1]!, physOut[2]! ] as [number, number, number],
591
+ velocity: [ physOut[3]!, physOut[4]!, physOut[5]! ] as [number, number, number],
592
+ angular: [ physOut[6]!, physOut[7]!, physOut[8]! ] as [number, number, number],
593
+ support: [ physOut[9]!, physOut[10]! ] as [number, number],
594
+ }
595
+ },
596
+ }
597
+ }
598
+ /** The skeleton's calibrated knee hinge axis for a side, with its confidence report — measured once
599
+ * over every bound clip's knee rotation track. Undefined = no leg chain / no knee motion bound. */
600
+ kneeAxis(side: "left" | "right"): KneeAxisReport | undefined {
601
+ if (!this.ensure()) return undefined
602
+ const ok = _creator.animatorKneeAxis(this.id, side === "left" ? 0 : 1, kneeOut)
603
+ if (!ok) return undefined
604
+ const r: KneeAxisReport = { axis: [ kneeOut[0]!, kneeOut[1]!, kneeOut[2]! ], spreadMean: kneeOut[3]!, spreadMax: kneeOut[4]!, samples: kneeOut[5]! }
605
+ if (kneeOut[6]! > -1e8) r.plant = { ankleY: kneeOut[6]!, toeY: kneeOut[7]! }
606
+ return r
607
+ }
608
+ /** STEP WARP v2 (the warp rewrite's knobs): `stride` scales each foot's travel-direction offset
609
+ * from its hip — the step shortens or lengthens, uniformly through stance and swing; `lift` is
610
+ * METRES added to each foot's height, gated to the swing by the contact marks (0 = neutral,
611
+ * negative = a shuffle — no probing), and half of what it adds raises the pelvis so the body
612
+ * steps higher with the foot; `pitch` (degrees, + = toes up) rotates each foot about its
613
+ * own lateral axis — a slope's foot rotation; `slope` (degrees, + = ascending) is the INVISIBLE
614
+ * STAIRCASE: foot heights follow the incline (leading foot higher) and the feet auto-pitch by
615
+ * the same angle — pair it with raising the character by tan(slope) × the stride-scaled clip
616
+ * travel (`clipInfo(clip).travelAt`), which holds every planted foot's world height constant on
617
+ * its own tread. Solved in the calibrated knee hinge plane with a SOFT reach (a leg at its limit
618
+ * keeps a residual knee bend instead of popping against the clamp), and whenever a leg would
619
+ * overreach — a descent, a long stride — the pelvis lowers by exactly the excess (weighted by
620
+ * the contact marks, spring-followed; zero when nothing overreaches, so an ascent or a shorter
621
+ * stride is untouched). Omit / null = stage off. */
622
+ setStepWarp(options?: { stride?: number, lift?: number, pitch?: number, slope?: number } | null): void {
623
+ if (!this.ensure()) return
624
+ _creator.animatorSetStepWarp(this.id, !!options, options?.stride ?? 1, options?.lift ?? 0, options?.pitch ?? 0, options?.slope ?? 0)
625
+ }
626
+ /** A slot for a clip on the base layer, bound on demand — what the pair-wise measures address. */
627
+ private slotOf(ref: string | number): SlotRec | undefined {
628
+ const found = this.resolveClip(ref)
629
+ if (!found || !this.ensure()) return undefined
630
+ return this.slotFor(this.layers[0], found[0], found[1])
631
+ }
632
+ /** The baked distance curve between two clips, cached per pair (the engine caches the bake itself;
633
+ * this keeps the samples on the JS side so a controller can read them per frame without copying). */
634
+ private readonly _fit = new Map<string, { dt: number, values: Float32Array }>()
635
+ private fitCurve(src: SlotRec, target: string | number, align: 0 | 1): { dt: number, values: Float32Array } | undefined {
636
+ const t = this.slotOf(target)
637
+ if (!t) return undefined
638
+ const key = `${src.slot}>${t.slot}:${align}`
639
+ const have = this._fit.get(key)
640
+ if (have) return have
641
+ const n = _creator.animatorSlotFit(this.id, src.slot, t.slot, align, new Float32Array(0))
642
+ if (n === 0) return undefined
643
+ const values = new Float32Array(n)
644
+ _creator.animatorSlotFit(this.id, src.slot, t.slot, align, values)
645
+ _creator.animatorSlotCurveInfo(this.id, src.slot, curveInfo)
646
+ const curve = { dt: curveInfo[0] || 1 / 60, values }
647
+ this._fit.set(key, curve)
648
+ return curve
649
+ }
650
+ /** The earliest moment handing `src` over to `target` costs no more than `within` metres. */
651
+ private fitExit(src: SlotRec, target: string | number, within: number, atContact: boolean, align: 0 | 1): number {
652
+ const t = this.slotOf(target)
653
+ return t ? _creator.animatorSlotExit(this.id, src.slot, t.slot, within, atContact, align) : -1
654
+ }
655
+
656
+ /** The contact bones, '
657
+ '-joined per side ("" = classify by name). */
658
+ private _feet?: [string, string]
659
+ setFeet(left: string, right: string): void {
660
+ this._feet = [ left, right ]
661
+ if (this.id) _creator.animatorSetFeet(this.id, left, right)
662
+ }
663
+ /** Stride / orientation warping, as the engine's fixed-order parameter array. */
664
+ private _warp?: Float32Array
665
+ setWarp(params: Float32Array): void {
666
+ this._warp = params
667
+ if (this.id) _creator.animatorSetWarpParams(this.id, params)
668
+ }
669
+ /** The feet stage (lock / ground IK), as the engine's fixed-order parameter array. */
670
+ private _feetParams?: Float32Array
671
+ setFeetParams(params: Float32Array): void {
672
+ this._feetParams = params
673
+ if (this.id && _creator.animatorSetFeetParams) _creator.animatorSetFeetParams(this.id, params)
674
+ }
675
+ /** One foot's lock state after this frame's evaluation into `out` (8 floats); false = no such foot
676
+ * or no feet stage on this host. */
677
+ footState(side: 0 | 1, out: Float32Array): boolean {
678
+ return this.id !== 0 && _creator.animatorFootState !== undefined && _creator.animatorFootState(this.id, side, out)
679
+ }
680
+ private readonly _steps = new Set<StepHandler>()
681
+ onStepHandler(cb: StepHandler): void { this._steps.add(cb) }
682
+ offStepHandler(cb: StepHandler): void { this._steps.delete(cb) }
683
+ private onStep(side: number, x: number, y: number, z: number): void {
684
+ if (this._steps.size === 0) return
685
+ const at = new Vec3(x, y, z)
686
+ for (const cb of [ ...this._steps ]) cb(side === 0 ? "left" : "right", at)
687
+ }
688
+
689
+ // ---- props ----
690
+ setSpeed(v: number): void { this.speed = v; if (this.id) _creator.animatorSetGlobal(this.id, v, false) }
691
+ setRootMotion(on: boolean, rotation = false): void { if (this.ensure()) _creator.animatorSetRootMotion(this.id, on ? "*" : "", (on ? 2 : 0) | (on && rotation ? 4 : 0)) }
692
+
693
+ // ---- events ----
694
+ on(event: string, cb: ClipEventHandler): void { (this._listeners.get(event) ?? this._listeners.set(event, new Set()).get(event)!).add(cb) }
695
+ off(event: string, cb: ClipEventHandler): void { this._listeners.get(event)?.delete(cb) }
696
+ /** Native slot event: 0 completed / 1 loop / 2 settled (no longer a source) / 3 hand-over / 4+i clip event i. */
697
+ private onEvent(slotIndex: number, type: number): void {
698
+ let s: SlotRec | undefined
699
+ for (const L of this.layers) { s = L.slots.find((x) => x.slot === slotIndex); if (s) break }
700
+ if (!s) return
701
+ if (type >= 4) {
702
+ const name = s.clip._events[type - 4]?.name
703
+ const cbs = name ? this._listeners.get(name) : undefined
704
+ if (cbs) for (const cb of [ ...cbs ]) cb(s.name, s.layer.view)
705
+ return
706
+ }
707
+ const p = s.playback
708
+ if (!p?.playing) return
709
+ if (type === 3) this.settle(p, true) // hand-over: the clip is over; what the app starts now takes over from it
710
+ else if (type === 2) this.settle(p, false) // dropped by the engine without the SDK asking (backstop)
711
+ }
712
+ }
713
+
714
+ function fitCycle(contacts: ClipInfo["contacts"], duration: number): ClipCycle | undefined {
715
+ if (!(duration > 0)) return undefined
716
+ const sides = [ "left", "right" ] as const
717
+ const bothDown = sides.every((side) => contacts.some((c) => c.side === side && c.from <= 0.02))
718
+ const steps: { t: number, side: 0 | 1 }[] = []
719
+ for (let side = 0; side < 2; side++) {
720
+ const spans = contacts.filter((c) => c.side === sides[side]).sort((a, b) => a.from - b.from)
721
+ const airborneAtEnd = spans.length > 0 && spans[spans.length - 1]!.to < duration * 0.85
722
+ for (const sp of spans) if (sp.from > 0.02 || (airborneAtEnd && !bothDown)) steps.push({ t: sp.from, side: side as 0 | 1 })
723
+ }
724
+ steps.sort((a, b) => a.t - b.t)
725
+ if (steps.length === 0) return undefined
726
+ const phi: number[] = []
727
+ for (let i = 0; i < steps.length; i++) phi.push(i === 0 ? (steps[0]!.side === 0 ? 0 : 0.5) : phi[i - 1]! + (steps[i]!.side !== steps[i - 1]!.side ? 0.5 : 1))
728
+ const cycles = steps.length / 2
729
+ let offset = 0
730
+ for (let i = 0; i < steps.length; i++) offset += phi[i]! - cycles * steps[i]!.t / duration
731
+ offset /= steps.length
732
+ offset -= Math.floor(offset)
733
+ let residual = 0
734
+ for (let i = 0; i < steps.length; i++) { let d = phi[i]! - (offset + cycles * steps[i]!.t / duration); d -= Math.round(d); residual = Math.max(residual, Math.abs(d)) }
735
+ return { offset, cycles, residual, steps: steps.length }
736
+ }