/** * 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(); // Обратная карта: ":" → 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} 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) {} } }