diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index 586f6fa..ff263b6 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -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 (
{ + 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 (
@@ -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 && (
пусто
)} @@ -580,6 +664,38 @@ const HierarchyPanel = ({ ); }; + const renderUserModelItem = (um, depth) => { + const displayName = um.name || 'Моя модель'; + return ( + + 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)} + + ); + }; + 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 && ( + <> +
setRootUserModelsOpen(!rootUserModelsOpen)} + > + + + + + Мои модели ({rootUserModels.length}) + +
+ {rootUserModelsOpen && rootUserModels.map(um => renderUserModelItem(um, 0))} + + )}
)} diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx index 61efc12..bb5b6a4 100644 --- a/src/editor/KubikonEditor.jsx +++ b/src/editor/KubikonEditor.jsx @@ -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 = () => { { + 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 }; } } diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index ce59924..cab9af0 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -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(); diff --git a/src/editor/engine/SelectionManager.js b/src/editor/engine/SelectionManager.js index 325b8aa..24d58ef 100644 --- a/src/editor/engine/SelectionManager.js +++ b/src/editor/engine/SelectionManager.js @@ -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,