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({ theta: 45, phi: 30, r: 8 }); // --- Refs for Three.js objects --- const mountRef = useRef(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 (
{/* 3D Canvas Container */}
{/* UI Overlay */}

Unity Coordinates

System: X-Right, Y-Up, Z-Forward (Into Screen)

* Pitch direction inverted

{/* Controls */}
handleChange('theta', v)} /> handleChange('phi', v)} /> handleChange('r', v)} />
{/* Calculations Display */}

Calculation Logic

{/* Step 1 */}
// 1. Deg to Rad (Pitch Inverted)
θ = {coords.theta}° ⇒ sin: {mathResult.sinT.toFixed(3)}, cos: {mathResult.cosT.toFixed(3)}
φ = -({coords.phi})° ⇒ sin: {mathResult.sinP.toFixed(3)}, cos: {mathResult.cosP.toFixed(3)}
{/* Step 2 */}
// 2. XZ Plane Projection
r_xz = r * cos(φ)
= {coords.r} * {mathResult.cosP.toFixed(3)} = {mathResult.r_xz.toFixed(3)}
{/* Step 3 */}
// 3. Unity Vectors
{/* Y */}
Y (Up) = r * sin(φ)
= {coords.r} * {mathResult.sinP.toFixed(3)} = {mathResult.valY.toFixed(2)}
{/* X */}
X (Right) = r_xz * sin(θ)
= {mathResult.r_xz.toFixed(3)} * {mathResult.sinT.toFixed(3)} = {mathResult.valX.toFixed(2)}
{/* Z */}
Z (Fwd) = r_xz * cos(θ)
= {mathResult.r_xz.toFixed(3)} * {mathResult.cosT.toFixed(3)} = {mathResult.valZ.toFixed(2)}
{/* Result Grid */}
X: {mathResult.valX.toFixed(2)}
Y: {mathResult.valY.toFixed(2)}
Z: {mathResult.valZ.toFixed(2)}
{/* Footer Tooltip */}
Left Click: Rotate · Right Click: Pan · Scroll: Zoom
); }; // --- 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 = ({ label, value, min, max, step, onChange }) => { return (
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" /> { 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" />
); }; export default App;