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)
1625 lines
81 KiB
JavaScript
1625 lines
81 KiB
JavaScript
/**
|
||
* 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) {}
|
||
}
|
||
}
|