8 Commits

Author SHA1 Message Date
chenlin 3fb9766fac Merge pull request 'Cen' (#8) from Cen into main
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
Reviewed-on: #8
2026-09-09 14:59:41 +08:00
chenlin 79f458713c Merge pull request 'Cen' (#7) from Cen into main
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
Reviewed-on: #7
2026-09-07 09:15:38 +08:00
chenlin fa5ccd5106 Merge pull request 'Cen' (#6) from Cen into main
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
Reviewed-on: #6
2026-09-01 17:53:11 +08:00
chenlin 95390e94ad Merge pull request 'Cen' (#5) from Cen into main
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
Reviewed-on: #5
2026-09-01 16:03:39 +08:00
chenlin 456f202685 Merge pull request 'Cen' (#4) from Cen into main
web-platform-ci / TypeScript、Lint、Unit、Build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
Reviewed-on: #4
2026-08-28 14:12:31 +08:00
chenlin fe4d98b4a5 Merge pull request 'Cen' (#3) from Cen into main
build / setup (compute matrix) (push) Has been cancelled
build / macos-15-arm64-studio (push) Has been cancelled
build / ubuntu-24.04-clang-18-studio (push) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (push) Has been cancelled
build / windows-2025-ninja-studio (push) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (push) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (push) Has been cancelled
build / ${{ matrix.label }} (push) Has been cancelled
Reviewed-on: #3
2026-08-24 17:24:21 +08:00
chenlin 1263d274dd Merge pull request 'Cen' (#2) from Cen into main
build / setup (compute matrix) (push) Has been cancelled
build / macos-15-arm64-studio (push) Has been cancelled
build / ubuntu-24.04-clang-18-studio (push) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (push) Has been cancelled
build / windows-2025-ninja-studio (push) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (push) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (push) Has been cancelled
build / ${{ matrix.label }} (push) Has been cancelled
Reviewed-on: #2
2026-08-21 16:55:40 +08:00
chenlin 2a50fab918 Merge pull request 'feat: add browser-based MuJoCo simulation platform' (#1) from Cen into main
build / setup (compute matrix) (push) Has been cancelled
build / ${{ matrix.label }} (push) Has been cancelled
build / macos-15-arm64-studio (push) Has been cancelled
build / ubuntu-24.04-clang-18-studio (push) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (push) Has been cancelled
build / windows-2025-ninja-studio (push) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (push) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (push) Has been cancelled
Reviewed-on: #1
2026-08-20 15:22:18 +08:00
7 changed files with 7 additions and 493 deletions
-9
View File
@@ -4,15 +4,6 @@
## [未发布] ## [未发布]
## [0.9.5] - 2026-09-09
- 修复外力拖动箭头起点停留在初始位置:保存拾取点的 body 局部坐标,每帧随部件平移和旋转更新,即使鼠标静止也持续跟随;保持现有外力方向/大小控制,补充位姿跟随与释放清理测试。
- 小坐标轴拖动增加与主视口相同参数的阻尼惯性,松手后逐帧减速且保持总旋转幅度;新操作、手势取消及资源清理可终止惯性,补充衰减与中断测试。
- 坐标轴拖动灵敏度对齐主视口高度与旋转速度;点击轴向采用约 300ms 平滑过渡,支持鼠标操作中断,补充幅度与过渡回归测试。
- 修复右下角 XYZ 方向指示器无法交互:支持左键拖动旋转视角、点击轴字母切换对应正轴视图及点击负轴端点切换反向视图;保持观察中心与距离,隔离场景点击,处理拖动取消与资源清理,并补充交互回归测试。
## [0.9.4] - 2026-09-09 ## [0.9.4] - 2026-09-09
- 前端设计优化:升级蓝黑、电光青与少量紫色的 Cyber HUD 视觉,保留亮色主题;统一工作台、欢迎界面、共享控件、浮层和源码/调参窗口材质。 - 前端设计优化:升级蓝黑、电光青与少量紫色的 Cyber HUD 视觉,保留亮色主题;统一工作台、欢迎界面、共享控件、浮层和源码/调参窗口材质。
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.9.5", "version": "0.9.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.9.5", "version": "0.9.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mujoco-web-platform", "name": "mujoco-web-platform",
"version": "0.9.5", "version": "0.9.4",
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台", "description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -1,41 +0,0 @@
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();
});
});
+1 -39
View File
@@ -118,10 +118,6 @@ export class MuJoCoViewer {
private dragJointStartWorld = new THREE.Vector3(); private dragJointStartWorld = new THREE.Vector3();
private dragJointStartPlaneVector = new THREE.Vector3(); private dragJointStartPlaneVector = new THREE.Vector3();
private dragForceOrigin = 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 dragForcePlane = new THREE.Plane();
private arrow: THREE.ArrowHelper | null = null; private arrow: THREE.ArrowHelper | null = null;
private highlightedJointId = -1; private highlightedJointId = -1;
@@ -240,9 +236,7 @@ export class MuJoCoViewer {
this.controls.enabled = !value; this.controls.enabled = !value;
}, },
}); });
this.orientationGizmo = new OrientationGizmo(host, this.controls, () => { this.orientationGizmo = new OrientationGizmo(host);
this.cameraFocusTransition = undefined;
});
this.orientationGizmo.update(this.camera); this.orientationGizmo.update(this.camera);
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
cancelAnimationFrame(this.resizeFrame); cancelAnimationFrame(this.resizeFrame);
@@ -626,7 +620,6 @@ export class MuJoCoViewer {
if (this.session) { if (this.session) {
this.updateMuJoCoScene(); this.updateMuJoCoScene();
this.updateJointMarker(); this.updateJointMarker();
this.updateForceArrowOrigin();
this.visualizationHelpers.update(); this.visualizationHelpers.update();
} }
this.updatePerceptionLines(); this.updatePerceptionLines();
@@ -1058,12 +1051,6 @@ export class MuJoCoViewer {
this.dragStart = this.pointer.clone(); this.dragStart = this.pointer.clone();
this.dragHitDistance = hit.distance; this.dragHitDistance = hit.distance;
this.dragForceOrigin.copy(hit.point); 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.dragForcePlane.setFromNormalAndCoplanarPoint(
this.camera.getWorldDirection(new THREE.Vector3()), this.camera.getWorldDirection(new THREE.Vector3()),
hit.point, hit.point,
@@ -1145,7 +1132,6 @@ export class MuJoCoViewer {
this.dragStart = null; this.dragStart = null;
this.dragJointId = -1; this.dragJointId = -1;
this.dragJointType = -1; this.dragJointType = -1;
this.dragForceBodyId = -1;
this.dragHitDistance = 0; this.dragHitDistance = 0;
this.dragSlideParameter = 0; this.dragSlideParameter = 0;
this.session?.clearExternalForce(); this.session?.clearExternalForce();
@@ -1180,30 +1166,6 @@ export class MuJoCoViewer {
position: [e[12], e[13], e[14]], 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 { private showArrow(origin: THREE.Vector3): void {
this.arrow = new THREE.ArrowHelper(new THREE.Vector3(1, 0, 0), origin, 0.01, 0xf97316); this.arrow = new THREE.ArrowHelper(new THREE.Vector3(1, 0, 0), origin, 0.01, 0xf97316);
this.arrow.visible = false; this.arrow.visible = false;
@@ -1,198 +0,0 @@
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();
});
});
+3 -203
View File
@@ -1,5 +1,4 @@
import * as THREE from 'three'; import * as THREE from 'three';
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
const SVG_NS = 'http://www.w3.org/2000/svg'; const SVG_NS = 'http://www.w3.org/2000/svg';
interface AxisElements { interface AxisElements {
@@ -22,52 +21,18 @@ export class OrientationGizmo {
private readonly projected = new THREE.Vector3(); private readonly projected = new THREE.Vector3();
private readonly center: SVGCircleElement; private readonly center: SVGCircleElement;
private readonly rotationDelta = new THREE.Vector2(); constructor(host: HTMLElement) {
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 = svgElement('svg');
this.element.setAttribute('viewBox', '0 0 100 100'); this.element.setAttribute('viewBox', '0 0 100 100');
this.element.setAttribute('role', 'img'); this.element.setAttribute('role', 'img');
this.element.setAttribute('aria-label', 'XYZ 方向指示器'); this.element.setAttribute('aria-label', 'XYZ 方向指示器');
this.element.setAttribute('aria-description', '左键拖动旋转视角,点击轴字母切换正视图');
Object.assign(this.element.style, { Object.assign(this.element.style, {
position: 'absolute', position: 'absolute',
right: '14px', right: '14px',
bottom: '14px', bottom: '14px',
width: '92px', width: '92px',
height: '92px', height: '92px',
pointerEvents: 'auto', pointerEvents: 'none',
touchAction: 'none',
userSelect: 'none',
cursor: 'grab',
border: '1px solid rgba(100,116,139,.45)', border: '1px solid rgba(100,116,139,.45)',
borderRadius: '10px', borderRadius: '10px',
background: 'rgba(15,23,42,.72)', background: 'rgba(15,23,42,.72)',
@@ -99,12 +64,6 @@ export class OrientationGizmo {
label.setAttribute('font-weight', '700'); label.setAttribute('font-weight', '700');
label.setAttribute('text-anchor', 'middle'); label.setAttribute('text-anchor', 'middle');
label.setAttribute('dominant-baseline', 'central'); 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); group.append(line, negative, positive, label);
this.element.append(group); this.element.append(group);
this.axes.push({ axis, line, negative, positive, label }); this.axes.push({ axis, line, negative, positive, label });
@@ -116,157 +75,9 @@ export class OrientationGizmo {
this.center.setAttribute('fill', '#cbd5e1'); this.center.setAttribute('fill', '#cbd5e1');
this.element.append(this.center); this.element.append(this.center);
host.append(this.element); 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);
} }
private readonly stopClick = (event: Event): void => event.stopPropagation(); update(camera: THREE.Camera): void {
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(); this.inverseQuaternion.copy(camera.quaternion).invert();
for (const item of this.axes) { for (const item of this.axes) {
this.projected.copy(item.axis).applyQuaternion(this.inverseQuaternion); this.projected.copy(item.axis).applyQuaternion(this.inverseQuaternion);
@@ -298,17 +109,6 @@ export class OrientationGizmo {
} }
dispose(): void { 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(); this.element.remove();
} }
} }