@pose_flow/babylon 0.1.0 → 0.3.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.
Files changed (53) hide show
  1. package/README.md +129 -105
  2. package/dist/AI_USAGE.md +386 -0
  3. package/dist/PUBLISHING.md +110 -0
  4. package/dist/babylon-inertialization.d.ts +29 -0
  5. package/dist/babylon-inertialization.js +88 -0
  6. package/dist/babylon.d.ts +27 -5
  7. package/dist/babylon.js +181 -26
  8. package/dist/core/controller.d.ts +26 -0
  9. package/dist/core/controller.js +78 -8
  10. package/dist/core/database.d.ts +8 -2
  11. package/dist/core/database.js +62 -24
  12. package/dist/core/features.d.ts +1 -1
  13. package/dist/core/features.js +9 -11
  14. package/dist/core/inertialization.d.ts +3 -0
  15. package/dist/core/inertialization.js +15 -0
  16. package/dist/core/profile.d.ts +34 -8
  17. package/dist/core/profile.js +49 -10
  18. package/dist/core/root-motion.d.ts +5 -3
  19. package/dist/core/root-motion.js +43 -7
  20. package/dist/core/scenario.d.ts +29 -0
  21. package/dist/core/scenario.js +57 -0
  22. package/dist/core/schema.d.ts +56 -0
  23. package/dist/core/schema.js +123 -0
  24. package/dist/core/search.d.ts +40 -2
  25. package/dist/core/search.js +147 -16
  26. package/dist/core/selection.d.ts +24 -0
  27. package/dist/core/selection.js +41 -0
  28. package/dist/core/types.d.ts +24 -4
  29. package/dist/core/types.js +1 -0
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.js +4 -0
  32. package/dist/offline/src/babylon-inertialization.js +88 -0
  33. package/dist/offline/src/babylon.js +281 -0
  34. package/dist/offline/src/core/controller.js +162 -0
  35. package/dist/offline/src/core/database.js +97 -0
  36. package/dist/offline/src/core/features.js +41 -0
  37. package/dist/offline/src/core/inertialization.js +15 -0
  38. package/dist/offline/src/core/profile.js +103 -0
  39. package/dist/offline/src/core/root-motion.js +78 -0
  40. package/dist/offline/src/core/scenario.js +57 -0
  41. package/dist/offline/src/core/schema.js +123 -0
  42. package/dist/offline/src/core/search.js +181 -0
  43. package/dist/offline/src/core/selection.js +41 -0
  44. package/dist/offline/src/core/types.js +3 -0
  45. package/dist/offline/src/index.js +11 -0
  46. package/dist/offline/tools/bake.js +88 -0
  47. package/dist/offline/tools/cli.js +30 -0
  48. package/dist/offline/tools/glb.js +54 -0
  49. package/dist/offline/tools/prepare.js +165 -0
  50. package/dist/offline/tools/profile-assets.js +90 -0
  51. package/dist/offline/tools/rig.js +47 -0
  52. package/dist/offline/tools/validate-assets.js +43 -0
  53. package/package.json +48 -39
