- Дерево «Объекты сцены»: авто-раскрытие ветки + скролл к объекту при выборе на сцене (HierarchyPanel useEffect на selection). - Копирование/дублирование примитива сохраняет вращение rotationX/Y/Z (SelectionManager клал selection без rotation → копия теряла поворот). - Копирование/дублирование переносит скрипты объекта на копию (_copyScriptsToNewObject + clip.scripts для Ctrl+C/V). - userModels (воксельные модели) теперь видны в дереве в группе «Мои модели», можно выбрать/удалить/прикрепить скрипт (target kind userModel уже поддержан в GameRuntime). - Free-drag: перетаскивание объекта ЛКМ как в Roblox Studio — скольжение по полу/поверх объектов с AABB-коллизией (скольжение вдоль преграды). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
855 lines
35 KiB
JavaScript
855 lines
35 KiB
JavaScript
/**
|
||
* 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,
|
||
// Вращение — нужно для корректного копирования/дублирования
|
||
// (без него копия теряла поворот, баг 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();
|
||
}
|
||
|
||
/** Выделить псевдо-объект «Пол» — настройки 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;
|
||
}
|
||
}
|