Newer
Older
rpg / v2 / packages / engine / src / models / __tests__ / mannequin.test.ts
import { describe, expect, it } from 'vitest';
import { mannequin } from '../mannequin';
import { validateRig, poseVoxels } from '../../animation/rig';
import { decodeModel } from '../format';

describe('mannequin (манекен + рига)', () => {
    it('детерминирован', () => {
        expect(JSON.stringify(mannequin())).toBe(JSON.stringify(mannequin()));
    });

    it('ригa проходит числовую валидацию', () => {
        const m = mannequin();
        expect(validateRig(m.rig, m.model)).toEqual([]);
    });

    it('состав частей: ноги 44 ×2, руки 32 ×2, корень 358 (торс+голова+детали)', () => {
        const { model, rig } = mannequin();
        const g = decodeModel(model);
        expect(g.count()).toBe(498); // 44×2 ноги + 32×2 руки + 346 корень
        const byBone = [0, 0, 0, 0, 0];
        for (let i = 0; i < g.data.length; i++)
            if (g.data[i] !== 0) byBone[rig.binding[i]]++;
        expect(byBone).toEqual([346, 44, 44, 32, 32]);
    });

    it('детали силуэта на месте: глаза-акценты, нос к +Z, мыски сапог', () => {
        const g = decodeModel(mannequin().model);
        const at = (x: number, y: number, z: number) => g.data[x + g.sx * (y + g.sy * z)];
        expect(at(5, 21, 5)).toBe(10); // глаз (тёмный, слот EYE) на фасаде
        expect(at(8, 21, 5)).toBe(10);
        expect(at(6, 20, 6)).toBe(7); // нос (кожа) выступает к +Z
        expect(at(3, 0, 6)).toBe(2); // мысок сапога
        expect(at(6, 18, 3)).toBe(7); // шея
    });

    it('палитра — параметр: слоты красятся, форма не меняется', () => {
        const plain = mannequin().model;
        const tinted = mannequin({ torso: '#111111', skin: '#222222' }).model;
        expect(JSON.stringify(mannequin().model)).toBe(JSON.stringify(plain)); // дефолт детерминирован
        expect(tinted.palette[4]).toBe('#111111'); // торс перекрашен
        expect(tinted.palette[7]).toBe('#222222'); // кожа перекрашена
        expect(tinted.palette[6]).toBe(plain.palette[6]); // штаны — дефолт
        expect(tinted.data).toBe(plain.data); // форма не зависит от палитры
    });

    it('каждая часть — связное тело (6-связность)', () => {
        const { model, rig } = mannequin();
        const g = decodeModel(model);
        const cells = (bone: number) => {
            const out: [number, number, number][] = [];
            for (let i = 0; i < g.data.length; i++)
                if (g.data[i] !== 0 && rig.binding[i] === bone) {
                    const x = i % g.sx, y = Math.floor(i / g.sx) % g.sy, z = Math.floor(i / (g.sx * g.sy));
                    out.push([x, y, z]);
                }
            return out;
        };
        for (const bone of [1, 2, 3, 4]) {
            const col = cells(bone);
            expect(col.length).toBeGreaterThan(3);
            // BFS от первой клетки: все воксели части достижимы соседством граней
            const seen = new Set([col[0]!.join(',')]);
            const queue = [col[0]!];
            while (queue.length) {
                const [x, y, z] = queue.shift()!;
                for (const [nx, ny, nz] of [[x + 1, y, z], [x - 1, y, z], [x, y + 1, z], [x, y - 1, z], [x, y, z + 1], [x, y, z - 1]]) {
                    const key = `${nx},${ny},${nz}`;
                    if (seen.has(key)) continue;
                    if (!col.some((c) => c[0] === nx && c[1] === ny && c[2] === nz)) continue;
                    seen.add(key);
                    queue.push([nx, ny, nz]);
                }
            }
            expect(seen.size).toBe(col.length);
        }
    });

    it('поворот ноги двигает ногу, но не торс (жёсткая привязка частей)', () => {
        const { model, rig } = mannequin();
        const bindPosed = poseVoxels(rig, model, {});
        const swing = poseVoxels(rig, model, { legL: { rot: [0.6, 0, 0] } });
        for (const a of bindPosed) {
            const b = swing.find((v) => v.index === a.index)!;
            const moved = Math.abs(a.pos[0] - b.pos[0]) + Math.abs(a.pos[1] - b.pos[1]) + Math.abs(a.pos[2] - b.pos[2]);
            const isLegL = rig.binding[a.index] === 1;
            if (isLegL) expect(moved).toBeGreaterThan(0.1);
            else expect(moved).toBe(0);
        }
    });
});