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,