/** * GizmoController — обёртка над Babylon GizmoManager. * * Поддерживает 3 режима манипуляции: * 'move' — стрелки X/Y/Z для перемещения (PositionGizmo) * 'rotate' — кольца X/Y/Z для поворота (RotationGizmo) * 'scale' — кубики X/Y/Z для масштабирования (ScaleGizmo) * * Также режим 'select' = ничего не показывать (просто выделение без гизмо). * * Snap-режим (для move): 1.0 / 0.5 / 0.25 / 0 (off). * * При завершении drag вызывает onDragEnd колбэк. * * ВАЖНО: GizmoManager создаёт sub-gizmos (positionGizmo и т.п.) лениво * при первом включении соответствующего режима. Поэтому slушатели и snap * нужно ставить ПОСЛЕ включения режима, не до. */ import { GizmoManager } from '@babylonjs/core'; export class GizmoController { constructor(layer, scene) { this.scene = scene; this.layer = layer; // 1.5 — общий размер гизмо. usePointerToAttach=false чтобы клик по объекту // не назначал гизмо автоматически — это делает наш SelectionManager. this.manager = new GizmoManager(scene, 1.5, layer); this.manager.usePointerToAttachGizmos = false; this._mode = 'select'; this._snap = 1.0; this._attached = null; // mesh или TransformNode на который указывает гизмо this._onDragEnd = null; this._onDragStart = null; // Set гизмо у которых уже привязаны слушатели (чтобы не делать повторно) this._listenersBound = new Set(); } setOnDragEnd(cb) { this._onDragEnd = cb; } setOnDragStart(cb) { this._onDragStart = cb; } setOnDrag(cb) { this._onDrag = cb; } /** Главный метод — переключить режим. */ setMode(mode) { this._mode = mode; this.manager.positionGizmoEnabled = false; this.manager.rotationGizmoEnabled = false; this.manager.scaleGizmoEnabled = false; if (mode === 'move') this.manager.positionGizmoEnabled = true; if (mode === 'rotate') this.manager.rotationGizmoEnabled = true; if (mode === 'scale') this.manager.scaleGizmoEnabled = true; this._bindListenersFor(mode); this._applySnap(); this._reattach(); } /** * Переустановить активный режим — пересоздаёт sub-gizmo с привязкой * к текущему _attached. Нужен после смены выделения, чтобы стрелки * гарантированно отвечали на драг. */ refreshMode() { const m = this._mode; if (m === 'select') return; // Сначала выключаем все sub-gizmo, потом включаем нужный заново — // Babylon при включении sub-gizmo возьмёт актуальный attachedMesh. this.manager.positionGizmoEnabled = false; this.manager.rotationGizmoEnabled = false; this.manager.scaleGizmoEnabled = false; if (m === 'move') this.manager.positionGizmoEnabled = true; if (m === 'rotate') this.manager.rotationGizmoEnabled = true; if (m === 'scale') this.manager.scaleGizmoEnabled = true; this._bindListenersFor(m); this._applySnap(); // _reattach уже сделан в setMode, но повторим — на случай что // attached изменился между вызовами. this._reattach(); } getMode() { return this._mode; } /** Привязать drag-листенеры к gizmo текущего режима если ещё не привязаны. */ _bindListenersFor(mode) { let gizmo = null; if (mode === 'move') gizmo = this.manager.gizmos.positionGizmo; if (mode === 'rotate') gizmo = this.manager.gizmos.rotationGizmo; if (mode === 'scale') gizmo = this.manager.gizmos.scaleGizmo; if (!gizmo || this._listenersBound.has(gizmo)) return; gizmo.onDragStartObservable.add(() => { if (this._onDragStart) this._onDragStart(); }); // onDrag (в процессе перетаскивания) — для live-обновления (напр. тайлинг // studs, чтобы кружки не растягивались пока тянешь scale-гизмо). if (gizmo.onDragObservable) { gizmo.onDragObservable.add(() => { if (this._onDrag) this._onDrag(mode); }); } gizmo.onDragEndObservable.add(() => { if (this._onDragEnd) this._onDragEnd(); }); this._listenersBound.add(gizmo); } /** * Прикрепить гизмо к mesh-объекту (или null чтобы отключить). * Должен вызываться после setMode (или setMode сам делает _reattach). */ attachTo(mesh) { this._attached = mesh; this._reattach(); } _reattach() { const target = this._attached; // Безопасно отвязываем гизмо const detach = () => { try { this.manager.attachToMesh(null); } catch (e) { /* ignore */ } if (typeof this.manager.attachToNode === 'function') { try { this.manager.attachToNode(null); } catch (e) { /* ignore */ } } }; if (!target) { detach(); return; } // Proxy-меши блоков (thin-instances) и liquid-proxy не имеют API Mesh // (нет removeBehavior, getWorldMatrix). Гизмо к ним не подвязывается — // блоки и так стоят на сетке, перемещать их через гизмо некуда. const isProxy = !!(target._isBlockProxy || target.metadata?._liquidProxy); if (isProxy) { detach(); return; } // Проверяем что у узла есть нужный API (Mesh / TransformNode полноценный). const hasNodeApi = typeof target.removeBehavior === 'function' && typeof target.getWorldMatrix === 'function'; if (!hasNodeApi) { detach(); return; } const isMesh = typeof target.getTotalVertices === 'function' && target.getTotalVertices() > 0; try { if (isMesh) { this.manager.attachToMesh(target); } else if (typeof this.manager.attachToNode === 'function') { this.manager.attachToNode(target); } else { detach(); } } catch (e) { // eslint-disable-next-line no-console console.warn('[GizmoController] attach failed', e); detach(); } } /** Установить snap step. */ setSnap(step) { this._snap = step; this._applySnap(); } /** Применить snap к текущим sub-gizmo (если они созданы). */ _applySnap() { const step = this._snap; const pg = this.manager.gizmos.positionGizmo; if (pg) { pg.snapDistance = step > 0 ? step : 0; // У PositionGizmo есть отдельные xGizmo/yGizmo/zGizmo, // и planarGizmoEnabled (плоскостные). Babylon 5+ автоматически // прокидывает snapDistance на оси. На всякий случай — явно: if (pg.xGizmo) pg.xGizmo.snapDistance = pg.snapDistance; if (pg.yGizmo) pg.yGizmo.snapDistance = pg.snapDistance; if (pg.zGizmo) pg.zGizmo.snapDistance = pg.snapDistance; } const rg = this.manager.gizmos.rotationGizmo; if (rg) { // Для rotation snap — в радианах. 15° = π/12. const rad = step > 0 ? Math.PI / 12 : 0; rg.snapDistance = rad; if (rg.xGizmo) rg.xGizmo.snapDistance = rad; if (rg.yGizmo) rg.yGizmo.snapDistance = rad; if (rg.zGizmo) rg.zGizmo.snapDistance = rad; } } getSnap() { return this._snap; } dispose() { try { this.manager.dispose(); } catch (e) { /* ignore */ } } }