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
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+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;
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1xK1SY-nHdgcy_n6gMkYNyMPdNrp-RAif
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Unity Coordinate Visualizer</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom scrollbar for the overlay */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #2d2d2d;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #555;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>
<script type="importmap">
{
"imports": {
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"three/": "https://aistudiocdn.com/three@^0.181.2/",
"three": "https://aistudiocdn.com/three@^0.181.2",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-neutral-900 text-white overflow-hidden">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,5 @@
{
"name": "Unity Coordinate Visualizer",
"description": "A 3D interactive visualization of Unity coordinate system (Left-handed, Z-forward) demonstrating the relationship between spherical inputs (Yaw/Pitch/Radius) and Cartesian coordinates.",
"requestFramePermissions": []
}
@@ -0,0 +1,22 @@
{
"name": "unity-coordinate-visualizer",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"three": "^0.181.2",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
@@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});