@@ -0,0 +1,88 @@
1
+ import { Quaternion, Vector3 } from '@babylonjs/core';
2
+ import { decayInertialOffset } from './core/inertialization.js';
3
+ export function captureLocal(node) {
4
+ return { position: node.position.clone(), rotation: node.rotationQuaternion?.clone() ?? Quaternion.FromEulerVector(node.rotation), scaling: node.scaling.clone() };
5
+ }
6
+ export function rotationVector(q) {
7
+ q = q.clone().normalize();
8
+ if (q.w < 0)
9
+ q.scaleInPlace(-1);
10
+ const length = Math.hypot(q.x, q.y, q.z);
11
+ return length < 1e-8 ? new Vector3(q.x * 2, q.y * 2, q.z * 2) : new Vector3(q.x, q.y, q.z).scale(2 * Math.atan2(length, q.w) / length);
12
+ }
13
+ function rotationFromVector(v) {
14
+ const angle = v.length();
15
+ return angle < 1e-8 ? new Quaternion(v.x / 2, v.y / 2, v.z / 2, 1).normalize() : Quaternion.RotationAxis(v.scale(1 / angle), angle);
16
+ }
17
+ export function poseVelocity(before, after, dt) {
18
+ return { position: after.position.subtract(before.position).scale(1 / dt), scaling: after.scaling.subtract(before.scaling).scale(1 / dt),
19
+ // rotation stores a tangent vector in XYZ; W is unused for velocities.
20
+ rotation: Quaternion.FromArray([...rotationVector(after.rotation.multiply(Quaternion.Inverse(before.rotation))).scale(1 / dt).asArray(), 0]) };
21
+ }
22
+ const zeroVelocity = () => ({ position: Vector3.Zero(), scaling: Vector3.Zero(), rotation: new Quaternion(0, 0, 0, 0) });
23
+ /** Owns local presentation residuals only. Evaluation happens before world matrices/rendering. */
24
+ export class BabylonInertializer {
25
+ scene;
26
+ halfLife;
27
+ observer;
28
+ last = new Map();
29
+ velocity = new Map();
30
+ residuals = new Map();
31
+ target = new Map();
32
+ dt = 0;
33
+ elapsed = 0;
34
+ revision = 0;
35
+ renderedRevision = -1;
36
+ constructor(scene, halfLife) {
37
+ this.scene = scene;
38
+ this.halfLife = halfLife;
39
+ this.observer = scene.onAfterAnimationsObservable.add(() => this.render());
40
+ }
41
+ update(target, targetVelocity, dt, switched) {
42
+ if (switched) {
43
+ this.residuals.clear();
44
+ this.elapsed = 0;
45
+ for (const [node, pose] of target) {
46
+ const before = this.last.get(node);
47
+ if (!before)
48
+ continue;
49
+ const v = this.velocity.get(node) ?? zeroVelocity(), incoming = targetVelocity.get(node) ?? zeroVelocity();
50
+ const angular = rotationVector(before.rotation.multiply(Quaternion.Inverse(pose.rotation)));
51
+ this.residuals.set(node, { offset: { position: before.position.subtract(pose.position), scaling: before.scaling.subtract(pose.scaling), rotation: new Quaternion(angular.x, angular.y, angular.z, 0) },
52
+ velocity: { position: v.position.subtract(incoming.position), scaling: v.scaling.subtract(incoming.scaling), rotation: v.rotation.subtract(incoming.rotation) } });
53
+ }
54
+ }
55
+ else
56
+ this.elapsed += dt;
57
+ this.target = target;
58
+ this.dt = dt;
59
+ this.revision++;
60
+ }
61
+ render() {
62
+ for (const [node, target] of this.target) {
63
+ const residual = this.residuals.get(node);
64
+ const decay = (offset, velocity) => new Vector3(...offset.asArray().map((v, i) => decayInertialOffset(v, velocity.asArray()[i], this.elapsed, this.halfLife)));
65
+ node.position.copyFrom(target.position);
66
+ node.scaling.copyFrom(target.scaling);
67
+ node.rotationQuaternion ??= Quaternion.Identity();
68
+ node.rotationQuaternion.copyFrom(target.rotation);
69
+ if (residual) {
70
+ node.position.addInPlace(decay(residual.offset.position, residual.velocity.position));
71
+ node.scaling.addInPlace(decay(residual.offset.scaling, residual.velocity.scaling));
72
+ const r = residual.offset.rotation, v = residual.velocity.rotation;
73
+ node.rotationQuaternion.copyFrom(rotationFromVector(decay(new Vector3(r.x, r.y, r.z), new Vector3(v.x, v.y, v.z))).multiply(target.rotation).normalize());
74
+ }
75
+ if (this.renderedRevision !== this.revision) {
76
+ const pose = captureLocal(node), previous = this.last.get(node);
77
+ if (previous && this.dt > 0)
78
+ this.velocity.set(node, poseVelocity(previous, pose, this.dt));
79
+ this.last.set(node, pose);
80
+ }
81
+ }
82
+ this.renderedRevision = this.revision;
83
+ if (this.elapsed > this.halfLife * 40)
84
+ this.residuals.clear();
85
+ }
86
+ reset() { this.last.clear(); this.velocity.clear(); this.residuals.clear(); this.target.clear(); this.elapsed = 0; this.renderedRevision = -1; }
87
+ dispose() { this.reset(); this.scene.onAfterAnimationsObservable.remove(this.observer); }
88
+ }
package/dist/babylon.d.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { AnimationGroup, TransformNode, type Scene } from '@babylonjs/core';
2
2
  import type { Clip, Playback, Pose } from './index.js';
3
+ import { type PoseSchema, type SchemaPose } from './core/schema.js';
4
+ export interface BabylonPlayerOptions {
5
+ /** Exact MODEL node names. No descendant expansion or name mapping is implied. */
6
+ mask?: readonly string[];
7
+ transition?: 'crossfade' | 'inertialization';
8
+ /** Positive seconds. Applies only to inertialization; default 0.1. */
9
+ inertialHalfLife?: number;
10
+ }
3
11
  /** Clone onto a target rig. Two playback slots allow crossfade between times of the same clip. */
