diff --git a/docs/plan.md b/docs/plan.md
index 26f6c23..729b1ba 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -46,6 +46,7 @@
| 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. |
+| 27 | v2 трек 6: рига/анимация («анимация = данные») — рига: кости (имя, родитель, pivot в связочном пространстве), детерминированная привязка вокселей к ближайшей кости; клипы: ключевые кадры {t, кость: rot/pos}, линейный семплинг поз, числовая валидация (монотонность ключей, непрерывность цикла первый=последний, контакт стопы); процедурная локомоция (walk-цикл из параметров); генератор манекена с ригой; рендер деформированных вокселей (instanced-облако); превью-лист кадров + мост (числовые снапшоты поз) + гейт | ✅ (2026-09-09). `animation/mat.ts` (чистая аффинная математика, порядок Эйлера X→Y→Z), `animation/rig.ts` (кости + pivot в связочном пространстве; привязка: у генератора — явная, для чужих — `bindNearest` по pivot; `poseVoxels` — жёсткая деформация на кость), `animation/clip.ts` (ключи {t, кость: rot/pos}, линейный семплинг с заворотом t для циклов, `validateClip` — границы/монотонность/замкнутость цикла, `footMinY` — контакт стопы), `animation/locomotion.ts` (walkClip: ноги в противофазе, руки против одноимённой ноги, корень приседает дважды и качается), `models/mannequin.ts` (5×11×3, 5 костей, явная привязка — Voronoi по pivot отнёс бы торс к рукам). Рендер: `addVoxelCloud` (InstancedMesh, ось-выровненные кубы, обновление позы без пересборки геометрии). Демка: `anim.html` + `anim.ts` — 8 кадров цикла (4×2), мост `poseStats(i)`/`clipCheck()`/`setModels`. Гейт `anim.mjs` 8 проверок: картинка (ячейки видны, соседние фазы различаются) + числа через мост (клип замкнут, противофазы, контакт стоп ≈0.5 в опоре / подъём в махе, приседание и качание корня). 91 юнит-тест. Уроки: **composition affine — перенос parent добавлялся дважды** (тест иерархии поймал: ребёнок уезжал на 2·t вместо t); **binding индексируется по сетке, posed-массив только из занятых вокселей** — сверять через `index` вокселя, не позицией в массиве; **Voronoi-привязка по pivot семантически неверна** для внутренних вокселей (торс у плеч ближе к кости руки) — генератор привязывает явно; соседние фазы 45° дают ~110 px разницы на мелком манекене (порог гейта 50, не 150); sheetInfo/gейт должен отдавать РЕАЛЬНЫЙ viewSize камеры (в turntable 30 vs 32 проходило только за счёт запаса эллипса — исправлено). |
Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся
единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт
diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md
index 41dcf3c..77baee1 100644
--- a/v2/CLAUDE.md
+++ b/v2/CLAUDE.md
@@ -57,6 +57,8 @@
`world.get`, шов чанков бесшовный (culled и AO через границы);
- `src/render/voxelRenderer.ts` — three.js-бэкенд: ортокамера диметрии 2:1,
dirLight + shadow map + hemisphere, низкое разрешение (480×270);
+ `addVoxelCloud` — instanced-кубы для поз анимации (не выровнены по
+ сетке; обновление позы без пересборки геометрии);
- `src/math/rng.ts` — детерминированный Rng (mulberry32): next/int/range/chance/pick;
- `src/models/format.ts` — формат модели (JSON: size + плотный base64 +
слоты-палитра), encode/decode/validate (числовая валидация агентом);
@@ -72,6 +74,19 @@
- `src/models/paletteMap.ts` — `remapSlots(model, palette)`: перекладка
слотов модели на палитру сцены (точный hex или ближайший RGB,
при равенстве — меньший слот); нужен после импорта .vox;
+ - `src/models/mannequin.ts` — `mannequin()`: эталонная модель 5×11×3
+ с ригой (root → legL/legR/armL/armR, pivot в связочном пространстве);
+ - `src/animation/mat.ts` — аффинная математика без three (Vec3/Mat3/Affine,
+ порядок Эйлера X→Y→Z, rotateAround); чистая, тестируется без рендера;
+ - `src/animation/rig.ts` — рига: `makeRig` (привязка явная или `bindNearest`
+ по ближайшему pivot), `validateRig` (родители раньше детей, длина и
+ согласованность привязки), `poseVoxels(rig, model, pose)` — деформация;
+ - `src/animation/clip.ts` — клипы: ключи {t, кость: rot/pos}, `samplePose`
+ (линейный семплинг, заворот t для циклов), `validateClip` (границы,
+ монотонность, замкнутость цикла), `footMinY` (контакт стопы);
+ - `src/animation/locomotion.ts` — `walkClip(params)`: процедурный walk-цикл
+ (ноги в противофазе, руки против одноимённой ноги, корень приседает
+ дважды за цикл и качается вбок); детерминирован, проходит валидацию;
- `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser с
опциональным viewport/
startDevServer/openGame/AgentClient/Checks), swiftshader в headless;
@@ -86,11 +101,21 @@
Мост демки: `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)` (скрыть
- модели для референсного кадра). Канвас в левом верхнем углу — пиксели
+ `sheetInfo()` (геометрия листа для расчёта ячеек — viewSize как у камеры!)
+ и `setModels(on)` (скрыть модели для референсного кадра). Канвас в левом
+ верхнем углу — пиксели
скриншота считаются от (0,0). Гейт `tools/checks/turntable.mjs`: проекция
ячеек той же матрицей, что камера, эллипс 48×42 в ромбе подставки,
5 проверок (ячейки непустые, варианты попарно различаются).
+- `apps/demo/anim.html` + `src/anim.ts` — аним-лист: 8 кадров walk-цикла
+ манекена (4×2, фазы i/8) на подставках; клип — `walkClip`, позы —
+ `samplePose`→`poseVoxels`, рисуются `addVoxelCloud`. Мост: `sheetInfo()`,
+ `poseStats(i)` (числовой снапшот позы: углы, качание корня, `footMinY`
+ обеих стоп), `clipCheck()` (validateClip через мост), `setModels(on)`.
+ Гейт `tools/checks/anim.mjs`: 8 проверок — картинка (ячейки видны,
+ соседние фазы различаются, порог 50) + числа через мост (клип замкнут,
+ противофазы ног/рук, контакт стоп в i=0/i=4, приседание и качание корня).
+ Фазы 0 и 4 двухшагового цикла совпадают — это норма, не баг.
## Известные грабли (уже собранные)
@@ -116,6 +141,17 @@
- **Заголовок .vox-чанка — 12 байт** (id + contentSize + childrenSize);
обрыв в заголовке ловить границей в `id()`, иначе голый RangeError
из DataView вместо понятной ошибки.
+- **Привязка вокселей индексируется по сетке, а posed-массив — только
+ занятые воксели**: сопоставлять через `PosedVoxel.index`, не позицией
+ в массиве (footMinY из-за этого дважды бы соврал).
+- **Voronoi-привязка по pivot семантически неверна** для внутренних
+ вокселей: торс у плеча ближе к кости руки, чем к корню. Генератор
+ привязывает части явно; `bindNearest` — только для чужих моделей.
+- **sheetInfo/мост должен отдавать РЕАЛЬНЫЙ viewSize камеры**: в turntable
+ стояло 30 при камере 32 — гейт проходил только за счёт запаса эллипса.
+- **Соседние фазы walk-цикла (45°) на мелком манекене дают ~110 px
+ разницы** — порог гейта «фазы различаются» 50, не 150 как у вариантов
+ моделей; а фазы 0 и 4 совпадают полностью (симметрия двух шагов).
## Рабочие привычки
diff --git a/v2/apps/demo/anim.html b/v2/apps/demo/anim.html
new file mode 100644
index 0000000..0ccfd03
--- /dev/null
+++ b/v2/apps/demo/anim.html
@@ -0,0 +1,22 @@
+
+
+
+
+ Аним-лист v2 — кадры walk-цикла
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/v2/apps/demo/src/anim.ts b/v2/apps/demo/src/anim.ts
new file mode 100644
index 0000000..f8dd12e
--- /dev/null
+++ b/v2/apps/demo/src/anim.ts
@@ -0,0 +1,124 @@
+/**
+ * Аним-лист: 8 кадров walk-цикла манекена (4×2) на подставках.
+ * Превью-лист кадров анимации (docs/plan.md, трек 6): клип процедурный
+ * (walkClip), позы считаются samplePose→poseVoxels, рисуются instanced-облаком.
+ * Мост отдаёт геометрию листа и числовые снапшоты поз — гейт проверяет
+ * и картинку, и данные (контакт стопы, противофазы, замкнутость цикла).
+ */
+import {
+ VoxelRenderer, VoxelGrid, meshVoxels, mannequin, walkClip, samplePose, poseVoxels,
+ validateClip, footMinY,
+} from '@rpg/engine';
+import type { VoxelPalette, VoxelCloud } from '@rpg/engine';
+
+// Палитра арт-библии — та же, что в демке (слоты → hex)
+const PALETTE: VoxelPalette = {
+ colors: {
+ 1: '#4a5340', 2: '#343b2c', 3: '#626c4c', 4: '#5c3b2e', 5: '#7d8662',
+ 6: '#5b5b66', 7: '#94949e', 8: '#82503a', 9: '#d99a32', 10: '#44454e',
+ },
+};
+
+const COLS = 4; // кадры 0..3 по оси X
+const ROWS = 2; // кадры 4..7 по оси Z
+const FRAMES = 8;
+const PITCH = 9;
+
+// walk-цикл манекена: параметры локомоции (агент правит их текстом через клип)
+const DURATION = 0.8;
+const clip = walkClip({ duration: DURATION, legSwing: 0.55, armSwing: 0.4, bob: 0.25, sway: 0.15 });
+const { model, rig } = mannequin();
+
+const sheetW = COLS * PITCH;
+const sheetD = ROWS * PITCH;
+const target: [number, number, number] = [sheetW / 2, 2, sheetD / 2];
+const VIEW_SIZE = 22; // лист 36×18 + рост манекена — с запасом
+
+const canvas = document.getElementById('scene') as HTMLCanvasElement;
+const renderer = new VoxelRenderer(canvas, { width: 480, height: 360, target, viewSize: VIEW_SIZE });
+
+/** Центр ячейки (целый: воксельные координаты должны быть целыми). */
+function cellCenter(col: number, row: number): [number, number] {
+ return [col * PITCH + Math.floor(PITCH / 2), row * PITCH + Math.floor(PITCH / 2)];
+}
+
+/** Подставки ячеек: одна сетка на весь лист, плита 8×8×2 в центре каждой. */
+function buildPads(): VoxelGrid {
+ const grid = new VoxelGrid(sheetW, 2, sheetD);
+ for (let col = 0; col < COLS; col++)
+ for (let row = 0; row < ROWS; row++) {
+ const [cx, cz] = cellCenter(col, row);
+ for (let dx = -4; dx < 4; dx++)
+ for (let dz = -4; dz < 4; dz++) {
+ grid.set(cx + dx, 0, cz + dz, 2);
+ const patch = ((cx + dx) * 7 + (cz + dz) * 13) % 11 < 3;
+ grid.set(cx + dx, 1, cz + dz, patch ? 3 : 1);
+ }
+ }
+ return grid;
+}
+renderer.addMesh(meshVoxels(buildPads(), PALETTE));
+
+// Кадры цикла: поза i → instanced-облако в ячейке (row = i / COLS)
+const clouds: VoxelCloud[] = [];
+for (let i = 0; i < FRAMES; i++) {
+ const col = i % COLS, row = Math.floor(i / COLS);
+ const [cx, cz] = cellCenter(col, row);
+ const pose = samplePose(clip, (DURATION * i) / FRAMES);
+ const posed = poseVoxels(rig, model, pose).map((v) => ({
+ // манекен 5×11×3 — центр по XZ ячейки, на подставке (y=2)
+ pos: [v.pos[0] + cx - model.size[0] / 2, v.pos[1] + 2, v.pos[2] + cz - model.size[2] / 2] as [number, number, number],
+ color: v.color,
+ }));
+ clouds.push(renderer.addVoxelCloud(posed));
+}
+
+function stats(): { clouds: number; voxels: number } {
+ return { clouds: clouds.length, voxels: clouds.reduce((n, c) => n + c.mesh.count, 0) };
+}
+
+/** Геометрия листа для расчёта ячеек в гейте (та же проекция, что у камеры). */
+function sheetInfo() {
+ return {
+ cols: COLS, rows: ROWS, pitch: PITCH, frames: FRAMES, duration: DURATION,
+ target, viewSize: VIEW_SIZE, width: 480, height: 360, scale: 2,
+ };
+}
+
+/** Числовый снапшот позы кадра i: углы, качание корня, контакт стоп. */
+function poseStats(i: number) {
+ const t = (DURATION * i) / FRAMES;
+ const pose = samplePose(clip, t);
+ return {
+ t,
+ legL: pose.legL?.rot?.[0] ?? 0,
+ legR: pose.legR?.rot?.[0] ?? 0,
+ armL: pose.armL?.rot?.[0] ?? 0,
+ armR: pose.armR?.rot?.[0] ?? 0,
+ rootPos: pose.root?.pos ?? [0, 0, 0],
+ footMinL: footMinY(rig, model, clip, 'legL', t),
+ footMinR: footMinY(rig, model, clip, 'legR', t),
+ };
+}
+
+const agent = {
+ stats,
+ sheetInfo,
+ poseStats,
+ /** Числовая валидация клипа (замкнутость цикла) — через мост, как валидация агентом. */
+ clipCheck(): string[] {
+ return validateClip(clip);
+ },
+ /** Скрыть/показать кадры (подставки остаются) — референсный кадр гейта. */
+ setModels(on: boolean): void {
+ for (const c of clouds) c.mesh.visible = on;
+ },
+};
+(window as unknown as Record).__agent = agent;
+
+function frame(): void {
+ renderer.render();
+ requestAnimationFrame(frame);
+}
+frame();
+(window as unknown as Record).__beacon = true;
\ No newline at end of file
diff --git a/v2/apps/demo/src/turntable.ts b/v2/apps/demo/src/turntable.ts
index 8eff55d..87fac2a 100644
--- a/v2/apps/demo/src/turntable.ts
+++ b/v2/apps/demo/src/turntable.ts
@@ -88,7 +88,7 @@
function sheetInfo() {
return {
cols: COLS, rows: ROWS, pitch: PITCH,
- target, viewSize: 30, width: 480, height: 360, scale: 2,
+ target, viewSize: 32, width: 480, height: 360, scale: 2,
models: MODELS.map((m) => m.name),
};
}
diff --git a/v2/apps/demo/tools/checks/anim.mjs b/v2/apps/demo/tools/checks/anim.mjs
new file mode 100644
index 0000000..4ccb9ae
--- /dev/null
+++ b/v2/apps/demo/tools/checks/anim.mjs
@@ -0,0 +1,140 @@
+/**
+ * Гейт аним-листа: превью walk-цикла манекена (8 кадров, 4×2) — визуальная
+ * и числовая проверка анимации (docs/plan.md, трек 6, «анимация = данные»).
+ * Картинка: ячейки видны, соседние фазы различаются (та же проекция, что
+ * камера, референсный кадр без моделей). Числа через мост: клип замкнут
+ * (validateClip), противофазы ног/рук, контакт стоп, качание корня.
+ * Скриншоты: /tmp/v2_anim.png, /tmp/v2_anim_pads.png.
+ *
+ * Запуск: node apps/demo/tools/checks/anim.mjs [--port 5299]
+ */
+import { readFileSync } from 'node:fs';
+import { sleep, launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs';
+import { decodePng } from '@rpg/engine/tools/png.mjs';
+
+const args = parseArgs(process.argv.slice(2));
+const port = Number(args.port ?? 5299);
+const server = startDevServer({ port, cwd: new URL('../../', import.meta.url).pathname });
+
+const report = { name: 'anim', ok: false, checks: [] };
+const check = (title, ok, info = null) => {
+ report.checks.push({ title, ok, info });
+ console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`);
+};
+
+try {
+ await server.waitReady;
+ // страница 960×720 — вьюпорт с запасом, иначе низ листа обрезается кадром
+ const browser = await launchBrowser({ viewport: { width: 960, height: 760 } });
+ try {
+ const client = await openGame(browser, { url: `http://localhost:${port}/anim.html` });
+
+ const errs = client.errors();
+ check('нет pageerror при загрузке листа', errs.length === 0, errs.slice(0, 3));
+
+ const info = await client.call('sheetInfo');
+ const stats = await client.call('stats');
+ check('лист: 8 кадров манекена в облаках', stats.clouds === 8 && stats.voxels > 100, stats);
+
+ // Числовая валидация клипа через мост (как агент валидирует текстом)
+ const problems = await client.call('clipCheck');
+ check('клип замкнут и проходит валидацию', problems.length === 0, problems);
+
+ const full = decodePng(readFileSync(await client.screenshot('/tmp/v2_anim.png')));
+ await client.call('setModels', false);
+ await sleep(300);
+ const pads = decodePng(readFileSync(await client.screenshot('/tmp/v2_anim_pads.png')));
+ await client.call('setModels', true);
+
+ // Ячейки: проекция мирового центра ячейки той же матрицей, что камера
+ const cells = [];
+ for (let col = 0; col < info.cols; col++)
+ for (let row = 0; row < info.rows; row++) {
+ const wx = col * info.pitch + Math.floor(info.pitch / 2);
+ const wz = row * info.pitch + Math.floor(info.pitch / 2);
+ const [px, py] = worldToPx([wx, 3, wz], info);
+ cells.push({ col, row, px, py, diff: cellDiff(full, pads, px, py) });
+ }
+ const empty = cells.filter((c) => c.diff <= 150);
+ check('во всех 8 ячейках манекен виден', empty.length === 0,
+ empty.length ? empty : { minDiff: Math.min(...cells.map((c) => c.diff)) });
+
+ // Соседние фазы различаются (внутри ряда; шаг фазы 45° даёт ~110 px —
+ // манекен мелкий, порог 50; фазы 0 и 4 цикла совпадают — норма двухшагового цикла)
+ const pairs = [];
+ for (const row of [0, 1])
+ for (let col = 0; col + 1 < info.cols; col++) {
+ const a = cells[row * info.cols + col], b = cells[row * info.cols + col + 1];
+ pairs.push(cellDiff(full, full, a.px, a.py, b.px, b.py));
+ }
+ const same = pairs.filter((d) => d <= 50);
+ check('соседние фазы различаются (6 пар)', same.length === 0,
+ same.length ? { same } : { minDiff: Math.min(...pairs) });
+
+ // Числовые снапшоты поз: противофазы, контакт стоп, качание корня
+ const ps = [];
+ for (let i = 0; i < info.frames; i++) ps.push(await client.call('poseStats', i));
+ const mid = ps[2], zero = ps[0];
+ check('ноги и руки в противофазе (i=2)', mid.legR === -mid.legL && mid.armR === -mid.armL, { legL: mid.legL, armL: mid.armL });
+ check('контакт стоп в i=0/i=4, мах в i=2 поднимает стопы',
+ Math.abs(ps[0].footMinL - 0.5) < 0.05 && Math.abs(ps[4].footMinL - 0.5) < 0.05 &&
+ mid.footMinL > 0.8 && mid.footMinR > 0.8,
+ { contact: ps[0].footMinL, midL: mid.footMinL, midR: mid.footMinR });
+ check('корень: в контакте y=0, в шаге приседает и качается вбок',
+ Math.abs(zero.rootPos[1]) < 0.01 && mid.rootPos[1] < -0.2 &&
+ Math.abs(mid.rootPos[0]) > 0.1 && Math.abs(ps[6].rootPos[0] + mid.rootPos[0]) < 0.01,
+ { zeroY: zero.rootPos[1], midY: mid.rootPos[1], swayL: mid.rootPos[0], swayR: ps[6].rootPos[0] });
+
+ report.ok = report.checks.every((c) => c.ok);
+ } finally {
+ await browser.close();
+ }
+} catch (e) {
+ check('прогон без исключений', false, String(e));
+} finally {
+ server.stop();
+}
+
+console.log(JSON.stringify(report, null, 2));
+process.exit(report.ok ? 0 : 1);
+
+/** Экранные координаты мировой точки — зеркалим VoxelRenderer (см. turntable.mjs). */
+function worldToPx(p, info) {
+ const norm = (v) => { const l = Math.hypot(...v); return v.map((c) => c / l); };
+ const cross = (a, b) => [
+ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0],
+ ];
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+ const dir = norm([Math.cos(Math.PI / 4) * 0.5, 1, Math.sin(Math.PI / 4) * 0.5]);
+ const xAxis = norm(cross([0, 1, 0], dir));
+ const yAxis = cross(dir, xAxis);
+ const eye = info.target.map((t, i) => t + dir[i] * info.viewSize * 4);
+ const rel = p.map((c, i) => c - eye[i]);
+ const aspect = info.width / info.height;
+ const ndcX = dot(rel, xAxis) / (info.viewSize * aspect);
+ const ndcY = dot(rel, yAxis) / info.viewSize;
+ return [
+ ((ndcX + 1) / 2) * info.width * info.scale,
+ (1 - (ndcY + 1) / 2) * info.height * info.scale,
+ ];
+}
+
+/** Число различающихся пикселей в эллипсе вокруг центров ячеек (см. turntable.mjs). */
+function cellDiff(a, b, pxA, pyA, pxB = pxA, pyB = pyA) {
+ const RX = 24, RY = 21;
+ let diff = 0;
+ for (let dy = -RY; dy <= RY; dy++)
+ for (let dx = -RX; dx <= RX; dx++) {
+ if ((dx / RX) ** 2 + (dy / RY) ** 2 > 1) continue;
+ const ia = pixelIndex(a, pxA + dx, pyA + dy);
+ const ib = pixelIndex(b, pxB + dx, pyB + dy);
+ for (let k = 0; k < 3; k++) if (Math.abs(a.rgba[ia + k] - b.rgba[ib + k]) > 8) { diff++; break; }
+ }
+ return diff;
+}
+
+function pixelIndex(png, x, y) {
+ const cx = Math.min(png.width - 1, Math.max(0, Math.round(x)));
+ const cy = Math.min(png.height - 1, Math.max(0, Math.round(y)));
+ return (cy * png.width + cx) * 4;
+}
\ No newline at end of file
diff --git a/v2/packages/engine/src/animation/__tests__/clip.test.ts b/v2/packages/engine/src/animation/__tests__/clip.test.ts
new file mode 100644
index 0000000..ae4d86d
--- /dev/null
+++ b/v2/packages/engine/src/animation/__tests__/clip.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from 'vitest';
+import { samplePose, validateClip, footMinY } from '../clip';
+import type { AnimClip } from '../clip';
+import { makeRig } from '../rig';
+
+/** Простой клип-ходьба: кость «leg» качается, кость «root» приседает. */
+function walkClip(): AnimClip {
+ const bone = (rotX: number, rootY: number) => ({
+ bones: { leg: { rot: [rotX, 0, 0] as const }, root: { pos: [0, rootY, 0] as const } },
+ });
+ return {
+ name: 'walk',
+ duration: 1,
+ loop: true,
+ keys: [
+ { t: 0, ...bone(0, 0) },
+ { t: 0.25, ...bone(0.5, -0.3) },
+ { t: 0.5, ...bone(0, 0) },
+ { t: 0.75, ...bone(-0.5, -0.3) },
+ { t: 1, ...bone(0, 0) },
+ ],
+ };
+}
+
+describe('samplePose (семплинг позы)', () => {
+ it('в ключах — точные значения', () => {
+ const pose = samplePose(walkClip(), 0.25);
+ expect(pose.leg?.rot).toEqual([0.5, 0, 0]);
+ expect(pose.root?.pos).toEqual([0, -0.3, 0]);
+ });
+
+ it('между ключами — линейная интерполяция по каналам', () => {
+ const pose = samplePose(walkClip(), 0.375); // середина между 0.25 и 0.5
+ expect(pose.leg?.rot?.[0]).toBeCloseTo(0.25);
+ expect(pose.root?.pos?.[1]).toBeCloseTo(-0.15);
+ });
+
+ it('зацикленный клип: t за пределами длительности заворачивается', () => {
+ const a = samplePose(walkClip(), 1.25);
+ const b = samplePose(walkClip(), 0.25);
+ expect(a.leg?.rot?.[0]).toBeCloseTo(b.leg?.rot?.[0] ?? NaN);
+ const c = samplePose(walkClip(), -0.75);
+ expect(c.leg?.rot?.[0]).toBeCloseTo(b.leg?.rot?.[0] ?? NaN);
+ });
+
+ it('незацикленный клип: за концом держит последний ключ', () => {
+ const clip = { ...walkClip(), loop: false };
+ expect(samplePose(clip, 5).leg?.rot?.[0]).toBeCloseTo(0);
+ });
+
+ it('кость только в одном из соседних ключей — берётся из ближнего целиком', () => {
+ const clip: AnimClip = {
+ name: 'gaps', duration: 1, loop: false,
+ keys: [
+ { t: 0, bones: { arm: { rot: [1, 0, 0] } } },
+ { t: 1, bones: {} },
+ ],
+ };
+ const mid = samplePose(clip, 0.9);
+ expect(mid.arm?.rot).toEqual([1, 0, 0]); // ближний ключ 0 несёт канал целиком
+ });
+});
+
+describe('validateClip (числовая валидация)', () => {
+ it('корректный цикл — пустой список проблем', () => {
+ expect(validateClip(walkClip())).toEqual([]);
+ });
+
+ it('ловит разрыв цикла на стыке', () => {
+ const clip = walkClip();
+ clip.keys[clip.keys.length - 1] = { t: 1, bones: { leg: { rot: [0.3, 0, 0] } } };
+ const problems = validateClip(clip);
+ expect(problems.some((p) => p.includes('рвётся'))).toBe(true);
+ });
+
+ it('ловит немонотонность и выход t за границы', () => {
+ const clip = walkClip();
+ clip.keys[2].t = 0.1;
+ const problems = validateClip(clip);
+ expect(problems.some((p) => p.includes('не больше предыдущего'))).toBe(true);
+ clip.keys[2].t = 2;
+ expect(validateClip(clip).some((p) => p.includes('вне'))).toBe(true);
+ });
+
+ it('ловит пустой клип и нулевую длительность', () => {
+ expect(validateClip({ name: 'e', duration: 0, loop: false, keys: [] }).length).toBe(2);
+ });
+});
+
+describe('footMinY (контакт стопы)', () => {
+ it('кость, поднятая позой, поднимает свои воксели', () => {
+ // модель 1×2×2: воксели (0,0,0) кость foot, (0,1,0) кость body
+ const model = {
+ size: [1, 2, 2] as [number, number, number],
+ palette: { 1: '#101010', 2: '#202020' },
+ data: btoa(String.fromCharCode(1, 2, 0, 0)),
+ };
+ const rig = makeRig(
+ [
+ { name: 'root', parent: -1, pivot: [0.5, 1, 0.5] },
+ { name: 'foot', parent: 0, pivot: [0.5, 0.5, 0.5] },
+ ],
+ model,
+ [1, 0, -1, -1],
+ );
+ const clip: AnimClip = {
+ name: 'hop', duration: 1, loop: true,
+ keys: [
+ { t: 0, bones: { foot: { pos: [0, 0, 0] } } },
+ { t: 0.5, bones: { foot: { pos: [0, 2, 0] } } },
+ { t: 1, bones: { foot: { pos: [0, 0, 0] } } },
+ ],
+ };
+ expect(footMinY(rig, model, clip, 'foot', 0)).toBeCloseTo(0.5); // центр вокселя на y=0.5
+ expect(footMinY(rig, model, clip, 'foot', 0.25)).toBeCloseTo(1.5); // интерполяция +1
+ });
+});
\ No newline at end of file
diff --git a/v2/packages/engine/src/animation/__tests__/locomotion.test.ts b/v2/packages/engine/src/animation/__tests__/locomotion.test.ts
new file mode 100644
index 0000000..959693c
--- /dev/null
+++ b/v2/packages/engine/src/animation/__tests__/locomotion.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest';
+import { walkClip } from '../locomotion';
+import { samplePose, validateClip, footMinY } from '../clip';
+import { makeRig } from '../rig';
+import type { VoxelModel } from '../../models/format';
+
+const PARAMS = { duration: 0.8, legSwing: 0.5, armSwing: 0.3, bob: 0.2, sway: 0.1 };
+
+describe('walkClip (процедурная локомоция)', () => {
+ it('детерминирован', () => {
+ expect(JSON.stringify(walkClip(PARAMS))).toBe(JSON.stringify(walkClip(PARAMS)));
+ });
+
+ it('проходит числовую валидацию (цикл замкнут, ключи монотонны)', () => {
+ expect(validateClip(walkClip(PARAMS))).toEqual([]);
+ });
+
+ it('ноги в противофазе, рука против одноимённой ноги', () => {
+ const clip = walkClip(PARAMS);
+ const q = samplePose(clip, PARAMS.duration * 0.25); // левая нога в махе
+ expect(q.legL?.rot?.[0]).toBeCloseTo(PARAMS.legSwing);
+ expect(q.legR?.rot?.[0]).toBeCloseTo(-PARAMS.legSwing);
+ expect(q.armL?.rot?.[0]).toBeCloseTo(-PARAMS.armSwing);
+ expect(q.armR?.rot?.[0]).toBeCloseTo(PARAMS.armSwing);
+ });
+
+ it('корень: в контакте y=0, в полном шаге приседает, качается вбок', () => {
+ const clip = walkClip(PARAMS);
+ const contact = samplePose(clip, 0);
+ expect(contact.root?.pos?.[1]).toBeCloseTo(0);
+ const stride = samplePose(clip, PARAMS.duration * 0.25);
+ expect(stride.root?.pos?.[1]).toBeCloseTo(-PARAMS.bob);
+ expect(stride.root?.pos?.[0]).toBeCloseTo(PARAMS.sway);
+ const strideR = samplePose(clip, PARAMS.duration * 0.75);
+ expect(strideR.root?.pos?.[0]).toBeCloseTo(-PARAMS.sway);
+ });
+
+ it('контакт стопы: в опоре стопа на земле, в махе поднята', () => {
+ // манекен-заглушка: нога 5 вокселей вниз от pivot на y=5
+ const model: VoxelModel = {
+ size: [1, 6, 1],
+ palette: { 6: '#5b5b66' },
+ data: btoa(String.fromCharCode(6, 6, 6, 6, 6, 6)),
+ };
+ const rig = makeRig(
+ [
+ { name: 'root', parent: -1, pivot: [0.5, 0, 0.5] },
+ { name: 'legL', parent: 0, pivot: [0.5, 5, 0.5] },
+ ],
+ model,
+ [1, 1, 1, 1, 1, 0],
+ );
+ const clip = walkClip({ ...PARAMS, legSwing: 0.5 });
+ const ground = footMinY(rig, model, clip, 'legL', 0);
+ const swing = footMinY(rig, model, clip, 'legL', PARAMS.duration * 0.25);
+ expect(ground).toBeCloseTo(0.5, 2); // стопа на земле (центр нижнего вокселя 0.5)
+ // мах поднимает стопу (с поправкой на приседание корня −bob в этой же фазе)
+ expect(swing).toBeGreaterThan(ground + PARAMS.legSwing * 0.5);
+ });
+});
\ No newline at end of file
diff --git a/v2/packages/engine/src/animation/__tests__/mat.test.ts b/v2/packages/engine/src/animation/__tests__/mat.test.ts
new file mode 100644
index 0000000..27e0a36
--- /dev/null
+++ b/v2/packages/engine/src/animation/__tests__/mat.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import { eulerMat, composeAffine, affinePoint, rotateAround, mat3Mul } from '../mat';
+
+const close = (a: readonly number[], b: readonly number[], eps = 1e-9): void => {
+ expect(a.length).toBe(b.length);
+ a.forEach((v, i) => expect(Math.abs(v - b[i])).toBeLessThan(eps));
+};
+
+describe('mat (аффинная математика анимации)', () => {
+ it('поворот на 90° вокруг X: Y → Z', () => {
+ const p = affinePoint({ m: eulerMat([Math.PI / 2, 0, 0]), t: [0, 0, 0] }, [0, 1, 0]);
+ close(p, [0, 0, 1]);
+ });
+
+ it('поворот на 90° вокруг Z: X → Y', () => {
+ const p = affinePoint({ m: eulerMat([0, 0, Math.PI / 2]), t: [0, 0, 0] }, [1, 0, 0]);
+ close(p, [0, 1, 0]);
+ });
+
+ it('rotateAround: поворот вокруг точки сохраняет саму точку', () => {
+ const a = rotateAround([0, 0, Math.PI], [2, 3, 1], [0, 0, 0]);
+ close(affinePoint(a, [2, 3, 1]), [2, 3, 1]);
+ // противоположная точка pivota уходит на противоположную сторону
+ close(affinePoint(a, [3, 3, 1]), [1, 3, 1]);
+ });
+
+ it('rotateAround: сдвиг pos двигает pivot', () => {
+ const a = rotateAround([0, 0, 0], [2, 3, 1], [5, 0, -2]);
+ close(affinePoint(a, [2, 3, 1]), [7, 3, -1]);
+ close(affinePoint(a, [0, 0, 0]), [5, 0, -2]);
+ });
+
+ it('композиция родителя и локального трансформа = последовательное применение', () => {
+ const parent = rotateAround([0, 0, Math.PI / 2], [0, 0, 0], [1, 0, 0]);
+ const local = rotateAround([0, 0, 0], [0, 0, 0], [0, 2, 0]);
+ const combined = composeAffine(parent, local);
+ const direct = affinePoint(parent, affinePoint(local, [3, 1, 0]));
+ close(affinePoint(combined, [3, 1, 0]), direct);
+ });
+
+ it('mat3Mul ассоциативен на примере двух поворотов', () => {
+ const rx = eulerMat([0.3, 0, 0]);
+ const ry = eulerMat([0, 0.7, 0]);
+ const p: [number, number, number] = [0.4, -0.9, 1.3];
+ const ab = affinePoint({ m: mat3Mul(rx, ry), t: [0, 0, 0] }, p);
+ const aOfb = affinePoint({ m: rx, t: [0, 0, 0] }, affinePoint({ m: ry, t: [0, 0, 0] }, p));
+ close(ab, aOfb);
+ });
+});
\ No newline at end of file
diff --git a/v2/packages/engine/src/animation/__tests__/rig.test.ts b/v2/packages/engine/src/animation/__tests__/rig.test.ts
new file mode 100644
index 0000000..808cf11
--- /dev/null
+++ b/v2/packages/engine/src/animation/__tests__/rig.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest';
+import { makeRig, bindNearest, validateRig, poseVoxels, boneWorlds } from '../rig';
+import type { VoxelModel } from '../../models/format';
+import { affinePoint } from '../mat';
+
+/** Модель 3×1×1: три вокселя вдоль X с разными слотами. */
+function model3(): VoxelModel {
+ return {
+ size: [3, 1, 1],
+ palette: { 1: '#101010', 2: '#202020', 3: '#303030' },
+ data: btoa(String.fromCharCode(1, 2, 3)),
+ };
+}
+
+/** Рига: корень в левом вокселе, «кончик» в правом. */
+function rig3() {
+ return makeRig(
+ [
+ { name: 'root', parent: -1, pivot: [0.5, 0.5, 0.5] },
+ { name: 'tip', parent: 0, pivot: [2.5, 0.5, 0.5] },
+ ],
+ model3(),
+ [0, 0, 1],
+ );
+}
+
+describe('makeRig/bindNearest (привязка вокселей к костям)', () => {
+ it('ближайший pivot, при равенстве — меньший индекс кости', () => {
+ const binding = bindNearest(
+ [
+ { name: 'l', parent: -1, pivot: [0.5, 0.5, 0.5] },
+ { name: 'r', parent: 0, pivot: [2.5, 0.5, 0.5] },
+ ],
+ model3(),
+ );
+ expect(binding).toEqual([0, 0, 1]); // воксель 1 равноудалён (1.0 vs 1.0) — остаётся меньший индекс 0
+ });
+
+ it('явная привязка передаётся как есть', () => {
+ expect(rig3().binding).toEqual([0, 0, 1]);
+ });
+});
+
+describe('validateRig (числовая валидация агентом)', () => {
+ it('корректная рига — пустой список проблем', () => {
+ expect(validateRig(rig3(), model3())).toEqual([]);
+ });
+
+ it('ловит цикл (родитель не раньше кости) и чужой индекс родителя', () => {
+ const rig = rig3();
+ rig.bones[0].parent = 1;
+ const problems = validateRig(rig, model3());
+ expect(problems.some((p) => p.includes('не раньше'))).toBe(true);
+ rig.bones[0].parent = -1;
+ rig.bones[1].parent = 5;
+ expect(validateRig(rig, model3()).some((p) => p.includes('вне риги'))).toBe(true);
+ });
+
+ it('ловит длину привязки и пустые/висячие воксели', () => {
+ const model = model3();
+ expect(validateRig({ bones: rig3().bones, binding: [0, 0] }, model).some((p) => p.includes('байт'))).toBe(true);
+ expect(validateRig({ bones: rig3().bones, binding: [0, -1, 1] }, model).some((p) => p.includes('не привязан'))).toBe(true);
+ expect(validateRig({ bones: rig3().bones, binding: [0, 0, 7] }, model).some((p) => p.includes('вне риги'))).toBe(true);
+ });
+});
+
+describe('poseVoxels (деформация)', () => {
+ it('сдвиг корня двигает все воксели', () => {
+ const posed = poseVoxels(rig3(), model3(), { root: { pos: [1, 0, 0] } });
+ expect(posed.map((v) => v.pos[0])).toEqual([1.5, 2.5, 3.5]);
+ expect(posed.map((v) => v.color)).toEqual(['#101010', '#202020', '#303030']);
+ });
+
+ it('поворот кости двигает только её воксели, вокруг pivot', () => {
+ const posed = poseVoxels(rig3(), model3(), { tip: { rot: [0, 0, Math.PI / 2] } });
+ // воксель кончика (центр 2.5,0.5,0.5) повёрнут вокруг (2.5,0.5,0.5) — на месте,
+ // но повернуть нечего: воксель совпадает с pivot; проверим сдвигом вниз на 1
+ const posed2 = poseVoxels(rig3(), model3(), { tip: { pos: [0, -1, 0] } });
+ expect(posed2[0].pos).toEqual([0.5, 0.5, 0.5]);
+ expect(posed2[2].pos).toEqual([2.5, -0.5, 0.5]);
+ expect(posed[2].pos).toEqual([2.5, 0.5, 0.5]);
+ });
+
+ it('поворот родителя тянет за собой детей (иерархия)', () => {
+ const posed = poseVoxels(rig3(), model3(), { root: { rot: [0, 0, Math.PI / 2] } });
+ // центр вокселя 2: (2.5,0.5,0.5) → поворот Z на 90° вокруг (0.5,0.5,0.5) → (0.5,2.5,0.5)
+ expect(posed[2].pos[0]).toBeCloseTo(0.5);
+ expect(posed[2].pos[1]).toBeCloseTo(2.5);
+ // мир-трансформ ребёнка композиция родителя
+ const worlds = boneWorlds(rig3(), {});
+ expect(affinePoint(worlds[1], [2.5, 0.5, 0.5])).toEqual([2.5, 0.5, 0.5]);
+ });
+});
\ No newline at end of file
diff --git a/v2/packages/engine/src/animation/clip.ts b/v2/packages/engine/src/animation/clip.ts
new file mode 100644
index 0000000..54e8fef
--- /dev/null
+++ b/v2/packages/engine/src/animation/clip.ts
@@ -0,0 +1,110 @@
+/**
+ * Анимационный клип — ключевые кадры как данные (docs/plan.md, «анимация =
+ * данные, не кадры»). Клип = набор ключей {t, кости: rot/pos}; поза в момент
+ * t — линейная интерполяция между соседними ключами (по каждой кости и каналу
+ * отдельно; канал, отсутствующий в ключе, берётся из ближайшего ключа с ним).
+ * Валидация числовая: границы и монотонность ключей, для зацикленных клипов —
+ * непрерывность цикла (первый ключ = последнему).
+ */
+import type { Pose, VoxelRig } from './rig';
+import { poseVoxels } from './rig';
+import type { VoxelModel } from '../models/format';
+import type { Vec3 } from './mat';
+
+/** Кадр одной кости: поворот (рад) и/или сдвиг (воксельных единиц). */
+export interface BoneFrame {
+ rot?: Vec3;
+ pos?: Vec3;
+}
+
+/** Ключевой кадр: момент t (сек) + каналы костей по именам. */
+export interface ClipKey {
+ t: number;
+ bones: Record;
+}
+
+export interface AnimClip {
+ name: string;
+ /** Длительность цикла в секундах (> 0). */
+ duration: number;
+ /** Зацикленный клип: первый ключ на t=0 обязан совпадать с последним на t=duration. */
+ loop: boolean;
+ /** Ключи отсортированы по t строго возрастание. */
+ keys: ClipKey[];
+}
+
+const EPS = 1e-6;
+
+/** Поза в момент t: линейная интерполяция каналов между соседними ключами. */
+export function samplePose(clip: AnimClip, t: number): Pose {
+ const pose: Record = {};
+ if (clip.keys.length === 0) return pose;
+ const time = clip.loop
+ ? ((t % clip.duration) + clip.duration) % clip.duration
+ : Math.min(clip.duration, Math.max(0, t));
+ // соседние ключи: keys[i].t ≤ time < keys[i+1].t (после последнего — последний)
+ let i = 0;
+ while (i + 1 < clip.keys.length && clip.keys[i + 1].t <= time) i++;
+ const a = clip.keys[i], b = clip.keys[Math.min(i + 1, clip.keys.length - 1)];
+ const span = b.t - a.t;
+ const k = span > EPS ? (time - a.t) / span : 0;
+ const names = new Set([...Object.keys(a.bones), ...Object.keys(b.bones)]);
+ for (const name of names) {
+ const fa = a.bones[name], fb = b.bones[name];
+ const rot = lerpVec(fa?.rot, fb?.rot, k);
+ const pos = lerpVec(fa?.pos, fb?.pos, k);
+ if (rot || pos) pose[name] = { ...(rot && { rot }), ...(pos && { pos }) };
+ }
+ return pose;
+}
+
+/** Линейная интерполяция вектора; отсутствующая сторона — вектор с другой стороны. */
+function lerpVec(a: Vec3 | undefined, b: Vec3 | undefined, k: number): Vec3 | undefined {
+ if (!a && !b) return undefined;
+ const va = a ?? b!, vb = b ?? a!;
+ return [
+ va[0] + (vb[0] - va[0]) * k,
+ va[1] + (vb[1] - va[1]) * k,
+ va[2] + (vb[2] - va[2]) * k,
+ ];
+}
+
+/** Список проблем клипа; пустой — клип корректен. */
+export function validateClip(clip: AnimClip): string[] {
+ const problems: string[] = [];
+ if (clip.duration <= 0) problems.push(`длительность ${clip.duration} ≤ 0`);
+ if (clip.keys.length === 0) problems.push('нет ключевых кадров');
+ clip.keys.forEach((key, i) => {
+ if (key.t < 0 || key.t > clip.duration) problems.push(`ключ ${i}: t=${key.t} вне [0, ${clip.duration}]`);
+ if (i > 0 && key.t <= clip.keys[i - 1].t) problems.push(`ключ ${i}: t=${key.t} не больше предыдущего`);
+ });
+ if (clip.loop && clip.keys.length >= 2) {
+ const first = clip.keys[0], last = clip.keys[clip.keys.length - 1];
+ if (first.t !== 0) problems.push(`цикл: первый ключ на t=${first.t}, жду 0`);
+ if (last.t !== clip.duration) problems.push(`цикл: последний ключ на t=${last.t}, жду ${clip.duration}`);
+ for (const name of Object.keys(first.bones)) {
+ const fa = first.bones[name], fb = last.bones[name];
+ if (!fb) continue; // кость есть только в начале — поза берётся из ближайшего ключа
+ for (const ch of ['rot', 'pos'] as const) {
+ const va = fa[ch], vb = fb[ch];
+ if (!va || !vb) continue;
+ if (va.some((v, j) => Math.abs(v - vb[j]) > EPS)) {
+ problems.push(`цикл: кость «${name}» канал ${ch} рвётся на стыке (${va} → ${vb})`);
+ }
+ }
+ }
+ }
+ return problems;
+}
+
+/**
+ * Контакт стопы: минимальная мировая Y вокселей кости в фазе опоры.
+ * Числовая проверка «y кости ≈ 0 в фазе опоры» (docs/plan.md) — работает
+ * на связочной модели, где стопы стоят на y ≈ 0.
+ */
+export function footMinY(rig: VoxelRig, model: VoxelModel, clip: AnimClip, bone: string, t: number): number {
+ const posed = poseVoxels(rig, model, samplePose(clip, t));
+ const index = rig.bones.findIndex((b) => b.name === bone);
+ const ys = posed.filter((v) => rig.binding[v.index] === index).map((v) => v.pos[1]);
+ return ys.length ? Math.min(...ys) : NaN;
+}
\ No newline at end of file
diff --git a/v2/packages/engine/src/animation/locomotion.ts b/v2/packages/engine/src/animation/locomotion.ts
new file mode 100644
index 0000000..a3b905d
--- /dev/null
+++ b/v2/packages/engine/src/animation/locomotion.ts
@@ -0,0 +1,48 @@
+/**
+ * Процедурная локомоция (docs/plan.md): walk-цикл генерируется параметрами,
+ * а не авторится кадрами. Два шага за цикл: левая и правая нога в противофазе
+ * (sin θ и sin(θ+π)), руки — в противофазе одноимённой ноге, корень приседает
+ * дважды за цикл (в фазах полного шага) и качается вбок один раз.
+ * Требования к костям: «legL»/«legR» (pitch вокруг X), «armL»/«armR», «root».
+ * Отсутствующие кости просто не получают каналов — семплинг это допускает.
+ */
+import type { AnimClip, ClipKey } from './clip';
+
+export interface WalkParams {
+ /** Длительность цикла (сек), > 0. */
+ duration: number;
+ /** Амплитуда ног (рад, ± вокруг нуля). */
+ legSwing: number;
+ /** Амплитуда рук (рад, ± вокруг нуля). */
+ armSwing: number;
+ /** Приседание корня в фазах полного шага (воксельных единиц, ≥ 0). */
+ bob: number;
+ /** Боковое качание корня (воксельных единиц; 0 — выключено). */
+ sway?: number;
+}
+
+const KEY_STEPS = 8; // ключей на цикл + 1 (замыкающий) — линейный семплинг гладкости не даёт, но дёшев
+
+/** Walk-цикл из параметров; детерминирован, проходит validateClip (цикл замкнут). */
+export function walkClip(params: WalkParams, name = 'walk'): AnimClip {
+ const keys: ClipKey[] = [];
+ for (let i = 0; i <= KEY_STEPS; i++) {
+ const t = (params.duration * i) / KEY_STEPS;
+ const th = (2 * Math.PI * i) / KEY_STEPS; // фаза цикла
+ const legL = params.legSwing * Math.sin(th);
+ const armL = -params.armSwing * Math.sin(th); // рука против одноимённой ноги
+ const bobY = -params.bob * Math.abs(Math.sin(th)); // приседание дважды за цикл
+ const swayX = (params.sway ?? 0) * Math.sin(th);
+ keys.push({
+ t,
+ bones: {
+ root: { pos: [swayX, bobY, 0] },
+ legL: { rot: [legL, 0, 0] },
+ legR: { rot: [-legL, 0, 0] },
+ armL: { rot: [armL, 0, 0] },
+ armR: { rot: [-armL, 0, 0] },
+ },
+ });
+ }
+ return { name, duration: params.duration, loop: true, keys };
+}
\ No newline at end of file
diff --git a/v2/packages/engine/src/animation/mat.ts b/v2/packages/engine/src/animation/mat.ts
new file mode 100644
index 0000000..963868e
--- /dev/null
+++ b/v2/packages/engine/src/animation/mat.ts
@@ -0,0 +1,68 @@
+/**
+ * Минимальная аффинная математика анимации — чистая, без three.js
+ * (урок №3: математика отдельно от вьюх, тестируется без рендера).
+ * Конвенция колонок: точка справа, p' = M·p + t. Порядок Эйлера —
+ * применяем X, потом Y, потом Z (R = Rz·Ry·Rx).
+ */
+export type Vec3 = readonly [number, number, number];
+export type Mat3 = readonly [number, number, number, number, number, number, number, number, number];
+
+/** Аффинный трансформ: поворот m плюс перенос t. */
+export interface Affine {
+ m: Mat3;
+ t: Vec3;
+}
+
+export const IDENTITY_AFFINE: Affine = { m: [1, 0, 0, 0, 1, 0, 0, 0, 1], t: [0, 0, 0] };
+
+/** Матрица поворота из эйлеровых углов (рад), порядок X → Y → Z. */
+export function eulerMat(rot: Vec3): Mat3 {
+ const [rx, ry, rz] = rot;
+ const cx = Math.cos(rx), sx = Math.sin(rx);
+ const cy = Math.cos(ry), sy = Math.sin(ry);
+ const cz = Math.cos(rz), sz = Math.sin(rz);
+ // Rz·Ry·Rx, развёрнуто (row-major)
+ return [
+ cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx,
+ sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx,
+ -sy, cy * sx, cy * cx,
+ ];
+}
+
+/** Композиция: сначала local, потом parent (p → parent(local(p))). */
+export function composeAffine(parent: Affine, local: Affine): Affine {
+ // parent(local(p)) = parent.m·local.m·p + (parent.m·local.t + parent.t)
+ return { m: mat3Mul(parent.m, local.m), t: affinePoint(parent, local.t) };
+}
+
+/** Умножение матриц a·b. */
+export function mat3Mul(a: Mat3, b: Mat3): Mat3 {
+ const out: number[] = [];
+ for (let r = 0; r < 3; r++)
+ for (let c = 0; c < 3; c++)
+ out.push(a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c]);
+ return out as unknown as Mat3;
+}
+
+/** Применение аффинного трансформа к точке. */
+export function affinePoint(a: Affine, p: Vec3): Vec3 {
+ const m = a.m;
+ return [
+ m[0] * p[0] + m[1] * p[1] + m[2] * p[2] + a.t[0],
+ m[3] * p[0] + m[4] * p[1] + m[5] * p[2] + a.t[1],
+ m[6] * p[0] + m[7] * p[1] + m[8] * p[2] + a.t[2],
+ ];
+}
+
+/** Сложение векторов. */
+export function addVec(a: Vec3, b: Vec3): Vec3 {
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
+}
+
+/** Аффинный трансформ «повернуть вокруг точки pivot и сдвинуть на pos». */
+export function rotateAround(rot: Vec3, pivot: Vec3, pos: Vec3): Affine {
+ const m = eulerMat(rot);
+ // p → R·(p − pivot) + pivot + pos = R·p + (pivot + pos − R·pivot)
+ const rp = affinePoint({ m, t: [0, 0, 0] }, pivot);
+ return { m, t: addVec(addVec(pivot, pos), [-rp[0], -rp[1], -rp[2]]) };
+}
\ No newline at end of file
diff --git a/v2/packages/engine/src/animation/rig.ts b/v2/packages/engine/src/animation/rig.ts
new file mode 100644
index 0000000..2bef013
--- /dev/null
+++ b/v2/packages/engine/src/animation/rig.ts
@@ -0,0 +1,123 @@
+/**
+ * Рига воксельной модели — «анимация = данные» (docs/plan.md, направление v2).
+ * Кость = имя + родитель + pivot (точка вращения в связочном пространстве
+ * модели, где центр вокселя (x,y,z) — это (x+0.5, y+0.5, z+0.5)).
+ * Привязка вокселей к костям — детерминированная: у генератора части известны
+ * заранее (привязка передаётся явно), для чужих моделей — bindNearest
+ * (ближайший pivot, при равенстве меньший индекс кости).
+ * Деформация жёсткая на кость (кости не растягиваются — дистанции внутри
+ * кости сохраняются по построению).
+ */
+import { decodeModel } from '../models/format';
+import type { VoxelModel } from '../models/format';
+import { composeAffine, rotateAround, affinePoint } from './mat';
+import type { Affine, Vec3 } from './mat';
+
+export interface RigBone {
+ name: string;
+ /** Индекс кости-родителя; -1 у корня. Родитель всегда с меньшим индексом. */
+ parent: number;
+ pivot: Vec3;
+}
+
+export interface VoxelRig {
+ bones: RigBone[];
+ /** Воксель i (i = x + sx·(y + sy·z)) → индекс кости; -1 у пустых. */
+ binding: number[];
+}
+
+/** Поза: по имени кости — поворот (рад) и сдвиг (воксельных единиц). */
+export interface Pose {
+ readonly [bone: string]: { rot?: Vec3; pos?: Vec3 } | undefined;
+}
+
+/** Деформированный воксель: позиция центра, цвет слота сцены и индекс в сетке. */
+export interface PosedVoxel {
+ pos: Vec3;
+ color: string;
+ /** Индекс вокселя в сетке модели (i = x + sx·(y + sy·z)) — для привязки к кости. */
+ index: number;
+}
+
+/** Привязка «ближайший pivot» для моделей без явной разметки частей. */
+export function bindNearest(bones: RigBone[], model: VoxelModel): number[] {
+ const grid = decodeModel(model);
+ const binding = new Array(grid.data.length).fill(-1);
+ const centers = bones.map((b) => [b.pivot[0], b.pivot[1], b.pivot[2]]);
+ for (let i = 0; i < grid.data.length; i++) {
+ if (grid.data[i] === 0) continue;
+ const x = i % grid.sx, y = Math.floor(i / grid.sx) % grid.sy, z = Math.floor(i / (grid.sx * grid.sy));
+ const c = [x + 0.5, y + 0.5, z + 0.5];
+ let best = 0, bestD = Infinity;
+ for (let b = 0; b < centers.length; b++) {
+ const d = (centers[b][0] - c[0]) ** 2 + (centers[b][1] - c[1]) ** 2 + (centers[b][2] - c[2]) ** 2;
+ if (d < bestD) { bestD = d; best = b; } // при равенстве остаётся меньший индекс
+ }
+ binding[i] = best;
+ }
+ return binding;
+}
+
+/** Рига из костей: привязка передаётся явно (генератор знает части) или по ближайшему pivot. */
+export function makeRig(bones: RigBone[], model: VoxelModel, binding?: number[]): VoxelRig {
+ return { bones, binding: binding ?? bindNearest(bones, model) };
+}
+
+/** Список проблем риги (числовая валидация агентом); пустой — рига корректна. */
+export function validateRig(rig: VoxelRig, model: VoxelModel): string[] {
+ const problems: string[] = [];
+ const { bones, binding } = rig;
+ if (bones.length === 0) problems.push('нет костей');
+ const seen = new Set();
+ bones.forEach((b, i) => {
+ if (!b.name) problems.push(`кость ${i}: пустое имя`);
+ if (seen.has(b.name)) problems.push(`кость ${i}: имя «${b.name}» повторяется`);
+ seen.add(b.name);
+ if (b.parent < -1 || b.parent >= bones.length) problems.push(`кость «${b.name}»: родитель ${b.parent} вне риги`);
+ else if (b.parent >= i) problems.push(`кость «${b.name}»: родитель ${b.parent} не раньше самой кости (цикл)`);
+ });
+ const grid = decodeModel(model);
+ if (binding.length !== grid.data.length) {
+ problems.push(`привязка ${binding.length} байт, а вокселей ${grid.data.length}`);
+ return problems;
+ }
+ for (let i = 0; i < binding.length; i++) {
+ const empty = grid.data[i] === 0;
+ if (empty && binding[i] !== -1) problems.push(`воксель ${i} пуст, но привязан к кости ${binding[i]}`);
+ if (!empty && binding[i] === -1) problems.push(`воксель ${i} занят, но не привязан ни к какой кости`);
+ if (!empty && (binding[i] < 0 || binding[i] >= bones.length)) {
+ problems.push(`воксель ${i} привязан к кости ${binding[i]} вне риги`);
+ break;
+ }
+ }
+ return problems;
+}
+
+/** Мир-трансформы костей (связочное пространство → мир) по цепочкам родителей. */
+export function boneWorlds(rig: VoxelRig, pose: Pose): Affine[] {
+ // родители всегда раньше детей по индексу (validateRig) — считаем итеративно
+ const worlds: Affine[] = [];
+ rig.bones.forEach((bone, i) => {
+ const p = pose[bone.name];
+ const local = rotateAround(p?.rot ?? [0, 0, 0], bone.pivot, p?.pos ?? [0, 0, 0]);
+ worlds[i] = bone.parent >= 0 && bone.parent < i
+ ? composeAffine(worlds[bone.parent], local)
+ : local;
+ });
+ return worlds;
+}
+
+/** Деформированные воксели модели в позе (центры в мировых координатах). */
+export function poseVoxels(rig: VoxelRig, model: VoxelModel, pose: Pose): PosedVoxel[] {
+ const grid = decodeModel(model);
+ const worlds = boneWorlds(rig, pose);
+ const out: PosedVoxel[] = [];
+ for (let i = 0; i < grid.data.length; i++) {
+ const slot = grid.data[i];
+ if (slot === 0) continue;
+ const x = i % grid.sx, y = Math.floor(i / grid.sx) % grid.sy, z = Math.floor(i / (grid.sx * grid.sy));
+ const pos = affinePoint(worlds[rig.binding[i]], [x + 0.5, y + 0.5, z + 0.5]);
+ out.push({ pos, color: model.palette[String(slot)], index: i });
+ }
+ return out;
+}
\ No newline at end of file
diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts
index faeaf14..ed1626f 100644
--- a/v2/packages/engine/src/index.ts
+++ b/v2/packages/engine/src/index.ts
@@ -23,7 +23,7 @@
export { meshChunk, chunkBounds } from './world/worldMesher';
export { Chunk, CHUNK_SX, CHUNK_SZ } from './world/chunk';
export { VoxelRenderer } from './render/voxelRenderer';
-export type { VoxelRendererOptions } from './render/voxelRenderer';
+export type { VoxelRendererOptions, VoxelCloud } from './render/voxelRenderer';
export { Rng } from './math/rng';
export { encodeModel, decodeModel, validateModel, assertModelValid } from './models/format';
export type { VoxelModel } from './models/format';
@@ -32,4 +32,14 @@
export { stampModel } from './models/stamp';
export type { StampClip } from './models/stamp';
export { parseVox, exportVox } from './models/vox';
-export { remapSlots } from './models/paletteMap';
\ No newline at end of file
+export { remapSlots } from './models/paletteMap';
+export { mannequin, MANNEQUIN_BONES } from './models/mannequin';
+export type { Mannequin, MannequinBone } from './models/mannequin';
+export { eulerMat, mat3Mul, composeAffine, affinePoint, rotateAround, addVec, IDENTITY_AFFINE } from './animation/mat';
+export type { Vec3, Mat3, Affine } from './animation/mat';
+export { makeRig, bindNearest, validateRig, boneWorlds, poseVoxels } from './animation/rig';
+export type { RigBone, VoxelRig, Pose, PosedVoxel } from './animation/rig';
+export { samplePose, validateClip, footMinY } from './animation/clip';
+export type { AnimClip, ClipKey, BoneFrame } from './animation/clip';
+export { walkClip } from './animation/locomotion';
+export type { WalkParams } from './animation/locomotion';
\ No newline at end of file
diff --git a/v2/packages/engine/src/models/__tests__/mannequin.test.ts b/v2/packages/engine/src/models/__tests__/mannequin.test.ts
new file mode 100644
index 0000000..328567b
--- /dev/null
+++ b/v2/packages/engine/src/models/__tests__/mannequin.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+import { mannequin } from '../mannequin';
+import { validateRig, poseVoxels } from '../../animation/rig';
+import { decodeModel } from '../format';
+
+describe('mannequin (манекен + рига)', () => {
+ it('детерминирован', () => {
+ expect(JSON.stringify(mannequin())).toBe(JSON.stringify(mannequin()));
+ });
+
+ it('ригa проходит числовую валидацию', () => {
+ const m = mannequin();
+ expect(validateRig(m.rig, m.model)).toEqual([]);
+ });
+
+ it('состав частей: 5 ног ×2, 4 руки ×2, торс 12, голова 2', () => {
+ const { model, rig } = mannequin();
+ const g = decodeModel(model);
+ expect(g.count()).toBe(32); // 10 ног + 12 торс + 2 голова + 8 рук
+ const byBone = [0, 0, 0, 0, 0];
+ for (let i = 0; i < g.data.length; i++)
+ if (g.data[i] !== 0) byBone[rig.binding[i]]++;
+ expect(byBone).toEqual([14, 5, 5, 4, 4]); // root: торс+голова
+ });
+
+ it('каждая часть — связная колонна (6-связность)', () => {
+ const { model, rig } = mannequin();
+ const g = decodeModel(model);
+ const cells = (bone: number) => {
+ const out: [number, number, number][] = [];
+ for (let i = 0; i < g.data.length; i++)
+ if (g.data[i] !== 0 && rig.binding[i] === bone) {
+ const x = i % g.sx, y = Math.floor(i / g.sx) % g.sy, z = Math.floor(i / (g.sx * g.sy));
+ out.push([x, y, z]);
+ }
+ return out.sort((a, b) => a[1] - b[1]);
+ };
+ for (const bone of [1, 2, 3, 4]) {
+ const col = cells(bone);
+ expect(col.length).toBeGreaterThan(3);
+ for (let k = 1; k < col.length; k++) {
+ expect(Math.abs(col[k][0] - col[k - 1][0]) + Math.abs(col[k][1] - col[k - 1][1]) + Math.abs(col[k][2] - col[k - 1][2])).toBe(1);
+ }
+ }
+ });
+
+ it('поворот ноги двигает ногу, но не торс (жёсткая привязка частей)', () => {
+ const { model, rig } = mannequin();
+ const bindPosed = poseVoxels(rig, model, {});
+ const swing = poseVoxels(rig, model, { legL: { rot: [0.6, 0, 0] } });
+ for (const a of bindPosed) {
+ const b = swing.find((v) => v.index === a.index)!;
+ const moved = Math.abs(a.pos[0] - b.pos[0]) + Math.abs(a.pos[1] - b.pos[1]) + Math.abs(a.pos[2] - b.pos[2]);
+ const isLegL = rig.binding[a.index] === 1;
+ if (isLegL) expect(moved).toBeGreaterThan(0.1);
+ else expect(moved).toBe(0);
+ }
+ });
+});
\ No newline at end of file
diff --git a/v2/packages/engine/src/models/mannequin.ts b/v2/packages/engine/src/models/mannequin.ts
new file mode 100644
index 0000000..64cf9d5
--- /dev/null
+++ b/v2/packages/engine/src/models/mannequin.ts
@@ -0,0 +1,65 @@
+/**
+ * Манекен — эталонная модель для рига и анимации (docs/plan.md, трек 6).
+ * Деревянный болванчик 5×11×3: ноги (P4), торс (R1), голова (P6),
+ * руки (R2 — ржавые, видны в махе). Части известны при генерации, поэтому
+ * привязка к костям передаётся явно (bindNearest здесь не нужен: торс
+ * рядом с плечами и Voronoi по pivot отнёс бы его к рукам).
+ */
+import { VoxelGrid } from '../voxel/grid';
+import { encodeModel } from './format';
+import type { VoxelModel } from './format';
+import { makeRig } from '../animation/rig';
+import type { VoxelRig } from '../animation/rig';
+
+// Слоты арт-библии: как в генераторах деревьев/валунов
+const TORSO = 4; // R1 — тёмное дерево
+const LEG = 6; // P4 — камень (ноги-колонны)
+const HEAD = 7; // P6 — свет
+const ARM = 8; // R2 — ржавчина (махи рук заметны)
+
+export const MANNEQUIN_BONES = ['root', 'legL', 'legR', 'armL', 'armR'] as const;
+export type MannequinBone = (typeof MANNEQUIN_BONES)[number];
+
+export interface Mannequin {
+ model: VoxelModel;
+ rig: VoxelRig;
+}
+
+/** Детерминированный манекен с ригой: root → legL/legR/armL/armR. */
+export function mannequin(): Mannequin {
+ const g = new VoxelGrid(5, 11, 3);
+ // привязка: индекс вокселя → индекс кости (root=0, legL=1, legR=2, armL=3, armR=4)
+ const binding = new Array(g.data.length).fill(-1);
+ const put = (x: number, y: number, z: number, slot: number, bone: number): void => {
+ g.set(x, y, z, slot);
+ binding[x + g.sx * (y + g.sy * z)] = bone;
+ };
+ // ноги: колонны x=1 и x=3, y=0..4
+ for (let y = 0; y <= 4; y++) {
+ put(1, y, 1, LEG, 1);
+ put(3, y, 1, LEG, 2);
+ }
+ // торс: x=1..3, y=5..8; голова: x=2, y=9..10
+ for (let y = 5; y <= 8; y++)
+ for (let x = 1; x <= 3; x++) put(x, y, 1, TORSO, 0);
+ put(2, 9, 1, HEAD, 0);
+ put(2, 10, 1, HEAD, 0);
+ // руки: колонны x=0 и x=4, y=5..8 (свободно висят)
+ for (let y = 5; y <= 8; y++) {
+ put(0, y, 1, ARM, 3);
+ put(4, y, 1, ARM, 4);
+ }
+ const model = encodeModel(g, { 4: '#5c3b2e', 6: '#5b5b66', 7: '#94949e', 8: '#82503a' });
+ const rig = makeRig(
+ [
+ { name: 'root', parent: -1, pivot: [2.5, 0, 1.5] }, // земля, центр пят
+ { name: 'legL', parent: 0, pivot: [1.5, 5, 1.5] }, // бедро левой
+ { name: 'legR', parent: 0, pivot: [3.5, 5, 1.5] }, // бедро правой
+ { name: 'armL', parent: 0, pivot: [0.5, 9, 1.5] }, // плечо левой
+ { name: 'armR', parent: 0, pivot: [4.5, 9, 1.5] }, // плечо правой
+ ],
+ model,
+ binding,
+ );
+ return { model, rig };
+}
\ No newline at end of file
diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts
index 258dc8e..1146bca 100644
--- a/v2/packages/engine/src/render/voxelRenderer.ts
+++ b/v2/packages/engine/src/render/voxelRenderer.ts
@@ -9,6 +9,15 @@
*/
import * as THREE from 'three';
import type { VoxelMesh } from '../voxel/mesher';
+import type { PosedVoxel } from '../animation/rig';
+
+/** Облако деформированных вокселей (позы рига — не выровнены по сетке). */
+export interface VoxelCloud {
+ /** Заменить воксели облака (позиции, цвета, количество). */
+ update(voxels: readonly (Pick)[]): void;
+ dispose(): void;
+ mesh: THREE.InstancedMesh;
+}
export interface VoxelRendererOptions {
/** низкое разрешение рендера (пиксель-арт), по умолчанию 480×270 */
@@ -85,6 +94,51 @@
}
/**
+ * Облако вокселей для поз анимации: instanced кубы 1×1×1 (ось-выровнены),
+ * позиция и цвет — на инстанс. Обновление позы — без пересборки геометрии.
+ */
+ addVoxelCloud(voxels: readonly (Pick)[]): VoxelCloud {
+ const geo = new THREE.BoxGeometry(1, 1, 1);
+ const mat = new THREE.MeshLambertMaterial();
+ const mesh = new THREE.InstancedMesh(geo, mat, Math.max(1, voxels.length));
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ mesh.frustumCulled = false; // bbox у InstancedMesh не считается по инстансам
+ this.scene.add(mesh);
+ const cloud: VoxelCloud = {
+ mesh,
+ update: (list) => {
+ if (list.length > mesh.instanceMatrix.count) {
+ this.scene.remove(mesh);
+ geo.dispose();
+ mat.dispose();
+ const fresh = this.addVoxelCloud(list);
+ cloud.update = fresh.update;
+ cloud.dispose = fresh.dispose;
+ cloud.mesh = fresh.mesh;
+ return;
+ }
+ const m = new THREE.Matrix4();
+ const c = new THREE.Color();
+ list.forEach((v, i) => {
+ mesh.setMatrixAt(i, m.setPosition(v.pos[0], v.pos[1], v.pos[2]));
+ mesh.setColorAt(i, c.set(v.color));
+ });
+ mesh.count = list.length;
+ mesh.instanceMatrix.needsUpdate = true;
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
+ },
+ dispose: () => {
+ this.scene.remove(mesh);
+ geo.dispose();
+ mat.dispose();
+ },
+ };
+ cloud.update(voxels);
+ return cloud;
+ }
+
+ /**
* Солнце дня: k — 0 рассвет → 0.5 полдень → 1 закат. Азимут едет по
* мировой горизонтали (восток→запад), высота — по синусу дня; цвет теплеет
* у горизонта. Тени на земле ползут от k — это ловит скриншот-гейт.