All checks were successful
При scale-гизмо mesh.scaling тянул faceUV → studs превращались в полосы. Фикс: во время drag прячем studs-текстуру (плоский цвет), в dragEnd меш пересоздаётся с правильным faceUV. _recreateMesh для studs пересоздаёт материал заново (свежий тайлинг + восстановление текстуры). GizmoController: + onDrag (live) колбэк для scale. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
191 lines
8.5 KiB
JavaScript
191 lines
8.5 KiB
JavaScript
/**
|
||
* 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 */ }
|
||
}
|
||
}
|