studio/src/editor/engine/GizmoController.js
МИН ea80ec3aa6
All checks were successful
CI / Lint (pull_request) Successful in 1m7s
CI / Build (pull_request) Successful in 1m58s
CI / Secret scan (pull_request) Successful in 2m31s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
fix(09): studs не растягиваются при scale-drag примитива
При 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>
2026-05-31 13:37:44 +03:00

191 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 */ }
}
}