Compare commits
4 Commits
2859edb62e
...
b6aded5281
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b6aded5281 | ||
![]() |
e59d1d90b3 | ||
![]() |
a8fcb5a7d9 | ||
![]() |
19fcf445e4 |
61
.dockerignore
Normal file
61
.dockerignore
Normal 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
65
.gitignore
vendored
@ -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
|
19
Cargo.toml
19
Cargo.toml
@ -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
302
DEPLOYMENT.md
Normal 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
296
DOCKER.md
Normal 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
108
Dockerfile
Normal 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
160
INTERFACES.md
Normal 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
88
PLOTTING.md
Normal 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
268
README.md
@ -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
223
STATUS.md
Normal 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
4
build.rs
Normal file
@ -0,0 +1,4 @@
|
||||
fn main() {
|
||||
#[cfg(feature = "gui")]
|
||||
tauri_build::build();
|
||||
}
|
11
config/earthsun_corrected.toml
Normal file
11
config/earthsun_corrected.toml
Normal 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
|
35
config/inner_solar_system.toml
Normal file
35
config/inner_solar_system.toml
Normal 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
|
@ -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]
|
||||
}
|
||||
]
|
||||
}
|
@ -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]
|
||||
}
|
||||
]
|
||||
}
|
@ -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
|
||||
|
@ -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]
|
||||
}
|
||||
]
|
||||
}
|
0
config/planets_normalized.json
Normal file
0
config/planets_normalized.json
Normal file
0
config/planets_normalized.toml
Normal file
0
config/planets_normalized.toml
Normal file
53
config/solar_system.toml
Normal file
53
config/solar_system.toml
Normal 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
156
deploy.sh
Normal 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
42
docker-compose.yml
Normal 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
347
inspect_trajectories.py
Executable 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
72
nginx.conf
Normal 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
570
plot_trajectories.py
Executable 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
3
requirements.txt
Normal 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
24
src-tauri/Cargo.toml
Normal 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
106
src-tauri/src/main.rs
Normal 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
79
src-tauri/tauri.conf.json
Normal 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
520
src/bin/api_server.rs
Normal 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();
|
||||
}
|
@ -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);
|
||||
// }
|
||||
// }
|
@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
89
src/types.rs
89
src/types.rs
@ -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
121
start_interfaces.sh
Executable 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
200
test_interfaces.sh
Executable 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
13
web/index.html
Normal 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
4470
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
web/package.json
Normal file
38
web/package.json
Normal 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
318
web/src/App.tsx
Normal 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;
|
288
web/src/components/ConfigurationPanel.tsx
Normal file
288
web/src/components/ConfigurationPanel.tsx
Normal 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;
|
299
web/src/components/SimulationCanvas.tsx
Normal file
299
web/src/components/SimulationCanvas.tsx
Normal 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;
|
59
web/src/components/SimulationControls.tsx
Normal file
59
web/src/components/SimulationControls.tsx
Normal 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;
|
138
web/src/components/SimulationList.tsx
Normal file
138
web/src/components/SimulationList.tsx
Normal 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;
|
144
web/src/components/TimelineSlider.tsx
Normal file
144
web/src/components/TimelineSlider.tsx
Normal 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
482
web/src/index.css
Normal 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
10
web/src/main.tsx
Normal 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
21
web/tsconfig.json
Normal 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
11
web/tsconfig.node.json
Normal 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
18
web/vite.config.ts
Normal 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',
|
||||
}
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user