115 lines
4.3 KiB
TypeScript
115 lines
4.3 KiB
TypeScript
import * as THREE from 'three';
|
|
|
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
interface AxisElements {
|
|
axis: THREE.Vector3;
|
|
line: SVGLineElement;
|
|
negative: SVGCircleElement;
|
|
positive: SVGCircleElement;
|
|
label: SVGTextElement;
|
|
}
|
|
|
|
function svgElement<K extends keyof SVGElementTagNameMap>(name: K): SVGElementTagNameMap[K] {
|
|
return document.createElementNS(SVG_NS, name);
|
|
}
|
|
|
|
/** 固定在视口右下角、随相机旋转的 XYZ 方向示意器。 */
|
|
export class OrientationGizmo {
|
|
readonly element: SVGSVGElement;
|
|
private readonly axes: AxisElements[] = [];
|
|
private readonly inverseQuaternion = new THREE.Quaternion();
|
|
private readonly projected = new THREE.Vector3();
|
|
private readonly center: SVGCircleElement;
|
|
|
|
constructor(host: HTMLElement) {
|
|
this.element = svgElement('svg');
|
|
this.element.setAttribute('viewBox', '0 0 100 100');
|
|
this.element.setAttribute('role', 'img');
|
|
this.element.setAttribute('aria-label', 'XYZ 方向指示器');
|
|
Object.assign(this.element.style, {
|
|
position: 'absolute',
|
|
right: '14px',
|
|
bottom: '14px',
|
|
width: '92px',
|
|
height: '92px',
|
|
pointerEvents: 'none',
|
|
border: '1px solid rgba(100,116,139,.45)',
|
|
borderRadius: '10px',
|
|
background: 'rgba(15,23,42,.72)',
|
|
backdropFilter: 'blur(3px)',
|
|
});
|
|
const definitions: [string, number, THREE.Vector3][] = [
|
|
['X', 0xef4444, new THREE.Vector3(1, 0, 0)],
|
|
['Y', 0x22c55e, new THREE.Vector3(0, 1, 0)],
|
|
['Z', 0x3b82f6, new THREE.Vector3(0, 0, 1)],
|
|
];
|
|
for (const [name, colorValue, axis] of definitions) {
|
|
const color = `#${colorValue.toString(16).padStart(6, '0')}`;
|
|
const group = svgElement('g');
|
|
const line = svgElement('line');
|
|
line.setAttribute('stroke', color);
|
|
line.setAttribute('stroke-width', '2.5');
|
|
line.setAttribute('stroke-linecap', 'round');
|
|
const negative = svgElement('circle');
|
|
negative.setAttribute('r', '4');
|
|
negative.setAttribute('fill', color);
|
|
negative.setAttribute('fill-opacity', '.7');
|
|
const positive = svgElement('circle');
|
|
positive.setAttribute('r', '8');
|
|
positive.setAttribute('fill', color);
|
|
const label = svgElement('text');
|
|
label.textContent = name;
|
|
label.setAttribute('fill', '#0f172a');
|
|
label.setAttribute('font-size', '10');
|
|
label.setAttribute('font-weight', '700');
|
|
label.setAttribute('text-anchor', 'middle');
|
|
label.setAttribute('dominant-baseline', 'central');
|
|
group.append(line, negative, positive, label);
|
|
this.element.append(group);
|
|
this.axes.push({ axis, line, negative, positive, label });
|
|
}
|
|
this.center = svgElement('circle');
|
|
this.center.setAttribute('cx', '50');
|
|
this.center.setAttribute('cy', '50');
|
|
this.center.setAttribute('r', '3.5');
|
|
this.center.setAttribute('fill', '#cbd5e1');
|
|
this.element.append(this.center);
|
|
host.append(this.element);
|
|
}
|
|
|
|
update(camera: THREE.Camera): void {
|
|
this.inverseQuaternion.copy(camera.quaternion).invert();
|
|
for (const item of this.axes) {
|
|
this.projected.copy(item.axis).applyQuaternion(this.inverseQuaternion);
|
|
const x = 50 + this.projected.x * 30,
|
|
y = 50 - this.projected.y * 30;
|
|
const nx = 50 - this.projected.x * 30,
|
|
ny = 50 + this.projected.y * 30;
|
|
item.line.setAttribute('x1', String(nx));
|
|
item.line.setAttribute('y1', String(ny));
|
|
item.line.setAttribute('x2', String(x));
|
|
item.line.setAttribute('y2', String(y));
|
|
item.negative.setAttribute('cx', String(nx));
|
|
item.negative.setAttribute('cy', String(ny));
|
|
item.positive.setAttribute('cx', String(x));
|
|
item.positive.setAttribute('cy', String(y));
|
|
item.label.setAttribute('x', String(x));
|
|
item.label.setAttribute('y', String(y + 0.5));
|
|
const opacity = String(0.65 + 0.35 * Math.max(0, this.projected.z));
|
|
item.positive.setAttribute('fill-opacity', opacity);
|
|
item.label.setAttribute('fill-opacity', opacity);
|
|
}
|
|
}
|
|
|
|
setTheme(theme: 'light' | 'dark'): void {
|
|
const light = theme === 'light';
|
|
this.element.style.background = light ? 'rgba(248,250,252,.82)' : 'rgba(15,23,42,.72)';
|
|
this.element.style.borderColor = light ? 'rgba(100,116,139,.35)' : 'rgba(100,116,139,.45)';
|
|
this.center.setAttribute('fill', light ? '#475569' : '#cbd5e1');
|
|
}
|
|
|
|
dispose(): void {
|
|
this.element.remove();
|
|
}
|
|
}
|