feat(studio): UX-правки редактора — выбор, копирование, userModels, free-drag

- Дерево «Объекты сцены»: авто-раскрытие ветки + скролл к объекту при
  выборе на сцене (HierarchyPanel useEffect на selection).
- Копирование/дублирование примитива сохраняет вращение rotationX/Y/Z
  (SelectionManager клал selection без rotation → копия теряла поворот).
- Копирование/дублирование переносит скрипты объекта на копию
  (_copyScriptsToNewObject + clip.scripts для Ctrl+C/V).
- userModels (воксельные модели) теперь видны в дереве в группе «Мои
  модели», можно выбрать/удалить/прикрепить скрипт (target kind userModel
  уже поддержан в GameRuntime).
- Free-drag: перетаскивание объекта ЛКМ как в Roblox Studio — скольжение
  по полу/поверх объектов с AABB-коллизией (скольжение вдоль преграды).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
min 2026-06-04 23:54:17 +03:00
parent b7e3620a21
commit 75e83a9f3b
4 changed files with 488 additions and 10 deletions

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import { getBlockType } from './engine/BlockTypes';
import { getModelType } from './engine/ModelTypes';
import { getPrimitiveType } from './engine/PrimitiveTypes';
@ -40,8 +40,17 @@ const ItemRow = ({
extraStyle,
}) => {
const [hovered, setHovered] = useState(false);
const rowRef = React.useRef(null);
// Когда строка стала выделенной подскроллить её в видимую зону дерева
// (после авто-раскрытия веток объект может оказаться вне видимой области).
useEffect(() => {
if (selected && rowRef.current) {
rowRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, [selected]);
return (
<div
ref={rowRef}
className={`item ${selected ? 'item-selected' : ''}`}
style={{
display: 'flex',
@ -226,8 +235,9 @@ function targetMatches(target, kind, ref) {
* onAssignToFolder(kind, ref, folderId) переместить объект/папку в папку
*/
const HierarchyPanel = ({
blocks, models, primitives = [], folders = [],
blocks, models, primitives = [], userModels = [], folders = [],
selection,
onSelectUserModel, onDeleteUserModel, onRenameUserModel,
onSelectBlock, onSelectModel, onSelectPrimitive, onSelectSpawn, onSelectLighting,
onSelectSound, onSelectPlayer, onSelectPlayerProps, onSelectFloor,
guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent,
@ -253,6 +263,7 @@ const HierarchyPanel = ({
const [rootBlocksOpen, setRootBlocksOpen] = useState(false);
const [rootPrimsOpen, setRootPrimsOpen] = useState(false);
const [rootModelsOpen, setRootModelsOpen] = useState(false);
const [rootUserModelsOpen, setRootUserModelsOpen] = useState(false);
// Главные «service»-категории (Roblox-style). По умолчанию свёрнуты.
const [workspaceOpen, setWorkspaceOpen] = useState(false);
const [lightingOpen, setLightingOpen] = useState(false);
@ -328,11 +339,23 @@ const HierarchyPanel = ({
return map;
}, [primitives]);
const userModelsByFolder = useMemo(() => {
const map = new Map();
for (const um of userModels) {
const k = um.folderId ?? null;
if (!map.has(k)) map.set(k, []);
map.get(k).push(um);
}
return map;
}, [userModels]);
const isBlockSelected = (b) =>
selection?.type === 'block' &&
selection.gridX === b.gridX && selection.gridY === b.gridY && selection.gridZ === b.gridZ;
const isModelSelected = (m) =>
selection?.type === 'model' && selection.instanceId === m.instanceId;
const isUserModelSelected = (um) =>
selection?.type === 'userModel' && selection.instanceId === um.instanceId;
const isPrimitiveSelected = (p) =>
selection?.type === 'primitive' && selection.id === p.id;
@ -344,6 +367,65 @@ const HierarchyPanel = ({
});
};
// === Авто-раскрытие пути до выделенного объекта ===
// Когда объект выбирают мышкой на сцене (или из скрипта), он должен стать
// ВИДИМЫМ в дереве: раскрываем «Сцену», нужную под-группу (Блоки/Примитивы/
// Модели) и всю цепочку папок-родителей. Подсветка строки уже работает через
// проп `selection`; здесь только разворачиваем свёрнутые ветки.
useEffect(() => {
if (!selection) return;
const t = selection.type;
// Находим объект и его folderId по выделению.
let obj = null, kind = null;
if (t === 'block') {
obj = blocks.find(b => b.gridX === selection.gridX && b.gridY === selection.gridY && b.gridZ === selection.gridZ);
kind = 'block';
} else if (t === 'primitive') {
obj = primitives.find(p => p.id === selection.id);
kind = 'primitive';
} else if (t === 'model') {
obj = models.find(m => m.instanceId === selection.instanceId);
kind = 'model';
} else if (t === 'userModel') {
obj = userModels.find(um => um.instanceId === selection.instanceId);
kind = 'userModel';
} else if (t === 'spawn' || t === 'floor') {
kind = 'workspace-only';
}
if (!kind) return; // lighting/sound/player/gui/script свои группы, не трогаем
// 1) Раскрыть корневую категорию «Сцена».
setWorkspaceOpen(true);
if (kind === 'workspace-only') return;
const folderId = obj?.folderId ?? null;
if (folderId == null) {
// 2a) Объект в корне раскрыть его под-группу (Блоки/Примитивы/Модели).
if (kind === 'block') setRootBlocksOpen(true);
else if (kind === 'primitive') setRootPrimsOpen(true);
else if (kind === 'model') setRootModelsOpen(true);
else if (kind === 'userModel') setRootUserModelsOpen(true);
} else {
// 2b) Объект в папке раскрыть всю цепочку папок-родителей.
setOpenFolders(prev => {
const n = new Set(prev);
let cur = folderId;
const guard = new Set(); // защита от циклов
while (cur != null && !guard.has(cur)) {
guard.add(cur);
n.add(cur);
const f = folders.find(ff => ff.id === cur);
cur = f ? (f.parentId ?? null) : null;
}
return n;
});
}
// Триггер только смена выделения (не трогаем при добавлении объектов,
// чтобы не переоткрывать ветки, которые пользователь свернул вручную).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection]);
const handleContextMenu = (e, item) => {
e.preventDefault();
e.stopPropagation();
@ -400,7 +482,8 @@ const HierarchyPanel = ({
const subBlocks = blocksByFolder.get(folder.id) || [];
const subModels = modelsByFolder.get(folder.id) || [];
const subPrims = primitivesByFolder.get(folder.id) || [];
const totalCount = subBlocks.length + subModels.length + subPrims.length + subFolders.length;
const subUserModels = userModelsByFolder.get(folder.id) || [];
const totalCount = subBlocks.length + subModels.length + subPrims.length + subUserModels.length + subFolders.length;
return (
<div key={`folder-${folder.id}`} className={cl.folderWrap}>
@ -452,6 +535,7 @@ const HierarchyPanel = ({
{subBlocks.map(b => renderBlockItem(b, depth + 1))}
{subPrims.map(p => renderPrimitiveItem(p, depth + 1))}
{subModels.map(m => renderModelItem(m, depth + 1))}
{subUserModels.map(um => renderUserModelItem(um, depth + 1))}
{totalCount === 0 && (
<div className={cl.empty} style={{ paddingLeft: (depth + 1) * 12 + 8 }}>пусто</div>
)}
@ -580,6 +664,38 @@ const HierarchyPanel = ({
);
};
const renderUserModelItem = (um, depth) => {
const displayName = um.name || 'Моя модель';
return (
<React.Fragment key={`um-${um.instanceId}`}>
<ItemRow
icon="🧊"
label={`${displayName} (${um.x.toFixed(1)}, ${um.y.toFixed(1)}, ${um.z.toFixed(1)})`}
title={`${displayName} — пользовательская модель (id: ${um.instanceId})`}
depth={depth}
selected={isUserModelSelected(um)}
draggable
onDragStart={(e) => handleDragStart(e, { kind: 'userModel', id: um.instanceId })}
onClick={() => onSelectUserModel?.(um.instanceId)}
onDoubleClick={() => { onSelectUserModel?.(um.instanceId); onFocusSelection?.(); }}
onContextMenu={(e) => handleContextMenu(e, { type: 'userModel', ...um })}
plusItems={[
{
id: 'add-script', label: 'Скрипт', icon: '📜',
onClick: () => onCreateScript?.({ kind: 'userModel', id: um.instanceId }),
},
{ divider: true },
{
id: 'delete', label: 'Удалить', icon: '🗑', danger: true,
onClick: () => onDeleteUserModel?.(um.instanceId),
},
]}
/>
{renderNestedScriptsFor('userModel', um.instanceId, depth)}
</React.Fragment>
);
};
const renderPrimitiveItem = (p, depth) => {
const def = getPrimitiveType(p.type);
const displayName = p.name || def?.name || p.type;
@ -633,6 +749,7 @@ const HierarchyPanel = ({
const rootFolders = foldersByParent.get(null) || [];
const rootBlocks = blocksByFolder.get(null) || [];
const rootModels = modelsByFolder.get(null) || [];
const rootUserModels = userModelsByFolder.get(null) || [];
const rootPrims = primitivesByFolder.get(null) || [];
return (
@ -757,6 +874,24 @@ const HierarchyPanel = ({
{rootModelsOpen && rootModels.map(m => renderModelItem(m, 0))}
</>
)}
{/* Мои модели (воксельный редактор) в корне */}
{rootUserModels.length > 0 && (
<>
<div
className={cl.groupHeader}
onClick={() => setRootUserModelsOpen(!rootUserModelsOpen)}
>
<span className={cl.chevron} style={{ display: 'inline-flex', alignItems: 'center' }}>
<Icon name={rootUserModelsOpen ? 'chevronDown' : 'chevronRight'} size={10} strokeWidth={2.5} />
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Icon name="cube" size={13} /> Мои модели ({rootUserModels.length})
</span>
</div>
{rootUserModelsOpen && rootUserModels.map(um => renderUserModelItem(um, 0))}
</>
)}
</div>
)}

View File

@ -776,6 +776,7 @@ const KubikonEditor = () => {
const [selection, setSelection] = useState(null);
const [blocksList, setBlocksList] = useState([]);
const [modelsList, setModelsList] = useState([]);
const [userModelsList, setUserModelsList] = useState([]);
const [primitivesList, setPrimitivesList] = useState([]);
const [foldersList, setFoldersList] = useState([]);
@ -1565,6 +1566,21 @@ const KubikonEditor = () => {
}
setModelsList(arr);
}
if (s.userModelManager) {
const arr = [];
for (const data of s.userModelManager.instances.values()) {
arr.push({
instanceId: data.instanceId,
userModelTypeId: data.userModelTypeId,
userModelId: data.userModelId,
x: data.x, y: data.y, z: data.z,
rotationY: data.rotationY,
folderId: data.folderId ?? null,
name: data.name || null,
});
}
setUserModelsList(arr);
}
if (s.primitiveManager) {
// getAll() не включает folderId добавляем вручную
const arr = s.primitiveManager.getAll();
@ -3064,9 +3080,21 @@ const KubikonEditor = () => {
<HierarchyPanel
blocks={blocksList}
models={modelsList}
userModels={userModelsList}
primitives={primitivesList}
folders={foldersList}
scripts={scriptsList}
onSelectUserModel={(id) => {
sceneRef.current?.selection?.selectUserModelByInstanceId(id);
setActiveTool('select');
}}
onDeleteUserModel={(id) => {
sceneRef.current?.userModelManager?.removeInstance(id);
sceneRef.current?.clearSelection();
}}
onRenameUserModel={(id, name) => {
if (sceneRef.current?.renameUserModel?.(id, name)) markDirty();
}}
onSelectScript={(scriptId) => {
sceneRef.current?.selection?.selectScript?.(scriptId);
setActiveTool('select');
@ -3080,7 +3108,7 @@ const KubikonEditor = () => {
if (target) {
if (target.kind === 'block') {
normalized = { kind: 'block', ref: { x: target.x, y: target.y, z: target.z } };
} else if (target.kind === 'model' || target.kind === 'primitive') {
} else if (target.kind === 'model' || target.kind === 'primitive' || target.kind === 'userModel') {
normalized = { kind: target.kind, id: target.id };
}
}

View File

@ -187,6 +187,10 @@ export class BabylonScene {
this._gizmo = null;
this._gizmoLayer = null;
this._gizmoDragging = false; // флаг что идёт drag гизмо
// Free-drag: свободное перетаскивание объекта ЛКМ (как в Roblox Studio).
this._freeDragCandidate = null; // {mesh} — потенциальный объект для перетаскивания
this._freeDragActive = false; // идёт ли перетаскивание
this._freeDragHalf = null; // {x,y,z} полу-габариты объекта (для коллизий)
this._isDragPlacing = false; // флаг drag-постановки/удаления блоков
this._isTerrainBrushing = false; // флаг drag-кисти террейна
this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять
@ -2290,6 +2294,15 @@ export class BabylonScene {
this._mouseDownY = e.clientY;
this._mouseDownTime = Date.now();
// Free-drag: ЛКМ на объекте при tool=select (и без gizmo-перетаскивания).
// Запоминаем объект как кандидата — реальное перетаскивание начнётся
// в mousemove, если курсор сдвинется (иначе это просто клик-выбор).
if (e.button === 0 && !e.shiftKey && this._activeTool === 'select' && !this._isPlaying) {
if (this._beginFreeDragCandidate()) {
e.preventDefault();
}
}
// ЛКМ + tool=block/erase → активируем drag-постановку.
// Сразу же ставим первый блок в клетке под курсором.
if (e.button === 0 && !e.shiftKey
@ -2355,6 +2368,23 @@ export class BabylonScene {
return;
}
// Free-drag: тянем объект ЛКМ. Активируем после небольшого сдвига,
// чтобы обычный клик-выбор не превращался в перетаскивание.
if (this._freeDragCandidate) {
if (!this._freeDragActive) {
const ddx = Math.abs(e.clientX - this._mouseDownX);
const ddy = Math.abs(e.clientY - this._mouseDownY);
if (ddx > 4 || ddy > 4) {
this._freeDragActive = true;
canvas.style.cursor = 'grabbing';
}
}
if (this._freeDragActive) {
this._updateFreeDrag();
return;
}
}
// Если идёт drag-постановка блоков — пытаемся поставить в новой клетке
if (this._isDragPlacing) {
this._dragPlaceTick(e.shiftKey);
@ -2396,6 +2426,18 @@ export class BabylonScene {
};
const onMouseUp = (e) => {
// Free-drag: завершаем перетаскивание. Если объект реально тащили —
// фиксируем историю и НЕ обрабатываем как клик (иначе сбросит выбор).
if (this._freeDragCandidate) {
const wasActive = this._endFreeDrag();
canvas.style.cursor = 'default';
if (wasActive) {
this._mouseDownButton = -1;
return;
}
// Не тащили (просто клик) — кандидат сброшен, продолжаем обычную
// обработку клика ниже (выбор уже сделан в _beginFreeDragCandidate).
}
// Если идёт drag гизмо — отдаём pointerup и завершаем
if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) {
const ulScene = this._gizmoLayer.utilityLayerScene;
@ -5112,6 +5154,43 @@ export class BabylonScene {
this.selection?.deleteSelected();
}
/**
* Перенести скрипты с объекта-оригинала на копию.
* Находит все скрипты, привязанные к src (по kind+id или kind+ref для блока),
* и создаёт их дубликаты с новым id и target на dst. Без этого копия
* объекта оставалась без скриптов (баг 2026-06-04).
*
* srcRef/dstRef: для 'primitive'|'model' id (число), для 'block' {x,y,z}.
*/
_copyScriptsToNewObject(kind, srcRef, dstRef) {
const matches = (target) => {
if (!target || target.kind !== kind) return false;
if (kind === 'block') {
const tr = target.ref || target;
return tr.x === srcRef.x && tr.y === srcRef.y && tr.z === srcRef.z;
}
const tid = target.id ?? target.ref;
return tid === srcRef;
};
const srcScripts = (this._scripts || []).filter(s => matches(s.target));
for (const s of srcScripts) {
const newId = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const newTarget = kind === 'block'
? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } }
: { kind, id: dstRef };
this._scripts.push({
id: newId,
code: s.code,
name: s.name || null,
target: newTarget,
});
}
if (srcScripts.length > 0) {
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
}
}
/**
* Дублировать выделенный объект (Ctrl+D).
* Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y).
@ -5130,6 +5209,8 @@ export class BabylonScene {
if (ny < 0) continue;
if (!this.blockManager.hasBlock(nx, ny, nz)) {
this.blockManager.addBlock(nx, ny, nz, sel.blockTypeId);
this._copyScriptsToNewObject('block',
{ x: sel.gridX, y: sel.gridY, z: sel.gridZ }, { x: nx, y: ny, z: nz });
this.selection.selectBlockAt(nx, ny, nz);
return;
}
@ -5140,9 +5221,13 @@ export class BabylonScene {
const typeId = sel.modelTypeId;
const sx = sel.x, sy = sel.y, sz = sel.z;
const rotY = sel.rotationY || 0;
const srcId = sel.instanceId;
this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY)
.then(newId => {
if (newId != null) this.selection?.selectModelByInstanceId(newId);
if (newId != null) {
this._copyScriptsToNewObject('model', srcId, newId);
this.selection?.selectModelByInstanceId(newId);
}
})
.catch(err => {
// eslint-disable-next-line no-console
@ -5152,10 +5237,14 @@ export class BabylonScene {
const typeId = sel.userModelTypeId;
const sx = sel.x, sy = sel.y, sz = sel.z;
const rotY = sel.rotationY || 0;
const srcUmId = sel.instanceId;
this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, {
currentUserId: this._currentUserId || null,
}).then(newId => {
if (newId != null) this.selection?.selectUserModelByInstanceId(newId);
if (newId != null) {
this._copyScriptsToNewObject('userModel', srcUmId, newId);
this.selection?.selectUserModelByInstanceId(newId);
}
}).catch(err => {
console.error('[BabylonScene] duplicate user model error:', err);
});
@ -5163,6 +5252,10 @@ export class BabylonScene {
const newId = this.primitiveManager.addInstance(sel.primitiveType, {
x: sel.x + 1, y: sel.y, z: sel.z,
sx: sel.sx, sy: sel.sy, sz: sel.sz,
// Сохраняем вращение копии (без этого сбрасывалось, баг 2026-06-04).
rotationX: sel.rotationX || 0,
rotationY: sel.rotationY || 0,
rotationZ: sel.rotationZ || 0,
color: sel.color, material: sel.material,
canCollide: sel.canCollide, visible: sel.visible,
anchored: sel.anchored,
@ -5170,7 +5263,10 @@ export class BabylonScene {
textureAsset: sel.textureAsset || null,
brightness: sel.brightness, range: sel.range, effect: sel.effect,
});
if (newId != null) this.selection.selectPrimitiveById(newId);
if (newId != null) {
this._copyScriptsToNewObject('primitive', sel.id, newId);
this.selection.selectPrimitiveById(newId);
}
}
}
@ -5199,6 +5295,9 @@ export class BabylonScene {
clip = {
kind: 'primitive', primitiveType: sel.primitiveType,
sx: sel.sx, sy: sel.sy, sz: sel.sz,
rotationX: sel.rotationX || 0,
rotationY: sel.rotationY || 0,
rotationZ: sel.rotationZ || 0,
color: sel.color, material: sel.material,
canCollide: sel.canCollide, visible: sel.visible,
anchored: sel.anchored,
@ -5207,11 +5306,45 @@ export class BabylonScene {
};
}
if (clip) {
// Прикрепляем скрипты объекта в буфер — чтобы вставленная копия
// получила те же скрипты (баг 2026-06-04: копия была без скриптов).
try {
let srcRef = null, kind = sel.type;
if (sel.type === 'block') srcRef = { x: sel.gridX, y: sel.gridY, z: sel.gridZ };
else if (sel.type === 'model' || sel.type === 'userModel') srcRef = sel.instanceId;
else if (sel.type === 'primitive') srcRef = sel.id;
const matchTarget = (target) => {
if (!target || target.kind !== kind) return false;
if (kind === 'block') {
const tr = target.ref || target;
return tr.x === srcRef.x && tr.y === srcRef.y && tr.z === srcRef.z;
}
const tid = target.id ?? target.ref;
return tid === srcRef;
};
clip.scripts = (this._scripts || [])
.filter(s => matchTarget(s.target))
.map(s => ({ code: s.code, name: s.name || null }));
} catch (e) { clip.scripts = []; }
try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); }
catch (e) { /* ignore — приватный режим / переполнение */ }
}
}
/** Создать скрипты из clip.scripts на новом объекте (kind+ref). */
_pasteScripts(clip, kind, dstRef) {
if (!clip || !Array.isArray(clip.scripts) || clip.scripts.length === 0) return;
for (const s of clip.scripts) {
const newId = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const target = kind === 'block'
? { kind: 'block', ref: { x: dstRef.x, y: dstRef.y, z: dstRef.z } }
: { kind, id: dstRef };
this._scripts.push({ id: newId, code: s.code, name: s.name || null, target });
}
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
}
/**
* Вставить объект из буфера обмена (Ctrl+V, Фаза 5.10).
* Объект появляется у точки, куда смотрит редактор-камера.
@ -5239,28 +5372,32 @@ export class BabylonScene {
let gy = 0;
while (gy < 64 && this.blockManager?.hasBlock(gx, gy, gz)) gy++;
this.blockManager?.addBlock(gx, gy, gz, clip.blockTypeId);
this._pasteScripts(clip, 'block', { x: gx, y: gy, z: gz });
this.selection?.selectBlockAt(gx, gy, gz);
} else if (clip.kind === 'model') {
this.modelManager?.addInstance(clip.modelTypeId, px, py, pz, clip.rotationY || 0)
.then(id => { if (id != null) this.selection?.selectModelByInstanceId(id); })
.then(id => { if (id != null) { this._pasteScripts(clip, 'model', id); this.selection?.selectModelByInstanceId(id); } })
.catch(() => {});
} else if (clip.kind === 'userModel') {
this.userModelManager?.addInstance(
clip.userModelTypeId, px, py, pz, clip.rotationY || 0,
{ currentUserId: this._currentUserId || null },
).then(id => { if (id != null) this.selection?.selectUserModelByInstanceId(id); })
).then(id => { if (id != null) { this._pasteScripts(clip, 'userModel', id); this.selection?.selectUserModelByInstanceId(id); } })
.catch(() => {});
} else if (clip.kind === 'primitive') {
const id = this.primitiveManager?.addInstance(clip.primitiveType, {
x: px, y: Math.max(py, (clip.sy || 1) / 2), z: pz,
sx: clip.sx, sy: clip.sy, sz: clip.sz,
rotationX: clip.rotationX || 0,
rotationY: clip.rotationY || 0,
rotationZ: clip.rotationZ || 0,
color: clip.color, material: clip.material,
canCollide: clip.canCollide, visible: clip.visible,
anchored: clip.anchored,
textureAsset: clip.textureAsset || null,
brightness: clip.brightness, range: clip.range, effect: clip.effect,
});
if (id != null) this.selection?.selectPrimitiveById(id);
if (id != null) { this._pasteScripts(clip, 'primitive', id); this.selection?.selectPrimitiveById(id); }
}
if (this._onSceneChange) this._onSceneChange();
}
@ -5323,6 +5460,179 @@ export class BabylonScene {
}
/** Изменить позицию выделенного (используется Inspector). */
// ====================== FREE-DRAG (как в Roblox Studio) ======================
// Зажал ЛКМ на объекте и тянешь — объект скользит по полу/поверх других
// объектов с учётом коллизий (скольжение вдоль преграды).
/** Получить корневой меш/узел выделенного объекта (для drag). */
_getSelectionRoot(sel) {
if (!sel) return null;
return sel.rootMesh || sel.mesh || sel.rootNode || null;
}
/** Полу-габариты (half-extent) AABB меша в мировых координатах. */
_meshHalfExtent(mesh) {
try {
const bb = mesh.getHierarchyBoundingVectors
? mesh.getHierarchyBoundingVectors(true)
: null;
if (bb) {
return {
x: Math.max(0.05, (bb.max.x - bb.min.x) / 2),
y: Math.max(0.05, (bb.max.y - bb.min.y) / 2),
z: Math.max(0.05, (bb.max.z - bb.min.z) / 2),
};
}
} catch (e) { /* ignore */ }
return { x: 0.5, y: 0.5, z: 0.5 };
}
/**
* Собрать AABB всех «препятствий» сцены, КРОМЕ перетаскиваемого объекта,
* пола и сетки. Возвращает массив {minX,maxX,minY,maxY,minZ,maxZ}.
*/
_collectObstacleAABBs(excludeRoot) {
const out = [];
const push = (mesh) => {
if (!mesh || mesh === excludeRoot) return;
try {
const bb = mesh.getHierarchyBoundingVectors(true);
out.push({
minX: bb.min.x, maxX: bb.max.x,
minY: bb.min.y, maxY: bb.max.y,
minZ: bb.min.z, maxZ: bb.max.z,
});
} catch (e) { /* ignore */ }
};
// Примитивы
if (this.primitiveManager?.instances) {
for (const d of this.primitiveManager.instances.values()) {
if (d.mesh && d.mesh !== excludeRoot) push(d.mesh);
}
}
// GLB-модели
if (this.modelManager?.instances) {
for (const d of this.modelManager.instances.values()) {
if (d.rootMesh && d.rootMesh !== excludeRoot) push(d.rootMesh);
}
}
// Пользовательские (воксельные) модели
if (this.userModelManager?.instances) {
for (const d of this.userModelManager.instances.values()) {
const root = d.rootNode || d.rootMesh;
if (root && root !== excludeRoot) push(root);
}
}
return out;
}
/** Пересекается ли AABB кандидата (центр cx,cy,cz + half) с препятствиями. */
_aabbCollides(cx, cy, cz, half, obstacles) {
const eps = 0.02; // небольшой зазор, чтобы не «прилипало»
const minX = cx - half.x + eps, maxX = cx + half.x - eps;
const minY = cy - half.y + eps, maxY = cy + half.y - eps;
const minZ = cz - half.z + eps, maxZ = cz + half.z - eps;
for (const o of obstacles) {
if (maxX > o.minX && minX < o.maxX
&& maxY > o.minY && minY < o.maxY
&& maxZ > o.minZ && minZ < o.maxZ) {
return true;
}
}
return false;
}
/** Начать потенциальный free-drag: запомнить объект под курсором. */
_beginFreeDragCandidate() {
if (this._isPlaying || this._activeTool !== 'select') return false;
const pick = this._pickFromMouse();
if (!pick || !pick.mesh) return false;
// Не тащим пол/сетку/гизмо.
const m = pick.mesh;
if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return false;
if (m.metadata?._isBlockProto) return false; // блоки тащим только гизмо
// Выбираем объект (резолв mesh→тип внутри selection).
this.selection?.selectByMesh(m);
const sel = this.selection?.getSelection();
if (!sel || sel.type === 'block' || sel.type === 'spawn') return false;
const root = this._getSelectionRoot(sel);
if (!root) return false;
this._freeDragCandidate = { root };
this._freeDragHalf = this._meshHalfExtent(root);
this._freeDragActive = false;
return true;
}
/** Обновить позицию объекта при перетаскивании (raycast по сцене + коллизия). */
_updateFreeDrag() {
const cand = this._freeDragCandidate;
if (!cand) return;
const sel = this.selection?.getSelection();
if (!sel) return;
const root = cand.root;
const half = this._freeDragHalf || { x: 0.5, y: 0.5, z: 0.5 };
// Raycast из курсора: ищем поверхность (пол ИЛИ верх другого объекта),
// ИСКЛЮЧАЯ сам перетаскиваемый объект и его дочерние меши.
const isOwn = (mesh) => {
let n = mesh;
while (n) { if (n === root) return true; n = n.parent; }
return false;
};
const pi = this.scene.pick(this.scene.pointerX, this.scene.pointerY, (mesh) => {
if (!mesh.isPickable) return false;
if (isOwn(mesh)) return false;
if (mesh === this._ghostMesh) return false;
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
if (mesh.metadata?._isBlockProto) return false;
return true;
});
let px, pz, surfaceY;
if (pi && pi.hit && pi.pickedPoint) {
px = pi.pickedPoint.x;
pz = pi.pickedPoint.z;
surfaceY = pi.pickedPoint.y; // верх пола или поверхности объекта
} else {
// Нет попадания — проецируем луч на горизонтальную плоскость y=0.
const ray = this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.scene.activeCamera);
if (Math.abs(ray.direction.y) < 1e-4) return;
const t = -ray.origin.y / ray.direction.y;
if (t < 0) return;
px = ray.origin.x + ray.direction.x * t;
pz = ray.origin.z + ray.direction.z * t;
surfaceY = 0;
}
// Целевой центр: объект стоит НА поверхности (низ касается surfaceY).
const targetX = px, targetZ = pz;
const targetY = surfaceY + half.y;
// Коллизия скольжением: двигаем по осям отдельно от текущей позиции.
const obstacles = this._collectObstacleAABBs(root);
const cur = { x: root.position.x, y: targetY, z: root.position.z };
let nx = cur.x, nz = cur.z;
// X
if (!this._aabbCollides(targetX, targetY, cur.z, half, obstacles)) nx = targetX;
// Z (с уже применённым X)
if (!this._aabbCollides(nx, targetY, targetZ, half, obstacles)) nz = targetZ;
this.moveSelectedTo(nx, targetY, nz);
}
/** Завершить free-drag, зафиксировать изменение в истории. */
_endFreeDrag() {
const wasActive = this._freeDragActive;
this._freeDragCandidate = null;
this._freeDragActive = false;
this._freeDragHalf = null;
if (wasActive) {
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
}
return wasActive;
}
moveSelectedTo(x, y, z) {
if (!this.selection) return;
const sel = this.selection.getSelection();

View File

@ -116,6 +116,11 @@ export class SelectionManager {
primitiveType: data.type,
x: data.x, y: data.y, z: data.z,
sx: data.sx, sy: data.sy, sz: data.sz,
// Вращение — нужно для корректного копирования/дублирования
// (без него копия теряла поворот, баг 2026-06-04).
rotationX: data.rotationX || 0,
rotationY: data.rotationY || 0,
rotationZ: data.rotationZ || 0,
color: data.color,
material: data.material,
canCollide: data.canCollide,