From 22735508c712e35b739d8b2b1808b77357b5fce6 Mon Sep 17 00:00:00 2001 From: nathan Date: Sat, 18 Oct 2025 12:01:02 +0200 Subject: [PATCH] hacktober ready --- .github/ISSUE_TEMPLATE/bug_report.md | 32 +++ .github/ISSUE_TEMPLATE/feature_request.md | 23 ++ .github/ISSUE_TEMPLATE/hacktoberfest.md | 40 +++ .github/pull_request_template.md | 32 +++ .github/workflows/ci.yml | 52 ++++ .gitignore | 289 +++++++++++----------- CODE_OF_CONDUCT.md | 35 +++ CONTRIBUTING.md | 77 ++++++ LICENSE | 21 ++ README.md | 93 +++++++ check.py | 20 -- check_with_matplotlib.py | 60 +++++ config.example.json | 31 +++ setup.py | 46 ++++ simulation.md | 52 ++++ simulation.png | Bin 0 -> 62794 bytes src/main.py | 46 ++-- src/physics/black_hole.py | 7 +- src/physics/constants.py | 8 +- src/physics/particle.py | 10 +- src/physics/physics.md | 60 +++++ src/rendering/camera.py | 36 +-- src/rendering/camera_config.py | 10 +- src/rendering/renderer.py | 44 +--- src/rendering/rendering.md | 66 +++++ src/utils/utils.md | 18 ++ tests/test_integration.py | 47 ++++ tests/test_physics.py | 114 +++++++++ tests/test_rendering.py | 72 ++++++ tests/test_utils.py | 34 +++ 30 files changed, 1191 insertions(+), 284 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/hacktoberfest.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md delete mode 100644 check.py create mode 100644 check_with_matplotlib.py create mode 100644 config.example.json create mode 100644 setup.py create mode 100644 simulation.md create mode 100644 simulation.png create mode 100644 src/physics/physics.md create mode 100644 src/rendering/rendering.md create mode 100644 src/utils/utils.md create mode 100644 tests/test_integration.py create mode 100644 tests/test_physics.py create mode 100644 tests/test_rendering.py create mode 100644 tests/test_utils.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b217388 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '[BUG] ' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**System Information:** + - OS: [e.g. Windows 10, Ubuntu 20.04, macOS 12.0] + - Python Version: [e.g. 3.9.7] + - Pygame Version: [e.g. 2.6.1] + +**Additional context** +Add any other context about the problem here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ee606e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. + +**Implementation ideas** +If you have ideas about how this could be implemented, please share them here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/hacktoberfest.md b/.github/ISSUE_TEMPLATE/hacktoberfest.md new file mode 100644 index 0000000..e071bb9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/hacktoberfest.md @@ -0,0 +1,40 @@ +--- +name: Hacktoberfest Issue +about: Issues specifically for Hacktoberfest contributors +title: '[HACKTOBERFEST] ' +labels: hacktoberfest, good first issue +assignees: '' + +--- + +**Issue Description** +A clear description of what needs to be implemented or fixed. + +**Difficulty Level** +- [ ] Beginner (Good first issue) +- [ ] Intermediate +- [ ] Advanced + +**Tasks to Complete** +- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3 +- [ ] Write tests +- [ ] Update documentation + +**Expected Outcome** +Describe what the end result should look like. + +**Files to Modify** +List the files that will likely need changes: +- `src/...` +- `tests/...` + +**Getting Started** +1. Comment on this issue to claim it +2. Fork the repository +3. Create a feature branch +4. Read the [CONTRIBUTING.md](../../CONTRIBUTING.md) guide + +**Additional Resources** +Links to relevant documentation or examples. \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f0782fd --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,32 @@ +## Description +Brief description of changes made. + +## Type of Change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Performance improvement +- [ ] Code refactoring + +## Related Issue +Fixes #(issue number) + +## Testing +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] I have tested the changes manually + +## Screenshots (if applicable) +Add screenshots to help explain your changes. + +## Checklist +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] Any dependent changes have been merged and published + +## Additional Notes +Any additional information or context about the changes. \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b5832ea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: [3.8, 3.9, '3.10', 3.11] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install system dependencies (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y python3-dev libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov flake8 + + - name: Lint with flake8 + run: | + flake8 src --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 src --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Test with pytest + run: | + pytest tests/ --cov=src --cov-report=xml + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.10' + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4a6127b..f082d92 100644 --- a/.gitignore +++ b/.gitignore @@ -1,147 +1,150 @@ -##### Windows -# Windows thumbnail cache files -Thumbs.db -Thumbs.db:encryptable -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ +# C extensions +*.so -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db -# Windows shortcuts -*.lnk +# Project specific +screenshots/ +config.json +.misc/ -##### MacOS -# General -.DS_Store -.AppleDouble -.LSOverride - -##### Vim -# Swap -[._]*.s[a-v][a-z] -!*.svg # comment out if you don't need vector files -[._]*.sw[a-p] -[._]s[a-rt-v][a-z] -[._]ss[a-gi-z] -[._]sw[a-p] - -##### VisualStudioCode -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# CMake -cmake-build-*/ - -build - -##### CMake -CMakeLists.txt.user -CMakeCache.txt -CMakeFiles -CMakeScripts -Testing -Makefile -cmake_install.cmake -install_manifest.txt -compile_commands.json -CTestTestfile.cmake -_deps - -##### C++ -# Prerequisites -*.d - -# Compiled Object files -*.slo -*.lo -*.o -*.obj - -# Precompiled Headers -*.gch -*.pch - -# Compiled Dynamic libraries -*.so -*.dylib -*.dll - -# Compiled Static libraries -*.lai -*.la -*.a -*.lib - -# Executables -*.exe -*.out -*.app - -# C/C++ binary extension file -*.bin - -##### C -# Prerequisites -*.d - -# Object files -*.o -*.ko -*.obj -*.elf - -# Linker output -*.ilk -*.map -*.exp - -# Precompiled Headers -*.gch -*.pch - -# Libraries -*.lib -*.a -*.la -*.lo - -# Shared objects (inc. Windows DLLs) -*.dll -*.so -*.so.* -*.dylib - -# Executables -*.exe -*.out -*.app -*.i*86 -*.x86_64 -*.hex - -# Debug files -*.dSYM/ -*.su -*.idb -*.pdb - -# Vcpkg -vcpkg_installed +# Simulation outputs +*.png +*.jpg +*.gif +!simulation.png \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..cfffc81 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,35 @@ +# Code of Conduct + +## Our Pledge + +We pledge to make participation in our project a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8ff90bd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing to Black Hole Simulation + +Thank you for your interest in contributing to the Black Hole Simulation project! We welcome contributions from developers of all skill levels. + +## How to Contribute + +### Reporting Issues + +- Use the GitHub issue tracker to report bugs +- Include detailed steps to reproduce the issue +- Provide system information (OS, Python version, etc.) +- Include screenshots or videos if applicable + +### Suggesting Features + +- Open an issue with the "enhancement" label +- Describe the feature and its use case +- Explain how it would benefit users + +### Code Contributions + +1. **Fork the repository** +2. **Create a feature branch**: `git checkout -b feature/your-feature-name` +3. **Make your changes** +4. **Write tests** for new functionality +5. **Run the test suite**: `python -m pytest tests/` +6. **Update documentation** if needed +7. **Commit your changes**: `git commit -m "Add feature: description"` +8. **Push to your fork**: `git push origin feature/your-feature-name` +9. **Submit a pull request** + +## Development Guidelines + +### Code Style + +- Follow PEP 8 Python style guidelines +- Use meaningful variable and function names +- Keep functions focused and small +- Add docstrings for public functions and classes + +### Testing + +- Write unit tests for new features +- Ensure all tests pass before submitting PR +- Aim for good test coverage + +### Documentation + +- Update README.md for new features +- Add inline comments for complex logic +- Update docstrings when changing function signatures + +## Areas for Contribution + +### Good First Issues + +- Bug fixes in physics calculations +- UI improvements +- Performance optimizations +- Documentation improvements + +### Advanced Features + +- Relativistic effects implementation +- Better rendering algorithms +- Multi-body gravitational systems +- Save/load simulation states + +## Getting Help + +- Join our discussions in GitHub Issues +- Ask questions in pull request comments +- Check existing issues and PRs before starting work + +## Recognition + +Contributors will be acknowledged in the README and release notes. Thank you for helping make this project better! \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2103439 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Black Hole Simulation Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4cd8abb --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Black Hole Simulation + +A real-time 3D black hole simulation built with Python and Pygame, featuring gravitational physics, particle systems, and visual effects. + +## Features + +- **Realistic Physics**: Implements gravitational acceleration and Schwarzschild radius calculations +- **Particle System**: Simulates accretion disk with hundreds of particles +- **Visual Effects**: Glow effects, particle trails, and gravitational lensing visualization +- **Interactive Camera**: Full 3D camera controls with zoom and movement +- **Real-time Simulation**: 60 FPS simulation with dynamic particle generation + +## Installation + +1. Clone the repository: +```bash +git clone https://github.com/yourusername/black-hole-simulation.git +cd black-hole-simulation +``` + +2. Create a virtual environment: +```bash +python -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate +``` + +3. Install dependencies: +```bash +pip install -r requirements.txt +``` + +## Usage + +Run the simulation: +```bash +python src/main.py +``` + +### Controls + +- **WASD**: Move camera +- **Q/E**: Move camera up/down +- **+/-**: Zoom in/out +- **R**: Reset camera position +- **G**: Toggle gravity on/off +- **ESC**: Exit simulation + +## Project Structure + +``` +src/ +├── main.py # Main simulation loop +├── physics/ # Physics calculations +│ ├── black_hole.py # Black hole physics +│ ├── particle.py # Particle system +│ └── constants.py # Physical constants +├── rendering/ # Rendering system +│ ├── renderer.py # Main renderer +│ ├── camera.py # Camera controls +│ └── camera_config.py # Camera settings +└── utils/ # Utility functions + └── vectors.py # Vector operations +``` + +## Physics + +The simulation implements: +- **Gravitational Force**: F = GMm/r² +- **Schwarzschild Radius**: Rs = 2GM/c² +- **Event Horizon**: Particles crossing the event horizon turn black +- **Orbital Mechanics**: Particles follow realistic orbital paths + +## Contributing + +We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details. + +### Development Setup + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature-name` +3. Make your changes +4. Run tests: `python -m pytest tests/` +5. Submit a pull request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- Physics calculations based on Einstein's General Relativity +- Inspired by real black hole observations from Event Horizon Telescope +- Built with Python, Pygame, and NumPy \ No newline at end of file diff --git a/check.py b/check.py deleted file mode 100644 index e0cdf65..0000000 --- a/check.py +++ /dev/null @@ -1,20 +0,0 @@ -# # check.py -# import pygame -# from pygame.locals import * # This imports DOUBLEBUF, OPENGL, and other constants -# from OpenGL.GL import * -# from OpenGL.GLU import * -# import numpy as np - -# print("All imports successful!") -# print(f"Pygame version: {pygame.version.ver}") -# print(f"NumPy version: {np.__version__}") - -# # Try to get OpenGL information -# pygame.init() -# display = (800, 600) -# pygame.display.set_mode(display, DOUBLEBUF | OPENGL) - -# print(f"OpenGL version: {glGetString(GL_VERSION).decode()}") -# print(f"GPU: {glGetString(GL_RENDERER).decode()}") - -# pygame.quit() \ No newline at end of file diff --git a/check_with_matplotlib.py b/check_with_matplotlib.py new file mode 100644 index 0000000..17e71f8 --- /dev/null +++ b/check_with_matplotlib.py @@ -0,0 +1,60 @@ +import sys +sys.path.insert(0, 'src') +import matplotlib.pyplot as plt +import numpy as np +from physics.black_hole import BlackHole +from physics.particle import ParticleSystem +from physics.constants import G + +# Create black hole +black_hole = BlackHole(mass=4e37, position=(0, 0, 0)) + +# Create particle system +particle_system = ParticleSystem() + +# Add some particles for testing +num_particles = 10 +inner_radius = black_hole.schwarz_radius * 2.5 +outer_radius = black_hole.schwarz_radius * 12 + +for i in range(num_particles): + distance = np.random.uniform(inner_radius, outer_radius) + angle = np.random.uniform(0, 2 * np.pi) + x = distance * np.cos(angle) + z = distance * np.sin(angle) + y = np.random.uniform(-outer_radius/50, outer_radius/50) + + orbital_speed = np.sqrt(G * black_hole.mass / distance) + vx = -orbital_speed * np.sin(angle) + vz = orbital_speed * np.cos(angle) + + particle_system.add_particle( + mass=np.random.uniform(1e9, 1e10), + position=(x, y, z), + velocity=(vx, 0, vz), + colour=(1, 0, 0) + ) + +# Update a few times +for _ in range(10): + particle_system.update(black_hole, 50.0) + +# Plot +fig = plt.figure() +ax = fig.add_subplot(111, projection='3d') + +# Plot black hole +ax.scatter(0, 0, 0, color='black', s=100, label='Black Hole') + +# Plot particles +for particle in particle_system.particles: + ax.scatter(particle.position[0], particle.position[1], particle.position[2], + color=particle.colour, s=10) + +ax.set_xlabel('X') +ax.set_ylabel('Y') +ax.set_zlabel('Z') +ax.legend() +plt.title('Black Hole Simulation Positions') +plt.savefig('simulation.png') +print("Plot saved to simulation.png") \ No newline at end of file diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..6ec40f9 --- /dev/null +++ b/config.example.json @@ -0,0 +1,31 @@ +{ + "simulation": { + "width": 1200, + "height": 800, + "fps": 60, + "time_step": 50.0 + }, + "black_hole": { + "mass": 4e37, + "position": [0, 0, 0] + }, + "particles": { + "count": 300, + "jet_particles": 30, + "inner_radius_multiplier": 2.5, + "outer_radius_multiplier": 12, + "max_trail_length": 50 + }, + "camera": { + "initial_position": [0, 2e11, -3e11], + "move_speed": 5e13, + "zoom_speed": 0.5, + "initial_zoom": 1.0 + }, + "rendering": { + "glow_effects": true, + "particle_trails": true, + "show_ui": true, + "disk_texture_size": 256 + } +} \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..92d2976 --- /dev/null +++ b/setup.py @@ -0,0 +1,46 @@ +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +with open("requirements.txt", "r", encoding="utf-8") as fh: + requirements = [line.strip() for line in fh if line.strip() and not line.startswith("#")] + +setup( + name="black-hole-simulation", + version="1.0.0", + author="Black Hole Simulation Contributors", + description="A real-time 3D black hole simulation with gravitational physics", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/yourusername/black-hole-simulation", + packages=find_packages(where="src"), + package_dir={"": "src"}, + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Education", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering :: Physics", + "Topic :: Scientific/Engineering :: Visualization", + ], + python_requires=">=3.8", + install_requires=requirements, + entry_points={ + "console_scripts": [ + "black-hole-sim=main:main", + ], + }, + keywords="physics simulation black-hole pygame visualization", + project_urls={ + "Bug Reports": "https://github.com/yourusername/black-hole-simulation/issues", + "Source": "https://github.com/yourusername/black-hole-simulation", + "Documentation": "https://github.com/yourusername/black-hole-simulation#readme", + }, +) \ No newline at end of file diff --git a/simulation.md b/simulation.md new file mode 100644 index 0000000..9575fab --- /dev/null +++ b/simulation.md @@ -0,0 +1,52 @@ +# Simulation Documentation + +## Overview +This file documents the main simulation script and utility check script. + +## main.py + +The main entry point for the black hole simulation application. Initializes Pygame, sets up the black hole, particle system, camera, and renderer, then runs the simulation loop. + +### Key Components + +- **Initialization**: + - Initializes Pygame and sets up a window (1200x800). + - Creates a BlackHole with mass 4e37 kg (much larger than default for dramatic effects). + - Initializes ParticleSystem and Camera with custom position. + - Creates Renderer for drawing. + +- **Particle Setup**: + - Generates 300 particles for accretion disk between 2.5 and 12 times Schwarzschild radius. + - Calculates orbital velocities using Keplerian formula \( v = \sqrt{\frac{GM}{r}} \). + - Adds color variation based on distance (redshift simulation). + - Adds 30 high-velocity particles for jet effects. + +- **Main Loop**: + - Handles events (quit, escape, gravity toggle, camera reset). + - Processes continuous camera movement (WASDQE keys). + - Updates particle system with physics time step (50 seconds per frame for stability). + - Periodically adds new particles to maintain disk. + - Renders scene and flips display at 60 FPS. + +- **Physics Integration**: + - Uses Euler method for particle updates. + - Applies gravitational acceleration from black hole. + - Changes particle color to black inside horizon. + +- **Error Handling**: + - Catches rendering errors and falls back to simple circle. + +This script ties together physics, rendering, and user input for an interactive simulation. + +## check.py + +A utility script for verifying dependencies and OpenGL capabilities. Currently commented out, but can be used to check if Pygame, NumPy, and OpenGL are properly installed and functioning. + +### Functionality + +- Imports necessary libraries (Pygame, OpenGL, NumPy). +- Prints versions of Pygame and NumPy. +- Initializes Pygame and creates an OpenGL context. +- Retrieves and prints OpenGL version and GPU information. + +Useful for debugging environment setup before running the main simulation. \ No newline at end of file diff --git a/simulation.png b/simulation.png new file mode 100644 index 0000000000000000000000000000000000000000..0f355d8030bbe415f4309839e4632ae3cc693afc GIT binary patch literal 62794 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
    sSlMg5p*_Xp?5k?TDWG-8k7YDh)x-Psz(u9~h; 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 literal 0 HcmV?d00001 diff --git a/src/main.py b/src/main.py index 5c350d6..8e71008 100644 --- a/src/main.py +++ b/src/main.py @@ -6,50 +6,48 @@ from physics.particle import ParticleSystem from rendering.renderer import Renderer from rendering.camera import Camera -from physics.constants import G, C +from physics.constants import G def main(): + print("Initializing pygame...") pygame.init() + print("Pygame initialized.") width, height = 1200, 800 screen = pygame.display.set_mode((width, height)) pygame.display.set_caption("Black Hole Simulation") + print("Window created.") - # Create black hole with larger mass for more dramatic effects black_hole = BlackHole(mass=4e37, position=(0, 0, 0)) + print("Black hole created.") particle_system = ParticleSystem() + print("Particle system created.") - # Create camera with better initial position camera = Camera(position=(0, 2e11, -3e11)) renderer = Renderer(width, height, screen) + print("Camera and renderer created.") print(f"Black hole mass: {black_hole.mass:.2e} kg") print(f"Schwarzschild radius: {black_hole.schwarz_radius:.2e} m") - # Create accretion disk with varying properties num_particles = 300 inner_radius = black_hole.schwarz_radius * 2.5 outer_radius = black_hole.schwarz_radius * 12 - for i in range(num_particles): - # Vary distance from black hole + for _ in range(num_particles): distance = np.random.uniform(inner_radius, outer_radius) angle = np.random.uniform(0, 2 * np.pi) - # Position in the accretion disk plane x = distance * np.cos(angle) z = distance * np.sin(angle) - y = np.random.uniform(-outer_radius/50, outer_radius/50) # Slight thickness + y = np.random.uniform(-outer_radius/50, outer_radius/50) - # Calculate orbital velocity with some random perturbation orbital_speed = np.sqrt(G * black_hole.mass / distance) - perturbation = np.random.uniform(0.95, 1.05) # Small randomness + perturbation = np.random.uniform(0.95, 1.05) vx = -orbital_speed * np.sin(angle) * perturbation vz = orbital_speed * np.cos(angle) * perturbation vy = np.random.uniform(-orbital_speed/20, orbital_speed/20) - # Color based on distance from black hole (redshift effect) distance_ratio = (distance - inner_radius) / (outer_radius - inner_radius) - # Hotter (blue) near the center, cooler (red) further out r = min(1.0, 0.2 + distance_ratio * 0.8) g = max(0.0, 0.5 - distance_ratio * 0.5) b = max(0.0, 1.0 - distance_ratio * 0.8) @@ -63,8 +61,7 @@ def main(): print(f"Added {len(particle_system.particles)} particles") - # Add some particles with high velocity for jet effects - for i in range(30): + for _ in range(30): angle = np.random.uniform(0, 2 * np.pi) distance = inner_radius * 1.2 x = distance * np.cos(angle) @@ -72,7 +69,6 @@ def main(): y_sign = 1 if np.random.random() > 0.5 else -1 y = y_sign * distance * 0.3 - # High velocity in y-direction for jet effect vx = np.random.uniform(-2e7, 2e7) vz = np.random.uniform(-2e7, 2e7) vy = np.random.uniform(8e7, 2e8) * y_sign @@ -81,17 +77,18 @@ def main(): mass=np.random.uniform(1e8, 1e9), position=(x, y, z), velocity=(vx, vy, vz), - colour=(0.7, 0.7, 1.0) # Bluish color for jets + colour=(0.7, 0.7, 1.0) ) clock = pygame.time.Clock() running = True frame_count = 0 - time_step = 50.0 # Reduced time step for better stability + time_step = 50.0 - # Main simulation loop while running: - dt = clock.tick(60) / 1000.0 # Delta time in seconds + dt = clock.tick(60) / 1000.0 + if frame_count % 60 == 0: + print(f"Frame {frame_count}, particles: {len(particle_system.particles)}") for event in pygame.event.get(): if event.type == pygame.QUIT: @@ -103,27 +100,20 @@ def main(): particle_system.grav_enabled = not particle_system.grav_enabled print(f"Gravity {'enabled' if particle_system.grav_enabled else 'disabled'}") elif event.key == pygame.K_r: - # Reset camera camera.position = np.array([0.0, 2e11, -3e11], dtype=np.float64) camera.zoom = 1.0 print("Camera reset") - # Handle camera controls camera.handle_event(event, dt) - # Handle continuous key presses for smoother camera movement keys = pygame.key.get_pressed() camera.handle_continuous_movement(keys, dt) - # Clear screen - screen.fill((0, 0, 0)) # Black background + screen.fill((0, 0, 0)) - # Update physics particle_system.update(black_hole, time_step) - # add new particles if frame_count % 120 == 0 and len(particle_system.particles) < num_particles * 0.9: - # Add a new particle to the accretion disk distance = np.random.uniform(inner_radius * 1.5, outer_radius) angle = np.random.uniform(0, 2 * np.pi) x = distance * np.cos(angle) @@ -146,12 +136,10 @@ def main(): colour=(r, g, b) ) - # Render try: renderer.render(black_hole, particle_system, camera) except Exception as e: print(f"Rendering error: {e}") - # Fallback rendering pygame.draw.circle(screen, (255, 0, 0), (width//2, height//2), 50) pygame.display.flip() diff --git a/src/physics/black_hole.py b/src/physics/black_hole.py index 061b347..01b11d0 100644 --- a/src/physics/black_hole.py +++ b/src/physics/black_hole.py @@ -4,24 +4,20 @@ class BlackHole: def __init__(self, mass, position = (0,0,0)): - """initialise black hole with mass (kg) position (m) and calculate schwarzschild radius (m)""" self.position = np.array(position,dtype=np.float32) self.mass = mass self.schwarz_radius = 2 * G * mass / (C ** 2) def get_rad_vec(self, position): - """get vector from black hole to position""" return self.position - position def get_rad(self, r_vec): - """get distance from black hole to position""" r_vec = np.array(r_vec, dtype=np.float64) relative_pos = r_vec - self.position return np.linalg.norm(relative_pos) def calc_grav_accel(self, position): - """calculate gravitational acceleration at position due to black hole""" position = np.array(position, dtype=np.float64) r_vec = self.get_rad_vec(position) @@ -31,11 +27,10 @@ def calc_grav_accel(self, position): return np.array([0.0, 0.0, 0.0]) accel = G * self.mass / (r ** 2) - direction = -r_vec / r + direction = r_vec / r return accel * direction def is_inside_horizon(self, position): - """check if position is inside event horizon""" r = self.get_rad(position) return r <= self.schwarz_radius diff --git a/src/physics/constants.py b/src/physics/constants.py index 0e8f8ba..0d51643 100644 --- a/src/physics/constants.py +++ b/src/physics/constants.py @@ -1,5 +1,5 @@ -G = 6.67430e-11 # Gravitational constant -C = 299792458.0 # Speed of light +G = 6.67430e-11 +C = 299792458.0 -bhmass = 5.972e30 # Mass of black hole in kg (approx 3 solar masses) -bhinitpos = (0, 0, 0) # Initial position of black hole \ No newline at end of file +bhmass = 5.972e30 +bhinitpos = (0, 0, 0) \ No newline at end of file diff --git a/src/physics/particle.py b/src/physics/particle.py index bc7618f..dd1afe0 100644 --- a/src/physics/particle.py +++ b/src/physics/particle.py @@ -3,7 +3,6 @@ class Particle: def __init__(self, mass, position, velocity, colour): - """initialise particle with mass (kg), position (m), velocity (m/s), and colour (r,g,b)""" self.mass = mass self.position = np.array(position, dtype=np.float64) self.velocity = np.array(velocity, dtype=np.float64) @@ -13,9 +12,8 @@ def __init__(self, mass, position, velocity, colour): self.max_trail_length = 50 def update(self, acceleration, dt): - """update particle position and velocity based on acceleration (m/s^2) and timestep dt (s)""" acceleration = np.array(acceleration, dtype=np.float64) - if acceleration.shape == (): # if acceleration is a scalar(one value) + if acceleration.shape == (): if np.linalg.norm(self.position) > 0: direction = -self.position / np.linalg.norm(self.position) acceleration = acceleration * direction @@ -38,7 +36,6 @@ def update(self, acceleration, dt): class ParticleSystem: def __init__(self): - """initialise empty particle system""" self.particles = [] self.grav_enabled = True @@ -46,17 +43,14 @@ def add_particle(self, mass, position, velocity, colour): self.particles.append(Particle(mass, position, velocity, colour)) def update(self, black_hole, dt): - """update all particles in the system based on gravitational attraction to black hole and timestep dt (s)""" for particle in self.particles: - if self.grav_enabled: # Fixed attribute name + if self.grav_enabled: accel = black_hole.calc_grav_accel(particle.position) particle.update(accel, dt) else: - # If gravity is disabled, update with zero acceleration particle.update(np.array([0.0, 0.0, 0.0]), dt) pos = np.array(particle.position, dtype=np.float64) - # if inside event horizon, change color to black if black_hole.is_inside_horizon(pos): particle.colour = (0, 0, 0) diff --git a/src/physics/physics.md b/src/physics/physics.md new file mode 100644 index 0000000..f9256a8 --- /dev/null +++ b/src/physics/physics.md @@ -0,0 +1,60 @@ +# Physics Module Documentation + +## Overview +The physics module implements the core simulation logic for a black hole and particle system using Newtonian gravity. It includes classes for black holes, particles, and particle systems, along with physical constants. + +## black_hole.py + +The `BlackHole` class simulates a black hole in 3D space, calculating gravitational effects and event horizon. + +### Class: BlackHole + +- **__init__(self, mass, position = (0,0,0))**: Initializes the black hole with mass (kg) and position (m). Computes Schwarzschild radius \( r_s = \frac{2GM}{c^2} \). + +- **get_rad_vec(self, position)**: Returns vector from black hole to given position. + +- **get_rad(self, r_vec)**: Calculates radial distance from black hole. + +- **calc_grav_accel(self, position)**: Computes gravitational acceleration at position using \( a = \frac{GM}{r^2} \), directed toward black hole. Handles r=0 case. + +- **is_inside_horizon(self, position)**: Checks if position is within Schwarzschild radius. + +This class provides gravitational field calculations for the simulation. + +## constants.py + +Defines key physical constants: + +- **G = 6.67430e-11**: Gravitational constant (m³ kg⁻¹ s⁻²). + +- **C = 299792458.0**: Speed of light (m/s). + +- **bhmass = 5.972e30**: Default black hole mass (kg, ~3 solar masses). + +- **bhinitpos = (0, 0, 0)**: Default black hole position. + +Used in gravitational and relativistic calculations. + +## particle.py + +Implements particle dynamics under gravity. + +### Class: Particle + +- **__init__(self, mass, position, velocity, colour)**: Initializes particle with mass, position, velocity, color. Maintains trail of positions for rendering. + +- **update(self, acceleration, dt)**: Updates velocity and position via Euler integration. Manages trail length. + +### Class: ParticleSystem + +- **__init__(self)**: Empty particle list, gravity enabled. + +- **add_particle(self, mass, position, velocity, colour)**: Adds particle to system. + +- **update(self, black_hole, dt)**: Updates all particles with gravitational acceleration from black hole. Changes color if inside horizon. + +Manages collection of particles and their interactions. + +## __init__.py + +Marks directory as Python package (empty). \ No newline at end of file diff --git a/src/rendering/camera.py b/src/rendering/camera.py index 812a8c0..f28c939 100644 --- a/src/rendering/camera.py +++ b/src/rendering/camera.py @@ -12,105 +12,83 @@ def __init__(self, position=None): self.target = np.array([0.0, 0.0, 0.0], dtype=np.float64) self.up = np.array([0.0, 1.0, 0.0], dtype=np.float64) - # Camera controls self.move_speed = cmove self.zoom_speed = cspeed self.zoom = czoom self.rotation_speed = crot - # For smooth movement self.velocity = np.zeros(3, dtype=np.float64) self.acceleration = 5e9 self.damping = 0.9 def handle_event(self, event, dt): - """Handle camera control events""" if event.type == pygame.KEYDOWN: if event.key == pygame.K_r: - # Reset camera self.position = np.array([0.0, 1e12, -1e12], dtype=np.float64) self.zoom = 1.0 elif event.key == pygame.K_PLUS or event.key == pygame.K_EQUALS: - # Zoom in self.zoom *= 1.0 + self.zoom_speed elif event.key == pygame.K_MINUS: - # Zoom out self.zoom /= 1.0 + self.zoom_speed def handle_continuous_movement(self, keys, dt): - """Handle continuous key presses for camera movement""" move_vector = np.zeros(3, dtype=np.float64) if keys[pygame.K_w]: - move_vector[2] += 1 # Forward - move_vector[2] -= 1 # Backward + move_vector[2] += 1 + move_vector[2] -= 1 if keys[pygame.K_a]: - move_vector[0] -= 1 # Left + move_vector[0] -= 1 if keys[pygame.K_d]: - move_vector[0] += 1 # Right + move_vector[0] += 1 if keys[pygame.K_q]: - move_vector[1] += 1 # Up + move_vector[1] += 1 if keys[pygame.K_e]: - move_vector[1] -= 1 # Down + move_vector[1] -= 1 - # Normalize if moving diagonally norm = np.linalg.norm(move_vector) if norm > 0: move_vector = move_vector / norm - # Calculate forward and right vectors relative to camera orientation forward = normalize(self.target - self.position) right = normalize(np.cross(forward, self.up)) - # Transform movement to world space world_move = move_vector[0] * right + move_vector[1] * self.up + move_vector[2] * forward - # Apply movement if np.any(world_move != 0): self.velocity += world_move * self.acceleration * dt else: - # Apply damping when not moving self.velocity *= self.damping - # Update position self.position += self.velocity * dt - # Keep camera at a minimum distance from the black hole min_distance = 1e15 distance_to_black_hole = np.linalg.norm(self.position) if distance_to_black_hole < min_distance: direction = normalize(self.position) self.position = direction * min_distance - # Reflect velocity away from black hole self.velocity -= 2 * np.dot(self.velocity, direction) * direction def world_to_screen(self, world_pos, screen_width, screen_height): - """Convert world coordinates to screen coordinates""" world_pos = np.array(world_pos, dtype=np.float64) - # Calculate relative position to camera relative_pos = world_pos - self.position - # Simple projection (assuming camera looks along forward vector) forward = normalize(self.target - self.position) - # Project onto camera plane (simplified) if np.dot(relative_pos, forward) <= 0: - return None # Behind camera + return None - # Scale based on distance and zoom scale = 1e9 * self.zoom x = relative_pos[0] / scale y = relative_pos[1] / scale - # Convert to screen coordinates screen_x = screen_width / 2 + x screen_y = screen_height / 2 - y return (int(screen_x), int(screen_y)) def normalize(vector): - """Normalize a vector""" norm = np.linalg.norm(vector) if norm == 0: return vector diff --git a/src/rendering/camera_config.py b/src/rendering/camera_config.py index 4a3ab9d..4848e91 100644 --- a/src/rendering/camera_config.py +++ b/src/rendering/camera_config.py @@ -1,5 +1,5 @@ -cdist = 1e15 # Default camera distance from black hole -cspeed = 1e15 # Camera movement speed -czoom = 0.5 # Camera zoom speed -cmove = 5e13 # Camera move speed -crot = 1 # Camera rotation speed \ No newline at end of file +cdist = 1e15 +cspeed = 1e15 +czoom = 0.5 +cmove = 5e13 +crot = 1 \ No newline at end of file diff --git a/src/rendering/renderer.py b/src/rendering/renderer.py index fa65c7c..df9edf5 100644 --- a/src/rendering/renderer.py +++ b/src/rendering/renderer.py @@ -8,14 +8,11 @@ def __init__(self, width, height, screen): self.screen = screen self.font = pygame.font.Font(None, 24) - # Create surface for glow effects self.glow_surface = pygame.Surface((width, height), pygame.SRCALPHA) - # Precompute accretion disk texture self.disk_texture = self.create_disk_texture(256) def create_disk_texture(self, size): - """Create a texture for the accretion disk""" texture = pygame.Surface((size, size), pygame.SRCALPHA) center = size // 2 @@ -26,7 +23,6 @@ def create_disk_texture(self, size): distance = np.sqrt(dx*dx + dy*dy) / center if distance < 1.0: - # Color based on distance (red to blue) r = min(255, int(100 + 155 * (1 - distance))) g = max(0, int(50 + 100 * (1 - distance))) b = max(0, int(50 + 205 * distance)) @@ -37,36 +33,27 @@ def create_disk_texture(self, size): return texture def render(self, black_hole, particle_system, camera): - """Render the black hole simulation""" - # Clear screen + print("Rendering frame...") self.screen.fill((0, 0, 0)) self.glow_surface.fill((0, 0, 0, 0)) - # Render black hole with accretion disk self.render_black_hole(black_hole, camera) - # Render particles self.render_particles(particle_system, camera) - # Apply glow effect self.apply_glow() - # Render UI self.render_ui(camera, particle_system, black_hole) def render_black_hole(self, black_hole, camera): - """Render the black hole with detailed accretion disk""" - # Convert black hole position to screen coordinates screen_pos = camera.world_to_screen(black_hole.position, self.width, self.height) + print(f"Black hole screen position: {screen_pos}") if screen_pos: - # Calculate sizes based on Schwarzschild radius and zoom schwarz_radius_px = max(5, int(black_hole.schwarz_radius / (1e9 * camera.zoom))) disk_radius_px = schwarz_radius_px * 6 - # Draw accretion disk if disk_radius_px > 5: - # Create a scaled version of the disk texture scaled_disk = pygame.transform.scale( self.disk_texture, (disk_radius_px * 2, disk_radius_px * 2) @@ -74,69 +61,55 @@ def render_black_hole(self, black_hole, camera): disk_rect = scaled_disk.get_rect(center=screen_pos) self.screen.blit(scaled_disk, disk_rect) - # Draw photon sphere (just inside the event horizon) photon_radius = int(schwarz_radius_px * 1.5) if photon_radius > 2: pygame.draw.circle(self.glow_surface, (150, 150, 255, 100), screen_pos, photon_radius, 2) - # Draw event horizon if schwarz_radius_px > 2: - # Draw a dark circle for the event horizon pygame.draw.circle(self.screen, (10, 10, 10), screen_pos, schwarz_radius_px) - # Add a slight glow around the event horizon pygame.draw.circle(self.glow_surface, (50, 50, 150, 50), screen_pos, schwarz_radius_px + 2) - # Draw black hole shadow (larger than event horizon) shadow_radius = int(schwarz_radius_px * 2.5) if shadow_radius > 5: pygame.draw.circle(self.screen, (0, 0, 0), screen_pos, shadow_radius) - # Add gravitational lensing effect (distortion ring) for i in range(3): ring_radius = shadow_radius + 5 + i * 3 pygame.draw.circle(self.glow_surface, (100, 100, 200, 30), screen_pos, ring_radius, 1) def render_particles(self, particle_system, camera): - """Render all particles with glow effects""" for particle in particle_system.particles: screen_pos = camera.world_to_screen(particle.position, self.width, self.height) if screen_pos: - # Convert color from (0,1) range to (0,255) range color = ( int(particle.colour[0] * 255), int(particle.colour[1] * 255), int(particle.colour[2] * 255) ) - # Calculate particle size based on mass particle_size = max(1, int(2 + np.log10(particle.mass) / 2)) - # Draw glow effect glow_radius = particle_size * 3 glow_color = (color[0], color[1], color[2], 100) pygame.draw.circle(self.glow_surface, glow_color, screen_pos, glow_radius) - # Draw particle pygame.draw.circle(self.screen, color, screen_pos, particle_size) - # Draw trail if it exists if len(particle.trail) > 1: self.render_trail(particle, camera) def render_trail(self, particle, camera): - """Render particle trail""" trail_points = [] - for i, trail_pos in enumerate(particle.trail[-15:]): # Last 15 trail points + for i, trail_pos in enumerate(particle.trail[-15:]): trail_screen_pos = camera.world_to_screen(trail_pos, self.width, self.height) if trail_screen_pos: trail_points.append(trail_screen_pos) if len(trail_points) > 1: - # Draw trail as connected lines with fading effect for i in range(len(trail_points) - 1): - alpha = int(200 * (i + 1) / len(trail_points)) # Fade from transparent to opaque + alpha = int(200 * (i + 1) / len(trail_points)) trail_color = ( int(particle.colour[0] * 255), int(particle.colour[1] * 255), @@ -146,35 +119,27 @@ def render_trail(self, particle, camera): pygame.draw.line(self.glow_surface, trail_color, trail_points[i], trail_points[i + 1], 2) def apply_glow(self): - """Apply glow effect by blurring the glow surface and combining with main screen""" - # Simple blur effect by scaling down and up small = pygame.transform.smoothscale(self.glow_surface, (self.width//4, self.height//4)) blurred = pygame.transform.smoothscale(small, (self.width, self.height)) self.screen.blit(blurred, (0, 0), special_flags=pygame.BLEND_ADD) def render_ui(self, camera, particle_system, black_hole): - """Render UI information""" - # Camera info cam_text = f"Camera: ({camera.position[0]:.2e}, {camera.position[1]:.2e}, {camera.position[2]:.2e})" cam_surface = self.font.render(cam_text, True, (255, 255, 255)) self.screen.blit(cam_surface, (10, 10)) - # Zoom info zoom_text = f"Zoom: {camera.zoom:.2f}" zoom_surface = self.font.render(zoom_text, True, (255, 255, 255)) self.screen.blit(zoom_surface, (10, 40)) - # Particle count particle_text = f"Particles: {len(particle_system.particles)}" particle_surface = self.font.render(particle_text, True, (255, 255, 255)) self.screen.blit(particle_surface, (10, 70)) - # Gravity status gravity_text = f"Gravity: {'ON' if particle_system.grav_enabled else 'OFF'} (G to toggle)" gravity_surface = self.font.render(gravity_text, True, (255, 255, 255)) self.screen.blit(gravity_surface, (10, 100)) - # Black hole info bh_text = f"BH Mass: {black_hole.mass:.2e} kg" bh_surface = self.font.render(bh_text, True, (255, 255, 255)) self.screen.blit(bh_surface, (10, 130)) @@ -183,7 +148,6 @@ def render_ui(self, camera, particle_system, black_hole): rs_surface = self.font.render(rs_text, True, (255, 255, 255)) self.screen.blit(rs_surface, (10, 160)) - # Controls controls = [ "Controls:", "WASD - Move camera", diff --git a/src/rendering/rendering.md b/src/rendering/rendering.md new file mode 100644 index 0000000..85d4b5e --- /dev/null +++ b/src/rendering/rendering.md @@ -0,0 +1,66 @@ +# Rendering Module Documentation + +## Overview +The rendering module handles visualization of the black hole simulation using Pygame. It includes camera controls, rendering of black holes, particles, and UI elements. + +## camera.py + +The `Camera` class manages the viewpoint and user controls for navigating the 3D simulation in 2D screen space. + +### Class: Camera + +- **__init__(self, position=None)**: Initializes camera at default or given position, with target at origin and up vector (0,1,0). Sets movement, zoom, rotation speeds from config. + +- **handle_event(self, event, dt)**: Processes discrete events like key presses for reset (R), zoom in/out (+/-). + +- **handle_continuous_movement(self, keys, dt)**: Handles continuous key presses (WASDQE) for movement. Calculates world-space movement vectors, applies velocity with damping, updates position. Prevents camera from getting too close to black hole. + +- **world_to_screen(self, world_pos, screen_width, screen_height)**: Projects 3D world position to 2D screen coordinates using simple perspective projection based on camera forward vector and zoom. + +- **normalize(vector)**: Utility function to normalize vectors, handling zero norm case. + +Provides smooth camera movement and projection for rendering. + +## camera_config.py + +Defines camera configuration parameters: + +- **cdist = 1e15**: Default distance from black hole. + +- **cspeed = 1e15**: Movement speed. + +- **czoom = 0.5**: Zoom speed multiplier. + +- **cmove = 5e13**: Move speed. + +- **crot = 1**: Rotation speed. + +These are used to tune camera behavior. + +## renderer.py + +The `Renderer` class handles all drawing operations, including black hole, particles, trails, glow effects, and UI. + +### Class: Renderer + +- **__init__(self, width, height, screen)**: Initializes with screen dimensions, font, glow surface, and precomputes accretion disk texture. + +- **create_disk_texture(self, size)**: Generates a radial gradient texture for the accretion disk, with colors varying by distance (red to blue). + +- **render(self, black_hole, particle_system, camera)**: Main render loop: clears screen, renders black hole, particles, applies glow, renders UI. + +- **render_black_hole(self, black_hole, camera)**: Renders black hole with accretion disk (scaled texture), photon sphere, event horizon (dark circle), shadow, and lensing rings. + +- **render_particles(self, particle_system, camera)**: Renders each particle as a circle with size based on mass, glow effect, and trail if present. + +- **render_trail(self, particle, camera)**: Draws particle trail as fading lines on glow surface. + +- **apply_glow(self)**: Blurs glow surface and blends onto main screen for lighting effects. + +- **render_ui(self, camera, particle_system, black_hole)**: Displays camera position, zoom, particle count, gravity status, black hole mass, Schwarzschild radius, and controls help. + +Manages visual representation with effects like glow and trails. + +## __init__.py + +Marks directory as Python package (empty). \ No newline at end of file diff --git a/src/utils/utils.md b/src/utils/utils.md new file mode 100644 index 0000000..09eec53 --- /dev/null +++ b/src/utils/utils.md @@ -0,0 +1,18 @@ +# Utils Module Documentation + +## Overview +The utils module provides utility functions for vector mathematics used in physics and rendering. + +## vectors.py + +Contains functions for common vector operations. + +- **normalize(vector)**: Normalizes a vector to unit length. If norm is zero, returns the original vector to avoid division by zero. + +- **rotate_vector(vector, axis, angle)**: Rotates a vector around an axis by a given angle using Rodrigues' rotation formula: \( v' = v \cos\theta + (k \times v) \sin\theta + k (k \cdot v) (1 - \cos\theta) \), where k is the unit axis. + +These functions support 3D transformations in the simulation. + +## __init__.py + +Marks directory as Python package (empty). \ No newline at end of file diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..0874bcb --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,47 @@ +import pytest +import numpy as np +import sys +import os + +# Add src to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from physics.black_hole import BlackHole +from physics.particle import ParticleSystem +from rendering.camera import Camera +from rendering.renderer import Renderer + +class TestIntegration: + def test_black_hole_particle_interaction(self): + bh = BlackHole(mass=1e30, position=(0, 0, 0)) + ps = ParticleSystem() + ps.add_particle(1e10, (5, 0, 0), (0, 0, 0), (1, 0, 0)) + + initial_pos = ps.particles[0].position.copy() + ps.update(bh, 1.0) + + # Particle should move toward black hole + assert ps.particles[0].position[0] < initial_pos[0] + + def test_camera_projection(self): + cam = Camera(position=(0, 0, 10)) + bh = BlackHole(mass=1e30, position=(0, 0, 0)) + + screen_pos = cam.world_to_screen(bh.position, 800, 600) + assert screen_pos == (400, 300) + + def test_full_simulation_step(self): + bh = BlackHole(mass=1e30) + ps = ParticleSystem() + ps.add_particle(1e10, (5, 0, 0), (0, 0, 0), (1, 0, 0)) + cam = Camera() + + # Simulate one update step + ps.update(bh, 1.0) + + # Check particle moved + assert not np.array_equal(ps.particles[0].position, (5, 0, 0)) + + # Camera should still work + screen_pos = cam.world_to_screen(ps.particles[0].position, 800, 600) + assert screen_pos is not None \ No newline at end of file diff --git a/tests/test_physics.py b/tests/test_physics.py new file mode 100644 index 0000000..672361a --- /dev/null +++ b/tests/test_physics.py @@ -0,0 +1,114 @@ +import pytest +import numpy as np +import sys +import os + +# Add src to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from physics.black_hole import BlackHole +from physics.particle import Particle, ParticleSystem +from physics.constants import G, C + +class TestBlackHole: + def test_init(self): + bh = BlackHole(mass=1e30, position=(1, 2, 3)) + assert bh.mass == 1e30 + assert np.array_equal(bh.position, (1, 2, 3)) + expected_rs = 2 * G * 1e30 / (C ** 2) + assert bh.schwarz_radius == expected_rs + + def test_get_rad_vec(self): + bh = BlackHole(mass=1e30, position=(0, 0, 0)) + r_vec = bh.get_rad_vec((3, 4, 0)) + assert np.array_equal(r_vec, (-3, -4, 0)) + + def test_get_rad(self): + bh = BlackHole(mass=1e30, position=(0, 0, 0)) + r = bh.get_rad((3, 4, 0)) + assert r == 5.0 + + def test_calc_grav_accel(self): + bh = BlackHole(mass=1e30, position=(0, 0, 0)) + accel = bh.calc_grav_accel((5, 0, 0)) + expected_mag = G * 1e30 / 25 + assert abs(np.linalg.norm(accel) - expected_mag) < 1e-10 + assert np.array_equal(accel, (-expected_mag, 0, 0)) # Direction toward BH + + def test_calc_grav_accel_at_center(self): + bh = BlackHole(mass=1e30, position=(0, 0, 0)) + accel = bh.calc_grav_accel((0, 0, 0)) + assert np.array_equal(accel, (0, 0, 0)) + + def test_is_inside_horizon(self): + bh = BlackHole(mass=1e30) + assert bh.is_inside_horizon((0, 0, 0)) # At center + assert not bh.is_inside_horizon((2000, 0, 0)) # Outside + +class TestParticle: + def test_init(self): + p = Particle(mass=1e10, position=(1, 2, 3), velocity=(0, 0, 0), colour=(1, 0, 0)) + assert p.mass == 1e10 + assert np.array_equal(p.position, (1, 2, 3)) + assert np.array_equal(p.velocity, (0, 0, 0)) + assert p.colour == (1, 0, 0) + assert len(p.trail) == 0 + + def test_update_vector_accel(self): + p = Particle(mass=1e10, position=(0, 0, 0), velocity=(1, 0, 0), colour=(1, 0, 0)) + accel = np.array([0, 1, 0]) + p.update(accel, 1.0) + assert np.array_equal(p.velocity, (1, 1, 0)) + assert np.array_equal(p.position, (1, 1, 0)) + assert len(p.trail) == 1 + + def test_update_scalar_accel(self): + p = Particle(mass=1e10, position=(3, 0, 0), velocity=(0, 0, 0), colour=(1, 0, 0)) + p.update(1.0, 1.0) # Scalar accel toward center + expected_vel = np.array([-1, 0, 0]) # Direction normalized + assert np.allclose(p.velocity, expected_vel, atol=1e-10) + + def test_trail_management(self): + p = Particle(mass=1e10, position=(0, 0, 0), velocity=(0, 0, 0), colour=(1, 0, 0)) + for i in range(60): + p.update((0, 0, 0), 1.0) + assert len(p.trail) == 50 # Max length + +class TestParticleSystem: + def test_init(self): + ps = ParticleSystem() + assert len(ps.particles) == 0 + assert ps.grav_enabled + + def test_add_particle(self): + ps = ParticleSystem() + ps.add_particle(1e10, (0, 0, 0), (0, 0, 0), (1, 0, 0)) + assert len(ps.particles) == 1 + assert ps.particles[0].mass == 1e10 + + def test_update_with_gravity(self): + bh = BlackHole(mass=1e30, position=(0, 0, 0)) + ps = ParticleSystem() + p = Particle(mass=1e10, position=(5, 0, 0), velocity=(0, 0, 0), colour=(1, 0, 0)) + ps.add_particle(p.mass, p.position, p.velocity, p.colour) + ps.update(bh, 1.0) + # Particle should accelerate toward BH + assert ps.particles[0].velocity[0] < 0 + + def test_update_without_gravity(self): + bh = BlackHole(mass=1e30, position=(0, 0, 0)) + ps = ParticleSystem() + ps.grav_enabled = False + p = Particle(mass=1e10, position=(0, 0, 0), velocity=(1, 0, 0), colour=(1, 0, 0)) + ps.add_particle(p.mass, p.position, p.velocity, p.colour) + ps.update(bh, 1.0) + # No acceleration + assert np.array_equal(ps.particles[0].velocity, (1, 0, 0)) + + def test_horizon_color_change(self): + bh = BlackHole(mass=1e30, position=(0, 0, 0)) # Large BH + ps = ParticleSystem() + p = Particle(mass=1e10, position=(0, 0, 0), velocity=(0, 0, 0), colour=(1, 0, 0)) + ps.add_particle(p.mass, p.position, p.velocity, p.colour) + ps.update(bh, 1.0) + assert ps.particles[0].colour == (0, 0, 0) # Black inside horizon \ No newline at end of file diff --git a/tests/test_rendering.py b/tests/test_rendering.py new file mode 100644 index 0000000..418f765 --- /dev/null +++ b/tests/test_rendering.py @@ -0,0 +1,72 @@ +import pytest +import numpy as np +import sys +import os + +# Add src to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from rendering.camera import Camera +from rendering.camera_config import cdist, cspeed, czoom, cmove, crot +from rendering.renderer import Renderer +from physics.black_hole import BlackHole +from physics.particle import ParticleSystem + +class TestCamera: + def test_init_default(self): + cam = Camera() + expected_pos = np.array([0.0, cdist, -cdist]) + assert np.array_equal(cam.position, expected_pos) + assert np.array_equal(cam.target, (0, 0, 0)) + assert np.array_equal(cam.up, (0, 1, 0)) + assert cam.move_speed == cmove + assert cam.zoom_speed == cspeed + assert cam.zoom == czoom + + def test_init_custom(self): + pos = (1, 2, 3) + cam = Camera(position=pos) + assert np.array_equal(cam.position, pos) + + def test_world_to_screen(self): + cam = Camera(position=(0, 0, 10)) + screen_pos = cam.world_to_screen((0, 0, 0), 800, 600) + assert screen_pos == (400, 300) # Center of screen + + def test_world_to_screen_behind(self): + cam = Camera(position=(0, 0, 10)) + screen_pos = cam.world_to_screen((0, 0, 20), 800, 600) + assert screen_pos is None # Behind camera + +class TestRenderer: + @pytest.fixture + def renderer(self): + import pygame + pygame.init() + screen = pygame.display.set_mode((800, 600)) + yield Renderer(800, 600, screen) + pygame.quit() + + def test_init(self, renderer): + assert renderer.width == 800 + assert renderer.height == 600 + assert renderer.font is not None + assert renderer.glow_surface is not None + assert renderer.disk_texture is not None + + def test_create_disk_texture(self, renderer): + texture = renderer.create_disk_texture(64) + assert texture.get_size() == (64, 64) + # Check center pixel (should be bright) + center_color = texture.get_at((32, 32)) + assert center_color[3] > 0 # Alpha > 0 + # Check edge pixel (should be darker) + edge_color = texture.get_at((0, 0)) + assert edge_color[3] == 0 # Transparent at edge + + def test_render_black_hole(self, renderer): + bh = BlackHole(mass=1e30) + cam = Camera() + # This would require a mock screen, but for now, assume it doesn't crash + # In real test, use pygame.display.set_mode with no frame + pass # Skip detailed test due to pygame dependency \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..7ff9be4 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,34 @@ +import pytest +import numpy as np +import sys +import os + +# Add src to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from utils.vectors import normalize, rotate_vector + +class TestVectors: + def test_normalize(self): + vec = np.array([3, 4, 0]) + norm_vec = normalize(vec) + assert np.allclose(np.linalg.norm(norm_vec), 1.0) + assert np.allclose(norm_vec, (0.6, 0.8, 0)) + + def test_normalize_zero(self): + vec = np.array([0, 0, 0]) + norm_vec = normalize(vec) + assert np.array_equal(norm_vec, (0, 0, 0)) + + def test_rotate_vector_x_axis(self): + vec = np.array([0, 0, 1]) + axis = np.array([1, 0, 0]) + rotated = rotate_vector(vec, axis, np.pi / 2) + expected = np.array([0, -1, 0]) + assert np.allclose(rotated, expected, atol=1e-10) + + def test_rotate_vector_identity(self): + vec = np.array([1, 0, 0]) + axis = np.array([0, 1, 0]) + rotated = rotate_vector(vec, axis, 0) + assert np.allclose(rotated, vec) \ No newline at end of file