/** * SelectionManager — управление выделением объектов в сцене. * * Поддерживает два типа выделения: * - блок: метаданные { isBlock: true, blockTypeId, gridX, gridY, gridZ } * - модель: метаданные { isModel: true, instanceId } (могут быть на любом * дочернем меше — модели Kenney состоят из иерархии mesh-узлов) * * Подсветка: enableEdgesRendering на основном меше (для блока) или на всех * меш-чилдренах (для модели). Edges-рёбра рисуются зелёным цветом и поверх * объекта. * * Public API: * selectBlockAt(x, y, z) — выделить блок по координатам * selectModelByInstanceId(id) — выделить модель * selectByMesh(mesh) — универсально (после raycast) * clear() — снять выделение * getSelection() — { type, ... } | null * setOnSelectionChange(cb) — подписка для UI * focusOnSelection(camera) — двинуть редактор-камеру к объекту */ import { Color3, Color4, MeshBuilder, Vector3, StandardMaterial } from '@babylonjs/core'; import { getModelType } from './ModelTypes'; const EDGE_COLOR = new Color4(0.4, 1.0, 0.4, 1.0); // ярко-зелёный const EDGE_WIDTH = 4; export class SelectionManager { constructor(scene, blockManager, modelManager) { this.scene = scene; this.blockManager = blockManager; this.modelManager = modelManager; this.primitiveManager = null; // Этап 5: пользовательские воксельные модели (UserModelManager). this.userModelManager = null; this._selection = null; // Multi-select: массив {kind: 'block'|'model'|'primitive'|'userModel', ref} this._multi = []; this._highlightedMeshes = []; this._onChange = null; this._snapStep = 0; this._scene3d = null; } setPrimitiveManager(pm) { this.primitiveManager = pm; } setUserModelManager(um) { this.userModelManager = um; } /** Установить ссылку на BabylonScene (для доступа к spawn-маркеру и т.п.). */ setScene3D(scene3d) { this._scene3d = scene3d; } /** Установить snap для координат моделей. Влияет на moveSelectedModel. */ setSnapStep(step) { this._snapStep = step; } _snap(value) { if (!this._snapStep || this._snapStep <= 0) return value; return Math.round(value / this._snapStep) * this._snapStep; } setOnSelectionChange(cb) { this._onChange = cb; } /** * Выделить по mesh (полученному из raycast). * Различает blocks и models через metadata. */ selectByMesh(mesh) { if (!mesh) return this.clear(); const m = mesh.metadata; if (m?.isBlock) { return this.selectBlockAt(m.gridX, m.gridY, m.gridZ); } if (m?.isModel) { // Заблокированный объект (Фаза 5.11) не выделяется кликом по // сцене — только через иерархию (чтобы можно было снять lock). const md = this.modelManager?.instances?.get(m.instanceId); if (md && md.locked) return this.clear(); return this.selectModelByInstanceId(m.instanceId); } if (m?.isUserModel) { const ud = this.userModelManager?.instances?.get(m.instanceId); if (ud && ud.locked) return this.clear(); return this.selectUserModelByInstanceId(m.instanceId); } if (m?.isPrimitive) { const pd = this.primitiveManager?.instances?.get(m.primitiveId); if (pd && pd.locked) return this.clear(); return this.selectPrimitiveById(m.primitiveId); } if (m?.isSpawn) { return this.selectSpawn(); } return this.clear(); } selectPrimitiveById(id) { if (!this.primitiveManager) return this.clear(); const data = this.primitiveManager.instances.get(id); if (!data) return this.clear(); this._removeHighlight(); this._highlightMesh(data.mesh); this._selection = { type: 'primitive', id, primitiveType: data.type, x: data.x, y: data.y, z: data.z, sx: data.sx, sy: data.sy, sz: data.sz, color: data.color, material: data.material, canCollide: data.canCollide, visible: data.visible, anchored: data.anchored, mass: data.mass ?? 1, // Параметры лампы / эмиттера / текстуры — чтобы инспектор // показывал текущие значения при перевыборе объекта. brightness: data.brightness, range: data.range, effect: data.effect, textureAsset: data.textureAsset || null, locked: !!data.locked, mesh: data.mesh, rootMesh: data.mesh, }; this._notifyChange(); } /** Сменить позицию примитива. */ moveSelectedPrimitive(newX, newY, newZ) { if (this._selection?.type !== 'primitive' || !this.primitiveManager) return; newX = this._snap(newX); newY = this._snap(newY); newZ = this._snap(newZ); this.primitiveManager.updateInstance(this._selection.id, { x: newX, y: newY, z: newZ }); this._selection.x = newX; this._selection.y = newY; this._selection.z = newZ; this._notifyChange(); } /** Изменить размер примитива. */ resizeSelectedPrimitive(sx, sy, sz) { if (this._selection?.type !== 'primitive') return; this.primitiveManager.updateInstance(this._selection.id, { sx, sy, sz }); this._selection.sx = sx; this._selection.sy = sy; this._selection.sz = sz; // mesh пересоздан — обновляем reference const data = this.primitiveManager.instances.get(this._selection.id); if (data) { this._removeHighlight(); this._highlightMesh(data.mesh); this._selection.mesh = data.mesh; this._selection.rootMesh = data.mesh; } this._notifyChange(); } /** Изменить свойства примитива (color/material/canCollide/visible). */ setSelectedPrimitiveProps(patch) { if (this._selection?.type !== 'primitive') return; this.primitiveManager.updateInstance(this._selection.id, patch); // Применяем в локальное selection Object.assign(this._selection, patch); this._notifyChange(); } /** Выделить точку спавна — показать гизмо на маркере. */ selectSpawn() { if (!this._scene3d || !this._scene3d._spawnMarker) return; this._removeHighlight(); // Подсвечиваем оба меша маркера for (const m of this._scene3d._spawnMarkerMeshes || []) { this._highlightMesh(m); } const sp = this._scene3d._spawnPoint; this._selection = { type: 'spawn', x: sp.x, y: sp.y, z: sp.z, rootMesh: this._scene3d._spawnMarker, }; this._notifyChange(); } /** Выделить псевдо-объект «Пол» — настройки grid'а пола. */ selectFloor() { if (!this._scene3d) return; this._removeHighlight(); this._selection = { type: 'floor', worldSize: this._scene3d.getWorldSize?.() ?? 80, enabled: this._scene3d.isFloorEnabled?.() !== false, }; this._notifyChange(); } /** Выделить пользовательский скрипт по id (этап 2.1 — только информационно). */ selectScript(scriptId) { if (!this._scene3d) return; const all = this._scene3d.getScripts?.() || []; const s = all.find(x => x.id === scriptId); if (!s) return this.clear(); this._removeHighlight(); this._selection = { type: 'script', scriptId: s.id, code: s.code, target: s.target || null, }; this._notifyChange(); } /** Выделить псевдо-объект «Освещение» — меняем настройки света через Inspector. */ selectLighting() { if (!this._scene3d) return; this._removeHighlight(); const sun = this._scene3d._sunLight; const hemi = this._scene3d._hemiLight; const env = this._scene3d.environment; this._selection = { type: 'lighting', envPreset: env?.preset || 'day', dayDurationMin: env?.dayDurationMin ?? 5, nightDurationMin: env?.nightDurationMin ?? 3, sunIntensity: sun?.intensity ?? 0.8, hemiIntensity: hemi?.intensity ?? 0.65, fogEnabled: env?.fogEnabled ?? false, fogDensity: env?.fogDensity ?? 0.005, fogColor: env ? `#${[env.fogColor?.[0] ?? 0.7, env.fogColor?.[1] ?? 0.8, env.fogColor?.[2] ?? 0.9].map(c => Math.round(Math.max(0, Math.min(1, c)) * 255).toString(16).padStart(2, '0')).join('')}` : '#b0c8e6', shadowQuality: this._scene3d.getShadowQuality?.() || 'soft', ssaoEnabled: this._scene3d.getSsaoEnabled?.() || false, }; this._notifyChange(); } /** Выделить псевдо-объект «Звук» — амбиент + музыка. */ selectSound() { if (!this._scene3d) return; this._removeHighlight(); const audio = this._scene3d.audioManager; // Импортировать пресеты сложно из selectionManager — подгрузим через scene3d const audioState = audio?.serialize?.() || {}; const presets = this._scene3d.getAudioPresets?.() || { ambient: [], music: [] }; this._selection = { type: 'sound', ambientId: audioState.ambientId || 'none', musicId: audioState.musicId || 'none', ambientPresets: presets.ambient, musicPresets: presets.music, }; this._notifyChange(); } /** Выделить псевдо-объект «Скин игрока» — только список моделей. */ selectPlayer() { if (!this._scene3d) return; this._removeHighlight(); const playerOptions = this._scene3d.getPlayerOptions?.() || []; this._selection = { type: 'player', playerModelType: this._scene3d.getPlayerModelType?.() || 'character-a', playerOptions, }; this._notifyChange(); } /** Выделить GUI-элемент по id. Возвращает true если найден. */ selectGui(id) { if (!this._scene3d?.guiManager) return false; const el = this._scene3d.guiManager.get(id); if (!el) return false; this._removeHighlight(); this._selection = { type: 'gui', id: el.id, guiType: el.type, data: { ...el }, }; this._notifyChange(); return true; } /** Выделить псевдо-объект «Свойства игрока» — прыжок, прицел и т.п. */ selectPlayerProps() { if (!this._scene3d) return; this._removeHighlight(); this._selection = { type: 'playerProps', jumpPower: this._scene3d.getPlayerJumpPower?.() ?? 1, crosshair: this._scene3d.getCrosshair?.() ?? 'none', }; this._notifyChange(); } /** Сменить позицию точки спавна. */ moveSelectedSpawn(newX, newY, newZ) { if (this._selection?.type !== 'spawn' || !this._scene3d) return; // Snap newX = this._snap(newX); newY = this._snap(newY); newZ = this._snap(newZ); this._scene3d._spawnPoint = { x: newX, y: newY, z: newZ }; this._scene3d._updateSpawnMarker(); this._selection.x = newX; this._selection.y = newY; this._selection.z = newZ; this._notifyChange(); // Триггерим dirty-tracking if (this._scene3d._onSceneChange) this._scene3d._onSceneChange(); this._scene3d.history?.markChange(); } selectBlockAt(x, y, z) { if (!this.blockManager) return; const key = `${x},${y},${z}`; const mesh = this.blockManager.blocks.get(key); if (!mesh) return this.clear(); this._removeHighlight(); // Для proxy-блоков (thin-instances) подсветка через отдельный overlay-mesh, // т.к. у proxy нет enableEdgesRendering. if (mesh._isBlockProxy || mesh.metadata?._liquidProxy) { this._showBlockHighlight(x, y, z); } else { this._highlightMesh(mesh); } this._selection = { type: 'block', gridX: x, gridY: y, gridZ: z, blockTypeId: mesh.metadata.blockTypeId, anchored: mesh.metadata.anchored !== false, canCollide: mesh.metadata.canCollide !== false, visible: mesh.metadata.visible !== false, mass: mesh.metadata.mass ?? 1, mesh, }; this._notifyChange(); } /** Показать рамку поверх блока в (x, y, z). Использует переиспользуемый overlay-mesh. */ _showBlockHighlight(x, y, z) { if (!this._blockHighlightMesh) { const m = MeshBuilder.CreateBox('blockHighlight', { size: 1.02 }, this.scene); m.isPickable = false; const mat = new StandardMaterial('blockHighlightMat', this.scene); mat.diffuseColor = new Color3(0, 0, 0); mat.alpha = 0.001; // почти невидим, но edges рисуются m.material = mat; m.enableEdgesRendering(); m.edgesWidth = EDGE_WIDTH; m.edgesColor = EDGE_COLOR; this._blockHighlightMesh = m; } this._blockHighlightMesh.position = new Vector3(x, y + 0.5, z); this._blockHighlightMesh.setEnabled(true); } _hideBlockHighlight() { if (this._blockHighlightMesh) this._blockHighlightMesh.setEnabled(false); } selectModelByInstanceId(instanceId) { if (!this.modelManager) return; const data = this.modelManager.instances.get(instanceId); if (!data) return this.clear(); this._removeHighlight(); // Подсвечиваем все mesh-чилдрены модели for (const m of data.clonedMeshes) { this._highlightMesh(m); } this._selection = { type: 'model', instanceId, modelTypeId: data.modelTypeId, x: data.x, y: data.y, z: data.z, rotationY: data.rotationY, scale: data.rootMesh?.scaling?.x ?? 1, anchored: data.anchored, canCollide: data.canCollide, visible: data.visible, mass: data.mass ?? 1, opacity: typeof data.opacity === 'number' ? data.opacity : 1, tint: data.tint || null, rootMesh: data.rootMesh, // Gameplay-метаданные модели (для Inspector враги/спавнеры). // Если у инстанса нет gameplay (например в старом проекте) — // дотягиваем из ModelTypes по modelTypeId. gameplay: data.gameplay || getModelType(data.modelTypeId)?.gameplay || null, gameplayParams: data.gameplayParams || null, }; this._notifyChange(); } /** Выделить пользовательскую voxel-модель (Этап 5 редактора моделей). */ selectUserModelByInstanceId(instanceId) { if (!this.userModelManager) return this.clear(); const data = this.userModelManager.instances.get(instanceId); if (!data) return this.clear(); this._removeHighlight(); // Подсвечиваем все меши инстанса (по одному per материал). for (const m of data.meshes) { this._highlightMesh(m); } this._selection = { type: 'userModel', instanceId, // Префикс 'user:' — ID который сохраняется в project_data userModelTypeId: data.userModelTypeId, userModelId: data.userModelId, x: data.x, y: data.y, z: data.z, rotationY: data.rotationY, scale: data.rootNode?.scaling?.x ?? 1, // По умолчанию воксельные модели — anchored true (декорация), // но если потом понадобится менять — добавим в data. anchored: data.anchored !== false, canCollide: data.canCollide !== false, visible: data.visible !== false, mass: data.mass ?? 1, rootMesh: data.rootNode, }; this._notifyChange(); } /** Сменить позицию выделенного блока — переустанавливаем блок (block-level операция). */ moveSelectedBlock(newX, newY, newZ) { if (this._selection?.type !== 'block') return; const s = this._selection; const typeId = s.blockTypeId; // Удаляем старый, создаём новый. Сохраняем выделение на новой позиции. this.blockManager.removeBlock(s.gridX, s.gridY, s.gridZ); const placed = this.blockManager.addBlock(newX, newY, newZ, typeId); if (placed) { this.selectBlockAt(newX, newY, newZ); } else { this.clear(); } } /** Сменить позицию модели (без удаления-создания — двигаем rootMesh). */ moveSelectedModel(newX, newY, newZ) { if (this._selection?.type !== 'model') return; const data = this.modelManager.instances.get(this._selection.instanceId); if (!data) return; // Snap к шагу если включён newX = this._snap(newX); newY = this._snap(newY); newZ = this._snap(newZ); data.x = newX; data.y = newY; data.z = newZ; if (data.rootMesh) { data.rootMesh.position.x = newX; data.rootMesh.position.y = newY; data.rootMesh.position.z = newZ; } this._selection.x = newX; this._selection.y = newY; this._selection.z = newZ; this._notifyChange(); this.modelManager._notifyChange?.(); } /** Поменять угол поворота модели по Y (радианы). */ rotateSelectedModel(angleRad) { if (this._selection?.type !== 'model') return; const data = this.modelManager.instances.get(this._selection.instanceId); if (!data) return; data.rotationY = angleRad; if (data.rootMesh) { data.rootMesh.rotation.y = angleRad; } this._selection.rotationY = angleRad; this._notifyChange(); this.modelManager._notifyChange?.(); } /** Поменять масштаб модели (равномерный). */ scaleSelectedModel(scale) { if (this._selection?.type !== 'model') return; const data = this.modelManager.instances.get(this._selection.instanceId); if (!data) return; if (data.rootMesh) { data.rootMesh.scaling.x = scale; data.rootMesh.scaling.y = scale; data.rootMesh.scaling.z = scale; } this._selection.scale = scale; this._notifyChange(); this.modelManager._notifyChange?.(); } /** Переместить выделенную user-модель. */ moveSelectedUserModel(newX, newY, newZ) { if (this._selection?.type !== 'userModel') return; const data = this.userModelManager.instances.get(this._selection.instanceId); if (!data) return; newX = this._snap(newX); newY = this._snap(newY); newZ = this._snap(newZ); data.x = newX; data.y = newY; data.z = newZ; if (data.rootNode) { data.rootNode.position.x = newX; data.rootNode.position.y = newY; data.rootNode.position.z = newZ; } this._selection.x = newX; this._selection.y = newY; this._selection.z = newZ; // Уведомляем физику — модель сдвинулась, spatial-индекс устарел. this._scene3d?._syncUserModelColliders?.(); try { this._scene3d?._onSceneChange?.(); } catch (e) {} this._notifyChange(); } /** Поворот выделенной user-модели по Y (радианы). */ rotateSelectedUserModel(angleRad) { if (this._selection?.type !== 'userModel') return; const data = this.userModelManager.instances.get(this._selection.instanceId); if (!data) return; data.rotationY = angleRad; if (data.rootNode) { data.rootNode.rotation.y = angleRad; } this._selection.rotationY = angleRad; this._scene3d?._syncUserModelColliders?.(); try { this._scene3d?._onSceneChange?.(); } catch (e) {} this._notifyChange(); } /** Равномерный масштаб user-модели. */ scaleSelectedUserModel(scale) { if (this._selection?.type !== 'userModel') return; const data = this.userModelManager.instances.get(this._selection.instanceId); if (!data) return; data.scale = scale; if (data.rootNode) { data.rootNode.scaling.x = scale; data.rootNode.scaling.y = scale; data.rootNode.scaling.z = scale; } this._selection.scale = scale; this._scene3d?._syncUserModelColliders?.(); try { this._scene3d?._onSceneChange?.(); } catch (e) {} this._notifyChange(); } /** Поменять свойства user-модели (canCollide / visible / mass / anchored). */ setSelectedUserModelProps(patch) { if (this._selection?.type !== 'userModel' || !this.userModelManager) return; const data = this.userModelManager.instances.get(this._selection.instanceId); if (!data) return; if ('canCollide' in patch) { data.canCollide = !!patch.canCollide; // Перерегистрируем коллайдер этого инстанса в физике if (this._scene3d?._syncUserModelColliders) { this._scene3d._syncUserModelColliders(); } } if ('visible' in patch) { data.visible = !!patch.visible; // Включаем/выключаем рендер всех мешей этого инстанса + root TransformNode try { data.rootNode?.setEnabled(data.visible); } catch (e) {} for (const m of data.meshes) { try { m.setEnabled(data.visible); } catch (e) {} } } if ('mass' in patch) data.mass = patch.mass; if ('anchored' in patch) { data.anchored = !!patch.anchored; // Якорь и канCollide вместе — оба меняют физическую регистрацию if (this._scene3d?._syncUserModelColliders) { this._scene3d._syncUserModelColliders(); } } Object.assign(this._selection, patch); // Дёрнем sceneChange чтобы markDirty сработал и проект сохранился try { this._scene3d?._onSceneChange?.(); } catch (e) {} this._notifyChange(); } /** Поменять свойства блока (canCollide / visible). */ setSelectedBlockProps(patch) { if (this._selection?.type !== 'block' || !this.blockManager) return; const { gridX, gridY, gridZ } = this._selection; this.blockManager.setBlockProps(gridX, gridY, gridZ, patch); Object.assign(this._selection, patch); this._notifyChange(); } /** Поменять свойства модели (canCollide / visible / mass). */ setSelectedModelProps(patch) { if (this._selection?.type !== 'model' || !this.modelManager) return; this.modelManager.setInstanceProps(this._selection.instanceId, patch); Object.assign(this._selection, patch); this._notifyChange(); } /** Поменять mass у выделенного объекта (любого типа). */ setSelectedMass(mass) { if (!this._selection) return; const m = Number(mass); if (!Number.isFinite(m) || m <= 0) return; if (this._selection.type === 'block' && this._selection.mesh) { this._selection.mesh.metadata.mass = m; this._selection.mass = m; this.blockManager?._notifyChange?.(); } else if (this._selection.type === 'model') { this.setSelectedModelProps({ mass: m }); return; } else if (this._selection.type === 'userModel') { this.setSelectedUserModelProps({ mass: m }); return; } else if (this._selection.type === 'primitive' && this.primitiveManager) { this.primitiveManager.updateInstance(this._selection.id, { mass: m }); this._selection.mass = m; } this._notifyChange(); } /** Поменять anchored у выделенного объекта (любого типа). */ setSelectedAnchored(anchored) { if (!this._selection) return; if (this._selection.type === 'block' && this._selection.mesh) { this._selection.mesh.metadata.anchored = !!anchored; this._selection.anchored = !!anchored; this.blockManager?._notifyChange?.(); } else if (this._selection.type === 'model' && this.modelManager) { const data = this.modelManager.instances.get(this._selection.instanceId); if (data) { data.anchored = !!anchored; this._selection.anchored = !!anchored; this.modelManager._notifyChange?.(); } } else if (this._selection.type === 'userModel') { this.setSelectedUserModelProps({ anchored: !!anchored }); return; } else if (this._selection.type === 'primitive' && this.primitiveManager) { this.primitiveManager.updateInstance(this._selection.id, { anchored: !!anchored }); this._selection.anchored = !!anchored; } this._notifyChange(); } /** Удалить выделенный объект. */ deleteSelected() { // Если есть multi-select — удаляем все if (this._multi.length > 0) { for (const it of this._multi) { if (it.kind === 'block') this.blockManager?.removeBlock(it.ref.x, it.ref.y, it.ref.z); else if (it.kind === 'model') this.modelManager?.removeInstance(it.ref); else if (it.kind === 'userModel') this.userModelManager?.removeInstance(it.ref); else if (it.kind === 'primitive') this.primitiveManager?.removeInstance(it.ref); } this.clear(); return; } if (!this._selection) return; if (this._selection.type === 'block') { this.blockManager.removeBlock(this._selection.gridX, this._selection.gridY, this._selection.gridZ); } else if (this._selection.type === 'model') { this.modelManager.removeInstance(this._selection.instanceId); } else if (this._selection.type === 'userModel') { this.userModelManager.removeInstance(this._selection.instanceId); } else if (this._selection.type === 'primitive') { this.primitiveManager.removeInstance(this._selection.id); } this.clear(); } /** Снять выделение. */ clear() { this._removeHighlight(); const had = this._selection !== null || this._multi.length > 0; this._selection = null; this._multi = []; if (had) this._notifyChange(); } /** * Добавить или убрать объект в мульти-выделении (Ctrl+ЛКМ). * Если такой объект уже выделен — снимает; иначе добавляет. */ toggleMeshSelection(mesh) { if (!mesh || !mesh.metadata) return; const md = mesh.metadata; let kind = null, ref = null; if (md.isBlock) { kind = 'block'; ref = { x: md.gridX, y: md.gridY, z: md.gridZ }; } else if (md.isModel) { kind = 'model'; ref = md.instanceId; } else if (md.isPrimitive) { kind = 'primitive'; ref = md.primitiveId; } else return; // Если в мульти ещё ничего нет — добавляем туда текущее single-selection if (this._multi.length === 0 && this._selection) { const s = this._selection; if (s.type === 'block') this._multi.push({ kind: 'block', ref: { x: s.gridX, y: s.gridY, z: s.gridZ } }); else if (s.type === 'model') this._multi.push({ kind: 'model', ref: s.instanceId }); else if (s.type === 'primitive') this._multi.push({ kind: 'primitive', ref: s.id }); } // Проверяем, есть ли уже такой const eq = (a, b) => { if (a.kind !== b.kind) return false; if (a.kind === 'block') return a.ref.x === b.ref.x && a.ref.y === b.ref.y && a.ref.z === b.ref.z; return a.ref === b.ref; }; const existing = { kind, ref }; const idx = this._multi.findIndex(it => eq(it, existing)); if (idx >= 0) { this._multi.splice(idx, 1); } else { this._multi.push(existing); } // Если осталось 0 — clear; если 1 — single-select; иначе оставляем _selection текущим (последний добавленный) this._removeHighlight(); if (this._multi.length === 0) { this._selection = null; } else if (this._multi.length === 1) { const only = this._multi[0]; this._multi = []; if (only.kind === 'block') this.selectBlockAt(only.ref.x, only.ref.y, only.ref.z); else if (only.kind === 'model') this.selectModelByInstanceId(only.ref); else if (only.kind === 'primitive') this.selectPrimitiveById(only.ref); return; // selectXxx уже notify } else { // Подсветим все меши в мульти this._highlightAllMulti(); this._selection = { type: 'multi', count: this._multi.length, items: [...this._multi] }; } this._notifyChange(); } _highlightAllMulti() { for (const it of this._multi) { if (it.kind === 'block') { const m = this.blockManager?.blocks.get(`${it.ref.x},${it.ref.y},${it.ref.z}`); if (m) this._highlightMesh(m); } else if (it.kind === 'model') { const data = this.modelManager?.instances.get(it.ref); if (data?.clonedMeshes) { for (const cm of data.clonedMeshes) this._highlightMesh(cm); } } else if (it.kind === 'primitive') { const data = this.primitiveManager?.instances.get(it.ref); if (data?.mesh) this._highlightMesh(data.mesh); } } } /** Получить массив multi-selection. */ getMultiSelection() { return [...this._multi]; } /** Выделить ВСЁ в сцене (Ctrl+A). */ selectAll() { this._removeHighlight(); this._multi = []; if (this.blockManager) { for (const mesh of this.blockManager.blocks.values()) { const md = mesh.metadata; if (md?.isBlock) this._multi.push({ kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } }); } } if (this.modelManager) { for (const id of this.modelManager.instances.keys()) { this._multi.push({ kind: 'model', ref: id }); } } if (this.primitiveManager) { for (const id of this.primitiveManager.instances.keys()) { this._multi.push({ kind: 'primitive', ref: id }); } } if (this._multi.length === 0) { this._selection = null; } else { this._highlightAllMulti(); this._selection = { type: 'multi', count: this._multi.length, items: [...this._multi] }; } this._notifyChange(); } /** Текущее выделение или null. */ getSelection() { return this._selection; } // === ВНУТРЕННЕЕ === _highlightMesh(mesh) { if (!mesh || mesh._isHighlighted) return; try { // enableEdgesRendering есть только у Mesh, не у TransformNode if (typeof mesh.enableEdgesRendering !== 'function') return; mesh.enableEdgesRendering(); mesh.edgesWidth = EDGE_WIDTH; mesh.edgesColor = EDGE_COLOR; mesh._isHighlighted = true; this._highlightedMeshes.push(mesh); } catch (e) { /* ignore */ } } _removeHighlight() { for (const m of this._highlightedMeshes) { try { m.disableEdgesRendering?.(); m._isHighlighted = false; } catch (e) { /* ignore */ } } this._highlightedMeshes = []; this._hideBlockHighlight(); } _notifyChange() { if (this._onChange) this._onChange(this._selection); } /** Снять выделение. */ clearSelection() { this._removeHighlight(); this._selection = null; this._notifyChange(); } dispose() { this._removeHighlight(); this._selection = null; this._onChange = null; } }