studio/src/editor/engine/FolderManager.js
min 4c8f8c99cb feat(studio): групповые манипуляции папки (выделение+move/rotate/scale всей группы)
Клик по папке в дереве → выделяется вся группа (подсветка всех объектов
внутри, рекурсивно по подпапкам) + групповой gizmo на пивоте в центре папки.
Манипуляторы двигают/вращают/масштабируют ВСЕ объекты папки сразу. Выбор
отдельной модели внутри — манипулирует только ей (как раньше).

- FolderManager: getFolderObjects (рекурсивный сбор + центр), moveFolderBy,
  scaleFolder (от центра, +размеры примитивов), rotateFolderY расширен на модели.
- SelectionManager.selectFolder → multi-подсветка + type:'folder' + пивот-gizmo.
- BabylonScene._attachFolderGizmo/_applyFolderGizmo: пивот-TransformNode,
  на dragEnd дельта (move/rotate/scale) применяется ко всей папке, пивот
  пересоздаётся в новом центре. Пивот убирается при смене выделения.
- Дерево: клик по строке папки = выделить группу; клик по шеврону = свернуть.

Многокомпонентные модели уже кладутся в авто-папку (ModelManager) — теперь
их можно двигать как единое целое.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 02:40:00 +03:00

414 lines
17 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.

