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;