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:
parent
7fc4ee94f6
commit
4c8f8c99cb
@ -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' }}>
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user