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(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(); } }