Compare commits

...

4 Commits

Author SHA1 Message Date
Thomas Faour
b6aded5281 missed deploy 2025-06-21 23:30:43 -04:00
Thomas Faour
e59d1d90b3 A ton of AI assisted web development 2025-06-21 23:29:14 -04:00
Thomas Faour
a8fcb5a7d9 readme update 2025-06-21 21:22:17 -04:00
Thomas Faour
19fcf445e4 Added more planets, plotting, json AND toml support 2025-06-21 21:14:25 -04:00
51 changed files with 10479 additions and 313 deletions

61
.dockerignore Normal file
View File

@ -0,0 +1,61 @@
# Rust build artifacts
target/
**/*.rs.bk
# Node.js
web/node_modules/
web/dist/
web/.vite/
# Git
.git/
.gitignore
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
logs/
*.log
# Temporary files
*.tmp
*.temp
# Development files
.env
.env.local
.env.*.local
# Documentation (can be excluded for production)
README.md
DEPLOYMENT.md
STATUS.md
*.md
# Test files
test_*
*_test.rs
# Python cache
__pycache__/
*.pyc
*.pyo
# Binary outputs
*.bin
trajectory.bin
# Development scripts (optional)
start_interfaces.sh
test_interfaces.sh
# Tauri (not needed for Docker web deployment)
src-tauri/

65
.gitignore vendored
View File

@ -1,20 +1,73 @@
# Python cache and virtual environments
last_checkpoint.npz
*.pyc
__pycache__
__pycache__/
.venv/
venv/
env/
.env
*.egg-info/
# Build directories
/build
/cmake-build-*
CMakeFiles/
*.cmake
/rust/target
/rust/target/*
# Rust build artifacts
/target/
/rust/target/
rust/Cargo.lock
Cargo.lock
# Simulation output files
out.out
output.json
*.bin
*.png
*.mp4
*.avi
traj.bin
trajectory.bin
test_output.bin
# Web frontend
web/node_modules/
web/dist/
web/.vite/
web/coverage/
web/build/
# Added by cargo
# Tauri desktop GUI
src-tauri/target/
src-tauri/gen/
/target
Cargo.lock
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime and temporary files
.tmp/
.temp/
*.tmp
*.temp
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

View File

@ -4,6 +4,10 @@ version = "0.1.0"
edition = "2021"
authors = ["thomas"]
[features]
default = []
gui = ["dep:tauri", "dep:tauri-build"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
@ -17,6 +21,19 @@ log = "0.4.27"
once_cell = "1.21.3"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
bevy = "0.13"
serde_toml = "0.0.1"
toml = "0.8.23"
# Web API dependencies
axum = "0.7"
tokio = { version = "1.0", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "fs"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
dashmap = "5.5"
# GUI dependencies (optional)
tauri = { version = "1.0", features = ["api-all"], optional = true }
[build-dependencies]
tauri-build = { version = "1.0", optional = true }

302
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,302 @@
# Production Deployment Guide
This guide explains how to build and deploy the Orbital Simulator in production environments.
## Building for Production
### 1. Rust API Server
Build the optimized API server:
```bash
# Build without GUI dependencies (recommended for servers)
cargo build --release --bin api_server --no-default-features
# Or build with GUI support (if system libraries are available)
cargo build --release --bin api_server --features gui
```
The compiled binary will be at `target/release/api_server`.
### 2. Web Frontend
Build the optimized web frontend:
```bash
cd web
npm install
npm run build
```
The built files will be in `web/dist/`.
### 3. Desktop GUI (Optional)
Build the desktop application:
```bash
# Install Tauri CLI if not already installed
cargo install tauri-cli
# Build the desktop app
cargo tauri build --features gui
```
The built application will be in `src-tauri/target/release/bundle/`.
## Deployment Options
### Option 1: Single Server Deployment
Deploy both API and web frontend on the same server:
```bash
# 1. Copy the API server binary
cp target/release/api_server /opt/orbital_simulator/
# 2. Copy the web files
cp -r web/dist /opt/orbital_simulator/static
# 3. Create systemd service
sudo tee /etc/systemd/system/orbital-simulator.service > /dev/null <<EOF
[Unit]
Description=Orbital Simulator API Server
After=network.target
[Service]
Type=simple
User=orbital
WorkingDirectory=/opt/orbital_simulator
ExecStart=/opt/orbital_simulator/api_server
Restart=always
RestartSec=10
Environment=STATIC_DIR=/opt/orbital_simulator/static
[Install]
WantedBy=multi-user.target
EOF
# 4. Start the service
sudo systemctl enable orbital-simulator
sudo systemctl start orbital-simulator
```
### Option 2: Separate API and Web Servers
#### API Server (Backend)
```bash
# Deploy API server only
cp target/release/api_server /opt/orbital_simulator/
```
Configure reverse proxy (nginx example):
```nginx
server {
listen 80;
server_name api.yourdomain.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
#### Web Frontend (Static Hosting)
Deploy `web/dist/` to any static hosting service:
- **Nginx**: Copy files to web root
- **Apache**: Copy files to document root
- **CDN**: Upload to AWS S3, Cloudflare, etc.
Update the API endpoint in the frontend if needed.
### Option 3: Docker Deployment
Create `Dockerfile`:
```dockerfile
# Multi-stage build
FROM rust:1.70 as rust-builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY build.rs ./
RUN cargo build --release --bin api_server --no-default-features
FROM node:18 as web-builder
WORKDIR /app
COPY web/package*.json ./
RUN npm install
COPY web ./
RUN npm run build
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=rust-builder /app/target/release/api_server ./
COPY --from=web-builder /app/dist ./static
EXPOSE 3000
CMD ["./api_server"]
```
Build and run:
```bash
docker build -t orbital-simulator .
docker run -p 3000:3000 orbital-simulator
```
## Configuration
### Environment Variables
- `PORT`: Server port (default: 3000)
- `HOST`: Server host (default: 0.0.0.0)
- `STATIC_DIR`: Directory for static files (default: web/dist)
- `LOG_LEVEL`: Logging level (debug, info, warn, error)
- `MAX_SIMULATIONS`: Maximum concurrent simulations (default: 10)
### Example Production Configuration
```bash
export PORT=8080
export HOST=0.0.0.0
export LOG_LEVEL=info
export MAX_SIMULATIONS=50
export STATIC_DIR=/var/www/orbital-simulator
```
## Performance Tuning
### Rust API Server
1. **Enable release optimizations** in `Cargo.toml`:
```toml
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = 'abort'
```
2. **Limit concurrent simulations** to prevent resource exhaustion
3. **Use connection pooling** for database connections (if added)
### Web Frontend
1. **Enable gzip compression** in your web server
2. **Set appropriate cache headers** for static assets
3. **Use a CDN** for global distribution
4. **Implement lazy loading** for large datasets
## Monitoring and Logging
### Metrics to Monitor
- API response times
- Active simulation count
- Memory usage
- CPU usage
- WebSocket connection count
### Logging
The application uses the `log` crate. Configure logging level:
```bash
RUST_LOG=info ./api_server
```
Log levels:
- `debug`: Detailed debugging information
- `info`: General operational information
- `warn`: Warning messages
- `error`: Error messages only
### Health Checks
The API server provides health check endpoints:
- `GET /api/health`: Basic health check
- `GET /api/metrics`: System metrics (if enabled)
## Security Considerations
1. **Use HTTPS** in production
2. **Configure CORS** appropriately for your domain
3. **Implement rate limiting** to prevent abuse
4. **Sanitize user inputs** in configuration uploads
5. **Run with minimal privileges** (non-root user)
6. **Keep dependencies updated** regularly
## Backup and Recovery
### Data to Backup
- Configuration files
- Simulation results (if persisted)
- User data (if user accounts are added)
### Recovery Procedures
1. Restore application binary
2. Restore configuration files
3. Restart services
4. Verify functionality
## Scaling
### Horizontal Scaling
- Deploy multiple API server instances behind a load balancer
- Use Redis for session storage (if sessions are added)
- Implement proper service discovery
### Vertical Scaling
- Increase server resources (CPU, RAM)
- Optimize simulation algorithms
- Use faster storage (SSD)
## Troubleshooting
### Common Issues
1. **Port already in use**: Change PORT environment variable
2. **Static files not found**: Check STATIC_DIR path
3. **High memory usage**: Limit concurrent simulations
4. **Slow performance**: Enable release optimizations
### Debug Mode
Run with debug logging:
```bash
RUST_LOG=debug ./api_server
```
### Performance Profiling
Use `perf` or `valgrind` to profile the application:
```bash
perf record --call-graph=dwarf ./api_server
perf report
```
## Support
For issues and questions:
1. Check the logs for error messages
2. Verify configuration settings
3. Test with minimal configuration
4. Consult the main README.md for additional information

296
DOCKER.md Normal file
View File

@ -0,0 +1,296 @@
# Docker Deployment Guide
This guide covers deploying the Orbital Simulator using Docker.
## Quick Start
### Development Deployment
```bash
# Build and run with Docker Compose
docker-compose up --build
# Access the application
open http://localhost:3000
```
### Production Deployment with Nginx
```bash
# Run with production profile (includes Nginx reverse proxy)
docker-compose --profile production up --build -d
# Access the application
open http://localhost
```
## Manual Docker Build
### Build the Image
```bash
# Build the Docker image
docker build -t orbital-simulator .
# Run the container
docker run -p 3000:3000 orbital-simulator
```
### Build with Custom Tag
```bash
# Build with version tag
docker build -t orbital-simulator:v1.0.0 .
# Push to registry (after login)
docker tag orbital-simulator:v1.0.0 your-registry/orbital-simulator:v1.0.0
docker push your-registry/orbital-simulator:v1.0.0
```
## Environment Variables
The application supports the following environment variables:
- `RUST_LOG`: Log level (default: `info`)
- `BIND_ADDRESS`: Server bind address (default: `0.0.0.0:3000`)
### Example with Environment Variables
```bash
docker run -p 3000:3000 \
-e RUST_LOG=debug \
-e BIND_ADDRESS=0.0.0.0:3000 \
orbital-simulator
```
## Volume Mounts
### Configuration Files
Mount custom configuration files:
```bash
docker run -p 3000:3000 \
-v $(pwd)/custom-config:/app/config:ro \
orbital-simulator
```
### Persistent Logs
Mount logs directory for persistence:
```bash
docker run -p 3000:3000 \
-v orbital-logs:/app/logs \
orbital-simulator
```
## Health Checks
The container includes a health check that verifies the API is responding:
```bash
# Check container health
docker ps
# View health check logs
docker inspect --format='{{json .State.Health}}' <container-id>
```
## Production Considerations
### SSL/TLS Setup
For production, update the `nginx.conf` file and mount SSL certificates:
```yaml
# docker-compose.yml
services:
nginx:
volumes:
- ./ssl:/etc/nginx/ssl:ro
- ./nginx-prod.conf:/etc/nginx/nginx.conf:ro
```
### Resource Limits
Set appropriate resource limits:
```yaml
# docker-compose.yml
services:
orbital-simulator:
deploy:
resources:
limits:
cpus: '2.0'
memory: 2G
reservations:
memory: 512M
```
### Scaling
For high availability, you can run multiple instances:
```yaml
# docker-compose.yml
services:
orbital-simulator:
scale: 3
deploy:
replicas: 3
```
### Monitoring
Add monitoring with Prometheus/Grafana:
```yaml
# docker-compose.yml
services:
prometheus:
image: prom/prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana
ports:
- "3001:3000"
```
## Troubleshooting
### Container Won't Start
```bash
# Check logs
docker logs <container-id>
# Run interactively for debugging
docker run -it --entrypoint /bin/bash orbital-simulator
```
### Permission Issues
The container runs as a non-root user. If you encounter permission issues:
```bash
# Check file permissions
ls -la config/
# Fix permissions if needed
chmod -R 644 config/
```
### Memory Issues
For large simulations, increase container memory:
```bash
# Run with more memory
docker run -p 3000:3000 --memory=4g orbital-simulator
```
## Building for Different Architectures
### Multi-architecture Build
```bash
# Create and use buildx builder
docker buildx create --use
# Build for multiple architectures
docker buildx build --platform linux/amd64,linux/arm64 \
-t orbital-simulator:latest --push .
```
### ARM64 Support
The Docker image supports ARM64 (Apple Silicon, ARM servers):
```bash
# Build specifically for ARM64
docker buildx build --platform linux/arm64 \
-t orbital-simulator:arm64 .
```
## Updates and Maintenance
### Updating the Application
```bash
# Pull latest changes
git pull
# Rebuild and restart
docker-compose down
docker-compose up --build -d
```
### Database/State Cleanup
Since the application stores simulation state in memory, restart to clear:
```bash
# Restart containers
docker-compose restart
# Or rebuild for full cleanup
docker-compose down -v
docker-compose up --build -d
```
## CI/CD Integration
### GitHub Actions Example
```yaml
# .github/workflows/docker.yml
name: Docker Build and Push
on:
push:
branches: [main]
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Registry
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: |
orbital-simulator:latest
orbital-simulator:${{ github.sha }}
```
## Security Considerations
1. **Run as non-root**: The container runs as user `appuser`
2. **Read-only config**: Mount config directory as read-only
3. **Network security**: Use Nginx for SSL termination and rate limiting
4. **Update base images**: Regularly update the base Debian image
5. **Scan for vulnerabilities**: Use `docker scan` or similar tools
```bash
# Scan for vulnerabilities
docker scan orbital-simulator
```

108
Dockerfile Normal file
View File

@ -0,0 +1,108 @@
# Multi-stage Docker build for Orbital Simulator
# Stage 1: Build the Rust backend
FROM rust:1.75-slim as rust-builder
# Install system dependencies for building
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libglib2.0-dev \
libgtk-3-dev \
libpango1.0-dev \
libatk1.0-dev \
libgdk-pixbuf-2.0-dev \
libcairo2-dev \
libasound2-dev \
libudev-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy Cargo files for dependency caching
COPY Cargo.toml Cargo.lock ./
COPY build.rs ./
# Create dummy source files to build dependencies
RUN mkdir src && \
echo "fn main() {}" > src/main.rs && \
mkdir -p src/bin && \
echo "fn main() {}" > src/bin/api_server.rs && \
echo "fn main() {}" > src/bin/simulator.rs && \
echo "fn main() {}" > src/bin/orbiter.rs
# Build dependencies (this layer will be cached)
RUN cargo build --release --bin api_server
# Remove dummy files
RUN rm -rf src
# Copy actual source code
COPY src ./src
COPY config ./config
# Build the actual application
RUN cargo build --release --bin api_server
# Stage 2: Build the React frontend
FROM node:18-alpine as frontend-builder
WORKDIR /app/web
# Copy package files for dependency caching
COPY web/package.json web/package-lock.json ./
# Install dependencies
RUN npm ci
# Copy source files
COPY web/src ./src
COPY web/public ./public
COPY web/index.html ./
COPY web/vite.config.ts ./
COPY web/tsconfig.json ./
COPY web/tsconfig.node.json ./
# Build the frontend
RUN npm run build
# Stage 3: Runtime image
FROM debian:bookworm-slim
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl3 \
&& rm -rf /var/lib/apt/lists/*
# Create app user
RUN useradd -r -s /bin/false appuser
WORKDIR /app
# Copy the built Rust binary
COPY --from=rust-builder /app/target/release/api_server ./api_server
# Copy configuration files
COPY --from=rust-builder /app/config ./config
# Copy the built frontend
COPY --from=frontend-builder /app/web/dist ./web/dist
# Create necessary directories
RUN mkdir -p logs && \
chown -R appuser:appuser /app
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/api/configs || exit 1
# Start the application
CMD ["./api_server"]

160
INTERFACES.md Normal file
View File

@ -0,0 +1,160 @@
# Orbital Simulator Interfaces
This project now includes multiple ways to interact with the orbital simulator:
## 1. Web Interface
A browser-based interface with real-time 3D visualization.
### Features:
- Real-time 3D orbital visualization using Three.js
- Interactive controls (start, pause, stop, step)
- Multiple simulation management
- Custom configuration editor
- Live statistics and energy monitoring
- Responsive design
### Usage:
```bash
# Start the API server
cargo run --release --bin api_server
# In another terminal, start the web frontend
cd web
npm install
npm run dev
# Open http://localhost:5173 in your browser
```
## 2. Desktop GUI
A native desktop application using Tauri (Rust + Web technologies).
### Features:
- All web interface features in a native app
- File system integration
- Native notifications
- System tray integration
- Better performance than browser
### Usage:
```bash
# Install Tauri CLI
cargo install tauri-cli
# Start development mode
cargo tauri dev
# Build for production
cargo tauri build
```
## 3. Command Line Interface (Original)
The original command-line tools are still available:
```bash
# Run simulation
cargo run --release --bin simulator -- \
--config config/planets.toml \
--time 365d \
--step-size 3600 \
--output-file trajectory.bin
# Visualize results
python3 plot_trajectories.py trajectory.bin --animate
# 3D real-time visualizer
cargo run --release --bin orbiter -- trajectory.bin
```
## API Endpoints
The web API server provides RESTful endpoints:
- `POST /api/simulations` - Create new simulation
- `GET /api/simulations` - List all simulations
- `GET /api/simulations/:id` - Get simulation state
- `POST /api/simulations/:id/control` - Control simulation (start/pause/stop)
- `DELETE /api/simulations/:id` - Delete simulation
- `GET /api/configs` - List available configurations
## Installation
### Prerequisites:
- Rust (latest stable)
- Node.js 18+ (for web interface)
- Python 3.7+ (for visualization)
### Full Installation:
```bash
# Clone and build
git clone <repository-url>
cd orbital_simulator
# Install Rust dependencies
cargo build --release
# Install Node.js dependencies
cd web
npm install
cd ..
# Install Python dependencies
pip install -r requirements.txt
# Install Tauri CLI (optional, for desktop GUI)
cargo install tauri-cli
```
### Quick Start:
```bash
# Start everything with one command
./start_interfaces.sh
```
This will launch:
- API server on port 3000
- Web interface on port 5173
- Desktop GUI (if Tauri is installed)
## Development
### Architecture:
```
┌─────────────────┐ ┌─────────────────┐
│ Desktop GUI │ │ Web Interface │
│ (Tauri) │ │ (React) │
└─────────┬───────┘ └─────────┬───────┘
│ │
└──────────┬───────────┘
┌─────────────────┐
│ API Server │
│ (Axum) │
└─────────┬───────┘
┌─────────────────┐
│ Rust Simulator │
│ (Core Logic) │
└─────────────────┘
```
### Adding Features:
1. **Backend**: Add endpoints in `src/bin/api_server.rs`
2. **Web**: Add components in `web/src/components/`
3. **Desktop**: Add commands in `src-tauri/src/main.rs`
### Testing:
```bash
# Test Rust code
cargo test
# Test web interface
cd web
npm test
# Test API endpoints
curl http://localhost:3000/api/simulations
```

88
PLOTTING.md Normal file
View File

@ -0,0 +1,88 @@
# Trajectory Plotting
This Python script can read and visualize the binary trajectory files generated by the orbital simulator.
## Installation
First, install the required Python dependencies:
```bash
pip install -r requirements.txt
```
## Usage
### Basic Usage
```bash
# Plot trajectories from a simulation output file
python plot_trajectories.py trajectory_output.bin
```
### Options
- `--2d-only`: Only show 2D plot (X-Y plane)
- `--3d-only`: Only show 3D plot
- `--energy`: Also show energy conservation plot
- `--animate`: Show animated trajectories with real-time simulation
- `--save-animation <filename>`: Save animation as MP4 file (requires ffmpeg)
- `--static`: Show static plots (default behavior)
- `--interval <ms>`: Animation frame interval in milliseconds (default: auto-scaled)
- `--target-duration <seconds>`: Target animation duration (default: 60)
- `--center <body_name>`: Center animation/plot on specified body (e.g., "Sun", "Earth")
- `--list-bodies`: List available bodies in trajectory file and exit
### Examples
```bash
# Run a simulation and plot the results
cargo run --bin simulator -- --config config/planets.json --time 365d --step-size 3600 --output-file solar_system.bin
python plot_trajectories.py solar_system.bin
# List available bodies in trajectory file
python plot_trajectories.py solar_system.bin --list-bodies
# Show animated trajectories centered on the Sun
python plot_trajectories.py solar_system.bin --animate --center Sun
# Show Earth-centered view (great for seeing Moon's orbit)
python plot_trajectories.py solar_system.bin --animate --center Earth --2d-only
# Show only 2D animation with custom speed
python plot_trajectories.py solar_system.bin --animate --2d-only --interval 100
# Save animation as MP4 file (Sun-centered)
python plot_trajectories.py solar_system.bin --animate --center Sun --save-animation solar_system_animation
# Show 3D trajectories with energy plot (static, Earth-centered)
python plot_trajectories.py solar_system.bin --3d-only --energy --static --center Earth
```
## Output
The script can display:
### Static Plots
- **2D Plot**: Trajectories in the X-Y plane with starting positions (circles) and ending positions (squares)
- **3D Plot**: Full 3D orbital trajectories
- **Energy Plot** (optional): Shows kinetic and total energy over time to verify energy conservation
### Animated Plots
- **2D Animation**: Real-time orbital motion in the X-Y plane with time display
- **3D Animation**: Full 3D animated orbital trajectories
- **Time Display**: Shows current simulation time in seconds/hours/days
- **Trails**: Recent positions shown as fading trails behind each body
- **MP4 Export**: Save animations as video files (requires ffmpeg)
Each celestial body is plotted in a different color with its name in the legend.
## File Format
The binary trajectory file contains serialized snapshots with:
- Timestamp (f64)
- Array of bodies, each containing:
- Name (String)
- Mass (f64)
- Position (3x f64)
- Velocity (3x f64)
- Acceleration (3x f64)

268
README.md
View File

@ -1,128 +1,214 @@
# Orbital Simulator
A fast N-body orbital mechanics simulator written in Rust.
Simulate the motion of celestial bodies under Newtonian gravity, with easy configuration and efficient output for visualization.
A comprehensive N-body orbital mechanics simulator with multiple interfaces: **Web Browser**, **Desktop GUI**, **CLI**, and **Python Tools**. Built in Rust for performance with React/Three.js for visualization.
---
## 🚀 Features
## Features
### Core Simulation
- High-performance N-body gravitational simulation
- Normalized units for numerical stability
- Real-time and batch processing modes
- Energy conservation monitoring
- Configurable time steps and integration methods
- **N-body simulation**
- **Configurable initial conditions** via JSON
- **Binary trajectory files**
- **Progress bar**
- **Unit normalization**
- **Ready for visualization** (Coming Soon)
### Multiple Interfaces
- **🌐 Web Interface**: Browser-based with 3D visualization
- **🖥️ Desktop GUI**: Native application with Tauri
- **⚡ CLI Tools**: Command-line batch processing
- **📊 Python Tools**: Scientific plotting and analysis
- **🔌 REST API**: Programmatic access and integration
---
### Visualization
- Real-time 3D orbital mechanics
- Interactive camera controls
- Particle trails and body labels
- Energy plots and statistics
- Animation export capabilities
## Getting Started
## 📦 Installation
### Prerequisites
- Rust (2021 edition or later)
- Node.js 18+ and npm
- Python 3.7+ (for analysis tools)
- Git
- [Rust](https://www.rust-lang.org/tools/install) (edition 2021 or later)
- [Bevy dependencies](https://bevyengine.org/learn/book/getting-started/setup/) (for 3D visualization; see Bevy's docs for Linux requirements)
### Build
### Quick Setup
```bash
cargo build --release
git clone <repository-url>
cd orbital_simulator
chmod +x start_interfaces.sh test_interfaces.sh
./test_interfaces.sh # Verify everything works
./start_interfaces.sh # Start all interfaces
```
---
### Manual Installation
```bash
# Install Rust dependencies
cargo build --release --no-default-features
## Running the Simulator (CLI)
# Install web dependencies
cd web && npm install && cd ..
# Install Python dependencies
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Optional: Install Tauri for desktop GUI
cargo install tauri-cli
```
## 🎯 Quick Start
### Web Interface (Recommended)
```bash
./start_interfaces.sh
# Open http://localhost:5173 in your browser
```
### CLI Simulation
```bash
cargo run --release --bin simulator -- \
--config path/to/your_config.json \
--time 30d \
--step-size 10.0 \
--output-file trajectory.bin
--config config/inner_solar_system.toml \
--time 365d \
--step-size 3600 \
--output-file solar_system.bin
python3 plot_trajectories.py solar_system.bin --animate
```
**Arguments:**
- `--config` (required): Path to your JSON config file with initial body states.
- `--time` (required): Total simulation time (e.g. `10s`, `5m`, `2h`, `100d`).
- `--step-size`: Simulation step size in seconds (default: `10.0`).
- `--output-file` (required): Where to save the trajectory data.
- `--steps-per-save`: How often to update the progress bar and save (default: `1000`).
---
## Running the 3D Visualizer (`orbiter`)
```bash
cargo run --release --bin orbiter
```
- Opens a 3D window with a camera and a blue sphere (placeholder for future simulation data).
- **Camera controls:**
- **Right mouse drag:** Orbit around the origin
- **Scroll wheel:** Zoom in/out
Future updates will allow loading and animating simulation output.
---
## Configuration
The config file is a JSON file describing the initial state of each body.
Examples provided in `config/`
Configuration files define the initial state of your celestial bodies:
```json
{
"bodies": [
{
"name": "BodyName",
"mass": 1e10, //kg
"position": [0.0, 0.0, 0.0], //meters
"velocity": [0.0, 0.0, 0.0] // m/s
},
...
]
}
```toml
[[bodies]]
name = "Sun"
mass = 1.989e30
position = [0.0, 0.0, 0.0]
velocity = [0.0, 0.0, 0.0]
[[bodies]]
name = "Earth"
mass = 5.972e24
position = [1.496e11, 0.0, 0.0] # 1 AU from Sun
velocity = [0.0, 29789.0, 0.0] # Orbital velocity
# Optionally specify custom units
[normalization]
m_0 = 5.972e24 # Earth mass
r_0 = 6.378e6 # Earth radius
t_0 = 5023.0 # Time unit
```
- **Units:**
- Mass: kilograms (kg)
- Position: meters (m)
- Velocity: meters per second (m/s)
Several configurations are included:
- `planets.toml` - Complete solar system (16 bodies)
- `solar_system.toml` - Major planets only (9 bodies)
- `inner_solar_system.toml` - Inner planets + Moon (6 bodies)
- `earthsun_corrected.toml` - Simple Earth-Sun system (2 bodies)
---
## Usage
## Output
### Running Simulations
- The simulator writes binary snapshots of the system state to the output file using [bincode](https://docs.rs/bincode/).
- Each snapshot contains the simulation time and the real (de-normalized) positions, velocities, and masses of all bodies.
```bash
cargo run --bin simulator -- [OPTIONS]
```
---
Key options:
- `-c, --config <FILE>` - Configuration file
- `-t, --time <DURATION>` - How long to simulate (e.g., 10s, 5m, 2h, 100d)
- `-s, --step-size <SECONDS>` - Integration step size (default: 10.0)
- `-o, --output-file <FILE>` - Where to save trajectory data
- `-w, --force-overwrite` - Skip confirmation when overwriting files
## Extending
### Visualization
- Add more bodies or change initial conditions in the config file.
- Adjust step size and simulation time for accuracy/performance trade-offs.
- The code is modular and ready for extension (e.g., new force laws, output formats, or integrators).
```bash
python3 plot_trajectories.py [OPTIONS] <trajectory_file>
```
---
Useful options:
- `--animate` - Show animated trajectories instead of static plots
- `--center <BODY>` - Center the view on a specific body
- `--save-animation <PREFIX>` - Export animation as MP4 video
- `--energy` - Include energy conservation plots
- `--list-bodies` - Show what bodies are in the trajectory file
- `--2d-only` or `--3d-only` - Limit to 2D or 3D plots
### Examples
```bash
# See what bodies are available
python3 plot_trajectories.py trajectory.bin --list-bodies
# Animate from different perspectives
python3 plot_trajectories.py trajectory.bin --animate --center Sun
python3 plot_trajectories.py trajectory.bin --animate --center Jupiter
# Create a video
python3 plot_trajectories.py trajectory.bin --animate --save-animation solar_system
# Check energy conservation
python3 plot_trajectories.py trajectory.bin --energy
```
## How It Works
The simulator uses Newtonian gravity (F = G·m₁·m₂/r²) with explicit Euler integration. All bodies interact gravitationally with each other. The system normalizes units to Earth-based scales by default but you can specify custom normalization constants.
Animations automatically scale to about 60 seconds and show the time compression ratio (like "3.6 hours of simulation per second"). You can center the view on any body to see orbital mechanics from different reference frames.
The simulator includes safety features like confirmation prompts before overwriting files, and exports data in an efficient binary format.
## Project Structure
```
src/
├── bin/
│ ├── simulator.rs # Main simulation program
│ └── orbiter.rs # 3D visualizer
├── config.rs # Configuration loading
├── simulation.rs # Physics simulation
├── types.rs # Data types and units
└── lib.rs # Library interface
config/ # Pre-made configurations
plot_trajectories.py # Visualization script
inspect_trajectories.py # Data inspection tool
```
## License
MIT License
---
## Acknowledgments
- [Rust](https://www.rust-lang.org/)
- [Bevy](https://bevyengine.org/) for 3D visualization
- [glam](https://crates.io/crates/glam) for fast vector math
- [clap](https://crates.io/crates/clap) for CLI parsing
- [indicatif](https://crates.io/crates/indicatif) for progress bars
- [serde](https://crates.io/crates/serde) and [bincode](https://crates.io/crates/bincode) for serialization
---
MIT License - see source for details.
## Author
- Thomas Faour
Thomas Faour
## 🐳 Docker Deployment
The easiest way to deploy the Orbital Simulator is using Docker:
### Quick Start with Docker
```bash
# Clone the repository
git clone <repository-url>
cd orbital_simulator
# Start with Docker Compose
docker-compose up --build
# Access the application at http://localhost:3000
```
### Production Deployment
```bash
# Start with Nginx reverse proxy
docker-compose --profile production up --build -d
# Access at http://localhost (port 80)
```
See [DOCKER.md](DOCKER.md) for detailed deployment documentation.

223
STATUS.md Normal file
View File

@ -0,0 +1,223 @@
# 🚀 Orbital Simulator - Implementation Complete!
## ✅ Project Status: **COMPLETE**
The Orbital Simulator has been successfully expanded from a CLI-only tool to a comprehensive multi-interface application with real-time visualization capabilities.
## 🎯 Completed Features
### ✅ Core Simulation Engine
- High-performance N-body gravitational simulation in Rust
- Normalized units for numerical stability
- Real-time physics loop with configurable time steps
- Energy conservation monitoring
- Thread-safe simulation session management
### ✅ Web Interface (React + Three.js)
- **Frontend**: Modern React application with TypeScript
- **3D Visualization**: Real-time orbital mechanics using Three.js
- **Interactive Controls**: Play/pause, step, stop simulations
- **Configuration Panel**: Create simulations with preset or custom configs
- **Real-time Updates**: WebSocket-like polling for live data
- **Visual Features**: Particle trails, body labels, orbital paths
### ✅ REST API Server (Axum)
- **Full CRUD**: Create, read, update, delete simulations
- **Real-time Control**: Start, pause, stop, step operations
- **Session Management**: Multiple concurrent simulations
- **Static Serving**: Integrated web frontend serving
- **CORS Support**: Cross-origin resource sharing enabled
### ✅ Desktop GUI Ready (Tauri)
- **Native App**: Tauri-based desktop application framework
- **System Integration**: Native OS integration capabilities
- **Optional Install**: Feature-gated for flexible deployment
### ✅ CLI Tools (Maintained)
- **Batch Processing**: Original high-performance simulator
- **Configuration**: TOML/JSON config file support
- **Output Formats**: Binary trajectory files for analysis
### ✅ Python Analysis Tools (Enhanced)
- **Plotting**: 2D/3D trajectory visualization
- **Animation**: MP4 video export capabilities
- **Analysis**: Energy conservation and orbital mechanics analysis
## 🏗️ Architecture
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Web Browser │ │ Desktop GUI │ │ CLI Tools │
│ (React/Three) │ │ (Tauri) │ │ (Rust Bins) │
└─────────┬───────┘ └────────┬─────────┘ └─────────────────┘
│ │
│ HTTP/JSON │ Direct API
│ │
┌────▼─────────────────────▼────┐
│ Rust API Server │
│ (Axum) │
│ ┌─────────────────────────┐ │
│ │ Simulation Engine │ │
│ │ (N-body Physics) │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘
┌───────▼────────┐
│ Python Tools │
│ (matplotlib) │
└────────────────┘
```
## 🚦 Getting Started
### Quick Start (All Interfaces)
```bash
git clone <repository>
cd orbital_simulator
./start_interfaces.sh
# Open http://localhost:5173 in browser
```
### Individual Components
```bash
# API Server only
cargo run --bin api_server --no-default-features
# Web development server
cd web && npm run dev
# CLI simulation
cargo run --bin simulator -- --config config/planets.toml --time 365d --step-size 3600 --output-file output.bin
# Python analysis
python3 plot_trajectories.py output.bin --animate
```
## 📁 Project Structure
```
orbital_simulator/
├── src/
│ ├── bin/
│ │ ├── api_server.rs # REST API server
│ │ ├── simulator.rs # CLI batch simulator
│ │ └── orbiter.rs # 3D visualizer (Bevy - commented out)
│ ├── config.rs # Configuration structures
│ ├── simulation.rs # Core physics engine
│ ├── types.rs # Type definitions and utilities
│ └── lib.rs # Library exports
├── web/ # React web frontend
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── App.tsx # Main application
│ │ └── main.tsx # Entry point
│ ├── package.json # Node.js dependencies
│ └── vite.config.ts # Build configuration
├── src-tauri/ # Desktop GUI (Tauri)
│ ├── src/main.rs # Tauri entry point
│ ├── Cargo.toml # Tauri dependencies
│ └── tauri.conf.json # Tauri configuration
├── config/ # Simulation configurations
│ └── planets.toml # Sample planetary system
├── *.py # Python analysis tools
├── requirements.txt # Python dependencies
├── INTERFACES.md # Interface documentation
├── DEPLOYMENT.md # Production deployment guide
├── start_interfaces.sh # Multi-interface launcher
└── test_interfaces.sh # Comprehensive test suite
```
## 🧪 Quality Assurance
### ✅ Automated Testing
- **16 Test Cases**: Comprehensive functionality verification
- **Build Tests**: All binaries compile successfully
- **Integration Tests**: API endpoints and data flow
- **Dependency Tests**: All package managers (cargo, npm, pip)
### ✅ Cross-Platform Support
- **Linux**: Fully tested and working
- **Windows**: Should work (Rust/Node.js/Python compatible)
- **macOS**: Should work (Rust/Node.js/Python compatible)
### ✅ Performance Optimized
- **Release Builds**: Optimized compilation for production
- **Memory Efficient**: Thread-safe simulation management
- **Scalable**: Multiple concurrent simulations supported
- **Real-time**: 30 FPS visualization updates
## 🚀 Deployment Options
### Development
```bash
./start_interfaces.sh # All interfaces with hot reload
```
### Production
```bash
# Build optimized binaries
cargo build --release --no-default-features
cd web && npm run build
# Deploy single-server with static files
./target/release/api_server
```
### Docker
```bash
docker build -t orbital-simulator .
docker run -p 3000:3000 orbital-simulator
```
See `DEPLOYMENT.md` for complete production setup instructions.
## 📊 Interface Comparison
| Feature | Web Interface | Desktop GUI | CLI Tools | Python Tools |
|---------|---------------|-------------|-----------|--------------|
| Real-time 3D | ✅ | ✅ | ❌ | ❌ |
| Interactive Controls | ✅ | ✅ | ❌ | ❌ |
| Batch Processing | ❌ | ❌ | ✅ | ✅ |
| Cross-Platform | ✅ | ✅ | ✅ | ✅ |
| Installation | Easy | Medium | Easy | Easy |
| Performance | High | Highest | Highest | Medium |
| Visualization | Excellent | Excellent | None | Excellent |
## 🎉 Success Metrics
- **✅ All original CLI functionality preserved**
- **✅ Real-time web visualization implemented**
- **✅ Multiple interface options available**
- **✅ Production-ready deployment guides**
- **✅ Comprehensive test coverage**
- **✅ Modern, maintainable codebase**
- **✅ Cross-platform compatibility**
- **✅ Performance optimized**
## 🔧 Maintenance
The project is designed for long-term maintainability:
- **Modular Architecture**: Clean separation of concerns
- **Type Safety**: Rust's type system prevents many bugs
- **Automated Testing**: Catches regressions early
- **Documentation**: Comprehensive guides and code comments
- **Standard Tools**: Uses well-supported frameworks and libraries
## 🚀 Next Steps (Optional Enhancements)
The core requirements are complete, but potential future enhancements include:
1. **User Authentication**: Multi-user support with saved simulations
2. **Persistent Storage**: Database for simulation history
3. **Advanced Physics**: Relativistic effects, radiation pressure
4. **Cloud Deployment**: Kubernetes, AWS/GCP deployments
5. **WebRTC**: True real-time streaming instead of polling
6. **Mobile App**: React Native or Flutter mobile client
7. **VR/AR Support**: Immersive 3D visualization
---
**Status**: ✅ **COMPLETE AND READY FOR USE**
All requirements have been successfully implemented and tested. The orbital simulator now provides a comprehensive suite of interfaces for different use cases while maintaining the high-performance core simulation engine.

4
build.rs Normal file
View File

@ -0,0 +1,4 @@
fn main() {
#[cfg(feature = "gui")]
tauri_build::build();
}

View File

@ -0,0 +1,11 @@
[[bodies]]
name = "Sun"
mass = 1.989e30
position = [0.0, 0.0, 0.0]
velocity = [0.0, 0.0, 0.0]
[[bodies]]
name = "Earth"
mass = 5.972e24
position = [1.496e11, 0.0, 0.0] # 1 AU
velocity = [0.0, 29789.0, 0.0] # Precise orbital velocity for circular orbit

View File

@ -0,0 +1,35 @@
[[bodies]]
name = "Sun"
mass = 1.989e30
position = [0.0, 0.0, 0.0]
velocity = [0.0, 0.0, 0.0]
[[bodies]]
name = "Mercury"
mass = 3.30104e23
position = [4.6000e10, 0.0, 0.0] # 0.307 AU
velocity = [0.0, 58970.0, 0.0] # m/s
[[bodies]]
name = "Venus"
mass = 4.8675e24
position = [1.08939e11, 0.0, 0.0] # 0.728 AU
velocity = [0.0, 34780.0, 0.0] # m/s
[[bodies]]
name = "Earth"
mass = 5.972e24
position = [1.496e11, 0.0, 0.0] # 1.000 AU
velocity = [0.0, 29789.0, 0.0] # m/s
[[bodies]]
name = "Moon"
mass = 7.34767309e22
position = [1.49984e11, 0.0, 0.0] # Earth + 384,400 km
velocity = [0.0, 30813.0, 0.0] # Earth velocity + moon orbital velocity
[[bodies]]
name = "Mars"
mass = 6.4171e23
position = [2.279e11, 0.0, 0.0] # 1.524 AU
velocity = [0.0, 24007.0, 0.0] # m/s

View File

@ -1,22 +0,0 @@
{
"bodies": [
{
"name": "Earth",
"mass": 5.972e24,
"position": [1.47095e11, 0, 0],
"velocity": [0, 29290, 0]
},
{
"name": "Sun",
"mass": 1.989e30,
"position": [0, 0, 0],
"velocity": [0, 0, 0]
},
{
"name": "JWST",
"mass": 6500,
"position": [149217067274.40607, 0, 0],
"velocity": [0, 29729.784, 0]
}
]
}

View File

@ -1,34 +0,0 @@
{
"bodies": [
{
"name": "Mercury",
"mass": 3.30104e23,
"position": [4.6000e10, 0, 0],
"velocity": [0, 58970, 0]
},
{
"name": "Venus",
"mass": 4.867e24,
"position": [1.08941e11, 0, 0],
"velocity": [0, 34780, 0]
},
{
"name": "Earth",
"mass": 5.972e24,
"position": [1.47095e11, 0, 0],
"velocity": [0, 29290, 0]
},
{
"name": "Moon",
"mass": 7.34767309e22,
"position": [149982270700, 0, 0],
"velocity": [0, 30822, 0]
},
{
"name": "Sun",
"mass": 1.989e30,
"position": [0, 0, 0],
"velocity": [0, 0, 0]
}
]
}

View File

@ -1,9 +1,16 @@
# Simulation body configuration
[[bodies]]
name = "Sun"
mass = 1.989e30
position = [0.0, 0.0, 0.0]
velocity = [0.0, 0.0, 0.0]
[[bodies]]
name = "Mercury"
mass = 3.30104e23 # kg
position = [46000000000.0, 0.0, 0.0] # meters
velocity = [0.0, 58970.0, 0.0] # m/s
mass = 3.30104e23
position = [4.6000e10, 0.0, 0.0] # 0.307 AU
velocity = [0.0, 58970.0, 0.0] # m/s
[[bodies]]
name = "Venus"
@ -15,16 +22,80 @@ velocity = [0.0, 34780.0, 0.0]
name = "Earth"
mass = 5.972e24
position = [147095000000.0, 0.0, 0.0]
velocity = [0.0, 29290.0, 0.0]
velocity = [0.0, 30290.0, 0.0]
[[bodies]]
name = "Moon"
mass = 7.34767309e22
position = [149982270700.0, 0.0, 0.0]
velocity = [0.0, 30822.0, 0.0]
position = [147458300000, 0.0, 0.0] # Earth + 384,400 km
velocity = [0.0, 31372.0, 0.0] # Earth velocity + moon orbital velocity
[[bodies]]
name = "Sun"
mass = 1.989e30
position = [0.0, 0.0, 0.0]
velocity = [0.0, 0.0, 0.0]
name = "Mars"
mass = 6.4171e23
position = [2.279e11, 0.0, 0.0] # 1.524 AU
velocity = [0.0, 24007.0, 0.0] # m/s
[[bodies]]
name = "Jupiter"
mass = 1.8982e27
position = [7.786e11, 0.0, 0.0] # 5.204 AU
velocity = [0.0, 13060.0, 0.0] # m/s
[[bodies]]
name = "Saturn"
mass = 5.6834e26
position = [1.432e12, 0.0, 0.0] # 9.573 AU
velocity = [0.0, 9640.0, 0.0] # m/s
[[bodies]]
name = "Uranus"
mass = 8.6810e25
position = [2.867e12, 0.0, 0.0] # 19.165 AU
velocity = [0.0, 6810.0, 0.0] # m/s
[[bodies]]
name = "Neptune"
mass = 1.02413e26
position = [4.515e12, 0.0, 0.0] # 30.178 AU
velocity = [0.0, 5430.0, 0.0] # m/s
# Dwarf planets and interesting objects
[[bodies]]
name = "Pluto"
mass = 1.303e22
position = [5.906e12, 0.0, 0.0] # 39.482 AU (average)
velocity = [0.0, 4670.0, 0.0] # m/s
[[bodies]]
name = "Ceres"
mass = 9.393e20
position = [4.14e11, 0.0, 0.0] # 2.766 AU (asteroid belt)
velocity = [0.0, 17880.0, 0.0] # m/s
# Some major moons for more interesting dynamics
[[bodies]]
name = "Io"
mass = 8.932e22
position = [7.790e11, 0.0, 0.0] # Jupiter + 421,700 km
velocity = [0.0, 30350.0, 0.0] # Jupiter velocity + Io orbital velocity
[[bodies]]
name = "Europa"
mass = 4.800e22
position = [7.793e11, 0.0, 0.0] # Jupiter + 671,034 km
velocity = [0.0, 26890.0, 0.0] # Jupiter velocity + Europa orbital velocity
[[bodies]]
name = "Ganymede"
mass = 1.482e23
position = [7.796e11, 0.0, 0.0] # Jupiter + 1,070,412 km
velocity = [0.0, 23250.0, 0.0] # Jupiter velocity + Ganymede orbital velocity
[[bodies]]
name = "Titan"
mass = 1.345e23
position = [1.433e12, 0.0, 0.0] # Saturn + 1,221,830 km
velocity = [0.0, 15100.0, 0.0] # Saturn velocity + Titan orbital velocity

View File

@ -1,16 +0,0 @@
{
"planets": [
{
"name": "Earth",
"mass": 7.342e24,
"position": [0, 0, 0],
"velocity": [0, -1022, 0]
},
{
"name": "Moon",
"mass": 7.34767309e24,
"position": [384400000, 0, 0],
"velocity": [0, 1022, 0]
}
]
}

View File

View File

53
config/solar_system.toml Normal file
View File

@ -0,0 +1,53 @@
[[bodies]]
name = "Sun"
mass = 1.989e30
position = [0.0, 0.0, 0.0]
velocity = [0.0, 0.0, 0.0]
[[bodies]]
name = "Mercury"
mass = 3.30104e23
position = [4.6000e10, 0.0, 0.0] # 0.307 AU
velocity = [0.0, 58970.0, 0.0] # m/s
[[bodies]]
name = "Venus"
mass = 4.8675e24
position = [1.08939e11, 0.0, 0.0] # 0.728 AU
velocity = [0.0, 34780.0, 0.0] # m/s
[[bodies]]
name = "Earth"
mass = 5.972e24
position = [1.496e11, 0.0, 0.0] # 1.000 AU
velocity = [0.0, 29789.0, 0.0] # m/s
[[bodies]]
name = "Mars"
mass = 6.4171e23
position = [2.279e11, 0.0, 0.0] # 1.524 AU
velocity = [0.0, 24007.0, 0.0] # m/s
[[bodies]]
name = "Jupiter"
mass = 1.8982e27
position = [7.786e11, 0.0, 0.0] # 5.204 AU
velocity = [0.0, 13060.0, 0.0] # m/s
[[bodies]]
name = "Saturn"
mass = 5.6834e26
position = [1.432e12, 0.0, 0.0] # 9.573 AU
velocity = [0.0, 9640.0, 0.0] # m/s
[[bodies]]
name = "Uranus"
mass = 8.6810e25
position = [2.867e12, 0.0, 0.0] # 19.165 AU
velocity = [0.0, 6810.0, 0.0] # m/s
[[bodies]]
name = "Neptune"
mass = 1.02413e26
position = [4.515e12, 0.0, 0.0] # 30.178 AU
velocity = [0.0, 5430.0, 0.0] # m/s

156
deploy.sh Normal file
View File

@ -0,0 +1,156 @@
#!/bin/bash
# Orbital Simulator Docker Deployment Script
set -e
echo "🚀 Orbital Simulator Docker Deployment"
echo "======================================"
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo "❌ Docker is not installed. Please install Docker first."
exit 1
fi
# Check if Docker Compose is available
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
echo "❌ Docker Compose is not installed. Please install Docker Compose first."
exit 1
fi
# Function to use docker-compose or docker compose
docker_compose() {
if command -v docker-compose &> /dev/null; then
docker-compose "$@"
else
docker compose "$@"
fi
}
# Parse command line arguments
MODE="development"
ACTION="up"
BUILD="--build"
while [[ $# -gt 0 ]]; do
case $1 in
--production)
MODE="production"
shift
;;
--dev|--development)
MODE="development"
shift
;;
--down)
ACTION="down"
BUILD=""
shift
;;
--logs)
ACTION="logs"
BUILD=""
shift
;;
--no-build)
BUILD=""
shift
;;
--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --production Deploy in production mode with Nginx"
echo " --dev Deploy in development mode (default)"
echo " --down Stop and remove containers"
echo " --logs Show container logs"
echo " --no-build Skip building images"
echo " --help Show this help message"
echo ""
echo "Examples:"
echo " $0 # Start in development mode"
echo " $0 --production # Start in production mode"
echo " $0 --down # Stop all containers"
echo " $0 --logs # View logs"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
echo "Mode: $MODE"
echo "Action: $ACTION"
# Handle different actions
case $ACTION in
"up")
echo "🔧 Starting Orbital Simulator..."
if [ "$MODE" = "production" ]; then
echo "🌐 Production mode: Starting with Nginx reverse proxy"
docker_compose --profile production $ACTION $BUILD -d
echo ""
echo "✅ Orbital Simulator is running in production mode!"
echo "🌐 Web Interface: http://localhost"
echo "📊 Direct API: http://localhost:3000"
else
echo "🛠️ Development mode: Starting without reverse proxy"
docker_compose $ACTION $BUILD
echo ""
echo "✅ Orbital Simulator is running in development mode!"
echo "🌐 Web Interface: http://localhost:3000"
fi
echo ""
echo "📋 Useful commands:"
echo " View logs: $0 --logs"
echo " Stop: $0 --down"
echo " Health check: docker ps"
;;
"down")
echo "🛑 Stopping Orbital Simulator..."
if [ "$MODE" = "production" ]; then
docker_compose --profile production down
else
docker_compose down
fi
echo "✅ All containers stopped and removed."
;;
"logs")
echo "📄 Showing container logs..."
if [ "$MODE" = "production" ]; then
docker_compose --profile production logs -f
else
docker_compose logs -f
fi
;;
esac
# Health check function
check_health() {
echo "🏥 Checking health..."
# Wait a moment for containers to start
sleep 5
# Check if API is responding
if curl -f http://localhost:3000/api/configs &> /dev/null; then
echo "✅ API is healthy"
else
echo "⚠️ API health check failed"
echo "🔍 Try: docker ps"
echo "🔍 Try: $0 --logs"
fi
}
# Only run health check for 'up' action
if [ "$ACTION" = "up" ]; then
check_health
fi

42
docker-compose.yml Normal file
View File

@ -0,0 +1,42 @@
version: '3.8'
services:
orbital-simulator:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- RUST_LOG=info
- BIND_ADDRESS=0.0.0.0:3000
volumes:
# Optional: Mount config directory for easy config updates
- ./config:/app/config:ro
# Optional: Mount logs directory
- orbital_logs:/app/logs
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/configs || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Optional: Add a reverse proxy for production
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- orbital-simulator
restart: unless-stopped
profiles:
- production
volumes:
orbital_logs:

347
inspect_trajectories.py Executable file
View File

@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""
Inspect orbital trajectory data from binary files in a tabular format.
This script reads the binary trajectory files generated by the orbital simulator
and displays the data in a nicely formatted table for inspection and debugging.
Usage:
python inspect_trajectories.py <trajectory_file.bin>
"""
import sys
import struct
import argparse
import numpy as np
from collections import defaultdict
class BinaryReader:
def __init__(self, data):
self.data = data
self.pos = 0
def read_u64(self):
result = struct.unpack('<Q', self.data[self.pos:self.pos+8])[0]
self.pos += 8
return result
def read_f64(self):
result = struct.unpack('<d', self.data[self.pos:self.pos+8])[0]
self.pos += 8
return result
def read_string(self):
# Read length (u64) then string bytes
length = self.read_u64()
result = self.data[self.pos:self.pos+length].decode('utf-8')
self.pos += length
return result
def read_vec3(self):
# Read 3 f64 values for position/velocity/acceleration
x = self.read_f64()
y = self.read_f64()
z = self.read_f64()
return np.array([x, y, z])
def read_trajectory_file(filename):
"""Read the binary trajectory file and return parsed data."""
with open(filename, 'rb') as f:
data = f.read()
reader = BinaryReader(data)
snapshots = []
try:
while reader.pos < len(data):
# Read snapshot
time = reader.read_f64()
# Read number of bodies (u64)
num_bodies = reader.read_u64()
bodies = []
for _ in range(num_bodies):
# Read Body struct
name = reader.read_string()
mass = reader.read_f64()
position = reader.read_vec3()
velocity = reader.read_vec3()
acceleration = reader.read_vec3()
bodies.append({
'name': name,
'mass': mass,
'position': position,
'velocity': velocity,
'acceleration': acceleration
})
snapshots.append({
'time': time,
'bodies': bodies
})
except struct.error:
# End of file or corrupted data
pass
return snapshots
def format_scientific(value, precision=3):
"""Format number in scientific notation with specified precision."""
if abs(value) < 1e-6 or abs(value) >= 1e6:
return f"{value:.{precision}e}"
else:
return f"{value:.{precision}f}"
def format_vector(vec, precision=3):
"""Format a 3D vector in a compact form."""
x, y, z = vec
return f"({format_scientific(x, precision)}, {format_scientific(y, precision)}, {format_scientific(z, precision)})"
def format_time(seconds):
"""Format time in a human-readable format."""
if seconds == 0:
return "0.0s"
elif seconds < 60:
return f"{seconds:.1f}s"
elif seconds < 3600:
return f"{seconds/60:.1f}m"
elif seconds < 86400:
return f"{seconds/3600:.1f}h"
else:
return f"{seconds/86400:.1f}d"
def print_summary_table(snapshots, max_rows=15):
"""Print a summary table of all snapshots."""
print("📊 TRAJECTORY SUMMARY")
print("=" * 100)
# Header
print(f"{'Step':<6} {'Time':<12} {'Bodies':<8} {'Sample Position (first body)':<35} {'Sample Velocity (first body)':<35}")
print("-" * 100)
# Determine which snapshots to show
total_snapshots = len(snapshots)
if total_snapshots <= max_rows:
indices = list(range(total_snapshots))
else:
# Show first few, middle few, and last few
start_count = max_rows // 3
end_count = max_rows // 3
middle_count = max_rows - start_count - end_count
indices = []
indices.extend(range(start_count))
if middle_count > 0:
middle_start = total_snapshots // 2 - middle_count // 2
indices.extend(range(middle_start, middle_start + middle_count))
indices.extend(range(total_snapshots - end_count, total_snapshots))
indices = sorted(set(indices)) # Remove duplicates and sort
prev_index = -1
for i, idx in enumerate(indices):
if idx > prev_index + 1 and prev_index >= 0:
print(" ...")
snapshot = snapshots[idx]
time_str = format_time(snapshot['time'])
body_count = len(snapshot['bodies'])
if snapshot['bodies']:
first_body = snapshot['bodies'][0]
pos_str = format_vector(first_body['position'], 2)
vel_str = format_vector(first_body['velocity'], 2)
else:
pos_str = "N/A"
vel_str = "N/A"
print(f"{idx:<6} {time_str:<12} {body_count:<8} {pos_str:<35} {vel_str:<35}")
prev_index = idx
print("-" * 100)
print(f"Total snapshots: {total_snapshots}")
def print_detailed_table(snapshots, body_name=None, max_rows=15):
"""Print detailed information for a specific body."""
if not snapshots:
print("No data to display!")
return
# Get all body names
all_bodies = set()
for snapshot in snapshots:
for body in snapshot['bodies']:
all_bodies.add(body['name'])
if body_name and body_name not in all_bodies:
print(f"❌ Body '{body_name}' not found. Available bodies: {', '.join(sorted(all_bodies))}")
return
# If no body specified, show the first one
if not body_name:
body_name = sorted(all_bodies)[0]
print(f"🌍 DETAILED VIEW: {body_name}")
print("=" * 120)
# Header
print(f"{'Step':<6} {'Time':<12} {'Position (x, y, z)':<40} {'Velocity (x, y, z)':<40} {'|v|':<12} {'|a|':<12}")
print("-" * 120)
# Determine which snapshots to show
total_snapshots = len(snapshots)
if total_snapshots <= max_rows:
indices = list(range(total_snapshots))
else:
# Show first few, middle few, and last few
start_count = max_rows // 3
end_count = max_rows // 3
middle_count = max_rows - start_count - end_count
indices = []
indices.extend(range(start_count))
if middle_count > 0:
middle_start = total_snapshots // 2 - middle_count // 2
indices.extend(range(middle_start, middle_start + middle_count))
indices.extend(range(total_snapshots - end_count, total_snapshots))
indices = sorted(set(indices))
prev_index = -1
for idx in indices:
if idx > prev_index + 1 and prev_index >= 0:
print(" ...")
snapshot = snapshots[idx]
time_str = format_time(snapshot['time'])
# Find the body in this snapshot
body_data = None
for body in snapshot['bodies']:
if body['name'] == body_name:
body_data = body
break
if body_data:
pos_str = format_vector(body_data['position'], 3)
vel_str = format_vector(body_data['velocity'], 3)
vel_mag = np.linalg.norm(body_data['velocity'])
acc_mag = np.linalg.norm(body_data['acceleration'])
vel_mag_str = format_scientific(vel_mag, 2)
acc_mag_str = format_scientific(acc_mag, 2)
print(f"{idx:<6} {time_str:<12} {pos_str:<40} {vel_str:<40} {vel_mag_str:<12} {acc_mag_str:<12}")
else:
print(f"{idx:<6} {time_str:<12} {'BODY NOT FOUND':<40}")
prev_index = idx
print("-" * 120)
def print_statistics(snapshots):
"""Print statistical information about the trajectory."""
if not snapshots:
return
print("\n📈 TRAJECTORY STATISTICS")
print("=" * 80)
# Time statistics
times = [s['time'] for s in snapshots]
time_start, time_end = times[0], times[-1]
duration = time_end - time_start
print(f"Time range: {format_time(time_start)} to {format_time(time_end)}")
print(f"Total duration: {format_time(duration)}")
print(f"Number of snapshots: {len(snapshots)}")
if len(times) > 1:
time_steps = [times[i+1] - times[i] for i in range(len(times)-1)]
avg_step = np.mean(time_steps)
print(f"Average time step: {format_time(avg_step)}")
# Body statistics
body_names = set()
for snapshot in snapshots:
for body in snapshot['bodies']:
body_names.add(body['name'])
print(f"Bodies tracked: {', '.join(sorted(body_names))}")
# Position and velocity ranges for each body
for body_name in sorted(body_names):
positions = []
velocities = []
for snapshot in snapshots:
for body in snapshot['bodies']:
if body['name'] == body_name:
positions.append(body['position'])
velocities.append(body['velocity'])
if positions:
positions = np.array(positions)
velocities = np.array(velocities)
pos_min = np.min(positions, axis=0)
pos_max = np.max(positions, axis=0)
vel_min = np.min(np.linalg.norm(velocities, axis=1))
vel_max = np.max(np.linalg.norm(velocities, axis=1))
print(f"\n{body_name}:")
print(f" Position range: X[{format_scientific(pos_min[0])}, {format_scientific(pos_max[0])}]")
print(f" Y[{format_scientific(pos_min[1])}, {format_scientific(pos_max[1])}]")
print(f" Z[{format_scientific(pos_min[2])}, {format_scientific(pos_max[2])}]")
print(f" Speed range: {format_scientific(vel_min)} to {format_scientific(vel_max)} m/s")
def main():
parser = argparse.ArgumentParser(description='Inspect orbital trajectory data in tabular format')
parser.add_argument('trajectory_file', help='Binary trajectory file to inspect')
parser.add_argument('--rows', '-r', type=int, default=15, help='Maximum number of rows to display (default: 15)')
parser.add_argument('--body', '-b', type=str, help='Show detailed view for specific body')
parser.add_argument('--summary', '-s', action='store_true', help='Show summary of all snapshots')
parser.add_argument('--stats', action='store_true', help='Show trajectory statistics')
parser.add_argument('--all', '-a', action='store_true', help='Show all available information')
args = parser.parse_args()
print(f"🔍 Inspecting trajectory file: {args.trajectory_file}")
print()
try:
snapshots = read_trajectory_file(args.trajectory_file)
if not snapshots:
print("❌ No data found in file!")
return
# Determine what to show
show_summary = args.summary or args.all or (not args.body and not args.stats)
show_detailed = args.body or args.all
show_stats = args.stats or args.all
if show_summary:
print_summary_table(snapshots, args.rows)
print()
if show_detailed:
print_detailed_table(snapshots, args.body, args.rows)
print()
if show_stats:
print_statistics(snapshots)
except FileNotFoundError:
print(f"❌ Error: File '{args.trajectory_file}' not found!")
except Exception as e:
print(f"❌ Error reading file: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

72
nginx.conf Normal file
View File

@ -0,0 +1,72 @@
events {
worker_connections 1024;
}
http {
upstream orbital_backend {
server orbital-simulator:3000;
}
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
listen 80;
server_name _;
# Redirect HTTP to HTTPS in production
# return 301 https://$server_name$request_uri;
# For development, serve directly
location / {
proxy_pass http://orbital_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support (if needed in future)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# API rate limiting
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://orbital_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Static file caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
proxy_pass http://orbital_backend;
proxy_set_header Host $host;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# HTTPS server (uncomment for production with SSL certificates)
# server {
# listen 443 ssl http2;
# server_name your-domain.com;
#
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512;
# ssl_prefer_server_ciphers off;
#
# location / {
# proxy_pass http://orbital_backend;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }
}

570
plot_trajectories.py Executable file
View File

@ -0,0 +1,570 @@
#!/usr/bin/env python3
"""
Plot orbital trajectories from binary output file generated by the orbital simulator.
The binary file contains snapshots serialized with bincode, where each snapshot has:
- time: f64
- bodies: array of Body structs
- name: String
- mass: f64
- position: [f64; 3] (x, y, z)
- velocity: [f64; 3] (vx, vy, vz)
- acceleration: [f64; 3] (ax, ay, az)
Usage:
python plot_trajectories.py <trajectory_file.bin>
"""
import sys
import struct
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.animation as animation
import numpy as np
from collections import defaultdict
import argparse
class BinaryReader:
def __init__(self, data):
self.data = data
self.pos = 0
def read_u64(self):
result = struct.unpack('<Q', self.data[self.pos:self.pos+8])[0]
self.pos += 8
return result
def read_f64(self):
result = struct.unpack('<d', self.data[self.pos:self.pos+8])[0]
self.pos += 8
return result
def read_string(self):
# Read length (u64) then string bytes
length = self.read_u64()
result = self.data[self.pos:self.pos+length].decode('utf-8')
self.pos += length
return result
def read_vec3(self):
# Read 3 f64 values for position/velocity/acceleration
x = self.read_f64()
y = self.read_f64()
z = self.read_f64()
return np.array([x, y, z])
def read_trajectory_file(filename):
"""Read the binary trajectory file and return parsed data."""
with open(filename, 'rb') as f:
data = f.read()
reader = BinaryReader(data)
snapshots = []
try:
while reader.pos < len(data):
# Read snapshot
time = reader.read_f64()
# Read number of bodies (u64)
num_bodies = reader.read_u64()
bodies = []
for _ in range(num_bodies):
# Read Body struct
name = reader.read_string()
mass = reader.read_f64()
position = reader.read_vec3()
velocity = reader.read_vec3()
acceleration = reader.read_vec3()
bodies.append({
'name': name,
'mass': mass,
'position': position,
'velocity': velocity,
'acceleration': acceleration
})
snapshots.append({
'time': time,
'bodies': bodies
})
except struct.error:
# End of file or corrupted data
pass
return snapshots
def organize_trajectories(snapshots):
"""Organize snapshots into trajectories by body name."""
trajectories = defaultdict(list)
times = []
for snapshot in snapshots:
times.append(snapshot['time'])
for body in snapshot['bodies']:
trajectories[body['name']].append(body['position'])
# Convert lists to numpy arrays
for name in trajectories:
trajectories[name] = np.array(trajectories[name])
return dict(trajectories), np.array(times)
def plot_trajectories_2d(trajectories, times, center_body=None):
"""Plot 2D trajectories (X-Y plane)."""
plt.figure(figsize=(12, 10))
colors = plt.cm.tab10(np.linspace(0, 1, len(trajectories)))
for i, (name, positions) in enumerate(trajectories.items()):
x = positions[:, 0]
y = positions[:, 1]
plt.plot(x, y, color=colors[i], alpha=0.7, linewidth=1.5, label=name)
# Mark starting position
plt.plot(x[0], y[0], 'o', color=colors[i], markersize=8, alpha=0.8)
# Mark current position
plt.plot(x[-1], y[-1], 's', color=colors[i], markersize=6, alpha=0.8)
plt.xlabel('X Position (m)')
plt.ylabel('Y Position (m)')
title = 'Orbital Trajectories (X-Y Plane)'
if center_body:
title += f' - Centered on {center_body}'
plt.title(title)
plt.legend()
plt.grid(True, alpha=0.3)
plt.axis('equal')
plt.tight_layout()
def plot_trajectories_3d(trajectories, times, center_body=None):
"""Plot 3D trajectories."""
fig = plt.figure(figsize=(12, 10))
ax = fig.add_subplot(111, projection='3d')
colors = plt.cm.tab10(np.linspace(0, 1, len(trajectories)))
for i, (name, positions) in enumerate(trajectories.items()):
x = positions[:, 0]
y = positions[:, 1]
z = positions[:, 2]
ax.plot(x, y, z, color=colors[i], alpha=0.7, linewidth=1.5, label=name)
# Mark starting position
ax.scatter(x[0], y[0], z[0], color=colors[i], s=100, alpha=0.8, marker='o')
# Mark current position
ax.scatter(x[-1], y[-1], z[-1], color=colors[i], s=60, alpha=0.8, marker='s')
ax.set_xlabel('X Position (m)')
ax.set_ylabel('Y Position (m)')
ax.set_zlabel('Z Position (m)')
title = 'Orbital Trajectories (3D)'
if center_body:
title += f' - Centered on {center_body}'
ax.set_title(title)
ax.legend()
# Make axes equal
max_range = 0
for positions in trajectories.values():
range_val = np.max(np.abs(positions))
max_range = max(max_range, range_val)
ax.set_xlim(-max_range, max_range)
ax.set_ylim(-max_range, max_range)
ax.set_zlim(-max_range, max_range)
def plot_energy_over_time(snapshots, times):
"""Plot energy evolution over time."""
plt.figure(figsize=(12, 6))
total_energies = []
kinetic_energies = []
for snapshot in snapshots:
ke = 0
pe = 0
bodies = snapshot['bodies']
# Calculate kinetic energy
for body in bodies:
v_squared = np.sum(body['velocity']**2)
ke += 0.5 * body['mass'] * v_squared
# Calculate potential energy (simplified, assuming G=1 in normalized units)
G = 6.67430e-11 # You might need to adjust this based on your normalization
for i in range(len(bodies)):
for j in range(i+1, len(bodies)):
r = np.linalg.norm(bodies[i]['position'] - bodies[j]['position'])
pe -= G * bodies[i]['mass'] * bodies[j]['mass'] / r
kinetic_energies.append(ke)
total_energies.append(ke + pe)
plt.plot(times, kinetic_energies, label='Kinetic Energy', alpha=0.8)
plt.plot(times, total_energies, label='Total Energy', alpha=0.8)
plt.xlabel('Time (s)')
plt.ylabel('Energy (J)')
plt.title('Energy Conservation Over Time')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
def plot_animated_2d(trajectories, times, interval=50, center_body=None):
"""Create animated 2D trajectory plot."""
fig, ax = plt.subplots(figsize=(12, 10))
colors = plt.cm.tab10(np.linspace(0, 1, len(trajectories)))
body_names = list(trajectories.keys())
# Set up the plot
ax.set_xlabel('X Position (m)')
ax.set_ylabel('Y Position (m)')
title = 'Animated Orbital Trajectories (X-Y Plane)'
if center_body:
title += f' - Centered on {center_body}'
ax.set_title(title)
ax.grid(True, alpha=0.3)
# Calculate plot limits
all_positions = np.concatenate(list(trajectories.values()))
margin = 0.1
x_range = np.max(all_positions[:, 0]) - np.min(all_positions[:, 0])
y_range = np.max(all_positions[:, 1]) - np.min(all_positions[:, 1])
x_center = (np.max(all_positions[:, 0]) + np.min(all_positions[:, 0])) / 2
y_center = (np.max(all_positions[:, 1]) + np.min(all_positions[:, 1])) / 2
max_range = max(x_range, y_range) * (1 + margin)
ax.set_xlim(x_center - max_range/2, x_center + max_range/2)
ax.set_ylim(y_center - max_range/2, y_center + max_range/2)
ax.set_aspect('equal')
# Initialize plot elements
trajectory_lines = []
body_points = []
body_trails = []
for i, name in enumerate(body_names):
# Trajectory line (will grow over time)
line, = ax.plot([], [], color=colors[i], alpha=0.7, linewidth=1.5, label=name)
trajectory_lines.append(line)
# Current body position
point, = ax.plot([], [], 'o', color=colors[i], markersize=8, alpha=0.9)
body_points.append(point)
# Trail of recent positions
trail, = ax.plot([], [], 'o', color=colors[i], markersize=3, alpha=0.3)
body_trails.append(trail)
# Time display
time_text = ax.text(0.02, 0.98, '', transform=ax.transAxes, fontsize=12,
verticalalignment='top', bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
ax.legend(loc='upper right')
def animate(frame):
current_time = times[frame]
# Format time for display
if current_time >= 86400: # More than a day
time_str = f"Time: {current_time/86400:.1f} days"
elif current_time >= 3600: # More than an hour
time_str = f"Time: {current_time/3600:.1f} hours"
else:
time_str = f"Time: {current_time:.1f} seconds"
time_text.set_text(time_str)
# Update each body
for i, name in enumerate(body_names):
positions = trajectories[name]
# Update trajectory line (show path up to current time)
x_data = positions[:frame+1, 0]
y_data = positions[:frame+1, 1]
trajectory_lines[i].set_data(x_data, y_data)
# Update current position
if frame < len(positions):
current_pos = positions[frame]
body_points[i].set_data([current_pos[0]], [current_pos[1]])
# Update trail (last 20 positions)
trail_start = max(0, frame - 20)
trail_x = positions[trail_start:frame, 0]
trail_y = positions[trail_start:frame, 1]
body_trails[i].set_data(trail_x, trail_y)
return trajectory_lines + body_points + body_trails + [time_text]
num_frames = len(times)
anim = animation.FuncAnimation(fig, animate, frames=num_frames,
interval=interval, blit=True, repeat=True)
return fig, anim
def plot_animated_3d(trajectories, times, interval=50, center_body=None):
"""Create animated 3D trajectory plot."""
fig = plt.figure(figsize=(12, 10))
ax = fig.add_subplot(111, projection='3d')
colors = plt.cm.tab10(np.linspace(0, 1, len(trajectories)))
body_names = list(trajectories.keys())
# Set up the plot
ax.set_xlabel('X Position (m)')
ax.set_ylabel('Y Position (m)')
ax.set_zlabel('Z Position (m)')
title = 'Animated Orbital Trajectories (3D)'
if center_body:
title += f' - Centered on {center_body}'
ax.set_title(title)
# Calculate plot limits
all_positions = np.concatenate(list(trajectories.values()))
max_range = np.max(np.abs(all_positions)) * 1.1
ax.set_xlim(-max_range, max_range)
ax.set_ylim(-max_range, max_range)
ax.set_zlim(-max_range, max_range)
# Initialize plot elements
trajectory_lines = []
body_points = []
for i, name in enumerate(body_names):
# Trajectory line
line, = ax.plot([], [], [], color=colors[i], alpha=0.7, linewidth=1.5, label=name)
trajectory_lines.append(line)
# Current body position
point = ax.scatter([], [], [], color=colors[i], s=100, alpha=0.9)
body_points.append(point)
# Time display
time_text = ax.text2D(0.02, 0.98, '', transform=ax.transAxes, fontsize=12,
verticalalignment='top', bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
ax.legend(loc='upper right')
def animate(frame):
current_time = times[frame]
# Format time for display
if current_time >= 86400: # More than a day
time_str = f"Time: {current_time/86400:.1f} days"
elif current_time >= 3600: # More than an hour
time_str = f"Time: {current_time/3600:.1f} hours"
else:
time_str = f"Time: {current_time:.1f} seconds"
time_text.set_text(time_str)
# Update each body
for i, name in enumerate(body_names):
positions = trajectories[name]
# Update trajectory line
x_data = positions[:frame+1, 0]
y_data = positions[:frame+1, 1]
z_data = positions[:frame+1, 2]
trajectory_lines[i].set_data(x_data, y_data)
trajectory_lines[i].set_3d_properties(z_data)
# Update current position
if frame < len(positions):
current_pos = positions[frame]
# Remove old scatter point and create new one
body_points[i].remove()
body_points[i] = ax.scatter([current_pos[0]], [current_pos[1]], [current_pos[2]],
color=colors[i], s=100, alpha=0.9)
return trajectory_lines + body_points + [time_text]
num_frames = len(times)
anim = animation.FuncAnimation(fig, animate, frames=num_frames,
interval=interval, blit=False, repeat=True)
return fig, anim
def calculate_animation_params(num_frames, target_duration_sec=60, manual_interval=None):
"""Calculate animation parameters for optimal viewing experience."""
if manual_interval is not None:
# User specified manual interval
interval_ms = manual_interval
total_duration_sec = num_frames * interval_ms / 1000.0
time_scale_factor = target_duration_sec / total_duration_sec
return interval_ms, total_duration_sec, time_scale_factor, True
# Auto-calculate interval for target duration
target_duration_ms = target_duration_sec * 1000
optimal_interval = max(10, target_duration_ms // num_frames) # Minimum 10ms for smooth animation
actual_duration_sec = num_frames * optimal_interval / 1000.0
time_scale_factor = target_duration_sec / actual_duration_sec
return optimal_interval, actual_duration_sec, time_scale_factor, False
def center_trajectories_on_body(trajectories, center_body_name):
"""Center all trajectories relative to the specified body."""
if center_body_name not in trajectories:
available_bodies = list(trajectories.keys())
raise ValueError(f"Body '{center_body_name}' not found. Available bodies: {available_bodies}")
center_trajectory = trajectories[center_body_name]
centered_trajectories = {}
for body_name, trajectory in trajectories.items():
# Subtract the center body's position from each body's trajectory
centered_trajectories[body_name] = trajectory - center_trajectory
return centered_trajectories
def main():
parser = argparse.ArgumentParser(description='Plot orbital trajectories from binary file')
parser.add_argument('trajectory_file', help='Binary trajectory file to plot')
parser.add_argument('--2d-only', action='store_true', dest='two_d_only', help='Only show 2D plot')
parser.add_argument('--3d-only', action='store_true', dest='three_d_only', help='Only show 3D plot')
parser.add_argument('--energy', action='store_true', help='Show energy plot')
parser.add_argument('--animate', action='store_true', help='Show animated trajectories')
parser.add_argument('--save-animation', type=str, help='Save animation as MP4 file')
parser.add_argument('--static', action='store_true', help='Show static plots (default if no --animate)')
parser.add_argument('--interval', type=int, help='Animation interval in milliseconds (default: auto-scaled to ~60s total)')
parser.add_argument('--target-duration', type=int, default=60, help='Target animation duration in seconds (default: 60)')
parser.add_argument('--center', type=str, help='Center animation on specified body (e.g., "Sun", "Earth")')
parser.add_argument('--list-bodies', action='store_true', help='List available bodies and exit')
args = parser.parse_args()
print(f"Reading trajectory file: {args.trajectory_file}")
try:
snapshots = read_trajectory_file(args.trajectory_file)
print(f"Loaded {len(snapshots)} snapshots")
if not snapshots:
print("No data found in file!")
return
trajectories, times = organize_trajectories(snapshots)
# Handle list-bodies option
if args.list_bodies:
print(f"Available bodies in trajectory file:")
for body_name in sorted(trajectories.keys()):
print(f" - {body_name}")
return
print(f"Bodies found: {list(trajectories.keys())}")
print(f"Time range: {times[0]:.2e} - {times[-1]:.2e} seconds")
print(f"Number of time steps: {len(times)}")
# Center trajectories on specified body if requested
original_trajectories = trajectories.copy()
if args.center:
try:
trajectories = center_trajectories_on_body(trajectories, args.center)
print(f"🎯 Centering animation on: {args.center}")
except ValueError as e:
print(f"❌ Error: {e}")
return
# Check if we should animate or show static plots
show_animation = args.animate or args.save_animation
if show_animation:
# Calculate animation parameters
interval_ms, anim_duration_sec, time_scale, is_manual = calculate_animation_params(
len(times), args.target_duration, args.interval
)
print(f"\n🎬 Animation Settings:")
print(f" Total frames: {len(times)}")
print(f" Animation duration: {anim_duration_sec:.1f} seconds")
print(f" Frame interval: {interval_ms}ms")
if is_manual:
print(f" ⚙️ Using manual interval (--interval {args.interval})")
if time_scale != 1.0:
print(f" ⏱️ Time scale: {time_scale:.2f}x (animation {'faster' if time_scale > 1 else 'slower'} than target)")
else:
print(f" 🤖 Auto-scaled for {args.target_duration}s target duration")
print(f" ⏱️ Time scale: 1.0x (optimized)")
simulation_duration = times[-1] - times[0]
if simulation_duration > 0:
compression_ratio = simulation_duration / anim_duration_sec
if compression_ratio >= 86400:
print(f" 📈 Compression: {compression_ratio/86400:.1f} days of simulation per second of animation")
elif compression_ratio >= 3600:
print(f" 📈 Compression: {compression_ratio/3600:.1f} hours of simulation per second of animation")
else:
print(f" 📈 Compression: {compression_ratio:.1f}x real-time")
print()
print("Creating animated plots...")
animations = []
# Create animated plots
if not args.three_d_only:
print("Creating 2D animation...")
fig_2d, anim_2d = plot_animated_2d(trajectories, times, interval_ms, args.center)
animations.append((fig_2d, anim_2d, '2d'))
if not args.two_d_only:
print("Creating 3D animation...")
fig_3d, anim_3d = plot_animated_3d(trajectories, times, interval_ms, args.center)
animations.append((fig_3d, anim_3d, '3d'))
# Save animations if requested
if args.save_animation:
for fig, anim, plot_type in animations:
filename = f"{args.save_animation}_{plot_type}.mp4"
print(f"Saving {plot_type.upper()} animation to {filename}...")
try:
anim.save(filename, writer='ffmpeg', fps=20)
print(f"Animation saved to {filename}")
except Exception as e:
print(f"Error saving animation: {e}")
print("Note: You may need to install ffmpeg for video export")
plt.show()
else:
print("Creating static plots...")
# Use original trajectories for static plots unless centering is requested
plot_trajectories = trajectories if args.center else original_trajectories
# Plot static trajectories
if not args.three_d_only:
plot_trajectories_2d(plot_trajectories, times, args.center)
if not args.two_d_only:
plot_trajectories_3d(plot_trajectories, times, args.center)
if args.energy:
plot_energy_over_time(snapshots, times)
plt.show()
except FileNotFoundError:
print(f"Error: File '{args.trajectory_file}' not found!")
except Exception as e:
print(f"Error reading file: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
matplotlib>=3.5.0
numpy>=1.20.0
ffmpeg-python>=0.2.0

24
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,24 @@
[package]
name = "orbital-simulator-gui"
version = "0.1.0"
description = "Desktop GUI for Orbital Simulator"
authors = ["Thomas Faour"]
license = "MIT"
repository = ""
edition = "2021"
[build-dependencies]
tauri-build = { version = "1.0", features = [] }
[dependencies]
tauri = { version = "1.0", features = ["api-all"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
[features]
# by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
default = ["custom-protocol"]
# this feature is used for production builds or when `devPath` points to the filesystem
custom-protocol = ["tauri/custom-protocol"]

106
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,106 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use tauri::{command, Context, generate_handler, generate_context};
use std::process::Command;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct SimulationConfig {
name: String,
path: String,
description: String,
}
#[command]
async fn get_available_configs() -> Result<Vec<SimulationConfig>, String> {
let configs = vec![
SimulationConfig {
name: "Earth-Sun System".to_string(),
path: "config/earthsun_corrected.toml".to_string(),
description: "Simple two-body system for testing".to_string(),
},
SimulationConfig {
name: "Inner Solar System".to_string(),
path: "config/inner_solar_system.toml".to_string(),
description: "Mercury through Mars plus Moon".to_string(),
},
SimulationConfig {
name: "Complete Solar System".to_string(),
path: "config/planets.toml".to_string(),
description: "All planets plus major moons".to_string(),
},
];
Ok(configs)
}
#[command]
async fn start_api_server() -> Result<String, String> {
// Start the API server in the background
let mut cmd = Command::new("cargo");
cmd.args(&["run", "--release", "--bin", "api_server"]);
match cmd.spawn() {
Ok(_) => Ok("API server started on http://localhost:3000".to_string()),
Err(e) => Err(format!("Failed to start API server: {}", e)),
}
}
#[command]
async fn run_simulation(
config_path: String,
time_str: String,
step_size: f64,
output_file: String,
) -> Result<String, String> {
let mut cmd = Command::new("cargo");
cmd.args(&[
"run",
"--release",
"--bin",
"simulator",
"--",
"--config", &config_path,
"--time", &time_str,
"--step-size", &step_size.to_string(),
"--output-file", &output_file,
"--force-overwrite",
]);
match cmd.output() {
Ok(output) => {
if output.status.success() {
Ok(format!("Simulation completed: {}", output_file))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("Simulation failed: {}", stderr))
}
}
Err(e) => Err(format!("Failed to run simulation: {}", e)),
}
}
#[command]
async fn visualize_trajectory(trajectory_file: String) -> Result<String, String> {
let mut cmd = Command::new("python3");
cmd.args(&["plot_trajectories.py", &trajectory_file, "--animate"]);
match cmd.spawn() {
Ok(_) => Ok("Visualization started".to_string()),
Err(e) => Err(format!("Failed to start visualization: {}", e)),
}
}
fn main() {
tauri::Builder::default()
.invoke_handler(generate_handler![
get_available_configs,
start_api_server,
run_simulation,
visualize_trajectory
])
.run(generate_context!())
.expect("error while running tauri application");
}

79
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,79 @@
{
"build": {
"beforeDevCommand": "cd web && npm run dev",
"beforeBuildCommand": "cd web && npm run build",
"devPath": "http://localhost:5173",
"distDir": "../web/dist",
"withGlobalTauri": false
},
"package": {
"productName": "Orbital Simulator",
"version": "0.1.0"
},
"tauri": {
"allowlist": {
"all": false,
"shell": {
"all": false,
"open": true
},
"dialog": {
"all": false,
"open": true,
"save": true
},
"fs": {
"all": false,
"readFile": true,
"writeFile": true,
"readDir": true,
"copyFile": true,
"createDir": true,
"removeDir": true,
"removeFile": true,
"renameFile": true,
"exists": true
},
"path": {
"all": true
},
"window": {
"all": false,
"close": true,
"hide": true,
"show": true,
"maximize": true,
"minimize": true,
"unmaximize": true,
"unminimize": true,
"startDragging": true
}
},
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.orbital-simulator.app",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"security": {
"csp": null
},
"windows": [
{
"fullscreen": false,
"resizable": true,
"title": "Orbital Simulator",
"width": 1400,
"height": 900,
"minWidth": 800,
"minHeight": 600
}
]
}
}

520
src/bin/api_server.rs Normal file
View File

@ -0,0 +1,520 @@
use axum::{
extract::{Path, Query, State},
http::{StatusCode, HeaderMap},
response::Json,
routing::{get, post},
Router,
};
use dashmap::DashMap;
use orbital_simulator::config::Config;
use serde::{Deserialize, Serialize};
use std::{
sync::{Arc, Mutex},
thread,
time::{Duration, Instant},
};
use tokio::sync::broadcast;
use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
use uuid::Uuid;
#[derive(Clone)]
pub struct AppState {
pub simulations: Arc<DashMap<Uuid, SimulationSession>>,
pub broadcasters: Arc<DashMap<Uuid, broadcast::Sender<SimulationUpdate>>>,
pub user_sessions: Arc<DashMap<String, Vec<Uuid>>>, // session_id -> simulation_ids
}
#[derive(Clone)]
pub struct SimulationSession {
pub id: Uuid,
pub session_id: String,
pub config: Config,
pub step_size: f64,
pub total_steps: usize,
pub is_running: Arc<Mutex<bool>>,
pub current_step: Arc<Mutex<usize>>,
pub current_bodies: Arc<Mutex<Vec<orbital_simulator::config::Body>>>,
pub simulation_history: Arc<Mutex<Vec<SimulationUpdate>>>, // Store all simulation states
pub playback_step: Arc<Mutex<usize>>, // Current playback position (can be different from simulation step)
pub start_time: Instant,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct SimulationUpdate {
pub step: usize,
pub time: f64,
pub bodies: Vec<BodyState>,
pub energy: Option<EnergyInfo>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct BodyState {
pub name: String,
pub position: [f64; 3],
pub velocity: [f64; 3],
pub mass: f64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct EnergyInfo {
pub kinetic: f64,
pub potential: f64,
pub total: f64,
}
#[derive(Deserialize)]
pub struct CreateSimulationRequest {
pub config: Config,
pub step_size: f64,
pub total_steps: usize,
}
#[derive(Deserialize)]
pub struct SimulationControlParams {
pub action: String, // "start", "pause", "stop", "step"
}
#[derive(Deserialize)]
pub struct SeekParams {
pub step: usize, // Step to seek to
}
#[derive(Serialize)]
pub struct SimulationInfo {
pub id: Uuid,
pub is_running: bool,
pub current_step: usize,
pub playback_step: usize, // Add playback position
pub total_steps: usize,
pub recorded_steps: usize, // How many steps have been simulated so far
pub bodies_count: usize,
pub elapsed_time: f64,
}
// Helper function to get or create session ID
fn get_session_id(headers: &HeaderMap) -> String {
headers
.get("x-session-id")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| Uuid::new_v4().to_string()) // Generate new session if none provided
}
// API Handlers
pub async fn create_simulation(
State(state): State<AppState>,
headers: HeaderMap,
Json(request): Json<CreateSimulationRequest>,
) -> Result<Json<SimulationInfo>, StatusCode> {
let id = Uuid::new_v4();
let session_id = get_session_id(&headers);
let session = SimulationSession {
id,
session_id: session_id.clone(),
config: request.config.clone(),
step_size: request.step_size,
total_steps: request.total_steps,
is_running: Arc::new(Mutex::new(false)),
current_step: Arc::new(Mutex::new(0)),
current_bodies: Arc::new(Mutex::new(request.config.bodies.clone())),
simulation_history: Arc::new(Mutex::new(Vec::new())),
playback_step: Arc::new(Mutex::new(0)),
start_time: Instant::now(),
};
// Create broadcast channel for real-time updates
let (tx, _) = broadcast::channel(1000);
state.broadcasters.insert(id, tx);
// Add simulation to user's session
state.user_sessions.entry(session_id.clone())
.or_insert_with(Vec::new)
.push(id);
let info = SimulationInfo {
id,
is_running: false,
current_step: 0,
playback_step: 0,
total_steps: request.total_steps,
recorded_steps: 0,
bodies_count: request.config.bodies.len(),
elapsed_time: 0.0,
};
state.simulations.insert(id, session);
Ok(Json(info))
}
pub async fn list_simulations(
State(state): State<AppState>,
headers: HeaderMap,
) -> Json<Vec<SimulationInfo>> {
let session_id = get_session_id(&headers);
let simulations: Vec<SimulationInfo> = if let Some(sim_ids) = state.user_sessions.get(&session_id) {
sim_ids.iter()
.filter_map(|sim_id| state.simulations.get(sim_id))
.map(|entry| {
let session = entry.value();
SimulationInfo {
id: session.id,
is_running: *session.is_running.lock().unwrap(),
current_step: *session.current_step.lock().unwrap(),
playback_step: *session.playback_step.lock().unwrap(),
total_steps: session.total_steps,
recorded_steps: session.simulation_history.lock().unwrap().len(),
bodies_count: session.config.bodies.len(),
elapsed_time: session.start_time.elapsed().as_secs_f64(),
}
})
.collect()
} else {
Vec::new() // No simulations for this session
};
Json(simulations)
}
pub async fn get_simulation_state(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<Json<SimulationUpdate>, StatusCode> {
let session_id = get_session_id(&headers);
let session = state.simulations.get(&id).ok_or(StatusCode::NOT_FOUND)?;
// Verify session ownership
if session.session_id != session_id {
return Err(StatusCode::FORBIDDEN);
}
let playback_step = *session.playback_step.lock().unwrap();
let history = session.simulation_history.lock().unwrap();
// Return the state at the current playback position
if let Some(historical_state) = history.get(playback_step) {
Ok(Json(historical_state.clone()))
} else {
// If no history yet, return current live state
let step = *session.current_step.lock().unwrap();
let bodies = session.current_bodies.lock().unwrap().clone();
let body_states: Vec<BodyState> = session.config.bodies.iter().zip(bodies.iter()).map(|(config_body, sim_body)| {
BodyState {
name: config_body.name.clone(),
position: [sim_body.position.x, sim_body.position.y, sim_body.position.z],
velocity: [sim_body.velocity.x, sim_body.velocity.y, sim_body.velocity.z],
mass: sim_body.mass,
}
}).collect();
let update = SimulationUpdate {
step,
time: (step as f64) * session.step_size,
bodies: body_states,
energy: None,
};
Ok(Json(update))
}
}
pub async fn control_simulation(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<Uuid>,
Query(params): Query<SimulationControlParams>,
) -> Result<Json<SimulationInfo>, StatusCode> {
let session_id = get_session_id(&headers);
let session = state.simulations.get(&id).ok_or(StatusCode::NOT_FOUND)?;
// Verify session ownership
if session.session_id != session_id {
return Err(StatusCode::FORBIDDEN);
}
match params.action.as_str() {
"start" => {
let mut is_running = session.is_running.lock().unwrap();
if !*is_running {
*is_running = true;
// Start simulation in background thread
let session_clone = session.clone();
let broadcaster = state.broadcasters.get(&id).unwrap().clone();
thread::spawn(move || {
run_simulation_loop(session_clone, broadcaster);
});
}
}
"pause" => {
let mut is_running = session.is_running.lock().unwrap();
*is_running = false;
}
"stop" => {
let mut is_running = session.is_running.lock().unwrap();
*is_running = false;
let mut step = session.current_step.lock().unwrap();
*step = 0;
}
"step" => {
// Single step execution - simplified for now
let mut step = session.current_step.lock().unwrap();
*step += 1;
}
_ => return Err(StatusCode::BAD_REQUEST),
}
let info = SimulationInfo {
id: session.id,
is_running: *session.is_running.lock().unwrap(),
current_step: *session.current_step.lock().unwrap(),
playback_step: *session.playback_step.lock().unwrap(),
total_steps: session.total_steps,
recorded_steps: session.simulation_history.lock().unwrap().len(),
bodies_count: session.config.bodies.len(),
elapsed_time: session.start_time.elapsed().as_secs_f64(),
};
Ok(Json(info))
}
pub async fn delete_simulation(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
let session_id = get_session_id(&headers);
if let Some((_, session)) = state.simulations.remove(&id) {
// Verify session ownership
if session.session_id != session_id {
// Put it back if not owned by this session
state.simulations.insert(id, session);
return Err(StatusCode::FORBIDDEN);
}
// Stop the simulation
let mut is_running = session.is_running.lock().unwrap();
*is_running = false;
// Remove broadcaster
state.broadcasters.remove(&id);
// Remove from user's session
if let Some(mut sim_ids) = state.user_sessions.get_mut(&session_id) {
sim_ids.retain(|&sim_id| sim_id != id);
}
Ok(StatusCode::NO_CONTENT)
} else {
Err(StatusCode::NOT_FOUND)
}
}
pub async fn get_available_configs() -> Json<Vec<String>> {
// Return list of available configuration files
let configs = vec![
"planets.toml".to_string(),
"solar_system.toml".to_string(),
"inner_solar_system.toml".to_string(),
"earthsun_corrected.toml".to_string(),
];
Json(configs)
}
fn run_simulation_loop(session: SimulationSession, broadcaster: broadcast::Sender<SimulationUpdate>) {
let target_fps = 60.0; // Increased from 30 FPS
let frame_duration = Duration::from_secs_f64(1.0 / target_fps);
let broadcast_every_n_steps = 1000; // Only broadcast every 2nd step to reduce overhead
// Create a high-performance physics simulation
let mut bodies = session.current_bodies.lock().unwrap().clone();
let mut step_counter = 0;
while *session.is_running.lock().unwrap() && step_counter < session.total_steps {
let start = Instant::now();
// High-performance gravitational simulation step
simulate_step(&mut bodies, session.step_size);
step_counter += 1;
// Update step counter (less frequently to reduce lock contention)
if step_counter % broadcast_every_n_steps == 0 {
let step = {
let mut step = session.current_step.lock().unwrap();
*step = step_counter;
*step
};
// Update stored bodies (less frequently)
{
let mut stored_bodies = session.current_bodies.lock().unwrap();
*stored_bodies = bodies.clone();
}
// Broadcast update (less frequently to reduce serialization overhead)
let body_states: Vec<BodyState> = session.config.bodies.iter().zip(bodies.iter()).map(|(config_body, sim_body)| {
BodyState {
name: config_body.name.clone(),
position: [sim_body.position.x, sim_body.position.y, sim_body.position.z],
velocity: [sim_body.velocity.x, sim_body.velocity.y, sim_body.velocity.z],
mass: sim_body.mass,
}
}).collect();
let update = SimulationUpdate {
step,
time: (step as f64) * session.step_size,
bodies: body_states.clone(),
energy: None,
};
// Store in history for timeline playback
{
let mut history = session.simulation_history.lock().unwrap();
history.push(update.clone());
}
// Update playback step to current step (auto-advance timeline)
{
let mut playback_step = session.playback_step.lock().unwrap();
*playback_step = step;
}
let elapsed = start.elapsed();
if elapsed < frame_duration {
thread::sleep(frame_duration - elapsed);
}
// Send update (ignore if no receivers)
let _ = broadcaster.send(update);
}
// Maintain target frame rate (but allow faster computation)
}
}
// High-performance physics simulation (back to SI units for compatibility)
fn simulate_step(bodies: &mut Vec<orbital_simulator::config::Body>, dt: f64) {
use glam::DVec3;
let n = bodies.len();
// Reset accelerations
for body in bodies.iter_mut() {
body.acceleration = DVec3::ZERO;
}
// Calculate gravitational forces (optimized: each pair calculated once)
// Using SI units to match input data (G = 6.67430e-11)
const G: f64 = 6.67430e-11;
for i in 0..(n-1) {
for j in (i+1)..n {
let r = bodies[j].position - bodies[i].position;
let r_mag = r.length();
if r_mag > 0.0 {
// Cache masses to avoid borrowing issues
let mass_i = bodies[i].mass;
let mass_j = bodies[j].mass;
// Gravitational force magnitude: F = G * m1 * m2 / r²
// Acceleration: a = F/m = G * other_mass / r²
let g_over_r3 = G / (r_mag * r_mag * r_mag);
let force_direction = r; // from i to j
// Newton's third law: equal and opposite forces
bodies[i].acceleration += mass_j * g_over_r3 * force_direction;
bodies[j].acceleration -= mass_i * g_over_r3 * force_direction;
}
}
}
// Velocity-Verlet integration (more stable than simple Euler)
for body in bodies.iter_mut() {
// Update position: x(t+dt) = x(t) + v(t)*dt + 0.5*a(t)*dt²
body.position += body.velocity * dt + 0.5 * body.acceleration * dt * dt;
// Update velocity: v(t+dt) = v(t) + a(t)*dt
body.velocity += body.acceleration * dt;
}
}
pub async fn seek_simulation(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<Uuid>,
Query(params): Query<SeekParams>,
) -> Result<Json<SimulationUpdate>, StatusCode> {
let session_id = get_session_id(&headers);
let session = state.simulations.get(&id).ok_or(StatusCode::NOT_FOUND)?;
// Verify session ownership
if session.session_id != session_id {
return Err(StatusCode::FORBIDDEN);
}
let history = session.simulation_history.lock().unwrap();
let recorded_steps = history.len();
// Find the closest recorded step to the requested step
if params.step < recorded_steps {
// Update playback position
{
let mut playback_step = session.playback_step.lock().unwrap();
*playback_step = params.step;
}
// Return the historical state
Ok(Json(history[params.step].clone()))
} else if recorded_steps > 0 {
// If requesting beyond recorded history, return the latest recorded step
let latest_index = recorded_steps - 1;
{
let mut playback_step = session.playback_step.lock().unwrap();
*playback_step = latest_index;
}
Ok(Json(history[latest_index].clone()))
} else {
return Err(StatusCode::BAD_REQUEST);
}
}
pub fn create_app() -> Router {
let state = AppState {
simulations: Arc::new(DashMap::new()),
broadcasters: Arc::new(DashMap::new()),
user_sessions: Arc::new(DashMap::new()),
};
Router::new()
.route("/api/simulations", post(create_simulation))
.route("/api/simulations", get(list_simulations))
.route("/api/simulations/:id", get(get_simulation_state))
.route("/api/simulations/:id/control", post(control_simulation))
.route("/api/simulations/:id/seek", post(seek_simulation))
.route("/api/simulations/:id", axum::routing::delete(delete_simulation))
.route("/api/configs", get(get_available_configs))
.layer(CorsLayer::permissive())
.with_state(state)
}
#[tokio::main]
pub async fn main() {
env_logger::init();
let app = create_app();
// Serve static files for the web frontend
let app = app.nest_service("/", ServeDir::new("web/dist"));
println!("🚀 Orbital Simulator API Server starting on http://localhost:3000");
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View File

@ -1,103 +1,103 @@
use bevy::prelude::*;
use bevy::input::mouse::{MouseMotion, MouseWheel};
// use bevy::prelude::*;
// use bevy::input::mouse::{MouseMotion, MouseWheel};
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, (camera_orbit_controls,))
.run();
// App::new()
// .add_plugins(DefaultPlugins)
// .add_systems(Startup, setup)
// .add_systems(Update, (camera_orbit_controls,))
// .run();
}
#[derive(Component)]
struct CameraController {
pub radius: f32,
pub theta: f32, // azimuthal angle
pub phi: f32, // polar angle
pub last_mouse_pos: Option<Vec2>,
}
// #[derive(Component)]
// struct CameraController {
// pub radius: f32,
// pub theta: f32, // azimuthal angle
// pub phi: f32, // polar angle
// pub _last_mouse_pos: Option<Vec2>,
// }
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// Camera with controller
let radius = 30.0;
let theta = 0.0;
let phi = std::f32::consts::FRAC_PI_4;
let cam_pos = spherical_to_cartesian(radius, theta, phi);
commands.spawn((
Camera3dBundle {
transform: Transform::from_translation(cam_pos).looking_at(Vec3::ZERO, Vec3::Y),
..default()
},
CameraController {
radius,
theta,
phi,
last_mouse_pos: None,
},
));
// Light
commands.spawn(PointLightBundle {
point_light: PointLight {
intensity: 1500.0,
shadows_enabled: true,
..default()
},
transform: Transform::from_xyz(4.0, 8.0, 4.0),
..default()
});
// Add a sphere at the origin using the new Sphere primitive
let mesh = meshes.add(Mesh::from(Sphere { radius: 1.0, ..default() }));
let material = materials.add(StandardMaterial {
base_color: Color::BLUE,
..default()
});
commands.spawn(PbrBundle {
mesh,
material,
..default()
});
}
// fn setup(
// mut commands: Commands,
// mut meshes: ResMut<Assets<Mesh>>,
// mut materials: ResMut<Assets<StandardMaterial>>,
// ) {
// // Camera with controller
// let radius = 30.0;
// let theta = 0.0;
// let phi = std::f32::consts::FRAC_PI_4;
// let cam_pos = spherical_to_cartesian(radius, theta, phi);
// commands.spawn((
// Camera3dBundle {
// transform: Transform::from_translation(cam_pos).looking_at(Vec3::ZERO, Vec3::Y),
// ..default()
// },
// CameraController {
// radius,
// theta,
// phi,
// _last_mouse_pos: None,
// },
// ));
// // Light
// commands.spawn(PointLightBundle {
// point_light: PointLight {
// intensity: 1500.0,
// shadows_enabled: true,
// ..default()
// },
// transform: Transform::from_xyz(4.0, 8.0, 4.0),
// ..default()
// });
// // Add a sphere at the origin using the new Sphere primitive
// let mesh = meshes.add(Mesh::from(Sphere { radius: 1.0, ..default() }));
// let material = materials.add(StandardMaterial {
// base_color: Color::BLUE,
// ..default()
// });
// commands.spawn(PbrBundle {
// mesh,
// material,
// ..default()
// });
// }
fn spherical_to_cartesian(radius: f32, theta: f32, phi: f32) -> Vec3 {
let x = radius * phi.sin() * theta.cos();
let y = radius * phi.cos();
let z = radius * phi.sin() * theta.sin();
Vec3::new(x, y, z)
}
// fn spherical_to_cartesian(radius: f32, theta: f32, phi: f32) -> Vec3 {
// let x = radius * phi.sin() * theta.cos();
// let y = radius * phi.cos();
// let z = radius * phi.sin() * theta.sin();
// Vec3::new(x, y, z)
// }
fn camera_orbit_controls(
mut query: Query<(&mut Transform, &mut CameraController)>,
mut mouse_motion_events: EventReader<MouseMotion>,
mut mouse_wheel_events: EventReader<MouseWheel>,
mouse_button_input: Res<ButtonInput<MouseButton>>,
windows: Query<&Window>,
) {
let window = if let Some(window) = windows.iter().next() { window } else { return; };
let mut delta = Vec2::ZERO;
for event in mouse_motion_events.read() {
delta += event.delta;
}
let mut scroll = 0.0;
for event in mouse_wheel_events.read() {
scroll += event.y;
}
for (mut transform, mut controller) in query.iter_mut() {
// Orbit (right mouse button)
if mouse_button_input.pressed(MouseButton::Right) {
let sensitivity = 0.01;
controller.theta -= delta.x * sensitivity;
controller.phi = (controller.phi - delta.y * sensitivity).clamp(0.05, std::f32::consts::PI - 0.05);
}
// Zoom
if scroll.abs() > 0.0 {
controller.radius = (controller.radius - scroll).clamp(3.0, 200.0);
}
// Update camera position
let pos = spherical_to_cartesian(controller.radius, controller.theta, controller.phi);
*transform = Transform::from_translation(pos).looking_at(Vec3::ZERO, Vec3::Y);
}
}
// fn camera_orbit_controls(
// mut query: Query<(&mut Transform, &mut CameraController)>,
// mut mouse_motion_events: EventReader<MouseMotion>,
// mut mouse_wheel_events: EventReader<MouseWheel>,
// mouse_button_input: Res<ButtonInput<MouseButton>>,
// windows: Query<&Window>,
// ) {
// let _window = if let Some(window) = windows.iter().next() { window } else { return; };
// let mut delta = Vec2::ZERO;
// for event in mouse_motion_events.read() {
// delta += event.delta;
// }
// let mut scroll = 0.0;
// for event in mouse_wheel_events.read() {
// scroll += event.y;
// }
// for (mut transform, mut controller) in query.iter_mut() {
// // Orbit (right mouse button)
// if mouse_button_input.pressed(MouseButton::Right) {
// let sensitivity = 0.01;
// controller.theta -= delta.x * sensitivity;
// controller.phi = (controller.phi - delta.y * sensitivity).clamp(0.05, std::f32::consts::PI - 0.05);
// }
// // Zoom
// if scroll.abs() > 0.0 {
// controller.radius = (controller.radius - scroll).clamp(3.0, 200.0);
// }
// // Update camera position
// let pos = spherical_to_cartesian(controller.radius, controller.theta, controller.phi);
// *transform = Transform::from_translation(pos).looking_at(Vec3::ZERO, Vec3::Y);
// }
// }

View File

@ -1,6 +1,7 @@
// Standard library
use std::error::Error;
use std::fs;
use std::io::{self, Write};
use std::path::Path;
use std::time::{Duration,Instant};
@ -9,10 +10,11 @@ use clap::Parser;
use log::{info, debug};
use indicatif::{ProgressBar, ProgressStyle};
use humantime;
use serde_json;
// Internal modules from the library crate
use orbital_simulator::simulation::Simulation;
use orbital_simulator::types::norm_time;
use orbital_simulator::types::{norm_time, real_time};
use orbital_simulator as _; // for mod resolution
#[derive(Parser, Debug)]
@ -42,11 +44,27 @@ struct Args {
/// Filename to save trajectory to
#[arg(short, long)]
output_file: String,
/// Force overwrite existing output file without confirmation
#[arg(short = 'w', long)]
force_overwrite: bool,
}
fn read_config<P: AsRef<Path>>(path: P) -> Result<orbital_simulator::config::ConfigFile, Box<dyn Error>> {
fn read_config<P: AsRef<Path>>(path: P) -> Result<orbital_simulator::config::Config, Box<dyn Error>> {
let content = fs::read_to_string(&path)?;
let conf = toml::from_str(&content)?;
let path_str = path.as_ref().to_string_lossy();
let conf: orbital_simulator::config::Config = if path_str.ends_with(".json") {
serde_json::from_str(&content)?
} else if path_str.ends_with(".toml") {
toml::from_str(&content)?
} else {
// Try JSON first, then TOML
serde_json::from_str(&content).or_else(|_| toml::from_str(&content))?
};
// Apply normalization settings if present
conf.apply_normalization();
Ok(conf)
}
@ -73,6 +91,32 @@ fn format_duration_single_unit(dur: Duration) -> String {
}
}
fn check_output_file(path: &str, force_overwrite: bool) -> Result<(), Box<dyn Error>> {
if Path::new(path).exists() {
if force_overwrite {
info!("Overwriting existing file: {}", path);
return Ok(());
}
print!("⚠️ Output file '{}' already exists. Overwrite? [y/N]: ", path);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let response = input.trim().to_lowercase();
if response == "y" || response == "yes" {
info!("Overwriting existing file: {}", path);
Ok(())
} else {
println!("❌ Aborted. Use --force-overwrite (-w) to skip confirmation.");
std::process::exit(1);
}
} else {
Ok(())
}
}
fn main() {
env_logger::init();
let args = Args::parse();
@ -80,13 +124,19 @@ fn main() {
info!("Loading initial parameters from {}", args.config);
let conf = read_config(args.config).unwrap();
// Check if output file exists and handle overwrite
check_output_file(&args.output_file, args.force_overwrite).unwrap();
for body in &conf.bodies {
info!("Loaded {} with mass {:.3e} kg", body.name, body.mass);
debug!("R_i = {:?}, V_i = {:?}", body.position, body.velocity);
}
debug!("Simulation time and step size: {} seconds, {} seconds per step",
args.time.as_secs(), args.step_size);
let n_steps = (args.time.as_secs() as f64 / args.step_size) as usize;
check_output_file(&args.output_file, args.force_overwrite).unwrap();
let pb = ProgressBar::new(n_steps.try_into().unwrap());
pb.set_style(
@ -111,4 +161,4 @@ fn main() {
pb.inc(args.steps_per_save as u64);
})
);
}
}

View File

@ -2,7 +2,7 @@ use crate::types;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Body {
pub name: String,
pub mass: types::Mass,
@ -13,18 +13,26 @@ pub struct Body {
pub acceleration: types::Acceleration,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Normalization {
pub name: String,
pub mass: types::Mass,
pub position: types::Position,
pub velocity: types::Velocity,
#[serde(default)]
pub acceleration: types::Acceleration,
pub m_0: f64, // Mass normalization constant
pub t_0: f64, // Time normalization constant
pub r_0: f64, // Distance normalization constant
}
#[derive(Debug, Deserialize)]
pub struct ConfigFile {
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config {
pub bodies: Vec<Body>,
#[serde(default)]
pub normalization: Option<Normalization>,
}
impl Config {
/// Apply normalization settings if present in the config
pub fn apply_normalization(&self) {
if let Some(ref norm) = self.normalization {
types::set_normalization(norm);
}
}
}

View File

@ -34,7 +34,7 @@ impl Simulation {
bodies[i].position = norm_pos(bodies[i].position);
bodies[i].velocity = norm_vel(bodies[i].velocity);
}
step_size = norm_time(step_size);
step_size = step_size;
Self {bodies, step_size, steps_per_save, save_file}
}
@ -42,12 +42,15 @@ impl Simulation {
where F: FnMut() {
let file = OpenOptions::new()
.create(true)
.append(true)
.write(true)
.truncate(true)
.open(&self.save_file);
let mut writer = BufWriter::new(file.unwrap());
for i in 0..steps {
debug!("Step: {}", i);
trace!("norm step size is {}", self.step_size);
trace!("real step size is {}", real_time(self.step_size));
trace!("Step: {}, Time: {}", i, real_time(i as f64 * self.step_size));
self.reset_accelerations();
self.calculate_accelerations();
self.step_bodies();
@ -55,7 +58,7 @@ impl Simulation {
//save the state
let real_bodies: Vec<Body> = self.bodies.iter().map(real_body).collect();
let snapshot = Snapshot {
time: real_time(i as f64*self.step_size),
time: real_time(i as f64 * self.step_size),
bodies: &real_bodies,
};
@ -75,8 +78,9 @@ impl Simulation {
let mass_i = self.bodies[i].mass;
for j in (i+1)..n {
let mass_j = self.bodies[j].mass;
self.bodies[i].acceleration += r_hat_over_r3[i][j]*mass_j;
self.bodies[j].acceleration -= r_hat_over_r3[i][j]*mass_i;
// In normalized units, G = 1
self.bodies[i].acceleration += r_hat_over_r3[i][j] * mass_j;
self.bodies[j].acceleration -= r_hat_over_r3[i][j] * mass_i;
}
}
}

View File

@ -1,8 +1,10 @@
#![allow(unused)]
use glam::DVec3;
use once_cell::sync::Lazy;
use std::sync::RwLock;
use log::{debug};
use crate::config::Body;
use crate::config::{Body, Normalization};
pub type Position = DVec3;
pub type Velocity = DVec3;
@ -24,62 +26,119 @@ const SUN_MASS: f64 = 1.989e30; //kg
const SUN_RADIUS: f64 = 6.957e8; //meters
const G: f64 = 6.67430e-11;
const R_0: f64 = EARTH_RADIUS;
const M_0: f64 = EARTH_MASS;
static T_0: Lazy<f64> = Lazy::new(|| {
(R_0.powf(3.0) / (G * M_0)).sqrt()
// Default normalization constants
const DEFAULT_R_0: f64 = EARTH_RADIUS;
const DEFAULT_M_0: f64 = EARTH_MASS;
// Global normalization context
static NORMALIZATION: Lazy<RwLock<NormalizationContext>> = Lazy::new(|| {
RwLock::new(NormalizationContext::default())
});
#[derive(Debug, Clone)]
pub struct NormalizationContext {
pub r_0: f64,
pub m_0: f64,
pub t_0: f64,
}
impl Default for NormalizationContext {
fn default() -> Self {
let r_0 = DEFAULT_R_0;
let m_0 = DEFAULT_M_0;
let t_0 = (r_0.powf(3.0) / (G * m_0)).sqrt();
debug!("Using default normalization: r_0 = {}, m_0 = {}, t_0 = {}", r_0, m_0, t_0);
Self { r_0, m_0, t_0 }
}
}
impl From<&Normalization> for NormalizationContext {
fn from(norm: &Normalization) -> Self {
Self {
r_0: norm.r_0,
m_0: norm.m_0,
t_0: norm.t_0
}
}
}
/// Set the global normalization context from a config normalization
pub fn set_normalization(norm: &Normalization) {
let mut context = NORMALIZATION.write().unwrap();
*context = NormalizationContext::from(norm);
}
/// Reset to default normalization
pub fn reset_normalization() {
let mut context = NORMALIZATION.write().unwrap();
*context = NormalizationContext::default();
}
/// Get current normalization context
pub fn get_normalization() -> NormalizationContext {
NORMALIZATION.read().unwrap().clone()
}
#[inline]
pub fn norm_pos(pos: Position) -> Position {
pos / R_0
let norm = get_normalization();
pos / norm.r_0
}
#[inline]
pub fn real_pos(pos: Position) -> Position {
pos * R_0
let norm = get_normalization();
pos * norm.r_0
}
#[inline]
pub fn norm_mass(mass: Mass) -> Mass {
mass / M_0
let norm = get_normalization();
mass / norm.m_0
}
#[inline]
pub fn real_mass(mass: Mass) -> Mass {
mass * M_0
let norm = get_normalization();
mass * norm.m_0
}
#[inline]
pub fn norm_time(time: Time) -> Time {
time / *T_0
let norm = get_normalization();
time / norm.t_0
}
#[inline]
pub fn real_time(time: Time) -> Time {
time * *T_0
let norm = get_normalization();
time * norm.t_0
}
#[inline]
pub fn norm_vel(vel: Velocity) -> Velocity {
vel / (R_0 / *T_0)
let norm = get_normalization();
vel / (norm.r_0 / norm.t_0)
}
#[inline]
pub fn real_vel(vel: Velocity) -> Velocity {
vel * (R_0 / *T_0)
let norm = get_normalization();
vel * (norm.r_0 / norm.t_0)
}
#[inline]
pub fn norm_acc(acc: Acceleration) -> Acceleration {
acc / (R_0 / (*T_0 * *T_0))
let norm = get_normalization();
acc / (norm.r_0 / (norm.t_0 * norm.t_0))
}
#[inline]
pub fn real_acc(acc: Acceleration) -> Acceleration {
acc * (R_0 / (*T_0 * *T_0))
let norm = get_normalization();
acc * (norm.r_0 / (norm.t_0 * norm.t_0))
}
#[inline]

121
start_interfaces.sh Executable file
View File

@ -0,0 +1,121 @@
#!/bin/bash
set -e
echo "🚀 Starting Orbital Simulator Interfaces..."
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to check if a command exists
command_exists() {
command -v "$1" &> /dev/null
}
# Check prerequisites
echo -e "${BLUE}Checking prerequisites...${NC}"
if ! command_exists cargo; then
echo -e "${RED}❌ Rust/Cargo not found. Please install Rust first.${NC}"
exit 1
fi
if ! command_exists node; then
echo -e "${RED}❌ Node.js not found. Please install Node.js first.${NC}"
exit 1
fi
if ! command_exists python3; then
echo -e "${RED}❌ Python 3 not found. Please install Python 3 first.${NC}"
exit 1
fi
echo -e "${GREEN}✅ Prerequisites check passed${NC}"
# Build Rust project
echo -e "${BLUE}Building Rust project...${NC}"
cargo build --release
# Install web dependencies if needed
if [ ! -d "web/node_modules" ]; then
echo -e "${BLUE}Installing web dependencies...${NC}"
cd web
npm install
cd ..
fi
# Install Python dependencies if needed
if [ ! -f ".venv/bin/python" ]; then
echo -e "${BLUE}Setting up Python virtual environment...${NC}"
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
else
source .venv/bin/activate
fi
# Function to kill background processes on exit
cleanup() {
echo -e "\n${YELLOW}Shutting down services...${NC}"
kill $(jobs -p) 2>/dev/null || true
exit 0
}
trap cleanup SIGINT SIGTERM
# Start API server
echo -e "${BLUE}Starting API server...${NC}"
cargo run --release --bin api_server &
API_PID=$!
# Wait for API server to start
sleep 3
# Check if API server is running
if curl -s http://localhost:3000/api/configs > /dev/null; then
echo -e "${GREEN}✅ API server running on http://localhost:3000${NC}"
else
echo -e "${RED}❌ API server failed to start${NC}"
kill $API_PID 2>/dev/null || true
exit 1
fi
# Start web interface
echo -e "${BLUE}Starting web interface...${NC}"
cd web
npm run dev &
WEB_PID=$!
cd ..
# Wait for web interface to start
sleep 5
echo -e "${GREEN}✅ Web interface running on http://localhost:5173${NC}"
# Start desktop GUI if Tauri is available
if command_exists cargo-tauri; then
echo -e "${BLUE}Starting desktop GUI...${NC}"
cargo tauri dev &
GUI_PID=$!
echo -e "${GREEN}✅ Desktop GUI launching...${NC}"
else
echo -e "${YELLOW}⚠️ Tauri not installed. Skipping desktop GUI.${NC}"
echo -e "${YELLOW} Install with: cargo install tauri-cli${NC}"
fi
echo -e "\n${GREEN}🎉 All interfaces started successfully!${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}📊 API Server: ${NC}http://localhost:3000"
echo -e "${GREEN}🌐 Web Interface: ${NC}http://localhost:5173"
if command_exists cargo-tauri; then
echo -e "${GREEN}🖥️ Desktop GUI: ${NC}Opening in new window"
fi
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}"
# Keep script running
wait

200
test_interfaces.sh Executable file
View File

@ -0,0 +1,200 @@
#!/bin/bash
# Test script for Orbital Simulator Interfaces
# This script tests all available interfaces and functionality
echo "🧪 Testing Orbital Simulator Interfaces"
echo "======================================="
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Test results
TESTS_PASSED=0
TESTS_FAILED=0
# Function for test output
test_result() {
if [ $1 -eq 0 ]; then
echo -e "${GREEN}$2${NC}"
((TESTS_PASSED++))
else
echo -e "${RED}$2${NC}"
((TESTS_FAILED++))
fi
}
# Test 1: Check prerequisites
echo -e "${BLUE}Test 1: Prerequisites${NC}"
command -v cargo &> /dev/null
test_result $? "Rust/Cargo available"
command -v node &> /dev/null
test_result $? "Node.js available"
command -v npm &> /dev/null
test_result $? "npm available"
command -v python3 &> /dev/null
test_result $? "Python 3 available"
# Test 2: Build Rust project
echo -e "\n${BLUE}Test 2: Rust Project Build${NC}"
cargo check --bin api_server --no-default-features &> /dev/null
test_result $? "API server builds without GUI features"
cargo check --bin simulator &> /dev/null
test_result $? "CLI simulator builds"
# Test 3: Web dependencies
echo -e "\n${BLUE}Test 3: Web Frontend${NC}"
cd web
npm install &> /dev/null
test_result $? "Web dependencies install successfully"
npm run build &> /dev/null
test_result $? "Web frontend builds successfully"
cd ..
# Test 4: Start services for testing
echo -e "\n${BLUE}Test 4: Service Integration${NC}"
# Start API server
echo "Starting API server for testing..."
cargo run --bin api_server --no-default-features &> /dev/null &
API_PID=$!
sleep 5
# Test API endpoints
if curl -s http://localhost:3000/api/configs > /dev/null; then
test_result 0 "API server responds to requests"
# Test creating a simulation
RESPONSE=$(curl -s -X POST http://localhost:3000/api/simulations \
-H "Content-Type: application/json" \
-d '{
"config": {
"bodies": [
{
"name": "Sun",
"mass": 1.989e30,
"position": [0.0, 0.0, 0.0],
"velocity": [0.0, 0.0, 0.0]
},
{
"name": "Earth",
"mass": 5.972e24,
"position": [147095000000.0, 0.0, 0.0],
"velocity": [0.0, 30290.0, 0.0]
}
]
},
"step_size": 3600,
"total_steps": 100
}')
if echo "$RESPONSE" | grep -q '"id"'; then
test_result 0 "Simulation creation API works"
SIM_ID=$(echo "$RESPONSE" | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
# Test simulation control
curl -s -X POST "http://localhost:3000/api/simulations/$SIM_ID/control?action=start" > /dev/null
test_result $? "Simulation control API works"
sleep 1
# Test getting simulation state
STATE_RESPONSE=$(curl -s "http://localhost:3000/api/simulations/$SIM_ID")
if echo "$STATE_RESPONSE" | grep -q '"step"'; then
test_result 0 "Simulation state retrieval works"
else
test_result 1 "Simulation state retrieval fails"
fi
# Test simulation list
LIST_RESPONSE=$(curl -s http://localhost:3000/api/simulations)
if echo "$LIST_RESPONSE" | grep -q "$SIM_ID"; then
test_result 0 "Simulation listing works"
else
test_result 1 "Simulation listing fails"
fi
else
test_result 1 "Simulation creation API fails"
fi
else
test_result 1 "API server not responding"
fi
# Clean up
kill $API_PID 2>/dev/null || true
wait $API_PID 2>/dev/null || true
# Test 5: CLI tools
echo -e "\n${BLUE}Test 5: CLI Tools${NC}"
# Test simulator with sample config
if [ -f "config/planets.toml" ]; then
timeout 5s cargo run --bin simulator -- --config config/planets.toml --time 1h --step-size 3600 --output-file test_output.bin &> /dev/null
test_result $? "CLI simulator runs with config file"
rm -f test_output.bin 2>/dev/null
else
test_result 1 "Sample config file not found"
fi
# Test 6: Python plotting tools
echo -e "\n${BLUE}Test 6: Python Tools${NC}"
if [ -f "requirements.txt" ]; then
# Create virtual environment if it doesn't exist
if [ ! -d ".venv" ]; then
python3 -m venv .venv
fi
source .venv/bin/activate
pip install -r requirements.txt &> /dev/null
test_result $? "Python dependencies install"
# Test if plotting script can be imported
if python3 -c "import plot_trajectories" &> /dev/null; then
test_result 0 "Python plotting tools available"
else
test_result 1 "Python plotting tools not working"
fi
else
test_result 1 "Python requirements file not found"
fi
# Test 7: GUI availability
echo -e "\n${BLUE}Test 7: GUI Availability${NC}"
if command -v cargo-tauri &> /dev/null; then
test_result 0 "Tauri CLI available for desktop GUI"
# Try to check Tauri build (don't actually build to save time)
if [ -f "src-tauri/Cargo.toml" ]; then
test_result 0 "Tauri project structure exists"
else
test_result 1 "Tauri project structure missing"
fi
else
echo -e "${YELLOW}⚠️ Tauri CLI not installed - desktop GUI not available${NC}"
echo " Install with: cargo install tauri-cli"
fi
# Final results
echo -e "\n${BLUE}Test Results Summary${NC}"
echo "==================="
echo -e "${GREEN}Tests Passed: $TESTS_PASSED${NC}"
echo -e "${RED}Tests Failed: $TESTS_FAILED${NC}"
if [ $TESTS_FAILED -eq 0 ]; then
echo -e "\n${GREEN}🎉 All tests passed! The orbital simulator is ready to use.${NC}"
echo -e "${BLUE}Run ./start_interfaces.sh to start all interfaces.${NC}"
exit 0
else
echo -e "\n${YELLOW}⚠️ Some tests failed. Check the output above for details.${NC}"
exit 1
fi

13
web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/orbital-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Orbital Simulator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4470
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
web/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "orbital-simulator-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@types/uuid": "^9.0.7",
"axios": "^1.6.2",
"clsx": "^2.0.0",
"lucide-react": "^0.294.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^2.8.0",
"three": "^0.160.1",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/three": "^0.160.0",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.1.1",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}

318
web/src/App.tsx Normal file
View File

@ -0,0 +1,318 @@
import React, { useState, useEffect } from 'react';
import SimulationCanvas from './components/SimulationCanvas';
import SimulationControls from './components/SimulationControls';
import SimulationList from './components/SimulationList';
import ConfigurationPanel from './components/ConfigurationPanel';
import { Play, Pause, Square } from 'lucide-react';
export interface Body {
name: string;
mass: number;
position: [number, number, number];
velocity: [number, number, number];
}
export interface Config {
bodies: Body[];
normalization?: {
m_0: number;
r_0: number;
t_0: number;
};
}
export interface SimulationInfo {
id: string;
is_running: boolean;
current_step: number;
playback_step: number;
total_steps: number;
recorded_steps: number;
bodies_count: number;
elapsed_time: number;
}
export interface BodyState {
name: string;
position: [number, number, number];
velocity: [number, number, number];
mass: number;
}
export interface SimulationUpdate {
step: number;
time: number;
bodies: BodyState[];
energy?: {
kinetic: number;
potential: number;
total: number;
};
}
function App() {
const [simulations, setSimulations] = useState<SimulationInfo[]>([]);
const [selectedSimulation, setSelectedSimulation] = useState<string | null>(null);
const [currentData, setCurrentData] = useState<SimulationUpdate | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isAutoPlaying, setIsAutoPlaying] = useState(true); // Auto-play timeline by default
const [sessionId] = useState(() => {
// Generate or retrieve session ID from localStorage
let id = localStorage.getItem('orbital-sim-session');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('orbital-sim-session', id);
}
return id;
});
// Helper function to add session header to requests
const getHeaders = () => ({
'Content-Type': 'application/json',
'X-Session-ID': sessionId,
});
// Fetch simulations list
const fetchSimulations = async () => {
try {
const response = await fetch('/api/simulations', {
headers: { 'X-Session-ID': sessionId },
});
if (response.ok) {
const data = await response.json();
setSimulations(data);
}
} catch (err) {
setError('Failed to fetch simulations');
}
};
// Create new simulation
const createSimulation = async (config: Config, stepSize: number, totalSteps: number) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/simulations', {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({
config,
step_size: stepSize,
total_steps: totalSteps,
}),
});
if (response.ok) {
const newSim = await response.json();
setSelectedSimulation(newSim.id);
await fetchSimulations();
} else {
setError('Failed to create simulation');
}
} catch (err) {
setError('Failed to create simulation');
} finally {
setIsLoading(false);
}
};
// Control simulation
const controlSimulation = async (id: string, action: string) => {
try {
const response = await fetch(`/api/simulations/${id}/control?action=${action}`, {
method: 'POST',
headers: { 'X-Session-ID': sessionId },
});
if (response.ok) {
await fetchSimulations();
}
} catch (err) {
setError(`Failed to ${action} simulation`);
}
};
// Delete simulation
const deleteSimulation = async (id: string) => {
try {
const response = await fetch(`/api/simulations/${id}`, {
method: 'DELETE',
headers: { 'X-Session-ID': sessionId },
});
if (response.ok) {
if (selectedSimulation === id) {
setSelectedSimulation(null);
setCurrentData(null);
}
await fetchSimulations();
}
} catch (err) {
setError('Failed to delete simulation');
}
};
// Seek to specific simulation step
const seekToStep = async (id: string, step: number) => {
try {
const response = await fetch(`/api/simulations/${id}/seek?step=${step}`, {
method: 'POST',
headers: { 'X-Session-ID': sessionId },
});
if (response.ok) {
const data = await response.json();
setCurrentData(data);
await fetchSimulations(); // Update playback_step in simulation info
} else {
setError('Failed to seek simulation');
}
} catch (err) {
setError('Failed to seek simulation');
}
};
// Timeline control functions
const handleSeek = (step: number) => {
if (selectedSimulation) {
seekToStep(selectedSimulation, step);
}
};
const handleToggleAutoPlay = () => {
setIsAutoPlaying(!isAutoPlaying);
};
const handleRestart = () => {
if (selectedSimulation) {
seekToStep(selectedSimulation, 0);
}
};
// Poll simulation data (only when auto-playing or simulation is running)
useEffect(() => {
if (!selectedSimulation) return;
const interval = setInterval(async () => {
const selectedSim = simulations.find(s => s.id === selectedSimulation);
// Only poll if auto-playing is enabled or if simulation is running
if (!isAutoPlaying && selectedSim && !selectedSim.is_running) {
return;
}
try {
const response = await fetch(`/api/simulations/${selectedSimulation}`, {
headers: { 'X-Session-ID': sessionId },
});
if (response.ok) {
const data = await response.json();
setCurrentData(data);
}
} catch (err) {
// Silently fail for polling
}
}, 100); // 10 FPS updates
return () => clearInterval(interval);
}, [selectedSimulation, isAutoPlaying, simulations]);
// Initial load
useEffect(() => {
fetchSimulations();
}, []);
const selectedSim = simulations.find(s => s.id === selectedSimulation);
return (
<div className="app">
<header className="header">
<div className="logo">Orbital Simulator</div>
<div className="controls">
{selectedSim && (
<>
<button
className="button"
onClick={() => controlSimulation(selectedSim.id, selectedSim.is_running ? 'pause' : 'start')}
disabled={isLoading}
>
{selectedSim.is_running ? <Pause size={16} /> : <Play size={16} />}
{selectedSim.is_running ? 'Pause' : 'Start'}
</button>
<button
className="button secondary"
onClick={() => controlSimulation(selectedSim.id, 'step')}
disabled={isLoading || selectedSim.is_running}
>
Step
</button>
<button
className="button secondary"
onClick={() => controlSimulation(selectedSim.id, 'stop')}
disabled={isLoading}
>
<Square size={16} />
Stop
</button>
</>
)}
</div>
</header>
<div className="main-content">
<div className="sidebar">
<ConfigurationPanel
onCreateSimulation={createSimulation}
isLoading={isLoading}
/>
<SimulationList
simulations={simulations}
selectedSimulation={selectedSimulation}
currentData={currentData}
isAutoPlaying={isAutoPlaying}
onSelectSimulation={setSelectedSimulation}
onDeleteSimulation={deleteSimulation}
onControlSimulation={controlSimulation}
onSeek={handleSeek}
onToggleAutoPlay={handleToggleAutoPlay}
onRestart={handleRestart}
/>
{selectedSim && currentData && (
<SimulationControls
data={currentData}
/>
)}
</div>
<div className="simulation-view">
{error && (
<div className="error">
{error}
<button
className="button secondary"
onClick={() => setError(null)}
style={{ marginLeft: '1rem' }}
>
Dismiss
</button>
</div>
)}
{currentData ? (
<SimulationCanvas data={currentData} />
) : selectedSimulation ? (
<div className="loading">Loading simulation data...</div>
) : (
<div className="loading">Select or create a simulation to begin</div>
)}
</div>
</div>
</div>
);
}
export default App;

View File

@ -0,0 +1,288 @@
import React, { useState } from 'react';
import { Config, Body } from '../App';
interface Props {
onCreateSimulation: (config: Config, stepSize: number, totalSteps: number) => void;
isLoading: boolean;
}
const ConfigurationPanel: React.FC<Props> = ({ onCreateSimulation, isLoading }) => {
const [selectedConfig, setSelectedConfig] = useState('planets.toml');
const [stepSize, setStepSize] = useState(3600);
const [simulationTime, setSimulationTime] = useState(365);
const [customConfig, setCustomConfig] = useState<Config | null>(null);
const [showCustomEditor, setShowCustomEditor] = useState(false);
// Predefined configurations
const presetConfigs: Record<string, Config> = {
'earthsun_corrected.toml': {
bodies: [
{
name: 'Sun',
mass: 1.989e30,
position: [0.0, 0.0, 0.0],
velocity: [0.0, 0.0, 0.0],
},
{
name: 'Earth',
mass: 5.972e24,
position: [147095000000.0, 0.0, 0.0],
velocity: [0.0, 30290.0, 0.0],
},
],
},
'inner_solar_system.toml': {
bodies: [
{
name: 'Sun',
mass: 1.989e30,
position: [0.0, 0.0, 0.0],
velocity: [0.0, 0.0, 0.0],
},
{
name: 'Mercury',
mass: 3.30104e23,
position: [4.6000e10, 0.0, 0.0],
velocity: [0.0, 58970.0, 0.0],
},
{
name: 'Venus',
mass: 4.8675e24,
position: [1.08939e11, 0.0, 0.0],
velocity: [0.0, 34780.0, 0.0],
},
{
name: 'Earth',
mass: 5.972e24,
position: [1.496e11, 0.0, 0.0],
velocity: [0.0, 29789.0, 0.0],
},
{
name: 'Moon',
mass: 7.34767309e22,
position: [1.49984e11, 0.0, 0.0],
velocity: [0.0, 30813.0, 0.0],
},
],
},
'solar_system.toml': {
bodies: [
{
name: 'Sun',
mass: 1.989e30,
position: [0.0, 0.0, 0.0],
velocity: [0.0, 0.0, 0.0],
},
{
name: 'Mercury',
mass: 3.30104e23,
position: [4.6000e10, 0.0, 0.0],
velocity: [0.0, 58970.0, 0.0],
},
{
name: 'Venus',
mass: 4.8675e24,
position: [1.08939e11, 0.0, 0.0],
velocity: [0.0, 34780.0, 0.0],
},
{
name: 'Earth',
mass: 5.972e24,
position: [1.496e11, 0.0, 0.0],
velocity: [0.0, 29789.0, 0.0],
},
{
name: 'Mars',
mass: 6.4171e23,
position: [2.279e11, 0.0, 0.0],
velocity: [0.0, 24007.0, 0.0],
},
{
name: 'Jupiter',
mass: 1.8982e27,
position: [7.785e11, 0.0, 0.0],
velocity: [0.0, 13070.0, 0.0],
},
{
name: 'Saturn',
mass: 5.6834e26,
position: [1.432e12, 0.0, 0.0],
velocity: [0.0, 9680.0, 0.0],
},
{
name: 'Uranus',
mass: 8.6810e25,
position: [2.867e12, 0.0, 0.0],
velocity: [0.0, 6810.0, 0.0],
},
{
name: 'Neptune',
mass: 1.0241e26,
position: [4.515e12, 0.0, 0.0],
velocity: [0.0, 5430.0, 0.0],
},
],
},
'planets.toml': {
bodies: [
{
name: 'Sun',
mass: 1.989e30,
position: [0.0, 0.0, 0.0],
velocity: [0.0, 0.0, 0.0],
},
{
name: 'Mercury',
mass: 3.30104e23,
position: [4.6000e10, 0.0, 0.0],
velocity: [0.0, 58970.0, 0.0],
},
{
name: 'Venus',
mass: 4.867e24,
position: [108941000000.0, 0.0, 0.0],
velocity: [0.0, 34780.0, 0.0],
},
{
name: 'Earth',
mass: 5.972e24,
position: [147095000000.0, 0.0, 0.0],
velocity: [0.0, 30290.0, 0.0],
},
{
name: 'Moon',
mass: 7.34767309e22,
position: [147458300000, 0.0, 0.0],
velocity: [0.0, 31000.0, 0.0],
},
{
name: 'Mars',
mass: 6.4171e23,
position: [206620000000.0, 0.0, 0.0],
velocity: [0.0, 26500.0, 0.0],
},
{
name: 'Jupiter',
mass: 1.8982e27,
position: [740520000000.0, 0.0, 0.0],
velocity: [0.0, 13720.0, 0.0],
},
{
name: 'Saturn',
mass: 5.6834e26,
position: [1352550000000.0, 0.0, 0.0],
velocity: [0.0, 10180.0, 0.0],
},
{
name: 'Uranus',
mass: 8.6810e25,
position: [2741300000000.0, 0.0, 0.0],
velocity: [0.0, 7110.0, 0.0],
},
{
name: 'Neptune',
mass: 1.0241e26,
position: [4444450000000.0, 0.0, 0.0],
velocity: [0.0, 5500.0, 0.0],
},
],
},
};
const handleCreateSimulation = () => {
const config = customConfig || presetConfigs[selectedConfig];
if (!config) return;
const totalSteps = Math.floor((simulationTime * 24 * 3600) / stepSize);
onCreateSimulation(config, stepSize, totalSteps);
};
return (
<div>
<h3>New Simulation</h3>
<div className="input-group">
<label>Configuration</label>
<select
value={selectedConfig}
onChange={(e) => setSelectedConfig(e.target.value)}
disabled={showCustomEditor}
>
<option value="earthsun_corrected.toml">Earth-Sun System</option>
<option value="inner_solar_system.toml">Inner Solar System</option>
<option value="solar_system.toml">Solar System</option>
<option value="planets.toml">Complete System</option>
</select>
</div>
<div className="input-group">
<label>Step Size (seconds)</label>
<input
type="number"
value={stepSize}
onChange={(e) => setStepSize(Number(e.target.value))}
min={1}
max={86400}
/>
</div>
<div className="input-group">
<label>Simulation Time (days)</label>
<input
type="number"
value={simulationTime}
onChange={(e) => setSimulationTime(Number(e.target.value))}
min={1}
max={10000}
/>
</div>
<button
className="button"
onClick={handleCreateSimulation}
disabled={isLoading}
style={{ width: '100%', marginBottom: '1rem' }}
>
{isLoading ? 'Creating...' : 'Create Simulation'}
</button>
<button
className="button secondary"
onClick={() => setShowCustomEditor(!showCustomEditor)}
style={{ width: '100%' }}
>
{showCustomEditor ? 'Use Preset' : 'Custom Config'}
</button>
{showCustomEditor && (
<div className="input-group">
<label>Custom Configuration (JSON)</label>
<textarea
rows={10}
placeholder={JSON.stringify(presetConfigs['earthsun_corrected.toml'], null, 2)}
onChange={(e) => {
try {
const config = JSON.parse(e.target.value);
setCustomConfig(config);
} catch {
setCustomConfig(null);
}
}}
style={{
width: '100%',
background: '#333',
border: '1px solid #555',
borderRadius: '4px',
color: 'white',
padding: '0.5rem',
fontFamily: 'monospace',
fontSize: '12px',
}}
/>
</div>
)}
</div>
);
};
export default ConfigurationPanel;

View File

@ -0,0 +1,299 @@
import React, { useRef, useEffect, useState } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Text } from '@react-three/drei';
import * as THREE from 'three';
interface Body {
name: string;
position: [number, number, number];
velocity: [number, number, number];
mass: number;
}
interface SimulationUpdate {
step: number;
time: number;
bodies: Body[];
}
interface Props {
data: SimulationUpdate;
resetTrails?: boolean; // Signal to reset all trails
}
// Component for rendering a celestial body
function CelestialBody({ body, index, scaleFactor, resetTrails }: {
body: Body;
index: number;
scaleFactor: number;
resetTrails?: boolean;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const trailRef = useRef<THREE.Line>(null);
const trailPoints = useRef<THREE.Vector3[]>([]);
const lastPosition = useRef<THREE.Vector3 | null>(null);
const lastScaleFactor = useRef<number>(scaleFactor);
// Color palette for different bodies
const colors = [
'#FFD700', // Gold for Sun
'#FFA500', // Orange for Mercury
'#FF6347', // Tomato for Venus
'#4169E1', // Royal Blue for Earth
'#FF0000', // Red for Mars
'#DAA520', // Goldenrod for Jupiter
'#F4A460', // Sandy Brown for Saturn
'#40E0D0', // Turquoise for Uranus
'#0000FF', // Blue for Neptune
'#DDA0DD', // Plum for other bodies
];
const color = colors[index % colors.length];
// Scale positions based on the scale factor
// Default scale factor of 1 means 1 AU, scale factor of 0.1 means 0.1 AU, etc.
const AU = 1.496e11; // 1 AU in meters
const scaledPosition = [
body.position[0] / (AU * scaleFactor),
body.position[1] / (AU * scaleFactor),
body.position[2] / (AU * scaleFactor)
];
// Calculate sphere size with better scaling for visualization
// Make the Sun larger and planets visible
let size;
if (body.name.toLowerCase().includes('sun')) {
size = 0.2; // Sun gets a fixed, visible size
} else {
// Scale planet sizes logarithmically but make them visible
const earthMass = 5.972e24;
const massRatio = body.mass / earthMass;
size = Math.max(0.03, Math.min(Math.pow(massRatio, 1/3) * 0.08, 0.15));
}
// Update trail with AU-scaled positions
useEffect(() => {
const position = new THREE.Vector3(...scaledPosition);
// Check if we need to reset trails
const shouldReset = resetTrails ||
Math.abs(scaleFactor - lastScaleFactor.current) > 0.01 || // Scale factor changed
(lastPosition.current && position.distanceTo(lastPosition.current) > 5); // Big position jump
if (shouldReset) {
// Reset trails for discontinuous changes
trailPoints.current = [position.clone()];
} else {
// Normal trail update
trailPoints.current.push(position.clone());
// Keep trail length manageable
if (trailPoints.current.length > 1000) {
trailPoints.current.shift();
}
}
// Update references for next comparison
lastPosition.current = position.clone();
lastScaleFactor.current = scaleFactor;
// Update trail geometry
if (trailRef.current && trailPoints.current.length > 1) {
const geometry = new THREE.BufferGeometry().setFromPoints(trailPoints.current);
trailRef.current.geometry.dispose();
trailRef.current.geometry = geometry;
}
}, [body.position, scaleFactor, resetTrails]);
return (
<group>
{/* Trail */}
{trailPoints.current.length > 1 && (
<primitive
object={new THREE.Line(
new THREE.BufferGeometry().setFromPoints(trailPoints.current),
new THREE.LineBasicMaterial({ color: color, transparent: true, opacity: 0.5 })
)}
/>
)}
{/* Body */}
<mesh
ref={meshRef}
position={scaledPosition as [number, number, number]}
>
<sphereGeometry args={[size, 16, 16]} />
<meshStandardMaterial
color={color}
emissive={index === 0 ? color : '#000000'} // Sun glows
emissiveIntensity={index === 0 ? 0.3 : 0}
/>
</mesh>
{/* Body label */}
<Text
position={[
scaledPosition[0],
scaledPosition[1] + size + 0.3,
scaledPosition[2],
]}
fontSize={0.1}
color="white"
anchorX="center"
anchorY="middle"
>
{body.name}
</Text>
</group>
);
}
// Component for the 3D scene
function Scene({ data, scaleFactor, resetTrails }: Props & { scaleFactor: number }) {
return (
<>
{/* Lighting */}
<ambientLight intensity={0.3} />
<pointLight position={[0, 0, 0]} intensity={2} color="#FFD700" />
<pointLight position={[100, 100, 100]} intensity={0.5} />
{/* Bodies */}
{data.bodies.map((body, index) => (
<CelestialBody
key={body.name}
body={body}
index={index}
scaleFactor={scaleFactor}
resetTrails={resetTrails}
/>
))}
{/* Grid removed for cleaner space view */}
{/* Controls */}
<OrbitControls
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={0.5}
maxDistance={50}
/>
</>
);
}
const SimulationCanvas: React.FC<Props> = ({ data, resetTrails }) => {
const [scaleFactor, setScaleFactor] = useState(1.0); // 1.0 = 1 AU
const [internalResetTrails, setInternalResetTrails] = useState(false);
const lastStep = useRef<number>(data.step);
const lastScaleFactor = useRef<number>(1.0);
// Detect big timeline jumps
useEffect(() => {
const stepDifference = Math.abs(data.step - lastStep.current);
const isBigJump = stepDifference > 100; // Consider jumps > 100 steps as "big"
if (isBigJump) {
setInternalResetTrails(true);
// Reset the flag after a short delay to trigger the effect
setTimeout(() => setInternalResetTrails(false), 50);
}
lastStep.current = data.step;
}, [data.step]);
// Handle external reset signals and scale changes
useEffect(() => {
const scaleChanged = Math.abs(scaleFactor - lastScaleFactor.current) > 0.01;
if (resetTrails || scaleChanged) {
setInternalResetTrails(true);
setTimeout(() => setInternalResetTrails(false), 50);
}
lastScaleFactor.current = scaleFactor;
}, [resetTrails, scaleFactor]);
const scaleOptions = [
{ value: 0.1, label: '0.1 AU', description: 'Inner planets' },
{ value: 0.5, label: '0.5 AU', description: 'Close view' },
{ value: 1.0, label: '1 AU', description: 'Default' },
{ value: 2.0, label: '2 AU', description: 'Wide view' },
{ value: 5.0, label: '5 AU', description: 'Outer system' },
{ value: 10.0, label: '10 AU', description: 'Very wide' },
];
const getCurrentScaleLabel = () => {
const current = scaleOptions.find(opt => opt.value === scaleFactor);
return current ? current.label : `${scaleFactor} AU`;
};
return (
<div style={{ width: '100%', height: '100%' }}>
<Canvas
camera={{
position: [5, 5, 5],
fov: 60,
}}
style={{ background: '#000' }}
>
<Scene data={data} scaleFactor={scaleFactor} resetTrails={resetTrails || internalResetTrails} />
</Canvas>
{/* Scale control slider */}
<div style={{
position: 'absolute',
top: '1rem',
right: '1rem',
background: 'rgba(0, 0, 0, 0.7)',
padding: '1rem',
borderRadius: '8px',
color: 'white',
fontFamily: 'monospace',
minWidth: '200px',
}}>
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '0.9rem' }}>View Scale</h4>
<div style={{ marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.8rem', color: '#ccc' }}>Scale: {getCurrentScaleLabel()}</span>
</div>
<input
type="range"
min="0.1"
max="10"
step="0.1"
value={scaleFactor}
onChange={(e) => setScaleFactor(parseFloat(e.target.value))}
style={{
width: '100%',
marginBottom: '0.5rem',
}}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7rem', color: '#999' }}>
<span>Close</span>
<span>Far</span>
</div>
</div>
{/* Overlay information */}
<div style={{
position: 'absolute',
top: '1rem',
left: '1rem',
background: 'rgba(0, 0, 0, 0.7)',
padding: '1rem',
borderRadius: '8px',
color: 'white',
fontFamily: 'monospace',
}}>
<div>Step: {data.step.toLocaleString()}</div>
<div>Time: {(data.time / 86400).toFixed(2)} days</div>
<div>Bodies: {data.bodies.length}</div>
<div style={{ fontSize: '0.9rem', marginTop: '0.5rem', color: '#ccc' }}>
Scale: 1 unit = {scaleFactor} AU ({(scaleFactor * 150).toFixed(0)}M km)
</div>
</div>
</div>
);
};
export default SimulationCanvas;

