From da4d59c31a2672326cfcd914b93d80ca55922b43 Mon Sep 17 00:00:00 2001 From: cen617-code <1057290604@qq.com> Date: Wed, 9 Sep 2026 17:01:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(viewer):=20release=20V0.9.5=20=E8=A7=86?= =?UTF-8?q?=E8=A7=92=E4=BA=A4=E4=BA=92=E4=B8=8E=E5=A4=96=E5=8A=9B=E7=AE=AD?= =?UTF-8?q?=E5=A4=B4=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 + package-lock.json | 4 +- package.json | 2 +- .../src/viewer/MuJoCoViewer.force.test.ts | 41 ++++ web_platform/src/viewer/MuJoCoViewer.ts | 40 +++- .../src/viewer/OrientationGizmo.test.ts | 198 +++++++++++++++++ web_platform/src/viewer/OrientationGizmo.ts | 206 +++++++++++++++++- 7 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 web_platform/src/viewer/MuJoCoViewer.force.test.ts create mode 100644 web_platform/src/viewer/OrientationGizmo.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6424a438..81ace38e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ## [未发布] +## [0.9.5] - 2026-09-09 + +- 修复外力拖动箭头起点停留在初始位置:保存拾取点的 body 局部坐标,每帧随部件平移和旋转更新,即使鼠标静止也持续跟随;保持现有外力方向/大小控制,补充位姿跟随与释放清理测试。 + +- 小坐标轴拖动增加与主视口相同参数的阻尼惯性,松手后逐帧减速且保持总旋转幅度;新操作、手势取消及资源清理可终止惯性,补充衰减与中断测试。 + +- 坐标轴拖动灵敏度对齐主视口高度与旋转速度;点击轴向采用约 300ms 平滑过渡,支持鼠标操作中断,补充幅度与过渡回归测试。 +- 修复右下角 XYZ 方向指示器无法交互:支持左键拖动旋转视角、点击轴字母切换对应正轴视图及点击负轴端点切换反向视图;保持观察中心与距离,隔离场景点击,处理拖动取消与资源清理,并补充交互回归测试。 + ## [0.9.4] - 2026-09-09 - 前端设计优化:升级蓝黑、电光青与少量紫色的 Cyber HUD 视觉,保留亮色主题;统一工作台、欢迎界面、共享控件、浮层和源码/调参窗口材质。 diff --git a/package-lock.json b/package-lock.json index 6ee80ef1..b4d0b8ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2e45619c..62bbc0f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mujoco-web-platform", - "version": "0.9.4", + "version": "0.9.5", "description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台", "private": true, "type": "module", diff --git a/web_platform/src/viewer/MuJoCoViewer.force.test.ts b/web_platform/src/viewer/MuJoCoViewer.force.test.ts new file mode 100644 index 00000000..9d7d8ab8 --- /dev/null +++ b/web_platform/src/viewer/MuJoCoViewer.force.test.ts @@ -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(); + }); +}); diff --git a/web_platform/src/viewer/MuJoCoViewer.ts b/web_platform/src/viewer/MuJoCoViewer.ts index ca5b31bf..f8e3cd42 100644 --- a/web_platform/src/viewer/MuJoCoViewer.ts +++ b/web_platform/src/viewer/MuJoCoViewer.ts @@ -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; diff --git a/web_platform/src/viewer/OrientationGizmo.test.ts b/web_platform/src/viewer/OrientationGizmo.test.ts new file mode 100644 index 00000000..58b93b96 --- /dev/null +++ b/web_platform/src/viewer/OrientationGizmo.test.ts @@ -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(); + 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(); + }); +}); diff --git a/web_platform/src/viewer/OrientationGizmo.ts b/web_platform/src/viewer/OrientationGizmo.ts index 661b11d7..87a98945 100644 --- a/web_platform/src/viewer/OrientationGizmo.ts +++ b/web_platform/src/viewer/OrientationGizmo.ts @@ -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(); } }