@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.
- package/README.md +129 -105
- package/dist/AI_USAGE.md +386 -0
- package/dist/PUBLISHING.md +110 -0
- package/dist/babylon-inertialization.d.ts +29 -0
- package/dist/babylon-inertialization.js +88 -0
- package/dist/babylon.d.ts +27 -5
- package/dist/babylon.js +181 -26
- package/dist/core/controller.d.ts +26 -0
- package/dist/core/controller.js +78 -8
- package/dist/core/database.d.ts +8 -2
- package/dist/core/database.js +62 -24
- package/dist/core/features.d.ts +1 -1
- package/dist/core/features.js +9 -11
- package/dist/core/inertialization.d.ts +3 -0
- package/dist/core/inertialization.js +15 -0
- package/dist/core/profile.d.ts +34 -8
- package/dist/core/profile.js +49 -10
- package/dist/core/root-motion.d.ts +5 -3
- package/dist/core/root-motion.js +43 -7
- package/dist/core/scenario.d.ts +29 -0
- package/dist/core/scenario.js +57 -0
- package/dist/core/schema.d.ts +56 -0
- package/dist/core/schema.js +123 -0
- package/dist/core/search.d.ts +40 -2
- package/dist/core/search.js +147 -16
- package/dist/core/selection.d.ts +24 -0
- package/dist/core/selection.js +41 -0
- package/dist/core/types.d.ts +24 -4
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/offline/src/babylon-inertialization.js +88 -0
- package/dist/offline/src/babylon.js +281 -0
- package/dist/offline/src/core/controller.js +162 -0
- package/dist/offline/src/core/database.js +97 -0
- package/dist/offline/src/core/features.js +41 -0
- package/dist/offline/src/core/inertialization.js +15 -0
- package/dist/offline/src/core/profile.js +103 -0
- package/dist/offline/src/core/root-motion.js +78 -0
- package/dist/offline/src/core/scenario.js +57 -0
- package/dist/offline/src/core/schema.js +123 -0
- package/dist/offline/src/core/search.js +181 -0
- package/dist/offline/src/core/selection.js +41 -0
- package/dist/offline/src/core/types.js +3 -0
- package/dist/offline/src/index.js +11 -0
- package/dist/offline/tools/bake.js +88 -0
- package/dist/offline/tools/cli.js +30 -0
- package/dist/offline/tools/glb.js +54 -0
- package/dist/offline/tools/prepare.js +165 -0
- package/dist/offline/tools/profile-assets.js +90 -0
- package/dist/offline/tools/rig.js +47 -0
- package/dist/offline/tools/validate-assets.js +43 -0
- package/package.json +48 -39
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { FreeCamera, TransformNode, Vector3 } from '@babylonjs/core';
|
|
3
|
+
import { hashProfileAssets, loadProfileAssets, profileArgument, publicPath, readProfile } from './profile-assets.js';
|
|
4
|
+
import { buildDatabase, encodeFeatures, SchemaQueryBuilder, rootMotionFeatures, cropRootMotion, validateRootMotion, profileSignature } from '../src/index.js';
|
|
5
|
+
import { BabylonMotionPlayer, BabylonSchemaPoseReader } from '../src/babylon.js';
|
|
6
|
+
const profile = await readProfile(profileArgument());
|
|
7
|
+
const builder = new SchemaQueryBuilder(profile.schemaDefinition), layout = builder.layout;
|
|
8
|
+
const assets = await loadProfileAssets(profile);
|
|
9
|
+
try {
|
|
10
|
+
const { scene, containers } = assets, body = containers.get(profile.model);
|
|
11
|
+
new FreeCamera('bake-camera', new Vector3(0, 2, -5), scene);
|
|
12
|
+
body.addAllToScene();
|
|
13
|
+
const controller = new TransformNode('bake-controller', scene);
|
|
14
|
+
body.rootNodes.forEach(n => n.parent = controller);
|
|
15
|
+
const nodes = new Map(body.transformNodes.map(n => [n.name, n])), targets = new Map(nodes);
|
|
16
|
+
for (const [source, target] of Object.entries(profile.boneMap ?? {}))
|
|
17
|
+
targets.set(source, nodes.get(target));
|
|
18
|
+
const reader = new BabylonSchemaPoseReader(controller, new Map(layout.bones.map(role => [role, nodes.get(profile.rig[role])])), layout.schema);
|
|
19
|
+
const clips = [], frames = [], raw = [], sampleRate = profile.sampleRate;
|
|
20
|
+
for (const spec of profile.clips) {
|
|
21
|
+
const { id, file, animation, gait, looping } = spec, nominalVelocity = spec.nominalVelocity ?? [0, 0, 0];
|
|
22
|
+
const source = containers.get(file).animationGroups.find(g => g.name === animation);
|
|
23
|
+
const fps = source.targetedAnimations[0].animation.framePerSecond, sourceDuration = (source.to - source.from) / fps;
|
|
24
|
+
const [start, end] = spec.range ?? [0, sourceDuration], duration = end - start;
|
|
25
|
+
let motion = spec.motionFile ? JSON.parse(await readFile(publicPath(profile.assetBase + spec.motionFile), 'utf8')) : undefined;
|
|
26
|
+
if (motion) {
|
|
27
|
+
validateRootMotion(motion);
|
|
28
|
+
motion.samples = motion.samples.map(sample => ({ ...sample, x: sample.x * profile.scale, z: sample.z * profile.scale }));
|
|
29
|
+
if (Math.abs(motion.duration - sourceDuration) > 1e-5)
|
|
30
|
+
throw new Error(`Motion/GLB time mismatch: ${id}`);
|
|
31
|
+
}
|
|
32
|
+
if (motion && spec.range)
|
|
33
|
+
motion = cropRootMotion(motion, start, end);
|
|
34
|
+
const descriptor = { id, file, animation, duration, looping, gait,
|
|
35
|
+
...Object.fromEntries(['context', 'entryWindows', 'exitWindows', 'excludedWindows'].filter(key => key in spec).map(key => [key, spec[key]])),
|
|
36
|
+
...(spec.range ? { sourceStart: start } : {}), ...(spec.rootOrientation ? { rootOrientation: spec.rootOrientation } : {}),
|
|
37
|
+
...(motion ? { motionSource: motion.source } : { nominalVelocity, motionSource: 'nominal' }) };
|
|
38
|
+
const player = new BabylonMotionPlayer(scene, [descriptor], new Map([[id, source]]), targets, 0, profile.stripRoot ?? '');
|
|
39
|
+
const clipTime = (t) => looping ? ((t % duration) + duration) % duration : Math.max(0, Math.min(t, duration));
|
|
40
|
+
// Sample the same target model/player/root stripping used at runtime.
|
|
41
|
+
const sample = (t) => {
|
|
42
|
+
player.update({ clip: 0, time: clipTime(t), switched: false, match: null }, 0);
|
|
43
|
+
scene.render();
|
|
44
|
+
return reader.read(0);
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
const clip = clips.length;
|
|
48
|
+
clips.push(descriptor);
|
|
49
|
+
const average = [0, 0, 0], count = Math.ceil(duration * sampleRate);
|
|
50
|
+
for (let i = 0; i < Math.ceil(duration * sampleRate); i++) {
|
|
51
|
+
const time = i / sampleRate, h = 1 / sampleRate;
|
|
52
|
+
const before = sample(time - h), after = sample(time + h), now = sample(time);
|
|
53
|
+
const measured = motion ? rootMotionFeatures(motion, time, h, layout.horizons, looping) : undefined;
|
|
54
|
+
const rootVelocity = measured?.velocity ?? nominalVelocity;
|
|
55
|
+
if (measured)
|
|
56
|
+
for (let axis = 0; axis < 3; axis++)
|
|
57
|
+
average[axis] += rootVelocity[axis] / count;
|
|
58
|
+
const elapsed = looping ? 2 * h : clipTime(time + h) - clipTime(time - h);
|
|
59
|
+
const bones = Object.fromEntries(layout.bones.map(role => {
|
|
60
|
+
const position = now.bones[role].position;
|
|
61
|
+
const velocity = position.map((_, j) => (after.bones[role].position[j] - before.bones[role].position[j]) / elapsed + rootVelocity[j]);
|
|
62
|
+
velocity[0] += (measured?.angularVelocity ?? 0) * position[2];
|
|
63
|
+
velocity[2] -= (measured?.angularVelocity ?? 0) * position[0];
|
|
64
|
+
return [role, { position, velocity: velocity }];
|
|
65
|
+
}));
|
|
66
|
+
const pose = { bones, rootVelocity };
|
|
67
|
+
const future = measured?.trajectory ?? layout.horizons.map(t => ({ position: [nominalVelocity[0] * t, nominalVelocity[2] * t], facing: [0, 1] }));
|
|
68
|
+
raw.push(...builder.features(pose, future));
|
|
69
|
+
frames.push({ clip, time });
|
|
70
|
+
}
|
|
71
|
+
if (motion)
|
|
72
|
+
descriptor.averageVelocity = average;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
player.dispose();
|
|
76
|
+
}
|
|
77
|
+
console.log(`${id}: ${duration.toFixed(3)}s`);
|
|
78
|
+
}
|
|
79
|
+
const db = buildDatabase({ profileId: profile.id, profileSignature: profileSignature(profile), assetHashes: await hashProfileAssets(profile), sampleRate, clips, frames }, Float32Array.from(raw), layout.schema);
|
|
80
|
+
const output = publicPath(profile.database);
|
|
81
|
+
await mkdir(output, { recursive: true });
|
|
82
|
+
await writeFile(`${output}/manifest.json`, JSON.stringify(db.manifest, null, 2));
|
|
83
|
+
await writeFile(`${output}/poses.bin`, new Uint8Array(encodeFeatures(db)));
|
|
84
|
+
console.log(`Baked ${frames.length} poses × ${layout.dimensions} features (${layout.schema.id}); ${db.features.byteLength} bytes.`);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
assets.dispose();
|
|
88
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Node-only entry; paths are relative to the caller, never to this installation. */
|
|
3
|
+
const command = process.argv[2];
|
|
4
|
+
try {
|
|
5
|
+
if (command === 'bake')
|
|
6
|
+
await import('./bake.js');
|
|
7
|
+
else if (command === 'prepare') {
|
|
8
|
+
const at = process.argv.indexOf('--config');
|
|
9
|
+
if (at < 0 || !process.argv[at + 1])
|
|
10
|
+
throw new Error('Provide --config preparation.json');
|
|
11
|
+
const { readFile } = await import('node:fs/promises');
|
|
12
|
+
const { parsePreparation, prepareGLB } = await import('./prepare.js');
|
|
13
|
+
console.log(JSON.stringify(await prepareGLB(parsePreparation(JSON.parse(await readFile(process.argv[at + 1], 'utf8')))), null, 2));
|
|
14
|
+
}
|
|
15
|
+
else if (command === 'validate' || command === 'inspect') {
|
|
16
|
+
if (command === 'inspect')
|
|
17
|
+
process.argv.splice(3, 0, '--inspect');
|
|
18
|
+
await import('./validate-assets.js');
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
console.log('pose-flow bake|validate --profile public/profiles/rig.json [--public-dir public]\npose-flow inspect model.glb\npose-flow prepare --config preparation.json');
|
|
22
|
+
if (command && command !== '--help')
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function readGLB(bytes) {
|
|
2
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
3
|
+
if (bytes.length < 20 || view.getUint32(0, true) !== 0x46546c67 || view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== bytes.length)
|
|
4
|
+
throw new Error('Invalid GLB header');
|
|
5
|
+
let json, binary = new Uint8Array();
|
|
6
|
+
for (let offset = 12; offset < bytes.length;) {
|
|
7
|
+
if (offset + 8 > bytes.length)
|
|
8
|
+
throw new Error('Truncated GLB chunk');
|
|
9
|
+
const length = view.getUint32(offset, true), kind = view.getUint32(offset + 4, true);
|
|
10
|
+
offset += 8;
|
|
11
|
+
if (length % 4 || offset + length > bytes.length)
|
|
12
|
+
throw new Error('Invalid GLB chunk');
|
|
13
|
+
if (kind === 0x4e4f534a)
|
|
14
|
+
json = JSON.parse(new TextDecoder().decode(bytes.subarray(offset, offset + length)));
|
|
15
|
+
else if (kind === 0x004e4942)
|
|
16
|
+
binary = bytes.slice(offset, offset + length);
|
|
17
|
+
offset += length;
|
|
18
|
+
}
|
|
19
|
+
if (!json || json.buffers?.length !== 1 || json.buffers[0].uri || json.buffers[0].byteLength > binary.length || json.images?.some(i => i.uri && !i.uri.startsWith('data:')))
|
|
20
|
+
throw new Error('Preparation requires a self-contained GLB with one embedded buffer/images');
|
|
21
|
+
return { json, binary };
|
|
22
|
+
}
|
|
23
|
+
export function writeGLB(json, binary) {
|
|
24
|
+
const data = new TextEncoder().encode(JSON.stringify(json)), jsonLength = Math.ceil(data.length / 4) * 4, binLength = Math.ceil(binary.length / 4) * 4;
|
|
25
|
+
const output = new Uint8Array(28 + jsonLength + binLength), view = new DataView(output.buffer);
|
|
26
|
+
view.setUint32(0, 0x46546c67, true);
|
|
27
|
+
view.setUint32(4, 2, true);
|
|
28
|
+
view.setUint32(8, output.length, true);
|
|
29
|
+
view.setUint32(12, jsonLength, true);
|
|
30
|
+
view.setUint32(16, 0x4e4f534a, true);
|
|
31
|
+
output.fill(32, 20, 20 + jsonLength);
|
|
32
|
+
output.set(data, 20);
|
|
33
|
+
view.setUint32(20 + jsonLength, binLength, true);
|
|
34
|
+
view.setUint32(24 + jsonLength, 0x004e4942, true);
|
|
35
|
+
output.set(binary, 28 + jsonLength);
|
|
36
|
+
return output;
|
|
37
|
+
}
|
|
38
|
+
export function appendFloats(document, values, width) {
|
|
39
|
+
if (!values.length || values.length % width || !values.every(Number.isFinite))
|
|
40
|
+
throw new Error('Invalid prepared samples');
|
|
41
|
+
const { json } = document, offset = Math.ceil(document.binary.length / 4) * 4;
|
|
42
|
+
const binary = new Uint8Array(offset + values.length * 4);
|
|
43
|
+
binary.set(document.binary);
|
|
44
|
+
const view = new DataView(binary.buffer);
|
|
45
|
+
values.forEach((v, i) => view.setFloat32(offset + i * 4, v, true));
|
|
46
|
+
const bufferView = (json.bufferViews ??= []).length;
|
|
47
|
+
json.bufferViews.push({ buffer: 0, byteOffset: offset, byteLength: values.length * 4 });
|
|
48
|
+
const accessor = (json.accessors ??= []).length;
|
|
49
|
+
json.accessors.push({ bufferView, componentType: 5126, count: values.length / width, type: width === 1 ? 'SCALAR' : 'VEC' + width,
|
|
50
|
+
...(width === 1 ? { min: [values.reduce((min, v) => Math.min(min, v), Infinity)], max: [values.reduce((max, v) => Math.max(max, v), -Infinity)] } : {}) });
|
|
51
|
+
json.buffers[0].byteLength = binary.length;
|
|
52
|
+
document.binary = binary;
|
|
53
|
+
return accessor;
|
|
54
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { FreeCamera, Matrix, NullEngine, Quaternion, Scene, SceneLoader, Vector3 } from '@babylonjs/core';
|
|
5
|
+
import { GLTFFileLoader } from '@babylonjs/loaders/glTF/index.js';
|
|
6
|
+
import { validateRootMotion } from '../src/index.js';
|
|
7
|
+
import { appendFloats, readGLB, writeGLB } from './glb.js';
|
|
8
|
+
export function parsePreparation(input) {
|
|
9
|
+
if (!input || typeof input !== 'object')
|
|
10
|
+
throw new Error('Invalid preparation config');
|
|
11
|
+
const c = input;
|
|
12
|
+
if (c.version !== 1 || ![c.input, c.output, c.animation, c.rootNode].every(v => typeof v === 'string' && v.trim())
|
|
13
|
+
|| !['root', 'hips'].includes(c.source) || !['horizontal', 'all'].includes(c.position) || typeof c.heading !== 'boolean'
|
|
14
|
+
|| !Number.isFinite(c.sampleRate) || c.sampleRate < 1 || c.sampleRate > 120
|
|
15
|
+
|| (c.unitScale !== undefined && (!Number.isFinite(c.unitScale) || c.unitScale <= 0))
|
|
16
|
+
|| (c.yawOffset !== undefined && !Number.isFinite(c.yawOffset)))
|
|
17
|
+
throw new Error('Invalid preparation config');
|
|
18
|
+
if (c.playbackRate !== undefined && (!Number.isFinite(c.playbackRate) || c.playbackRate < .25 || c.playbackRate > 4))
|
|
19
|
+
throw new Error('Preparation playbackRate must be between .25 and 4');
|
|
20
|
+
if (!c.input.endsWith('.glb') || !c.output.endsWith('.glb') || resolve(c.input) === resolve(c.output))
|
|
21
|
+
throw new Error('Preparation requires distinct input/output GLB paths');
|
|
22
|
+
return structuredClone(c);
|
|
23
|
+
}
|
|
24
|
+
/** Offline only. No character names; the prepared GLB and measured track are a pair. */
|
|
25
|
+
export async function prepareGLB(input) {
|
|
26
|
+
const config = parsePreparation(input), bytes = new Uint8Array(await readFile(config.input)), document = readGLB(bytes), { json } = document;
|
|
27
|
+
const matches = json.nodes.flatMap((node, index) => node.name === config.rootNode ? [index] : []);
|
|
28
|
+
if (matches.length !== 1)
|
|
29
|
+
throw new Error('Missing/ambiguous preparation root node');
|
|
30
|
+
const rootIndex = matches[0];
|
|
31
|
+
const animations = json.animations?.filter(a => a.name === config.animation);
|
|
32
|
+
if (animations?.length !== 1)
|
|
33
|
+
throw new Error('Missing/ambiguous preparation animation');
|
|
34
|
+
const selected = animations[0];
|
|
35
|
+
json.animations = [selected];
|
|
36
|
+
// Keep axis/unit conversion in the exported GLB, not only in the measured JSON.
|
|
37
|
+
const conversion = Matrix.Scaling(config.unitScale ?? 1, config.unitScale ?? 1, config.unitScale ?? 1).multiply(Matrix.RotationY(config.yawOffset ?? 0));
|
|
38
|
+
for (const index of json.scenes[json.scene ?? 0].nodes) {
|
|
39
|
+
const node = json.nodes[index];
|
|
40
|
+
const original = node.matrix ? Matrix.FromArray(node.matrix) : Matrix.Compose(Vector3.FromArray(node.scale ?? [1, 1, 1]), Quaternion.FromArray(node.rotation ?? [0, 0, 0, 1]), Vector3.FromArray(node.translation ?? [0, 0, 0]));
|
|
41
|
+
if (selected.channels.some(c => c.target.node === index))
|
|
42
|
+
throw new Error('Animated scene root requires offline axis/unit conversion before preparation');
|
|
43
|
+
node.matrix = original.multiply(conversion).asArray();
|
|
44
|
+
delete node.translation;
|
|
45
|
+
delete node.rotation;
|
|
46
|
+
delete node.scale;
|
|
47
|
+
}
|
|
48
|
+
const engine = new NullEngine(), scene = new Scene(engine);
|
|
49
|
+
scene.useRightHandedSystem = true;
|
|
50
|
+
new FreeCamera('preparation-camera', new Vector3(0, 2, -5), scene);
|
|
51
|
+
const hook = SceneLoader.OnPluginActivatedObservable.add(p => { if (p instanceof GLTFFileLoader)
|
|
52
|
+
p.skipMaterials = true; });
|
|
53
|
+
try {
|
|
54
|
+
const container = await SceneLoader.LoadAssetContainerAsync('', writeGLB(json, document.binary), scene, undefined, '.glb');
|
|
55
|
+
container.animationGroups.forEach(g => g.stop());
|
|
56
|
+
container.addAllToScene();
|
|
57
|
+
const roots = container.transformNodes.filter(n => n.name === config.rootNode);
|
|
58
|
+
if (roots.length !== 1)
|
|
59
|
+
throw new Error('Missing/ambiguous evaluated root');
|
|
60
|
+
const root = roots[0], group = container.animationGroups[0], fps = group.targetedAnimations[0]?.animation.framePerSecond;
|
|
61
|
+
if (!fps || group.targetedAnimations.some(t => t.animation.framePerSecond !== fps) || group.to <= group.from)
|
|
62
|
+
throw new Error('Invalid preparation clip/FPS');
|
|
63
|
+
const duration = (group.to - group.from) / fps, count = Math.ceil(duration * config.sampleRate);
|
|
64
|
+
if (count > 1_000_000)
|
|
65
|
+
throw new Error('Preparation clip too long');
|
|
66
|
+
const times = [], positions = [], rotations = [], motion = { version: 1, source: config.source, duration, samples: [] };
|
|
67
|
+
let initialPosition, initialRotation, previousYaw = 0, previousQ;
|
|
68
|
+
group.start(true, 0);
|
|
69
|
+
group.pause();
|
|
70
|
+
for (let i = 0; i <= count; i++) {
|
|
71
|
+
const time = duration * i / count;
|
|
72
|
+
group.goToFrame(group.from + time * fps, true);
|
|
73
|
+
scene.render();
|
|
74
|
+
root.computeWorldMatrix(true);
|
|
75
|
+
const scale = new Vector3(), rotation = new Quaternion(), position = new Vector3();
|
|
76
|
+
if (!root.getWorldMatrix().decompose(scale, rotation, position) || Math.min(scale.x, scale.y, scale.z) <= 0 || Math.max(scale.x, scale.y, scale.z) - Math.min(scale.x, scale.y, scale.z) > 1e-4)
|
|
77
|
+
throw new Error('Preparation requires positive uniform world scale without shear');
|
|
78
|
+
const recomposed = Matrix.Compose(scale, rotation, position).asArray();
|
|
79
|
+
if (root.getWorldMatrix().asArray().some((v, j) => Math.abs(v - recomposed[j]) > 1e-4))
|
|
80
|
+
throw new Error('Preparation does not support shear');
|
|
81
|
+
if (root.parent) {
|
|
82
|
+
const parentScale = new Vector3();
|
|
83
|
+
root.parent.getWorldMatrix().decompose(parentScale);
|
|
84
|
+
if (Math.min(parentScale.x, parentScale.y, parentScale.z) <= 0 || Math.max(parentScale.x, parentScale.y, parentScale.z) - Math.min(parentScale.x, parentScale.y, parentScale.z) > 1e-4)
|
|
85
|
+
throw new Error('Preparation requires a uniformly scaled root parent');
|
|
86
|
+
}
|
|
87
|
+
initialPosition ??= position.clone();
|
|
88
|
+
initialRotation ??= rotation.clone();
|
|
89
|
+
const relative = rotation.multiply(initialRotation.conjugate()), forward = new Vector3(0, 0, 1).applyRotationQuaternion(relative);
|
|
90
|
+
if (config.heading && Math.hypot(forward.x, forward.z) < 1e-5)
|
|
91
|
+
throw new Error('Undefined planar heading');
|
|
92
|
+
const angle = Math.atan2(forward.x, forward.z), yaw = config.heading ? previousYaw + Math.atan2(Math.sin(angle - previousYaw), Math.cos(angle - previousYaw)) : 0;
|
|
93
|
+
previousYaw = yaw;
|
|
94
|
+
motion.samples.push({ time, x: position.x - initialPosition.x, z: position.z - initialPosition.z, yaw });
|
|
95
|
+
position.x = initialPosition.x;
|
|
96
|
+
position.z = initialPosition.z;
|
|
97
|
+
if (config.position === 'all')
|
|
98
|
+
position.y = initialPosition.y;
|
|
99
|
+
const corrected = Matrix.Compose(scale, Quaternion.RotationAxis(Vector3.Up(), -yaw).multiply(rotation), position);
|
|
100
|
+
const local = root.parent ? corrected.multiply(Matrix.Invert(root.parent.getWorldMatrix())) : corrected;
|
|
101
|
+
const localScale = new Vector3(), q = new Quaternion(), p = new Vector3();
|
|
102
|
+
if (!local.decompose(localScale, q, p))
|
|
103
|
+
throw new Error('Cannot decompose prepared root');
|
|
104
|
+
if (i === 0) {
|
|
105
|
+
const node = json.nodes[rootIndex];
|
|
106
|
+
delete node.matrix;
|
|
107
|
+
node.translation = p.asArray();
|
|
108
|
+
node.rotation = q.asArray();
|
|
109
|
+
node.scale = localScale.asArray();
|
|
110
|
+
}
|
|
111
|
+
if (previousQ && Quaternion.Dot(previousQ, q) < 0)
|
|
112
|
+
q.scaleInPlace(-1);
|
|
113
|
+
previousQ = q;
|
|
114
|
+
times.push(time);
|
|
115
|
+
positions.push(p.x, p.y, p.z);
|
|
116
|
+
rotations.push(q.x, q.y, q.z, q.w);
|
|
117
|
+
}
|
|
118
|
+
const rate = config.playbackRate ?? 1;
|
|
119
|
+
motion.duration /= rate;
|
|
120
|
+
motion.samples.forEach(s => s.time /= rate);
|
|
121
|
+
validateRootMotion(motion);
|
|
122
|
+
const timeAccessor = appendFloats(document, times.map(t => t / rate), 1);
|
|
123
|
+
selected.channels = selected.channels.filter(c => c.target.node !== rootIndex || !['translation', 'rotation'].includes(c.target.path));
|
|
124
|
+
if (rate !== 1) {
|
|
125
|
+
const remapped = new Map();
|
|
126
|
+
for (const index of new Set(selected.channels.map(c => c.sampler))) {
|
|
127
|
+
const sampler = selected.samplers[index];
|
|
128
|
+
if (sampler.interpolation === 'CUBICSPLINE')
|
|
129
|
+
throw new Error('Time scaling requires LINEAR/STEP source channels; resample cubic channels offline first');
|
|
130
|
+
if (!remapped.has(sampler.input)) {
|
|
131
|
+
const accessor = json.accessors[sampler.input];
|
|
132
|
+
if (accessor.type !== 'SCALAR' || accessor.componentType !== 5126 || accessor.sparse)
|
|
133
|
+
throw new Error('Unsupported timeline accessor');
|
|
134
|
+
const view = json.bufferViews[accessor.bufferView];
|
|
135
|
+
const data = new DataView(document.binary.buffer, document.binary.byteOffset, document.binary.byteLength);
|
|
136
|
+
const values = Array.from({ length: accessor.count }, (_, i) => data.getFloat32((view.byteOffset ?? 0) + (accessor.byteOffset ?? 0) + i * (view.byteStride ?? 4), true) / rate);
|
|
137
|
+
remapped.set(sampler.input, appendFloats(document, values, 1));
|
|
138
|
+
}
|
|
139
|
+
sampler.input = remapped.get(sampler.input);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const [path, values, width] of [['translation', positions, 3], ['rotation', rotations, 4]]) {
|
|
143
|
+
const sampler = selected.samplers.length;
|
|
144
|
+
selected.samplers.push({ input: timeAccessor, output: appendFloats(document, [...values], width), interpolation: 'LINEAR' });
|
|
145
|
+
selected.channels.push({ sampler, target: { node: rootIndex, path } });
|
|
146
|
+
}
|
|
147
|
+
// Babylon GLTF frames are seconds × FPS; GLTF accessor times are seconds.
|
|
148
|
+
// Reject nonzero source origins rather than silently offsetting other channels.
|
|
149
|
+
if (Math.abs(group.from) > 1e-5)
|
|
150
|
+
throw new Error('Preparation requires source animation starting at zero; crop/convert offline first');
|
|
151
|
+
const output = resolve(config.output), motionFile = output.replace(/\.glb$/, '.motion.json');
|
|
152
|
+
await mkdir(dirname(output), { recursive: true });
|
|
153
|
+
await writeFile(output, writeGLB(json, document.binary));
|
|
154
|
+
await writeFile(motionFile, JSON.stringify(motion, null, 2));
|
|
155
|
+
const report = { config, inputSHA256: createHash('sha256').update(bytes).digest('hex'), duration: motion.duration, samples: times.length,
|
|
156
|
+
displacement: motion.samples.at(-1), note: 'Paired prepared GLB + measured planar trajectory. No retargeting; preserve source provenance/license. Horizontal policy preserves vertical motion.' };
|
|
157
|
+
await writeFile(output.replace(/\.glb$/, '.preparation.json'), JSON.stringify(report, null, 2));
|
|
158
|
+
return report;
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
SceneLoader.OnPluginActivatedObservable.remove(hook);
|
|
162
|
+
scene.dispose();
|
|
163
|
+
engine.dispose();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { NullEngine, Scene, SceneLoader, TransformNode } from '@babylonjs/core';
|
|
5
|
+
import { GLTFFileLoader } from '@babylonjs/loaders/glTF/index.js';
|
|
6
|
+
import { parseProfile, resolveProfile, validateRootMotion, compileSchema } from '../src/index.js';
|
|
7
|
+
export function profileArgument() {
|
|
8
|
+
const at = process.argv.indexOf('--profile');
|
|
9
|
+
if (at >= 0 && !process.argv[at + 1])
|
|
10
|
+
throw new Error('Missing --profile JSON path');
|
|
11
|
+
return at >= 0 ? process.argv[at + 1] : `public/profiles/${process.argv.includes('--paladin') ? 'paladin' : 'kaykit'}.json`;
|
|
12
|
+
}
|
|
13
|
+
export const publicPath = (url) => resolve(process.argv.includes('--public-dir') ? process.argv[process.argv.indexOf('--public-dir') + 1] : 'public', '.' + url);
|
|
14
|
+
export async function hashProfileAssets(profile) {
|
|
15
|
+
const urls = new Set([profile.model, ...profile.clips.flatMap(c => [c.file, ...(c.motionFile ? [c.motionFile] : [])])].map(f => profile.assetBase + f));
|
|
16
|
+
return Object.fromEntries(await Promise.all([...urls].sort().map(async (url) => [url, createHash('sha256').update(await readFile(publicPath(url))).digest('hex')])));
|
|
17
|
+
}
|
|
18
|
+
export async function readProfile(path) {
|
|
19
|
+
const profile = parseProfile(JSON.parse(await readFile(path, 'utf8')));
|
|
20
|
+
const [schema, set] = await Promise.all([profile.schema, profile.animationSet].map(async (url) => JSON.parse(await readFile(publicPath(url), 'utf8'))));
|
|
21
|
+
return resolveProfile(profile, schema, set);
|
|
22
|
+
}
|
|
23
|
+
export async function loadProfileAssets(profile) {
|
|
24
|
+
const layout = compileSchema(profile.schemaDefinition);
|
|
25
|
+
const engine = new NullEngine(), scene = new Scene(engine);
|
|
26
|
+
scene.useRightHandedSystem = true;
|
|
27
|
+
const hook = SceneLoader.OnPluginActivatedObservable.add(p => { if (p instanceof GLTFFileLoader)
|
|
28
|
+
p.skipMaterials = true; });
|
|
29
|
+
const containers = new Map();
|
|
30
|
+
const dispose = () => { SceneLoader.OnPluginActivatedObservable.remove(hook); scene.dispose(); engine.dispose(); };
|
|
31
|
+
try {
|
|
32
|
+
for (const file of new Set([profile.model, ...profile.clips.map(c => c.file)])) {
|
|
33
|
+
const data = new Uint8Array(await readFile(publicPath(profile.assetBase + file)));
|
|
34
|
+
const container = await SceneLoader.LoadAssetContainerAsync('', data, scene, undefined, '.glb');
|
|
35
|
+
container.animationGroups.forEach(g => g.stop());
|
|
36
|
+
for (const node of container.rootNodes)
|
|
37
|
+
if (node instanceof TransformNode)
|
|
38
|
+
node.scaling.scaleInPlace(profile.scale);
|
|
39
|
+
containers.set(file, container);
|
|
40
|
+
}
|
|
41
|
+
const body = containers.get(profile.model);
|
|
42
|
+
const names = body.transformNodes.map(n => n.name);
|
|
43
|
+
if (new Set(names).size !== names.length)
|
|
44
|
+
throw new Error('Ambiguous duplicate model node names');
|
|
45
|
+
for (const node of [...Object.values(profile.rig), ...(profile.stripRoot ? [profile.stripRoot] : [])])
|
|
46
|
+
if (!names.includes(node))
|
|
47
|
+
throw new Error(`Model bone missing: ${node}`);
|
|
48
|
+
const warnings = [];
|
|
49
|
+
for (const clip of profile.clips) {
|
|
50
|
+
const container = containers.get(clip.file);
|
|
51
|
+
const groups = container.animationGroups.filter(g => g.name === clip.animation);
|
|
52
|
+
if (groups.length !== 1)
|
|
53
|
+
throw new Error(`${clip.id}: expected one animation named ${clip.animation}`);
|
|
54
|
+
const group = groups[0], fps = group.targetedAnimations[0]?.animation.framePerSecond;
|
|
55
|
+
if (!fps || group.to <= group.from || group.targetedAnimations.some(t => t.animation.framePerSecond !== fps))
|
|
56
|
+
throw new Error(`${clip.id}: empty clip or inconsistent FPS`);
|
|
57
|
+
if (clip.range && clip.range[1] > (group.to - group.from) / fps + 1e-6)
|
|
58
|
+
throw new Error(`${clip.id}: range exceeds source duration`);
|
|
59
|
+
const mapped = new Set();
|
|
60
|
+
for (const track of group.targetedAnimations) {
|
|
61
|
+
const target = profile.boneMap?.[track.target.name] ?? track.target.name;
|
|
62
|
+
if (!names.includes(target))
|
|
63
|
+
throw new Error(`${clip.id}: unmapped target ${track.target.name}; provide boneMap or retarget the animation`);
|
|
64
|
+
mapped.add(target);
|
|
65
|
+
}
|
|
66
|
+
for (const role of Object.values(profile.rig)) {
|
|
67
|
+
const matches = container.transformNodes.filter(n => (profile.boneMap?.[n.name] ?? n.name) === role);
|
|
68
|
+
if (matches.length !== 1)
|
|
69
|
+
throw new Error(`${clip.id}: missing/ambiguous source bone for ${role}`);
|
|
70
|
+
}
|
|
71
|
+
if (clip.motionFile) {
|
|
72
|
+
const motion = JSON.parse(await readFile(publicPath(profile.assetBase + clip.motionFile), 'utf8'));
|
|
73
|
+
validateRootMotion(motion);
|
|
74
|
+
if (Math.abs(motion.duration - (group.to - group.from) / fps) > 1e-5)
|
|
75
|
+
throw new Error(`${clip.id}: motion/GLB duration mismatch`);
|
|
76
|
+
}
|
|
77
|
+
if (layout.bones.some(role => !mapped.has(profile.rig[role])))
|
|
78
|
+
warnings.push(`${clip.id}: schema bones depend on inherited transforms; verify visible pose`);
|
|
79
|
+
}
|
|
80
|
+
for (const gait of ['idle', 'walk', 'run', 'strafe', 'turn', 'stop'])
|
|
81
|
+
if (!profile.clips.some(c => c.gait === gait))
|
|
82
|
+
warnings.push(`Coverage absent: ${gait}`);
|
|
83
|
+
warnings.push('Name compatibility does not validate bind pose, proportions or retargeting. Visually verify a new rig.');
|
|
84
|
+
return { scene, engine, containers, warnings, dispose };
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
dispose();
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Matrix, Quaternion, Vector3 } from '@babylonjs/core';
|
|
2
|
+
import { readGLB } from './glb.js';
|
|
3
|
+
/** Validate rest geometry, not animation names. Missing bind data is an error, never a pass. */
|
|
4
|
+
export function compareBindRigs(target, source, rootName, boneMap = {}, tolerance = .001) {
|
|
5
|
+
const bind = (bytes) => {
|
|
6
|
+
const { json, binary } = readGLB(bytes);
|
|
7
|
+
const skins = json.skins;
|
|
8
|
+
if (skins?.length !== 1)
|
|
9
|
+
throw new Error('Bind validation requires exactly one skin with inverse bind matrices');
|
|
10
|
+
const skin = skins[0], accessor = json.accessors[skin.inverseBindMatrices];
|
|
11
|
+
if (!accessor || accessor.type !== 'MAT4' || accessor.componentType !== 5126 || accessor.count !== skin.joints.length)
|
|
12
|
+
throw new Error('Unsupported inverse bind accessor');
|
|
13
|
+
const view = json.bufferViews[accessor.bufferView];
|
|
14
|
+
const data = new DataView(binary.buffer, binary.byteOffset, binary.byteLength), result = new Map();
|
|
15
|
+
skin.joints.forEach((node, index) => {
|
|
16
|
+
const name = json.nodes[node].name;
|
|
17
|
+
if (!name || result.has(name))
|
|
18
|
+
throw new Error('Ambiguous skin joint');
|
|
19
|
+
const offset = (view.byteOffset ?? 0) + (accessor.byteOffset ?? 0) + index * (view.byteStride ?? 64);
|
|
20
|
+
const matrix = Matrix.FromArray(Array.from({ length: 16 }, (_, j) => data.getFloat32(offset + j * 4, true)));
|
|
21
|
+
if (!matrix.asArray().every(Number.isFinite) || Math.abs(matrix.determinant()) < 1e-12)
|
|
22
|
+
throw new Error('Invalid inverse bind matrix');
|
|
23
|
+
result.set(name, Matrix.Invert(matrix));
|
|
24
|
+
});
|
|
25
|
+
return result;
|
|
26
|
+
};
|
|
27
|
+
const a = bind(target), b = bind(source), sourceRoot = [...b.keys()].filter(name => (boneMap[name] ?? name) === rootName);
|
|
28
|
+
if (!a.has(rootName) || sourceRoot.length !== 1)
|
|
29
|
+
throw new Error('Missing/ambiguous bind root');
|
|
30
|
+
const inverseA = Matrix.Invert(a.get(rootName)), inverseB = Matrix.Invert(b.get(sourceRoot[0])), errors = [];
|
|
31
|
+
for (const [name, matrix] of b) {
|
|
32
|
+
const mapped = boneMap[name] ?? name, targetMatrix = a.get(mapped);
|
|
33
|
+
if (!targetMatrix) {
|
|
34
|
+
errors.push(`Missing mapped bind joint: ${name} -> ${mapped}`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const qa = new Quaternion(), qb = new Quaternion(), pa = new Vector3(), pb = new Vector3(), sa = new Vector3(), sb = new Vector3();
|
|
38
|
+
targetMatrix.multiply(inverseA).decompose(sa, qa, pa);
|
|
39
|
+
matrix.multiply(inverseB).decompose(sb, qb, pb);
|
|
40
|
+
if (Vector3.Distance(pa, pb) > tolerance || Vector3.Distance(sa, sb) > tolerance || 1 - Math.abs(Quaternion.Dot(qa, qb)) > 1e-5)
|
|
41
|
+
errors.push(`Bind mismatch: ${name} -> ${mapped} (position ${Vector3.Distance(pa, pb).toFixed(5)} m)`);
|
|
42
|
+
}
|
|
43
|
+
for (const name of a.keys())
|
|
44
|
+
if (![...b.keys()].some(sourceName => (boneMap[sourceName] ?? sourceName) === name))
|
|
45
|
+
errors.push('Missing source bind joint: ' + name);
|
|
46
|
+
return errors;
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { NullEngine, Scene, SceneLoader } from '@babylonjs/core';
|
|
3
|
+
import { GLTFFileLoader } from '@babylonjs/loaders/glTF/index.js';
|
|
4
|
+
import { suggestRig } from '../src/index.js';
|
|
5
|
+
import { loadProfileAssets, profileArgument, publicPath, readProfile } from './profile-assets.js';
|
|
6
|
+
import { compareBindRigs } from './rig.js';
|
|
7
|
+
const inspectAt = process.argv.indexOf('--inspect');
|
|
8
|
+
if (inspectAt >= 0) {
|
|
9
|
+
const path = process.argv[inspectAt + 1];
|
|
10
|
+
if (!path)
|
|
11
|
+
throw new Error('Provide a local GLB path after --inspect');
|
|
12
|
+
const engine = new NullEngine(), scene = new Scene(engine);
|
|
13
|
+
scene.useRightHandedSystem = true;
|
|
14
|
+
const hook = SceneLoader.OnPluginActivatedObservable.add(p => { if (p instanceof GLTFFileLoader)
|
|
15
|
+
p.skipMaterials = true; });
|
|
16
|
+
try {
|
|
17
|
+
const container = await SceneLoader.LoadAssetContainerAsync('', new Uint8Array(await readFile(path)), scene, undefined, '.glb');
|
|
18
|
+
const nodes = container.transformNodes.map(n => n.name);
|
|
19
|
+
console.log(JSON.stringify({ nodes, animations: container.animationGroups.map(g => g.name), suggestions: suggestRig(nodes), note: 'Suggestions require explicit confirmation in a profile; ambiguous roots are not selected automatically.' }, null, 2));
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
SceneLoader.OnPluginActivatedObservable.remove(hook);
|
|
23
|
+
scene.dispose();
|
|
24
|
+
engine.dispose();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const profile = await readProfile(profileArgument()), assets = await loadProfileAssets(profile);
|
|
29
|
+
try {
|
|
30
|
+
if (process.argv.includes('--check-bind')) {
|
|
31
|
+
const target = new Uint8Array(await readFile(publicPath(profile.assetBase + profile.model)));
|
|
32
|
+
for (const file of new Set(profile.clips.map(c => c.file))) {
|
|
33
|
+
const errors = compareBindRigs(target, new Uint8Array(await readFile(publicPath(profile.assetBase + file))), profile.rig.root, profile.boneMap);
|
|
34
|
+
if (errors.length)
|
|
35
|
+
throw new Error(`${file}: incompatible bind geometry; retarget offline first\n${errors.join('\n')}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
console.log(JSON.stringify({ profile: profile.id, valid: true, bindChecked: process.argv.includes('--check-bind'), clips: profile.clips.length, warnings: assets.warnings }, null, 2));
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
assets.dispose();
|
|
42
|
+
}
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,39 +1,48 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@pose_flow/babylon",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"license": "MIT",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"files": [
|
|
7
|
-
"dist"
|
|
8
|
-
],
|
|
9
|
-
"exports": {
|
|
10
|
-
".": {
|
|
11
|
-
"types": "./dist/index.d.ts",
|
|
12
|
-
"import": "./dist/index.js"
|
|
13
|
-
},
|
|
14
|
-
"./babylon": {
|
|
15
|
-
"types": "./dist/babylon.d.ts",
|
|
16
|
-
"import": "./dist/babylon.js"
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"@babylonjs/
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@pose_flow/babylon",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./babylon": {
|
|
15
|
+
"types": "./dist/babylon.d.ts",
|
|
16
|
+
"import": "./dist/babylon.js"
|
|
17
|
+
},
|
|
18
|
+
"./AI_USAGE.md": "./dist/AI_USAGE.md",
|
|
19
|
+
"./PUBLISHING.md": "./dist/PUBLISHING.md"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"dev": "vite --host 127.0.0.1",
|
|
23
|
+
"build": "tsc -p tsconfig.build.json && tsc -p tsconfig.offline.json && vite build && node tools/copy-docs.mjs",
|
|
24
|
+
"prepack": "npm run build",
|
|
25
|
+
"check:docs": "node tools/copy-docs.mjs --check",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "tsx --test tests/*.test.ts",
|
|
28
|
+
"bake": "tsx tools/bake.ts",
|
|
29
|
+
"validate:assets": "tsx tools/validate-assets.ts",
|
|
30
|
+
"benchmark": "tsx tools/benchmark.ts"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@babylonjs/core": "^9.32.1"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@babylonjs/core": "9.32.1",
|
|
37
|
+
"@types/node": "^22.0.0",
|
|
38
|
+
"tsx": "^4.19.0",
|
|
39
|
+
"typescript": "^5.7.0",
|
|
40
|
+
"vite": "^6.0.0"
|
|
41
|
+
},
|
|
42
|
+
"bin": {
|
|
43
|
+
"pose-flow": "./dist/offline/tools/cli.js"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@babylonjs/loaders": "9.32.1"
|
|
47
|
+
}
|
|
48
|
+
}
|