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:
parent
b7e3620a21
commit
75e83a9f3b
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { getBlockType } from './engine/BlockTypes';
|
import { getBlockType } from './engine/BlockTypes';
|
||||||
import { getModelType } from './engine/ModelTypes';
|
import { getModelType } from './engine/ModelTypes';
|
||||||
import { getPrimitiveType } from './engine/PrimitiveTypes';
|
import { getPrimitiveType } from './engine/PrimitiveTypes';
|
||||||
@ -40,8 +40,17 @@ const ItemRow = ({
|
|||||||
extraStyle,
|
extraStyle,
|
||||||
}) => {
|
}) => {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const rowRef = React.useRef(null);
|
||||||
|
// Когда строка стала выделенной — подскроллить её в видимую зону дерева
|
||||||
|
// (после авто-раскрытия веток объект может оказаться вне видимой области).
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected && rowRef.current) {
|
||||||
|
rowRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [selected]);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={rowRef}
|
||||||
className={`item ${selected ? 'item-selected' : ''}`}
|
className={`item ${selected ? 'item-selected' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -226,8 +235,9 @@ function targetMatches(target, kind, ref) {
|
|||||||
* onAssignToFolder(kind, ref, folderId) — переместить объект/папку в папку
|
* onAssignToFolder(kind, ref, folderId) — переместить объект/папку в папку
|
||||||
*/
|
*/
|
||||||
const HierarchyPanel = ({
|
const HierarchyPanel = ({
|
||||||
blocks, models, primitives = [], folders = [],
|
blocks, models, primitives = [], userModels = [], folders = [],
|
||||||
selection,
|
selection,
|
||||||
|
onSelectUserModel, onDeleteUserModel, onRenameUserModel,
|
||||||
onSelectBlock, onSelectModel, onSelectPrimitive, onSelectSpawn, onSelectLighting,
|
onSelectBlock, onSelectModel, onSelectPrimitive, onSelectSpawn, onSelectLighting,
|
||||||
onSelectSound, onSelectPlayer, onSelectPlayerProps, onSelectFloor,
|
onSelectSound, onSelectPlayer, onSelectPlayerProps, onSelectFloor,
|
||||||
guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent,
|
guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent,
|
||||||
@ -253,6 +263,7 @@ const HierarchyPanel = ({
|
|||||||
const [rootBlocksOpen, setRootBlocksOpen] = useState(false);
|
const [rootBlocksOpen, setRootBlocksOpen] = useState(false);
|
||||||
const [rootPrimsOpen, setRootPrimsOpen] = useState(false);
|
const [rootPrimsOpen, setRootPrimsOpen] = useState(false);
|
||||||
const [rootModelsOpen, setRootModelsOpen] = useState(false);
|
const [rootModelsOpen, setRootModelsOpen] = useState(false);
|
||||||
|
const [rootUserModelsOpen, setRootUserModelsOpen] = useState(false);
|
||||||
// Главные «service»-категории (Roblox-style). По умолчанию свёрнуты.
|
// Главные «service»-категории (Roblox-style). По умолчанию свёрнуты.
|
||||||
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
||||||
const [lightingOpen, setLightingOpen] = useState(false);
|
const [lightingOpen, setLightingOpen] = useState(false);
|
||||||
@ -328,11 +339,23 @@ const HierarchyPanel = ({
|
|||||||
return map;
|
return map;
|
||||||
}, [primitives]);
|
}, [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) =>
|
const isBlockSelected = (b) =>
|
||||||
selection?.type === 'block' &&
|
selection?.type === 'block' &&
|
||||||
selection.gridX === b.gridX && selection.gridY === b.gridY && selection.gridZ === b.gridZ;
|
selection.gridX === b.gridX && selection.gridY === b.gridY && selection.gridZ === b.gridZ;
|
||||||
const isModelSelected = (m) =>
|
const isModelSelected = (m) =>
|
||||||
selection?.type === 'model' && selection.instanceId === m.instanceId;
|
selection?.type === 'model' && selection.instanceId === m.instanceId;
|
||||||
|
const isUserModelSelected = (um) =>
|
||||||
|
selection?.type === 'userModel' && selection.instanceId === um.instanceId;
|
||||||
const isPrimitiveSelected = (p) =>
|
const isPrimitiveSelected = (p) =>
|
||||||
selection?.type === 'primitive' && selection.id === p.id;
|
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) => {
|
const handleContextMenu = (e, item) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -400,7 +482,8 @@ const HierarchyPanel = ({
|
|||||||
const subBlocks = blocksByFolder.get(folder.id) || [];
|
const subBlocks = blocksByFolder.get(folder.id) || [];
|
||||||
const subModels = modelsByFolder.get(folder.id) || [];
|
const subModels = modelsByFolder.get(folder.id) || [];
|
||||||
const subPrims = primitivesByFolder.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 (
|
return (
|
||||||
<div key={`folder-${folder.id}`} className={cl.folderWrap}>
|
<div key={`folder-${folder.id}`} className={cl.folderWrap}>
|
||||||
@ -452,6 +535,7 @@ const HierarchyPanel = ({
|
|||||||
{subBlocks.map(b => renderBlockItem(b, depth + 1))}
|
{subBlocks.map(b => renderBlockItem(b, depth + 1))}
|
||||||
{subPrims.map(p => renderPrimitiveItem(p, depth + 1))}
|
{subPrims.map(p => renderPrimitiveItem(p, depth + 1))}
|
||||||
{subModels.map(m => renderModelItem(m, depth + 1))}
|
{subModels.map(m => renderModelItem(m, depth + 1))}
|
||||||
|
{subUserModels.map(um => renderUserModelItem(um, depth + 1))}
|
||||||
{totalCount === 0 && (
|
{totalCount === 0 && (
|
||||||
<div className={cl.empty} style={{ paddingLeft: (depth + 1) * 12 + 8 }}>пусто</div>
|
<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 renderPrimitiveItem = (p, depth) => {
|
||||||
const def = getPrimitiveType(p.type);
|
const def = getPrimitiveType(p.type);
|
||||||
const displayName = p.name || def?.name || p.type;
|
const displayName = p.name || def?.name || p.type;
|
||||||
@ -633,6 +749,7 @@ const HierarchyPanel = ({
|
|||||||
const rootFolders = foldersByParent.get(null) || [];
|
const rootFolders = foldersByParent.get(null) || [];
|
||||||
const rootBlocks = blocksByFolder.get(null) || [];
|
const rootBlocks = blocksByFolder.get(null) || [];
|
||||||
const rootModels = modelsByFolder.get(null) || [];
|
const rootModels = modelsByFolder.get(null) || [];
|
||||||
|
const rootUserModels = userModelsByFolder.get(null) || [];
|
||||||
const rootPrims = primitivesByFolder.get(null) || [];
|
const rootPrims = primitivesByFolder.get(null) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -757,6 +874,24 @@ const HierarchyPanel = ({
|
|||||||
{rootModelsOpen && rootModels.map(m => renderModelItem(m, 0))}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -776,6 +776,7 @@ const KubikonEditor = () => {
|
|||||||
const [selection, setSelection] = useState(null);
|
const [selection, setSelection] = useState(null);
|
||||||
const [blocksList, setBlocksList] = useState([]);
|
const [blocksList, setBlocksList] = useState([]);
|
||||||
const [modelsList, setModelsList] = useState([]);
|
const [modelsList, setModelsList] = useState([]);
|
||||||
|
const [userModelsList, setUserModelsList] = useState([]);
|
||||||
const [primitivesList, setPrimitivesList] = useState([]);
|
const [primitivesList, setPrimitivesList] = useState([]);
|
||||||
const [foldersList, setFoldersList] = useState([]);
|
const [foldersList, setFoldersList] = useState([]);
|
||||||
|
|
||||||
@ -1565,6 +1566,21 @@ const KubikonEditor = () => {
|
|||||||
}
|
}
|
||||||
setModelsList(arr);
|
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) {
|
if (s.primitiveManager) {
|
||||||
// getAll() не включает folderId — добавляем вручную
|
// getAll() не включает folderId — добавляем вручную
|
||||||
const arr = s.primitiveManager.getAll();
|
const arr = s.primitiveManager.getAll();
|
||||||
@ -3064,9 +3080,21 @@ const KubikonEditor = () => {
|
|||||||
<HierarchyPanel
|
<HierarchyPanel
|
||||||
blocks={blocksList}
|
blocks={blocksList}
|
||||||
models={modelsList}
|
models={modelsList}
|
||||||
|
userModels={userModelsList}
|
||||||
primitives={primitivesList}
|
primitives={primitivesList}
|
||||||
folders={foldersList}
|
folders={foldersList}
|
||||||
scripts={scriptsList}
|
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) => {
|
onSelectScript={(scriptId) => {
|
||||||
sceneRef.current?.selection?.selectScript?.(scriptId);
|
sceneRef.current?.selection?.selectScript?.(scriptId);
|
||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
@ -3080,7 +3108,7 @@ const KubikonEditor = () => {
|
|||||||
if (target) {
|
if (target) {
|
||||||
if (target.kind === 'block') {
|
if (target.kind === 'block') {
|
||||||
normalized = { kind: 'block', ref: { x: target.x, y: target.y, z: target.z } };
|
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 };
|
normalized = { kind: target.kind, id: target.id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -187,6 +187,10 @@ export class BabylonScene {
|
|||||||
this._gizmo = null;
|
this._gizmo = null;
|
||||||
this._gizmoLayer = null;
|
this._gizmoLayer = null;
|
||||||
this._gizmoDragging = false; // флаг что идёт drag гизмо
|
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._isDragPlacing = false; // флаг drag-постановки/удаления блоков
|
||||||
this._isTerrainBrushing = false; // флаг drag-кисти террейна
|
this._isTerrainBrushing = false; // флаг drag-кисти террейна
|
||||||
this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять
|
this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять
|
||||||
@ -2290,6 +2294,15 @@ export class BabylonScene {
|
|||||||
this._mouseDownY = e.clientY;
|
this._mouseDownY = e.clientY;
|
||||||
this._mouseDownTime = Date.now();
|
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-постановку.
|
// ЛКМ + tool=block/erase → активируем drag-постановку.
|
||||||
// Сразу же ставим первый блок в клетке под курсором.
|
// Сразу же ставим первый блок в клетке под курсором.
|
||||||
if (e.button === 0 && !e.shiftKey
|
if (e.button === 0 && !e.shiftKey
|
||||||
@ -2355,6 +2368,23 @@ export class BabylonScene {
|
|||||||
return;
|
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-постановка блоков — пытаемся поставить в новой клетке
|
// Если идёт drag-постановка блоков — пытаемся поставить в новой клетке
|
||||||
if (this._isDragPlacing) {
|
if (this._isDragPlacing) {
|
||||||
this._dragPlaceTick(e.shiftKey);
|
this._dragPlaceTick(e.shiftKey);
|
||||||
@ -2396,6 +2426,18 @@ export class BabylonScene {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onMouseUp = (e) => {
|
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 и завершаем
|
// Если идёт drag гизмо — отдаём pointerup и завершаем
|
||||||
if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) {
|
if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) {
|
||||||
const ulScene = this._gizmoLayer.utilityLayerScene;
|
const ulScene = this._gizmoLayer.utilityLayerScene;
|
||||||
@ -5112,6 +5154,43 @@ export class BabylonScene {
|
|||||||
this.selection?.deleteSelected();
|
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).
|
* Дублировать выделенный объект (Ctrl+D).
|
||||||
* Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y).
|
* Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y).
|
||||||
@ -5130,6 +5209,8 @@ export class BabylonScene {
|
|||||||
if (ny < 0) continue;
|
if (ny < 0) continue;
|
||||||
if (!this.blockManager.hasBlock(nx, ny, nz)) {
|
if (!this.blockManager.hasBlock(nx, ny, nz)) {
|
||||||
this.blockManager.addBlock(nx, ny, nz, sel.blockTypeId);
|
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);
|
this.selection.selectBlockAt(nx, ny, nz);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -5140,9 +5221,13 @@ export class BabylonScene {
|
|||||||
const typeId = sel.modelTypeId;
|
const typeId = sel.modelTypeId;
|
||||||
const sx = sel.x, sy = sel.y, sz = sel.z;
|
const sx = sel.x, sy = sel.y, sz = sel.z;
|
||||||
const rotY = sel.rotationY || 0;
|
const rotY = sel.rotationY || 0;
|
||||||
|
const srcId = sel.instanceId;
|
||||||
this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY)
|
this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY)
|
||||||
.then(newId => {
|
.then(newId => {
|
||||||
if (newId != null) this.selection?.selectModelByInstanceId(newId);
|
if (newId != null) {
|
||||||
|
this._copyScriptsToNewObject('model', srcId, newId);
|
||||||
|
this.selection?.selectModelByInstanceId(newId);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@ -5152,10 +5237,14 @@ export class BabylonScene {
|
|||||||
const typeId = sel.userModelTypeId;
|
const typeId = sel.userModelTypeId;
|
||||||
const sx = sel.x, sy = sel.y, sz = sel.z;
|
const sx = sel.x, sy = sel.y, sz = sel.z;
|
||||||
const rotY = sel.rotationY || 0;
|
const rotY = sel.rotationY || 0;
|
||||||
|
const srcUmId = sel.instanceId;
|
||||||
this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, {
|
this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, {
|
||||||
currentUserId: this._currentUserId || null,
|
currentUserId: this._currentUserId || null,
|
||||||
}).then(newId => {
|
}).then(newId => {
|
||||||
if (newId != null) this.selection?.selectUserModelByInstanceId(newId);
|
if (newId != null) {
|
||||||
|
this._copyScriptsToNewObject('userModel', srcUmId, newId);
|
||||||
|
this.selection?.selectUserModelByInstanceId(newId);
|
||||||
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('[BabylonScene] duplicate user model error:', err);
|
console.error('[BabylonScene] duplicate user model error:', err);
|
||||||
});
|
});
|
||||||
@ -5163,6 +5252,10 @@ export class BabylonScene {
|
|||||||
const newId = this.primitiveManager.addInstance(sel.primitiveType, {
|
const newId = this.primitiveManager.addInstance(sel.primitiveType, {
|
||||||
x: sel.x + 1, y: sel.y, z: sel.z,
|
x: sel.x + 1, y: sel.y, z: sel.z,
|
||||||
sx: sel.sx, sy: sel.sy, sz: sel.sz,
|
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,
|
color: sel.color, material: sel.material,
|
||||||
canCollide: sel.canCollide, visible: sel.visible,
|
canCollide: sel.canCollide, visible: sel.visible,
|
||||||
anchored: sel.anchored,
|
anchored: sel.anchored,
|
||||||
@ -5170,7 +5263,10 @@ export class BabylonScene {
|
|||||||
textureAsset: sel.textureAsset || null,
|
textureAsset: sel.textureAsset || null,
|
||||||
brightness: sel.brightness, range: sel.range, effect: sel.effect,
|
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 = {
|
clip = {
|
||||||
kind: 'primitive', primitiveType: sel.primitiveType,
|
kind: 'primitive', primitiveType: sel.primitiveType,
|
||||||
sx: sel.sx, sy: sel.sy, sz: sel.sz,
|
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,
|
color: sel.color, material: sel.material,
|
||||||
canCollide: sel.canCollide, visible: sel.visible,
|
canCollide: sel.canCollide, visible: sel.visible,
|
||||||
anchored: sel.anchored,
|
anchored: sel.anchored,
|
||||||
@ -5207,11 +5306,45 @@ export class BabylonScene {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (clip) {
|
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)); }
|
try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); }
|
||||||
catch (e) { /* ignore — приватный режим / переполнение */ }
|
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).
|
* Вставить объект из буфера обмена (Ctrl+V, Фаза 5.10).
|
||||||
* Объект появляется у точки, куда смотрит редактор-камера.
|
* Объект появляется у точки, куда смотрит редактор-камера.
|
||||||
@ -5239,28 +5372,32 @@ export class BabylonScene {
|
|||||||
let gy = 0;
|
let gy = 0;
|
||||||
while (gy < 64 && this.blockManager?.hasBlock(gx, gy, gz)) gy++;
|
while (gy < 64 && this.blockManager?.hasBlock(gx, gy, gz)) gy++;
|
||||||
this.blockManager?.addBlock(gx, gy, gz, clip.blockTypeId);
|
this.blockManager?.addBlock(gx, gy, gz, clip.blockTypeId);
|
||||||
|
this._pasteScripts(clip, 'block', { x: gx, y: gy, z: gz });
|
||||||
this.selection?.selectBlockAt(gx, gy, gz);
|
this.selection?.selectBlockAt(gx, gy, gz);
|
||||||
} else if (clip.kind === 'model') {
|
} else if (clip.kind === 'model') {
|
||||||
this.modelManager?.addInstance(clip.modelTypeId, px, py, pz, clip.rotationY || 0)
|
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(() => {});
|
.catch(() => {});
|
||||||
} else if (clip.kind === 'userModel') {
|
} else if (clip.kind === 'userModel') {
|
||||||
this.userModelManager?.addInstance(
|
this.userModelManager?.addInstance(
|
||||||
clip.userModelTypeId, px, py, pz, clip.rotationY || 0,
|
clip.userModelTypeId, px, py, pz, clip.rotationY || 0,
|
||||||
{ currentUserId: this._currentUserId || null },
|
{ 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(() => {});
|
.catch(() => {});
|
||||||
} else if (clip.kind === 'primitive') {
|
} else if (clip.kind === 'primitive') {
|
||||||
const id = this.primitiveManager?.addInstance(clip.primitiveType, {
|
const id = this.primitiveManager?.addInstance(clip.primitiveType, {
|
||||||
x: px, y: Math.max(py, (clip.sy || 1) / 2), z: pz,
|
x: px, y: Math.max(py, (clip.sy || 1) / 2), z: pz,
|
||||||
sx: clip.sx, sy: clip.sy, sz: clip.sz,
|
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,
|
color: clip.color, material: clip.material,
|
||||||
canCollide: clip.canCollide, visible: clip.visible,
|
canCollide: clip.canCollide, visible: clip.visible,
|
||||||
anchored: clip.anchored,
|
anchored: clip.anchored,
|
||||||
textureAsset: clip.textureAsset || null,
|
textureAsset: clip.textureAsset || null,
|
||||||
brightness: clip.brightness, range: clip.range, effect: clip.effect,
|
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();
|
if (this._onSceneChange) this._onSceneChange();
|
||||||
}
|
}
|
||||||
@ -5323,6 +5460,179 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Изменить позицию выделенного (используется Inspector). */
|
/** Изменить позицию выделенного (используется 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) {
|
moveSelectedTo(x, y, z) {
|
||||||
if (!this.selection) return;
|
if (!this.selection) return;
|
||||||
const sel = this.selection.getSelection();
|
const sel = this.selection.getSelection();
|
||||||
|
|||||||
@ -116,6 +116,11 @@ export class SelectionManager {
|
|||||||
primitiveType: data.type,
|
primitiveType: data.type,
|
||||||
x: data.x, y: data.y, z: data.z,
|
x: data.x, y: data.y, z: data.z,
|
||||||
sx: data.sx, sy: data.sy, sz: data.sz,
|
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,
|
color: data.color,
|
||||||
material: data.material,
|
material: data.material,
|
||||||
canCollide: data.canCollide,
|
canCollide: data.canCollide,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user