diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index ea55712..aef2513 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -243,6 +243,7 @@ const HierarchyPanel = ({ guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent, guiOverlayHidden = false, onToggleGuiOverlay, floorEnabled = true, onCreateFloor, onDeleteFloor, onDeleteSpawn, spawnEnabled = true, + onSelectFolder, scripts = [], onSelectScript, onCreateScript, onDeleteScript, onRenameModel, onRenamePrimitive, onRenameScript, /** @@ -488,14 +489,16 @@ const HierarchyPanel = ({ return (
toggleFolder(folder.id)} + onClick={() => onSelectFolder?.(folder.id)} onContextMenu={(e) => handleContextMenu(e, { type: 'folder', ...folder })} onDragOver={handleDragOver} onDrop={(e) => handleDropOnFolder(e, folder.id)} > - + { e.stopPropagation(); toggleFolder(folder.id); }} + > diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 9c2ecc3..62980b7 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -3321,6 +3321,12 @@ const KubikonEditor = () => { setSpawnEnabledUI(false); markDirty(); }} + onSelectFolder={(folderId) => { + sceneRef.current?.selection?.selectFolder?.(folderId); + setActiveTool('select'); + // Активируем gizmo «Двигать» чтобы сразу таскать всю группу. + setGizmoMode('move'); + }} onSelectLighting={() => { sceneRef.current?.selection?.selectLighting(); setActiveTool('select'); diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index afc6a55..79abf35 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -3742,11 +3742,69 @@ export class BabylonScene { } } + /** + * Создать пивот-узел в центре папки и привязать к нему gizmo. При drag + * gizmo двигает/вращает/масштабирует пивот, а на dragEnd дельта применяется + * ко всем объектам папки (FolderManager). Групповая трансформация. + */ + _attachFolderGizmo(folderId, center) { + try { + if (this._folderPivot) { this._folderPivot.dispose(); this._folderPivot = null; } + const pivot = new TransformNode('folderPivot_' + folderId, this.scene); + pivot.position = new Vector3(center.x, center.y, center.z); + pivot.rotation = new Vector3(0, 0, 0); + pivot.scaling = new Vector3(1, 1, 1); + this._folderPivot = pivot; + this._folderPivotId = folderId; + // Запоминаем стартовое состояние пивота для вычисления дельты в dragEnd. + this._folderPivotStart = { + pos: { x: center.x, y: center.y, z: center.z }, + center: { x: center.x, y: center.y, z: center.z }, + }; + } catch (e) { console.warn('[folderGizmo] attach failed', e); } + } + + /** Применить трансформацию пивота к объектам папки (вызывается на dragEnd). */ + _applyFolderGizmo(mode) { + const pivot = this._folderPivot; + const fid = this._folderPivotId; + const start = this._folderPivotStart; + if (!pivot || fid == null || !start || !this.folderManager) return; + if (mode === 'move') { + const dx = pivot.position.x - start.pos.x; + const dy = pivot.position.y - start.pos.y; + const dz = pivot.position.z - start.pos.z; + if (dx || dy || dz) this.folderManager.moveFolderBy(fid, dx, dy, dz); + } else if (mode === 'rotate') { + const ry = pivot.rotation.y; + if (Math.abs(ry) > 0.0001) this.folderManager.rotateFolderY(fid, ry, start.center); + } else if (mode === 'scale') { + const f = (pivot.scaling.x + pivot.scaling.y + pivot.scaling.z) / 3; + if (Math.abs(f - 1) > 0.001) this.folderManager.scaleFolder(fid, f, start.center); + } + // Пересоздаём пивот в новом центре (сброс дельты для следующего drag). + const g = this.folderManager.getFolderObjects(fid); + this._attachFolderGizmo(fid, g.center); + // Переустановить gizmo на новый пивот + обновить selection.center. + const sel = this.selection?.getSelection?.(); + if (sel && sel.type === 'folder') { sel.center = g.center; } + if (this._gizmo && this._folderPivot) { + this._gizmo.attachTo(this._folderPivot); + this._gizmo.refreshMode(); + } + if (this._onSceneChange) this._onSceneChange(); + } + /** * Обновить гизмо под текущее выделение. */ _updateGizmoForSelection(sel) { if (!this._gizmo) return; + // Сменилось выделение и это НЕ папка → убрать пивот папки. + if ((!sel || sel.type !== 'folder') && this._folderPivot) { + try { this._folderPivot.dispose(); } catch (e) {} + this._folderPivot = null; this._folderPivotId = null; + } if (!sel) { this._gizmo.attachTo(null); return; @@ -3758,6 +3816,9 @@ export class BabylonScene { this._gizmo.attachTo(sel.rootMesh); } else if (sel.type === 'primitive') { this._gizmo.attachTo(sel.mesh); + } else if (sel.type === 'folder') { + // Групповой gizmo — привязан к пивоту папки (создан в _attachFolderGizmo). + if (this._folderPivot) this._gizmo.attachTo(this._folderPivot); } // Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale) // гарантированно пересоздалась поверх нового attached-mesh. @@ -3795,6 +3856,10 @@ export class BabylonScene { if (!sel) return; const mode = this._gizmo.getMode(); + if (sel.type === 'folder') { + this._applyFolderGizmo(mode); + return; + } if (sel.type === 'block') { if (mode === 'move') { // Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ) diff --git a/src/editor/engine/FolderManager.js b/src/editor/engine/FolderManager.js index 28ab638..048c52b 100644 --- a/src/editor/engine/FolderManager.js +++ b/src/editor/engine/FolderManager.js @@ -215,29 +215,48 @@ export class FolderManager { * Возвращает количество повёрнутых примитивов. */ rotateFolderY(folderId, angle, pivot) { - if (!this.primitiveManager || !pivot) return 0; + if (!pivot) return 0; const cosA = Math.cos(angle); const sinA = Math.sin(angle); let count = 0; - for (const data of this.primitiveManager.instances.values()) { - if (data.folderId !== folderId) continue; - // Поворачиваем позицию вокруг pivot.y axis (XZ-плоскость) - const dx = data.x - pivot.x; - const dz = data.z - pivot.z; - const newX = pivot.x + dx * cosA - dz * sinA; - const newZ = pivot.z + dx * sinA + dz * cosA; - data.x = newX; - data.z = newZ; - data.rotationY = (data.rotationY || 0) + angle; - if (data.mesh) { - data.mesh.position.set(newX, data.y, newZ); - data.mesh.rotation.y = data.rotationY; - if (data._worldMatrixFrozen) { - try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} - data._worldMatrixFrozen = false; + // Примитивы папки. + 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++; } - count++; } this._notifyChange(); return count; @@ -259,6 +278,99 @@ export class FolderManager { 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(); diff --git a/src/editor/engine/SelectionManager.js b/src/editor/engine/SelectionManager.js index 351d3b2..8e51719 100644 --- a/src/editor/engine/SelectionManager.js +++ b/src/editor/engine/SelectionManager.js @@ -199,6 +199,29 @@ export class SelectionManager { 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;