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>
This commit is contained in:
min 2026-06-05 02:40:00 +03:00
parent 7fc4ee94f6
commit 4c8f8c99cb
5 changed files with 230 additions and 21 deletions

View File

@ -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 (
<div key={`folder-${folder.id}`} className={cl.folderWrap}>
<div
className={cl.folderHeader}
className={`${cl.folderHeader} ${selection?.type === 'folder' && selection?.folderId === folder.id ? cl.itemSelected : ''}`}
style={{ paddingLeft: depth * 12 + 8 }}
onClick={() => toggleFolder(folder.id)}
onClick={() => onSelectFolder?.(folder.id)}
onContextMenu={(e) => handleContextMenu(e, { type: 'folder', ...folder })}
onDragOver={handleDragOver}
onDrop={(e) => handleDropOnFolder(e, folder.id)}
>
<span className={cl.chevron} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className={cl.chevron} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={(e) => { e.stopPropagation(); toggleFolder(folder.id); }}
>
<Icon name={isOpen ? 'chevronDown' : 'chevronRight'} size={10} strokeWidth={2.5} />
</span>
<span className={cl.folderIcon} style={{ display: 'flex', alignItems: 'center' }}>

View File

@ -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');

View File

@ -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)

View File

@ -215,22 +215,21 @@ 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;
// Примитивы папки.
if (this.primitiveManager) {
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.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(newX, data.y, newZ);
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) {}
@ -239,6 +238,26 @@ export class FolderManager {
}
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;
}
@ -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();

View File

@ -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;