4
12
  export declare class BabylonMotionPlayer {
5
13
  private scene;
@@ -11,18 +19,32 @@ export declare class BabylonMotionPlayer {
11
19
  private tracks;
12
20
  private active?;
13
21
  private disposed;
14
- constructor(scene: Scene, clips: readonly Clip[], sources: ReadonlyMap<string, AnimationGroup>, nodes: ReadonlyMap<string, TransformNode>, fadeSeconds?: number, rootName?: string);
22
+ private strippedRoot?;
23
+ private rootRestRotation?;
24
+ private owned;
25
+ private rest;
26
+ private inertia?;
27
+ constructor(scene: Scene, clips: readonly Clip[], sources: ReadonlyMap<string, AnimationGroup>, nodes: ReadonlyMap<string, TransformNode>, fadeSeconds?: number, rootName?: string, options?: BabylonPlayerOptions);
15
28
  private create;
16
29
  update(state: Playback, dt: number): void;
30
+ private sampleLocal;
31
+ /** Clear presentation history after teleport/reconciliation; ownership stays until dispose. */
32
+ reset(): void;
17
33
  private clear;
18
34
  dispose(): void;
19
35
  }
20
- /** Sample AFTER scene animation evaluation. Measures visible feet, including crossfade. */
21
- export declare class BabylonPoseReader {
36
+ /** Sample AFTER scene animation evaluation. Semantic roles are defined by the schema. */
37
+ export declare class BabylonSchemaPoseReader {
22
38
  private root;
23
- private left;
24
- private right;
25
39
  private previous?;
40
+ private nodes;
41
+ constructor(root: TransformNode, bones: ReadonlyMap<string, TransformNode>, schema: PoseSchema);
42
+ reset(): void;
43
+ read(dt: number): SchemaPose;
44
+ }
45
+ /** Compatibility reader for the original two-foot preset. */
46
+ export declare class BabylonPoseReader {
47
+ private reader;
26
48
  constructor(root: TransformNode, left: TransformNode, right: TransformNode);
27
49
  reset(): void;
28
50
  read(dt: number): Pose;
package/dist/babylon.js CHANGED
@@ -1,4 +1,26 @@
1
- import { AnimationGroup, Matrix, Vector3 } from '@babylonjs/core';
1
+ import { Animation, AnimationGroup, Matrix, Quaternion, Vector3 } from '@babylonjs/core';
2
+ import { compileSchema, LOCOMOTION_SCHEMA } from './core/schema.js';
3
+ import { parseRootOrientation } from './core/profile.js';
4
+ import { BabylonInertializer, captureLocal, poseVelocity } from './babylon-inertialization.js';
5
+ const nodeOwners = new WeakMap();
6
+ /** Constant authoring orientation belongs to the pose; varying root heading belongs to gameplay. */
7
+ function constantRootRotation(animation) {
8
+ const keys = animation.getKeys();
9
+ if (!keys.length || keys.some(k => k.inTangent !== undefined || k.outTangent !== undefined))
10
+ return undefined;
11
+ const rotation = (value) => animation.targetProperty === 'rotationQuaternion' && value instanceof Quaternion ? value.clone()
12
+ : animation.targetProperty === 'rotation' && value instanceof Vector3 ? Quaternion.FromEulerVector(value) : undefined;
13
+ const first = rotation(keys[0].value);
14
+ if (!first || !first.asArray().every(Number.isFinite) || first.length() < 1e-8)
15
+ return undefined;
16
+ first.normalize();
17
+ for (const key of keys) {
18
+ const q = rotation(key.value);
19
+ if (!q || !q.asArray().every(Number.isFinite) || q.length() < 1e-8 || Math.abs(Quaternion.Dot(first, q.normalize())) < 1 - 1e-8)
20
+ return undefined;
21
+ }
22
+ return first;
23
+ }
2
24
  /** Clone onto a target rig. Two playback slots allow crossfade between times of the same clip. */
3
25
  export class BabylonMotionPlayer {
4
26
  scene;
@@ -10,7 +32,12 @@ export class BabylonMotionPlayer {
10
32
  tracks = [];
11
33
  active;
12
34
  disposed = false;
13
- constructor(scene, clips, sources, nodes, fadeSeconds = 0.15, rootName = 'root') {
35
+ strippedRoot;
36
+ rootRestRotation;
37
+ owned = new Set();
38
+ rest = new Map();
39
+ inertia;
40
+ constructor(scene, clips, sources, nodes, fadeSeconds = 0.15, rootName = 'root', options = {}) {
14
41
  this.scene = scene;
15
42
  this.clips = clips;
16
43
  this.sources = sources;
@@ -19,27 +46,91 @@ export class BabylonMotionPlayer {
19
46
  this.rootName = rootName;
20
47
  if (!Number.isFinite(fadeSeconds) || fadeSeconds < 0)
21
48
  throw new Error('Invalid crossfade duration');
22
- for (const clip of clips)
23
- if (!sources.has(clip.id))
49
+ if (options.transition !== undefined && !['crossfade', 'inertialization'].includes(options.transition))
50
+ throw new Error('Invalid transition mode');
51
+ if (options.inertialHalfLife !== undefined && (!Number.isFinite(options.inertialHalfLife) || options.inertialHalfLife <= 0))
52
+ throw new Error('Invalid inertial half life');
53
+ for (const clip of clips) {
54
+ const source = sources.get(clip.id);
55
+ if (!source)
24
56
  throw new Error(`Missing animation source: ${clip.id}`);
57
+ const fps = source.targetedAnimations[0]?.animation.framePerSecond, start = clip.sourceStart ?? 0;
58
+ if (!Number.isFinite(start) || start < 0 || !Number.isFinite(clip.duration) || clip.duration <= 0 || !fps
59
+ || start + clip.duration > (source.to - source.from) / fps + 1e-5)
60
+ throw new Error(`Invalid source range: ${clip.id}`);
61
+ }
62
+ const mapped = new Set(nodes.values());
63
+ if (options.mask !== undefined && (!Array.isArray(options.mask) || !options.mask.length || new Set(options.mask).size !== options.mask.length
64
+ || options.mask.some(name => typeof name !== 'string' || [...mapped].filter(n => n.name === name).length !== 1)))
65
+ throw new Error('Invalid player mask: use unique mapped model node names');
66
+ const mask = options.mask === undefined ? mapped : new Set([...mapped].filter(n => options.mask.includes(n.name)));
67
+ for (const clip of clips)
68
+ for (const targeted of sources.get(clip.id).targetedAnimations) {
69
+ const node = nodes.get(targeted.target.name);
70
+ if (node && mask.has(node))
71
+ this.owned.add(node);
72
+ }
73
+ if (!this.owned.size)
74
+ throw new Error('No matching rig animation tracks in player mask');
75
+ if ([...this.owned].some(node => nodeOwners.has(node)))
76
+ throw new Error('Animation node already owned by another motion player');
77
+ this.strippedRoot = [...mapped].find(n => n.name === rootName);
78
+ if (this.strippedRoot && this.owned.has(this.strippedRoot)) {
79
+ this.strippedRoot.rotationQuaternion ??= Quaternion.FromEulerVector(this.strippedRoot.rotation);
80
+ this.rootRestRotation = this.strippedRoot.rotationQuaternion.clone();
81
+ }
82
+ for (const clip of clips)
83
+ if (clip.rootOrientation !== undefined) {
84
+ parseRootOrientation(clip.rootOrientation);
85
+ if (!this.strippedRoot)
86
+ throw new Error('rootOrientation requires a mapped stripped root');
87
+ }
88
+ if (options.transition === 'inertialization') {
89
+ for (const clip of clips)
90
+ for (const targeted of sources.get(clip.id).targetedAnimations) {
91
+ const node = nodes.get(targeted.target.name), property = targeted.animation.targetProperty;
92
+ if (node && this.owned.has(node) && node !== this.strippedRoot && !/^(?:(?:position|scaling)(\.[xyz])?|rotationQuaternion(\.[xyzw])?)$/.test(property))
93
+ throw new Error('Inertialization supports local position/scaling/quaternion tracks only');
94
+ }
95
+ this.rest = new Map([...this.owned].map(node => [node, captureLocal(node)]));
96
+ this.inertia = new BabylonInertializer(scene, options.inertialHalfLife ?? .1);
97
+ }
98
+ for (const node of this.owned)
99
+ nodeOwners.set(node, this);
25
100
  }
26
101
  create(clip, time) {
27
102
  const source = this.sources.get(this.clips[clip].id);
28
103
  const group = new AnimationGroup(`mm-${this.clips[clip].id}-${this.tracks.length}`, this.scene);
104
+ let rootRotation = this.rootRestRotation?.clone();
29
105
  for (const targeted of source.targetedAnimations) {
30
106
  const node = this.nodes.get(targeted.target.name);
31
- if (!node)
107
+ if (!node || !this.owned.has(node))
32
108
  continue;
33
109
  const property = targeted.animation.targetProperty;
34
- // Gameplay owns horizontal root displacement AND heading. Preserve vertical bob only on other bones.
35
- if (node.name === this.rootName && (property === 'position' || property === 'rotationQuaternion' || property === 'rotation'))
36
- continue;
110
+ if (node === this.strippedRoot) {
111
+ if (property === 'position' || property.startsWith('position.'))
112
+ continue;
113
+ if (property === 'rotationQuaternion' || property === 'rotation' || property.startsWith('rotation.') || property.startsWith('rotationQuaternion.')) {
114
+ rootRotation = constantRootRotation(targeted.animation) ?? rootRotation;
115
+ continue;
116
+ }
117
+ }
37
118
  group.addTargetedAnimation(targeted.animation, node);
38
119
  }
39
120
  if (!group.targetedAnimations.length) {
40
121
  group.dispose();
41
122
  throw new Error('No matching rig animation tracks');
42
123
  }
124
+ const correction = this.clips[clip].rootOrientation;
125
+ if (correction)
126
+ rootRotation = Quaternion.FromArray([...correction]).normalize();
127
+ if (this.strippedRoot && this.owned.has(this.strippedRoot) && rootRotation) {
128
+ // An explicit constant binding also restores the baseline after a clip with
129
+ // another orientation, and participates in the same weighted crossfade.
130
+ const rotation = new Animation('mm-root-pose', 'rotationQuaternion', source.targetedAnimations[0].animation.framePerSecond, Animation.ANIMATIONTYPE_QUATERNION);
131
+ rotation.setKeys([{ frame: source.from, value: rootRotation }, { frame: source.to, value: rootRotation.clone() }]);
132
+ group.addTargetedAnimation(rotation, this.strippedRoot);
133
+ }
43
134
  // Paused groups still participate in Babylon's late weighted bindings when
44
135
  // goToFrame(frame, true) is called. Speed zero alone resets on scene.render().
45
136
  group.start(true, 0);
@@ -60,6 +151,13 @@ export class BabylonMotionPlayer {
60
151
  }
61
152
  if (!this.clips[state.clip])
62
153
  throw new Error('Unknown playback clip');
154
+ const switched = !this.active || state.switched || this.active.clip !== state.clip;
155
+ if (switched && this.inertia) {
156
+ for (const track of this.tracks)
157
+ track.group.dispose();
158
+ this.tracks = [];
159
+ this.active = undefined;
160
+ }
63
161
  if (!this.active || state.switched || this.active.clip !== state.clip) {
64
162
  // Bound simultaneous slots during rapid replanning; residual poses retain relative weights.
65
163
  if (this.tracks.length >= 4) {
@@ -69,7 +167,18 @@ export class BabylonMotionPlayer {
69
167
  this.active = this.create(state.clip, state.time);
70
168
  }
71
169
  this.active.time = state.time;
72
- const alpha = this.fadeSeconds === 0 ? 1 : 1 - Math.exp(-dt * 4 / this.fadeSeconds);
170
+ if (this.inertia) {
171
+ const clip = this.clips[state.clip], h = Math.min(1 / 120, clip.duration / 4);
172
+ const time = clip.looping ? ((state.time % clip.duration) + clip.duration) % clip.duration : Math.max(0, Math.min(state.time, clip.duration));
173
+ const low = clip.looping ? time - h : Math.max(0, time - h), high = clip.looping ? time + h : Math.min(clip.duration, time + h);
174
+ let incomingVelocity = new Map();
175
+ if (switched) {
176
+ const before = this.sampleLocal(this.active, low), after = this.sampleLocal(this.active, high);
177
+ incomingVelocity = new Map([...before].map(([node, pose]) => [node, poseVelocity(pose, after.get(node), high - low)]));
178
+ }
179
+ this.inertia.update(this.sampleLocal(this.active, time), incomingVelocity, dt, switched);
180
+ }
181
+ const alpha = this.inertia || this.fadeSeconds === 0 ? 1 : 1 - Math.exp(-dt * 4 / this.fadeSeconds);
73
182
  for (const track of this.tracks) {
74
183
  if (track !== this.active)
75
184
  track.time += dt;
@@ -79,9 +188,10 @@ export class BabylonMotionPlayer {
79
188
  const total = this.tracks.reduce((sum, t) => sum + t.weight, 0);
80
189
  for (const track of this.tracks) {
81
190
  const descriptor = this.clips[track.clip], group = track.group;
82
- const time = descriptor.looping ? track.time % descriptor.duration : Math.min(track.time, descriptor.duration);
191
+ const time = descriptor.looping ? ((track.time % descriptor.duration) + descriptor.duration) % descriptor.duration : Math.max(0, Math.min(track.time, descriptor.duration));
83
192
  group.setWeightForAllAnimatables(total > 0 ? track.weight / total : (track === this.active ? 1 : 0));
84
- group.goToFrame(group.from + (group.to - group.from) * time / descriptor.duration, true);
193
+ const source = this.sources.get(descriptor.id), fps = source.targetedAnimations[0].animation.framePerSecond;
194
+ group.goToFrame(source.from + ((descriptor.sourceStart ?? 0) + time) * fps, true);
85
195
  }
86
196
  this.tracks = this.tracks.filter(track => {
87
197
  if (track !== this.active && track.weight < 0.001) {
@@ -91,36 +201,81 @@ export class BabylonMotionPlayer {
91
201
  return true;
92
202
  });
93
203
  }
204
+ sampleLocal(track, time) {
205
+ const result = new Map([...this.rest].map(([node, pose]) => [node, { position: pose.position.clone(), scaling: pose.scaling.clone(), rotation: pose.rotation.clone() }]));
206
+ const clip = this.clips[track.clip], source = this.sources.get(clip.id);
207
+ time = clip.looping ? ((time % clip.duration) + clip.duration) % clip.duration : Math.max(0, Math.min(time, clip.duration));
208
+ for (const targeted of track.group.targetedAnimations) {
209
+ const node = targeted.target, pose = result.get(node);
210
+ const [property, component] = targeted.animation.targetProperty.split('.');
211
+ const value = targeted.animation.evaluate(source.from + ((clip.sourceStart ?? 0) + time) * targeted.animation.framePerSecond);
212
+ const target = property === 'rotationQuaternion' ? pose.rotation : property === 'scaling' ? pose.scaling : pose.position;
213
+ if (component)
214
+ target[component] = value;
215
+ else
216
+ target.copyFrom(value);
217
+ }
218
+ return result;
219
+ }
220
+ /** Clear presentation history after teleport/reconciliation; ownership stays until dispose. */
221
+ reset() { this.clear(); }
94
222
  clear() { for (const t of this.tracks)
95
- t.group.dispose(); this.tracks = []; this.active = undefined; }
96
- dispose() { this.clear(); this.disposed = true; }
223
+ t.group.dispose(); this.tracks = []; this.active = undefined; this.inertia?.reset(); }
224
+ dispose() {
225
+ this.clear();
226
+ this.disposed = true;
227
+ this.inertia?.dispose();
228
+ for (const node of this.owned)
229
+ if (nodeOwners.get(node) === this)
230
+ nodeOwners.delete(node);
231
+ this.owned.clear();
232
+ }
97
233
  }
98
- /** Sample AFTER scene animation evaluation. Measures visible feet, including crossfade. */
99
- export class BabylonPoseReader {
234
+ /** Sample AFTER scene animation evaluation. Semantic roles are defined by the schema. */
235
+ export class BabylonSchemaPoseReader {
100
236
  root;
101
- left;
102
- right;
103
237
  previous;
104
- constructor(root, left, right) {
238
+ nodes;
239
+ constructor(root, bones, schema) {
105
240
  this.root = root;
106
- this.left = left;
107
- this.right = right;
241
+ const roles = compileSchema(schema).bones;
242
+ for (const role of roles)
243
+ if (!bones.has(role))
244
+ throw new Error('Missing schema bone node: ' + role);
245
+ this.nodes = new Map(roles.map(role => [role, bones.get(role)]));
108
246
  }
109
247
  reset() { this.previous = undefined; }
110
248
  read(dt) {
111
249
  if (!Number.isFinite(dt) || dt < 0)
112
250
  throw new Error('Invalid pose delta');
113
251
  this.root.computeWorldMatrix(true);
114
- this.left.computeWorldMatrix(true);
115
- this.right.computeWorldMatrix(true);
116
- const root = this.root.getAbsolutePosition().clone(), left = this.left.getAbsolutePosition().clone(), right = this.right.getAbsolutePosition().clone();
252
+ const root = this.root.getAbsolutePosition().clone(), positions = new Map();
253
+ for (const [role, node] of this.nodes) {
254
+ node.computeWorldMatrix(true);
255
+ positions.set(role, node.getAbsolutePosition().clone());
256
+ }
117
257
  const inverse = Matrix.Invert(this.root.getWorldMatrix());
118
258
  // Vector transform through inverse includes inverse model scale: consumers should keep root scale 1.
119
259
  const tuple = (v) => [v.x, v.y, v.z];
120
260
  const velocity = (value, previous) => previous && dt > 0 ? Vector3.TransformNormal(value.subtract(previous).scale(1 / dt), inverse) : Vector3.Zero();
121
- const pose = { leftPosition: tuple(Vector3.TransformCoordinates(left, inverse)), rightPosition: tuple(Vector3.TransformCoordinates(right, inverse)),
122
- leftVelocity: tuple(velocity(left, this.previous?.left)), rightVelocity: tuple(velocity(right, this.previous?.right)), rootVelocity: tuple(velocity(root, this.previous?.root)) };
123
- this.previous = { left, right, root };
261
+ const bones = Object.fromEntries([...positions].map(([role, position]) => [role, {
262
+ position: tuple(Vector3.TransformCoordinates(position, inverse)), velocity: tuple(velocity(position, this.previous?.bones.get(role))),
263
+ }]));
264
+ const pose = { bones, rootVelocity: tuple(velocity(root, this.previous?.root)) };
265
+ this.previous = { bones: positions, root };
124
266
  return pose;
125
267
  }
126
268
  }
269
+ /** Compatibility reader for the original two-foot preset. */
270
+ export class BabylonPoseReader {
271
+ reader;
272
+ constructor(root, left, right) {
273
+ this.reader = new BabylonSchemaPoseReader(root, new Map([['leftFoot', left], ['rightFoot', right]]), LOCOMOTION_SCHEMA);
274
+ }
275
+ reset() { this.reader.reset(); }
276
+ read(dt) {
277
+ const pose = this.reader.read(dt);
278
+ return { leftPosition: pose.bones.leftFoot.position, rightPosition: pose.bones.rightFoot.position,
279
+ leftVelocity: pose.bones.leftFoot.velocity, rightVelocity: pose.bones.rightFoot.velocity, rootVelocity: pose.rootVelocity };
280
+ }
281
+ }
@@ -1,11 +1,24 @@
1
1
  import { PoseSearch } from './search.js';
2
2
  import type { Database, Match, Query } from './types.js';
3
+ import type { SearchReport } from './search.js';
3
4
  export interface Playback {
4
5
  clip: number;
5
6
  time: number;
6
7
  switched: boolean;
7
8
  match: Match | null;
8
9
  }
10
+ export interface MatcherDecision {
11
+ searched: boolean;
12
+ reason: string;
13
+ previousClip: number;
14
+ currentClip: number;
15
+ currentCost: number | null;
16
+ candidateCost: number | null;
17
+ sinceSwitch: number;
18
+ minimumHold: number;
19
+ jumpPenalty: number;
20
+ improvement: number;
21
+ }
9
22
  export interface MatcherOptions {
10
23
  searchInterval: number;
11
24
  minimumHold: number;
@@ -13,6 +26,12 @@ export interface MatcherOptions {
13
26
  jumpPenalty: number;
14
27
  timeSeparation: number;
15
28
  reselectDelay: number;
29
+ /** Active only when the query explicitly requests idle alone. */
30
+ idleVariation?: {
31
+ interval: number;
32
+ historySize: number;
33
+ };
34
+ diagnostics?: boolean;
16
35
  }
17
36
  /** Owns animation time only. Caller retains movement and action authority. */
18
37
  export declare class MotionMatcher {
@@ -26,6 +45,13 @@ export declare class MotionMatcher {
26
45
  private last;
27
46
  private completed;
28
47
  private indices;
48
+ private idleElapsed;
49
+ private idleHistory;
50
+ private effectiveQuery?;
51
+ private decisionState;
52
+ /** Snapshot of the last continuity decision. Query copying requires diagnostics=true. */
53
+ get decision(): MatcherDecision | null;
54
+ inspect(limit?: number): SearchReport | null;
29
55
  constructor(database: Database, options?: Partial<MatcherOptions>);
30
56
  reset(): void;
31
57
  update(dt: number, query: Query): Playback;
@@ -1,4 +1,5 @@
1
1
  import { PoseSearch } from './search.js';
2
+ import { withinWindows } from './selection.js';
2
3
  /** Owns animation time only. Caller retains movement and action authority. */
3
4
  export class MotionMatcher {
4
5
  database;
@@ -11,20 +12,36 @@ export class MotionMatcher {
11
12
  last = null;
12
13
  completed = new Map();
13
14
  indices;
15
+ idleElapsed = 0;
16
+ idleHistory = [];
17
+ effectiveQuery;
18
+ decisionState = null;
19
+ /** Snapshot of the last continuity decision. Query copying requires diagnostics=true. */
20
+ get decision() { return this.decisionState ? { ...this.decisionState } : null; }
21
+ inspect(limit = 5) { return this.options.diagnostics && this.effectiveQuery ? this.search.inspect(this.effectiveQuery, limit) : null; }
14
22
  constructor(database, options = {}) {
15
23
  this.database = database;
16
24
  this.search = new PoseSearch(database);
17
25
  this.options = { searchInterval: 0.1, minimumHold: 0.12, improvement: 0.1, jumpPenalty: 0.5, timeSeparation: 0.2, reselectDelay: 0.6, ...options };
18
- if (!Object.values(this.options).every(v => Number.isFinite(v) && v >= 0) || this.options.searchInterval === 0 || this.options.improvement >= 1)
26
+ const { idleVariation, diagnostics, ...numeric } = this.options;
27
+ if (!Object.values(numeric).every(v => Number.isFinite(v) && v >= 0) || this.options.searchInterval === 0 || this.options.improvement >= 1)
19
28
  throw new Error('Invalid matcher options');
29
+ if (idleVariation && (!Number.isFinite(idleVariation.interval) || idleVariation.interval <= 0 || !Number.isInteger(idleVariation.historySize) || idleVariation.historySize < 1))
30
+ throw new Error('Invalid idle variation policy');
31
+ if (diagnostics !== undefined && typeof diagnostics !== 'boolean')
32
+ throw new Error('Invalid diagnostics option');
20
33
  this.indices = database.manifest.clips.map((_, clip) => database.manifest.frames.flatMap((f, i) => f.clip === clip ? [i] : []).sort((a, b) => database.manifest.frames[a].time - database.manifest.frames[b].time));
21
34
  if (this.indices.some(i => !i.length))
22
35
  throw new Error('Every clip must have indexed samples');
23
36
  }
24
- reset() { this.clip = -1; this.time = 0; this.sinceSearch = this.sinceSwitch = Infinity; this.last = null; this.completed.clear(); }
37
+ reset() { this.clip = -1; this.time = 0; this.sinceSearch = this.sinceSwitch = Infinity; this.last = null; this.completed.clear(); this.idleElapsed = 0; this.idleHistory = []; this.effectiveQuery = undefined; this.decisionState = null; }
25
38
  update(dt, query) {
26
39
  if (!Number.isFinite(dt) || dt < 0)
27
40
  throw new Error('Invalid delta time');
41
+ // Validate identity even while continuity skips a search this frame.
42
+ this.search.validateQuery(query);
43
+ const previousClip = this.clip;
44
+ let reason = 'search-interval', searched = false, currentCost = null, candidateCost = null;
28
45
  let switched = false, ended = false;
29
46
  for (const [clip, remaining] of this.completed) {
30
47
  if (remaining <= dt)
@@ -40,15 +57,47 @@ export class MotionMatcher {
40
57
  }
41
58
  this.sinceSearch += dt;
42
59
  this.sinceSwitch += dt;
43
- const allowed = this.clip >= 0 && !query.excludedClips?.has(this.clip) && (!query.gaits || query.gaits.has(this.database.manifest.clips[this.clip].gait));
60
+ const allowed = this.search.allowsClip(this.clip, query);
61
+ const idleOnly = query.gaits?.size === 1 && query.gaits.has('idle') && !!this.options.idleVariation;
62
+ if (idleOnly && allowed)
63
+ this.idleElapsed += dt;
64
+ else {
65
+ this.idleElapsed = 0;
66
+ this.idleHistory = [];
67
+ }
44
68
  if (this.sinceSearch >= this.options.searchInterval || !allowed || ended) {
69
+ searched = true;
45
70
  this.sinceSearch = 0;
46
71
  // A finished one-shot must yield to another clip, rather than selecting
47
72
  // its terminal pose and restarting forever. User exclusions still apply.
48
73
  if (ended)
49
74
  this.completed.set(this.clip, this.options.reselectDelay);
50
75
  const excludedClips = new Set([...(query.excludedClips ?? []), ...this.completed.keys()]);
51
- const best = this.search.search({ ...query, excludedClips });
76
+ let variation = false;
77
+ let best = null;
78
+ const runSearch = (effective) => {
79
+ // Keep the query that actually produced the candidate, including idle
80
+ // history/completion exclusions. Features are snapshotted for later UI.
81
+ if (this.options.diagnostics)
82
+ this.effectiveQuery = { ...effective, features: effective.features.slice(), context: effective.context ? { ...effective.context } : undefined,
83
+ gaits: effective.gaits ? new Set(effective.gaits) : undefined, excludedClips: effective.excludedClips ? new Set(effective.excludedClips) : undefined };
84
+ return this.search.search(effective);
85
+ };
86
+ if (idleOnly && allowed && !ended) {
87
+ if (this.idleElapsed >= this.options.idleVariation.interval) {
88
+ // Relax oldest history first when the pool is small, but never force
89
+ // a restart of the current clip or relax the consumer's context.
90
+ const history = [...this.idleHistory];
91
+ do {
92
+ best = runSearch({ ...query, excludedClips: new Set([...excludedClips, this.clip, ...history]) });
93
+ } while (!best && history.shift() !== undefined);
94
+ variation = best !== null;
95
+ }
96
+ if (!best)
97
+ best = runSearch({ ...query, excludedClips: new Set([...excludedClips, ...this.database.manifest.clips.flatMap((_, i) => i === this.clip ? [] : [i])]) });
98
+ }
99
+ else
100
+ best = runSearch({ ...query, excludedClips });
52
101
  this.last = best;
53
102
  if (best) {
54
103
  const current = this.clip >= 0 ? this.nearestFrame(this.clip, this.time) : -1;
@@ -58,18 +107,39 @@ export class MotionMatcher {
58
107
  gap = Math.min(gap, duration - gap);
59
108
  const distant = best.clip !== this.clip || gap > this.options.timeSeparation;
60
109
  const better = current < 0 || best.cost + this.options.jumpPenalty < this.search.cost(current) * (1 - this.options.improvement);
61
- if (current < 0 || !allowed || ended || (distant && better && this.sinceSwitch >= this.options.minimumHold)) {
110
+ currentCost = current < 0 ? null : this.search.cost(current);
111
+ candidateCost = best.cost;
112
+ const incompatibleVelocity = current >= 0 && distant && this.sinceSwitch >= this.options.minimumHold && query.velocityTolerance !== undefined
113
+ && this.search.rootVelocityError(current, query) > Math.max(query.velocityTolerance, this.search.rootVelocityError(best.index, query) + 1e-6);
114
+ const mayReselectTime = query.allowTemporalReselection !== false || best.clip !== this.clip || !this.database.manifest.clips[best.clip].looping;
115
+ const mayExit = this.clip < 0 || withinWindows(this.time, this.database.manifest.clips[this.clip].exitWindows);
116
+ const shouldSwitch = current < 0 || !allowed || ended || (mayExit && mayReselectTime && (incompatibleVelocity || (distant && (better || variation) && this.sinceSwitch >= this.options.minimumHold)));
117
+ reason = current < 0 ? 'initial' : !allowed ? 'incompatible-current' : ended ? 'clip-ended' : !mayExit ? 'exit-window' : !mayReselectTime ? 'phase-preserved'
118
+ : incompatibleVelocity ? 'velocity-incompatible' : !distant ? 'nearby-candidate' : this.sinceSwitch < this.options.minimumHold ? 'minimum-hold' : variation ? 'idle-variation' : better ? 'better-candidate' : 'insufficient-improvement';
119
+ if (shouldSwitch) {
62
120
  this.clip = best.clip;
63
121
  this.time = best.time;
64
122
  this.sinceSwitch = 0;
65
123
  switched = true;
124
+ this.idleElapsed = 0;
125
+ if (this.database.manifest.clips[this.clip].gait === 'idle') {
126
+ this.idleHistory.push(this.clip);
127
+ this.idleHistory = this.idleHistory.slice(-(this.options.idleVariation?.historySize ?? 1));
128
+ }
129
+ else
130
+ this.idleHistory = [];
66
131
  }
67
132
  }
68
- else if (!allowed || ended) {
69
- this.clip = -1;
70
- this.time = 0;
133
+ else {
134
+ reason = !allowed || ended ? 'no-candidate-cleared' : 'no-entry-continue';
135
+ if (!allowed || ended) {
136
+ this.clip = -1;
137
+ this.time = 0;
138
+ }
71
139
  }
72
140
  }
141
+ this.decisionState = { searched, reason, previousClip, currentClip: this.clip, currentCost, candidateCost, sinceSwitch: this.sinceSwitch,
142
+ minimumHold: this.options.minimumHold, improvement: this.options.improvement, jumpPenalty: this.options.jumpPenalty };
73
143
  return { clip: this.clip, time: this.time, switched, match: this.last };
74
144
  }
75
145
  nearestFrame(clip, time) {
@@ -1,6 +1,12 @@
1
1
  import { type Database, type DatabaseManifest } from './types.js';
2
+ import { type PoseSchema } from './schema.js';
2
3
  export declare function validateDatabase(db: Database): void;
3
- export declare function buildDatabase(manifest: Omit<DatabaseManifest, 'version' | 'dimensions' | 'coordinates' | 'horizons' | 'mean' | 'std'>, raw: Float32Array): Database;
4
+ type BuildManifest = Omit<DatabaseManifest, 'version' | 'dimensions' | 'schema' | 'schemaSignature' | 'coordinates' | 'horizons' | 'mean' | 'std'>;
5
+ /** Omitting schema selects the legacy biped preset. New data is always v2. */
6
+ export declare function buildDatabase(manifest: BuildManifest, raw: Float32Array, schema?: PoseSchema): Database;
4
7
  /** Binary format is explicitly little endian float32; version/layout live in manifest. */
5
8
  export declare function encodeFeatures(db: Database): ArrayBuffer;
6
- export declare function decodeDatabase(manifest: DatabaseManifest, bytes: ArrayBuffer): Database;
9
+ export declare function decodeDatabase(manifest: unknown, bytes: ArrayBuffer, expectedSchema?: PoseSchema): Database;
10
+ /** Explicit, lossless conversion ONLY for the original v1 27-dimensional preset. */
11
+ export declare function migrateLegacyDatabase(input: unknown, bytes: ArrayBuffer): Database;
12
+ export {};