@pose_flow/babylon 0.1.0 → 0.2.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 (40) hide show
  1. package/README.md +123 -105
  2. package/dist/AI_USAGE.md +291 -0
  3. package/dist/PUBLISHING.md +108 -0
  4. package/dist/babylon.d.ts +13 -4
  5. package/dist/babylon.js +96 -21
  6. package/dist/core/controller.js +6 -1
  7. package/dist/core/database.d.ts +8 -2
  8. package/dist/core/database.js +60 -24
  9. package/dist/core/features.d.ts +1 -1
  10. package/dist/core/features.js +9 -11
  11. package/dist/core/profile.d.ts +23 -6
  12. package/dist/core/profile.js +35 -10
  13. package/dist/core/root-motion.d.ts +5 -3
  14. package/dist/core/root-motion.js +43 -7
  15. package/dist/core/schema.d.ts +56 -0
  16. package/dist/core/schema.js +123 -0
  17. package/dist/core/search.d.ts +8 -2
  18. package/dist/core/search.js +59 -12
  19. package/dist/core/types.d.ts +21 -3
  20. package/dist/core/types.js +1 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/offline/src/babylon.js +201 -0
  24. package/dist/offline/src/core/controller.js +97 -0
  25. package/dist/offline/src/core/database.js +95 -0
  26. package/dist/offline/src/core/features.js +41 -0
  27. package/dist/offline/src/core/profile.js +89 -0
  28. package/dist/offline/src/core/root-motion.js +78 -0
  29. package/dist/offline/src/core/schema.js +123 -0
  30. package/dist/offline/src/core/search.js +97 -0
  31. package/dist/offline/src/core/types.js +3 -0
  32. package/dist/offline/src/index.js +8 -0
  33. package/dist/offline/tools/bake.js +86 -0
  34. package/dist/offline/tools/cli.js +30 -0
  35. package/dist/offline/tools/glb.js +54 -0
  36. package/dist/offline/tools/prepare.js +165 -0
  37. package/dist/offline/tools/profile-assets.js +90 -0
  38. package/dist/offline/tools/rig.js +47 -0
  39. package/dist/offline/tools/validate-assets.js +43 -0
  40. package/package.json +48 -39
