player/src/engine/SelectionManager.js
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
Open-source web player for Rublox games, dual-licensed under
AGPL-3.0 + Commercial.

Highlights:
- Babylon.js 7 + React 18 + Vite 5 stack
- Self-contained engine (~46k lines): BlockManager, ModelManager,
  PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD
  gamemodes
- Configurable backend via VITE_API_BASE and friends — works against
  staging (dev-api.rublox.pro) out of the box
- Standalone mode (VITE_STANDALONE=true) loads a bundled sample game
  for first-run without any backend
- Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG
- Lint + format scaffolding (ESLint + Prettier + EditorConfig)
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Removed before public release:
- frontend_deploy.py (contained production SSH credentials)
- ~27 admin endpoints (kept in private repo)
- Hard-coded internal URLs and IPs
- All previous git history (clean repo init)
2026-05-27 23:04:04 +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;
}
}