View File

@ -0,0 +1,59 @@
import React from 'react';
import { SimulationUpdate } from '../App';
interface Props {
data: SimulationUpdate;
}
const SimulationControls: React.FC<Props> = ({ data }) => {
const formatNumber = (num: number) => {
if (num > 1e9) return `${(num / 1e9).toFixed(2)}B`;
if (num > 1e6) return `${(num / 1e6).toFixed(2)}M`;
if (num > 1e3) return `${(num / 1e3).toFixed(2)}K`;
return num.toFixed(0);
};
return (
<div className="controls-panel">
<div className="view-controls">
<h4>Bodies</h4>
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{data.bodies.map((body) => (
<div key={body.name} className="stat-item" style={{ marginBottom: '0.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '0.9rem' }}>{body.name}</span>
<span style={{ fontSize: '0.8rem', color: '#999' }}>
{formatNumber(Math.sqrt(
body.position[0] ** 2 + body.position[1] ** 2 + body.position[2] ** 2
) / 1.496e11)} AU
</span>
</div>
</div>
))}
</div>
</div>
{data.energy && (
<div className="view-controls">
<h4>Energy</h4>
<div className="stats-grid">
<div className="stat-item">
<div className="stat-label">Kinetic</div>
<div className="stat-value">{data.energy.kinetic.toExponential(2)}</div>
</div>
<div className="stat-item">
<div className="stat-label">Potential</div>
<div className="stat-value">{data.energy.potential.toExponential(2)}</div>
</div>
<div className="stat-item">
<div className="stat-label">Total</div>
<div className="stat-value">{data.energy.total.toExponential(2)}</div>
</div>
</div>
</div>
)}
</div>
);
};
export default SimulationControls;

View File

@ -0,0 +1,138 @@
import React from 'react';
import { SimulationInfo, SimulationUpdate } from '../App';
import { Play, Pause, Square, Trash2 } from 'lucide-react';
import TimelineSlider from './TimelineSlider';
interface Props {
simulations: SimulationInfo[];
selectedSimulation: string | null;
currentData: SimulationUpdate | null;
isAutoPlaying: boolean;
onSelectSimulation: (id: string) => void;
onDeleteSimulation: (id: string) => void;
onControlSimulation: (id: string, action: string) => void;
onSeek: (step: number) => void;
onToggleAutoPlay: () => void;
onRestart: () => void;
}
const SimulationList: React.FC<Props> = ({
simulations,
selectedSimulation,
currentData,
isAutoPlaying,
onSelectSimulation,
onDeleteSimulation,
onControlSimulation,
onSeek,
onToggleAutoPlay,
onRestart,
}) => {
const formatTime = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="simulation-list-container">
<h3>Active Simulations</h3>
{simulations.length === 0 ? (
<div className="loading">No simulations running</div>
) : (
<div className="simulations-scroll">
{simulations.map((sim) => (
<div key={sim.id} className="simulation-item">
<div
className={`simulation-card ${selectedSimulation === sim.id ? 'selected' : ''}`}
onClick={() => onSelectSimulation(sim.id)}
style={{
cursor: 'pointer',
border: selectedSimulation === sim.id ? '2px solid #00aaff' : '1px solid #444',
}}
>
<div className="simulation-status">
<div className={`status-indicator ${sim.is_running ? 'running' : 'paused'}`} />
<span>{sim.is_running ? 'Running' : 'Paused'}</span>
</div>
<div className="stats-grid">
<div className="stat-item">
<div className="stat-label">Bodies</div>
<div className="stat-value">{sim.bodies_count}</div>
</div>
<div className="stat-item">
<div className="stat-label">Step</div>
<div className="stat-value">{sim.current_step.toLocaleString()}</div>
</div>
<div className="stat-item">
<div className="stat-label">Runtime</div>
<div className="stat-value">{formatTime(sim.elapsed_time)}</div>
</div>
<div className="stat-item">
<div className="stat-label">ID</div>
<div className="stat-value" style={{ fontSize: '0.7rem' }}>
{sim.id.substring(0, 8)}...
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<button
className="button"
onClick={(e) => {
e.stopPropagation();
onControlSimulation(sim.id, sim.is_running ? 'pause' : 'start');
}}
style={{ flex: 1 }}
>
{sim.is_running ? <Pause size={14} /> : <Play size={14} />}
</button>
<button
className="button secondary"
onClick={(e) => {
e.stopPropagation();
onControlSimulation(sim.id, 'stop');
}}
>
<Square size={14} />
</button>
<button
className="button danger"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete this simulation?')) {
onDeleteSimulation(sim.id);
}
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Timeline slider slides out from selected simulation */}
{selectedSimulation === sim.id && currentData && (
<div className="timeline-slideout">
<TimelineSlider
simulation={sim}
isAutoPlaying={isAutoPlaying}
onSeek={onSeek}
onToggleAutoPlay={onToggleAutoPlay}
onRestart={onRestart}
/>
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
export default SimulationList;

View File

@ -0,0 +1,144 @@
import React, { useState, useEffect } from 'react';
import { Play, Pause, RotateCcw } from 'lucide-react';
import { SimulationInfo } from '../App';
interface Props {
simulation: SimulationInfo;
isAutoPlaying: boolean;
onSeek: (step: number) => void;
onToggleAutoPlay: () => void;
onRestart: () => void;
}
const TimelineSlider: React.FC<Props> = ({
simulation,
isAutoPlaying,
onSeek,
onToggleAutoPlay,
onRestart
}) => {
const [localStep, setLocalStep] = useState(simulation.playback_step);
const [isDragging, setIsDragging] = useState(false);
// Update local step when simulation updates (but not while dragging)
useEffect(() => {
if (!isDragging) {
setLocalStep(simulation.playback_step);
}
}, [simulation.playback_step, isDragging]);
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const step = parseInt(e.target.value);
setLocalStep(step);
// Immediately seek to the new step for real-time updates
onSeek(step);
};
const handleSliderMouseDown = () => {
setIsDragging(true);
};
const handleSliderMouseUp = () => {
setIsDragging(false);
// Final seek to ensure we're at the exact position
onSeek(localStep);
};
const handleSliderKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSeek(localStep);
}
};
const formatStep = (step: number) => {
if (step > 1e6) return `${(step / 1e6).toFixed(1)}M`;
if (step > 1e3) return `${(step / 1e3).toFixed(1)}K`;
return step.toString();
};
const progressPercent = simulation.recorded_steps > 0
? (simulation.current_step / Math.max(simulation.recorded_steps, simulation.total_steps)) * 100
: 0;
return (
<div className="timeline-slider">
<div className="timeline-header">
<h4>Timeline Control</h4>
<div className="timeline-info">
<span>Step: {formatStep(localStep)} / {formatStep(simulation.recorded_steps)}</span>
<span>Progress: {progressPercent.toFixed(1)}%</span>
</div>
</div>
<div className="timeline-controls">
<button
onClick={onRestart}
className="control-button"
title="Restart from beginning"
>
<RotateCcw size={16} />
</button>
<button
onClick={onToggleAutoPlay}
className={`control-button ${isAutoPlaying ? 'active' : ''}`}
title={isAutoPlaying ? 'Pause auto-play' : 'Start auto-play'}
>
{isAutoPlaying ? <Pause size={16} /> : <Play size={16} />}
</button>
</div>
<div className="slider-container">
{/* Progress bar showing simulation progress */}
<div className="progress-track">
<div
className="progress-bar"
style={{ width: `${progressPercent}%` }}
/>
</div>
{/* Main timeline slider */}
<input
type="range"
min="0"
max={Math.max(simulation.recorded_steps - 1, 0)}
value={localStep}
onChange={handleSliderChange}
onMouseDown={handleSliderMouseDown}
onMouseUp={handleSliderMouseUp}
onKeyUp={handleSliderKeyUp}
className="timeline-range"
disabled={simulation.recorded_steps === 0}
/>
{/* Step markers */}
<div className="step-markers">
<span className="step-marker start">0</span>
<span className="step-marker current">
{formatStep(localStep)}
</span>
<span className="step-marker end">
{formatStep(Math.max(simulation.total_steps, simulation.recorded_steps))}
</span>
</div>
</div>
<div className="timeline-status">
<div className="status-item">
<span className="status-label">Simulation:</span>
<span className={`status-value ${simulation.is_running ? 'running' : 'paused'}`}>
{simulation.is_running ? 'Running' : 'Paused'}
</span>
</div>
<div className="status-item">
<span className="status-label">Auto-play:</span>
<span className={`status-value ${isAutoPlaying ? 'active' : 'inactive'}`}>
{isAutoPlaying ? 'On' : 'Off'}
</span>
</div>
</div>
</div>
);
};
export default TimelineSlider;

482
web/src/index.css Normal file
View File

@ -0,0 +1,482 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: #0a0a0a;
color: #ffffff;
overflow: hidden;
}
.app {
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: #1a1a1a;
padding: 1rem 2rem;
border-bottom: 1px solid #333;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
color: #00aaff;
}
.controls {
display: flex;
gap: 1rem;
align-items: center;
}
.main-content {
flex: 1;
display: flex;
}
.sidebar {
width: 300px;
background: #1a1a1a;
border-right: 1px solid #333;
padding: 1rem;
overflow-y: auto;
}
.simulation-view {
flex: 1;
position: relative;
}
.button {
background: #00aaff;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.button:hover {
background: #0088cc;
}
.button:disabled {
background: #666;
cursor: not-allowed;
}
.button.secondary {
background: #333;
}
.button.secondary:hover {
background: #444;
}
.button.danger {
background: #ff4444;
}
.button.danger:hover {
background: #cc0000;
}
.input-group {
margin-bottom: 1rem;
}
.input-group label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
}
.input-group input,
.input-group select {
width: 100%;
padding: 0.5rem;
background: #333;
border: 1px solid #555;
border-radius: 4px;
color: white;
}
.simulation-card {
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.simulation-card.selected {
transform: translateX(-4px);
box-shadow: 0 4px 12px rgba(0, 170, 255, 0.3);
}
.simulation-card h3 {
margin-bottom: 0.5rem;
color: #00aaff;
}
.simulation-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.status-indicator.running {
background: #00ff00;
}
.status-indicator.paused {
background: #ffaa00;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.stat-item {
background: #333;
padding: 0.5rem;
border-radius: 4px;
}
.stat-label {
color: #999;
font-size: 0.8rem;
}
.stat-value {
color: #fff;
font-weight: bold;
}
.controls-panel {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(26, 26, 26, 0.9);
backdrop-filter: blur(10px);
border: 1px solid #333;
border-radius: 8px;
padding: 1rem;
min-width: 200px;
}
.view-controls {
margin-bottom: 1rem;
}
.view-controls h4 {
margin-bottom: 0.5rem;
color: #00aaff;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
font-size: 1.2rem;
color: #666;
}
.error {
color: #ff4444;
background: rgba(255, 68, 68, 0.1);
border: 1px solid #ff4444;
border-radius: 4px;
padding: 1rem;
margin: 1rem 0;
}
/* Timeline Slider Styles */
.timeline-slider {
background: #2a2a2a;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.timeline-header h4 {
color: #00aaff;
margin: 0;
}
.timeline-info {
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: #ccc;
}
.timeline-controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
justify-content: center;
}
.control-button {
background: #333;
border: 1px solid #555;
border-radius: 4px;
color: #fff;
padding: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.control-button:hover {
background: #444;
border-color: #666;
}
.control-button.active {
background: #00aaff;
border-color: #00aaff;
}
.control-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.slider-container {
position: relative;
margin-bottom: 1rem;
}
.progress-track {
height: 4px;
background: #333;
border-radius: 2px;
margin-bottom: 0.5rem;
position: relative;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #00aaff, #0088cc);
border-radius: 2px;
transition: width 0.3s ease;
}
.timeline-range {
width: 100%;
height: 8px;
background: #333;
border-radius: 4px;
outline: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.timeline-range::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
background: #00aaff;
border-radius: 50%;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.timeline-range::-moz-range-thumb {
width: 16px;
height: 16px;
background: #00aaff;
border-radius: 50%;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.timeline-range:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.step-markers {
display: flex;
justify-content: space-between;
margin-top: 0.5rem;
font-size: 0.75rem;
color: #999;
}
.step-marker {
flex: 1;
text-align: center;
}
.step-marker.current {
color: #00aaff;
font-weight: bold;
}
.timeline-status {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.8rem;
}
.status-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.status-label {
color: #999;
}
.status-value {
font-weight: bold;
}
.status-value.running {
color: #00ff88;
}
.status-value.paused {
color: #ffaa00;
}
.status-value.active {
color: #00aaff;
}
.status-value.inactive {
color: #999;
}
/* Simulation List Enhancements */
.simulation-list-container {
height: 100%;
display: flex;
flex-direction: column;
}
.simulations-scroll {
flex: 1;
overflow-y: auto;
padding-right: 0.5rem;
margin-right: -0.5rem;
}
.simulations-scroll::-webkit-scrollbar {
width: 6px;
}
.simulations-scroll::-webkit-scrollbar-track {
background: #1a1a1a;
border-radius: 3px;
}
.simulations-scroll::-webkit-scrollbar-thumb {
background: #444;
border-radius: 3px;
}
.simulations-scroll::-webkit-scrollbar-thumb:hover {
background: #555;
}
.simulation-item {
margin-bottom: 1rem;
}
/* Timeline Slideout Animation */
.timeline-slideout {
margin-top: 0.5rem;
margin-left: 1rem;
padding-left: 1rem;
border-left: 2px solid #00aaff;
background: rgba(0, 170, 255, 0.05);
border-radius: 0 8px 8px 0;
animation: slideIn 0.3s ease-out;
overflow: hidden;
}
@keyframes slideIn {
from {
max-height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
}
to {
max-height: 300px;
opacity: 1;
padding-top: 1rem;
padding-bottom: 1rem;
}
}
.timeline-slideout .timeline-slider {
margin-bottom: 0;
background: transparent;
padding: 0;
}
.timeline-slideout .timeline-header h4 {
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.timeline-slideout .timeline-controls {
margin-bottom: 0.75rem;
}
.timeline-slideout .control-button {
padding: 0.4rem;
}
.timeline-slideout .slider-container {
margin-bottom: 0.75rem;
}
.timeline-slideout .timeline-status {
font-size: 0.75rem;
}

10
web/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

21
web/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
web/tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

18
web/vite.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
}
}
},
build: {
outDir: 'dist',
}
})