@@ -0,0 +1,108 @@
1
+ # Publicar @pose_flow/babylon no npm
2
+
3
+ Fonte deste documento: `docs/PUBLISHING.md`. O build gera `dist/PUBLISHING.md`, incluído no pacote npm. Atualizar o documento de origem, nunca a cópia.
4
+
5
+ ## Preparar uma atualização
6
+
7
+ Executar na raiz do repositório da biblioteca. Os comandos abaixo não pertencem ao projeto consumidor.
8
+
9
+ ```powershell
10
+ Set-Location D:\motion_match
11
+ npm ci
12
+ npm run typecheck
13
+ npm test
14
+ ```
15
+
16
+ Antes da versão, atualizar `docs/AI_USAGE.md` para toda implementação/correção e este documento se o processo mudar. Não prosseguir se algum comando falhar.
17
+
18
+ ## Aumentar a versão
19
+
20
+ ### Marco planejado de estabilidade 1.0.0
21
+
22
+ O roadmap do repositório exige uma release estável 1.0.0 como entrega final. A série atual 0.1.x não recebe essa classificação por esta atualização documental. Não aumentar a versão agora por causa do planejamento.
23
+
24
+ Antes da 1.0.0, concluir os critérios de docs/ROADMAP.md, preparar candidatos 1.0.0-rc.N e verificar instalação, ferramentas offline e reprodução em consumidor independente. Registrar evidências, matriz de suporte, metas/resultados de desempenho, notas da versão e migração dos contratos 0.x. Defeitos críticos ou de alta severidade no escopo suportado bloqueiam a release. Executar as verificações abaixo para o pacote candidato e novamente para o pacote final; conferir a versão exata instalada, sem presumir que latest aponta para um candidato.
25
+
26
+ Após aprovação dos critérios, a entrega final deve ser 1.0.0, em vez de aplicar automaticamente o incremento patch descrito para manutenção de 0.x. Publicar somente mediante pedido explícito do mantenedor e conferir instalação da versão publicada. Após 1.0.0, preservar contratos em patch/minor; quebras exigem major e guia de migração. A topologia dos pacotes/CLI será definida na etapa de distribuição; os arquivos e comandos atuais abaixo descrevem somente o pacote existente.
27
+
28
+ ### Mudança de formatos na Fase 1 (ainda sem publicação)
29
+
30
+ O código local usa banco/perfil v2 e schema v1. Antes da próxima release, escolher uma versão 0.x apropriada à quebra de contrato e publicar as instruções de migração incluídas em AI_USAGE.md; não tratar esta entrega como simples correção patch. A versão em package.json não foi aumentada nesta implementação. Esse era o conteúdo na Fase 1. A Fase 2 abaixo inclui CLI offline; JSON externos e bancos continuam fora do pacote.
31
+
32
+ ### Manutenção da série atual
33
+
34
+ A publicação de 0.1.0 foi informada pelo mantenedor. Versões já publicadas não podem ser sobrescritas. A inclusão destes documentos exige uma nova publicação para chegar aos consumidores.
35
+
36
+ Para uma correção ou atualização de documentação, executar **uma única vez**:
37
+
38
+ ```powershell
39
+ npm version patch --no-git-tag-version
40
+ ```
41
+
42
+ Se a versão local ainda for 0.1.0, isso gera 0.1.1 e atualiza package.json/package-lock.json, sem criar commit ou tag. Não repetir o comando se já aumentou a versão para essa entrega. Para funcionalidade compatível, avaliar `npm version minor --no-git-tag-version`; para quebra de contrato, planejar a versão e a migração explicitamente, especialmente enquanto a lib estiver em 0.x.
43
+
44
+ ## Build e inspeção
45
+
46
+ ```powershell
47
+ npm run build
48
+ npm run check:docs
49
+ npm pack --dry-run --ignore-scripts
50
+ ```
51
+
52
+ Conferir a versão e os arquivos listados: biblioteca JS/tipos em dist/, dist/AI_USAGE.md, dist/PUBLISHING.md, README.md, LICENSE e package.json. Personagens, animações, bancos, perfis de exemplo, testes e fontes TypeScript não entram no pacote. Ferramentas compiladas e seu runtime interno em dist/offline/ acompanham a CLI.
53
+
54
+ O `--ignore-scripts` acima é usado **somente após o build explícito**, para inspecionar sem compilá-lo novamente. No fluxo normal de `npm pack` e `npm publish`, o `prepack` executa o build e copia a documentação atual. Não usar `--ignore-scripts` na publicação normal.
55
+
56
+ Para gerar um .tgz local e testar a instalação antes de publicar:
57
+
58
+ ```powershell
59
+ npm pack
60
+ ```
61
+
62
+ No projeto de teste, instalar o caminho do .tgz exibido pelo comando anterior. Não assumir o nome/versão do arquivo. Testar imports do núcleo/adaptador e localizar o tutorial em node_modules/@pose_flow/babylon/dist/AI_USAGE.md.
63
+
64
+ ## Autenticar e publicar
65
+
66
+ ```powershell
67
+ npm login
68
+ npm whoami
69
+ npm publish --access public
70
+ ```
71
+
72
+ Verificar a conta e a permissão no escopo `pose_flow`. Concluir a autenticação de dois fatores solicitada pelo npm. Não armazenar senha, token ou códigos de recuperação no repositório/documentos. Uma IA só deve executar a publicação se o mantenedor pedir essa ação explicitamente; a autenticação interativa é feita pelo mantenedor.
73
+
74
+ Depois, conferir o registro:
75
+
76
+ ```powershell
77
+ npm view @pose_flow/babylon version
78
+ ```
79
+
80
+ No projeto consumidor:
81
+
82
+ ```powershell
83
+ npm install @pose_flow/babylon@latest
84
+ ```
85
+
86
+ Conferir a versão efetivamente instalada no lockfile e ler o tutorial daquela versão. Não copiar dist/ manualmente para node_modules.
87
+
88
+ ## Erros comuns
89
+
90
+ - E403 com mensagem de 2FA: conferir 2FA da conta e autenticar novamente. Não é erro do conteúdo do tarball.
91
+ - Sem permissão no escopo: conferir acesso à organização pose_flow e nome do pacote.
92
+ - Versão já existente: consultar a versão publicada e preparar uma versão nova; não tentar sobrescrever.
93
+ - Tutorial ausente/desatualizado: editar docs/AI_USAGE.md, executar build e check:docs, inspecionar o pacote.
94
+
95
+ ## Referências oficiais
96
+
97
+ - [npm publish](https://docs.npmjs.com/cli/v11/commands/npm-publish/): publicação e versões imutáveis.
98
+ - [npm version](https://docs.npmjs.com/cli/v11/commands/npm-version/): incremento de versão e opção sem tag Git.
99
+ - [Lifecycle scripts](https://docs.npmjs.com/cli/v11/using-npm/scripts/): prepack.
100
+ - [Configurar 2FA](https://docs.npmjs.com/configuring-two-factor-authentication/).
101
+
102
+ ## CLI offline — Fase 2, sem publicação
103
+
104
+ O build agora executa também `tsc -p tsconfig.offline.json`. `bin.pose-flow` aponta para `dist/offline/tools/cli.js`; `dist/offline/src/` contém o runtime interno das ferramentas. Os exports públicos permanecem dist/index.js e dist/babylon.js. Não importar caminhos internos da CLI como API.
105
+
106
+ `@babylonjs/loaders@7.54.3` passou de devDependency para dependency para permitir inspeção/bake após instalar. Babylon core continua peerDependency. Lockfile atualizado; versão não alterada. Conferir CLI/runtime interno e ambos os guias no dry-run. Testar instalação em diretório independente e executar `npx --no-install pose-flow validate --profile public/profiles/rig.json` e `npx --no-install pose-flow bake --profile public/profiles/rig.json`. O teste deve ficar fora do MMORPG protegido.
107
+
108
+ A CLI agora inclui `prepare --config preparation.json`, com os módulos compilados prepare/glb/rig. Conferir esses módulos no pacote. Os scripts Python de conversão e o experimento Blender não são distribuídos; Blender não é dependência do runtime nem da CLI GLB. Testar também prepare → validate → bake no pacote instalado com GLB de entrada do consumidor. `--check-bind` é opcional e exige dados de skin/bind suportados. O pacote continua sem assets, perfis, bancos ou publicação automática.
package/dist/babylon.d.ts CHANGED
@@ -1,5 +1,6 @@
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';
3
4
  /** Clone onto a target rig. Two playback slots allow crossfade between times of the same clip. */
4
5
  export declare class BabylonMotionPlayer {
5
6
  private scene;
@@ -11,18 +12,26 @@ export declare class BabylonMotionPlayer {
11
12
  private tracks;
12
13
  private active?;
13
14
  private disposed;
15
+ private strippedRoot?;
16
+ private rootRestRotation?;
14
17
  constructor(scene: Scene, clips: readonly Clip[], sources: ReadonlyMap<string, AnimationGroup>, nodes: ReadonlyMap<string, TransformNode>, fadeSeconds?: number, rootName?: string);
15
18
  private create;
16
19
  update(state: Playback, dt: number): void;
17
20
  private clear;
18
21
  dispose(): void;
19
22
  }
20
- /** Sample AFTER scene animation evaluation. Measures visible feet, including crossfade. */
21
- export declare class BabylonPoseReader {
23
+ /** Sample AFTER scene animation evaluation. Semantic roles are defined by the schema. */
24
+ export declare class BabylonSchemaPoseReader {
22
25
  private root;
23
- private left;
24
- private right;
25
26
  private previous?;
27
+ private nodes;
28
+ constructor(root: TransformNode, bones: ReadonlyMap<string, TransformNode>, schema: PoseSchema);
29
+ reset(): void;
30
+ read(dt: number): SchemaPose;
31
+ }
32
+ /** Compatibility reader for the original two-foot preset. */
33
+ export declare class BabylonPoseReader {
34
+ private reader;
26
35
  constructor(root: TransformNode, left: TransformNode, right: TransformNode);
27
36
  reset(): void;
28
37
  read(dt: number): Pose;
package/dist/babylon.js CHANGED
@@ -1,4 +1,24 @@
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
+ /** Constant authoring orientation belongs to the pose; varying root heading belongs to gameplay. */
5
+ function constantRootRotation(animation) {
6
+ const keys = animation.getKeys();
7
+ if (!keys.length || keys.some(k => k.inTangent !== undefined || k.outTangent !== undefined))
8
+ return undefined;
9
+ const rotation = (value) => animation.targetProperty === 'rotationQuaternion' && value instanceof Quaternion ? value.clone()
10
+ : animation.targetProperty === 'rotation' && value instanceof Vector3 ? Quaternion.FromEulerVector(value) : undefined;
11
+ const first = rotation(keys[0].value);
12
+ if (!first || !first.asArray().every(Number.isFinite) || first.length() < 1e-8)
13
+ return undefined;
14
+ first.normalize();
15
+ for (const key of keys) {
16
+ const q = rotation(key.value);
17
+ if (!q || !q.asArray().every(Number.isFinite) || q.length() < 1e-8 || Math.abs(Quaternion.Dot(first, q.normalize())) < 1 - 1e-8)
18
+ return undefined;
19
+ }
20
+ return first;
21
+ }
2
22
  /** Clone onto a target rig. Two playback slots allow crossfade between times of the same clip. */
3
23
  export class BabylonMotionPlayer {
4
24
  scene;
@@ -10,6 +30,8 @@ export class BabylonMotionPlayer {
10
30
  tracks = [];
11
31
  active;
12
32
  disposed = false;
33
+ strippedRoot;
34
+ rootRestRotation;
13
35
  constructor(scene, clips, sources, nodes, fadeSeconds = 0.15, rootName = 'root') {
14
36
  this.scene = scene;
15
37
  this.clips = clips;
@@ -19,27 +41,60 @@ export class BabylonMotionPlayer {
19
41
  this.rootName = rootName;
20
42
  if (!Number.isFinite(fadeSeconds) || fadeSeconds < 0)
21
43
  throw new Error('Invalid crossfade duration');
22
- for (const clip of clips)
23
- if (!sources.has(clip.id))
44
+ for (const clip of clips) {
45
+ const source = sources.get(clip.id);
46
+ if (!source)
24
47
  throw new Error(`Missing animation source: ${clip.id}`);
48
+ const fps = source.targetedAnimations[0]?.animation.framePerSecond, start = clip.sourceStart ?? 0;
49
+ if (!Number.isFinite(start) || start < 0 || !Number.isFinite(clip.duration) || clip.duration <= 0 || !fps
50
+ || start + clip.duration > (source.to - source.from) / fps + 1e-5)
51
+ throw new Error(`Invalid source range: ${clip.id}`);
52
+ }
53
+ this.strippedRoot = [...nodes.values()].find(n => n.name === rootName);
54
+ if (this.strippedRoot) {
55
+ this.strippedRoot.rotationQuaternion ??= Quaternion.FromEulerVector(this.strippedRoot.rotation);
56
+ this.rootRestRotation = this.strippedRoot.rotationQuaternion.clone();
57
+ }
58
+ for (const clip of clips)
59
+ if (clip.rootOrientation !== undefined) {
60
+ parseRootOrientation(clip.rootOrientation);
61
+ if (!this.strippedRoot)
62
+ throw new Error('rootOrientation requires a mapped stripped root');
63
+ }
25
64
  }
26
65
  create(clip, time) {
27
66
  const source = this.sources.get(this.clips[clip].id);
28
67
  const group = new AnimationGroup(`mm-${this.clips[clip].id}-${this.tracks.length}`, this.scene);
68
+ let rootRotation = this.rootRestRotation?.clone();
29
69
  for (const targeted of source.targetedAnimations) {
30
70
  const node = this.nodes.get(targeted.target.name);
31
71
  if (!node)
32
72
  continue;
33
73
  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;
74
+ if (node === this.strippedRoot) {
75
+ if (property === 'position' || property.startsWith('position.'))
76
+ continue;
77
+ if (property === 'rotationQuaternion' || property === 'rotation' || property.startsWith('rotation.') || property.startsWith('rotationQuaternion.')) {
78
+ rootRotation = constantRootRotation(targeted.animation) ?? rootRotation;
79
+ continue;
80
+ }
81
+ }
37
82
  group.addTargetedAnimation(targeted.animation, node);
38
83
  }
39
84
  if (!group.targetedAnimations.length) {
40
85
  group.dispose();
41
86
  throw new Error('No matching rig animation tracks');
42
87
  }
88
+ const correction = this.clips[clip].rootOrientation;
89
+ if (correction)
90
+ rootRotation = Quaternion.FromArray([...correction]).normalize();
91
+ if (this.strippedRoot && rootRotation) {
92
+ // An explicit constant binding also restores the baseline after a clip with
93
+ // another orientation, and participates in the same weighted crossfade.
94
+ const rotation = new Animation('mm-root-pose', 'rotationQuaternion', source.targetedAnimations[0].animation.framePerSecond, Animation.ANIMATIONTYPE_QUATERNION);
95
+ rotation.setKeys([{ frame: source.from, value: rootRotation }, { frame: source.to, value: rootRotation.clone() }]);
96
+ group.addTargetedAnimation(rotation, this.strippedRoot);
97
+ }
43
98
  // Paused groups still participate in Babylon's late weighted bindings when
44
99
  // goToFrame(frame, true) is called. Speed zero alone resets on scene.render().
45
100
  group.start(true, 0);
@@ -79,9 +134,10 @@ export class BabylonMotionPlayer {
79
134
  const total = this.tracks.reduce((sum, t) => sum + t.weight, 0);
80
135
  for (const track of this.tracks) {
81
136
  const descriptor = this.clips[track.clip], group = track.group;
82
- const time = descriptor.looping ? track.time % descriptor.duration : Math.min(track.time, descriptor.duration);
137
+ const time = descriptor.looping ? ((track.time % descriptor.duration) + descriptor.duration) % descriptor.duration : Math.max(0, Math.min(track.time, descriptor.duration));
83
138
  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);
139
+ const source = this.sources.get(descriptor.id), fps = source.targetedAnimations[0].animation.framePerSecond;
140
+ group.goToFrame(source.from + ((descriptor.sourceStart ?? 0) + time) * fps, true);
85
141
  }
86
142
  this.tracks = this.tracks.filter(track => {
87
143
  if (track !== this.active && track.weight < 0.001) {
@@ -95,32 +151,51 @@ export class BabylonMotionPlayer {
95
151
  t.group.dispose(); this.tracks = []; this.active = undefined; }
96
152
  dispose() { this.clear(); this.disposed = true; }
97
153
  }
98
- /** Sample AFTER scene animation evaluation. Measures visible feet, including crossfade. */
99
- export class BabylonPoseReader {
154
+ /** Sample AFTER scene animation evaluation. Semantic roles are defined by the schema. */
155
+ export class BabylonSchemaPoseReader {
100
156
  root;
101
- left;
102
- right;
103
157
  previous;
104
- constructor(root, left, right) {
158
+ nodes;
159
+ constructor(root, bones, schema) {
105
160
  this.root = root;
106
- this.left = left;
107
- this.right = right;
161
+ const roles = compileSchema(schema).bones;
162
+ for (const role of roles)
163
+ if (!bones.has(role))
164
+ throw new Error('Missing schema bone node: ' + role);
165
+ this.nodes = new Map(roles.map(role => [role, bones.get(role)]));
108
166
  }
109
167
  reset() { this.previous = undefined; }
110
168
  read(dt) {
111
169
  if (!Number.isFinite(dt) || dt < 0)
112
170
  throw new Error('Invalid pose delta');
113
171
  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();
172
+ const root = this.root.getAbsolutePosition().clone(), positions = new Map();
173
+ for (const [role, node] of this.nodes) {
174
+ node.computeWorldMatrix(true);
175
+ positions.set(role, node.getAbsolutePosition().clone());
176
+ }
117
177
  const inverse = Matrix.Invert(this.root.getWorldMatrix());
118
178
  // Vector transform through inverse includes inverse model scale: consumers should keep root scale 1.
119
179
  const tuple = (v) => [v.x, v.y, v.z];
120
180
  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 };
181
+ const bones = Object.fromEntries([...positions].map(([role, position]) => [role, {
182
+ position: tuple(Vector3.TransformCoordinates(position, inverse)), velocity: tuple(velocity(position, this.previous?.bones.get(role))),
183
+ }]));
184
+ const pose = { bones, rootVelocity: tuple(velocity(root, this.previous?.root)) };
185
+ this.previous = { bones: positions, root };
124
186
  return pose;
125
187
  }
126
188
  }
189
+ /** Compatibility reader for the original two-foot preset. */
190
+ export class BabylonPoseReader {
191
+ reader;
192
+ constructor(root, left, right) {
193
+ this.reader = new BabylonSchemaPoseReader(root, new Map([['leftFoot', left], ['rightFoot', right]]), LOCOMOTION_SCHEMA);
194
+ }
195
+ reset() { this.reader.reset(); }
196
+ read(dt) {
197
+ const pose = this.reader.read(dt);
198
+ return { leftPosition: pose.bones.leftFoot.position, rightPosition: pose.bones.rightFoot.position,
199
+ leftVelocity: pose.bones.leftFoot.velocity, rightVelocity: pose.bones.rightFoot.velocity, rootVelocity: pose.rootVelocity };
200
+ }
201
+ }
@@ -25,6 +25,8 @@ export class MotionMatcher {
25
25
  update(dt, query) {
26
26
  if (!Number.isFinite(dt) || dt < 0)
27
27
  throw new Error('Invalid delta time');
28
+ // Validate identity even while continuity skips a search this frame.
29
+ this.search.validateQuery(query);
28
30
  let switched = false, ended = false;
29
31
  for (const [clip, remaining] of this.completed) {
30
32
  if (remaining <= dt)
@@ -58,7 +60,10 @@ export class MotionMatcher {
58
60
  gap = Math.min(gap, duration - gap);
59
61
  const distant = best.clip !== this.clip || gap > this.options.timeSeparation;
60
62
  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)) {
63
+ const incompatibleVelocity = current >= 0 && distant && this.sinceSwitch >= this.options.minimumHold && query.velocityTolerance !== undefined
64
+ && this.search.rootVelocityError(current, query) > Math.max(query.velocityTolerance, this.search.rootVelocityError(best.index, query) + 1e-6);
65
+ const mayReselectTime = query.allowTemporalReselection !== false || best.clip !== this.clip || !this.database.manifest.clips[best.clip].looping;
66
+ if (current < 0 || !allowed || ended || (mayReselectTime && (incompatibleVelocity || (distant && better && this.sinceSwitch >= this.options.minimumHold)))) {
62
67
  this.clip = best.clip;
63
68
  this.time = best.time;
64
69
  this.sinceSwitch = 0;
@@ -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 {};
@@ -1,59 +1,95 @@
1
- import { DIMENSIONS, HORIZONS } from './types.js';
1
+ import { compileSchema, LOCOMOTION_SCHEMA } from './schema.js';
2
+ import { parseRootOrientation } from './profile.js';
3
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
2
4
  export function validateDatabase(db) {
3
- const m = db.manifest;
4
- if (m.version !== 1 || m.dimensions !== DIMENSIONS || m.coordinates !== 'meters-y-up-z-forward'
5
- || m.horizons.length !== 3 || m.horizons.some((v, i) => v !== HORIZONS[i]))
6
- throw new Error('Unsupported pose database schema');
7
- if (!Number.isFinite(m.sampleRate) || m.sampleRate <= 0 || !m.frames.length || !m.clips.length
8
- || db.features.length !== m.frames.length * DIMENSIONS)
5
+ const m = db?.manifest;
6
+ if (!record(m) || m.version !== 2)
7
+ throw new Error('Unsupported pose database version; v1 requires migrateLegacyDatabase or a new bake');
8
+ const layout = compileSchema(m.schema), dimensions = layout.dimensions;
9
+ if (m.dimensions !== dimensions || m.schemaSignature !== layout.signature || m.coordinates !== 'meters-y-up-z-forward'
10
+ || !Array.isArray(m.horizons) || m.horizons.length !== layout.horizons.length || m.horizons.some((v, i) => v !== layout.horizons[i]))
11
+ throw new Error('Incompatible pose database schema');
12
+ if (!Number.isFinite(m.sampleRate) || m.sampleRate <= 0 || !Array.isArray(m.frames) || !m.frames.length || !Array.isArray(m.clips) || !m.clips.length
13
+ || !(db.features instanceof Float32Array) || db.features.length !== m.frames.length * dimensions)
9
14
  throw new Error('Invalid database size/sample rate');
10
- if (m.mean.length !== DIMENSIONS || m.std.length !== DIMENSIONS || !m.mean.every(Number.isFinite)
11
- || !m.std.every(v => Number.isFinite(v) && v >= 1e-4) || !db.features.every(Number.isFinite))
15
+ if (!Array.isArray(m.mean) || !Array.isArray(m.std) || m.mean.length !== dimensions || m.std.length !== dimensions || ![...m.mean].every(Number.isFinite)
16
+ || ![...m.std].every(v => Number.isFinite(v) && v >= 1e-4) || !db.features.every(Number.isFinite))
12
17
  throw new Error('Invalid normalization/features');
18
+ for (const { channel, offset, dimensions: width } of layout.channels)
19
+ if (channel.normalization === 'none') {
20
+ for (let j = offset; j < offset + width; j++)
21
+ if (m.mean[j] !== 0 || m.std[j] !== 1)
22
+ throw new Error('Invalid unnormalized channel statistics');
23
+ }
24
+ 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))))
25
+ throw new Error('Invalid asset hashes');
13
26
  const ids = new Set();
14
27
  for (const clip of m.clips) {
15
- if (ids.has(clip.id) || !clip.id || !clip.file || !clip.animation || !Number.isFinite(clip.duration) || clip.duration <= 0
28
+ if (!record(clip) || typeof clip.id !== 'string' || ids.has(clip.id) || !clip.id || typeof clip.file !== 'string' || !clip.file
29
+ || typeof clip.animation !== 'string' || !clip.animation || !Number.isFinite(clip.duration) || clip.duration <= 0 || typeof clip.looping !== 'boolean'
16
30
  || !['idle', 'walk', 'run', 'strafe', 'turn', 'stop'].includes(clip.gait))
17
31
  throw new Error('Invalid clip');
18
32
  ids.add(clip.id);
33
+ if (clip.sourceStart !== undefined && (!Number.isFinite(clip.sourceStart) || clip.sourceStart < 0))
34
+ throw new Error('Invalid source start');
35
+ if (clip.averageVelocity !== undefined && (!Array.isArray(clip.averageVelocity) || clip.averageVelocity.length !== 3 || !clip.averageVelocity.every(Number.isFinite)))
36
+ throw new Error('Invalid measured average velocity');
37
+ if (clip.rootOrientation !== undefined)
38
+ parseRootOrientation(clip.rootOrientation);
19
39
  }
20
40
  for (const frame of m.frames) {
21
- if (!Number.isInteger(frame.clip) || !m.clips[frame.clip] || !Number.isFinite(frame.time)
41
+ if (!record(frame) || !Number.isInteger(frame.clip) || !m.clips[frame.clip] || !Number.isFinite(frame.time)
22
42
  || frame.time < 0 || frame.time > m.clips[frame.clip].duration + 1e-6)
23
43
  throw new Error('Invalid pose metadata');
24
44
  }
25
45
  }
26
- export function buildDatabase(manifest, raw) {
27
- const count = manifest.frames.length;
28
- if (!count || raw.length !== count * DIMENSIONS || !raw.every(Number.isFinite))
46
+ /** Omitting schema selects the legacy biped preset. New data is always v2. */
47
+ export function buildDatabase(manifest, raw, schema = LOCOMOTION_SCHEMA) {
48
+ const layout = compileSchema(schema), dimensions = layout.dimensions, count = manifest.frames.length;
49
+ if (!count || raw.length !== count * dimensions || !raw.every(Number.isFinite))
29
50
  throw new Error('Invalid raw samples');
30
- const mean = Array(DIMENSIONS).fill(0), std = Array(DIMENSIONS).fill(0);
51
+ const mean = Array(dimensions).fill(0), std = Array(dimensions).fill(0);
31
52
  for (let i = 0; i < raw.length; i++)
32
- mean[i % DIMENSIONS] += raw[i] / count;
53
+ mean[i % dimensions] += raw[i] / count;
33
54
  for (let i = 0; i < raw.length; i++)
34
- std[i % DIMENSIONS] += (raw[i] - mean[i % DIMENSIONS]) ** 2 / count;
35
- // Constant channels carry no measured variation. A unit scale avoids amplifying
36
- // unsupported trajectories (e.g. turns absent from the dataset) by 10,000×.
37
- for (let j = 0; j < DIMENSIONS; j++)
55
+ std[i % dimensions] += (raw[i] - mean[i % dimensions]) ** 2 / count;
56
+ for (let j = 0; j < dimensions; j++)
38
57
  std[j] = Math.sqrt(std[j]) < 1e-4 ? 1 : Math.sqrt(std[j]);
39
- const features = Float32Array.from(raw, (v, i) => (v - mean[i % DIMENSIONS]) / std[i % DIMENSIONS]);
40
- const db = { manifest: { ...manifest, version: 1, dimensions: DIMENSIONS, coordinates: 'meters-y-up-z-forward', horizons: HORIZONS, mean, std }, features };
58
+ for (const { channel, offset, dimensions: width } of layout.channels)
59
+ if (channel.normalization === 'none') {
60
+ mean.fill(0, offset, offset + width);
61
+ std.fill(1, offset, offset + width);
62
+ }
63
+ const features = Float32Array.from(raw, (v, i) => (v - mean[i % dimensions]) / std[i % dimensions]);
64
+ const db = { manifest: { ...manifest, version: 2, dimensions, schema: layout.schema, schemaSignature: layout.signature,
65
+ coordinates: 'meters-y-up-z-forward', horizons: layout.horizons, mean, std }, features };
41
66
  validateDatabase(db);
42
67
  return db;
43
68
  }
44
69
  /** Binary format is explicitly little endian float32; version/layout live in manifest. */
45
70
  export function encodeFeatures(db) {
71
+ validateDatabase(db);
46
72
  const bytes = new ArrayBuffer(db.features.length * 4), view = new DataView(bytes);
47
73
  db.features.forEach((v, i) => view.setFloat32(i * 4, v, true));
48
74
  return bytes;
49
75
  }
50
- export function decodeDatabase(manifest, bytes) {
76
+ export function decodeDatabase(manifest, bytes, expectedSchema) {
51
77
  if (bytes.byteLength % 4)
52
78
  throw new Error('Truncated feature binary');
53
79
  const view = new DataView(bytes), features = new Float32Array(bytes.byteLength / 4);
54
80
  for (let i = 0; i < features.length; i++)
55
81
  features[i] = view.getFloat32(i * 4, true);
56
- const db = { manifest, features };
82
+ const db = { manifest: manifest, features };
57
83
  validateDatabase(db);
84
+ if (expectedSchema && db.manifest.schemaSignature !== compileSchema(expectedSchema).signature)
85
+ throw new Error('Incompatible expected schema; regenerate database');
58
86
  return db;
59
87
  }
88
+ /** Explicit, lossless conversion ONLY for the original v1 27-dimensional preset. */
89
+ export function migrateLegacyDatabase(input, bytes) {
90
+ const layout = compileSchema(LOCOMOTION_SCHEMA);
91
+ if (!record(input) || input.version !== 1 || input.dimensions !== layout.dimensions || !Array.isArray(input.horizons)
92
+ || input.horizons.length !== layout.horizons.length || input.horizons.some((h, i) => h !== layout.horizons[i]))
93
+ throw new Error('Unsupported legacy database schema');
94
+ return decodeDatabase({ ...input, version: 2, schema: layout.schema, schemaSignature: layout.signature }, bytes);
95
+ }
@@ -12,4 +12,4 @@ export interface TrajectoryInput {
12
12
  turnSpeed: number;
13
13
  }
14
14
  /** Prediction only: never writes to the gameplay controller. Uses fixed substeps. */
15
- export declare function predictTrajectory(input: TrajectoryInput): TrajectoryPoint[];
15
+ export declare function predictTrajectory(input: TrajectoryInput, horizons?: readonly number[]): TrajectoryPoint[];
@@ -1,14 +1,11 @@
1
1
  import { DIMENSIONS, HORIZONS } from './types.js';
2
+ import { SchemaQueryBuilder, validateHorizons } from './schema.js';
3
+ const legacyBuilder = new SchemaQueryBuilder();
2
4
  export function featureVector(pose, trajectory, out = new Float32Array(DIMENSIONS)) {
3
- if (trajectory.length !== 3 || out.length !== DIMENSIONS)
4
- throw new Error('Expected 3 trajectory points and 27 features');
5
- out.set(pose.leftPosition, 0);
6
- out.set(pose.rightPosition, 3);
7
- out.set(pose.leftVelocity, 6);
8
- out.set(pose.rightVelocity, 9);
9
- out.set(pose.rootVelocity, 12);
10
- trajectory.forEach((point, i) => { out.set(point.position, 15 + i * 2); out.set(point.facing, 21 + i * 2); });
11
- return out;
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);
12
9
  }
13
10
  /** World delta to current character-local XZ (yaw zero looks along +Z). */
14
11
  export function toLocal(x, z, yaw) {
@@ -16,13 +13,14 @@ export function toLocal(x, z, yaw) {
16
13
  return [c * x - s * z, s * x + c * z];
17
14
  }
18
15
  /** Prediction only: never writes to the gameplay controller. Uses fixed substeps. */
19
- export function predictTrajectory(input) {
16
+ export function predictTrajectory(input, horizons = HORIZONS) {
17
+ validateHorizons(horizons);
20
18
  if (![...input.velocity, ...input.desiredVelocity, input.yaw, input.desiredYaw, input.acceleration, input.braking, input.turnSpeed].every(Number.isFinite)
21
19
  || Math.min(input.acceleration, input.braking, input.turnSpeed) < 0)
22
20
  throw new Error('Invalid trajectory input');
23
21
  let [vx, vz] = input.velocity, x = 0, z = 0, yaw = input.yaw, elapsed = 0;
24
22
  const points = [];
25
- for (const horizon of HORIZONS) {
23
+ for (const horizon of horizons) {
26
24
  while (elapsed < horizon - 1e-9) {
27
25
  const dt = Math.min(1 / 60, horizon - elapsed);
28
26
  const dx = input.desiredVelocity[0] - vx, dz = input.desiredVelocity[1] - vz, length = Math.hypot(dx, dz);
@@ -1,6 +1,7 @@
1
- import type { Gait, Vec3 } from './types.js';
1
+ import type { Gait, Quat, Vec3 } from './types.js';
2
+ import { type PoseSchema } from './schema.js';
2
3
  export interface CharacterProfile {
3
- version: 1;
4
+ version: 2;
4
5
  id: string;
5
6
  label: string;
6
7
  assetBase: string;
@@ -10,13 +11,17 @@ export interface CharacterProfile {
10
11
  sampleRate: number;
11
12
  rig: {
12
13
  root: string;
13
- leftFoot: string;
14
- rightFoot: string;
15
- };
14
+ } & Record<string, string>;
15
+ schema: string;
16
+ animationSet: string;
16
17
  /** Source animation node -> model node; name mapping does not perform retargeting. */
17
18
  boneMap?: Record<string, string>;
18
19
  /** Model node whose position/heading tracks are excluded by the player. */
19
20
  stripRoot?: string;
21
+ }
22
+ export interface AnimationSet {
23
+ version: 1;
24
+ id: string;
20
25
  clips: {
21
26
  id: string;
22
27
  file: string;
@@ -25,12 +30,24 @@ export interface CharacterProfile {
25
30
  looping: boolean;
26
31
  nominalVelocity?: Vec3;
27
32
  motionFile?: string;
33
+ rootOrientation?: Quat;
34
+ /** Source interval in seconds. */
35
+ range?: readonly [number, number];
28
36
  }[];
29
37
  }
38
+ export interface ResolvedProfile extends CharacterProfile {
39
+ schemaDefinition: PoseSchema;
40
+ clips: AnimationSet['clips'];
41
+ setId: string;
42
+ }
43
+ export declare function parseRootOrientation(value: unknown): Quat;
30
44
  /** Read untrusted JSON as unknown; report a configuration error before loading assets. */
31
45
  export declare function parseProfile(input: unknown): CharacterProfile;
46
+ export declare function parseAnimationSet(input: unknown): AnimationSet;
47
+ /** Resolve external configuration explicitly; core performs no I/O. */
48
+ export declare function resolveProfile(profile: CharacterProfile, schemaInput: unknown, setInput: unknown): ResolvedProfile;
32
49
  /** Deterministic configuration fingerprint; detects stale banks, not a security signature. */
33
- export declare function profileSignature(profile: CharacterProfile): string;
50
+ export declare function profileSignature(profile: ResolvedProfile): string;
34
51
  /** Suggestions only: ambiguity is returned to the caller, never silently resolved. */
35
52
  export declare function suggestRig(names: readonly string[]): Record<'root' | 'leftFoot' | 'rightFoot', string[]>;
36
53
  /** Semantic availability, independent of character/clip names. Preserve optional actions. */