studio/src/editor/engine/SelectionManager.js
min 19f47b2d75 feat(inspector): новые слайдеры света — заливка теней, экспозиция, контраст, насыщенность
В Свет и атмосфера добавлено:
- Заливка теней (scene.ambientColor) — позволяет окрасить тени в
  сером тоне без пересвета diffuse материалов.
- Экспозиция (ipc.exposure 0.3-2) — общая яркость через
  imageProcessingConfiguration.
- Контраст (ipc.contrast 0.5-2)
- Насыщенность (colorCurves.globalSaturation -100..+100)

Юзер крутит слайдеры до момента когда импортированная Roblox-карта
выглядит как оригинал. Дефолты: ambient 0.3, exposure 1.0, contrast
1.0, saturation 1.0.

Также убрал mat.ambientColor=цвет — теперь default (0,0,0). Освещение
управляется глобально через панель.

Состояние пока не сохраняется в проект (только сессия). Persistence
добавим в следующем шаге.
2026-06-08 18:54:00 +03:00

913 lines
38 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;
// Если объект лежит в папке — клик по СЦЕНЕ выделяет ВСЮ папку целиком
// (отдельную часть можно выбрать через дерево). folderId берём из data.
const folderIdOf = (kind, id) => {
let d = null;
if (kind === 'model') d = this.modelManager?.instances?.get(id);
else if (kind === 'userModel') d = this.userModelManager?.instances?.get(id);
else if (kind === 'primitive') d = this.primitiveManager?.instances?.get(id);
return d ? (d.folderId ?? null) : null;
};
if (m?.isBlock) {
return this.selectBlockAt(m.gridX, m.gridY, m.gridZ);
}
if (m?.isModel) {
const md = this.modelManager?.instances?.get(m.instanceId);
if (md && md.locked) return this.clear();
const fid = folderIdOf('model', m.instanceId);
if (fid != null) return this.selectFolder(fid);
return this.selectModelByInstanceId(m.instanceId);
}
if (m?.isUserModel) {
const ud = this.userModelManager?.instances?.get(m.instanceId);
if (ud && ud.locked) return this.clear();
const fid = folderIdOf('userModel', m.instanceId);
if (fid != null) return this.selectFolder(fid);
return this.selectUserModelByInstanceId(m.instanceId);
}
if (m?.isPrimitive) {
const pd = this.primitiveManager?.instances?.get(m.primitiveId);
if (pd && pd.locked) return this.clear();
const fid = folderIdOf('primitive', m.primitiveId);
if (fid != null) return this.selectFolder(fid);
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,
// Вращение — нужно для корректного копирования/дублирования
// (без него копия теряла поворот, баг 2026-06-04).
rotationX: data.rotationX || 0,
rotationY: data.rotationY || 0,
rotationZ: data.rotationZ || 0,
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,
studDensity: data.studDensity || 1,
label: data.label || null, // подпись над объектом (задача 10)
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();
}
/**
* Выделить ПАПКУ целиком: подсветить все объекты внутри + поставить
* selection.type='folder'. Групповой gizmo привязывается в BabylonScene.
*/
selectFolder(folderId) {
const fm = this._scene3d?.folderManager;
if (!fm) return;
const g = fm.getFolderObjects(folderId);
this._removeHighlight();
this._multi = [];
for (const m of g.meshes) this._highlightMesh(m);
this._selection = {
type: 'folder',
folderId,
center: g.center,
count: g.count,
meshes: g.meshes,
};
this._notifyChange();
// Поставить групповой gizmo на пивот папки.
this._scene3d?._attachFolderGizmo?.(folderId, g.center);
}
/** Выделить псевдо-объект «Пол» — настройки 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,
// Новые: глобальный ambient + image processing
sceneAmbient: this._scene3d._sceneAmbient ?? 0.3,
exposure: this._scene3d._exposure ?? 1.0,
contrast: this._scene3d._contrast ?? 1.0,
saturation: this._scene3d._saturation ?? 1.0,
};
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);
this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
} else if (this._selection.type === 'userModel') {
this.userModelManager.removeInstance(this._selection.instanceId);
this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
} else if (this._selection.type === 'primitive') {
this.primitiveManager.removeInstance(this._selection.id);
this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
} else if (this._selection.type === 'spawn') {
// Удаление точки спавна → игрок будет появляться в (0, высота, 0).
this._scene3d?.deleteSpawn?.();
} else if (this._selection.type === 'folder') {
// Папка целиком — удаляем со ВСЕМ содержимым (рекурсивно).
const fid = this._selection.folderId;
this.clear();
this._scene3d?.folderManager?.removeFolder?.(fid, true);
// Удаляем скрипты, привязанные к объектам этой папки? Они привязаны
// к примитивам, которые removeFolder удалит; скрипты на них чистятся
// через _onSceneChange / при сохранении. Дополнительно пусть движок
// подчистит «осиротевшие» скрипты.
this._scene3d?._cleanupOrphanScripts?.();
return;
}
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;
}
}