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,
|
guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent,
|
||||||
guiOverlayHidden = false, onToggleGuiOverlay,
|
guiOverlayHidden = false, onToggleGuiOverlay,
|
||||||
floorEnabled = true, onCreateFloor, onDeleteFloor, onDeleteSpawn, spawnEnabled = true,
|
floorEnabled = true, onCreateFloor, onDeleteFloor, onDeleteSpawn, spawnEnabled = true,
|
||||||
|
onSelectFolder,
|
||||||
scripts = [], onSelectScript, onCreateScript, onDeleteScript,
|
scripts = [], onSelectScript, onCreateScript, onDeleteScript,
|
||||||
onRenameModel, onRenamePrimitive, onRenameScript,
|
onRenameModel, onRenamePrimitive, onRenameScript,
|
||||||
/**
|
/**
|
||||||
@ -488,14 +489,16 @@ const HierarchyPanel = ({
|
|||||||
return (
|
return (
|
||||||
<div key={`folder-${folder.id}`} className={cl.folderWrap}>
|
<div key={`folder-${folder.id}`} className={cl.folderWrap}>
|
||||||
<div
|
<div
|
||||||
className={cl.folderHeader}
|
className={`${cl.folderHeader} ${selection?.type === 'folder' && selection?.folderId === folder.id ? cl.itemSelected : ''}`}
|
||||||
style={{ paddingLeft: depth * 12 + 8 }}
|
style={{ paddingLeft: depth * 12 + 8 }}
|
||||||
onClick={() => toggleFolder(folder.id)}
|
onClick={() => onSelectFolder?.(folder.id)}
|
||||||
onContextMenu={(e) => handleContextMenu(e, { type: 'folder', ...folder })}
|
onContextMenu={(e) => handleContextMenu(e, { type: 'folder', ...folder })}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={(e) => handleDropOnFolder(e, folder.id)}
|
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} />
|
<Icon name={isOpen ? 'chevronDown' : 'chevronRight'} size={10} strokeWidth={2.5} />
|
||||||
</span>
|
</span>
|
||||||
<span className={cl.folderIcon} style={{ display: 'flex', alignItems: 'center' }}>
|
<span className={cl.folderIcon} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
|||||||
@ -3321,6 +3321,12 @@ const KubikonEditor = () => {
|
|||||||
setSpawnEnabledUI(false);
|
setSpawnEnabledUI(false);
|
||||||
markDirty();
|
markDirty();
|
||||||
}}
|
}}
|
||||||
|
onSelectFolder={(folderId) => {
|
||||||
|
sceneRef.current?.selection?.selectFolder?.(folderId);
|
||||||
|
setActiveTool('select');
|
||||||
|
// Активируем gizmo «Двигать» чтобы сразу таскать всю группу.
|
||||||
|
setGizmoMode('move');
|
||||||
|
}}
|
||||||
onSelectLighting={() => {
|
onSelectLighting={() => {
|
||||||
sceneRef.current?.selection?.selectLighting();
|
sceneRef.current?.selection?.selectLighting();
|
||||||
setActiveTool('select');
|
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) {
|
_updateGizmoForSelection(sel) {
|
||||||
if (!this._gizmo) return;
|
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) {
|
if (!sel) {
|
||||||
this._gizmo.attachTo(null);
|
this._gizmo.attachTo(null);
|
||||||
return;
|
return;
|
||||||
@ -3758,6 +3816,9 @@ export class BabylonScene {
|
|||||||
this._gizmo.attachTo(sel.rootMesh);
|
this._gizmo.attachTo(sel.rootMesh);
|
||||||
} else if (sel.type === 'primitive') {
|
} else if (sel.type === 'primitive') {
|
||||||
this._gizmo.attachTo(sel.mesh);
|
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)
|
// Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale)
|
||||||
// гарантированно пересоздалась поверх нового attached-mesh.
|
// гарантированно пересоздалась поверх нового attached-mesh.
|
||||||
@ -3795,6 +3856,10 @@ export class BabylonScene {
|
|||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const mode = this._gizmo.getMode();
|
const mode = this._gizmo.getMode();
|
||||||
|
|
||||||
|
if (sel.type === 'folder') {
|
||||||
|
this._applyFolderGizmo(mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (sel.type === 'block') {
|
if (sel.type === 'block') {
|
||||||
if (mode === 'move') {
|
if (mode === 'move') {
|
||||||
// Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ)
|
// Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ)
|
||||||
|
|||||||
@ -215,22 +215,21 @@ export class FolderManager {
|
|||||||
* Возвращает количество повёрнутых примитивов.
|
* Возвращает количество повёрнутых примитивов.
|
||||||
*/
|
*/
|
||||||
rotateFolderY(folderId, angle, pivot) {
|
rotateFolderY(folderId, angle, pivot) {
|
||||||
if (!this.primitiveManager || !pivot) return 0;
|
if (!pivot) return 0;
|
||||||
const cosA = Math.cos(angle);
|
const cosA = Math.cos(angle);
|
||||||
const sinA = Math.sin(angle);
|
const sinA = Math.sin(angle);
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
// Примитивы папки.
|
||||||
|
if (this.primitiveManager) {
|
||||||
for (const data of this.primitiveManager.instances.values()) {
|
for (const data of this.primitiveManager.instances.values()) {
|
||||||
if (data.folderId !== folderId) continue;
|
if (data.folderId !== folderId) continue;
|
||||||
// Поворачиваем позицию вокруг pivot.y axis (XZ-плоскость)
|
|
||||||
const dx = data.x - pivot.x;
|
const dx = data.x - pivot.x;
|
||||||
const dz = data.z - pivot.z;
|
const dz = data.z - pivot.z;
|
||||||
const newX = pivot.x + dx * cosA - dz * sinA;
|
data.x = pivot.x + dx * cosA - dz * sinA;
|
||||||
const newZ = pivot.z + dx * sinA + dz * cosA;
|
data.z = pivot.z + dx * sinA + dz * cosA;
|
||||||
data.x = newX;
|
|
||||||
data.z = newZ;
|
|
||||||
data.rotationY = (data.rotationY || 0) + angle;
|
data.rotationY = (data.rotationY || 0) + angle;
|
||||||
if (data.mesh) {
|
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;
|
data.mesh.rotation.y = data.rotationY;
|
||||||
if (data._worldMatrixFrozen) {
|
if (data._worldMatrixFrozen) {
|
||||||
try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
|
try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
|
||||||
@ -239,6 +238,26 @@ export class FolderManager {
|
|||||||
}
|
}
|
||||||
count++;
|
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();
|
this._notifyChange();
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
@ -259,6 +278,99 @@ export class FolderManager {
|
|||||||
return count;
|
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). */
|
/** Найти папку по имени (regex/exact). */
|
||||||
findByName(name) {
|
findByName(name) {
|
||||||
const n = String(name || '').toLowerCase();
|
const n = String(name || '').toLowerCase();
|
||||||
|
|||||||
@ -199,6 +199,29 @@ export class SelectionManager {
|
|||||||
this._notifyChange();
|
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'а пола. */
|
/** Выделить псевдо-объект «Пол» — настройки grid'а пола. */
|
||||||
selectFloor() {
|
selectFloor() {
|
||||||
if (!this._scene3d) return;
|
if (!this._scene3d) return;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user