import { describe, expect, it } from 'vitest';
import { makeRig, bindNearest, validateRig, poseVoxels, boneWorlds } from '../rig';
import type { VoxelModel } from '../../models/format';
import { affinePoint } from '../mat';
/** Модель 3×1×1: три вокселя вдоль X с разными слотами. */
function model3(): VoxelModel {
return {
size: [3, 1, 1],
palette: { 1: '#101010', 2: '#202020', 3: '#303030' },
data: btoa(String.fromCharCode(1, 2, 3)),
};
}
/** Рига: корень в левом вокселе, «кончик» в правом. */
function rig3() {
return makeRig(
[
{ name: 'root', parent: -1, pivot: [0.5, 0.5, 0.5] },
{ name: 'tip', parent: 0, pivot: [2.5, 0.5, 0.5] },
],
model3(),
[0, 0, 1],
);
}
describe('makeRig/bindNearest (привязка вокселей к костям)', () => {
it('ближайший pivot, при равенстве — меньший индекс кости', () => {
const binding = bindNearest(
[
{ name: 'l', parent: -1, pivot: [0.5, 0.5, 0.5] },
{ name: 'r', parent: 0, pivot: [2.5, 0.5, 0.5] },
],
model3(),
);
expect(binding).toEqual([0, 0, 1]); // воксель 1 равноудалён (1.0 vs 1.0) — остаётся меньший индекс 0
});
it('явная привязка передаётся как есть', () => {
expect(rig3().binding).toEqual([0, 0, 1]);
});
});
describe('validateRig (числовая валидация агентом)', () => {
it('корректная рига — пустой список проблем', () => {
expect(validateRig(rig3(), model3())).toEqual([]);
});
it('ловит цикл (родитель не раньше кости) и чужой индекс родителя', () => {
const rig = rig3();
rig.bones[0].parent = 1;
const problems = validateRig(rig, model3());
expect(problems.some((p) => p.includes('не раньше'))).toBe(true);
rig.bones[0].parent = -1;
rig.bones[1].parent = 5;
expect(validateRig(rig, model3()).some((p) => p.includes('вне риги'))).toBe(true);
});
it('ловит длину привязки и пустые/висячие воксели', () => {
const model = model3();
expect(validateRig({ bones: rig3().bones, binding: [0, 0] }, model).some((p) => p.includes('байт'))).toBe(true);
expect(validateRig({ bones: rig3().bones, binding: [0, -1, 1] }, model).some((p) => p.includes('не привязан'))).toBe(true);
expect(validateRig({ bones: rig3().bones, binding: [0, 0, 7] }, model).some((p) => p.includes('вне риги'))).toBe(true);
});
});
describe('poseVoxels (деформация)', () => {
it('сдвиг корня двигает все воксели', () => {
const posed = poseVoxels(rig3(), model3(), { root: { pos: [1, 0, 0] } });
expect(posed.map((v) => v.pos[0])).toEqual([1.5, 2.5, 3.5]);
expect(posed.map((v) => v.color)).toEqual(['#101010', '#202020', '#303030']);
});
it('поворот кости двигает только её воксели, вокруг pivot', () => {
const posed = poseVoxels(rig3(), model3(), { tip: { rot: [0, 0, Math.PI / 2] } });
// воксель кончика (центр 2.5,0.5,0.5) повёрнут вокруг (2.5,0.5,0.5) — на месте,
// но повернуть нечего: воксель совпадает с pivot; проверим сдвигом вниз на 1
const posed2 = poseVoxels(rig3(), model3(), { tip: { pos: [0, -1, 0] } });
expect(posed2[0].pos).toEqual([0.5, 0.5, 0.5]);
expect(posed2[2].pos).toEqual([2.5, -0.5, 0.5]);
expect(posed[2].pos).toEqual([2.5, 0.5, 0.5]);
});
it('поворот родителя тянет за собой детей (иерархия)', () => {
const posed = poseVoxels(rig3(), model3(), { root: { rot: [0, 0, Math.PI / 2] } });
// центр вокселя 2: (2.5,0.5,0.5) → поворот Z на 90° вокруг (0.5,0.5,0.5) → (0.5,2.5,0.5)
expect(posed[2].pos[0]).toBeCloseTo(0.5);
expect(posed[2].pos[1]).toBeCloseTo(2.5);
// мир-трансформ ребёнка композиция родителя
const worlds = boneWorlds(rig3(), {});
expect(affinePoint(worlds[1], [2.5, 0.5, 0.5])).toEqual([2.5, 0.5, 0.5]);
});
});