/**
* FolderManager — папки/группы в иерархии сцены.
*
* Папка — логическое объединение объектов (блоков/моделей/примитивов).
* Объект помещается в папку записью `folderId` в его metadata
* (через FolderManager.assignToFolder).
*
* Структура папки:
* { id, name, parentId, visible }
*
* parentId === null → папка в корне.
* folderId === null → объект в корне сцены.
*
* Public API:
* createFolder(name, parentId?) → id
* renameFolder(id, name)
* removeFolder(id, deleteContent?) → удаляет папку (и контент опционально)
* setVisible(id, visible) — скрыть/показать вместе с объектами
* assignToFolder(kind, ref, folderId) — переместить блок/модель/прим в папку
* getFolder(id) / getAll()
* getChildrenOf(folderId) — { folders, blocks, models, primitives }
* getRootChildren() — что в корне (folderId === null)
* serialize() / loadFromArray(arr)
*/
export class FolderManager {
constructor(blockManager, modelManager, primitiveManager) {
this.blockManager = blockManager;
this.modelManager = modelManager;
this.primitiveManager = primitiveManager;
this.folders = new Map(); // id → folder data
this._nextId = 1;
this._onChange = null;
}
setOnChange(cb) { this._onChange = cb; }
_notifyChange() { if (this._onChange) this._onChange(); }
createFolder(name = 'Новая папка', parentId = null) {
const id = this._nextId++;
this.folders.set(id, { id, name, parentId, visible: true });
this._notifyChange();
return id;
}
getFolder(id) {
return this.folders.get(id) || null;
}
getAll() {
return Array.from(this.folders.values());
}
renameFolder(id, name) {
const f = this.folders.get(id);
if (!f) return;
f.name = name;
this._notifyChange();
}
/**
* Удалить папку. Если deleteContent=true — содержимое стирается со сцены,
* иначе содержимое поднимается в parent папку (или в корень).
*/
removeFolder(id, deleteContent = false) {
const f = this.folders.get(id);
if (!f) return;
// Сначала рекурсивно: подпапки.
const childFolders = this.getAll().filter(x => x.parentId === id);
for (const cf of childFolders) {
if (deleteContent) this.removeFolder(cf.id, true);
else this._setFolderParent(cf.id, f.parentId);
}
// Объекты в папке — в parent или удалить.
if (this.blockManager) {
for (const mesh of this.blockManager.blocks.values()) {
if (mesh.metadata?.folderId === id) {
if (deleteContent) {
this.blockManager.removeBlock(mesh.metadata.gridX, mesh.metadata.gridY, mesh.metadata.gridZ);
} else {
mesh.metadata.folderId = f.parentId;
}
}
}
}
if (this.modelManager) {
for (const data of this.modelManager.instances.values()) {
if (data.folderId === id) {
if (deleteContent) this.modelManager.removeInstance(data.instanceId);
else data.folderId = f.parentId;
}
}
}
if (this.primitiveManager) {
for (const data of this.primitiveManager.instances.values()) {
if (data.folderId === id) {
if (deleteContent) this.primitiveManager.removeInstance(data.id);
else data.folderId = f.parentId;
}
}
}
this.folders.delete(id);
this._notifyChange();
}
_setFolderParent(id, parentId) {
const f = this.folders.get(id);
if (!f) return;
f.parentId = parentId;
}
/** Поменять видимость папки (рекурсивно вместе с подпапками и контентом). */
setVisible(id, visible) {
const f = this.folders.get(id);
if (!f) return;
f.visible = visible;
this._applyVisibility(id, this._effectiveVisible(id));
this._notifyChange();
}
/** Эффективная видимость = AND по цепочке родителей. */
_effectiveVisible(folderId) {
let cur = this.folders.get(folderId);
while (cur) {
if (!cur.visible) return false;
cur = cur.parentId != null ? this.folders.get(cur.parentId) : null;
}
return true;
}
/** Применить видимость к содержимому (включая вложенные папки). */
_applyVisibility(folderId, visible) {
// Объекты в этой папке
if (this.blockManager) {
for (const mesh of this.blockManager.blocks.values()) {
if (mesh.metadata?.folderId === folderId) {
// Для proxy-блоков (thin instance) — через setBlockProps
if (mesh._isBlockProxy) {
this.blockManager.setBlockProps(
mesh.metadata.gridX, mesh.metadata.gridY, mesh.metadata.gridZ,
{ visible }
);
} else if (typeof mesh.setEnabled === 'function') {
mesh.setEnabled(visible);
}
}
}
}
if (this.modelManager) {
for (const data of this.modelManager.instances.values()) {
if (data.folderId === folderId && data.rootMesh) {
data.rootMesh.setEnabled(visible);
}
}
}
if (this.primitiveManager) {
for (const data of this.primitiveManager.instances.values()) {
if (data.folderId === folderId && data.mesh) {
// Учитываем собственный visible-флаг примитива
data.mesh.setEnabled(visible && data.visible !== false);
}
}
}
// Подпапки
for (const cf of this.getAll()) {
if (cf.parentId === folderId) {
this._applyVisibility(cf.id, visible && cf.visible);
}
}
}
/**
* Поместить объект в папку (или вынуть в корень при folderId=null).
* kind: 'block' | 'model' | 'primitive'
* ref:
* block → {x,y,z}
* model → instanceId
* primitive → primitive id
*/
assignToFolder(kind, ref, folderId) {
if (kind === 'block' && this.blockManager) {
const mesh = this.blockManager.blocks.get(`${ref.x},${ref.y},${ref.z}`);
if (mesh) {
if (!mesh.metadata) mesh.metadata = {};
mesh.metadata.folderId = folderId;
mesh.setEnabled(folderId == null ? true : this._effectiveVisible(folderId));
}
} else if (kind === 'model' && this.modelManager) {
const data = this.modelManager.instances.get(ref);
if (data) {
data.folderId = folderId;
if (data.rootMesh) data.rootMesh.setEnabled(folderId == null ? true : this._effectiveVisible(folderId));
}
} else if (kind === 'primitive' && this.primitiveManager) {
const data = this.primitiveManager.instances.get(ref);
if (data) {
data.folderId = folderId;
if (data.mesh) {
const folderVisible = folderId == null ? true : this._effectiveVisible(folderId);
data.mesh.setEnabled(folderVisible && data.visible !== false);
}
}
}
this._notifyChange();
}
/**
* Повернуть все примитивы папки вокруг точки pivot на угол angle (рад) по Y.
* Только примитивы (блоки и модели не вращаем — у блоков нет ротации,
* для моделей удобнее делать через ModelManager напрямую).
* pivot: {x, z} — точка вокруг которой вращаем (обычно центр группы).
* Возвращает количество повёрнутых примитивов.
*/
rotateFolderY(folderId, angle, pivot) {
if (!pivot) return 0;
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
let count = 0;
// Примитивы папки.
if (this.primitiveManager) {
for (const data of this.primitiveManager.instances.values()) {
if (data.folderId !== folderId) continue;
const dx = data.x - pivot.x;
const dz = data.z - pivot.z;
data.x = pivot.x + dx * cosA - dz * sinA;
data.z = pivot.z + dx * sinA + dz * cosA;
data.rotationY = (data.rotationY || 0) + angle;
if (data.mesh) {
data.mesh.position.set(data.x, data.y, data.z);
data.mesh.rotation.y = data.rotationY;
if (data._worldMatrixFrozen) {
try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
data._worldMatrixFrozen = false;
}
}
count++;
}
}
// Модели папки (позиция вокруг pivot + собственный поворот).
if (this.modelManager) {
const Vec = this.modelManager._Vector3 || null;
for (const data of this.modelManager.instances.values()) {
if (data.folderId !== folderId) continue;
const dx = data.x - pivot.x;
const dz = data.z - pivot.z;
data.x = pivot.x + dx * cosA - dz * sinA;
data.z = pivot.z + dx * sinA + dz * cosA;
data.rotationY = (data.rotationY || 0) + angle;
const root = data.rootMesh || data.rootNode;
if (root) {
if (data._worldMatrixFrozen) { try { root.unfreezeWorldMatrix?.(); } catch (e) {} data._worldMatrixFrozen = false; }
root.position.set(data.x, data.y, data.z);
if (root.rotation) root.rotation.y = data.rotationY;
}
count++;
}
}
this._notifyChange();
return count;
}
/**
* Установить АБСОЛЮТНЫЙ угол поворота папки (вокруг pivot).
* Папка хранит свой текущий yaw в `_yaw` (по умолчанию 0).
* Вращает на разницу angle - _yaw.
*/
setFolderYawY(folderId, angle, pivot) {
const f = this.folders.get(folderId);
if (!f) return 0;
const prev = f._yaw || 0;
const delta = angle - prev;
if (Math.abs(delta) < 0.0001) return 0;
const count = this.rotateFolderY(folderId, delta, pivot);
f._yaw = angle;
return count;
}
/**
* Собрать все объекты папки (рекурсивно по подпапкам) с их мешами.
* Возвращает { models:[{data}], primitives:[{data}], blocks:[mesh],
* meshes:[meshes для подсветки], center:{x,y,z}, count }.
*/
getFolderObjects(folderId) {
const out = { models: [], primitives: [], blocks: [], meshes: [] };
const ids = new Set([folderId]);
// Собираем id всех вложенных подпапок.
let added = true;
while (added) {
added = false;
for (const f of this.getAll()) {
if (f.parentId != null && ids.has(f.parentId) && !ids.has(f.id)) {
ids.add(f.id); added = true;
}
}
}
if (this.modelManager) {
for (const d of this.modelManager.instances.values()) {
if (ids.has(d.folderId)) {
out.models.push(d);
const root = d.rootMesh || d.rootNode;
if (root) out.meshes.push(root);
}
}
}
if (this.primitiveManager) {
for (const d of this.primitiveManager.instances.values()) {
if (ids.has(d.folderId)) {
out.primitives.push(d);
if (d.mesh) out.meshes.push(d.mesh);
}
}
}
if (this.blockManager) {
for (const mesh of this.blockManager.blocks.values()) {
if (ids.has(mesh.metadata?.folderId)) out.blocks.push(mesh);
}
}
// Центр группы (по позициям моделей/примитивов).
let sx = 0, sy = 0, sz = 0, n = 0;
for (const d of out.models) { sx += d.x || 0; sy += d.y || 0; sz += d.z || 0; n++; }
for (const d of out.primitives) { sx += d.x || 0; sy += d.y || 0; sz += d.z || 0; n++; }
out.center = n > 0 ? { x: sx / n, y: sy / n, z: sz / n } : { x: 0, y: 0, z: 0 };
out.count = out.models.length + out.primitives.length + out.blocks.length;
return out;
}
/** Сдвинуть все объекты папки на (dx,dy,dz). */
moveFolderBy(folderId, dx, dy, dz) {
const g = this.getFolderObjects(folderId);
const apply = (d, mesh) => {
d.x = (d.x || 0) + dx; d.y = (d.y || 0) + dy; d.z = (d.z || 0) + dz;
if (mesh) {
if (d._worldMatrixFrozen) { try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} d._worldMatrixFrozen = false; }
mesh.position.set(d.x, d.y, d.z);
}
};
for (const d of g.models) apply(d, d.rootMesh || d.rootNode);
for (const d of g.primitives) apply(d, d.mesh);
this._notifyChange();
}
/**
* Масштабировать папку относительно центра pivot на коэффициент factor.
* Позиции расходятся/сходятся от центра + размеры примитивов меняются.
*/
scaleFolder(folderId, factor, pivot) {
if (!Number.isFinite(factor) || factor <= 0) return;
const g = this.getFolderObjects(folderId);
const p = pivot || g.center;
const sc = (d, mesh, isPrim) => {
d.x = p.x + ((d.x || 0) - p.x) * factor;
d.y = p.y + ((d.y || 0) - p.y) * factor;
d.z = p.z + ((d.z || 0) - p.z) * factor;
if (isPrim) {
d.sx = (d.sx || 1) * factor; d.sy = (d.sy || 1) * factor; d.sz = (d.sz || 1) * factor;
} else {
d.scale = (d.scale || 1) * factor;
}
if (mesh) {
if (d._worldMatrixFrozen) { try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} d._worldMatrixFrozen = false; }
mesh.position.set(d.x, d.y, d.z);
if (isPrim && mesh.scaling) mesh.scaling.set(d.sx, d.sy, d.sz);
else if (mesh.scaling) mesh.scaling.scaleInPlace(factor);
}
};
for (const d of g.models) sc(d, d.rootMesh || d.rootNode, false);
for (const d of g.primitives) sc(d, d.mesh, true);
this._notifyChange();
}
/** Найти папку по имени (regex/exact). */
findByName(name) {
const n = String(name || '').toLowerCase();
for (const f of this.folders.values()) {
if (String(f.name).toLowerCase() === n) return f;
}
return null;
}
serialize() {
return Array.from(this.folders.values()).map(f => ({
id: f.id, name: f.name, parentId: f.parentId, visible: f.visible,
}));
}
loadFromArray(arr) {
this.clear();
let maxId = 0;
for (const f of arr || []) {
this.folders.set(f.id, {
id: f.id, name: f.name,
parentId: f.parentId ?? null,
visible: f.visible !== false,
});
if (f.id > maxId) maxId = f.id;
}
this._nextId = maxId + 1;
this._notifyChange();
}
clear() {
this.folders.clear();
this._nextId = 1;
}
dispose() {
this.clear();
this._onChange = null;
}
}