studio/src/editor/engine/VoxelModelScene.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

1625 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* VoxelModelScene — изолированная Babylon-сцена для редактора пользовательской
* воксельной модели (Этап 4 редактора моделей).
*
* Никакой связи с основной сценой проекта: своя Engine + Scene, орбит-камера,
* сетка границ, воксели = thin-instances одного куба с per-instance цветом
* (через colorBuffer thinInstanceSetBuffer).
*
* Размер модели — параметр grid_size (8/16/32/64). По умолчанию 16 (~4 м).
* Воксели хранятся в Map<"x,y,z" → colorHex> + параллельно через
* thin-instance buffer для рендера.
*
* Инструменты:
* - 'draw' — добавить voxel выбранного цвета
* - 'erase' — удалить voxel
* - 'paint' — перекрасить существующий
* - 'fill' — 3D flood-fill соединённой области одного цвета
*
* Управление камерой:
* - ПКМ + drag → орбит вокруг центра модели
* - Колесо мыши → zoom
* - F → центрировать обратно
*
* Серьёзно урезанная архитектура относительно BabylonScene.js: нет физики,
* блок-менеджеров, моделей, gizmo и т.п. Только voxel grid + кисти + камера.
*/
import {
Engine, Scene, ArcRotateCamera, HemisphericLight, DirectionalLight,
Vector3, Color3, Color4, Matrix, MeshBuilder, StandardMaterial,
Texture, Tools, SceneLoader, TransformNode, VertexBuffer,
} from '@babylonjs/core';
import '@babylonjs/loaders/glTF';
import { encodeVoxelModel, decodeVoxelModel } from './voxelModelCodec';
/**
* Каталог текстур для рисования. Использует те же текстуры что и terrain
* (kubikon-assets/textures), но это просто PNG → diffuse map.
* id — стабильный ключ для сохранения; preview — URL для палитры; file —
* URL текстуры для diffuseTexture.
*/
const TEX_BASE = '/kubikon-assets/textures';
export const VOXEL_TEXTURES = [
{ id: 'grass', label: 'Трава', file: `${TEX_BASE}/grass_top.png` },
{ id: 'stone', label: 'Камень', file: `${TEX_BASE}/greystone.png` },
{ id: 'dirt', label: 'Земля', file: `${TEX_BASE}/dirt.png` },
{ id: 'sand', label: 'Песок', file: `${TEX_BASE}/sand.png` },
{ id: 'snow', label: 'Снег', file: `${TEX_BASE}/snow.png` },
{ id: 'wood', label: 'Дерево', file: `${TEX_BASE}/wood.png` },
{ id: 'leaves', label: 'Листва', file: `${TEX_BASE}/leaves.png` },
{ id: 'trunk', label: 'Ствол', file: `${TEX_BASE}/trunk_side.png` },
{ id: 'water', label: 'Вода', file: `${TEX_BASE}/water.png` },
{ id: 'ice', label: 'Лёд', file: `${TEX_BASE}/ice.png` },
{ id: 'gravel', label: 'Гравий', file: `${TEX_BASE}/gravel_dirt.png` },
{ id: 'brick_red', label: 'Кирпич красн.', file: `${TEX_BASE}/brick_red.png` },
{ id: 'brick_grey', label: 'Кирпич сер.', file: `${TEX_BASE}/brick_grey.png` },
];
/** Размер одного voxel'а в мире (метров).
* 0.0625 м (=1/16 м) — мелкая ячейка. Это размер ЯЧЕЙКИ-кубика на сцене.
* Минимальный кубик который ставит кисть = размер 1 voxel = 0.0625 м.
* При gridSize=16 модель = 1 м; 32 = 2 м; 64 = 4 м; 128 = 8 м.
* Большие gridSize дают БОЛЬШЕ РАМКУ при том же мелком кубике —
* пользователь сам выбирает баланс детализации vs размера модели. */
export const VOXEL_SIZE = 0.0625;
/** Допустимые размеры модели (длина стороны grid в voxel'ах).
* При VOXEL_SIZE=0.0625 м: 16=1м, 32=2м, 48=3м, 64=4м, 80=5м, 96=6м, 128=8м.
* Промежуточные 48/80/96 нужны для крупных моделей (мебель, транспорт,
* здания) — генератор и пользователи используют их, чтобы модель не
* тонула в пустой сетке и не упиралась в стенки. */
export const ALLOWED_GRID_SIZES = [16, 32, 48, 64, 80, 96, 128];
/** Преобразование hex-строки '#RRGGBB' → Color4(0..1, alpha=1). */
function hexToColor4(hex) {
if (!hex || typeof hex !== 'string') return new Color4(1, 1, 1, 1);
const m = hex.trim().match(/^#?([0-9a-fA-F]{6})$/);
if (!m) return new Color4(1, 1, 1, 1);
const n = parseInt(m[1], 16);
return new Color4(
((n >> 16) & 0xff) / 255,
((n >> 8) & 0xff) / 255,
(n & 0xff) / 255,
1,
);
}
/** Преобразование Color4 → hex-строка. */
function color4ToHex(c) {
const r = Math.round(c.r * 255);
const g = Math.round(c.g * 255);
const b = Math.round(c.b * 255);
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
}
export class VoxelModelScene {
/**
* @param {HTMLCanvasElement} canvas
* @param {object} options
* @param {number} options.gridSize — длина стороны 8|16|32|64 (default 16)
* @param {string} options.initialColor — стартовый цвет кисти '#RRGGBB'
* @param {function} options.onChange — колбэк когда модель меняется (для dirty-флага UI)
*/
constructor(canvas, options = {}) {
this.canvas = canvas;
this.gridSize = ALLOWED_GRID_SIZES.includes(options.gridSize)
? options.gridSize : 32;
this.currentColor = options.initialColor || '#5fa84e';
this.currentTool = 'draw'; // 'draw' | 'erase' | 'paint' | 'fill'
// Размер кисти 1..5 — за клик ставим/стираем куб NxNxN ячеек.
this.brushSize = 1;
// Рабочая плоскость для рисования "в пустоту" (как MagicaVoxel slice mode):
// workPlaneAxis: 'x' | 'y' | 'z' — какая ось перпендикулярна плоскости
// workPlaneLevel: 0..gridSize-1 — на какой ячейке по этой оси стоит
// По умолчанию y=0 (на полу). Пользователь меняет в sidebar.
this.workPlaneAxis = 'y';
this.workPlaneLevel = 0;
// Текстура voxel'я: null = чистый цвет, иначе id из VOXEL_TEXTURES.
this.currentTexture = null;
this._onChange = options.onChange || null;
// Undo / Redo стеки. Хранят snapshot (serialize-строки).
// Размер ограничен 50 шагами, чтобы не съедать память.
this._undoStack = [];
this._redoStack = [];
this._undoLimit = 50;
// voxels: Map<"x,y,z" → { color, texture, proto, idx }>
// color — '#RRGGBB' если voxel цветной (proto = _solidProto)
// texture — id из VOXEL_TEXTURES если voxel текстурный (proto = _texProtos.get(id))
// proto — ссылка на mesh куда был добавлен этот voxel (нужно для remove)
// idx — индекс инстанса в protо
this.voxels = new Map();
// Обратная карта: "<protoTag>:<idx>" → key. Один _idxToKey недостаточен
// т.к. разные proto-меши имеют независимую нумерацию.
this._idxToKey = new Map();
// Per-proto _freeSlots: каждая proto-сетка имеет свой список свободных
// слотов (когда удаляем — помечаем slot как scale=0, idx возвращается
// в pool того же proto).
this._freeSlotsByProto = new Map();
// Батч-режим: при массовой загрузке/очистке (deserialize, clear)
// _addVoxel/_removeVoxelByKey НЕ пересчитывают boundingInfo на каждом
// вокселе — это O(N) на вызов → O(N²) на всю модель (модель 20K
// вокселей грузилась "вечность"). В батче refresh откладывается и
// делается один раз через _refreshAllProtos() в конце операции.
this._batchMode = false;
// Состояние ввода (для ПКМ-орбита и ЛКМ-рисования)
this._mouse = {
isOrbiting: false, isDrawing: false,
lastX: 0, lastY: 0,
lastBrushKey: null,
};
// Множество voxel-ключей поставленных за текущий drag.
// Используется чтобы предотвратить "какаху к камере": когда пользователь
// двигает мышь медленно, pick может попасть в только что поставленный
// voxel и draw поставит соседний по нормали (новый voxel между ударом
// и камерой). Если pick попал в "свой" voxel — игнорируем.
this._currentDragVoxels = new Set();
this._ptrX = null;
this._ptrY = null;
// WASD + QE — флаги нажатия клавиш для полёта камеры.
// Активны только когда ПКМ зажата (как в редакторе сцены) — иначе
// конфликты с input-полями.
this._keys = { w: false, a: false, s: false, d: false, q: false, e: false, shift: false };
this._initEngine();
this._initScene();
this._initCamera();
this._initLights();
this._initGrid();
this._initVoxelProto();
this._attachInput();
this._startRender();
}
// =============== Engine / Scene ===============
_initEngine() {
this.engine = new Engine(this.canvas, true, {
preserveDrawingBuffer: true,
stencil: true,
antialias: true,
powerPreference: 'high-performance',
});
}
_initScene() {
this.scene = new Scene(this.engine);
// Тёмный фон под общую тёмную тему редактора Рублокса.
// #141414 = .canvasWrap background — сцена сливается с UI.
this.scene.clearColor = new Color4(0.078, 0.078, 0.078, 1);
// Высокий ambient — чтобы при ЛЮБОМ угле камеры все грани были
// хорошо видны (модельный редактор, не игра — нужна равномерная подсветка).
this.scene.ambientColor = new Color3(1, 1, 1);
}
_initCamera() {
// ArcRotateCamera — орбита вокруг центра модели
const half = this.gridSize * VOXEL_SIZE * 0.5;
const dist = this.gridSize * VOXEL_SIZE * 2.2;
this.camera = new ArcRotateCamera(
'voxModelCam',
-Math.PI / 4, // alpha (горизонтальный угол)
Math.PI / 3, // beta (вертикальный)
dist,
new Vector3(half, half, half),
this.scene,
);
this.camera.lowerRadiusLimit = this.gridSize * VOXEL_SIZE * 0.5;
this.camera.upperRadiusLimit = this.gridSize * VOXEL_SIZE * 8;
this.camera.wheelDeltaPercentage = 0.04;
this.camera.pinchDeltaPercentage = 0.02;
this.camera.panningSensibility = 50;
this.camera.minZ = 0.05;
// ВАЖНО: НЕ зовём camera.attachControl(canvas) — мы сами обрабатываем
// ввод (ЛКМ = рисование, ПКМ = орбита, средняя = pan).
}
_initLights() {
// Освещение модельного редактора: равномерное со всех сторон, чтобы
// ЛЮБАЯ грань при ЛЮБОМ угле камеры была хорошо видна.
// 4 directional light'а с разных сторон + сильный hemispheric с равным
// sky/ground цветом (по сути ambient).
const hemi = new HemisphericLight('hemi', new Vector3(0, 1, 0), this.scene);
hemi.intensity = 0.6;
// ВАЖНО: одинаковые diffuse и groundColor — иначе нижние грани темнее.
hemi.diffuse = new Color3(1, 1, 1);
hemi.groundColor = new Color3(0.85, 0.85, 0.9);
hemi.specular = new Color3(0, 0, 0);
// Свет сверху
const top = new DirectionalLight('top', new Vector3(-0.3, -1, -0.2), this.scene);
top.intensity = 0.45;
top.specular = new Color3(0, 0, 0);
// Свет снизу — слабее, чтобы при повороте грани не уходили в чёрное
const bottom = new DirectionalLight('bottom', new Vector3(0.3, 1, 0.2), this.scene);
bottom.intensity = 0.25;
bottom.specular = new Color3(0, 0, 0);
// Две боковые подсветки чтобы выявить рельеф (subtle rim)
const sideA = new DirectionalLight('sideA', new Vector3(-1, 0, 0.4), this.scene);
sideA.intensity = 0.20;
sideA.specular = new Color3(0, 0, 0);
const sideB = new DirectionalLight('sideB', new Vector3(1, 0, -0.4), this.scene);
sideB.intensity = 0.20;
sideB.specular = new Color3(0, 0, 0);
}
/**
* Каркас сетки — рисуем рамку куба границ + плоскость "пола" для ориентира.
* Помогает понять размер модели и где низ.
*/
_initGrid() {
const N = this.gridSize;
const S = VOXEL_SIZE;
const total = N * S;
// Рамка-куб (wireframe) — серовато-синяя, едва заметная на светлом фоне.
const frame = MeshBuilder.CreateBox('voxFrame', { size: total }, this.scene);
frame.position = new Vector3(total / 2, total / 2, total / 2);
const frameMat = new StandardMaterial('voxFrameMat', this.scene);
frameMat.wireframe = true;
frameMat.emissiveColor = new Color3(0.55, 0.62, 0.78);
frameMat.disableLighting = true;
frameMat.alpha = 0.50;
frame.material = frameMat;
frame.isPickable = false;
frame._isFrame = true;
// Пол — pickable, чтобы можно было рисовать на y=0.
// Также служит визуальным ориентиром низа сетки.
const floor = MeshBuilder.CreateGround('voxFloor', {
width: total, height: total,
}, this.scene);
floor.position = new Vector3(total / 2, 0, total / 2);
const floorMat = new StandardMaterial('voxFloorMat', this.scene);
floorMat.emissiveColor = new Color3(0.83, 0.85, 0.90);
floorMat.disableLighting = true;
floorMat.alpha = 0.85;
floor.material = floorMat;
floor.isPickable = true;
floor._isFloor = true;
// Рабочая плоскость — синяя полупрозрачная, ставит voxel "в пустоту"
// на выбранной оси/уровне. Включается из sidebar.
const wp = MeshBuilder.CreateGround('voxWorkPlane', {
width: total, height: total,
}, this.scene);
const wpMat = new StandardMaterial('voxWorkPlaneMat', this.scene);
wpMat.emissiveColor = new Color3(0.18, 0.30, 0.55);
wpMat.disableLighting = true;
wpMat.alpha = 0.16;
wpMat.backFaceCulling = false;
wp.material = wpMat;
wp.isPickable = true;
wp._isWorkPlane = true;
this._frame = frame;
this._floor = floor;
this._workPlane = wp;
this._applyWorkPlaneTransform();
}
/**
* Прото-меши. Один solid-proto для цветных voxels (per-instance vertex color)
* + по одному proto на каждую текстуру (диффуз = текстура).
* Все используют thin-instances → 1 draw call per proto.
*/
_initVoxelProto() {
// === Solid proto (цветной режим) ===
const solidProto = this._createBaseProto('voxSolidProto');
const solidMat = new StandardMaterial('voxSolidMat', this.scene);
solidMat.diffuseColor = new Color3(1, 1, 1);
solidMat.specularColor = new Color3(0, 0, 0);
solidMat.backFaceCulling = true;
solidProto.material = solidMat;
solidProto.hasVertexAlpha = false;
solidProto.useVertexColors = true;
// Color buffer на gridSize³
const maxVoxels = this.gridSize * this.gridSize * this.gridSize;
this._colorsBuffer = new Float32Array(maxVoxels * 4);
solidProto.thinInstanceSetBuffer('color', this._colorsBuffer, 4, false);
this._solidProto = solidProto;
this._freeSlotsByProto.set(solidProto, []);
// === Текстурные proto (по одному на текстуру) ===
this._texProtos = new Map(); // textureId → proto
for (const tex of VOXEL_TEXTURES) {
const p = this._createBaseProto(`voxTex_${tex.id}_proto`);
const m = new StandardMaterial(`voxTex_${tex.id}_mat`, this.scene);
try {
const t = new Texture(tex.file, this.scene);
// Минификация nearest для pixel-art стиля (как у блоков Minecraft)
t.updateSamplingMode(Texture.NEAREST_NEAREST_MIPNEAREST);
m.diffuseTexture = t;
} catch (e) {
m.diffuseColor = new Color3(1, 0.4, 0.8); // фолбэк — заметный пурпур если файл не загрузился
}
m.specularColor = new Color3(0, 0, 0);
m.backFaceCulling = true;
p.material = m;
p.hasVertexAlpha = false;
this._texProtos.set(tex.id, p);
this._freeSlotsByProto.set(p, []);
}
}
/** Создать базовый proto-mesh (cube) с общими настройками pick/render. */
_createBaseProto(name) {
const p = MeshBuilder.CreateBox(name, { size: VOXEL_SIZE }, this.scene);
p.isPickable = true;
p.thinInstanceEnablePicking = true;
// ВАЖНО: alwaysSelectAsActiveMesh=true мы НЕ ставим — иначе bbox
// считается только по 1 proto-кубу в (0,0,0), а pick проверяет
// ray-bbox в ЛОКАЛЬНОЙ системе и инстансы за пределами bbox не пикаются.
// doNotSyncBoundingInfo тоже выключаем — Babylon сам пересчитывает
// bbox при thinInstanceAdd чтобы pick работал по всем instance.
// НЕ freezeWorldMatrix — ломает thin-instance picking.
return p;
}
// =============== Ввод ===============
_attachInput() {
// Сами обрабатываем mouse: различаем ЛКМ vs ПКМ + drag.
// Координаты pointer'а относительно canvas вычисляем через
// getBoundingClientRect — НЕ полагаемся на scene.pointerX (он
// обновляется только если Babylon attachControl, который мы выключили).
const c = this.canvas;
c.style.outline = 'none';
c.tabIndex = 0;
c.addEventListener('contextmenu', this._onContextMenu);
c.addEventListener('pointerdown', this._onPointerDown);
window.addEventListener('pointerup', this._onPointerUp);
window.addEventListener('pointermove', this._onPointerMove);
c.addEventListener('wheel', this._onWheel, { passive: false });
window.addEventListener('keydown', this._onKeyDown);
window.addEventListener('keyup', this._onKeyUp);
}
_detachInput() {
const c = this.canvas;
try {
c.removeEventListener('contextmenu', this._onContextMenu);
c.removeEventListener('pointerdown', this._onPointerDown);
window.removeEventListener('pointerup', this._onPointerUp);
window.removeEventListener('pointermove', this._onPointerMove);
c.removeEventListener('wheel', this._onWheel);
window.removeEventListener('keydown', this._onKeyDown);
window.removeEventListener('keyup', this._onKeyUp);
} catch (e) {}
}
_onContextMenu = (e) => { e.preventDefault(); };
/** Обновить pointer-координаты в this._ptrX/Y (относительно canvas).
* Эти координаты используются в _applyBrushAtPointer для pick. */
_updatePtr(e) {
const rect = this.canvas.getBoundingClientRect();
this._ptrX = e.clientX - rect.left;
this._ptrY = e.clientY - rect.top;
}
_onPointerDown = (e) => {
this.canvas.focus();
this._updatePtr(e);
// ПКМ может приходить с button=2; средняя кнопка с button=1.
// ЛКМ = button 0.
if (e.button === 2 || e.button === 1) {
this._mouse.isOrbiting = true;
this._mouse.lastX = e.clientX;
this._mouse.lastY = e.clientY;
try { this.canvas.setPointerCapture(e.pointerId); } catch (err) {}
} else if (e.button === 0) {
this._mouse.isDrawing = true;
this._mouse.lastBrushKey = null;
this._currentDragVoxels.clear();
try { this.canvas.setPointerCapture(e.pointerId); } catch (err) {}
// Снимок ДО изменений — один раз на каждое нажатие ЛКМ.
// Drag не плодит снимки, undo откатывает всё мазок целиком.
this._pushUndoSnapshot();
this._applyBrushAtPointer(e.shiftKey);
}
};
_onPointerUp = (e) => {
if (e.button === 2 || e.button === 1) {
this._mouse.isOrbiting = false;
try { this.canvas.releasePointerCapture(e.pointerId); } catch (err) {}
}
if (e.button === 0) {
this._mouse.isDrawing = false;
this._mouse.lastBrushKey = null;
this._currentDragVoxels.clear();
try { this.canvas.releasePointerCapture(e.pointerId); } catch (err) {}
}
};
_onPointerMove = (e) => {
this._updatePtr(e);
if (this._mouse.isOrbiting) {
const dx = e.clientX - this._mouse.lastX;
const dy = e.clientY - this._mouse.lastY;
this._mouse.lastX = e.clientX;
this._mouse.lastY = e.clientY;
// Орбита (ручной alpha/beta). dx>0 (мышь вправо) → камера вращается
// вокруг target по часовой стрелке → визуально модель уезжает влево.
this.camera.alpha -= dx * 0.008;
this.camera.beta = Math.max(0.05, Math.min(Math.PI - 0.05,
this.camera.beta - dy * 0.008));
} else if (this._mouse.isDrawing) {
this._applyBrushAtPointer(e.shiftKey);
}
};
_onWheel = (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 1.08 : 0.92;
const newR = this.camera.radius * delta;
this.camera.radius = Math.max(
this.camera.lowerRadiusLimit,
Math.min(this.camera.upperRadiusLimit, newR),
);
};
_onKeyDown = (e) => {
const tag = (e.target?.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea') return;
// Ctrl+Z / Ctrl+Y — обрабатываются на React-уровне; сюда не доходят
if (e.ctrlKey || e.metaKey) return;
if (e.code === 'KeyF') {
const half = this.gridSize * VOXEL_SIZE * 0.5;
this.camera.target = new Vector3(half, half, half);
return;
}
// WASD + Q/E — флаг нажатия (активно при зажатой ПКМ для движения камеры)
let consumed = false;
switch (e.code) {
case 'KeyW': this._keys.w = true; consumed = true; break;
case 'KeyA': this._keys.a = true; consumed = true; break;
case 'KeyS': this._keys.s = true; consumed = true; break;
case 'KeyD': this._keys.d = true; consumed = true; break;
case 'KeyQ': this._keys.q = true; consumed = true; break;
case 'KeyE': this._keys.e = true; consumed = true; break;
case 'ShiftLeft':
case 'ShiftRight':
this._keys.shift = true; break;
default: break;
}
// Предотвращаем scroll-страницы (стрелки/пробел), хотя у нас не они
if (consumed) e.preventDefault();
};
_onKeyUp = (e) => {
switch (e.code) {
case 'KeyW': this._keys.w = false; break;
case 'KeyA': this._keys.a = false; break;
case 'KeyS': this._keys.s = false; break;
case 'KeyD': this._keys.d = false; break;
case 'KeyQ': this._keys.q = false; break;
case 'KeyE': this._keys.e = false; break;
case 'ShiftLeft':
case 'ShiftRight':
this._keys.shift = false; break;
default: break;
}
};
/** Полёт камеры WASD/QE — летит в направлении взгляда (как fly-cam в shooter'ах).
* W/S = вперёд/назад по лучу зрения (включая вертикальную составляющую),
* A/D = вбок (strafe), Q/E = вверх/вниз по миру. */
_updateCameraFly() {
const k = this._keys;
const anyKey = k.w || k.a || k.s || k.d || k.q || k.e;
if (!anyKey) return;
const dtRaw = this.engine.getDeltaTime() / 1000;
const dt = dtRaw > 0 && dtRaw < 0.2 ? dtRaw : 0.016;
const speed = (this.gridSize * VOXEL_SIZE * 0.6) * (k.shift ? 2.5 : 1) * dt;
const cam = this.camera;
// Forward — направление взгляда (от позиции камеры к target), в 3D.
// Это даёт полёт "куда смотрю" — W полетит вверх если камера смотрит вверх.
const forward = cam.target.subtract(cam.position);
if (forward.lengthSquared() < 1e-6) return;
forward.normalize();
// Right — перпендикуляр к forward в горизонтальной плоскости.
// ВАЖНО: Babylon — ЛЕВОсторонняя система координат. Для вектора
// "вправо" нужен cross(worldUp, forward), а НЕ cross(forward, worldUp)
// — последний даёт вектор ВЛЕВО, из-за чего A/D были перепутаны.
// Если forward почти вертикальный — fallback через ось X.
const worldUp = new Vector3(0, 1, 0);
let right = Vector3.Cross(worldUp, forward);
if (right.lengthSquared() < 1e-6) {
// Камера смотрит строго вверх/вниз — берём перпендикуляр через X
right = Vector3.Cross(new Vector3(1, 0, 0), forward);
}
right.normalize();
const move = new Vector3(0, 0, 0);
if (k.w) move.addInPlace(forward.scale(speed));
if (k.s) move.subtractInPlace(forward.scale(speed));
if (k.d) move.addInPlace(right.scale(speed));
if (k.a) move.subtractInPlace(right.scale(speed));
if (k.e) move.y += speed;
if (k.q) move.y -= speed;
// Двигаем target — ArcRotateCamera следует position автоматически.
cam.target.addInPlace(move);
}
// =============== Кисти ===============
/**
* Рейкаст под курсором и применить текущий инструмент.
* Различаем pick по voxel'у (для erase/paint) или по полу (для draw на пустое).
*
* Координаты pointer'а — this._ptrX/Y, обновляются в _updatePtr на каждом
* pointermove (относительно canvas). НЕ используем scene.pointerX —
* он не обновляется без Babylon attachControl.
*/
/** Является ли этот mesh одним из voxel-proto (solid или textured). */
_isVoxelProto(m) {
if (m === this._solidProto) return true;
for (const p of this._texProtos.values()) {
if (m === p) return true;
}
return false;
}
_applyBrushAtPointer(shiftKey) {
if (this._ptrX == null || this._ptrY == null) return;
// Pickable: все voxel-proto + пол + workPlane.
// Pick по полу: voxel на y=0. Pick по workPlane: voxel на выбранном
// axis/level (рисование "в пустоту"). Pick по существующему voxel'у:
// соседний по нормали грани.
const pick = this.scene.pick(this._ptrX, this._ptrY, (m) => {
return this._isVoxelProto(m) || m === this._floor || m === this._workPlane;
});
if (!pick || !pick.hit) return;
let tool = this.currentTool;
if (shiftKey && tool === 'draw') tool = 'erase';
const hitProto = pick.pickedMesh && this._isVoxelProto(pick.pickedMesh)
? pick.pickedMesh : null;
const isVoxelHit = hitProto
&& pick.thinInstanceIndex !== undefined
&& pick.thinInstanceIndex >= 0;
const isFloorHit = !!(pick.pickedMesh && pick.pickedMesh._isFloor);
const isWorkPlaneHit = !!(pick.pickedMesh && pick.pickedMesh._isWorkPlane);
// === Воксель под курсором (для erase/paint/fill всегда; для draw — ставим соседний) ===
if (isVoxelHit) {
const hitKey = this._findKeyByInstanceIdx(hitProto, pick.thinInstanceIndex);
if (!hitKey) return;
// Anti-spam: один и тот же voxel за drag обрабатываем 1 раз
if (tool === 'erase' || tool === 'paint') {
if (hitKey === this._mouse.lastBrushKey) return;
this._mouse.lastBrushKey = hitKey;
}
if (tool === 'erase') {
// Erase brushSize-aware: при N>1 удаляется куб NxNxN вокруг попадания.
const [bx, by, bz] = hitKey.split(',').map(Number);
this._tryEraseAt({ x: bx, y: by, z: bz });
return;
}
if (tool === 'paint') {
this._paintVoxelByKey(hitKey, this._currentMaterial());
this._emit();
return;
}
if (tool === 'fill') {
// Fill применяется один раз на клик (не на каждый drag-тик)
this._floodFillFromKey(hitKey, this._currentMaterial());
this._emit();
this._mouse.isDrawing = false; // отпускаем — иначе drag заливал бы повторно
return;
}
if (tool === 'draw') {
// АНТИ-КАКАХА: если pick попал в voxel который мы поставили
// в текущем drag — игнорируем. Это происходит когда мышь
// двигается медленно после клика: pick прицеливается в новый
// voxel и draw поставил бы соседний "к камере".
if (this._currentDragVoxels.has(hitKey)) return;
// Поставить voxel «над» гранью (по нормали)
const cell = this._cellFromVoxelHit(pick, hitProto);
if (cell) this._tryDrawAt(cell);
return;
}
}
// === Пол под курсором (пустая часть сетки, y=0) ===
if (isFloorHit && pick.pickedPoint) {
if (tool !== 'draw') return;
const cell = this._cellFromFloorHit(pick);
if (cell) this._tryDrawAt(cell);
return;
}
// === Рабочая плоскость (рисование "в пустоту" на axis/level) ===
if (isWorkPlaneHit && pick.pickedPoint) {
if (tool !== 'draw') return;
const cell = this._cellFromWorkPlaneHit(pick);
if (cell) this._tryDrawAt(cell);
}
}
/** Cell-индекс при клике по workPlane (зависит от axis/level). */
_cellFromWorkPlaneHit(pick) {
const p = pick.pickedPoint;
const S = VOXEL_SIZE;
const lv = this.workPlaneLevel | 0;
if (this.workPlaneAxis === 'y') {
return { x: Math.floor(p.x / S), y: lv, z: Math.floor(p.z / S) };
} else if (this.workPlaneAxis === 'x') {
return { x: lv, y: Math.floor(p.y / S), z: Math.floor(p.z / S) };
} else {
return { x: Math.floor(p.x / S), y: Math.floor(p.y / S), z: lv };
}
}
/** Поставить кубик NxNxN (brushSize) в cell.
* cell — центральная ячейка, вокруг неё заполняется куб.
* brushSize=1 → 1 voxel, =2 → 2³=8 ячеек, =3 → 27, =5 → 125. */
_tryDrawAt(cell) {
const key = `${cell.x},${cell.y},${cell.z}`;
if (key === this._mouse.lastBrushKey) return;
this._mouse.lastBrushKey = key;
this._applyBrushBox(cell, /*draw=*/ true);
}
/** Стереть кубик NxNxN вокруг cell. */
_tryEraseAt(cell) {
this._applyBrushBox(cell, /*draw=*/ false);
}
/**
* Применить brushSize-куб в cell.
* draw=true → добавить voxel'и (если ячейка пуста); draw=false → стереть.
* Куб всегда центрирован на cell: brushSize=1 → только cell, =2 → cell..cell+1
* (anchor нижний-левый-задний), =3 → cell-1..cell+1 (по центру).
*/
_applyBrushBox(cell, draw) {
const N = Math.max(1, Math.min(5, this.brushSize | 0));
// Смещение: для нечётных N — куб с центром в cell;
// для чётных — anchor cell (куб расширяется в +x/+y/+z).
const half = Math.floor(N / 2);
const offs = (N % 2 === 1) ? -half : 0;
let changed = false;
for (let dy = 0; dy < N; dy++) {
for (let dx = 0; dx < N; dx++) {
for (let dz = 0; dz < N; dz++) {
const x = cell.x + offs + dx;
const y = cell.y + offs + dy;
const z = cell.z + offs + dz;
if (!this._inBounds({ x, y, z })) continue;
const k = `${x},${y},${z}`;
if (draw) {
if (this.voxels.has(k)) continue;
this._addVoxel(x, y, z, this._currentMaterial());
// Запоминаем что мы поставили этот voxel в текущем drag —
// следующий pick по нему не должен ставить соседний.
this._currentDragVoxels.add(k);
changed = true;
} else {
if (!this.voxels.has(k)) continue;
this._removeVoxelByKey(k);
changed = true;
}
}
}
}
if (changed) this._emit();
}
/** Cell-индекс из попадания в voxel + нормаль грани (для draw на соседнюю). */
_cellFromVoxelHit(pick, hitProto) {
const idx = pick.thinInstanceIndex;
const key = this._findKeyByInstanceIdx(hitProto, idx);
if (!key) return null;
const [bx, by, bz] = key.split(',').map(Number);
// Нормаль грани — определяем по PickingInfo.getNormal(true)
const n = pick.getNormal?.(true);
if (!n) return null;
const ax = Math.abs(n.x), ay = Math.abs(n.y), az = Math.abs(n.z);
let dx = 0, dy = 0, dz = 0;
if (ax >= ay && ax >= az) dx = Math.sign(n.x);
else if (ay >= ax && ay >= az) dy = Math.sign(n.y);
else dz = Math.sign(n.z);
return { x: bx + dx, y: by + dy, z: bz + dz };
}
/** Cell-индекс при клике по полу (y=0). */
_cellFromFloorHit(pick) {
const p = pick.pickedPoint;
const S = VOXEL_SIZE;
return {
x: Math.floor(p.x / S),
y: 0,
z: Math.floor(p.z / S),
};
}
_inBounds(cell) {
return cell.x >= 0 && cell.x < this.gridSize
&& cell.y >= 0 && cell.y < this.gridSize
&& cell.z >= 0 && cell.z < this.gridSize;
}
_findKeyByInstanceIdx(proto, idx) {
return this._idxToKey.get(this._reverseKey(proto, idx)) || null;
}
// =============== Storage + thin-instance ===============
/** Поставить voxel. material: { color?, texture? }. */
_addVoxel(x, y, z, material) {
const key = `${x},${y},${z}`;
if (this.voxels.has(key)) return;
const S = VOXEL_SIZE;
const mat = Matrix.Translation(
(x + 0.5) * S,
(y + 0.5) * S,
(z + 0.5) * S,
);
// Выбираем proto по типу материала
const useTexture = !!material.texture;
const proto = useTexture
? this._texProtos.get(material.texture)
: this._solidProto;
if (!proto) return;
const free = this._freeSlotsByProto.get(proto);
let idx;
// ВАЖНО для скорости: refresh-флаг = !this._batchMode.
// thinInstanceAdd(mat, true) на КАЖДОМ вокселе заставляет Babylon
// пересоздавать весь матричный буфер (Float32Array N×16) — это
// O(N²) копирований на загрузку модели. В батч-режиме передаём
// refresh=false; буфер обновляется ОДИН раз через _refreshAllProtos().
const refresh = !this._batchMode;
if (free && free.length > 0) {
idx = free.pop();
proto.thinInstanceSetMatrixAt(idx, mat, refresh);
} else {
idx = proto.thinInstanceAdd(mat, refresh);
}
// Помечаем proto как "грязный" — в батче нужно потом обновить буфер.
if (this._batchMode) {
if (!this._dirtyProtos) this._dirtyProtos = new Set();
this._dirtyProtos.add(proto);
}
const record = {
color: useTexture ? null : material.color,
texture: useTexture ? material.texture : null,
proto, idx,
};
this.voxels.set(key, record);
this._idxToKey.set(this._reverseKey(proto, idx), key);
if (!useTexture) this._setColorAt(idx, material.color);
// Обновить bounding info по всем thin-instance — нужно для корректного pick.
// В батч-режиме откладываем: _refreshAllProtos() сделает это один раз.
if (!this._batchMode) {
try { proto.thinInstanceRefreshBoundingInfo(true); } catch (e) {}
}
}
/** Пересчитать матричный буфер + boundingInfo у всех proto-мешей разом.
* Вызывается после батч-операции (deserialize/clear) — вместо
* per-voxel refresh, который давал O(N²). */
_refreshAllProtos() {
// Сначала обновляем матричные буферы тех proto, что менялись в батче
// (thinInstanceAdd с refresh=false не отдал буфер на GPU).
if (this._dirtyProtos) {
for (const proto of this._dirtyProtos) {
try { proto.thinInstanceBufferUpdated('matrix'); } catch (e) {}
}
this._dirtyProtos.clear();
}
// Color-буфер solid-proto — обновляем один раз, если менялся в батче.
if (this._colorBufferDirty) {
try { this._solidProto?.thinInstanceBufferUpdated('color'); } catch (e) {}
this._colorBufferDirty = false;
}
try { this._solidProto?.thinInstanceRefreshBoundingInfo(true); } catch (e) {}
if (this._texProtos) {
for (const proto of this._texProtos.values()) {
try { proto.thinInstanceRefreshBoundingInfo(true); } catch (e) {}
}
}
}
_reverseKey(proto, idx) {
return `${proto.name}:${idx}`;
}
_removeVoxelByKey(key) {
const v = this.voxels.get(key);
if (!v) return;
const zero = Matrix.Scaling(0, 0, 0);
// refresh=false в батче — буфер обновится разом в _refreshAllProtos.
const refresh = !this._batchMode;
try { v.proto.thinInstanceSetMatrixAt(v.idx, zero, refresh); } catch (e) {}
if (this._batchMode) {
if (!this._dirtyProtos) this._dirtyProtos = new Set();
this._dirtyProtos.add(v.proto);
}
const free = this._freeSlotsByProto.get(v.proto);
if (free) free.push(v.idx);
this._idxToKey.delete(this._reverseKey(v.proto, v.idx));
this.voxels.delete(key);
// В батч-режиме refresh откладывается (см. _refreshAllProtos).
if (!this._batchMode) {
try { v.proto.thinInstanceRefreshBoundingInfo(true); } catch (e) {}
}
}
_paintVoxelByKey(key, material) {
const v = this.voxels.get(key);
if (!v) return;
const sameColor = !material.texture && v.color === material.color;
const sameTex = !!material.texture && v.texture === material.texture;
if (sameColor || sameTex) return;
// Если меняется proto (цвет ↔ текстура или текстура ↔ текстура) —
// надо удалить и поставить заново
const oldProto = v.proto;
const newUsesTex = !!material.texture;
const newProto = newUsesTex
? this._texProtos.get(material.texture)
: this._solidProto;
if (oldProto !== newProto) {
// Простой re-add: удаляем + добавляем (idx, proto меняются)
const [x, y, z] = key.split(',').map(Number);
this._removeVoxelByKey(key);
this._addVoxel(x, y, z, material);
return;
}
// Тот же proto — для solid просто меняем цвет в буфере
if (!newUsesTex) {
v.color = material.color;
this._setColorAt(v.idx, material.color);
}
// Текстурные с одинаковой текстурой — sameTex выше уже отсеял
}
/** 3D flood-fill: меняем материал у всех соединённых voxels того же типа. */
_floodFillFromKey(startKey, newMaterial) {
const start = this.voxels.get(startKey);
if (!start) return;
// Старый материал в каноническом виде (color или texture)
const oldKey = start.texture ? `t:${start.texture}` : `c:${start.color}`;
const newKey = newMaterial.texture ? `t:${newMaterial.texture}` : `c:${newMaterial.color}`;
if (oldKey === newKey) return;
const stack = [startKey];
const seen = new Set();
while (stack.length) {
const k = stack.pop();
if (seen.has(k)) continue;
seen.add(k);
const v = this.voxels.get(k);
if (!v) continue;
const vKey = v.texture ? `t:${v.texture}` : `c:${v.color}`;
if (vKey !== oldKey) continue;
this._paintVoxelByKey(k, newMaterial);
const [x, y, z] = k.split(',').map(Number);
for (const [dx, dy, dz] of [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]]) {
const nk = `${x+dx},${y+dy},${z+dz}`;
if (!seen.has(nk) && this.voxels.has(nk)) stack.push(nk);
}
}
}
/** Установить цвет для thin-instance idx через colorBuffer. */
_setColorAt(idx, hex) {
const c = hexToColor4(hex);
// Буфер выделен под gridSize^3 — всегда достаточно места.
const off = idx * 4;
if (off + 3 >= this._colorsBuffer.length) {
// Запас: если приходит idx больше ожидаемого (например после resize
// мы потеряли точное соответствие), растим буфер.
const newSize = Math.max((idx + 16) * 4, this._colorsBuffer.length * 2);
const nb = new Float32Array(newSize);
nb.set(this._colorsBuffer);
this._colorsBuffer = nb;
this._solidProto.thinInstanceSetBuffer('color', this._colorsBuffer, 4, false);
}
this._colorsBuffer[off + 0] = c.r;
this._colorsBuffer[off + 1] = c.g;
this._colorsBuffer[off + 2] = c.b;
this._colorsBuffer[off + 3] = 1;
// В батч-режиме НЕ отдаём color-буфер на GPU на каждом вокселе —
// это O(N²). _refreshAllProtos() обновит его один раз в конце.
if (this._batchMode) {
this._colorBufferDirty = true;
} else {
try { this._solidProto.thinInstanceBufferUpdated('color'); } catch (e) {}
}
}
// =============== Публичный API ===============
setTool(tool) {
if (['draw', 'erase', 'paint', 'fill'].includes(tool)) {
this.currentTool = tool;
this._mouse.lastBrushKey = null;
}
}
setColor(hex) {
this.currentColor = hex;
this.currentTexture = null; // выбор цвета сбрасывает текстуру
}
/** Выбрать текстуру (id из VOXEL_TEXTURES) или null для возврата к цвету. */
setTexture(textureId) {
this.currentTexture = textureId || null;
}
/** Текущий материал для кисти. */
_currentMaterial() {
return this.currentTexture
? { texture: this.currentTexture }
: { color: this.currentColor };
}
/** Размер кисти 1..5 — куб NxNxN ячеек за клик. */
setBrushSize(n) {
this.brushSize = Math.max(1, Math.min(5, n | 0));
}
/** Ось рабочей плоскости: 'x' | 'y' | 'z'. */
setWorkPlaneAxis(axis) {
if (axis !== 'x' && axis !== 'y' && axis !== 'z') return;
this.workPlaneAxis = axis;
this._applyWorkPlaneTransform();
}
/** Уровень рабочей плоскости — 0..gridSize-1 по выбранной оси. */
setWorkPlaneLevel(level) {
const lv = Math.max(0, Math.min(this.gridSize - 1, level | 0));
this.workPlaneLevel = lv;
this._applyWorkPlaneTransform();
}
/** Применить позицию/ориентацию workPlane по текущим axis/level. */
_applyWorkPlaneTransform() {
const wp = this._workPlane;
if (!wp) return;
const S = VOXEL_SIZE;
const N = this.gridSize;
const half = (N * S) / 2;
const lv = (this.workPlaneLevel + 0.5) * S;
if (this.workPlaneAxis === 'y') {
wp.rotation.set(0, 0, 0);
wp.position.set(half, lv, half);
} else if (this.workPlaneAxis === 'x') {
wp.rotation.set(0, 0, Math.PI / 2);
wp.position.set(lv, half, half);
} else {
wp.rotation.set(Math.PI / 2, 0, 0);
wp.position.set(half, half, lv);
}
}
/** Очистить все voxels (с undo snapshot). */
clear() {
this._pushUndoSnapshot();
this._clearWithoutSnapshot();
this._emit();
}
_clearWithoutSnapshot() {
// Батч: убираем все воксели без per-voxel refresh, refresh один раз в конце.
const wasBatch = this._batchMode;
this._batchMode = true;
for (const k of Array.from(this.voxels.keys())) {
this._removeVoxelByKey(k);
}
this._batchMode = wasBatch;
if (!this._batchMode) this._refreshAllProtos();
}
/** Сохранить текущий snapshot в undo-стек. Чистит redo-стек. */
_pushUndoSnapshot() {
const snap = this.serialize();
this._undoStack.push(snap);
if (this._undoStack.length > this._undoLimit) {
this._undoStack.shift();
}
// Любое новое действие сбрасывает redo
this._redoStack.length = 0;
}
/** Откатить последнее изменение. */
undo() {
if (this._undoStack.length === 0) return false;
const current = this.serialize();
const prev = this._undoStack.pop();
this._redoStack.push(current);
this._restoreFromSnapshot(prev);
return true;
}
/** Вернуть откат. */
redo() {
if (this._redoStack.length === 0) return false;
const current = this.serialize();
const next = this._redoStack.pop();
this._undoStack.push(current);
this._restoreFromSnapshot(next);
return true;
}
/** Toggle референс-фигурки человека (стандартный рост 2м = 8 voxels).
* Помогает оценить масштаб модели — особенно полезно на крупных сетках. */
setReferenceVisible(visible) {
console.log('[VoxelModelScene] setReferenceVisible called with:', visible,
'_reference exists:', !!this._reference);
if (visible && !this._reference) {
this._buildReference();
}
if (this._reference) {
this._reference.setEnabled(!!visible);
}
}
/** Построить референс масштаба: GLB-модель bacon-hair (рост 2 м).
* Логика повторяет ModelManager.addInstance — проверенный рабочий путь:
* LoadAssetContainerAsync + instantiateModelsToScene + парентим к
* своему TransformNode + scale по targetHeight через getVerticesData. */
_buildReference() {
console.log('[VoxelModelScene] _buildReference() called');
const allNodes = [];
let pendingVisible = false;
const applyVisible = (v) => {
console.log('[VoxelModelScene] applyVisible:', v, 'allNodes.length=', allNodes.length);
let i = 0;
for (const n of allNodes) {
try {
n.setEnabled(!!v);
if (i < 3) {
console.log(' node:', n.name || '(unnamed)',
'type=', n.getClassName?.() || typeof n,
'enabled=', n.isEnabled?.());
}
} catch (e) {
console.warn(' setEnabled failed for', n, e);
}
i++;
}
};
this._reference = {
setEnabled: (v) => {
console.log('[VoxelModelScene] _reference.setEnabled:', v,
'wasLoaded=', allNodes.length > 0);
pendingVisible = !!v;
// Запоминаем последнее запрошенное состояние видимости —
// нужно чтобы при снятии превью скрыть и вернуть как было.
this._referenceVisible = !!v;
applyVisible(v);
},
dispose: () => {
for (const n of allNodes) { try { n.dispose(); } catch (e) {} }
allNodes.length = 0;
},
};
SceneLoader.LoadAssetContainerAsync(
'/kubikon-assets/models/characters/',
'bacon-hair.glb',
this.scene,
).then((container) => {
if (!this._reference) {
try { container.dispose(); } catch (e) {}
return;
}
// Инстанцируем модель (рабочий путь из ModelManager.addInstance).
const inst = container.instantiateModelsToScene(
(name) => `voxRef_${name}`,
true,
{ doNotInstantiate: false }
);
console.log('[VoxelModelScene] bacon instantiated:',
'rootNodes=', inst.rootNodes.length,
'anims=', inst.animationGroups?.length || 0);
// Останавливаем клонированные анимации (бег и т.п.).
if (inst.animationGroups) {
for (const ag of inst.animationGroups) {
try { ag.stop(); } catch (e) {}
}
}
// Используем root самого инстанса (__root__ от GLB) как наш root —
// НЕ создаём свой TransformNode-родитель сверху, иначе scaling
// умножается на исходный glTF-scale (обычно 0.01).
const root = inst.rootNodes[0];
if (!root) {
console.warn('[VoxelModelScene] no rootNodes in instance');
return;
}
root.name = 'voxRefBacon';
console.log('[VoxelModelScene] using GLB root directly:', root.name,
'initial scaling=', root.scaling?.x, root.scaling?.y, root.scaling?.z,
'initial rotation=', root.rotationQuaternion ? 'quat' : 'euler',
'class=', root.getClassName());
// Собираем clonedMeshes для bbox расчёта + регистрируем в allNodes.
const clonedMeshes = [];
root.getChildMeshes(false).forEach(m => {
m.isPickable = false;
clonedMeshes.push(m);
allNodes.push(m);
});
if (root.getTotalVertices && root.getTotalVertices() > 0) {
root.isPickable = false;
clonedMeshes.push(root);
}
allNodes.push(root);
// alwaysSelectAsActiveMesh — для надёжности рендера.
for (const m of clonedMeshes) {
try { m.alwaysSelectAsActiveMesh = true; } catch (e) {}
}
// Сохраняем ИСХОДНЫЕ знаки scaling — glTF-loader использует
// scaling.z=-1 для конвертации right-handed → left-handed системы
// координат. Если затереть на (1,1,1), нормали инвертируются и
// модель рендерится наизнанку (backface culling делает её невидимой).
const initSx = Math.sign(root.scaling.x) || 1;
const initSy = Math.sign(root.scaling.y) || 1;
const initSz = Math.sign(root.scaling.z) || 1;
console.log('[VoxelModelScene] initial scaling signs:', initSx, initSy, initSz);
root.position.set(0, 0, 0);
// glTF-loader использует rotationQuaternion вместо rotation —
// нужно его обнулить чтобы наше Euler-rotation работало.
if (root.rotationQuaternion) {
root.rotationQuaternion = null;
}
root.rotation.set(0, 0, 0);
// Единичный scaling для расчёта bbox, но с СОХРАНЁННЫМИ знаками.
root.scaling.set(initSx, initSy, initSz);
try { root.computeWorldMatrix(true); } catch (e) {}
for (const m of clonedMeshes) {
try { m.computeWorldMatrix(true); } catch (e) {}
}
let minY = Infinity, maxY = -Infinity;
let minX = Infinity, maxX = -Infinity;
let minZ = Infinity, maxZ = -Infinity;
const tmp = new Vector3();
for (const m of clonedMeshes) {
if (!m || typeof m.getTotalVertices !== 'function') continue;
if (m.getTotalVertices() <= 0) continue;
let positions;
try { positions = m.getVerticesData(VertexBuffer.PositionKind); }
catch (e) { continue; }
if (!positions) continue;
const wm = m.getWorldMatrix();
for (let i = 0; i < positions.length; i += 3) {
tmp.set(positions[i], positions[i + 1], positions[i + 2]);
const w = Vector3.TransformCoordinates(tmp, wm);
if (w.y < minY) minY = w.y;
if (w.y > maxY) maxY = w.y;
if (w.x < minX) minX = w.x;
if (w.x > maxX) maxX = w.x;
if (w.z < minZ) minZ = w.z;
if (w.z > maxZ) maxZ = w.z;
}
}
const realHeight = (maxY - minY) > 0.001 && isFinite(maxY - minY)
? (maxY - minY) : 2.0;
// Целевая высота = реальный рост игрового персонажа в Кубиконе.
// PlayerController использует HALF_H = 0.9 м → полная высота AABB
// = 1.8 м. Это стандартный рост Roblox/Кубикон ≈ человеческий рост.
// Так пользователь видит МАСШТАБ своей модели относительно
// настоящего игрового персонажа.
const targetH = 1.8;
const finalScale = targetH / realHeight;
const targetW = (maxX - minX) * finalScale;
const targetD = (maxZ - minZ) * finalScale;
console.log('[VoxelModelScene] bacon natural size: w=',
(maxX - minX).toFixed(2),
'h=', realHeight.toFixed(2),
'd=', (maxZ - minZ).toFixed(2),
'minY=', minY.toFixed(2),
'targetH=', targetH.toFixed(3),
'finalScale=', finalScale.toFixed(4));
// Применяем scale с СОХРАНЁННЫМИ знаками. Z=-1 — это glTF-конвенция,
// её нельзя терять иначе модель рендерится наизнанку (backface).
root.scaling.set(initSx * finalScale, initSy * finalScale, initSz * finalScale);
root.rotation.set(0, Math.PI, 0);
// Сначала ставим в 0,0,0 чтобы пересчитать world bbox.
root.position.set(0, 0, 0);
// ПОЗИЦИОНИРОВАНИЕ ОТ КАМЕРЫ.
// Камера ArcRotateCamera стоит в alpha=-PI/4, beta=PI/3 — это
// смотрит из квадранта +X+Z к центру куба сетки (L/2, L/2, L/2).
// Ближний к камере угол пола = (L, 0, L), дальний = (0, 0, 0).
//
// Хочу bacon ПЕРЕД кубом (между камерой и кубом), у ближнего угла.
// Сместить от ближнего угла куба в направлении +X (правее куба
// с точки зрения мира). Левее по экрану камера видит +X как лево
// потому что rotation_z экрана повёрнут.
//
// Простое решение: bacon в (L + zazor + W/2, 0, L/2) — выходит
// вправо от куба по +X, по центру по Z. Если "слева от куба" с
// точки зрения экрана — меняем +X на -X, минусуем половину ширины.
// ПОЗИЦИОНИРОВАНИЕ ПО WORLD-BBOX ПОСЛЕ scale+rotate.
// Пересчитываем bbox в мировых координатах с применёнными
// scaling и rotation — это даёт ТОЧНЫЕ границы как они в сцене.
// Затем сдвигаем bacon так чтобы его world-bbox оказался в нужном
// месте: ноги на y=0, ближнее-нижнее-правое углое модели рядом
// с углом (L, 0, L) куба сетки.
try { root.computeWorldMatrix(true); } catch (e) {}
for (const m of clonedMeshes) {
try { m.computeWorldMatrix(true); } catch (e) {}
}
let realMinX = Infinity, realMaxX = -Infinity;
let realMinY = Infinity, realMaxY = -Infinity;
let realMinZ = Infinity, realMaxZ = -Infinity;
for (const m of clonedMeshes) {
if (!m.getTotalVertices || m.getTotalVertices() <= 0) continue;
let positions;
try { positions = m.getVerticesData(VertexBuffer.PositionKind); }
catch (e) { continue; }
if (!positions) continue;
const wm = m.getWorldMatrix();
for (let i = 0; i < positions.length; i += 3) {
tmp.set(positions[i], positions[i + 1], positions[i + 2]);
const w = Vector3.TransformCoordinates(tmp, wm);
if (w.x < realMinX) realMinX = w.x;
if (w.x > realMaxX) realMaxX = w.x;
if (w.y < realMinY) realMinY = w.y;
if (w.y > realMaxY) realMaxY = w.y;
if (w.z < realMinZ) realMinZ = w.z;
if (w.z > realMaxZ) realMaxZ = w.z;
}
}
console.log('[VoxelModelScene] bacon REAL world bbox after xform (root at 0,0,0):',
'x[', realMinX.toFixed(2), '..', realMaxX.toFixed(2), ']',
'y[', realMinY.toFixed(2), '..', realMaxY.toFixed(2), ']',
'z[', realMinZ.toFixed(2), '..', realMaxZ.toFixed(2), ']');
// Маркер origin виден в углу (0, 0, 0) куба — это передний-левый-
// нижний угол на экране (подтверждено скриншотом).
// Хочу bacon ВПЛОТНУЮ слева от маркера, снаружи куба:
// bacon.maxX = 0 - gap (правый бок bacon чуть левее угла куба)
// bacon.minY = 0 (ноги на полу куба)
// bacon.minZ = 0 - gap (задний край bacon чуть в -Z от угла куба)
const gap = 0.10;
const shiftX = (-gap) - realMaxX; // bacon полностью в x<0
const shiftY = 0 - realMinY; // ноги на y=0
const shiftZ = (-gap) - realMinZ; // bacon в z<0 (впереди куба)
root.position.set(shiftX, shiftY, shiftZ);
console.log('[VoxelModelScene] bacon shift to corner (0,0,0) outside:',
shiftX.toFixed(2), shiftY.toFixed(2), shiftZ.toFixed(2));
console.log('[VoxelModelScene] bacon final position:',
root.position.x.toFixed(2),
root.position.y.toFixed(2),
root.position.z.toFixed(2),
'targetW=', targetW.toFixed(2),
'targetD=', targetD.toFixed(2));
// Диагностика после применения scale — реальные мировые координаты.
try { root.computeWorldMatrix(true); } catch (e) {}
for (const m of clonedMeshes) {
try { m.computeWorldMatrix(true); } catch (e) {}
}
let postMinY = Infinity, postMaxY = -Infinity;
for (const m of clonedMeshes) {
if (!m.getTotalVertices || m.getTotalVertices() <= 0) continue;
let positions;
try { positions = m.getVerticesData(VertexBuffer.PositionKind); }
catch (e) { continue; }
if (!positions) continue;
const wm = m.getWorldMatrix();
for (let i = 0; i < positions.length; i += 3) {
tmp.set(positions[i], positions[i + 1], positions[i + 2]);
const w = Vector3.TransformCoordinates(tmp, wm);
if (w.y < postMinY) postMinY = w.y;
if (w.y > postMaxY) postMaxY = w.y;
}
}
console.log('[VoxelModelScene] AFTER scale applied: world Y range [',
postMinY.toFixed(2), '..', postMaxY.toFixed(2),
'] height=', (postMaxY - postMinY).toFixed(2),
'(ожидается ~2.0)');
console.log('[VoxelModelScene] bacon root after xform:',
'pos=', root.position.x.toFixed(2), root.position.y.toFixed(2), root.position.z.toFixed(2),
'scaling=', root.scaling.x.toFixed(3),
'enabled=', root.isEnabled());
console.log('[VoxelModelScene] clonedMeshes details:');
for (let i = 0; i < Math.min(clonedMeshes.length, 5); i++) {
const m = clonedMeshes[i];
console.log(' mesh#' + i, m.name,
'enabled=', m.isEnabled?.(),
'visibility=', m.visibility,
'verts=', m.getTotalVertices?.(),
'parent=', m.parent?.name,
'material=', m.material?.name,
'matAlpha=', m.material?.alpha);
}
console.log('[VoxelModelScene] allNodes.length=', allNodes.length,
'pendingVisible=', pendingVisible);
applyVisible(pendingVisible);
// Дополнительная проверка — после applyVisible ещё раз посмотрим
console.log('[VoxelModelScene] AFTER applyVisible:',
'root.enabled=', root.isEnabled(),
'firstMesh.enabled=', clonedMeshes[0]?.isEnabled());
}).catch((err) => {
console.warn('[VoxelModelScene] bacon-hair load failed:', err);
});
}
_restoreFromSnapshot(snap) {
// Загружаем без push'a в undo (избегаем рекурсии)
this._clearWithoutSnapshot();
const decoded = decodeVoxelModel(snap);
if (!decoded) return;
if (ALLOWED_GRID_SIZES.includes(decoded.size) && decoded.size !== this.gridSize) {
// Размер изменился — пересоздаём grid без потери snapshot'ов
this.gridSize = decoded.size;
try { this._frame?.dispose(); } catch (e) {}
try { this._floor?.dispose(); } catch (e) {}
try { this._workPlane?.dispose(); } catch (e) {}
this._initGrid();
const maxVoxels = decoded.size * decoded.size * decoded.size;
this._colorsBuffer = new Float32Array(maxVoxels * 4);
this._solidProto.thinInstanceSetBuffer('color', this._colorsBuffer, 4, false);
const half = decoded.size * VOXEL_SIZE * 0.5;
this.camera.target = new Vector3(half, half, half);
}
// Батч-режим — без per-voxel refresh (см. deserialize).
this._batchMode = true;
for (const v of decoded.voxels) {
const mat = v.t ? { texture: v.t } : { color: v.c || '#ffffff' };
this._addVoxel(v.x, v.y, v.z, mat);
}
this._batchMode = false;
this._refreshAllProtos();
this._emit();
}
canUndo() { return this._undoStack.length > 0; }
canRedo() { return this._redoStack.length > 0; }
/** Сменить размер сетки — сбрасывает все voxels (с undo snapshot). */
resize(newSize) {
if (!ALLOWED_GRID_SIZES.includes(newSize)) return;
this._pushUndoSnapshot();
this._clearWithoutSnapshot();
this.gridSize = newSize;
// Корректируем workPlaneLevel чтобы не выйти за пределы новой сетки
this.workPlaneLevel = Math.min(this.workPlaneLevel, newSize - 1);
// Пересоздаём frame/floor/workPlane
try { this._frame?.dispose(); } catch (e) {}
try { this._floor?.dispose(); } catch (e) {}
try { this._workPlane?.dispose(); } catch (e) {}
this._initGrid();
// Пересоздаём color-buffer под новый размер (n³ × 4 float)
const maxVoxels = newSize * newSize * newSize;
this._colorsBuffer = new Float32Array(maxVoxels * 4);
this._solidProto.thinInstanceSetBuffer('color', this._colorsBuffer, 4, false);
// Пересоздаём камеру-таргет
const half = newSize * VOXEL_SIZE * 0.5;
this.camera.target = new Vector3(half, half, half);
this.camera.radius = newSize * VOXEL_SIZE * 2.2;
this.camera.lowerRadiusLimit = newSize * VOXEL_SIZE * 0.5;
this.camera.upperRadiusLimit = newSize * VOXEL_SIZE * 8;
}
/** Сериализация модели в JSON. Формат v3 (компактный: палитра + плоский
* массив чисел) — в 4-5 раз легче старого v2. См. voxelModelCodec.js. */
serialize() {
const arr = [];
for (const [key, v] of this.voxels) {
const [x, y, z] = key.split(',').map(Number);
if (v.texture) arr.push({ x, y, z, t: v.texture });
else arr.push({ x, y, z, c: v.color });
}
return encodeVoxelModel(this.gridSize, arr);
}
/** Загрузить из JSON-строки. Кодек читает v3 (компактный), v2 и v1. */
deserialize(json) {
this.clear();
const decoded = decodeVoxelModel(json);
if (!decoded) return false;
if (ALLOWED_GRID_SIZES.includes(decoded.size) && decoded.size !== this.gridSize) {
this.resize(decoded.size);
}
// Батч-режим: десятки тысяч вокселей добавляются без per-voxel
// refresh boundingInfo (был O(N²) → "вечная" загрузка). Один
// refresh в конце.
this._batchMode = true;
for (const v of decoded.voxels) {
const mat = v.t ? { texture: v.t } : { color: v.c || '#ffffff' };
this._addVoxel(v.x, v.y, v.z, mat);
}
this._batchMode = false;
this._refreshAllProtos();
this._emit();
return true;
}
/**
* Спрятать вспомогательные элементы сцены (рамка-куб границ сетки,
* нижняя плоскость-пол, рабочая плоскость, референс роста) — на момент
* снятия превью, чтобы они не попадали в обложку модели.
* Возвращает объект состояния для последующего _restoreHelpers().
*/
_hideHelpers() {
const state = {
frame: this._frame ? this._frame.isEnabled() : null,
floor: this._floor ? this._floor.isEnabled() : null,
workPlane: this._workPlane ? this._workPlane.isEnabled() : null,
reference: !!this._referenceVisible,
};
try { this._frame?.setEnabled(false); } catch (e) {}
try { this._floor?.setEnabled(false); } catch (e) {}
try { this._workPlane?.setEnabled(false); } catch (e) {}
if (this._referenceVisible) {
try { this._reference?.setEnabled(false); } catch (e) {}
}
return state;
}
/** Вернуть видимость вспомогательных элементов после снятия превью. */
_restoreHelpers(state) {
if (!state) return;
try { if (state.frame !== null) this._frame?.setEnabled(state.frame); } catch (e) {}
try { if (state.floor !== null) this._floor?.setEnabled(state.floor); } catch (e) {}
try { if (state.workPlane !== null) this._workPlane?.setEnabled(state.workPlane); } catch (e) {}
if (state.reference) {
try { this._reference?.setEnabled(true); } catch (e) {}
}
}
/** Публично спрятать вспомогательные элементы (для режима выделения
* превью — чтобы пользователь видел ровно то, что попадёт в обложку).
* Состояние сохраняется в this._helperHideState. */
hideHelpersForPreview() {
this._helperHideState = this._hideHelpers();
}
/** Публично вернуть вспомогательные элементы после режима выделения. */
restoreHelpersAfterPreview() {
this._restoreHelpers(this._helperHideState);
this._helperHideState = null;
}
/** Снять PNG превью текущего viewport'а — для thumbnail_b64.
* Вспомогательные элементы (рамка/пол/рабочая плоскость) скрываются. */
captureThumbnail(width = 128, height = 128) {
// Babylon Tools.CreateScreenshot — callback API, оборачиваем в Promise.
// Возвращает data URL PNG: "data:image/png;base64,iVBORw..."
return new Promise((resolve) => {
// Если helpers уже скрыты вручную (режим выделения превью) —
// не трогаем их, чтобы не вернуть видимость раньше времени.
const manualHide = !!this._helperHideState;
const helperState = manualHide ? null : this._hideHelpers();
try {
Tools.CreateScreenshot(this.engine, this.camera,
{ width, height }, (data) => {
if (!manualHide) this._restoreHelpers(helperState);
resolve(data || '');
});
} catch (e) {
console.warn('[VoxelModelScene] captureThumbnail failed:', e);
if (!manualHide) this._restoreHelpers(helperState);
resolve('');
}
});
}
/**
* Снять снимок канваса в ЕГО ТЕКУЩЕМ разрешении (как видит пользователь).
* Нужно для интерактивного выделения превью: пользователь выделяет
* квадрат в координатах экрана, мы потом вырежем его из этого снимка.
* Вспомогательные элементы (рамка/пол/рабочая плоскость) скрываются —
* чтобы они не попадали в превью.
*
* Возвращает { dataUrl, width, height } — PNG data URL + размеры
* картинки в пикселях рендера (canvas.width/height, не CSS-размер).
*/
captureCanvasSnapshot() {
return new Promise((resolve) => {
// Если helpers уже скрыты вручную (режим выделения превью) —
// не трогаем: они должны оставаться скрытыми пока юзер выделяет.
const manualHide = !!this._helperHideState;
const helperState = manualHide ? null : this._hideHelpers();
try {
// Рендер-разрешение канваса (с учётом DPR).
const w = this.engine.getRenderWidth();
const h = this.engine.getRenderHeight();
Tools.CreateScreenshot(this.engine, this.camera,
{ width: w, height: h }, (data) => {
if (!manualHide) this._restoreHelpers(helperState);
resolve({ dataUrl: data || '', width: w, height: h });
});
} catch (e) {
console.warn('[VoxelModelScene] captureCanvasSnapshot failed:', e);
if (!manualHide) this._restoreHelpers(helperState);
resolve({ dataUrl: '', width: 0, height: 0 });
}
});
}
/**
* Вырезать квадратную область из снимка канваса и отмасштабировать
* в outSize×outSize PNG. Координаты sx/sy/sSize — в пикселях исходного
* снимка (того, что вернул captureCanvasSnapshot).
*
* @param {string} srcDataUrl — PNG data URL исходного снимка
* @param {number} sx,sy — левый-верхний угол квадрата в пикселях снимка
* @param {number} sSize — сторона квадрата в пикселях снимка
* @param {number} outSize — сторона результата (по умолчанию 128)
* @returns {Promise<string>} PNG data URL результата
*/
static cropSquareThumbnail(srcDataUrl, sx, sy, sSize, outSize = 128) {
return new Promise((resolve) => {
if (!srcDataUrl) { resolve(''); return; }
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = outSize;
canvas.height = outSize;
const ctx = canvas.getContext('2d');
// Клампим область в границы исходника.
let x = Math.max(0, Math.min(sx, img.width - 1));
let y = Math.max(0, Math.min(sy, img.height - 1));
let s = Math.max(1, Math.min(sSize, img.width - x, img.height - y));
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, x, y, s, s, 0, 0, outSize, outSize);
resolve(canvas.toDataURL('image/png'));
} catch (e) {
console.warn('[VoxelModelScene] cropSquareThumbnail failed:', e);
resolve('');
}
};
img.onerror = () => resolve('');
img.src = srcDataUrl;
});
}
_emit() {
try { this._onChange?.(this.voxels.size); } catch (e) {}
}
// =============== Lifecycle ===============
_startRender() {
// Сразу подстраиваем размер канваса под CSS-размер (иначе картинка
// растянутая, и pick'и попадают мимо — координаты воспринимаются
// через canvas.width × canvas.height).
try { this.engine.resize(); } catch (e) {}
this.engine.runRenderLoop(() => {
this._updateCameraFly();
this.scene.render();
});
// resize-наблюдатель — следит за изменением CSS-размера canvas
this._ro = new ResizeObserver(() => {
try { this.engine.resize(); } catch (e) {}
});
try { this._ro.observe(this.canvas); } catch (e) {}
// Ещё один resize через rAF — чтобы попасть после первого layout React'а.
requestAnimationFrame(() => {
try { this.engine.resize(); } catch (e) {}
});
}
dispose() {
try { this._ro?.disconnect(); } catch (e) {}
this._detachInput();
try { this.engine.stopRenderLoop(); } catch (e) {}
try { this.scene.dispose(); } catch (e) {}
try { this.engine.dispose(); } catch (e) {}
}
}