Newer
Older
rpg / v2 / packages / engine / src / animation / __tests__ / rig.test.ts
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]);
    });
});