@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,281 @@
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
+ }
24
+ /** Clone onto a target rig. Two playback slots allow crossfade between times of the same clip. */
25
+ export class BabylonMotionPlayer {
26
+ scene;
27
+ clips;
28
+ sources;
29
+ nodes;
30
+ fadeSeconds;
31
+ rootName;
32
+ tracks = [];
33
+ active;
34
+ disposed = false;
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 = {}) {
41
+ this.scene = scene;
42
+ this.clips = clips;
43
+ this.sources = sources;
44
+ this.nodes = nodes;
45
+ this.fadeSeconds = fadeSeconds;
46
+ this.rootName = rootName;
47
+ if (!Number.isFinite(fadeSeconds) || fadeSeconds < 0)
48
+ throw new Error('Invalid crossfade duration');
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)
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);
100
+ }
101
+ create(clip, time) {
102
+ const source = this.sources.get(this.clips[clip].id);
103
+ const group = new AnimationGroup(`mm-${this.clips[clip].id}-${this.tracks.length}`, this.scene);
104
+ let rootRotation = this.rootRestRotation?.clone();
105
+ for (const targeted of source.targetedAnimations) {
106
+ const node = this.nodes.get(targeted.target.name);
107
+ if (!node || !this.owned.has(node))
108
+ continue;
109
+ const property = targeted.animation.targetProperty;
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
+ }
118
+ group.addTargetedAnimation(targeted.animation, node);
119
+ }
120
+ if (!group.targetedAnimations.length) {
121
+ group.dispose();
122
+ throw new Error('No matching rig animation tracks');
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
+ }
134
+ // Paused groups still participate in Babylon's late weighted bindings when
135
+ // goToFrame(frame, true) is called. Speed zero alone resets on scene.render().
136
+ group.start(true, 0);
137
+ group.pause();
138
+ group.setWeightForAllAnimatables(0);
139
+ const track = { group, clip, time, weight: this.tracks.length === 0 ? 1 : 0 };
140
+ this.tracks.push(track);
141
+ return track;
142
+ }
143
+ update(state, dt) {
144
+ if (this.disposed)
145
+ throw new Error('Player already disposed');
146
+ if (!Number.isFinite(dt) || dt < 0)
147
+ throw new Error('Invalid player delta');
148
+ if (state.clip < 0) {
149
+ this.clear();
150
+ return;
151
+ }
152
+ if (!this.clips[state.clip])
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
+ }
161
+ if (!this.active || state.switched || this.active.clip !== state.clip) {
162
+ // Bound simultaneous slots during rapid replanning; residual poses retain relative weights.
163
+ if (this.tracks.length >= 4) {
164
+ const oldest = this.tracks.shift();
165
+ oldest.group.dispose();
166
+ }
167
+ this.active = this.create(state.clip, state.time);
168
+ }
169
+ this.active.time = state.time;
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);
182
+ for (const track of this.tracks) {
183
+ if (track !== this.active)
184
+ track.time += dt;
185
+ track.weight += ((track === this.active ? 1 : 0) - track.weight) * alpha;
186
+ }
187
+ // First pose must be fully defined even at dt=0.
188
+ const total = this.tracks.reduce((sum, t) => sum + t.weight, 0);
189
+ for (const track of this.tracks) {
190
+ const descriptor = this.clips[track.clip], group = track.group;
191
+ const time = descriptor.looping ? ((track.time % descriptor.duration) + descriptor.duration) % descriptor.duration : Math.max(0, Math.min(track.time, descriptor.duration));
192
+ group.setWeightForAllAnimatables(total > 0 ? track.weight / total : (track === this.active ? 1 : 0));
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);
195
+ }
196
+ this.tracks = this.tracks.filter(track => {
197
+ if (track !== this.active && track.weight < 0.001) {
198
+ track.group.dispose();
199
+ return false;
200
+ }
201
+ return true;
202
+ });
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(); }
222
+ clear() { for (const t of this.tracks)
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
+ }
233
+ }
234
+ /** Sample AFTER scene animation evaluation. Semantic roles are defined by the schema. */
235
+ export class BabylonSchemaPoseReader {
236
+ root;
237
+ previous;
238
+ nodes;
239
+ constructor(root, bones, schema) {
240
+ this.root = root;
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)]));
246
+ }
247
+ reset() { this.previous = undefined; }
248
+ read(dt) {
249
+ if (!Number.isFinite(dt) || dt < 0)
250
+ throw new Error('Invalid pose delta');
251
+ this.root.computeWorldMatrix(true);
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
+ }
257
+ const inverse = Matrix.Invert(this.root.getWorldMatrix());
258
+ // Vector transform through inverse includes inverse model scale: consumers should keep root scale 1.
259
+ const tuple = (v) => [v.x, v.y, v.z];
260
+ const velocity = (value, previous) => previous && dt > 0 ? Vector3.TransformNormal(value.subtract(previous).scale(1 / dt), inverse) : Vector3.Zero();
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 };
266
+ return pose;
267
+ }
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
+ }
@@ -0,0 +1,162 @@
1
+ import { PoseSearch } from './search.js';
2
+ import { withinWindows } from './selection.js';
3
+ /** Owns animation time only. Caller retains movement and action authority. */
4
+ export class MotionMatcher {
5
+ database;
6
+ search;
7
+ options;
8
+ clip = -1;
9
+ time = 0;
10
+ sinceSearch = Infinity;
11
+ sinceSwitch = Infinity;
12
+ last = null;
13
+ completed = new Map();
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; }
22
+ constructor(database, options = {}) {
23
+ this.database = database;
24
+ this.search = new PoseSearch(database);
25
+ this.options = { searchInterval: 0.1, minimumHold: 0.12, improvement: 0.1, jumpPenalty: 0.5, timeSeparation: 0.2, reselectDelay: 0.6, ...options };
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)
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');
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));
34
+ if (this.indices.some(i => !i.length))
35
+ throw new Error('Every clip must have indexed samples');
36
+ }
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; }
38
+ update(dt, query) {
39
+ if (!Number.isFinite(dt) || dt < 0)
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;
45
+ let switched = false, ended = false;
46
+ for (const [clip, remaining] of this.completed) {
47
+ if (remaining <= dt)
48
+ this.completed.delete(clip);
49
+ else
50
+ this.completed.set(clip, remaining - dt);
51
+ }
52
+ if (this.clip >= 0) {
53
+ const clip = this.database.manifest.clips[this.clip];
54
+ this.time += dt;
55
+ ended = !clip.looping && this.time >= clip.duration;
56
+ this.time = clip.looping ? this.time % clip.duration : Math.min(this.time, clip.duration);
57
+ }
58
+ this.sinceSearch += dt;
59
+ this.sinceSwitch += dt;
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
+ }
68
+ if (this.sinceSearch >= this.options.searchInterval || !allowed || ended) {
69
+ searched = true;
70
+ this.sinceSearch = 0;
71
+ // A finished one-shot must yield to another clip, rather than selecting
72
+ // its terminal pose and restarting forever. User exclusions still apply.
73
+ if (ended)
74
+ this.completed.set(this.clip, this.options.reselectDelay);
75
+ const excludedClips = new Set([...(query.excludedClips ?? []), ...this.completed.keys()]);
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 });
101
+ this.last = best;
102
+ if (best) {
103
+ const current = this.clip >= 0 ? this.nearestFrame(this.clip, this.time) : -1;
104
+ const duration = this.database.manifest.clips[best.clip].duration;
105
+ let gap = Math.abs(best.time - this.time);
106
+ if (best.clip === this.clip && this.database.manifest.clips[best.clip].looping)
107
+ gap = Math.min(gap, duration - gap);
108
+ const distant = best.clip !== this.clip || gap > this.options.timeSeparation;
109
+ const better = current < 0 || best.cost + this.options.jumpPenalty < this.search.cost(current) * (1 - this.options.improvement);
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) {
120
+ this.clip = best.clip;
121
+ this.time = best.time;
122
+ this.sinceSwitch = 0;
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 = [];
131
+ }
132
+ }
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
+ }
139
+ }
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 };
143
+ return { clip: this.clip, time: this.time, switched, match: this.last };
144
+ }
145
+ nearestFrame(clip, time) {
146
+ const indices = this.indices[clip];
147
+ if (!indices?.length)
148
+ return -1;
149
+ const { frames, clips } = this.database.manifest;
150
+ let best = indices[0], distance = Infinity;
151
+ for (const i of indices) {
152
+ let delta = Math.abs(frames[i].time - time);
153
+ if (clips[clip].looping)
154
+ delta = Math.min(delta, clips[clip].duration - delta);
155
+ if (delta < distance) {
156
+ best = i;
157
+ distance = delta;
158
+ }
159
+ }
160
+ return best;
161
+ }
162
+ }
@@ -0,0 +1,97 @@
1
+ import { compileSchema, LOCOMOTION_SCHEMA } from './schema.js';
2
+ import { parseRootOrientation } from './profile.js';
3
+ import { validateSelection } from './selection.js';
4
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
5
+ export function validateDatabase(db) {
6
+ const m = db?.manifest;
7
+ if (!record(m) || m.version !== 2)
8
+ throw new Error('Unsupported pose database version; v1 requires migrateLegacyDatabase or a new bake');
9
+ const layout = compileSchema(m.schema), dimensions = layout.dimensions;
10
+ if (m.dimensions !== dimensions || m.schemaSignature !== layout.signature || m.coordinates !== 'meters-y-up-z-forward'
11
+ || !Array.isArray(m.horizons) || m.horizons.length !== layout.horizons.length || m.horizons.some((v, i) => v !== layout.horizons[i]))
12
+ throw new Error('Incompatible pose database schema');
13
+ if (!Number.isFinite(m.sampleRate) || m.sampleRate <= 0 || !Array.isArray(m.frames) || !m.frames.length || !Array.isArray(m.clips) || !m.clips.length
14
+ || !(db.features instanceof Float32Array) || db.features.length !== m.frames.length * dimensions)
15
+ throw new Error('Invalid database size/sample rate');
16
+ if (!Array.isArray(m.mean) || !Array.isArray(m.std) || m.mean.length !== dimensions || m.std.length !== dimensions || ![...m.mean].every(Number.isFinite)
17
+ || ![...m.std].every(v => Number.isFinite(v) && v >= 1e-4) || !db.features.every(Number.isFinite))
18
+ throw new Error('Invalid normalization/features');
19
+ for (const { channel, offset, dimensions: width } of layout.channels)
20
+ if (channel.normalization === 'none') {
21
+ for (let j = offset; j < offset + width; j++)
22
+ if (m.mean[j] !== 0 || m.std[j] !== 1)
23
+ throw new Error('Invalid unnormalized channel statistics');
24
+ }
25
+ if (m.assetHashes !== undefined && (!record(m.assetHashes) || !Object.entries(m.assetHashes).length || !Object.entries(m.assetHashes).every(([url, hash]) => url.startsWith('/') && typeof hash === 'string' && /^[a-f0-9]{64}$/.test(hash))))
26
+ throw new Error('Invalid asset hashes');
27
+ const ids = new Set();
28
+ for (const clip of m.clips) {
29
+ if (!record(clip) || typeof clip.id !== 'string' || ids.has(clip.id) || !clip.id || typeof clip.file !== 'string' || !clip.file
30
+ || typeof clip.animation !== 'string' || !clip.animation || !Number.isFinite(clip.duration) || clip.duration <= 0 || typeof clip.looping !== 'boolean'
31
+ || !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(clip.gait))
32
+ throw new Error('Invalid clip');
33
+ ids.add(clip.id);
34
+ validateSelection(clip, clip.duration);
35
+ if (clip.sourceStart !== undefined && (!Number.isFinite(clip.sourceStart) || clip.sourceStart < 0))
36
+ throw new Error('Invalid source start');
37
+ if (clip.averageVelocity !== undefined && (!Array.isArray(clip.averageVelocity) || clip.averageVelocity.length !== 3 || !clip.averageVelocity.every(Number.isFinite)))
38
+ throw new Error('Invalid measured average velocity');
39
+ if (clip.rootOrientation !== undefined)
40
+ parseRootOrientation(clip.rootOrientation);
41
+ }
42
+ for (const frame of m.frames) {
43
+ if (!record(frame) || !Number.isInteger(frame.clip) || !m.clips[frame.clip] || !Number.isFinite(frame.time)
44
+ || frame.time < 0 || frame.time > m.clips[frame.clip].duration + 1e-6)
45
+ throw new Error('Invalid pose metadata');
46
+ }
47
+ }
48
+ /** Omitting schema selects the legacy biped preset. New data is always v2. */
49
+ export function buildDatabase(manifest, raw, schema = LOCOMOTION_SCHEMA) {
50
+ const layout = compileSchema(schema), dimensions = layout.dimensions, count = manifest.frames.length;
51
+ if (!count || raw.length !== count * dimensions || !raw.every(Number.isFinite))
52
+ throw new Error('Invalid raw samples');
53
+ const mean = Array(dimensions).fill(0), std = Array(dimensions).fill(0);
54
+ for (let i = 0; i < raw.length; i++)
55
+ mean[i % dimensions] += raw[i] / count;
56
+ for (let i = 0; i < raw.length; i++)
57
+ std[i % dimensions] += (raw[i] - mean[i % dimensions]) ** 2 / count;
58
+ for (let j = 0; j < dimensions; j++)
59
+ std[j] = Math.sqrt(std[j]) < 1e-4 ? 1 : Math.sqrt(std[j]);
60
+ for (const { channel, offset, dimensions: width } of layout.channels)
61
+ if (channel.normalization === 'none') {
62
+ mean.fill(0, offset, offset + width);
63
+ std.fill(1, offset, offset + width);
64
+ }
65
+ const features = Float32Array.from(raw, (v, i) => (v - mean[i % dimensions]) / std[i % dimensions]);
66
+ const db = { manifest: { ...manifest, version: 2, dimensions, schema: layout.schema, schemaSignature: layout.signature,
67
+ coordinates: 'meters-y-up-z-forward', horizons: layout.horizons, mean, std }, features };
68
+ validateDatabase(db);
69
+ return db;
70
+ }
71
+ /** Binary format is explicitly little endian float32; version/layout live in manifest. */
72
+ export function encodeFeatures(db) {
73
+ validateDatabase(db);
74
+ const bytes = new ArrayBuffer(db.features.length * 4), view = new DataView(bytes);
75
+ db.features.forEach((v, i) => view.setFloat32(i * 4, v, true));
76
+ return bytes;
77
+ }
78
+ export function decodeDatabase(manifest, bytes, expectedSchema) {
79
+ if (bytes.byteLength % 4)
80
+ throw new Error('Truncated feature binary');
81
+ const view = new DataView(bytes), features = new Float32Array(bytes.byteLength / 4);
82
+ for (let i = 0; i < features.length; i++)
83
+ features[i] = view.getFloat32(i * 4, true);
84
+ const db = { manifest: manifest, features };
85
+ validateDatabase(db);
86
+ if (expectedSchema && db.manifest.schemaSignature !== compileSchema(expectedSchema).signature)
87
+ throw new Error('Incompatible expected schema; regenerate database');
88
+ return db;
89
+ }
90
+ /** Explicit, lossless conversion ONLY for the original v1 27-dimensional preset. */
91
+ export function migrateLegacyDatabase(input, bytes) {
92
+ const layout = compileSchema(LOCOMOTION_SCHEMA);
93
+ if (!record(input) || input.version !== 1 || input.dimensions !== layout.dimensions || !Array.isArray(input.horizons)
94
+ || input.horizons.length !== layout.horizons.length || input.horizons.some((h, i) => h !== layout.horizons[i]))
95
+ throw new Error('Unsupported legacy database schema');
96
+ return decodeDatabase({ ...input, version: 2, schema: layout.schema, schemaSignature: layout.signature }, bytes);
97
+ }
@@ -0,0 +1,41 @@
1
+ import { DIMENSIONS, HORIZONS } from './types.js';
2
+ import { SchemaQueryBuilder, validateHorizons } from './schema.js';
3
+ const legacyBuilder = new SchemaQueryBuilder();
4
+ export function featureVector(pose, trajectory, out = new Float32Array(DIMENSIONS)) {
5
+ return legacyBuilder.features({ rootVelocity: pose.rootVelocity, bones: {
6
+ leftFoot: { position: pose.leftPosition, velocity: pose.leftVelocity },
7
+ rightFoot: { position: pose.rightPosition, velocity: pose.rightVelocity },
8
+ } }, trajectory, out);
9
+ }
10
+ /** World delta to current character-local XZ (yaw zero looks along +Z). */
11
+ export function toLocal(x, z, yaw) {
12
+ const c = Math.cos(yaw), s = Math.sin(yaw);
13
+ return [c * x - s * z, s * x + c * z];
14
+ }
15
+ /** Prediction only: never writes to the gameplay controller. Uses fixed substeps. */
16
+ export function predictTrajectory(input, horizons = HORIZONS) {
17
+ validateHorizons(horizons);
18
+ if (![...input.velocity, ...input.desiredVelocity, input.yaw, input.desiredYaw, input.acceleration, input.braking, input.turnSpeed].every(Number.isFinite)
19
+ || Math.min(input.acceleration, input.braking, input.turnSpeed) < 0)
20
+ throw new Error('Invalid trajectory input');
21
+ let [vx, vz] = input.velocity, x = 0, z = 0, yaw = input.yaw, elapsed = 0;
22
+ const points = [];
23
+ for (const horizon of horizons) {
24
+ while (elapsed < horizon - 1e-9) {
25
+ const dt = Math.min(1 / 60, horizon - elapsed);
26
+ const dx = input.desiredVelocity[0] - vx, dz = input.desiredVelocity[1] - vz, length = Math.hypot(dx, dz);
27
+ const rate = Math.hypot(...input.desiredVelocity) < Math.hypot(vx, vz) ? input.braking : input.acceleration;
28
+ const amount = length > 0 ? Math.min(1, rate * dt / length) : 0;
29
+ const nx = vx + dx * amount, nz = vz + dz * amount;
30
+ x += (vx + nx) * 0.5 * dt;
31
+ z += (vz + nz) * 0.5 * dt;
32
+ vx = nx;
33
+ vz = nz;
34
+ const angle = Math.atan2(Math.sin(input.desiredYaw - yaw), Math.cos(input.desiredYaw - yaw));
35
+ yaw += Math.sign(angle) * Math.min(Math.abs(angle), input.turnSpeed * dt);
36
+ elapsed += dt;
37
+ }
38
+ points.push({ position: toLocal(x, z, input.yaw), facing: [Math.sin(yaw - input.yaw), Math.cos(yaw - input.yaw)] });
39
+ }
40
+ return points;
41
+ }
@@ -0,0 +1,15 @@
1
+ /** Critically damped residual. With zero initial velocity, offset halves at halfLife.
2
+ * Does not integrate gameplay/root displacement. */
3
+ export function decayInertialOffset(offset, velocity, elapsed, halfLife) {
4
+ if (![offset, velocity, elapsed, halfLife].every(Number.isFinite) || elapsed < 0 || halfLife <= 0)
5
+ throw new Error('Invalid inertial residual');
6
+ if (elapsed === 0)
7
+ return offset;
8
+ const exponent = (1.67834699001666 / halfLife) * elapsed;
9
+ if (exponent > 745)
10
+ return 0;
11
+ const decay = Math.exp(-exponent);
12
+ // Reassociate products: avoid Infinity * 0 after a long pause, and avoid
13
+ // overflowing omega*offset while the final damped value is representable.
14
+ return offset * ((1 + exponent) * decay) + velocity * (elapsed * decay);
15
+ }