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

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

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

848 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 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:<numericId>' — 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;
}
}