This commit is contained in:
xsl
2025-11-25 13:16:57 +08:00
parent 3df1a1d8db
commit 9fbde885ec
9 changed files with 558 additions and 0 deletions
+379
View File
@@ -0,0 +1,379 @@
import React, { useEffect, useRef, useState, useMemo } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
// --- Types & Constants ---
interface CoordinateState {
theta: number; // Yaw
phi: number; // Pitch
r: number; // Radius
}
const AXIS_LEN = 12;
const App: React.FC = () => {
// --- State ---
const [coords, setCoords] = useState<CoordinateState>({
theta: 45,
phi: 30,
r: 8
});
// --- Refs for Three.js objects ---
const mountRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<{
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
renderer: THREE.WebGLRenderer;
sphereMesh: THREE.Mesh;
radiusLine: THREE.Line;
projLine: THREE.Line;
heightLine: THREE.Line;
controls: OrbitControls;
} | null>(null);
// --- Math Logic (Extracted for UI & 3D sync) ---
const mathResult = useMemo(() => {
const { theta, phi, r } = coords;
// --- CHANGE HERE: Invert the Phi (Pitch) direction ---
// Original was: const radPhi = phi * Math.PI / 180;
const radTheta = theta * Math.PI / 180;
const radPhi = -phi * Math.PI / 180; // Inverted rotation direction
const sinT = Math.sin(radTheta);
const cosT = Math.cos(radTheta);
const sinP = Math.sin(radPhi);
const cosP = Math.cos(radPhi);
// Unity Coordinate Calculation
const r_xz = r * cosP; // Projection length on XZ plane
const valX = r_xz * sinT;
const valY = r * sinP;
const valZ = r_xz * cosT;
return {
radTheta,
radPhi,
sinT, cosT, sinP, cosP,
r_xz,
valX, valY, valZ
};
}, [coords]);
// --- Initialize Three.js Scene ---
useEffect(() => {
if (!mountRef.current) return;
// 1. Scene Setup
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
const width = mountRef.current.clientWidth;
const height = mountRef.current.clientHeight;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
camera.position.set(0, 5, 20);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
mountRef.current.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// 2. Helpers
const gridHelper = new THREE.GridHelper(30, 30, 0x444444, 0x222222);
scene.add(gridHelper);
// 3. Axes Creation Helper
const createLine = (start: THREE.Vector3, end: THREE.Vector3, color: number, dashed = false) => {
const points = [start, end];
const geo = new THREE.BufferGeometry().setFromPoints(points);
let mat: THREE.Material;
if (dashed) {
mat = new THREE.LineDashedMaterial({ color, dashSize: 0.5, gapSize: 0.3, scale: 1 });
} else {
mat = new THREE.LineBasicMaterial({ color, linewidth: 2 });
}
const line = new THREE.Line(geo, mat);
if (dashed) line.computeLineDistances();
return line;
};
// Axes
scene.add(createLine(new THREE.Vector3(0, 0, 0), new THREE.Vector3(AXIS_LEN, 0, 0), 0xff0000)); // X
scene.add(createLine(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, AXIS_LEN, 0), 0x00ff00)); // Y
scene.add(createLine(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -AXIS_LEN), 0x0066ff)); // Z (Visualized into screen)
// Labels (Using Sprites)
const addLabel = (text: string, pos: THREE.Vector3, colorStr: string) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx) {
canvas.width = 64; canvas.height = 64;
ctx.fillStyle = colorStr;
ctx.font = "bold 48px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, 32, 32);
const texture = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({ map: texture });
const sprite = new THREE.Sprite(spriteMat);
sprite.position.copy(pos);
sprite.scale.set(1.5, 1.5, 1.5);
scene.add(sprite);
}
};
addLabel("X", new THREE.Vector3(AXIS_LEN + 1, 0, 0), "#ff5555");
addLabel("Y", new THREE.Vector3(0, AXIS_LEN + 1, 0), "#55ff55");
addLabel("Z", new THREE.Vector3(0, 0, -AXIS_LEN - 1), "#5588ff");
// 4. Dynamic Objects
const sphereMesh = new THREE.Mesh(
new THREE.SphereGeometry(0.4, 32, 32),
new THREE.MeshBasicMaterial({ color: 0xffff00 })
);
scene.add(sphereMesh);
const radiusLine = createLine(new THREE.Vector3(), new THREE.Vector3(), 0xffff00, true);
const projLine = createLine(new THREE.Vector3(), new THREE.Vector3(), 0x00aa00);
const heightLine = createLine(new THREE.Vector3(), new THREE.Vector3(), 0x00aaff);
scene.add(radiusLine, projLine, heightLine);
// Store refs
sceneRef.current = {
scene, camera, renderer, sphereMesh, radiusLine, projLine, heightLine, controls
};
// Animation Loop
const animate = () => {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
// Cleanup
return () => {
if (mountRef.current) {
mountRef.current.removeChild(renderer.domElement);
}
renderer.dispose();
};
}, []);
// --- Update Scene when Coordinates Change ---
useEffect(() => {
if (!sceneRef.current) return;
const { sphereMesh, radiusLine, projLine, heightLine } = sceneRef.current;
const { valX, valY, valZ } = mathResult;
// Unity Z+ is "Forward". In Three.js (WebGL), "Forward" into the screen is Z-.
// To visually match the Unity inspector logic where Z+ goes "into" the scene relative to camera start:
const renderX = valX;
const renderY = valY;
const renderZ = -valZ;
sphereMesh.position.set(renderX, renderY, renderZ);
const origin = new THREE.Vector3(0, 0, 0);
const target = new THREE.Vector3(renderX, renderY, renderZ);
const ground = new THREE.Vector3(renderX, 0, renderZ);
// Update lines
radiusLine.geometry.setFromPoints([origin, target]);
radiusLine.computeLineDistances(); // Necessary for dashed lines
radiusLine.geometry.attributes.position.needsUpdate = true;
projLine.geometry.setFromPoints([origin, ground]);
projLine.geometry.attributes.position.needsUpdate = true;
heightLine.geometry.setFromPoints([ground, target]);
heightLine.geometry.attributes.position.needsUpdate = true;
}, [mathResult]);
// --- Resize Handler ---
useEffect(() => {
const handleResize = () => {
if (!mountRef.current || !sceneRef.current) return;
const width = window.innerWidth;
const height = window.innerHeight;
const { camera, renderer } = sceneRef.current;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// --- UI Handlers ---
const handleChange = (key: keyof CoordinateState, value: number) => {
setCoords(prev => ({ ...prev, [key]: value }));
};
return (
<div className="relative w-full h-screen bg-neutral-900">
{/* 3D Canvas Container */}
<div ref={mountRef} className="w-full h-full cursor-move" />
{/* UI Overlay */}
<div className="absolute top-4 left-4 w-[400px] max-h-[90vh] overflow-y-auto custom-scrollbar bg-neutral-800/95 backdrop-blur-sm border border-neutral-700 rounded-lg shadow-2xl p-5 flex flex-col gap-4">
<div className="border-b border-neutral-600 pb-3">
<h3 className="text-xl font-semibold text-sky-400">Unity Coordinates</h3>
<p className="text-xs text-neutral-400 mt-1">
System: X-Right, Y-Up, <span className="text-blue-400 font-bold">Z-Forward (Into Screen)</span>
</p>
<p className="text-xs text-amber-400 mt-1 font-mono">
* Pitch direction inverted
</p>
</div>
{/* Controls */}
<div className="space-y-4">
<ControlRow
label="Theta (Yaw) [-180 ~ 180]"
value={coords.theta}
min={-180} max={180} step={1}
onChange={(v) => handleChange('theta', v)}
/>
<ControlRow
label="Phi (Pitch) [-90 ~ 90]"
value={coords.phi}
min={-90} max={90} step={1}
onChange={(v) => handleChange('phi', v)}
/>
<ControlRow
label="Radius (r) [1 ~ 15]"
value={coords.r}
min={1} max={15} step={0.1}
onChange={(v) => handleChange('r', v)}
/>
</div>
{/* Calculations Display */}
<div className="mt-2">
<h4 className="text-sm font-semibold text-amber-400 border-l-4 border-amber-400 pl-2 mb-2">Calculation Logic</h4>
<div className="bg-black/40 p-3 rounded border border-neutral-700 font-mono text-xs leading-relaxed text-neutral-300">
{/* Step 1 */}
<div className="mb-3 border-b border-dashed border-neutral-700 pb-2">
<div className="text-green-400 italic mb-1">// 1. Deg to Rad (Pitch Inverted)</div>
<div>&theta; = {coords.theta}&deg; &rArr; sin: {mathResult.sinT.toFixed(3)}, cos: {mathResult.cosT.toFixed(3)}</div>
<div>&phi; = <span className='text-red-400'>-({coords.phi})</span>&deg; &rArr; sin: {mathResult.sinP.toFixed(3)}, cos: {mathResult.cosP.toFixed(3)}</div>
</div>
{/* Step 2 */}
<div className="mb-3 border-b border-dashed border-neutral-700 pb-2">
<div className="text-green-400 italic mb-1">// 2. XZ Plane Projection</div>
<div>
<span className="text-sky-300 font-bold">r_xz</span> = r * cos(&phi;)
</div>
<div className="pl-4 text-neutral-400">
= {coords.r} * {mathResult.cosP.toFixed(3)} = <span className="text-sky-200 font-bold">{mathResult.r_xz.toFixed(3)}</span>
</div>
</div>
{/* Step 3 */}
<div>
<div className="text-green-400 italic mb-1">// 3. Unity Vectors</div>
{/* Y */}
<div className="flex justify-between">
<span><span className="text-green-400 font-bold">Y (Up)</span> = r * sin(&phi;)</span>
</div>
<div className="pl-4 text-neutral-400 mb-1">
= {coords.r} * {mathResult.sinP.toFixed(3)} = <span className="text-green-300 font-bold">{mathResult.valY.toFixed(2)}</span>
</div>
{/* X */}
<div className="flex justify-between">
<span><span className="text-red-400 font-bold">X (Right)</span> = r_xz * sin(&theta;)</span>
</div>
<div className="pl-4 text-neutral-400 mb-1">
= {mathResult.r_xz.toFixed(3)} * {mathResult.sinT.toFixed(3)} = <span className="text-red-300 font-bold">{mathResult.valX.toFixed(2)}</span>
</div>
{/* Z */}
<div className="flex justify-between">
<span><span className="text-blue-400 font-bold">Z (Fwd)</span> = r_xz * cos(&theta;)</span>
</div>
<div className="pl-4 text-neutral-400">
= {mathResult.r_xz.toFixed(3)} * {mathResult.cosT.toFixed(3)} = <span className="text-blue-300 font-bold">{mathResult.valZ.toFixed(2)}</span>
</div>
</div>
</div>
</div>
{/* Result Grid */}
<div className="grid grid-cols-3 gap-2 font-mono text-sm font-bold text-center shadow-inner">
<div className="bg-red-900/40 border border-red-500/50 text-red-200 p-2 rounded">
X: {mathResult.valX.toFixed(2)}
</div>
<div className="bg-green-900/40 border border-green-500/50 text-green-200 p-2 rounded">
Y: {mathResult.valY.toFixed(2)}
</div>
<div className="bg-blue-900/40 border border-blue-500/50 text-blue-200 p-2 rounded">
Z: {mathResult.valZ.toFixed(2)}
</div>
</div>
</div>
{/* Footer Tooltip */}
<div className="absolute bottom-6 w-full text-center pointer-events-none select-none">
<span className="text-neutral-500 text-sm drop-shadow-md bg-black/50 px-3 py-1 rounded-full">
Left Click: Rotate · Right Click: Pan · Scroll: Zoom
</span>
</div>
</div>
);
};
// --- Sub-component for Slider+Input ---
interface ControlRowProps {
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (val: number) => void;
}
const ControlRow: React.FC<ControlRowProps> = ({ label, value, min, max, step, onChange }) => {
return (
<div className="flex flex-col gap-1">
<label className="text-xs text-neutral-400">{label}</label>
<div className="flex items-center gap-3">
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="flex-grow h-1 bg-neutral-600 rounded-lg appearance-none cursor-pointer accent-sky-500 hover:accent-sky-400 transition-all"
/>
<input
type="number"
min={min} max={max} step={step}
value={value}
onChange={(e) => {
let v = parseFloat(e.target.value);
if (isNaN(v)) return;
if (v < min) v = min;
if (v > max) v = max;
onChange(v);
}}
className="w-16 bg-neutral-950 border border-neutral-600 text-white text-right text-sm p-1 rounded focus:outline-none focus:border-sky-500 font-mono"
/>
</div>
</div>
);
};
export default App;