diff --git a/docs/plan.md b/docs/plan.md index ab366cd..26f6c23 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -45,6 +45,7 @@ | 23 | v2 трек 2: чанковый мир — `Chunk` (16×H×16), `VoxelWorld` (словарь чанков, get/set через границы, dirty-трекинг), `meshRegion` (мешер области по функции доступа — шов чанков бесшовный, AO через границы), демка на мире 3×3 чанков, мост `setVoxel`, гейт: воксель поставлен → кадр изменился, AO на шве не рвётся | ✅ сделано (2026-09-09): движок — `world/chunk.ts` + `world/world.ts` (правка кромки помечает соседа) + `world/worldMesher.ts`; мешер обобщён до `meshRegion` (meshVoxels — обёртка); тест шва — меш 4 чанков ≡ меш одной сеткой (поз+AO попиксельно), 20 тестов v2. Демка — мир 6×6 чанков (96×96) с ремешингом dirty, мост `setVoxel`/`chunkStats`/`perf` (правка атомарна с ремешингом). Гейт 9/9: постройка 36 чанков + мешинг ~0.4–0.5 с (лимит 2 с), столбик на шве x=48 меняет кадр (~1000 пикселей). Урок счёта: интерфейсная грань двух вокселей — два квада (12−2=10, не 11); правка занятого вокселя лицом не меняет счётчики (тест-столбик дважды «исчезал»: вне кадра и внутри дома) | | 24 | v2 трек 3: генераторы воксельных моделей — формат внутреннего JSON (size + dense base64, валидация агентом; .vox — позже), процедурные генераторы (деревья 3 варианта, валуны 3 варианта, детерминизм по сиду), штамповка в мир с клиппингом, демка на генераторах, гейт: варианты различаются, штампы на месте | ✅ (2026-09-09). `math/rng.ts` (mulberry32), `models/format.ts` (encode/decode/validate), `models/generators.ts` (деревья: круглая/колонна/ель; валуны: union сфер + свет на верхних гранях), `models/stamp.ts` (клиппинг half-open). Демка: 12 деревьев + 3 валуна из генераторов, ручные tree/boulder удалены (урок №4). Гейт 12 проверок: + модельная статистика, + детерминизм через мост (`stampTree` дважды → тот же счётчик, вариант 1 ≠ вариант 0). Грабля: `VoxelGrid.count()` — метод, а не геттер (тесты дважды ловили). | | 25 | v2 трек 4: turntable-листы — визуальный гейт генераторов: страница со всеми вариантами моделей (деревья/валуны) × 4 поворота, скриншот в /tmp, проверки: ячейки непустые, варианты попарно различаются; основа для апрува сгенерированного контента агентом | ✅ (2026-09-09). `apps/demo/turntable.html` + `src/turntable.ts`: 6 моделей × 4 ракурса (0/45/90/135° — диагонали дают другой силуэт) на подставках 8×8×2, ракурс — `mesh.rotation.y` вокруг центра (geometry.translate −sx/2). Гейт `tools/checks/turntable.mjs`: центры ячеек считаются той же проекцией, что камера (зеркало lookAt-базиса), референсный кадр без моделей через `setModels(false)`, выборка — эллипс 48×42 в ромбе подставки; 5/5 зелёных, minDiff 570/490 при пороге 150. `launchBrowser({viewport})` в agent-lib (страница 720px выше дефолтного вьюпорта). Уроки: дробные координаты VoxelGrid.set молчу no-op (Uint8Array[4.5]); скриншот = вьюпорт браузера, канвас без центрирования; малые боксы у края ромба дают мало пикселей — выборка эллипсом. | +| 26 | v2 трек 5: .vox импорт/экспорт (MagicaVoxel) — parseVox/exportVox (чанки MAIN/SIZE/XYZI/RGBA, PACK>1 — ошибка), оси vox z-up → движок y-up, слоты модели → подряд 1..N при экспорте; тесты roundtrip; мост демки `stampVoxB64` + гейт: штамп .vox меняет кадр | ✅ (2026-09-09). `models/vox.ts`: полный обход чанков (неизвестные пропускаются с детьми, обрыв файла и обрыв в заголовке — ошибки), оси (x,y,z)_engine = (x,z,y)_vox, RGBA ровно 256 записей. Обнаружили «слоты модели ≠ слоты сцены» → `models/paletteMap.ts`: `remapSlots` (точный hex или ближайший RGB, детерминированно). Демка: мост `stampVoxB64(b64,x,z)` (парсинг → перекладка на палитру сцены → штамп + ремешинг). Гейт 13 проверок: `.vox`-фикстура (собрана exportVox-ом, base64 вкладывается в скрипт) штампует 12 вокселей и меняет кадр. Уроки: **Node не импортирует `@rpg/engine`** (TS-исходники с extensionless-импортами) — фиксстуры для гейтов генерятся tsx-ом и вкладываются в скрипт готовыми; точка штампа для проверки «кадр изменился» обязана быть в кадре камеры (штамп вне вью дал placed=12 при voxDiff=0); заголовок чанка — 12 байт (id+contentSize+childrenSize), обрыв в нём ловить границей в `id()`, иначе голый RangeError из DataView. | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 51ad97f..41dcf3c 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -65,6 +65,13 @@ верхушках столбцов); (вид, сид) → VoxelModel, детерминировано; - `src/models/stamp.ts` — `stampModel`: штамповка модели в мир с клиппингом (half-open прямоугольник + вертикаль мира), возвращает число вокселей; + - `src/models/vox.ts` — импорт/экспорт .vox (MagicaVoxel): чанки + MAIN/SIZE/XYZI/RGBA (PACK>1 — ошибка, неизвестные чанки пропускаются + с детьми), оси (x,y,z)_engine = (x,z,y)_vox, RGBA ровно 256 записей; + при экспорте слоты модели сжимаются в подряд 1..N; + - `src/models/paletteMap.ts` — `remapSlots(model, palette)`: перекладка + слотов модели на палитру сцены (точный hex или ближайший RGB, + при равенстве — меньший слот); нужен после импорта .vox; - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser с опциональным viewport/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless; @@ -75,8 +82,8 @@ нет pageerror, меш собран, тени ползут (утро/вечер), апскейл кратен 2×2, многокрасочность, 36 чанков, постройка+мешинг < 2 с (замер ~0.5 с), `setVoxel` на шве меняет кадр, 12+ деревьев всех 3 вариантов, генератор - детерминирован и варианты различаются (12 проверок). - Мост демки: `window.__agent.setSun(k)`/`stats()`/`setVoxel(x,y,z,c)`/`chunkStats()`/`modelStats()`/`stampTree(v,x,z,seed)`/`perf()`. + детерминирован и варианты различаются, `.vox`-штамп меняет кадр (13 проверок). + Мост демки: `window.__agent.setSun(k)`/`stats()`/`setVoxel(x,y,z,c)`/`chunkStats()`/`modelStats()`/`stampTree(v,x,z,seed)`/`stampVoxB64(b64,x,z)` (парсинг → `remapSlots` на палитру сцены → штамп)/`perf()`. - `apps/demo/turntable.html` + `src/turntable.ts` — turntable-лист: все варианты генераторов (деревья/валуны) × 4 ракурса на подставках 8×8×2; мост `sheetInfo()` (геометрия листа для расчёта ячеек) и `setModels(on)` (скрыть @@ -101,6 +108,14 @@ - **Скриншот = вьюпорт браузера**, не страница: канвас выше дефолтного вьюпорта 960×540 обрезается снизу; `launchBrowser({viewport})` и канвас без центрирования — иначе все координаты гейта плывут. +- **Node-скрипты гейтов не импортируют `@rpg/engine`** (TS-исходники с + extensionless-импортами): фикстуры движка (модели, .vox-байты) генерятся + один раз tsx-ом и вкладываются в скрипт гейта готовыми (base64). +- **Точка правки для проверки «кадр изменился» должна быть в кадре камеры**: + штамп вне вью дал `placed=12` при `voxDiff=0` — счётчик зелёный, кадр нет. +- **Заголовок .vox-чанка — 12 байт** (id + contentSize + childrenSize); + обрыв в заголовке ловить границей в `id()`, иначе голый RangeError + из DataView вместо понятной ошибки. ## Рабочие привычки diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts index cdf5569..5a99310 100644 --- a/v2/apps/demo/src/main.ts +++ b/v2/apps/demo/src/main.ts @@ -7,7 +7,7 @@ */ import { VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX, - treeModel, boulderModel, stampModel, type Variant, + treeModel, boulderModel, stampModel, parseVox, remapSlots, type Variant, } from '@rpg/engine'; import * as THREE from 'three'; @@ -134,6 +134,16 @@ remeshDirty(); return placed; } +/** Штамповка .vox-модели (base64): парсинг → палитра сцены → штамп + ремешинг. */ +function stampVoxB64(b64: string, x: number, z: number): number { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + const model = remapSlots(parseVox(bytes), PALETTE); + const placed = stampModel(world, model, x, 2, z); + remeshDirty(); + return placed; +} const agent = { setSun(k: number): void { sunK = k; renderer.setSun(k); }, setVoxel(x: number, y: number, z: number, color: number): void { @@ -144,6 +154,7 @@ chunkStats, modelStats, stampTree, + stampVoxB64, perf(): { chunks: number; meshes: number; buildMs: number; vertices: number; triangles: number } { return { ...chunkStats(), buildMs, ...stats() }; }, diff --git a/v2/apps/demo/tools/checks/render.mjs b/v2/apps/demo/tools/checks/render.mjs index f70a706..de5ba24 100644 --- a/v2/apps/demo/tools/checks/render.mjs +++ b/v2/apps/demo/tools/checks/render.mjs @@ -11,6 +11,10 @@ import { sleep, launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; import { decodePng } from '@rpg/engine/tools/png.mjs'; +// Фикстура .vox (3×2×2, два слоя: слоты камня 6/7 с hex арт-библии), +// собрана exportVox-ом (юнит-тесты движка); мост парсит и штампует её. +const VOX_B64 = 'Vk9YIJYAAABNQUlOAAAAAGQEAABTSVpFDAAAAAAAAAADAAAAAgAAAAIAAABYWVpJNAAAAAAAAAAMAAAAAAAAAQEAAAECAAABAAEAAQEBAAECAQABAAABAgEAAQICAAECAAEBAgEBAQICAQECUkdCQQAEAAAAAAAAAAAAAFtbZv+UlJ7/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/w=='; + const args = parseArgs(process.argv.slice(2)); const port = Number(args.port ?? 5299); const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url)); @@ -82,6 +86,13 @@ const t3 = await client.call('stampTree', 1, 1, 92, 501); check('генератор детерминирован, варианты различаются', t1 === t2 && t1 !== t3, { t1, t3 }); + // .vox: мост парсит фикстуру и штампует с перекладкой на палитру сцены + const beforeVox = decodePng(readFileSync(await client.screenshot('/tmp/v2_render_before_vox.png'))); + const placed = await client.call('stampVoxB64', VOX_B64, 42, 40); + const afterVox = decodePng(readFileSync(await client.screenshot('/tmp/v2_render_after_vox.png'))); + const voxDiff = countDiff(beforeVox.rgba, afterVox.rgba); + check('.vox штамп: 12 вокселей и кадр изменился', placed === 12 && voxDiff > 200, { placed, voxDiff }); + report.ok = report.checks.every((c) => c.ok); } finally { await browser.close(); diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index e915179..faeaf14 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -30,4 +30,6 @@ export { treeModel, boulderModel } from './models/generators'; export type { Variant } from './models/generators'; export { stampModel } from './models/stamp'; -export type { StampClip } from './models/stamp'; \ No newline at end of file +export type { StampClip } from './models/stamp'; +export { parseVox, exportVox } from './models/vox'; +export { remapSlots } from './models/paletteMap'; \ No newline at end of file diff --git a/v2/packages/engine/src/models/__tests__/paletteMap.test.ts b/v2/packages/engine/src/models/__tests__/paletteMap.test.ts new file mode 100644 index 0000000..f1e3b4c --- /dev/null +++ b/v2/packages/engine/src/models/__tests__/paletteMap.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { remapSlots } from '../paletteMap'; +import { decodeModel } from '../format'; +import type { VoxelPalette } from '../../voxel/grid'; +import type { VoxelModel } from '../format'; + +const SCENE: VoxelPalette = { + colors: { + 1: '#4a5340', 6: '#5b5b66', 7: '#94949e', + }, +}; + +/** Модель 2×1×1: воксель слота 9 с «каменным» hex и слот 2 с «луговым». */ +function makeModel(hexA: string, hexB: string): VoxelModel { + return { + size: [2, 1, 1], + palette: { 9: hexA, 2: hexB }, + data: btoa(String.fromCharCode(9, 2)), + }; +} + +describe('remapSlots (палитра модели → палитра сцены)', () => { + it('точное совпадение hex перекладывает слоты 1:1', () => { + const m = remapSlots(makeModel('#5b5b66', '#4a5340'), SCENE); + const g = decodeModel(m); + expect(g.get(0, 0, 0)).toBe(6); + expect(g.get(1, 0, 0)).toBe(1); + expect(m.palette).toEqual({ 6: '#5b5b66', 1: '#4a5340' }); + }); + + it('нет совпадения — ближайший по RGB', () => { + // #5d5d68 чуть светлее камня #5b5b66 → слот 6; #ffffff ближе всего к свету #94949e + const m = remapSlots(makeModel('#5d5d68', '#ffffff'), SCENE); + const g = decodeModel(m); + expect(g.get(0, 0, 0)).toBe(6); + expect(g.get(1, 0, 0)).toBe(7); + }); + + it('детерминирован и не трогает пустоту', () => { + const a = remapSlots(makeModel('#5d5d68', '#ffffff'), SCENE); + const b = remapSlots(makeModel('#5d5d68', '#ffffff'), SCENE); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + const g = decodeModel(a); + expect(g.get(0, 0, 1)).toBe(0); // вне размера не появилось + expect(g.count()).toBe(2); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/models/__tests__/vox.test.ts b/v2/packages/engine/src/models/__tests__/vox.test.ts new file mode 100644 index 0000000..a41bea1 --- /dev/null +++ b/v2/packages/engine/src/models/__tests__/vox.test.ts @@ -0,0 +1,89 @@ +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 = { 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[] => { + 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/); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/models/paletteMap.ts b/v2/packages/engine/src/models/paletteMap.ts new file mode 100644 index 0000000..57df5f6 --- /dev/null +++ b/v2/packages/engine/src/models/paletteMap.ts @@ -0,0 +1,40 @@ +/** + * Перекладка слотов модели на палитру сцены. Нужна после импорта .vox + * (там слоты 1..N без отношения к сцене): цвет модели замещается цветом + * сцены — точным совпадением hex или ближайшим по RGB-расстоянию + * (детерминированно, при равенстве — меньший слот). + */ +import { hexToRgb } from '../voxel/grid'; +import { decodeModel, encodeModel } from './format'; +import type { VoxelPalette } from '../voxel/grid'; +import type { VoxelModel } from './format'; + +export function remapSlots(model: VoxelModel, palette: VoxelPalette): VoxelModel { + const scene = Object.entries(palette.colors).map(([slot, hex]) => ({ + slot: Number(slot), + rgb: hexToRgb(hex), + })); + // слот модели → слот сцены + const mapping = new Map(); + for (const [slot, hex] of Object.entries(model.palette)) { + const rgb = hexToRgb(hex); + let best = scene[0], bestD = Infinity; + for (const s of scene) { + const d = (s.rgb[0] - rgb[0]) ** 2 + (s.rgb[1] - rgb[1]) ** 2 + (s.rgb[2] - rgb[2]) ** 2; + if (d < bestD || (d === bestD && s.slot < best.slot)) { bestD = d; best = s; } + } + mapping.set(Number(slot), best.slot); + } + + const grid = decodeModel(model); + for (let i = 0; i < grid.data.length; i++) { + const b = grid.data[i]; + if (b !== 0) grid.data[i] = mapping.get(b) ?? b; + } + // палитра результата — занятые слоты сцены с их hex + const used = new Set(grid.data.filter((b) => b !== 0)); + const out = Object.fromEntries( + [...used].map((slot) => [slot, palette.colors[slot]]), + ); + return encodeModel(grid, out); +} \ No newline at end of file diff --git a/v2/packages/engine/src/models/vox.ts b/v2/packages/engine/src/models/vox.ts new file mode 100644 index 0000000..427b406 --- /dev/null +++ b/v2/packages/engine/src/models/vox.ts @@ -0,0 +1,190 @@ +/** + * Импорт/экспорт .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 | 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'); +} \ No newline at end of file