/**
* Импорт/экспорт .vox (MagicaVoxel) — мост к ручному пути героев.
* Поддерживаем минимум формата: один модельный чанк, SIZE + XYZI + RGBA.
* Оси: у .vox z — вверх, у движка y — вверх: (x,y,z)_engine = (x,z,y)_vox.
* Цвета: RGBA-палитра обязательна (MagicaVoxel её всегда пишет); слот
* движка = индекс цвета .vox 1..255. При экспорте слоты модели сжимаются
* в подряд идущие индексы 1..N, при импорте индекс становится слотом —
* геометрия и цвета сохраняются, номера слотов могут отличаться.
*
* Спецификация: github.com/ephtracy/voxel-model.
*/
import { VoxelGrid } from '../voxel/grid';
import { encodeModel, decodeModel, assertModelValid } from './format';
import type { VoxelModel } from './format';
const MAX_DIM = 1024;
/** Парсит .vox-байты в модель. Бросает на непонятном формате. */
export function parseVox(bytes: Uint8Array): VoxelModel {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const reader = new ChunkReader(bytes, view);
// магия «VOX » + версия int32 — не чанк, читаем напрямую (8 байт)
const magic = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]);
if (magic !== 'VOX ') throw new Error('parseVox: не .vox-файл (нет магии «VOX »)');
reader.skip(8);
let size: [number, number, number] | null = null;
const xs: number[] = [], ys: number[] = [], zs: number[] = [], slots: number[] = [];
let palette: Record<string, string> | null = null;
if (reader.id() !== 'MAIN') throw new Error('parseVox: жду чанк MAIN');
while (!reader.done()) {
const chunk = reader.id(); // читает id и заголовок, позиция — на содержимом
if (chunk === 'SIZE') {
const vx = reader.i32(), vy = reader.i32(), vz = reader.i32();
size = [vx, vz, vy]; // оси: движок y = vox z, движок z = vox y
reader.skipChildren();
} else if (chunk === 'XYZI') {
const n = reader.i32();
for (let i = 0; i < n; i++) {
xs.push(reader.u8()); zs.push(reader.u8()); ys.push(reader.u8());
slots.push(reader.u8());
}
reader.skipChildren();
} else if (chunk === 'RGBA') {
palette = {};
reader.skip(4); // запись 0 палитры не используется
for (let i = 1; i <= 255; i++) {
palette[i] = `#${hex2(reader.u8())}${hex2(reader.u8())}${hex2(reader.u8())}`;
reader.skip(1); // альфа
}
reader.skipChildren();
} else if (chunk === 'PACK') {
if (reader.i32() > 1) throw new Error('parseVox: multi-model .vox (PACK>1) не поддерживается');
// содержимое пропущено; дети PACK (SIZE/XYZI модели) читаем дальше как обычно
} else {
reader.skipRest(); // неизвестный чанк — содержимое и дети целиком мимо
}
}
if (!size) throw new Error('parseVox: нет чанка SIZE');
if (size.some((n) => n <= 0 || n > MAX_DIM)) throw new Error(`parseVox: плохой размер ${size}`);
if (!palette) throw new Error('parseVox: нет чанка RGBA (палитра обязательна)');
const grid = new VoxelGrid(size[0], size[1], size[2]);
for (let i = 0; i < slots.length; i++) {
if (slots[i] === 0) continue; // индекс 0 — фоновый, вокселем не считаем
if (xs[i] >= size[0] || ys[i] >= size[1] || zs[i] >= size[2]) {
throw new Error(`parseVox: воксель (${xs[i]},${ys[i]},${zs[i]}) вне размера ${size}`);
}
grid.set(xs[i], ys[i], zs[i], slots[i]);
}
// палитру прореживаем до занятых слотов (в .vox она всегда на 256 записей)
const used = new Set(grid.data.filter((b) => b !== 0));
const slim = Object.fromEntries(
Object.entries(palette).filter(([slot]) => used.has(Number(slot))),
);
return encodeModel(grid, slim);
}
/**
* Экспортирует модель в .vox-байты: VOX-заголовок, MAIN с детьми
* SIZE/XYZI/RGBA. Слоты → подряд 1..N (отсортированные), палитра — hex модели.
*/
export function exportVox(model: VoxelModel): Uint8Array {
assertModelValid(model);
const grid = decodeModel(model);
const slots = Object.keys(model.palette).map(Number).sort((a, b) => a - b);
if (slots.length === 0) throw new Error('exportVox: пустая палитра');
const slotToIdx = new Map(slots.map((s, i) => [s, i + 1]));
const voxels: number[] = [];
for (let y = 0; y < grid.sy; y++)
for (let z = 0; z < grid.sz; z++)
for (let x = 0; x < grid.sx; x++) {
const c = grid.get(x, y, z);
if (c !== 0) voxels.push(x, z, y, slotToIdx.get(c)!); // y ↔ z
}
const out: number[] = [];
const chunk = (idStr: string, body: number[]): void => {
out.push(...str4(idStr), ...i32(body.length), ...i32(0), ...body);
};
out.push(...str4('VOX '), ...i32(150));
out.push(...str4('MAIN'), ...i32(0), ...i32(0));
chunk('SIZE', [...i32(grid.sx), ...i32(grid.sz), ...i32(grid.sy)]);
chunk('XYZI', [...i32(voxels.length / 4), ...voxels]);
const rgba: number[] = [0, 0, 0, 0]; // запись 0 палитры не используется
for (let i = 1; i <= 255; i++) {
const slot = slots[i - 1]; // индекс i → i-й слот модели (подряд)
if (slot === undefined) rgba.push(0, 0, 0, 255);
else {
const [r, g, b] = hexRgbBytes(model.palette[String(slot)]);
rgba.push(r, g, b, 255);
}
}
chunk('RGBA', rgba); // ровно 256 записей = 1024 байта, как требует формат
// структура: VOX+версия (8) → MAIN id (4) + contentSize (4) → childrenSize на 16..19
out.splice(16, 4, ...i32(out.length - 20));
return new Uint8Array(out);
}
/** Читатель чанков: id → заголовок (длина содержимого + детей) → содержимое. */
class ChunkReader {
pos = 0;
/** Начало содержимого последнего прочитанного заголовка. */
private contentStart = 0;
constructor(private readonly bytes: Uint8Array, private readonly view: DataView) {}
done(): boolean { return this.pos >= this.bytes.length; }
/** 4 символа id чанка + пропуск заголовка; позиция встаёт на содержимое. */
id(): string {
if (this.pos + 12 > this.bytes.length) throw new Error('parseVox: файл оборван посреди заголовка чанка');
const s = String.fromCharCode(
this.bytes[this.pos], this.bytes[this.pos + 1],
this.bytes[this.pos + 2], this.bytes[this.pos + 3],
);
this.pos += 12; // id + contentSize + childrenSize
this.contentStart = this.pos;
return s;
}
/** Длина содержимого текущего чанка. */
contentSize(): number { return this.view.getInt32(this.contentStart - 8, true); }
/** Длина детей текущего чанка. */
childrenSize(): number { return this.view.getInt32(this.contentStart - 4, true); }
/** Пропустить остаток чанка: непрочитанное содержимое + детей. */
skipRest(): void {
this.pos = this.contentStart + this.contentSize() + this.childrenSize();
}
/** Пропустить детей известного чанка (у SIZE/XYZI/RGBA их не ждём). */
skipChildren(): void {
this.pos += this.childrenSize();
}
i32(): number {
if (this.pos + 4 > this.bytes.length) throw new Error('parseVox: файл оборван посреди чанка');
const v = this.view.getInt32(this.pos, true); this.pos += 4; return v;
}
u8(): number {
if (this.pos >= this.bytes.length) throw new Error('parseVox: файл оборван посреди чанка');
return this.bytes[this.pos++];
}
skip(n: number): void { this.pos += n; }
}
function str4(s: string): number[] {
return [...s].map((ch) => ch.charCodeAt(0));
}
/** Число int32 → 4 байта LE. */
function i32(n: number): number[] {
return [n & 255, (n >> 8) & 255, (n >> 16) & 255, (n >>> 24) & 255];
}
/** hex (#rgb/#rrggbb) → [r,g,b] 0..255. */
function hexRgbBytes(hex: string): [number, number, number] {
const h = hex.replace('#', '');
const s = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
const n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function hex2(n: number): string {
return n.toString(16).padStart(2, '0');
}