Newer
Older
rpg / v2 / packages / engine / src / models / vox.ts
/**
 * Импорт/экспорт .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');
}