init
This commit is contained in:
@@ -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>θ = {coords.theta}° ⇒ sin: {mathResult.sinT.toFixed(3)}, cos: {mathResult.cosT.toFixed(3)}</div>
|
||||
<div>φ = <span className='text-red-400'>-({coords.phi})</span>° ⇒ 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(φ)
|
||||
</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(φ)</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(θ)</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(θ)</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;
|
||||
Reference in New Issue
Block a user