From 04c593ef5a51d5b16f61313d5570a7418637a4db Mon Sep 17 00:00:00 2001 From: min Date: Mon, 15 Jun 2026 19:45:26 +0300 Subject: [PATCH] =?UTF-8?q?feat(studio):=20=D1=80=D0=B0=D0=BC=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20(ma?= =?UTF-8?q?rquee)=20+=20hover-=D0=BF=D0=BE=D0=B4=D1=81=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Рамка выделения (rubber-band): - ЛКМ-drag по пустому месту при tool=select → зелёный прямоугольник; объекты, чей центр (экранная проекция) попал в рамку, выделяются группой (multi). Пол/террейн/сетка не выделяются. - Ctrl+рамка добавляет к текущему выделению. - Групповой гизмо (_attachMultiGizmo) двигает всю группу; перетаскивание за объект группы тоже двигает группу; Ctrl+D дублирует всю группу. - SelectionManager: setMultiSelection/moveMultiBy/getMultiCenter. Hover-подсветка (как Roblox Studio): - Наведение мышью на объект → белый контур (HighlightLayer). - Объект в папке → подсвечивается вся папка целиком. - Pick троттлится через requestAnimationFrame; контур снимается при вращении камеры / рамке / уводе курсора. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/editor/engine/BabylonScene.js | 562 ++++++++++++++++++++++++++ src/editor/engine/SelectionManager.js | 148 +++++++ 2 files changed, 710 insertions(+) diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index b1fd219..757a25e 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -35,6 +35,8 @@ import { ParticleSystem, Texture, Ray, + Matrix, + HighlightLayer, PointerEventTypes, Tools as BabylonTools, ColorCurves, @@ -198,6 +200,18 @@ export class BabylonScene { this._freeDragCandidate = null; // {mesh} — потенциальный объект для перетаскивания this._freeDragActive = false; // идёт ли перетаскивание this._freeDragHalf = null; // {x,y,z} полу-габариты объекта (для коллизий) + // Рамка выделения (rubber-band / marquee): ЛКМ-drag по пустому месту. + this._marqueeCandidate = null; // {startX,startY,curX,curY,additive} + this._marqueeActive = false; // появилась ли рамка (после сдвига) + this._marqueeEl = null; // DOM-оверлей прямоугольника + // Групповой пивот multi-выделения (для гизмо). + this._multiPivot = null; + this._multiPivotLast = null; + // Hover-подсветка (белый контур при наведении, как в Roblox Studio). + this._hoverLayer = null; // HighlightLayer + this._hoverMeshes = []; // подсвеченные сейчас меши + this._hoverKey = null; // ключ текущего hover-объекта (для throttle) + this._hoverRaf = 0; // requestAnimationFrame id (throttle pick) this._isDragPlacing = false; // флаг drag-постановки/удаления блоков this._isTerrainBrushing = false; // флаг drag-кисти террейна this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять @@ -1375,6 +1389,18 @@ export class BabylonScene { // когда родительская scene control включён (мы убрали detachControl). this._gizmoLayer = new UtilityLayerRenderer(this.scene); + // Hover-подсветка: белый контур по краю объекта при наведении мышью + // (как в Roblox Studio). HighlightLayer рисует мягкий outline. + try { + this._hoverLayer = new HighlightLayer('hoverLayer', this.scene, { + blurHorizontalSize: 1.0, + blurVerticalSize: 1.0, + }); + // Тонкая, не «неоновая» обводка — ближе к Roblox. + this._hoverLayer.innerGlow = false; + this._hoverLayer.outerGlow = true; + } catch (e) { console.warn('[hover] HighlightLayer init failed', e); } + this._gizmo = new GizmoController(this._gizmoLayer, this.scene); this._gizmo.setMode('select'); // по умолчанию — без манипулятора this._gizmo.setSnap(1.0); // снэп для блоков @@ -1388,6 +1414,8 @@ export class BabylonScene { // Групповая папка — применяем дельту в реальном времени (видно движение). const sel = this.selection?.getSelection?.(); if (sel && sel.type === 'folder') this._onFolderGizmoDrag(mode); + // Multi-выделение (рамка) — двигаем всю группу по дельте пивота. + if (sel && sel.type === 'multi') this._onMultiGizmoDrag(mode); }); // Привязка гизмо к выделенному @@ -2450,9 +2478,15 @@ export class BabylonScene { // Free-drag: ЛКМ на объекте при tool=select (и без gizmo-перетаскивания). // Запоминаем объект как кандидата — реальное перетаскивание начнётся // в mousemove, если курсор сдвинется (иначе это просто клик-выбор). + // Если ЛКМ попала в ПУСТОЕ место (не объект) — запускаем рамку + // выделения (rubber-band / marquee). if (e.button === 0 && !e.shiftKey && this._activeTool === 'select' && !this._isPlaying) { if (this._beginFreeDragCandidate()) { e.preventDefault(); + } else { + // Пусто под курсором → кандидат на рамку выделения. + // Реальная рамка появится в mousemove после сдвига. + this._beginMarqueeCandidate(e); } } @@ -2487,6 +2521,7 @@ export class BabylonScene { if (e.button === 2) { this._isRotating = true; + this._clearHover(); // прячем hover пока крутим камеру this._lastMouseX = e.clientX; this._lastMouseY = e.clientY; canvas.style.cursor = 'grabbing'; @@ -2521,6 +2556,24 @@ export class BabylonScene { return; } + // Рамка выделения (marquee): тянем прямоугольник. Активируем после + // небольшого сдвига, чтобы обычный клик по пустому месту (= снять + // выделение) не превращался в рамку. + if (this._marqueeCandidate) { + if (!this._marqueeActive) { + const ddx = Math.abs(e.clientX - this._marqueeCandidate.startClientX); + const ddy = Math.abs(e.clientY - this._marqueeCandidate.startClientY); + if (ddx > 4 || ddy > 4) { + this._marqueeActive = true; + this._showMarqueeBox(); + } + } + if (this._marqueeActive) { + this._updateMarqueeBox(e); + return; + } + } + // Free-drag: тянем объект ЛКМ. Активируем после небольшого сдвига, // чтобы обычный клик-выбор не превращался в перетаскивание. if (this._freeDragCandidate) { @@ -2558,6 +2611,13 @@ export class BabylonScene { this._updateTerrainBrushPosition(); } + // Hover-подсветка (белый контур при наведении). Только инструмент + // «Выделение», не в play, не во время вращения/панорамы камеры. + if (!this._isPlaying && this._activeTool === 'select' + && !this._isRotating && !this._isPanning && !this._marqueeActive) { + this._scheduleHoverUpdate(); + } + if (!this._isRotating && !this._isPanning) return; const dx = e.clientX - this._lastMouseX; const dy = e.clientY - this._lastMouseY; @@ -2579,6 +2639,18 @@ export class BabylonScene { }; const onMouseUp = (e) => { + // Рамка выделения: завершаем. Если рамку реально тянули — отбираем + // объекты внутри и НЕ обрабатываем как клик (иначе сбросит выбор). + if (this._marqueeCandidate) { + const wasActive = this._marqueeActive; + this._endMarquee(e); + if (wasActive) { + this._mouseDownButton = -1; + return; + } + // Не тянули (просто клик по пустому) — продолжаем обычную + // обработку клика ниже (она снимет выделение). + } // Free-drag: завершаем перетаскивание. Если объект реально тащили — // фиксируем историю и НЕ обрабатываем как клик (иначе сбросит выбор). if (this._freeDragCandidate) { @@ -2831,12 +2903,15 @@ export class BabylonScene { canvas.addEventListener('mousedown', onMouseDown, true); canvas.addEventListener('wheel', onWheel, { passive: false, capture: true }); canvas.addEventListener('contextmenu', onContextMenu, true); + // Курсор ушёл с canvas → снять hover-подсветку. + const onCanvasLeave = () => this._clearHover(); // mousemove/mouseup на window — для drag за пределами canvas. window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); window.addEventListener('blur', onBlur); + canvas.addEventListener('mouseleave', onCanvasLeave); this._listeners = [ { target: canvas, type: 'mousedown', fn: onMouseDown, opts: true }, @@ -2847,6 +2922,7 @@ export class BabylonScene { { target: window, type: 'keydown', fn: onKeyDown }, { target: window, type: 'keyup', fn: onKeyUp }, { target: window, type: 'blur', fn: onBlur }, + { target: canvas, type: 'mouseleave', fn: onCanvasLeave }, ]; } @@ -4047,6 +4123,58 @@ export class BabylonScene { if (this._onSceneChange) this._onSceneChange(); } + // ── Групповой гизмо для multi-выделения (рамка) ───────────────────────── + // По аналогии с папкой: пивот в центре группы, drag двигает/вращает/ + // масштабирует пивот, дельта применяется ко всем объектам через + // selection.moveMultiBy. Сейчас поддержан move (перемещение группы) — + // самая нужная операция; rotate/scale для произвольного multi сложнее + // (блоки на сетке) и пока сводятся к move. + + /** Создать пивот-узел в центре multi-группы и привязать к нему gizmo. */ + _attachMultiGizmo(center) { + try { + if (this._multiPivot) { this._multiPivot.dispose(); this._multiPivot = null; } + const pivot = new TransformNode('multiPivot', this.scene); + pivot.position = new Vector3(center.x, center.y, center.z); + pivot.rotation = new Vector3(0, 0, 0); + pivot.scaling = new Vector3(1, 1, 1); + this._multiPivot = pivot; + this._multiPivotLast = { x: center.x, y: center.y, z: center.z }; + if (this._gizmo) { + this._gizmo.attachTo(pivot); + this._gizmo.refreshMode(); + } + } catch (e) { console.warn('[multiGizmo] attach failed', e); } + } + + /** Инкрементально применить движение пивота к объектам группы во время drag. */ + _onMultiGizmoDrag(mode) { + const pivot = this._multiPivot; + const last = this._multiPivotLast; + if (!pivot || !last || !this.selection) return; + if (mode === 'move') { + const dx = pivot.position.x - last.x; + const dy = pivot.position.y - last.y; + const dz = pivot.position.z - last.z; + // Блоки двигаются по сетке (целые клетки) — копим дробный остаток, + // чтобы при медленном drag блоки тоже сдвигались на целые числа. + if (dx || dy || dz) { + this.selection.moveMultiBy(dx, dy, dz); + last.x = pivot.position.x; last.y = pivot.position.y; last.z = pivot.position.z; + } + } + // rotate/scale для произвольного multi не применяем (см. комментарий выше). + } + + /** dragEnd: добираем остаток дельты и пересоздаём пивот в новом центре. */ + _applyMultiGizmo(mode) { + if (!this.selection) return; + this._onMultiGizmoDrag(mode); + const c = this.selection.getMultiCenter(); + if (c) this._attachMultiGizmo(c); + if (this._onSceneChange) this._onSceneChange(); + } + /** * Обновить гизмо под текущее выделение. */ @@ -4057,6 +4185,11 @@ export class BabylonScene { try { this._folderPivot.dispose(); } catch (e) {} this._folderPivot = null; this._folderPivotId = null; } + // Сменилось выделение и это НЕ multi → убрать пивот multi. + if ((!sel || sel.type !== 'multi') && this._multiPivot) { + try { this._multiPivot.dispose(); } catch (e) {} + this._multiPivot = null; this._multiPivotLast = null; + } if (!sel) { this._gizmo.attachTo(null); return; @@ -4071,6 +4204,14 @@ export class BabylonScene { } else if (sel.type === 'folder') { // Групповой gizmo — привязан к пивоту папки (создан в _attachFolderGizmo). if (this._folderPivot) this._gizmo.attachTo(this._folderPivot); + } else if (sel.type === 'multi') { + // Multi (рамка) — привязан к пивоту группы. Если пивота ещё нет + // (multi выставлен не из рамки, напр. Ctrl+клик) — создаём его. + if (!this._multiPivot) { + const c = this.selection.getMultiCenter?.(); + if (c) { this._attachMultiGizmo(c); return; } + } + if (this._multiPivot) this._gizmo.attachTo(this._multiPivot); } // Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale) // гарантированно пересоздалась поверх нового attached-mesh. @@ -4112,6 +4253,10 @@ export class BabylonScene { this._applyFolderGizmo(mode); return; } + if (sel.type === 'multi') { + this._applyMultiGizmo(mode); + return; + } if (sel.type === 'block') { if (mode === 'move') { // Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ) @@ -5543,9 +5688,90 @@ export class BabylonScene { * Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y). * Модель: создаёт копию со смещением +1 по X. */ + /** + * Дублировать всё multi-выделение (Ctrl+D над рамкой). Модели/примитивы/ + * user-модели копируются РОВНО на месте оригиналов (как в Roblox Studio, + * дубль сразу можно тащить). Блоки — в свободную клетку рядом (нельзя + * наложить два блока в одну клетку). По завершении дубли становятся новым + * multi-выделением. + */ + async _duplicateMulti() { + const items = this.selection?.getMultiSelection?.() || []; + if (!items.length) return; + const newSel = []; + for (const it of items) { + try { + if (it.kind === 'block') { + const { x, y, z } = it.ref; + const typeId = this.blockManager?.blocks.get(`${x},${y},${z}`)?.metadata?.blockTypeId; + if (typeId == null) continue; + const cands = [[1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, 1, 0]]; + for (const [dx, dy, dz] of cands) { + const nx = x + dx, ny = y + dy, nz = z + dz; + if (ny < 0) continue; + if (!this.blockManager.hasBlock(nx, ny, nz)) { + this.blockManager.addBlock(nx, ny, nz, typeId); + this._copyScriptsToNewObject('block', { x, y, z }, { x: nx, y: ny, z: nz }); + newSel.push({ kind: 'block', ref: { x: nx, y: ny, z: nz } }); + break; + } + } + } else if (it.kind === 'primitive') { + const d = this.primitiveManager?.instances.get(it.ref); + if (!d) continue; + const newId = this.primitiveManager.addInstance(d.type, { + x: d.x, y: d.y, z: d.z, sx: d.sx, sy: d.sy, sz: d.sz, + rotationX: d.rotationX || 0, rotationY: d.rotationY || 0, rotationZ: d.rotationZ || 0, + color: d.color, material: d.material, + canCollide: d.canCollide, visible: d.visible, anchored: d.anchored, + textureAsset: d.textureAsset || null, + brightness: d.brightness, range: d.range, effect: d.effect, + }); + if (newId != null) { + this._copyScriptsToNewObject('primitive', it.ref, newId); + newSel.push({ kind: 'primitive', ref: newId }); + } + } else if (it.kind === 'model') { + const d = this.modelManager?.instances.get(it.ref); + if (!d) continue; + const newId = await this.modelManager.addInstance(d.modelTypeId, d.x, d.y, d.z, d.rotationY || 0); + if (newId != null) { + this._copyScriptsToNewObject('model', it.ref, newId); + newSel.push({ kind: 'model', ref: newId }); + } + } else if (it.kind === 'userModel') { + const d = this.userModelManager?.instances.get(it.ref); + if (!d) continue; + const newId = await this.userModelManager.addInstance( + d.userModelTypeId, d.x, d.y, d.z, d.rotationY || 0, + { currentUserId: this._currentUserId || null }); + if (newId != null) { + this._copyScriptsToNewObject('userModel', it.ref, newId); + newSel.push({ kind: 'userModel', ref: newId }); + } + } + } catch (err) { + // eslint-disable-next-line no-console + console.error('[BabylonScene] duplicate multi item error:', err); + } + } + // Выделяем дубли как новую группу. + if (newSel.length && this.selection) { + this.selection.setMultiSelection(newSel, false); + const c = this.selection.getMultiCenter?.(); + if (c) this._attachMultiGizmo(c); + } + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + } + duplicateSelected() { const sel = this.selection?.getSelection(); if (!sel) return; + if (sel.type === 'multi') { + this._duplicateMulti(); + return; + } if (sel.type === 'block') { // Ищем свободную клетку рядом const candidates = [ @@ -5901,6 +6127,18 @@ export class BabylonScene { const m = pick.mesh; if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return false; if (m.metadata?._isBlockProto) return false; // блоки тащим только гизмо + + // Если есть multi-выделение и кликнули по объекту ВНУТРИ него — + // тащим всю группу (а не пере-выбираем один объект). + const curSel = this.selection?.getSelection?.(); + if (curSel?.type === 'multi' && this._meshInMultiSelection(m)) { + const c = this.selection.getMultiCenter(); + this._freeDragCandidate = { multi: true, last: { ...(c || { x: 0, y: 0, z: 0 }) } }; + this._freeDragHalf = { x: 0.5, y: 0.5, z: 0.5 }; + this._freeDragActive = false; + return true; + } + // Выбираем объект (резолв mesh→тип внутри selection). this.selection?.selectByMesh(m); const sel = this.selection?.getSelection(); @@ -5945,6 +6183,23 @@ export class BabylonScene { return; } + // Multi (рамка): тащим всю группу по дельте центра в горизонтальной + // плоскости (как папку). Гизмо-пивот пересоздадим в _endFreeDrag. + if (cand.multi) { + 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 = (cand.last.y - ray.origin.y) / ray.direction.y; + if (t < 0) return; + const px = ray.origin.x + ray.direction.x * t; + const pz = ray.origin.z + ray.direction.z * t; + const dx = px - cand.last.x, dz = pz - cand.last.z; + if (dx || dz) { + this.selection?.moveMultiBy(dx, 0, dz); + cand.last.x = px; cand.last.z = pz; + } + return; + } + const root = cand.root; const half = this._freeDragHalf || { x: 0.5, y: 0.5, z: 0.5 }; @@ -5999,16 +6254,306 @@ export class BabylonScene { /** Завершить free-drag, зафиксировать изменение в истории. */ _endFreeDrag() { const wasActive = this._freeDragActive; + const wasMulti = this._freeDragCandidate?.multi; this._freeDragCandidate = null; this._freeDragActive = false; this._freeDragHalf = null; if (wasActive) { + // После перетаскивания multi-группы — пересоздать пивот гизмо в новом центре. + if (wasMulti && this.selection) { + const c = this.selection.getMultiCenter(); + if (c) this._attachMultiGizmo(c); + } this.history?.markChange(); if (this._onSceneChange) this._onSceneChange(); } return wasActive; } + /** Проверить, принадлежит ли mesh одному из объектов в multi-выделении. */ + _meshInMultiSelection(mesh) { + if (!this.selection) return false; + const multi = this.selection.getMultiSelection?.() || []; + if (!multi.length) return false; + const md = mesh.metadata || {}; + let kind = null, ref = null; + if (md.isBlock) { kind = 'block'; ref = { x: md.gridX, y: md.gridY, z: md.gridZ }; } + else if (md.isModel) { kind = 'model'; ref = md.instanceId; } + else if (md.isPrimitive) { kind = 'primitive'; ref = md.primitiveId; } + else if (md.isUserModel) { kind = 'userModel'; ref = md.instanceId; } + else return false; + return multi.some(it => { + if (it.kind !== kind) return false; + if (kind === 'block') return it.ref.x === ref.x && it.ref.y === ref.y && it.ref.z === ref.z; + return it.ref === ref; + }); + } + + // ── Рамка выделения (rubber-band / marquee) ───────────────────────────── + // ЛКМ зажата на ПУСТОМ месте (не на объекте) при tool=select → тянем + // прямоугольник. Все объекты, чей ЦЕНТР (экранная проекция позиции) + // попадает в прямоугольник — выделяются (multi-select). Пол не выделяется. + + /** Запомнить старт рамки. Реальная рамка появится после сдвига курсора. */ + _beginMarqueeCandidate(e) { + this._clearHover(); // не держим белый контур во время рамки + const r = this.canvas.getBoundingClientRect(); + this._marqueeCandidate = { + startClientX: e.clientX, + startClientY: e.clientY, + // Координаты относительно canvas (для проекции и оверлея). + startX: e.clientX - r.left, + startY: e.clientY - r.top, + curX: e.clientX - r.left, + curY: e.clientY - r.top, + additive: e.ctrlKey || e.metaKey, // Ctrl — добавить к текущему выделению + }; + this._marqueeActive = false; + } + + /** Создать DOM-оверлей прямоугольника поверх канваса. */ + _showMarqueeBox() { + if (!this._marqueeEl) { + const el = document.createElement('div'); + el.style.cssText = [ + 'position:absolute', 'pointer-events:none', 'z-index:50', + 'border:1px solid #38d957', + 'background:rgba(56,217,87,0.15)', + 'box-shadow:0 0 0 1px rgba(0,0,0,0.25) inset', + 'left:0', 'top:0', 'width:0', 'height:0', + ].join(';'); + // Вставляем в родителя канваса (он position:relative в редакторе). + const parent = this.canvas.parentElement || document.body; + parent.appendChild(el); + this._marqueeEl = el; + } + this._marqueeEl.style.display = 'block'; + } + + /** Обновить размеры оверлея под текущее положение курсора. */ + _updateMarqueeBox(e) { + const cand = this._marqueeCandidate; + if (!cand || !this._marqueeEl) return; + const r = this.canvas.getBoundingClientRect(); + cand.curX = e.clientX - r.left; + cand.curY = e.clientY - r.top; + const x0 = Math.min(cand.startX, cand.curX); + const y0 = Math.min(cand.startY, cand.curY); + const w = Math.abs(cand.curX - cand.startX); + const h = Math.abs(cand.curY - cand.startY); + const el = this._marqueeEl; + // Оверлей позиционируется относительно canvas.parentElement, поэтому + // добавляем offset канваса внутри родителя. + el.style.left = (this.canvas.offsetLeft + x0) + 'px'; + el.style.top = (this.canvas.offsetTop + y0) + 'px'; + el.style.width = w + 'px'; + el.style.height = h + 'px'; + } + + /** Спроецировать мировую точку в экранные координаты канваса (или null если за камерой). */ + _projectToScreen(x, y, z) { + const engine = this.engine; + const w = engine.getRenderWidth(); + const h = engine.getRenderHeight(); + const p = Vector3.Project( + new Vector3(x, y, z), + Matrix.Identity(), + this.scene.getTransformMatrix(), + { x: 0, y: 0, width: w, height: h } + ); + // p.z вне [0,1] → точка за ближней/дальней плоскостью (за спиной камеры). + if (p.z < 0 || p.z > 1) return null; + return { x: p.x, y: p.y }; + } + + /** Собрать все выделяемые объекты сцены с их центрами. */ + _collectSelectableObjects() { + const out = []; + if (this.blockManager) { + for (const mesh of this.blockManager.blocks.values()) { + const md = mesh.metadata; + if (!md?.isBlock) continue; + if (md.locked) continue; + out.push({ kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ }, + cx: md.gridX, cy: md.gridY + 0.5, cz: md.gridZ }); + } + } + if (this.modelManager) { + for (const [id, d] of this.modelManager.instances) { + if (d.locked) continue; + out.push({ kind: 'model', ref: id, cx: d.x || 0, cy: d.y || 0, cz: d.z || 0 }); + } + } + if (this.primitiveManager) { + for (const [id, d] of this.primitiveManager.instances) { + if (d.locked) continue; + out.push({ kind: 'primitive', ref: id, cx: d.x || 0, cy: d.y || 0, cz: d.z || 0 }); + } + } + if (this.userModelManager) { + for (const [id, d] of this.userModelManager.instances) { + if (d.locked) continue; + out.push({ kind: 'userModel', ref: id, cx: d.x || 0, cy: d.y || 0, cz: d.z || 0 }); + } + } + return out; + } + + /** Завершить рамку: отобрать объекты внутри и выставить multi-select. */ + _endMarquee(e) { + const cand = this._marqueeCandidate; + const wasActive = this._marqueeActive; + this._marqueeCandidate = null; + this._marqueeActive = false; + if (this._marqueeEl) this._marqueeEl.style.display = 'none'; + if (!wasActive || !cand) return; + + const minX = Math.min(cand.startX, cand.curX); + const maxX = Math.max(cand.startX, cand.curX); + const minY = Math.min(cand.startY, cand.curY); + const maxY = Math.max(cand.startY, cand.curY); + + const objs = this._collectSelectableObjects(); + const picked = []; + for (const o of objs) { + const s = this._projectToScreen(o.cx, o.cy, o.cz); + if (!s) continue; + if (s.x >= minX && s.x <= maxX && s.y >= minY && s.y <= maxY) { + picked.push({ kind: o.kind, ref: o.ref }); + } + } + + if (!this.selection) return; + // Ctrl при старте рамки → добавляем к уже выделенному, иначе заменяем. + this.selection.setMultiSelection(picked, cand.additive); + // Привязать групповой гизмо если выбрано >1. + const sel = this.selection.getSelection(); + if (sel?.type === 'multi') { + const c = this.selection.getMultiCenter(); + if (c) this._attachMultiGizmo(c); + } + this.history?.markChange(); + } + + // ── Hover-подсветка (белый контур при наведении, как Roblox Studio) ────── + // Наводим мышь на объект → подсвечиваем его белым контуром. Если объект + // в папке — подсвечиваем ВСЮ папку (все её меши), как в Roblox Studio. + + /** Запланировать обновление hover на следующий кадр (throttle дорогого pick). */ + _scheduleHoverUpdate() { + if (this._hoverRaf) return; + this._hoverRaf = requestAnimationFrame(() => { + this._hoverRaf = 0; + this._updateHover(); + }); + } + + /** Определить набор мешей под курсором для подсветки + уникальный ключ. */ + _resolveHoverTarget() { + const pick = this._pickFromMouse(); + if (!pick || !pick.mesh) return null; + const m = pick.mesh; + // Пол / сетка / ghost / террейн — не подсвечиваем. + if (m === this._ghostMesh) return null; + if (m.name && (m.name.startsWith('gridLine') || m.name === 'ground')) return null; + const md = m.metadata || {}; + if (md._isTerrainProto || md._isRegionMesh || md._isRobloxTerrain) return null; + + // Определяем тип объекта + его folderId (как в SelectionManager.selectByMesh). + let kind = null, id = null, folderId = null; + if (md.isBlock) { + kind = 'block'; id = `${md.gridX},${md.gridY},${md.gridZ}`; + folderId = md.folderId ?? null; + } else if (md.isModel) { + kind = 'model'; id = md.instanceId; + folderId = this.modelManager?.instances.get(id)?.folderId ?? null; + } else if (md.isUserModel) { + kind = 'userModel'; id = md.instanceId; + folderId = this.userModelManager?.instances.get(id)?.folderId ?? null; + } else if (md.isPrimitive) { + kind = 'primitive'; id = md.primitiveId; + folderId = this.primitiveManager?.instances.get(id)?.folderId ?? null; + } else { + return null; + } + + // Объект в папке → подсвечиваем всю папку. + if (folderId != null && this.folderManager) { + const g = this.folderManager.getFolderObjects(folderId); + const meshes = []; + for (const mesh of g.meshes) this._collectMeshTree(mesh, meshes); + // Блоки папки (у getFolderObjects блоки в g.blocks). + for (const bm of (g.blocks || [])) this._collectMeshTree(bm, meshes); + return { key: `folder:${folderId}`, meshes }; + } + + // Одиночный объект → собираем его меши. + const meshes = []; + if (kind === 'block') { + this._collectMeshTree(m, meshes); + } else if (kind === 'model') { + const d = this.modelManager?.instances.get(id); + for (const cm of (d?.clonedMeshes || [])) this._collectMeshTree(cm, meshes); + } else if (kind === 'userModel') { + const d = this.userModelManager?.instances.get(id); + for (const um of (d?.meshes || [])) this._collectMeshTree(um, meshes); + } else if (kind === 'primitive') { + const d = this.primitiveManager?.instances.get(id); + if (d?.mesh) this._collectMeshTree(d.mesh, meshes); + } + return { key: `${kind}:${id}`, meshes }; + } + + /** Добавить mesh и его дочерние меши (только реальные Mesh с геометрией). */ + _collectMeshTree(node, out) { + if (!node) return; + // HighlightLayer.addMesh работает только с настоящими Mesh (не TransformNode). + if (typeof node.getClassName === 'function' + && (node.getClassName() === 'Mesh' || node.getClassName() === 'InstancedMesh')) { + if (node.getTotalVertices?.() > 0) out.push(node); + } + const kids = node.getChildMeshes?.(false) || []; + for (const k of kids) { + if (k.getTotalVertices?.() > 0) out.push(k); + } + } + + /** Обновить hover-подсветку под текущим положением курсора. */ + _updateHover() { + if (!this._hoverLayer || this._isPlaying || this._activeTool !== 'select') { + this._clearHover(); + return; + } + const target = this._resolveHoverTarget(); + if (!target || !target.meshes.length) { + this._clearHover(); + return; + } + // Тот же объект — ничего не меняем (throttle лишних addMesh). + if (target.key === this._hoverKey) return; + this._clearHover(); + const WHITE = new Color3(1, 1, 1); + const seen = new Set(); + for (const mesh of target.meshes) { + if (seen.has(mesh)) continue; + seen.add(mesh); + try { this._hoverLayer.addMesh(mesh, WHITE); this._hoverMeshes.push(mesh); } + catch (e) { /* некоторые меши нельзя добавить — игнор */ } + } + this._hoverKey = target.key; + } + + /** Снять hover-подсветку. */ + _clearHover() { + if (this._hoverLayer && this._hoverMeshes.length) { + for (const mesh of this._hoverMeshes) { + try { this._hoverLayer.removeMesh(mesh); } catch (e) { /* ignore */ } + } + } + this._hoverMeshes = []; + this._hoverKey = null; + } + // ── Небо (задача 16) — обёртки для game-API и UI редактора ────────────── setSkybox(opts) { this.skybox?.setSkybox(opts); if (this._onSceneChange) this._onSceneChange(); } setClouds(opts) { this.skybox?.setClouds(opts); if (this._onSceneChange) this._onSceneChange(); } @@ -8711,6 +9256,23 @@ export class BabylonScene { try { this._gizmoLayer.dispose(); } catch (e) { /* ignore */ } this._gizmoLayer = null; } + if (this._multiPivot) { + try { this._multiPivot.dispose(); } catch (e) { /* ignore */ } + this._multiPivot = null; + } + if (this._marqueeEl) { + try { this._marqueeEl.remove(); } catch (e) { /* ignore */ } + this._marqueeEl = null; + } + if (this._hoverRaf) { + try { cancelAnimationFrame(this._hoverRaf); } catch (e) { /* ignore */ } + this._hoverRaf = 0; + } + if (this._hoverLayer) { + try { this._hoverLayer.dispose(); } catch (e) { /* ignore */ } + this._hoverLayer = null; + this._hoverMeshes = []; + } if (this.selection) { this.selection.dispose(); this.selection = null; diff --git a/src/editor/engine/SelectionManager.js b/src/editor/engine/SelectionManager.js index 834197f..7fe3b70 100644 --- a/src/editor/engine/SelectionManager.js +++ b/src/editor/engine/SelectionManager.js @@ -826,6 +826,11 @@ export class SelectionManager { } else if (it.kind === 'primitive') { const data = this.primitiveManager?.instances.get(it.ref); if (data?.mesh) this._highlightMesh(data.mesh); + } else if (it.kind === 'userModel') { + const data = this.userModelManager?.instances.get(it.ref); + if (data?.meshes) { + for (const m of data.meshes) this._highlightMesh(m); + } } } } @@ -833,6 +838,149 @@ export class SelectionManager { /** Получить массив multi-selection. */ getMultiSelection() { return [...this._multi]; } + /** + * Установить multi-выделение из списка {kind, ref} (рамка выделения). + * additive=true — добавить к текущему (Ctrl+рамка), иначе заменить. + * Если в итоге 0 объектов — clear; если 1 — обычный single-select. + */ + setMultiSelection(items, additive = false) { + const eq = (a, b) => { + if (a.kind !== b.kind) return false; + if (a.kind === 'block') return a.ref.x === b.ref.x && a.ref.y === b.ref.y && a.ref.z === b.ref.z; + return a.ref === b.ref; + }; + let next; + if (additive) { + // Стартуем с текущего multi (или текущего single, развёрнутого в элемент). + next = [...this._multi]; + if (next.length === 0 && this._selection) { + const s = this._selection; + if (s.type === 'block') next.push({ kind: 'block', ref: { x: s.gridX, y: s.gridY, z: s.gridZ } }); + else if (s.type === 'model') next.push({ kind: 'model', ref: s.instanceId }); + else if (s.type === 'primitive') next.push({ kind: 'primitive', ref: s.id }); + else if (s.type === 'userModel') next.push({ kind: 'userModel', ref: s.instanceId }); + } + for (const it of items) { + if (!next.some(x => eq(x, it))) next.push(it); + } + } else { + next = [...items]; + } + + this._removeHighlight(); + if (next.length === 0) { + this._multi = []; + this._selection = null; + this._notifyChange(); + return; + } + if (next.length === 1) { + this._multi = []; + const only = next[0]; + if (only.kind === 'block') this.selectBlockAt(only.ref.x, only.ref.y, only.ref.z); + else if (only.kind === 'model') this.selectModelByInstanceId(only.ref); + else if (only.kind === 'primitive') this.selectPrimitiveById(only.ref); + else if (only.kind === 'userModel') this.selectUserModelByInstanceId(only.ref); + return; + } + this._multi = next; + this._highlightAllMulti(); + this._selection = { type: 'multi', count: this._multi.length, items: [...this._multi] }; + this._notifyChange(); + } + + /** + * Развернуть multi-элемент в его data + mesh + текущую позицию. + * Возвращает { kind, data, mesh, pos:{x,y,z} } или null. + */ + _resolveMultiItem(it) { + if (it.kind === 'block') { + const mesh = this.blockManager?.blocks.get(`${it.ref.x},${it.ref.y},${it.ref.z}`); + return { kind: 'block', mesh, pos: { x: it.ref.x, y: it.ref.y, z: it.ref.z }, ref: it.ref }; + } + if (it.kind === 'model') { + const d = this.modelManager?.instances.get(it.ref); + if (!d) return null; + return { kind: 'model', data: d, mesh: d.rootMesh, pos: { x: d.x || 0, y: d.y || 0, z: d.z || 0 } }; + } + if (it.kind === 'userModel') { + const d = this.userModelManager?.instances.get(it.ref); + if (!d) return null; + return { kind: 'userModel', data: d, mesh: d.rootNode, pos: { x: d.x || 0, y: d.y || 0, z: d.z || 0 } }; + } + if (it.kind === 'primitive') { + const d = this.primitiveManager?.instances.get(it.ref); + if (!d) return null; + return { kind: 'primitive', data: d, mesh: d.mesh, pos: { x: d.x || 0, y: d.y || 0, z: d.z || 0 } }; + } + return null; + } + + /** Центр multi-выделения (среднее позиций всех объектов). */ + getMultiCenter() { + if (!this._multi.length) return null; + let sx = 0, sy = 0, sz = 0, n = 0; + for (const it of this._multi) { + const r = this._resolveMultiItem(it); + if (!r) continue; + sx += r.pos.x; sy += r.pos.y; sz += r.pos.z; n++; + } + if (!n) return null; + return { x: sx / n, y: sy / n, z: sz / n }; + } + + /** + * Сдвинуть ВСЕ объекты multi-выделения на (dx,dy,dz). + * Блоки переустанавливаются (block-операция через grid-координаты), + * модели/примитивы/user-модели двигают свой root-mesh и data. + * Блоки двигаем с округлением дельты к целым клеткам (сетка). + */ + moveMultiBy(dx, dy, dz) { + if (!this._multi.length) return; + // Блоки: только целочисленный сдвиг по сетке. Накапливаем дробную + // часть снаружи (в BabylonScene), сюда приходит уже округлённая для + // блоков дельта — но на всякий случай округляем здесь. + const bdx = Math.round(dx), bdy = Math.round(dy), bdz = Math.round(dz); + const newBlocks = []; + for (const it of this._multi) { + if (it.kind === 'block') { + const { x, y, z } = it.ref; + const typeId = this.blockManager?.blocks.get(`${x},${y},${z}`)?.metadata?.blockTypeId; + if (typeId == null) continue; + this.blockManager.removeBlock(x, y, z); + newBlocks.push({ x: x + bdx, y: Math.max(0, y + bdy), z: z + bdz, typeId, ref: it.ref }); + } + } + for (const nb of newBlocks) { + this.blockManager.addBlock(nb.x, nb.y, nb.z, nb.typeId); + // Обновляем ссылку в _multi на новую клетку. + nb.ref.x = nb.x; nb.ref.y = nb.y; nb.ref.z = nb.z; + } + // Модели / примитивы / user-модели — двигаем плавно. + for (const it of this._multi) { + if (it.kind === 'block') continue; + const r = this._resolveMultiItem(it); + if (!r || !r.data) continue; + r.data.x = (r.data.x || 0) + dx; + r.data.y = (r.data.y || 0) + dy; + r.data.z = (r.data.z || 0) + dz; + if (r.mesh) { + if (r.data._worldMatrixFrozen) { + try { r.mesh.unfreezeWorldMatrix?.(); } catch (e) {} + r.data._worldMatrixFrozen = false; + } + r.mesh.position.set(r.data.x, r.data.y, r.data.z); + } + } + // Перерисовать подсветку (меши блоков пересозданы). + this._removeHighlight(); + this._highlightAllMulti(); + this.modelManager?._notifyChange?.(); + this.primitiveManager?._notifyChange?.(); + this.userModelManager && this._scene3d?._syncUserModelColliders?.(); + this._notifyChange(); + } + /** Выделить ВСЁ в сцене (Ctrl+A). */ selectAll() { this._removeHighlight(); -- 2.47.2