/**
* Формат воксельной модели (JSON): размер, плотные данные base64,
* слоты палитры. Модель — данные: генераторы/импорт .vox/ручная правка
* дают один и тот же формат; валидация — числовая, без рендера.
*
* Слой Y растёт вверх; порядок вокселей в данных: x, затем y, затем z
* (idx = x + sx*(y + sy*z) — как в VoxelGrid).
*/
import { VoxelGrid } from '../voxel/grid';
/**
* Тайль пиксель-арта на грани вокселя: деталь меньше вокселя (лицо, пряди,
* пряжка) живёт текстурой, а не геометрией. Пиксель — слот палитры модели,
* поэтому перекраска персонажа (MannequinPalette) красит и текстуры.
*/
export interface VoxelTile {
/** Размер грани в текселях 1..8 (грань квадратная). */
size: number;
/**
* 6 граней в порядке +x,−x,+y,−y,+z,−z; каждая — base64 из size² байт,
* байт = слот палитры (0 запрещён — прозрачных текселей нет).
*/
faces: string[];
}
export interface VoxelModel {
/** Размер по осям [sx, sy, sz], каждая > 0. */
size: [number, number, number];
/** Слоты палитры: слот-индекс сцены → hex; значения 1..255 — воксели ссылаются на ключи. */
palette: Record<string, string>;
/** base64 плотных данных: 1 байт на воксель, 0 = пусто. */
data: string;
/** Текстуры граней: ключ = id тайля 1..255. */
tiles?: Record<string, VoxelTile>;
/** base64: 1 байт на воксель — id тайля (0 = плоский цвет), длина = объём. */
tex?: string;
}
const MAX_DIM = 1024; // предел по оси — защита от мусора и OOM
const MAX_VOXELS = 32 * 1024 * 1024; // ~32 МБ данных
/** Текстурный слой для encodeModel: тайли + id тайля на воксель. */
export interface ModelTexture {
tiles: Record<number, VoxelTile>;
/** id тайля на воксель (0 = плоский цвет), длина = объём сетки. */
tex: Uint8Array;
}
/** Кодирует сетку в модель: палитра из карты слот→hex, текстуры — опционально. */
export function encodeModel(grid: VoxelGrid, palette: Record<string, string>, texture?: ModelTexture): VoxelModel {
const bytes = new Uint8Array(grid.sx * grid.sy * grid.sz);
for (let i = 0; i < bytes.length; i++) bytes[i] = grid.data[i];
const model: VoxelModel = {
size: [grid.sx, grid.sy, grid.sz],
palette: { ...palette },
data: bytesToBase64(bytes),
};
if (texture) {
model.tiles = Object.fromEntries(
Object.entries(texture.tiles).map(([k, t]) => [k, { size: t.size, faces: [...t.faces] }]),
);
model.tex = bytesToBase64(texture.tex);
}
return model;
}
/** Id тайля вокселя i (0 = плоский цвет) — модели без текстур отвечают 0. */
export function texIdAt(model: VoxelModel, i: number): number {
if (!model.tex) return 0;
return base64ToBytes(model.tex)[i] ?? 0;
}
/** Раскладка tex целиком (для поз: один decode на весь проход) или null. */
export function texBytes(model: VoxelModel): Uint8Array | null {
return model.tex ? base64ToBytes(model.tex) : null;
}
/** Разрешённый тайль: пиксели граней в hex (по палитре модели) — вход рендера. */
export interface ResolvedTile {
size: number;
/** faces[грань][пиксель] = hex. */
faces: string[][];
}
/** Разворачивает тайли модели в hex-пиксели по её палитре (чистая функция). */
export function resolveTiles(model: VoxelModel): Record<number, ResolvedTile> {
const out: Record<number, ResolvedTile> = {};
if (!model.tiles) return out;
for (const [id, tile] of Object.entries(model.tiles)) {
out[Number(id)] = {
size: tile.size,
faces: tile.faces.map((face) => {
const bytes = base64ToBytes(face);
return Array.from(bytes, (b) => model.palette[String(b)] ?? '#000000');
}),
};
}
return out;
}
/** Декодирует модель в сетку. Бросает на невалидных данных (см. validateModel). */
export function decodeModel(model: VoxelModel): VoxelGrid {
assertModelValid(model);
const [sx, sy, sz] = model.size;
const bytes = base64ToBytes(model.data);
if (bytes.length !== sx * sy * sz) {
throw new Error(`decodeModel: данных ${bytes.length} байт, а нужно ${sx * sy * sz}`);
}
const grid = new VoxelGrid(sx, sy, sz);
for (let i = 0; i < bytes.length; i++) grid.data[i] = bytes[i];
return grid;
}
/**
* Числовая валидация модели без декодирования. Возвращает список проблем;
* пустой список = модель валидна. Проверяет: размер, объём, hex-слоты,
* что данные — валидный base64 нужной длины, что байты ссылаются на слоты.
*/
export function validateModel(model: unknown): string[] {
const problems: string[] = [];
const m = model as Partial<VoxelModel> | null;
if (!m || typeof m !== 'object') return ['модель — не объект'];
if (!Array.isArray(m.size) || m.size.length !== 3 ||
m.size.some((n) => !Number.isInteger(n) || n <= 0 || n > MAX_DIM)) {
problems.push(`size должен быть [sx,sy,sz] с целыми 1..${MAX_DIM}`);
return problems; // без размера дальше проверять нечего
}
const [sx, sy, sz] = m.size;
const volume = sx * sy * sz;
if (volume > MAX_VOXELS) problems.push(`объём ${volume} > ${MAX_VOXELS}`);
if (!m.palette || typeof m.palette !== 'object') {
problems.push('palette должен быть объектом слот→hex');
} else {
for (const [slot, hex] of Object.entries(m.palette)) {
const idx = Number(slot);
if (!Number.isInteger(idx) || idx < 1 || idx > 255) {
problems.push(`слот «${slot}» вне 1..255`);
}
if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(hex)) {
problems.push(`слот ${slot}: hex «${hex}» не вида #rgb/#rrggbb`);
}
}
}
const bytes = safeBase64(m.data);
if (!bytes) {
problems.push('data — не валидный base64');
return problems;
}
if (bytes.length !== volume) {
problems.push(`данных ${bytes.length} байт, а size требует ${volume}`);
return problems;
}
// Ссылки на слоты проверяем только при целостной палитре-объекте
const palette = m.palette;
if (palette && typeof palette === 'object' && !Array.isArray(palette)) {
for (const b of bytes) {
if (b !== 0 && !(b in palette)) {
problems.push(`воксель со слотом ${b} отсутствует в палитре`);
break;
}
}
}
validateTexture(m, volume, palette, problems);
return problems;
}
/** Кидает первую проблему валидации (удобно в decode). */
export function assertModelValid(model: unknown): void {
const problems = validateModel(model);
if (problems.length > 0) throw new Error(`модель невалидна: ${problems[0]}`);
}
/** Валидация текстурного слоя: тайли (размер/грани/слоты) и раскладка tex. */
function validateTexture(
m: Partial<VoxelModel>, volume: number,
palette: Record<string, string> | undefined, problems: string[],
): void {
if (m.tex !== undefined && m.tiles === undefined) {
problems.push('tex задан, но tiles отсутствуют');
return;
}
if (m.tiles !== undefined && m.tex === undefined) {
problems.push('tiles заданы, но tex отсутствует');
return;
}
if (m.tiles === undefined) return; // текстурного слоя нет — норма
const tiles = m.tiles as Record<string, unknown>;
if (typeof tiles !== 'object' || Array.isArray(tiles)) {
problems.push('tiles должен быть объектом id→тайль');
return;
}
for (const [key, tile] of Object.entries(tiles)) {
const id = Number(key);
if (!Number.isInteger(id) || id < 1 || id > 255) {
problems.push(`id тайля «${key}» вне 1..255`);
continue;
}
const t = tile as Partial<VoxelTile> | null;
if (!t || typeof t !== 'object' || !Number.isInteger(t.size) || t.size! < 1 || t.size! > 8) {
problems.push(`тайль ${id}: size должен быть целым 1..8`);
continue;
}
if (!Array.isArray(t.faces) || t.faces.length !== 6) {
problems.push(`тайль ${id}: faces должен быть массивом из 6 граней`);
continue;
}
t.faces.forEach((face, fi) => {
const bytes = safeBase64(face);
if (!bytes || bytes.length !== t.size! * t.size!) {
problems.push(`тайль ${id} грань ${fi}: base64 длины ${t.size!}² ожидается`);
return;
}
for (const b of bytes) {
if (b === 0) { problems.push(`тайль ${id} грань ${fi}: тексель 0 запрещён`); break; }
if (!(b in (palette ?? {}))) { problems.push(`тайль ${id} грань ${fi}: слот ${b} не в палитре`); break; }
}
});
}
const tex = safeBase64(m.tex);
if (!tex || tex.length !== volume) {
problems.push(`tex: данных ${tex?.length ?? 'нет'}, а нужно ${volume}`);
return;
}
for (const id of tex) {
if (id !== 0 && !(id in tiles)) {
problems.push(`tex ссылается на тайль ${id}, которого нет в tiles`);
break;
}
}
}
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(bin);
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
function safeBase64(s: unknown): Uint8Array | null {
if (typeof s !== 'string') return null;
try { return base64ToBytes(s); } catch { return null; }
}