Клик по папке в дереве → выделяется вся группа (подсветка всех объектов внутри, рекурсивно по подпапкам) + групповой 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>
414 lines
17 KiB
JavaScript
414 lines
17 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|