import { describe, expect, it } from 'vitest';
import { VoxelGrid, hexToRgb } from '../grid';
import { meshVoxels } from '../mesher';
const PALETTE = { colors: { 1: '#808080', 2: '#00ff00' } };
describe('VoxelGrid', () => {
it('set/get и границы: вне сетки всегда пусто', () => {
const g = new VoxelGrid(2, 3, 4);
g.set(1, 2, 3, 7);
expect(g.get(1, 2, 3)).toBe(7);
expect(g.get(2, 2, 3)).toBe(0);
expect(g.get(-1, 0, 0)).toBe(0);
g.set(100, 0, 0, 5); // игнорируется
expect(g.count()).toBe(1);
});
it('set валидирует цвет, конструктор — размеры', () => {
const g = new VoxelGrid(1, 1, 1);
expect(() => g.set(0, 0, 0, 256)).toThrow();
expect(() => new VoxelGrid(0, 1, 1)).toThrow();
});
it('hexToRgb: короткая и полная формы, ошибка на мусоре', () => {
expect(hexToRgb('#f00')).toEqual([1, 0, 0]);
expect(hexToRgb('#0080ff')).toEqual([0, 128 / 255, 1]);
expect(() => hexToRgb('#zzz')).toThrow();
});
});
describe('meshVoxels — culled-меш', () => {
it('один воксель: 6 граней, 24 вершины, 12 треугольников, все нормали по осям', () => {
const g = new VoxelGrid(1, 1, 1);
g.set(0, 0, 0, 1);
const m = meshVoxels(g, PALETTE);
expect(m.vertexCount).toBe(24);
expect(m.triangleCount).toBe(12);
// все 6 осевых направлений присутствуют (нормали не смешанные)
const dirs = new Set<string>();
for (let v = 0; v < m.vertexCount; v++) {
dirs.add(`${m.normals[v * 3]},${m.normals[v * 3 + 1]},${m.normals[v * 3 + 2]}`);
}
expect(dirs.size).toBe(6);
});
it('winding согласован с нормалью: cross(v1-v0, v2-v0) ≈ normal (для three.js)', () => {
const g = new VoxelGrid(2, 2, 2);
for (let x = 0; x < 2; x++) for (let y = 0; y < 2; y++) g.set(x, y, 0, 1), g.set(x, y, 1, 1);
const m = meshVoxels(g, PALETTE);
for (let t = 0; t < m.indices.length; t += 3) {
const i = [m.indices[t], m.indices[t + 1], m.indices[t + 2]];
const p = i.map((k) => [m.positions[k * 3], m.positions[k * 3 + 1], m.positions[k * 3 + 2]]);
const e1 = [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]];
const e2 = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]];
const cr = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
const n = i.map((k) => m.normals[k * 3]);
void n;
// осевые компоненты нормали совпадают с компонентами cross
for (let a = 0; a < 3; a++) {
const axisVal = m.normals[i[0] * 3 + a];
if (axisVal !== 0) expect(cr[a]).toBeCloseTo(axisVal, 5);
}
}
});
it('внутренние грани сплошного блока срезаны: поверхность 6×9 граней', () => {
const g = new VoxelGrid(3, 3, 3);
for (let x = 0; x < 3; x++)
for (let y = 0; y < 3; y++) for (let z = 0; z < 3; z++) g.set(x, y, z, 1);
const m = meshVoxels(g, PALETTE);
expect(m.vertexCount).toBe(6 * 9 * 4);
});
it('соседние воксели не выпускают общую грань', () => {
const g = new VoxelGrid(2, 1, 1);
g.set(0, 0, 0, 1);
g.set(1, 0, 0, 1);
const m = meshVoxels(g, PALETTE);
expect(m.vertexCount).toBe(10 * 4); // 12 граней − 1 скрытая
});
});
describe('meshVoxels — AO', () => {
it('вершина, зажатая двумя боковыми соседями, максимально темная (ao=0)', () => {
const g = new VoxelGrid(3, 3, 3);
g.set(1, 1, 1, 1); // исследуемый воксель
g.set(2, 2, 1, 1); // сосед сбоку по y+1
g.set(2, 1, 0, 1); // сосед сбоку по z-1
const m = meshVoxels(g, PALETTE);
const i0 = m.ao.indexOf(0);
expect(i0).toBeGreaterThanOrEqual(0);
// вершина с ao=0 лежит в углу (2,2,1)-грани +X: позиция (2,2,1)
expect(m.positions[i0 * 3]).toBeCloseTo(2);
expect(m.positions[i0 * 3 + 1]).toBeCloseTo(2);
expect(m.positions[i0 * 3 + 2]).toBeCloseTo(1);
});
it('AO затемняет землю рядом со стоящим блоком (ao=2 у стыка)', () => {
const g = new VoxelGrid(3, 2, 1);
g.set(0, 0, 0, 1);
g.set(2, 0, 0, 1); // земля
g.set(1, 1, 0, 1); // блок на земле
const m = meshVoxels(g, PALETTE);
const dark = m.ao.filter((a) => a === 2).length;
expect(dark).toBeGreaterThan(0);
// вся AO в допустимом диапазоне
for (const a of m.ao) expect(a).toBeGreaterThanOrEqual(0), expect(a).toBeLessThanOrEqual(3);
});
it('цвет запекается с яркостью AO: тёмная вершина темнее цвета палитры', () => {
const g = new VoxelGrid(3, 3, 3);
g.set(1, 1, 1, 1);
g.set(2, 2, 1, 1);
g.set(2, 1, 0, 1);
const m = meshVoxels(g, PALETTE);
const i0 = m.ao.indexOf(0);
expect(m.colors[i0 * 3]).toBeCloseTo(0x80 / 255 * 0.45, 3);
const bright = m.ao.indexOf(3);
expect(m.colors[bright * 3]).toBeCloseTo(0x80 / 255, 3);
});
it('флип диагонали: у стыка с тёмными углами треугольники идут через тёмную пару', () => {
// Сетка, где у грани +X вокселя (1,1,1) сумма AO порядка (0,3) > (3,2) — флип включается
const g = new VoxelGrid(4, 4, 4);
g.set(1, 1, 1, 1);
g.set(2, 2, 1, 1);
g.set(2, 1, 0, 1);
const m = meshVoxels(g, PALETTE);
// ищем квадроугольник грани +X: вершины (2,1,1),(2,2,1),(2,2,2),(2,1,2)
const want = [[2, 1, 1], [2, 2, 1], [2, 2, 2], [2, 1, 2]];
const s = findQuadStart(m, want);
expect(s).toBeGreaterThanOrEqual(0);
expect(findQuadIndices(m, s)).toEqual([s + 1, s + 2, s + 3, s + 1, s + 3, s]);
});
it('без стыка диагональ дефолтная (v0-v2)', () => {
const g = new VoxelGrid(2, 1, 1);
g.set(0, 0, 0, 1);
const m = meshVoxels(g, PALETTE);
// грань +X вокселя (0,0,0): вершины (1,0,0),(1,1,0),(1,1,1),(1,0,1)
const s = findQuadStart(m, [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]]);
expect(s).toBeGreaterThanOrEqual(0);
expect(findQuadIndices(m, s)).toEqual([s, s + 1, s + 2, s, s + 2, s + 3]);
});
});
/** Ищет стартовый индекс вершины квадроугольника с заданными позициями. */
function findQuadStart(m: ReturnType<typeof meshVoxels>, want: number[][]): number {
for (let v = 0; v + 3 < m.vertexCount; v += 4) {
let ok = true;
for (let k = 0; k < 4 && ok; k++) {
for (let a = 0; a < 3; a++) {
if (Math.abs(m.positions[(v + k) * 3 + a] - want[k][a]) > 1e-6) ok = false;
}
}
if (ok) return v;
}
return -1;
}
/** Ищет в массиве индексов пару треугольников квадроугольника с вершинами s..s+3. */
function findQuadIndices(m: ReturnType<typeof meshVoxels>, s: number): number[] {
const def = [s, s + 1, s + 2, s, s + 2, s + 3];
const flip = [s + 1, s + 2, s + 3, s + 1, s + 3, s];
for (let i = 0; i + 5 < m.indices.length; i++) {
const seg = m.indices.slice(i, i + 6);
if (seg.join(',') === def.join(',')) return def;
if (seg.join(',') === flip.join(',')) return flip;
}
return [];
}
describe('текстурный слой мешера (texAt → tiles/uvs)', () => {
it('без texAt: tiles нулевые, uvs в пределах грани', () => {
const g = new VoxelGrid(1, 1, 1);
g.set(0, 0, 0, 1);
const m = meshVoxels(g, PALETTE);
expect(m.tiles).toEqual(new Array(m.vertexCount).fill(0));
expect(m.uvs.length).toBe(m.vertexCount * 2);
for (let i = 0; i < m.uvs.length; i++) expect(m.uvs[i]).toBeGreaterThanOrEqual(0);
});
it('с texAt: 4 вершины грани несут один id тайля, uvs образуют квадрат 0..1', () => {
const g = new VoxelGrid(2, 1, 1);
g.set(0, 0, 0, 1);
g.set(1, 0, 0, 1);
const m = meshVoxels(g, PALETTE, { texAt: (x) => (x === 0 ? 5 : 0) });
// у вокселя x=0 все 6 граней с тайлем 5, у x=1 — нули
let fives = 0, zeros = 0;
for (const t of m.tiles) (t === 5 ? fives++ : zeros++);
expect(fives).toBe(20); // 5 видимых граней × 4 вершины (общая срезана)
expect(zeros).toBe(20);
// uv-квадрат: (0,0),(1,0),(1,1),(0,1) — 4 вершины первой грани
const q = m.uvs.slice(0, 8);
expect(q).toEqual([0, 0, 0, 1, 1, 1, 1, 0]); // первая грань −x (ORDER_NEG)
});
});