/** * 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; } }