fix(viewer): release V0.9.5 视角交互与外力箭头优化
This commit is contained in:
@@ -4,6 +4,15 @@
|
||||
|
||||
## [未发布]
|
||||
|
||||
## [0.9.5] - 2026-09-09
|
||||
|
||||
- 修复外力拖动箭头起点停留在初始位置:保存拾取点的 body 局部坐标,每帧随部件平移和旋转更新,即使鼠标静止也持续跟随;保持现有外力方向/大小控制,补充位姿跟随与释放清理测试。
|
||||
|
||||
- 小坐标轴拖动增加与主视口相同参数的阻尼惯性,松手后逐帧减速且保持总旋转幅度;新操作、手势取消及资源清理可终止惯性,补充衰减与中断测试。
|
||||
|
||||
- 坐标轴拖动灵敏度对齐主视口高度与旋转速度;点击轴向采用约 300ms 平滑过渡,支持鼠标操作中断,补充幅度与过渡回归测试。
|
||||
- 修复右下角 XYZ 方向指示器无法交互:支持左键拖动旋转视角、点击轴字母切换对应正轴视图及点击负轴端点切换反向视图;保持观察中心与距离,隔离场景点击,处理拖动取消与资源清理,并补充交互回归测试。
|
||||
|
||||
## [0.9.4] - 2026-09-09
|
||||
|
||||
- 前端设计优化:升级蓝黑、电光青与少量紫色的 Cyber HUD 视觉,保留亮色主题;统一工作台、欢迎界面、共享控件、浮层和源码/调参窗口材质。
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.5",
|
||||
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as THREE from 'three';
|
||||
import { MuJoCoViewer } from './MuJoCoViewer';
|
||||
|
||||
// 无需 WebGL,直接验证每帧绑定实际 body 位姿的更新入口。
|
||||
describe('外力箭头起点跟随', () => {
|
||||
it('鼠标不动时仍跟随 body 平移与旋转,保持箭头方向和长度', () => {
|
||||
const xpos = new Float64Array([0, 0, 0, 1, 2, 3]);
|
||||
const xquat = new Float64Array([1, 0, 0, 0, 1, 0, 0, 0]);
|
||||
const arrow = new THREE.ArrowHelper(new THREE.Vector3(0, 1, 0), new THREE.Vector3(), 2);
|
||||
const viewer = Object.assign(Object.create(MuJoCoViewer.prototype), {
|
||||
session: { data: { xpos, xquat }, clearExternalForce: vi.fn() },
|
||||
scene: new THREE.Scene(),
|
||||
arrow,
|
||||
dragForceBodyId: 1,
|
||||
dragForceLocalPoint: new THREE.Vector3(0.5, 0, 0),
|
||||
dragForceBodyPosition: new THREE.Vector3(),
|
||||
dragForceBodyQuaternion: new THREE.Quaternion(),
|
||||
}) as {
|
||||
updateForceArrowOrigin(): void;
|
||||
stopDrag(): void;
|
||||
dragForceBodyId: number;
|
||||
arrow: THREE.ArrowHelper | null;
|
||||
};
|
||||
const direction = arrow.quaternion.clone();
|
||||
const scale = arrow.line.scale.clone();
|
||||
viewer.updateForceArrowOrigin();
|
||||
expect(arrow.position.toArray()).toEqual([1.5, 2, 3]);
|
||||
xpos.set([4, 5, 6], 3);
|
||||
viewer.updateForceArrowOrigin();
|
||||
expect(arrow.position.toArray()).toEqual([4.5, 5, 6]);
|
||||
xquat.set([Math.SQRT1_2, 0, 0, Math.SQRT1_2], 4);
|
||||
viewer.updateForceArrowOrigin();
|
||||
expect(arrow.position.distanceTo(new THREE.Vector3(4, 5.5, 6))).toBeLessThan(1e-10);
|
||||
expect(arrow.quaternion.equals(direction)).toBe(true);
|
||||
expect(arrow.line.scale.equals(scale)).toBe(true);
|
||||
viewer.stopDrag();
|
||||
expect(viewer.dragForceBodyId).toBe(-1);
|
||||
expect(viewer.arrow).toBeNull();
|
||||
expect(() => viewer.updateForceArrowOrigin()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -118,6 +118,10 @@ export class MuJoCoViewer {
|
||||
private dragJointStartWorld = new THREE.Vector3();
|
||||
private dragJointStartPlaneVector = new THREE.Vector3();
|
||||
private dragForceOrigin = new THREE.Vector3();
|
||||
private dragForceBodyId = -1;
|
||||
private dragForceLocalPoint = new THREE.Vector3();
|
||||
private readonly dragForceBodyPosition = new THREE.Vector3();
|
||||
private readonly dragForceBodyQuaternion = new THREE.Quaternion();
|
||||
private dragForcePlane = new THREE.Plane();
|
||||
private arrow: THREE.ArrowHelper | null = null;
|
||||
private highlightedJointId = -1;
|
||||
@@ -236,7 +240,9 @@ export class MuJoCoViewer {
|
||||
this.controls.enabled = !value;
|
||||
},
|
||||
});
|
||||
this.orientationGizmo = new OrientationGizmo(host);
|
||||
this.orientationGizmo = new OrientationGizmo(host, this.controls, () => {
|
||||
this.cameraFocusTransition = undefined;
|
||||
});
|
||||
this.orientationGizmo.update(this.camera);
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(this.resizeFrame);
|
||||
@@ -620,6 +626,7 @@ export class MuJoCoViewer {
|
||||
if (this.session) {
|
||||
this.updateMuJoCoScene();
|
||||
this.updateJointMarker();
|
||||
this.updateForceArrowOrigin();
|
||||
this.visualizationHelpers.update();
|
||||
}
|
||||
this.updatePerceptionLines();
|
||||
@@ -1051,6 +1058,12 @@ export class MuJoCoViewer {
|
||||
this.dragStart = this.pointer.clone();
|
||||
this.dragHitDistance = hit.distance;
|
||||
this.dragForceOrigin.copy(hit.point);
|
||||
this.dragForceBodyId = bodyId;
|
||||
this.readForceBodyPose();
|
||||
this.dragForceLocalPoint
|
||||
.copy(hit.point)
|
||||
.sub(this.dragForceBodyPosition)
|
||||
.applyQuaternion(this.dragForceBodyQuaternion.clone().invert());
|
||||
this.dragForcePlane.setFromNormalAndCoplanarPoint(
|
||||
this.camera.getWorldDirection(new THREE.Vector3()),
|
||||
hit.point,
|
||||
@@ -1132,6 +1145,7 @@ export class MuJoCoViewer {
|
||||
this.dragStart = null;
|
||||
this.dragJointId = -1;
|
||||
this.dragJointType = -1;
|
||||
this.dragForceBodyId = -1;
|
||||
this.dragHitDistance = 0;
|
||||
this.dragSlideParameter = 0;
|
||||
this.session?.clearExternalForce();
|
||||
@@ -1166,6 +1180,30 @@ export class MuJoCoViewer {
|
||||
position: [e[12], e[13], e[14]],
|
||||
});
|
||||
}
|
||||
private readForceBodyPose(): void {
|
||||
if (!this.session || this.dragForceBodyId <= 0) return;
|
||||
const { xpos, xquat } = this.session.data;
|
||||
const p = this.dragForceBodyId * 3;
|
||||
const q = this.dragForceBodyId * 4;
|
||||
this.dragForceBodyPosition.set(Number(xpos[p]), Number(xpos[p + 1]), Number(xpos[p + 2]));
|
||||
// MuJoCo wxyz → Three.js xyzw,使用 body 而非 geom 位姿。
|
||||
this.dragForceBodyQuaternion.set(
|
||||
Number(xquat[q + 1]),
|
||||
Number(xquat[q + 2]),
|
||||
Number(xquat[q + 3]),
|
||||
Number(xquat[q]),
|
||||
);
|
||||
}
|
||||
|
||||
private updateForceArrowOrigin(): void {
|
||||
if (!this.arrow || !this.session || this.dragForceBodyId <= 0) return;
|
||||
this.readForceBodyPose();
|
||||
this.arrow.position
|
||||
.copy(this.dragForceLocalPoint)
|
||||
.applyQuaternion(this.dragForceBodyQuaternion)
|
||||
.add(this.dragForceBodyPosition);
|
||||
}
|
||||
|
||||
private showArrow(origin: THREE.Vector3): void {
|
||||
this.arrow = new THREE.ArrowHelper(new THREE.Vector3(1, 0, 0), origin, 0.01, 0xf97316);
|
||||
this.arrow.visible = false;
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import { OrientationGizmo } from './OrientationGizmo';
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()));
|
||||
|
||||
function setup() {
|
||||
const host = document.createElement('div');
|
||||
const canvas = document.createElement('canvas');
|
||||
Object.defineProperty(canvas, 'clientHeight', { value: 600 });
|
||||
host.append(canvas);
|
||||
document.body.append(host);
|
||||
const camera = new THREE.PerspectiveCamera();
|
||||
camera.up.set(0, 0, 1);
|
||||
camera.position.set(4, -5, 3);
|
||||
const controls = new OrbitControls(camera, canvas);
|
||||
controls.target.set(1, 2, 0.5);
|
||||
controls.update();
|
||||
controls.enableDamping = true;
|
||||
const interaction = vi.fn();
|
||||
const gizmo = new OrientationGizmo(host, controls, interaction);
|
||||
gizmo.update(camera);
|
||||
const captured = new Set<number>();
|
||||
gizmo.element.setPointerCapture = (id) => {
|
||||
captured.add(id);
|
||||
};
|
||||
gizmo.element.hasPointerCapture = (id) => captured.has(id);
|
||||
gizmo.element.releasePointerCapture = (id) => {
|
||||
captured.delete(id);
|
||||
};
|
||||
cleanups.push(() => {
|
||||
gizmo.dispose();
|
||||
controls.dispose();
|
||||
host.remove();
|
||||
});
|
||||
function pointer(target: Element, type: string, x = 30, y = 30, button = 0) {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
button,
|
||||
});
|
||||
Object.defineProperty(event, 'pointerId', { value: 1 });
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
return { camera, controls, gizmo, pointer, interaction, captured };
|
||||
}
|
||||
|
||||
describe('OrientationGizmo interaction', () => {
|
||||
it.each(['X', 'Y', 'Z', '-X', '-Y', '-Z'])('点击 %s 保持中心与距离并对齐轴向', (axis) => {
|
||||
const { camera, controls, gizmo, pointer } = setup();
|
||||
const target = controls.target.clone();
|
||||
const distance = camera.position.distanceTo(target);
|
||||
const endpoint = gizmo.element.querySelector(`[data-axis="${axis}"]`)!;
|
||||
const before = camera.position.clone();
|
||||
pointer(endpoint, 'pointerdown');
|
||||
pointer(gizmo.element, 'pointerup');
|
||||
expect(camera.position.distanceTo(before)).toBeLessThan(1e-8);
|
||||
gizmo.update(camera, performance.now() + 400);
|
||||
const expected = new THREE.Vector3(
|
||||
axis.endsWith('X') ? 1 : 0,
|
||||
axis.endsWith('Y') ? 1 : 0,
|
||||
axis.endsWith('Z') ? 1 : 0,
|
||||
).multiplyScalar(axis.startsWith('-') ? -1 : 1);
|
||||
expect(camera.position.clone().sub(target).normalize().distanceTo(expected)).toBeLessThan(
|
||||
0.00001,
|
||||
);
|
||||
expect(camera.position.distanceTo(target)).toBeCloseTo(distance, 8);
|
||||
expect(controls.target.equals(target)).toBe(true);
|
||||
expect(
|
||||
new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).dot(expected),
|
||||
).toBeCloseTo(-1, 8);
|
||||
});
|
||||
|
||||
it('左键拖动旋转,拖回起点不误触轴向定位,并释放捕获', () => {
|
||||
const { camera, controls, gizmo, pointer, interaction, captured } = setup();
|
||||
controls.enableDamping = false;
|
||||
const before = camera.position.clone();
|
||||
const distance = before.distanceTo(controls.target);
|
||||
const label = gizmo.element.querySelector('text')!;
|
||||
pointer(label, 'pointerdown');
|
||||
pointer(gizmo.element, 'pointermove', 40, 32);
|
||||
expect(camera.position.distanceTo(before)).toBeGreaterThan(0.1);
|
||||
expect(camera.position.distanceTo(controls.target)).toBeCloseTo(distance, 8);
|
||||
pointer(gizmo.element, 'pointermove', 30, 30);
|
||||
const released = camera.position.clone();
|
||||
pointer(gizmo.element, 'pointerup');
|
||||
expect(camera.position.distanceTo(released)).toBeLessThan(1e-8);
|
||||
expect(interaction).toHaveBeenCalledOnce();
|
||||
expect(captured.size).toBe(0);
|
||||
});
|
||||
|
||||
it('拖动按主视口高度和 rotateSpeed 缩放', () => {
|
||||
const { camera, controls, gizmo, pointer } = setup();
|
||||
controls.enableDamping = false;
|
||||
controls.rotateSpeed = 0.5;
|
||||
const toYUp = new THREE.Quaternion().setFromUnitVectors(camera.up, new THREE.Vector3(0, 1, 0));
|
||||
const spherical = () =>
|
||||
new THREE.Spherical().setFromVector3(
|
||||
camera.position.clone().sub(controls.target).applyQuaternion(toYUp),
|
||||
);
|
||||
const before = spherical();
|
||||
pointer(gizmo.element, 'pointerdown');
|
||||
pointer(gizmo.element, 'pointermove', 90, 60);
|
||||
const after = spherical();
|
||||
expect(after.theta - before.theta).toBeCloseTo((-2 * Math.PI * 60 * 0.5) / 600, 8);
|
||||
expect(after.phi - before.phi).toBeCloseTo((-2 * Math.PI * 30 * 0.5) / 600, 8);
|
||||
});
|
||||
|
||||
it('松手后沿原方向按主视口阻尼衰减,最终总幅度不变', () => {
|
||||
const { camera, controls, gizmo, pointer } = setup();
|
||||
controls.dampingFactor = 0.2;
|
||||
const toYUp = new THREE.Quaternion().setFromUnitVectors(camera.up, new THREE.Vector3(0, 1, 0));
|
||||
const theta = () =>
|
||||
new THREE.Spherical().setFromVector3(
|
||||
camera.position.clone().sub(controls.target).applyQuaternion(toYUp),
|
||||
).theta;
|
||||
const start = theta();
|
||||
const distance = camera.position.distanceTo(controls.target);
|
||||
pointer(gizmo.element, 'pointerdown');
|
||||
pointer(gizmo.element, 'pointermove', 90, 30);
|
||||
const first = theta();
|
||||
expect(first - start).toBeCloseTo(((-2 * Math.PI * 60) / 600) * 0.2, 8);
|
||||
pointer(gizmo.element, 'pointerup', 90, 30);
|
||||
gizmo.update(camera);
|
||||
const second = theta();
|
||||
expect(second - first).toBeCloseTo((first - start) * 0.8, 8);
|
||||
gizmo.update(camera);
|
||||
expect(theta() - second).toBeCloseTo((second - first) * 0.8, 8);
|
||||
for (let i = 0; i < 100; i++) gizmo.update(camera);
|
||||
expect(theta() - start).toBeCloseTo((-2 * Math.PI * 60) / 600, 6);
|
||||
expect(camera.position.distanceTo(controls.target)).toBeCloseTo(distance, 8);
|
||||
});
|
||||
|
||||
it.each(['pointercancel', 'lostpointercapture', 'viewport', 'click'])(
|
||||
'惯性可被 %s 中断',
|
||||
(kind) => {
|
||||
const { camera, controls, gizmo, pointer } = setup();
|
||||
pointer(gizmo.element, 'pointerdown');
|
||||
pointer(gizmo.element, 'pointermove', 90, 30);
|
||||
if (kind === 'viewport') {
|
||||
pointer(gizmo.element, 'pointerup', 90, 30);
|
||||
controls.dispatchEvent({ type: 'start' });
|
||||
} else if (kind === 'click') {
|
||||
pointer(gizmo.element, 'pointerup', 90, 30);
|
||||
pointer(gizmo.element, 'pointerdown');
|
||||
} else {
|
||||
pointer(gizmo.element, kind);
|
||||
}
|
||||
const before = camera.position.clone();
|
||||
gizmo.update(camera);
|
||||
expect(camera.position.equals(before)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('轴向切换有中间帧且可由主视口操作打断', () => {
|
||||
const { camera, controls, gizmo, pointer } = setup();
|
||||
const before = camera.position.clone();
|
||||
const distance = camera.position.distanceTo(controls.target);
|
||||
pointer(gizmo.element.querySelector('text')!, 'pointerdown');
|
||||
pointer(gizmo.element, 'pointerup');
|
||||
const now = performance.now();
|
||||
gizmo.update(camera, now + 150);
|
||||
expect(camera.position.distanceTo(before)).toBeGreaterThan(0.1);
|
||||
expect(
|
||||
camera.position
|
||||
.clone()
|
||||
.sub(controls.target)
|
||||
.normalize()
|
||||
.distanceTo(new THREE.Vector3(1, 0, 0)),
|
||||
).toBeGreaterThan(0.1);
|
||||
expect(camera.position.distanceTo(controls.target)).toBeCloseTo(distance, 8);
|
||||
controls.dispatchEvent({ type: 'start' });
|
||||
const interrupted = camera.position.clone();
|
||||
gizmo.update(camera, now + 500);
|
||||
expect(camera.position.equals(interrupted)).toBe(true);
|
||||
});
|
||||
|
||||
it('忽略右键;取消手势不定位,不向场景冒泡点击', () => {
|
||||
const { camera, gizmo, pointer } = setup();
|
||||
const before = camera.position.clone();
|
||||
const label = gizmo.element.querySelector('text')!;
|
||||
pointer(label, 'pointerdown', 30, 30, 2);
|
||||
pointer(gizmo.element, 'pointermove', 60, 60);
|
||||
expect(camera.position.equals(before)).toBe(true);
|
||||
pointer(label, 'pointerdown');
|
||||
pointer(gizmo.element, 'pointercancel');
|
||||
pointer(gizmo.element, 'pointerup');
|
||||
expect(camera.position.distanceTo(before)).toBeLessThan(1e-8);
|
||||
const listener = vi.fn();
|
||||
gizmo.element.parentElement!.addEventListener('click', listener);
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as THREE from 'three';
|
||||
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
interface AxisElements {
|
||||
@@ -21,18 +22,52 @@ export class OrientationGizmo {
|
||||
private readonly projected = new THREE.Vector3();
|
||||
private readonly center: SVGCircleElement;
|
||||
|
||||
constructor(host: HTMLElement) {
|
||||
private readonly rotationDelta = new THREE.Vector2();
|
||||
private readonly cancelMotion = (): void => {
|
||||
this.cancelTransition();
|
||||
this.rotationDelta.set(0, 0);
|
||||
};
|
||||
|
||||
private transition?: {
|
||||
start: number;
|
||||
from: THREE.Spherical;
|
||||
to: THREE.Spherical;
|
||||
fromYUp: THREE.Quaternion;
|
||||
};
|
||||
private readonly cancelTransition = (): void => {
|
||||
this.transition = undefined;
|
||||
};
|
||||
|
||||
private gesture?: {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
dragged: boolean;
|
||||
axis?: THREE.Vector3;
|
||||
};
|
||||
|
||||
constructor(
|
||||
host: HTMLElement,
|
||||
private readonly controls: OrbitControls,
|
||||
private readonly onInteraction: () => void = () => {},
|
||||
) {
|
||||
this.element = svgElement('svg');
|
||||
this.element.setAttribute('viewBox', '0 0 100 100');
|
||||
this.element.setAttribute('role', 'img');
|
||||
this.element.setAttribute('aria-label', 'XYZ 方向指示器');
|
||||
this.element.setAttribute('aria-description', '左键拖动旋转视角,点击轴字母切换正视图');
|
||||
Object.assign(this.element.style, {
|
||||
position: 'absolute',
|
||||
right: '14px',
|
||||
bottom: '14px',
|
||||
width: '92px',
|
||||
height: '92px',
|
||||
pointerEvents: 'none',
|
||||
pointerEvents: 'auto',
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
cursor: 'grab',
|
||||
border: '1px solid rgba(100,116,139,.45)',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(15,23,42,.72)',
|
||||
@@ -64,6 +99,12 @@ export class OrientationGizmo {
|
||||
label.setAttribute('font-weight', '700');
|
||||
label.setAttribute('text-anchor', 'middle');
|
||||
label.setAttribute('dominant-baseline', 'central');
|
||||
for (const endpoint of [positive, label]) {
|
||||
endpoint.dataset.axis = name;
|
||||
endpoint.style.cursor = 'pointer';
|
||||
}
|
||||
negative.dataset.axis = `-${name}`;
|
||||
negative.style.cursor = 'pointer';
|
||||
group.append(line, negative, positive, label);
|
||||
this.element.append(group);
|
||||
this.axes.push({ axis, line, negative, positive, label });
|
||||
@@ -75,9 +116,157 @@ export class OrientationGizmo {
|
||||
this.center.setAttribute('fill', '#cbd5e1');
|
||||
this.element.append(this.center);
|
||||
host.append(this.element);
|
||||
this.element.addEventListener('pointerdown', this.onPointerDown);
|
||||
this.element.addEventListener('pointermove', this.onPointerMove);
|
||||
this.element.addEventListener('pointerup', this.onPointerUp);
|
||||
this.element.addEventListener('pointercancel', this.onPointerCancel);
|
||||
this.element.addEventListener('lostpointercapture', this.onPointerCancel);
|
||||
this.element.addEventListener('click', this.stopClick);
|
||||
this.controls.addEventListener('start', this.cancelMotion);
|
||||
}
|
||||
|
||||
update(camera: THREE.Camera): void {
|
||||
private readonly stopClick = (event: Event): void => event.stopPropagation();
|
||||
|
||||
private readonly onPointerDown = (event: PointerEvent): void => {
|
||||
if (event.button !== 0 || this.gesture || !this.controls.enabled) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.cancelMotion();
|
||||
this.onInteraction();
|
||||
// 清除主视口尚未结束的旋转惯性,避免轴向定位后继续漂移。
|
||||
const damping = this.controls.enableDamping;
|
||||
this.controls.enableDamping = false;
|
||||
this.controls.update();
|
||||
this.controls.enableDamping = damping;
|
||||
const name = (event.target as SVGElement).dataset.axis;
|
||||
const index = name ? ['X', 'Y', 'Z'].indexOf(name.replace('-', '')) : -1;
|
||||
this.gesture = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
dragged: false,
|
||||
axis:
|
||||
index >= 0
|
||||
? this.axes[index].axis.clone().multiplyScalar(name?.startsWith('-') ? -1 : 1)
|
||||
: undefined,
|
||||
};
|
||||
this.element.setPointerCapture(event.pointerId);
|
||||
this.element.style.cursor = 'grabbing';
|
||||
};
|
||||
|
||||
private readonly onPointerMove = (event: PointerEvent): void => {
|
||||
const gesture = this.gesture;
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (
|
||||
!gesture.dragged &&
|
||||
Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) < 4
|
||||
)
|
||||
return;
|
||||
gesture.dragged = true;
|
||||
// 与 OrbitControls 相同:以主视口高度而非 92px 小控件归一化。
|
||||
const viewport = this.controls.domElement;
|
||||
const height = viewport?.clientHeight || viewport?.getBoundingClientRect().height || 1;
|
||||
const scale = (2 * Math.PI * this.controls.rotateSpeed) / height;
|
||||
const dx = event.clientX - gesture.x;
|
||||
const dy = event.clientY - gesture.y;
|
||||
if (dx === 0 && dy === 0) return;
|
||||
this.rotationDelta.x -= dx * scale;
|
||||
this.rotationDelta.y -= dy * scale;
|
||||
this.update(this.controls.object);
|
||||
gesture.x = event.clientX;
|
||||
gesture.y = event.clientY;
|
||||
};
|
||||
|
||||
private readonly onPointerUp = (event: PointerEvent): void => {
|
||||
const gesture = this.gesture;
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
// 包含松开位置,防止缺少最后一次 move 时将拖动误判为点击。
|
||||
this.onPointerMove(event);
|
||||
if (!gesture.dragged && gesture.axis) {
|
||||
const camera = this.controls.object;
|
||||
const distance = camera.position.distanceTo(this.controls.target);
|
||||
const toYUp = new THREE.Quaternion().setFromUnitVectors(
|
||||
camera.up,
|
||||
new THREE.Vector3(0, 1, 0),
|
||||
);
|
||||
const from = new THREE.Spherical().setFromVector3(
|
||||
camera.position.clone().sub(this.controls.target).applyQuaternion(toYUp),
|
||||
);
|
||||
const to = new THREE.Spherical().setFromVector3(
|
||||
gesture.axis.clone().multiplyScalar(distance).applyQuaternion(toYUp),
|
||||
);
|
||||
// 顶/底视图保留当前方位,其他轴沿最短方向旋转。
|
||||
if (Math.abs(Math.sin(to.phi)) < 1e-6) to.theta = from.theta;
|
||||
to.theta =
|
||||
from.theta + Math.atan2(Math.sin(to.theta - from.theta), Math.cos(to.theta - from.theta));
|
||||
to.phi = THREE.MathUtils.clamp(
|
||||
to.phi,
|
||||
this.controls.minPolarAngle,
|
||||
this.controls.maxPolarAngle,
|
||||
);
|
||||
to.makeSafe();
|
||||
this.transition = { start: performance.now(), from, to, fromYUp: toYUp.invert() };
|
||||
}
|
||||
this.endGesture(event);
|
||||
};
|
||||
|
||||
private readonly onPointerCancel = (event: PointerEvent): void => {
|
||||
if (this.gesture?.id !== event.pointerId) return;
|
||||
this.cancelMotion();
|
||||
this.endGesture(event);
|
||||
};
|
||||
|
||||
private endGesture(event: PointerEvent): void {
|
||||
this.gesture = undefined;
|
||||
if (this.element.hasPointerCapture(event.pointerId))
|
||||
this.element.releasePointerCapture(event.pointerId);
|
||||
this.element.style.cursor = 'grab';
|
||||
}
|
||||
|
||||
update(camera: THREE.Camera, now = performance.now()): void {
|
||||
if (!this.controls.enabled) this.cancelMotion();
|
||||
if (this.rotationDelta.lengthSq() > 0) {
|
||||
const toYUp = new THREE.Quaternion().setFromUnitVectors(
|
||||
camera.up,
|
||||
new THREE.Vector3(0, 1, 0),
|
||||
);
|
||||
const offset = camera.position.clone().sub(this.controls.target).applyQuaternion(toYUp);
|
||||
const spherical = new THREE.Spherical().setFromVector3(offset);
|
||||
// 与主视口相同:每次更新应用 dampingFactor,剩余角度逐帧衰减。
|
||||
const factor = this.controls.enableDamping ? this.controls.dampingFactor : 1;
|
||||
spherical.theta += this.rotationDelta.x * factor;
|
||||
spherical.phi += this.rotationDelta.y * factor;
|
||||
spherical.phi = THREE.MathUtils.clamp(
|
||||
spherical.phi,
|
||||
this.controls.minPolarAngle,
|
||||
this.controls.maxPolarAngle,
|
||||
);
|
||||
spherical.makeSafe();
|
||||
offset.setFromSpherical(spherical).applyQuaternion(toYUp.invert());
|
||||
camera.position.copy(this.controls.target).add(offset);
|
||||
this.rotationDelta.multiplyScalar(1 - factor);
|
||||
if (this.rotationDelta.lengthSq() < 1e-14) this.rotationDelta.set(0, 0);
|
||||
this.controls.update();
|
||||
}
|
||||
const transition = this.transition;
|
||||
if (transition) {
|
||||
const progress = THREE.MathUtils.clamp((now - transition.start) / 300, 0, 1);
|
||||
const eased = progress * progress * (3 - 2 * progress);
|
||||
const spherical = new THREE.Spherical(
|
||||
transition.from.radius,
|
||||
THREE.MathUtils.lerp(transition.from.phi, transition.to.phi, eased),
|
||||
THREE.MathUtils.lerp(transition.from.theta, transition.to.theta, eased),
|
||||
);
|
||||
camera.position
|
||||
.copy(this.controls.target)
|
||||
.add(new THREE.Vector3().setFromSpherical(spherical).applyQuaternion(transition.fromYUp));
|
||||
this.controls.update();
|
||||
if (progress === 1) this.cancelTransition();
|
||||
}
|
||||
this.inverseQuaternion.copy(camera.quaternion).invert();
|
||||
for (const item of this.axes) {
|
||||
this.projected.copy(item.axis).applyQuaternion(this.inverseQuaternion);
|
||||
@@ -109,6 +298,17 @@ export class OrientationGizmo {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.cancelMotion();
|
||||
this.controls.removeEventListener('start', this.cancelMotion);
|
||||
if (this.gesture && this.element.hasPointerCapture(this.gesture.id))
|
||||
this.element.releasePointerCapture(this.gesture.id);
|
||||
this.gesture = undefined;
|
||||
this.element.removeEventListener('pointerdown', this.onPointerDown);
|
||||
this.element.removeEventListener('pointermove', this.onPointerMove);
|
||||
this.element.removeEventListener('pointerup', this.onPointerUp);
|
||||
this.element.removeEventListener('pointercancel', this.onPointerCancel);
|
||||
this.element.removeEventListener('lostpointercapture', this.onPointerCancel);
|
||||
this.element.removeEventListener('click', this.stopClick);
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user