import { describe, expect, it } from 'vitest';
import { VoxelGrid } from '../../voxel/grid';
import { decodeModel, encodeModel } from '../format';
import { exportVox, parseVox } from '../vox';
/** Сетка с редкими слотами и «лестницей» по диагонали (проверка осей). */
function makeGrid(): VoxelGrid {
const g = new VoxelGrid(3, 4, 2);
g.set(0, 0, 0, 5); // слоты специально не подряд: 1, 3, 7
g.set(1, 0, 0, 1);
g.set(1, 1, 0, 3);
g.set(1, 2, 1, 7);
return g;
}
const PALETTE: Record<string, string> = { 1: '#4a5340', 3: '#626c4c', 5: '#5b5b66', 7: '#94949e' };
describe('.vox импорт/экспорт', () => {
it('roundtrip: геометрия и цвета сохраняются (слоты могут переехать)', () => {
const src = makeGrid();
const back = parseVox(exportVox(encodeModel(src, PALETTE)));
const backGrid = decodeModel(back);
expect([backGrid.sx, backGrid.sy, backGrid.sz]).toEqual([3, 4, 2]);
// набор занятых позиций с их цветами совпадает
const cellsOf = (g: VoxelGrid, pal: Record<string, string>): string[] => {
const out: string[] = [];
for (let z = 0; z < g.sz; z++)
for (let y = 0; y < g.sy; y++)
for (let x = 0; x < g.sx; x++)
if (g.get(x, y, z) !== 0) out.push(`${x},${y},${z}:${pal[String(g.get(x, y, z))]}`);
return out.sort();
};
expect(cellsOf(backGrid, back.palette)).toEqual(cellsOf(src, PALETTE));
// цвета доездились: исходный hex каждого слота есть в новой палитре
for (const slot of Object.keys(PALETTE)) {
expect(Object.values(back.palette)).toContain(PALETTE[Number(slot)]);
}
});
it('оси: экспорт пишет vox (x, y=движок z, z=движок y); импорт симметричен', () => {
// воксель движка (1,2,3) → в .vox должен лечь как (x=1, y=3, z=2)
const g = new VoxelGrid(4, 4, 4);
g.set(1, 2, 3, 1);
const bytes = exportVox(encodeModel(g, { 1: '#111111' }));
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
// XYZI — второй чанк: 20 + 24(SIZE) + 12(заголовок XYZI) = 56, счётчик + воксель
expect(dv.getInt32(56, true)).toBe(1);
expect([bytes[60], bytes[61], bytes[62], bytes[63]]).toEqual([1, 3, 2, 1]);
// roundtrip сохраняет координаты движка 1:1 (маппинг осей — только в байтах)
const back = decodeModel(parseVox(bytes));
expect(back.get(1, 2, 3)).toBe(1);
expect(back.count()).toBe(1);
});
it('экспорт: валидный .vox с магией, MAIN и тремя чанками', () => {
const bytes = exportVox(encodeModel(makeGrid(), PALETTE));
const head = String.fromCharCode(...bytes.slice(0, 8));
expect(head.startsWith('VOX ')).toBe(true);
// childrenSize MAIN: всё после заголовка MAIN (20 байт от начала)
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
expect(dv.getInt32(16, true)).toBe(bytes.length - 20);
// id чанков по порядку
let pos = 20;
const ids: string[] = [];
while (pos < bytes.length) {
ids.push(String.fromCharCode(...bytes.slice(pos, pos + 4)));
pos += 12 + dv.getInt32(pos + 4, true) + dv.getInt32(pos + 8, true);
}
expect(ids).toEqual(['SIZE', 'XYZI', 'RGBA']);
});
it('parseVox бросает: не .vox, нет RGBA, PACK>1', () => {
expect(() => parseVox(new Uint8Array([0, 1, 2, 3]))).toThrow(/VOX/);
// валидный .vox без RGBA: соберём export-подобные байты вручную
const noRgba = exportVox(encodeModel(makeGrid(), PALETTE));
// откусить чанк RGBA: он последний; его длина = 12 + 1024
const cut = noRgba.length - (12 + 1024);
const dv = new DataView(noRgba.buffer, noRgba.byteOffset, noRgba.length);
const mainAt = 16;
dv.setInt32(mainAt, dv.getInt32(mainAt, true) - (12 + 1024), true);
expect(() => parseVox(noRgba.slice(0, cut))).toThrow(/RGBA/);
// PACK>1: подменим SIZE на PACK с числом 2
const packed = exportVox(encodeModel(makeGrid(), PALETTE));
const pdv = new DataView(packed.buffer, packed.byteOffset, packed.byteLength);
pdv.setInt32(20, 0x4b434150, true); // 'PACK'
pdv.setInt32(32, 2, true); // numModels = 2
expect(() => parseVox(packed)).toThrow(/PACK/);
});
});