From 82d52be8a5be7e07b2fa5b913a0b588170108e82 Mon Sep 17 00:00:00 2001 From: ScriptSorcerer Date: Thu, 24 Sep 2026 15:45:31 +0200 Subject: [PATCH] proejct commit --- .gitignore | 11 + README.md | 193 +++++++ docs/ARCHITECTURE.md | 159 ++++++ docs/CONTROLS.md | 55 ++ docs/PHYSICS.md | 475 +++++++++++++++++ docs/PHYSIK_EINSTIEG.md | 466 ++++++++++++++++ docs/TESTING.md | 124 +++++ docs/images/debug.png | Bin 0 -> 68031 bytes docs/images/screenshot.png | Bin 0 -> 50595 bytes pyproject.toml | 74 +++ requirements-dev.txt | 6 + requirements.txt | 10 + src/sandbox/__init__.py | 18 + src/sandbox/__main__.py | 5 + src/sandbox/config.py | 440 +++++++++++++++ src/sandbox/demo.py | 51 ++ src/sandbox/input_handler.py | 232 ++++++++ src/sandbox/interaction/__init__.py | 20 + src/sandbox/interaction/drag.py | 116 ++++ src/sandbox/interaction/tools.py | 137 +++++ src/sandbox/interaction/velocity_tracker.py | 79 +++ src/sandbox/main.py | 283 ++++++++++ src/sandbox/physics/__init__.py | 36 ++ src/sandbox/physics/body.py | 278 ++++++++++ src/sandbox/physics/broadphase.py | 51 ++ src/sandbox/physics/collision.py | 266 ++++++++++ src/sandbox/physics/shapes.py | 139 +++++ src/sandbox/physics/sleep.py | 126 +++++ src/sandbox/physics/solver.py | 499 +++++++++++++++++ src/sandbox/physics/timestep.py | 69 +++ src/sandbox/physics/vector.py | 162 ++++++ src/sandbox/physics/world.py | 280 ++++++++++ src/sandbox/renderer.py | 243 +++++++++ src/sandbox/ui/__init__.py | 1 + src/sandbox/ui/drawing.py | 65 +++ src/sandbox/ui/sidebar.py | 298 +++++++++++ src/sandbox/ui/style.py | 59 +++ src/sandbox/ui/widgets.py | 500 ++++++++++++++++++ src/sandbox/viewport.py | 44 ++ tests/__init__.py | 1 + tests/app/__init__.py | 1 + tests/app/conftest.py | 18 + tests/app/driver.py | 68 +++ tests/app/test_app_integration.py | 332 ++++++++++++ tests/app/test_entry_points_and_edge_cases.py | 111 ++++ tests/app/test_smoke.py | 77 +++ tests/app/test_widgets.py | 193 +++++++ tests/conftest.py | 12 + tests/helpers.py | 81 +++ tests/interaction/__init__.py | 1 + tests/interaction/test_demo.py | 29 + tests/interaction/test_drag_and_tools.py | 226 ++++++++ tests/interaction/test_velocity_tracker.py | 75 +++ tests/physics/__init__.py | 1 + tests/physics/test_bodies.py | 171 ++++++ tests/physics/test_broadphase.py | 56 ++ tests/physics/test_collision.py | 244 +++++++++ tests/physics/test_sleep.py | 105 ++++ tests/physics/test_solver.py | 186 +++++++ tests/physics/test_timestep.py | 57 ++ tests/physics/test_vector.py | 127 +++++ tests/physics/test_world.py | 446 ++++++++++++++++ tests/test_architecture.py | 60 +++ 63 files changed, 8748 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CONTROLS.md create mode 100644 docs/PHYSICS.md create mode 100644 docs/PHYSIK_EINSTIEG.md create mode 100644 docs/TESTING.md create mode 100644 docs/images/debug.png create mode 100644 docs/images/screenshot.png create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 src/sandbox/__init__.py create mode 100644 src/sandbox/__main__.py create mode 100644 src/sandbox/config.py create mode 100644 src/sandbox/demo.py create mode 100644 src/sandbox/input_handler.py create mode 100644 src/sandbox/interaction/__init__.py create mode 100644 src/sandbox/interaction/drag.py create mode 100644 src/sandbox/interaction/tools.py create mode 100644 src/sandbox/interaction/velocity_tracker.py create mode 100644 src/sandbox/main.py create mode 100644 src/sandbox/physics/__init__.py create mode 100644 src/sandbox/physics/body.py create mode 100644 src/sandbox/physics/broadphase.py create mode 100644 src/sandbox/physics/collision.py create mode 100644 src/sandbox/physics/shapes.py create mode 100644 src/sandbox/physics/sleep.py create mode 100644 src/sandbox/physics/solver.py create mode 100644 src/sandbox/physics/timestep.py create mode 100644 src/sandbox/physics/vector.py create mode 100644 src/sandbox/physics/world.py create mode 100644 src/sandbox/renderer.py create mode 100644 src/sandbox/ui/__init__.py create mode 100644 src/sandbox/ui/drawing.py create mode 100644 src/sandbox/ui/sidebar.py create mode 100644 src/sandbox/ui/style.py create mode 100644 src/sandbox/ui/widgets.py create mode 100644 src/sandbox/viewport.py create mode 100644 tests/__init__.py create mode 100644 tests/app/__init__.py create mode 100644 tests/app/conftest.py create mode 100644 tests/app/driver.py create mode 100644 tests/app/test_app_integration.py create mode 100644 tests/app/test_entry_points_and_edge_cases.py create mode 100644 tests/app/test_smoke.py create mode 100644 tests/app/test_widgets.py create mode 100644 tests/conftest.py create mode 100644 tests/helpers.py create mode 100644 tests/interaction/__init__.py create mode 100644 tests/interaction/test_demo.py create mode 100644 tests/interaction/test_drag_and_tools.py create mode 100644 tests/interaction/test_velocity_tracker.py create mode 100644 tests/physics/__init__.py create mode 100644 tests/physics/test_bodies.py create mode 100644 tests/physics/test_broadphase.py create mode 100644 tests/physics/test_collision.py create mode 100644 tests/physics/test_sleep.py create mode 100644 tests/physics/test_solver.py create mode 100644 tests/physics/test_timestep.py create mode 100644 tests/physics/test_vector.py create mode 100644 tests/physics/test_world.py create mode 100644 tests/test_architecture.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d238431 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.pytest_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f560d6 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# Physik-Sandbox + +Interaktive 2D-Physiksimulation in Python. Über eine Werkzeugleiste erzeugst du Kreise und +Rechtecke unterschiedlicher Größe, greifst sie mit der Maus, ziehst sie herum und wirfst +sie gegeneinander. Die Körper fallen, prallen ab, reiben, rollen, stapeln sich und kommen +stabil zur Ruhe. + +Die **gesamte Physik ist selbst geschrieben** – Vektormathematik, Integration, +Kollisionserkennung und impulsbasierte Kollisionsauflösung verwenden ausschließlich das +Standardmodul `math`. [pygame-ce](https://pyga.me/) dient nur für Fenster, Zeichnen, +Eingaben und Zeitmessung. + +![Demo-Szene: Ein Rechteck wird gerade geworfen, daneben Kreispyramide und Rechteckturm](docs/images/screenshot.png) + +## Funktionen + +- Kreise und achsenparallele Rechtecke; Größe, Seitenverhältnis, Elastizität, Dichte und + Farbe in der Sidebar einstellbar – die Masse ergibt sich aus Fläche × Dichte. +- Objekte per Klick in die Welt oder per Ziehen aus der Vorschau-Kachel erzeugen. +- Drag & Drop mit Kollisionen: Ein gezogenes Objekt schiebt andere weg und wird von Wänden + gestoppt. Beim Loslassen erhält es die geglättete Mausgeschwindigkeit (Werfen). +- Schwerkraft, Kreis–Kreis-, Kreis–Rechteck-, Rechteck–Rechteck- und Wandkollisionen, + Restitution, Coulomb-Reibung, Rollen und Rollwiderstand. +- Fester Zeitschritt (120 Hz), semi-impliziter Euler, Warm-Starting, Positionskorrektur, + Schlafmodus für ruhende Körper, Geschwindigkeitsgrenze gegen Tunneln. +- Pause, Schwerkraft an/aus, Alles löschen, Debug-Ansicht mit Kontaktpunkten. + +## Voraussetzungen + +- Python **3.10 oder neuer** (entwickelt und getestet mit Python 3.14) +- Windows, macOS oder Linux + +> **Warum pygame-ce?** Das Original-`pygame` (2.6.1) bietet keine fertigen Pakete für +> Python 3.14. `pygame-ce` ist der API-kompatible Community-Fork; im Code heißt es weiterhin +> `import pygame`. + +## Einrichtung + +Im Projektordner eine virtuelle Umgebung anlegen und die Abhängigkeiten installieren: + +```bash +python -m venv .venv +``` + +Aktivieren – unter Windows (PowerShell): + +```powershell +.venv\Scripts\Activate.ps1 +``` + +Meldet PowerShell "Die Ausführung von Skripts ist auf diesem System deaktiviert", erlaube +lokale Skripte einmalig für deinen Benutzer (danach die Aktivierung wiederholen): + +```powershell +Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +``` + +Alternativ ohne Aktivierung: Ersetze in allen folgenden Befehlen + +| Befehl | durch | +|---|---| +| `python` | `.venv\Scripts\python.exe` | +| `pip` | `.venv\Scripts\python.exe -m pip` | +| `pytest` | `.venv\Scripts\python.exe -m pytest` | +| `ruff` | `.venv\Scripts\ruff.exe` | +| `physics-sandbox` | `.venv\Scripts\physics-sandbox.exe` | + +Unter macOS/Linux: + +```bash +source .venv/bin/activate +``` + +Dann installieren: + +```bash +pip install -r requirements.txt +``` + +`requirements.txt` installiert `pygame-ce` und das Projekt selbst im Editable-Modus +(`-e .`). Den Befehl deshalb im Projektordner ausführen. + +## Starten + +```bash +python -m sandbox +``` + +Weitere Möglichkeiten: + +| Befehl | Wirkung | +|---|---| +| `python -m sandbox --demo` | startet mit einer Beispielszene (Turm, Kreispyramide, Gummibälle) | +| `physics-sandbox` | gleichwertiger Befehl, den die Installation anlegt | +| `python -m sandbox --demo --screenshot bild.png --seconds 3` | läuft 3 s, speichert ein Bildschirmfoto und beendet sich | + +## Bedienung (Kurzfassung) + +| Eingabe | Aktion | +|---|---| +| Linksklick ins Leere | Objekt erzeugen (gedrückt halten und ziehen = sofort werfen) | +| Linksklick auf Objekt + Ziehen | greifen und verschieben; Loslassen in Bewegung wirft es | +| Ziehen aus der Vorschau-Kachel | neues Objekt aus der Sidebar in die Welt ziehen | +| Rechtsklick / `Entf` | Objekt unter der Maus löschen | +| Mausrad | Größe ändern (über einem Regler: diesen Regler verstellen) | +| `Leertaste` · `G` · `C` | Pause/Weiter · Schwerkraft an/aus · Alles löschen | +| `1` · `2` | Kreis · Rechteck | +| `F1` · `Esc` | Debug-Ansicht · Beenden | + +Alle Details: [docs/CONTROLS.md](docs/CONTROLS.md). + +## Entwicklung + +Entwicklungswerkzeuge (pytest, pytest-cov, ruff) installieren: + +```bash +pip install -r requirements-dev.txt +``` + +Tests ausführen (inklusive Headless-UI-Tests – es öffnet sich kein Fenster): + +```bash +pytest +``` + +Mit Testabdeckung: + +```bash +pytest --cov=sandbox --cov-report=term-missing +``` + +Code prüfen und formatieren: + +```bash +ruff check src tests +``` + +```bash +ruff format src tests +``` + +Details zu den Tests: [docs/TESTING.md](docs/TESTING.md). + +## Projektstruktur + +```text +├── src/sandbox/ +│ ├── main.py Einstiegspunkt, App und Game-Loop +│ ├── config.py alle Konstanten (Physik, Interaktion, Farben, Layout) +│ ├── physics/ eigene Physik-Engine – nur `math`, kein pygame +│ │ ├── vector.py Vector2 +│ │ ├── shapes.py Circle, Box (AABB), HalfPlane (Wände) +│ │ ├── body.py Body, Material +│ │ ├── collision.py Kollisionserkennung (Narrowphase) +│ │ ├── broadphase.py Sweep-and-Prune +│ │ ├── solver.py Impulsauflösung, Reibung, Rollwiderstand, Positionskorrektur +│ │ ├── sleep.py Schlafmodus (Kontakt-Inseln) +│ │ ├── timestep.py fester Zeitschritt +│ │ └── world.py Welt und Simulationsschritt +│ ├── interaction/ Werkzeuge ohne pygame: Erzeugen, Ziehen, Werfen +│ ├── ui/ Sidebar und selbst gezeichnete Widgets +│ ├── input_handler.py Maus und Tastatur → Aktionen +│ ├── renderer.py Zeichnen der Welt +│ ├── viewport.py Bildschirm ↔ Welt +│ └── demo.py Beispielszene +├── tests/ pytest-Suite (Physik, Interaktion, Headless-UI, Smoke-Test) +├── docs/ Dokumentation +├── requirements.txt Laufzeit-Abhängigkeiten +├── requirements-dev.txt Test- und Linting-Werkzeuge +└── pyproject.toml Paket-, ruff- und pytest-Konfiguration +``` + +## Dokumentation + +- [docs/PHYSIK_EINSTIEG.md](docs/PHYSIK_EINSTIEG.md) – die Physik Schritt für Schritt für Einsteiger +- [docs/PHYSICS.md](docs/PHYSICS.md) – die verwendete Physik und ihre Formeln +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) – Module, Datenfluss im Game-Loop, Diagramme +- [docs/TESTING.md](docs/TESTING.md) – Tests ausführen, was getestet wird, Headless-Modus +- [docs/CONTROLS.md](docs/CONTROLS.md) – alle Maus- und Tastaturbefehle + +## Screenshots + +Demo-Szene mit gerade geworfenem Rechteck (gelb umrandet, der Pfeil zeigt die +Wurfgeschwindigkeit): + +![Demo-Szene](docs/images/screenshot.png) + +Debug-Ansicht (`F1`): Kontaktpunkte (gelb), Kontaktnormalen (rot) und Kennzahlen: + +![Debug-Ansicht](docs/images/debug.png) + +Ein aktuelles Bild der Demo-Szene erzeugst du jederzeit mit +`python -m sandbox --demo --screenshot docs/images/demo.png`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..02f10df --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,159 @@ +# Architektur + +## Schichten + +Das Projekt besteht aus drei Schichten. Abhängigkeiten zeigen nur nach unten: + +```mermaid +flowchart TB + subgraph APP["Anwendung (pygame-ce)"] + main["main.py
App · Game-Loop"] + input["input_handler.py
Maus/Tastatur → Aktionen"] + renderer["renderer.py
Welt zeichnen"] + ui["ui/
Sidebar · Widgets · Stil"] + end + subgraph TOOLS["Interaktion (ohne pygame)"] + drag["interaction/drag.py
DragController"] + tracker["interaction/velocity_tracker.py
VelocityTracker"] + tools["interaction/tools.py
ToolSettings · BodyFactory"] + demo["demo.py
Beispielszene"] + viewport["viewport.py
Bildschirm ↔ Welt"] + end + subgraph PHYS["Physik (nur math)"] + world["world.py
World"] + solver["solver.py
ContactSolver"] + collision["collision.py
Narrowphase"] + broad["broadphase.py
Sweep-and-Prune"] + sleep["sleep.py
Schlafmodus"] + body["body.py · shapes.py
Body · Material · Formen"] + vector["vector.py
Vector2"] + timestep["timestep.py
FixedTimestep"] + end + config["config.py
alle Konstanten"] + + APP --> TOOLS + APP --> PHYS + TOOLS --> PHYS + APP --> config + TOOLS --> config + PHYS --> config +``` + +| Schicht | Pakete/Module | Darf importieren | +|---|---|---| +| **Physik** | `sandbox.physics` | nur `math`, `typing`, `dataclasses`, `sandbox.config` | +| **Interaktion** | `sandbox.interaction`, `sandbox.demo`, `sandbox.viewport` | Physik, Standardbibliothek | +| **Anwendung** | `sandbox.main`, `sandbox.input_handler`, `sandbox.renderer`, `sandbox.ui` | alles, inklusive pygame | + +**Warum diese Trennung?** Die Physik soll ohne Grafik laufen und isoliert testbar sein. Das +ist eine Anforderung, und `tests/test_architecture.py` prüft sie doppelt: statisch über den +Syntaxbaum und zur Laufzeit in einem frischen Interpreter, in dem nach +`import sandbox.physics` kein pygame geladen sein darf. Die Interaktionslogik – etwa die +Glättung der Wurfgeschwindigkeit – arbeitet mit Weltkoordinaten und Zeitstempeln statt mit +pygame-Events. So lässt auch sie sich ohne Fenster testen. + +`config.py` enthält ausschließlich Konstanten in unveränderlichen Dataclasses. Im Code gibt +es keine "Magic Numbers". Tests variieren einzelne Werte mit `dataclasses.replace`. + +## Module + +| Modul | Verantwortung | +|---|---| +| `physics/vector.py` | `Vector2`: unveränderlicher 2D-Vektor (Addition, Skalierung, Skalar- und Kreuzprodukt, Länge, Normalisierung …) | +| `physics/shapes.py` | `Circle`, `Box` (AABB), `HalfPlane` (Wand): Fläche, Trägheitsmoment, Punkttest | +| `physics/body.py` | `Material`, `Body`: Zustand, Masse = Fläche × Dichte, inverse Massen, Schlafzustand | +| `physics/collision.py` | `Contact` und die Tests Kreis–Kreis, Kreis–Rechteck, Rechteck–Rechteck, Körper–Wand | +| `physics/broadphase.py` | Sweep-and-Prune: Kandidatenpaare in O(n·log n + k) | +| `physics/solver.py` | `ContactSolver`: Sequential Impulses (Normale, Reibung, Rollwiderstand), Warm-Starting, Positionskorrektur | +| `physics/sleep.py` | Kontakt-Inseln (Union-Find), Einschlafen und Aufwecken | +| `physics/timestep.py` | `FixedTimestep`: Frame-Zeit → Anzahl fester Schritte, Interpolationsgewicht | +| `physics/world.py` | `World`: Körper und Wände verwalten, `step(dt)` orchestrieren | +| `interaction/tools.py` | `ToolSettings` (Sidebar-Auswahl), `BodyFactory` (Körper erzeugen), `BodyStyle` (Farbe) | +| `interaction/drag.py` | `DragController`: Greifen, per Geschwindigkeit ziehen, Werfen | +| `interaction/velocity_tracker.py` | geglättete Mausgeschwindigkeit (Zeitfenster + exponentielle Glättung) | +| `viewport.py` | Verschiebung zwischen Bildschirm- und Weltkoordinaten | +| `input_handler.py` | Events verteilen: Sidebar zuerst, sonst Werkzeug-Aktion in der Welt; Tastenkürzel | +| `ui/widgets.py` | selbst gezeichnete Widgets: `Label`, `Button`, `SegmentedControl`, `Slider`, `ColorPicker`, `SpawnTile` | +| `ui/sidebar.py` | Aufbau und Layout der Werkzeugleiste | +| `ui/style.py`, `ui/drawing.py` | Schriften, Text-Cache, Zeichenhelfer für Kreise und Rechtecke | +| `renderer.py` | Welt zeichnen: Interpolation, Hervorhebung, Wurfpfeil, Vorschau, Debug-Ansicht | +| `main.py` | `App`: alles verdrahten, Hauptschleife, Kommandozeile | +| `demo.py` | Beispielszene für `--demo` | + +## Datenfluss im Game-Loop + +Die Hauptschleife (`App.run`) ruft pro Bild `App.frame(frame_time)` auf: + +```mermaid +sequenceDiagram + autonumber + participant Loop as App.run + participant App as App.frame + participant Input as InputHandler + participant Sidebar + participant Drag as DragController + participant Step as FixedTimestep + participant World + participant Draw as Renderer + Sidebar + + Loop->>App: frame_time (Clock.tick, max. 60 FPS) + loop alle pygame-Events + App->>Input: handle_event(event, zeit) + Input->>Sidebar: Event anbieten + alt Sidebar verbraucht es + Sidebar-->>Input: True (Regler, Button, Kachel …) + else Klick/Bewegung in der Welt + Input->>Drag: grab / move / release + Input->>World: Körper erzeugen / löschen + end + end + App->>Input: update(zeit) – Mausposition an Tracker, Hover bestimmen + App->>Step: advance(frame_time) + Step-->>App: n feste Schritte (meist 2 × 1/120 s) + loop n-mal + App->>Drag: apply(dt) – Geschwindigkeit Richtung Maus + App->>World: step(dt) + end + App->>Draw: Welt (interpoliert mit alpha), Sidebar, Vorschau + App->>Loop: display.flip() +``` + +Wichtige Punkte: + +- **Entkopplung:** Die Bildrate (bis 60 FPS) und die Physik (fest 120 Hz) sind unabhängig. + Der Akkumulator in `FixedTimestep` übersetzt zwischen beiden. Das Interpolationsgewicht + `alpha` glättet die Darstellung. +- **Ziehen im Physiktakt:** `DragController.apply` läuft vor *jedem* Physikschritt, nicht + nur einmal pro Bild. Der gezogene Körper wird wie jeder andere vom Solver behandelt. +- **Mausgeschwindigkeit pro Bild:** `InputHandler.update` meldet die Mausposition in jedem + Frame an den Tracker, auch ohne Maus-Event. Nur so merkt er, dass die Maus stillsteht. +- **Pause:** Keine Physikschritte. Ein gezogener Körper wird direkt versetzt + (`DragController.place`). + +## Ablauf eines Physikschritts + +Details und Formeln in [PHYSICS.md](PHYSICS.md#4-ablauf-eines-simulationsschritts): + +```mermaid +flowchart LR + Z[Zustand merken] --> A[v += g·dt] --> B[Broadphase] --> C[Narrowphase] + C --> D[Schlaf-Inseln
wecken] --> E[Wandkontakte] --> F[Solver:
Warm-Start +
8 Iterationen] + F --> G[v begrenzen] --> H[x += v·dt] --> I[Positions-
korrektur] + I --> K[Notbremse] --> J[Schlafmodus] +``` + +## Entwurfsentscheidungen + +| Entscheidung | Begründung | +|---|---| +| `Vector2` ist unveränderlich (Zuweisung löst `AttributeError` aus) | Keine Aliasing-Fehler: Ein Körper kann seine Position herausgeben, ohne dass jemand sie über die geteilte Referenz verändert. | +| Solver-Schleifen rechnen mit Floats statt mit `Vector2` | Sie laufen tausendfach pro Schritt. Objekt-Erzeugung ist in Python teuer; die Formeln sind komponentenweise identisch. | +| Kontakte kanonisch geordnet (Kreis vor Rechteck vor Wand, sonst nach `id`) | Ein Körperpaar hat in jedem Schritt dieselbe Orientierung – Voraussetzung für das Warm-Starting. | +| Wände sind statische `Body`-Objekte mit `HalfPlane`-Form | Der Solver behandelt Wandkontakte ohne Sonderfall (inverse Masse 0). Halbebenen sind tunnelsicher. | +| Rechtecke drehen nie (Trägheitsmoment unendlich) | Die Aufgabe verlangt achsenparallele Rechtecke; jeder Kontakt braucht dann nur einen Punkt. | +| Ziehen über Geschwindigkeit statt Teleport | Das gezogene Objekt kollidiert weiter und kann nicht durch Wände geschoben werden. | +| Positionskorrektur per Projektion statt Baumgarte-Geschwindigkeit | Überlappende Objekte werden getrennt, ohne davongeschleudert zu werden. | +| Schlafmodus über Kontakt-Inseln, die gemeinsam einschlafen und gemeinsam aufwachen | Verhindert das gegenseitige Aufwecken einzelner Körper und schlafende Körper, die nach dem Wegziehen ihrer Stütze in der Luft hängen. | +| Abprallen aus der Geschwindigkeit vom Schrittbeginn | Sonst ginge die im selben Schritt addierte Schwerkraft in den Rückprall ein – ein elastischer Ball gewönne Höhe. | +| Widgets fragen Zustände über Callbacks ab | Die Anzeige stimmt immer, auch wenn ein Wert per Tastenkürzel geändert wurde. | +| Keine GUI-Bibliothek | pygame-ce genügt für die wenigen Widgets; weniger Abhängigkeiten. | diff --git a/docs/CONTROLS.md b/docs/CONTROLS.md new file mode 100644 index 0000000..7060c10 --- /dev/null +++ b/docs/CONTROLS.md @@ -0,0 +1,55 @@ +# Bedienung + +## Maus in der Welt + +| Eingabe | Aktion | +|---|---| +| **Linksklick ins Leere** | Erzeugt ein Objekt mit den Einstellungen der Sidebar an der Mausposition. Hält man die Taste gedrückt, hängt das neue Objekt sofort an der Maus und kann direkt geworfen werden. | +| **Linksklick auf ein Objekt, gedrückt halten** | Greift das Objekt (gelbe Umrandung). Es folgt der Maus, kollidiert dabei aber weiter: Leichtere Objekte werden weggeschoben, Wände und schwere Stapel bremsen es. | +| **Loslassen während der Bewegung** | Wirft das Objekt mit der (geglätteten) Mausgeschwindigkeit der letzten Frames. Der gelbe Pfeil zeigt beim Ziehen Richtung und Stärke des Wurfs. | +| **Loslassen nach kurzem Stillhalten** | Das Objekt fällt einfach herunter, ohne Schwung. | +| **Rechtsklick auf ein Objekt** | Löscht das Objekt. | +| **Mausrad** | Ändert die Größe neuer Objekte. | +| **Maus über einem Objekt** | Weiße Umrandung und Hand-Cursor: Das Objekt kann gegriffen werden. | +| **Maus über leerer Fläche** | Halbtransparente Vorschau des Objekts, das ein Klick erzeugen würde. | + +## Sidebar + +| Element | Wirkung | +|---|---| +| **Kreis / Rechteck** | Form neuer Objekte. | +| **Größe** | Radius bzw. halbe Kantenlänge (12–80 px). | +| **Seitenverhältnis** | Breite : Höhe von Rechtecken (0,5–2). Die Fläche und damit die Masse bleibt dabei gleich – außer bei sehr kleinen Rechtecken, deren kurze Seite nicht unter die Mindestgröße (24 px) fallen darf. Bei Kreisen ausgegraut. | +| **Elastizität** | Stoßzahl 0 (kein Abprallen) bis 1 (vollkommen elastisch). | +| **Dichte** | Masse pro Fläche (0,2–5). Schwere Objekte schieben leichte leichter weg. | +| **Farbe** | Feste Farbe oder (erstes Feld) Zufallsfarbe. | +| **Vorschau-Kachel** | Zeigt das nächste Objekt in seiner echten Farbe. Mit gedrückter Maustaste in die Welt ziehen: Das Objekt erscheint am Weltrand und hängt an der Maus. Wird über der Sidebar losgelassen, entsteht kein Objekt. | +| **Pause / Weiter** | Hält die Simulation an. Im Pausenmodus lassen sich Objekte erzeugen und exakt platzieren; beim Loslassen werden sie nicht geworfen. | +| **Schwerkraft an / aus** | Schaltet die Schwerkraft um und weckt alle ruhenden Objekte. | +| **Alles löschen** | Entfernt alle Objekte. | + +Alle Regler reagieren auf Klicken, Ziehen und das Mausrad (in Rasterschritten). Beim Ziehen +darf die Maus die Sidebar verlassen. + +## Tastatur + +| Taste | Aktion | +|---|---| +| `Leertaste` | Pause / Weiter | +| `G` | Schwerkraft an / aus | +| `C` | Alles löschen | +| `Entf` | Objekt unter der Maus löschen | +| `1` | Form: Kreis | +| `2` | Form: Rechteck | +| `F1` | Debug-Ansicht: Kontaktpunkte (gelb), Kontaktnormalen (rot), schlafende Objekte (graue Umrandung), Kennzahlen | +| `Esc` | Beenden | + +Die Tastenbelegung steht in `KEY_BINDINGS` in [`src/sandbox/config.py`](../src/sandbox/config.py). + +## Kommandozeile + +| Option | Wirkung | +|---|---| +| `--demo` | startet mit einer Beispielszene | +| `--screenshot DATEI` | läuft `--seconds` Sekunden, speichert ein Bildschirmfoto und beendet sich | +| `--seconds S` | Laufzeit vor dem Bildschirmfoto (Standard: 3) | diff --git a/docs/PHYSICS.md b/docs/PHYSICS.md new file mode 100644 index 0000000..bef9135 --- /dev/null +++ b/docs/PHYSICS.md @@ -0,0 +1,475 @@ +# Physik der Sandbox + +Dieses Dokument erklärt, *was* die Physik-Engine berechnet und *warum* sie es so tut. Alle +Formeln finden sich im Code wieder, meist mit derselben Bezeichnung. Die Zahlenwerte stehen +in [`src/sandbox/config.py`](../src/sandbox/config.py) (`PhysicsConfig`). + +Die Physik-Schicht (`src/sandbox/physics/`) verwendet ausschließlich das Standardmodul +`math` sowie `typing`/`dataclasses` für Typangaben. Ein Architektur-Test stellt sicher, +dass dort nie pygame importiert wird. + +## Inhalt + +1. [Einheiten und Koordinaten](#1-einheiten-und-koordinaten) +2. [Körper: Masse und Trägheit](#2-körper-masse-und-trägheit) +3. [Integration mit festem Zeitschritt](#3-integration-mit-festem-zeitschritt) +4. [Ablauf eines Simulationsschritts](#4-ablauf-eines-simulationsschritts) +5. [Kollisionserkennung](#5-kollisionserkennung) +6. [Impulsbasierte Kollisionsauflösung](#6-impulsbasierte-kollisionsauflösung) +7. [Restitution (Elastizität)](#7-restitution-elastizität) +8. [Reibung und Rollen](#8-reibung-und-rollen) +9. [Rollwiderstand](#9-rollwiderstand) +10. [Positionskorrektur](#10-positionskorrektur) +11. [Ruhe ohne Zittern: Schlafmodus](#11-ruhe-ohne-zittern-schlafmodus) +12. [Tunneln verhindern](#12-tunneln-verhindern) +13. [Ziehen und Werfen](#13-ziehen-und-werfen) +14. [Parameterübersicht](#14-parameterübersicht) + +--- + +## 1. Einheiten und Koordinaten + +| Größe | Einheit | +|---|---| +| Länge | Pixel (px) | +| Zeit | Sekunde (s) | +| Geschwindigkeit | px/s | +| Masse | Fläche × Dichte (px² · Dichte; nur Verhältnisse zählen) | + +Der Ursprung liegt oben links in der Welt, **x zeigt nach rechts, y nach unten** – wie auf +dem Bildschirm. Die Schwerkraft ist deshalb `g = (0, +980) px/s²`; bei einem Maßstab von +100 px pro Meter entspricht das 9,8 m/s². + +Weil y nach unten zeigt, bedeutet ein positiver Drehwinkel eine Drehung **im +Uhrzeigersinn** auf dem Bildschirm. Die Formeln sind davon unabhängig, solange man +konsequent `a × b = a.x·b.y − a.y·b.x` verwendet. + +## 2. Körper: Masse und Trägheit + +Jeder Körper (`Body`) hat eine Form, eine Position (Schwerpunkt), eine Geschwindigkeit und +bei Kreisen zusätzlich Winkel und Winkelgeschwindigkeit. + +**Masse = Fläche × Dichte.** Damit sind größere Körper bei gleicher Dichte schwerer: + +| Form | Fläche | Trägheitsmoment | +|---|---|---| +| Kreis (Radius r) | `π·r²` | `I = ½·m·r²` (homogene Scheibe) | +| Rechteck (halbe Kanten hw, hh) | `4·hw·hh` | unendlich (dreht nie) | + +Herleitung des Scheiben-Trägheitsmoments: Die Scheibe zerfällt in dünne Ringe mit Radius s +und Masse `dm = m/(π·r²) · 2π·s·ds`. Jeder Ring trägt `s²·dm` bei: + +```text +I = (2m / r²) · ∫₀ʳ s³ ds = (2m / r²) · r⁴/4 = ½·m·r² +``` + +Rechtecke sind laut Aufgabenstellung **achsenparallel**. Ein unendliches Trägheitsmoment +(also `1/I = 0`) garantiert, dass kein Drehimpuls sie jemals drehen kann. + +**Inverse Massen.** Gespeichert werden zusätzlich `1/m` und `1/I`. Wände haben `1/m = 0` +(unendliche Masse). So passen bewegliche und unbewegliche Körper ohne Sonderfälle in +dieselben Formeln: Ein Impuls P ändert die Geschwindigkeit um `P/m` – bei einer Wand um 0. + +## 3. Integration mit festem Zeitschritt + +### Semi-impliziter Euler + +Pro Schritt `dt = 1/120 s`: + +```text +v ← v + g·dt (zuerst die Geschwindigkeit …) +x ← x + v·dt (… dann die Position mit der NEUEN Geschwindigkeit) +θ ← θ + ω·dt +``` + +Der explizite Euler (Position mit der *alten* Geschwindigkeit) gewinnt bei Schwingungen und +Umlaufbahnen systematisch Energie und wird instabil. Der semi-implizite (symplektische) +Euler kostet genauso wenig, bleibt aber energetisch stabil. + +Für den freien Fall ergibt sich nach n Schritten exakt `v = n·g·dt` und +`s = g·dt²·n(n+1)/2`. Das weicht nur um den Faktor `(1 + 1/n)` vom analytischen +`s = ½·g·t²` ab; nach 0,5 s sind das 1,7 %. Genau diese Beziehung prüft ein Test. + +### Fester Zeitschritt, entkoppelt von der Bildrate + +Die Stabilität hängt von `dt` ab. Mit der schwankenden Frame-Dauer als `dt` würde sich die +Simulation auf jedem Rechner anders verhalten. Ein Ruckler von 200 ms ließe Körper außerdem +durch andere hindurchspringen. Deshalb sammelt ein Akkumulator (`FixedTimestep`) die real +vergangene Zeit und gibt sie in festen Portionen weiter: + +```text +akkumulator += frame_zeit +while akkumulator ≥ dt: world.step(dt); akkumulator −= dt +alpha = akkumulator / dt (Rest für die Interpolation) +``` + +- **Obergrenze** `max_steps_per_frame`: Schafft der Rechner die Physik nicht in Echtzeit, + wird der Rückstand verworfen, statt ihn endlos mitzuschleppen ("Spiral of Death"). +- **Interpolation:** Gezeichnet wird `lerp(vorige_position, position, alpha)`. Die Physik + läuft mit 120 Hz, der Bildschirm z. B. mit 60 Hz – ohne Interpolation ruckelt die + Bewegung sichtbar. + +## 4. Ablauf eines Simulationsschritts + +`World.step(dt)` führt nacheinander aus: + +```mermaid +flowchart TD + A[Zustand merken
für Interpolation] --> B[Geschwindigkeiten integrieren
v += g·dt] + B --> C[Broadphase
Sweep-and-Prune] + C --> D[Narrowphase
Kontakte Körper–Körper] + D --> E[Berührte Schläfer wecken] + E --> F[Wandkontakte] + F --> G[Impulse lösen
Normale · Reibung · Rollwiderstand] + G --> H[Geschwindigkeit begrenzen] + H --> I[Positionen integrieren
x += v·dt] + I --> J[Positionskorrektur] + J --> K[Notbremse an den Wänden] + K --> L[Schlafmodus aktualisieren] +``` + +Vor jedem Schritt setzt die Anwendung die Geschwindigkeit eines gezogenen Körpers +(siehe [Abschnitt 13](#13-ziehen-und-werfen)). + +## 5. Kollisionserkennung + +Jede Prüfung liefert einen **Kontakt** mit + +- **Normale n** – Einheitsvektor von Körper A zu Körper B, +- **Eindringtiefe** – Überlappung entlang n (≥ 0), +- **Kontaktpunkt** – Angriffspunkt der Kräfte. + +### Broadphase: Sweep-and-Prune + +Alle Paare genau zu prüfen kostet n·(n−1)/2 Tests – bei 150 Körpern über 11 000 pro +Schritt. Deshalb werden die umschließenden Boxen nach ihrer linken Kante sortiert. Für jede +Box müssen nur die Nachfolger geprüft werden, die links von ihrer rechten Kante beginnen. +Der Aufwand sinkt auf etwa O(n·log n + k) für k benachbarte Paare. Paare aus zwei +schlafenden Körpern werden übersprungen. + +### Kreis–Kreis + +```text +d = |p_B − p_A|, Kontakt, wenn d ≤ r_A + r_B +n = (p_B − p_A) / d, Tiefe = r_A + r_B − d +``` + +Der Kontaktpunkt liegt in der Mitte des Überlappungsbereichs. Liegen die Mittelpunkte exakt +aufeinander, ist die Richtung unbestimmt. Dann wird die feste Ersatznormale (0, −1) +verwendet, statt durch 0 zu teilen. + +### Kreis–Rechteck + +Den Kreismittelpunkt (relativ zum Rechteck) komponentenweise auf das Rechteck klemmen. Das +liefert den nächstgelegenen Punkt q des Rechtecks: + +```text +q = (clamp(dx, −hw, hw), clamp(dy, −hh, hh)) +Kontakt, wenn |Mittelpunkt − q| ≤ r +``` + +Sonderfall: Liegt der Kreismittelpunkt *im* Rechteck, ist q der Mittelpunkt selbst und +liefert keine Richtung. Dann wird der Kreis über die nächstgelegene Kante hinausgeschoben; +die Tiefe ist Radius + Abstand zu dieser Kante. + +### Rechteck–Rechteck + +Zwei achsenparallele Rechtecke überlappen genau dann, wenn sich ihre Projektionen auf +**beide** Achsen überlappen (Trennachsensatz): + +```text +overlap_x = hw_A + hw_B − |dx|, overlap_y = hh_A + hh_B − |dy| +``` + +Getrennt wird entlang der Achse mit der kleineren Überlappung – das ist der kürzeste Weg +aus der Durchdringung. Bei Gleichstand gewinnt die y-Achse, weil aufeinanderliegende Körper +der häufigste Fall sind. + +### Wände als Halbebenen + +Boden, Decke und Seiten sind Halbebenen `n·p ≥ offset` (die Normale zeigt in die Welt). +Die Reichweite eines Rechtecks entlang n ist `|n.x|·hw + |n.y|·hh`, die eines Kreises r. +Weil eine Halbebene unendlich dick ist, wird ein Körper **auch dann erkannt, wenn er in +einem Schritt komplett hinter die Wandlinie springt** – er kann nicht hindurchtunneln. + +## 6. Impulsbasierte Kollisionsauflösung + +### Herleitung des Kollisionsimpulses + +Relativgeschwindigkeit des Kontaktpunkts (B relativ zu A), mit den Hebelarmen r vom +Schwerpunkt zum Kontaktpunkt und `ω × r = ω·(−r.y, r.x)`: + +```text +v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), v_n = v_rel · n +``` + +`v_n < 0` bedeutet: Die Körper nähern sich an. Ein Impuls `P = λ·n` wirkt auf B positiv und +auf A negativ (actio = reactio): + +```text +v_A' = v_A − λ·n/m_A ω_A' = ω_A − (r_A × λn)/I_A +v_B' = v_B + λ·n/m_B ω_B' = ω_B + (r_B × λn)/I_B +``` + +Eingesetzt ändert sich die Normalgeschwindigkeit linear in λ: + +```text +v_n' = v_n + λ·K, K = 1/m_A + 1/m_B + (r_A × n)²/I_A + (r_B × n)²/I_B +``` + +Aus Newtons Stoßgesetz `v_n' = −e·v_n` folgt: + +```text +λ = −(1 + e) · v_n / K +``` + +`1/K` ist die **effektive Masse** des Kontakts – die träge Masse, die der Stoß entlang n +"spürt". Für einen einzelnen zentralen Stoß erhält diese Formel den Gesamtimpuls exakt und +liefert die Lehrbuchergebnisse. Beim elastischen Stoß zweier Kreise mit Massenverhältnis +1 : 3 und Geschwindigkeiten 200 bzw. −100 px/s kommen zum Beispiel exakt −250 und 50 px/s +heraus. Ein Test prüft das. + +**Besonderheit dieser Engine:** Bei Kreisen geht die Normale durch den Mittelpunkt +(r ∥ n, also r × n = 0), und Rechtecke drehen sich nie (1/I = 0). Die Drehterme von K +verschwinden daher für die Normale – **Normalimpulse erzeugen kein Drehmoment**. Nur die +Reibung bringt Kreise ins Rollen. + +### Viele Kontakte: Sequential Impulses + +Liegt ein Körper auf mehreren anderen, beeinflussen sich die Kontakte gegenseitig. Statt +ein Gleichungssystem zu lösen, werden die Kontakte reihum mehrfach aufgelöst +(Gauß-Seidel, `velocity_iterations = 8`). Zwei Details machen das Verfahren korrekt und +stabil: + +1. **Akkumulierte Impulse klemmen.** Geklemmt wird der über alle Durchläufe *summierte* + Impuls (`P_n ≥ 0`), nicht der Teilimpuls eines Durchlaufs. Ein späterer Durchlauf darf + einen zu großen früheren Impuls also wieder zurücknehmen. Das Verfahren konvergiert so + gegen die physikalische Lösung, in der Kontakte nur drücken und nie ziehen. +2. **Warm-Starting.** Die akkumulierten Impulse des vorigen Schritts werden zu Beginn erneut + angewendet. Ruhende Kontakte ändern sich kaum, der Startwert ist also schon fast richtig – + Stapel stehen dadurch nach wenigen Durchläufen stabil. Hat sich die Normale stark + gedreht (Skalarprodukt < 0,95), wird der alte Impuls verworfen. + +Reihenfolge je Kontakt und Durchlauf: **Normale → Reibung → Rollwiderstand.** Reibung und +Rollwiderstand sind durch den Normalimpuls begrenzt. Werden sie danach gelöst, gelten ihre +Grenzen nach jedem Durchlauf exakt für den aktuellen Normalimpuls. + +## 7. Restitution (Elastizität) + +Die Stoßzahl `e ∈ [0, 1]` ist das Verhältnis von Trenn- zu Aufprallgeschwindigkeit: +`e = 0` völlig unelastisch, `e = 1` vollkommen elastisch. + +- **Energieverlust:** Beim zentralen Stoß geht `ΔE = ½·μ·(1 − e²)·v_rel²` verloren + (μ = m_A·m_B/(m_A + m_B), die reduzierte Masse). Ein Test prüft genau diesen Wert. +- **Sprunghöhe:** Ein fallender Ball erreicht nach dem Aufprall `e²` seiner Fallhöhe + (Energie ∝ v²). +- **Mischregel:** `e = max(e_A, e_B)` – ein Gummiball springt auch von einem Holzklotz + ab. Wände haben `e = 0`, sodass allein das Material des Körpers zählt. +- **Schwellwert (`restitution_threshold` = 50 px/s):** Unterhalb dieser + Aufprallgeschwindigkeit wird nicht abgeprallt. Sonst würden aufliegende Körper durch + winzige Dauer-Hüpfer zittern. +- **Aufprallgeschwindigkeit vom Schrittbeginn:** `v_n` wird aus den Geschwindigkeiten *vor* + Schwerkraft und Warm-Starting bestimmt (`Body.previous_velocity`). Nähme man die + Geschwindigkeit nach der Schwerkraft, prallte ein Ball mit `e·(v + g·dt)` statt `e·v` ab. + Ein vollkommen elastischer Ball (e = 1) gewönne dann mit jedem Aufprall Höhe – Energie + aus dem Nichts. So springt er dagegen immer wieder genau auf seine Fallhöhe; ein Test + prüft das über mehrere Sprünge. +- **Gehaltene Körper prallen nicht ab:** Die Hand fängt den Stoß ab. Andernfalls würde ein + gegen ein Hindernis gedrückter Körper in jedem Schritt zurückprallen, sofort wieder + herangezogen werden und dabei sichtbar zittern. + +## 8. Reibung und Rollen + +**Coulomb-Reibung:** Entlang der Tangente `t = (−n.y, n.x)` soll die Gleitgeschwindigkeit +verschwinden. Der Reibungsimpuls ist aber begrenzt: + +```text +|P_t| ≤ μ · P_n +``` + +Reicht die Grenze, haften die Körper (Haftreibung). Sonst rutschen sie mit konstanter +Verzögerung `μ·g` (Gleitreibung): Eine Box mit 300 px/s kommt nach `v²/(2μg)` zum Stehen. +Gemischt wird mit dem geometrischen Mittel `μ = √(μ_A·μ_B)` – ein glatter Partner macht +den Kontakt glatt. + +**Rollen:** Der Reibungsimpuls greift am Kontaktpunkt an, nicht im Schwerpunkt. Bei Kreisen +erzeugt er daher ein Drehmoment `r × P_t`, die effektive Masse enthält den Term +`(r × t)²/I`. Eine rutschende Scheibe wird gebremst und zugleich in Drehung versetzt, bis +die Rollbedingung `v = ω·r` gilt. Drehimpulserhaltung um den Kontaktpunkt liefert die +Endgeschwindigkeit – unabhängig vom Reibwert: + +```text +v_end = v₀ / (1 + I/(m·r²)) = v₀ / 1,5 = ⅔ · v₀ +``` + +Auch das prüft ein Test. Damit man die Drehung sieht, zeichnet der Renderer in jeden Kreis +einen Radius-Strich. + +## 9. Rollwiderstand + +Reines Rollen verbraucht keine Reibungsenergie – ein rollender Kreis würde ewig rollen. Ein +Rechteck auf einem kleinen Kreis führe darauf wie auf einer Rolle davon. Real verformen sich +Kreis und Unterlage ein wenig, was ein bremsendes Moment erzeugt: + +```text +M ≤ μ_r · N · r (μ_r = rolling_resistance = 0,1) +``` + +Pro Schritt entspricht das einem Drehimpuls `≤ μ_r · P_n · r`. Umgesetzt ist das wie die +Reibung: als begrenzter Impuls, hier aber auf die relative Winkelgeschwindigkeit +`ω_B − ω_A`, mit der effektiven Masse `1/(1/I_A + 1/I_B)`. Weil die Grenze mit der +Normalkraft wächst, kommen schwer belastete "Rollen" schnell zur Ruhe, während ein frei +rollender Ball weit rollt. + +Für einen rollenden Ball ergibt sich eine konstante Verzögerung: + +```text +a = μ_r·g / (1 + I/(m·r²)) = μ_r·g / 1,5 ≈ 65 px/s² +``` + +Ein Ball mit 200 px/s rollt so etwa 300 px weit. Der Test misst genau diese Verzögerung. + +## 10. Positionskorrektur + +Impulse korrigieren nur Geschwindigkeiten. Durch den diskreten Zeitschritt überlappen +Körper trotzdem ein wenig: Ein fallender Körper "landet" meist schon etwas in der +Unterlage. Ohne Korrektur würden Stapel langsam ineinander einsinken. + +Die Korrektur verschiebt die **Positionen** direkt ("Projektion"), im Verhältnis der +inversen Massen: + +```text +C = factor · max(Tiefe − slop, 0) (höchstens max_position_correction) +p_A −= n · C · (1/m_A) / (1/m_A + 1/m_B) +p_B += n · C · (1/m_B) / (1/m_A + 1/m_B) +``` + +- **Warum Positionen statt Zusatzgeschwindigkeit (Baumgarte)?** Eine Geschwindigkeit, die + Körper auseinandertreibt, bleibt nach der Trennung erhalten und schleudert sie davon – + besonders bei großen Überlappungen, etwa wenn ein Objekt mitten in einen Haufen gesetzt + wird. Die Projektion erzeugt keine Bewegungsenergie. +- **Slop (0,5 px):** Eine kleine Resttiefe bleibt stehen. So wird der Kontakt im nächsten + Schritt sicher wieder erkannt, statt ständig zwischen "Kontakt" und "kein Kontakt" zu + wechseln – auch das wäre sichtbares Zittern. +- **Aktuelle Tiefe ohne erneute Kollisionsprüfung:** Tiefe bei der Erkennung minus die + seither erfolgte Relativverschiebung entlang n. Das ist exakt für Wände und Rechtecke und + eine sehr gute Näherung für Kreise. +- **Notbremse:** Kein Körper darf mehr als `max_wall_penetration` (12 px) in eine Wand + ragen. Der Wert liegt bewusst über der größten Eindringtiefe, die ein frei fliegender + Körper in einem Schritt erreichen kann (`MAX_SPEED · dt ≈ 10,8 px`). Normale Aufpralle + löst deshalb immer der Solver; die Bremse greift nur in Extremfällen, etwa wenn ein + gezogenes Objekt einen Körper gegen die Wand quetscht. Würde sie schon bei normalen + Aufprallen greifen, verschöbe sie Positionen und fügte dabei Lageenergie hinzu. + +## 11. Ruhe ohne Zittern: Schlafmodus + +Ein Solver mit endlich vielen Iterationen hinterlässt immer winzige Restbewegungen. Ein +Stapel "atmet" dadurch sichtbar oder kriecht. Deshalb werden ruhende Körper eingefroren: + +1. Jeder Körper zählt, wie lange er ununterbrochen fast stillsteht + (`|v| ≤ 4 px/s` und `|ω| ≤ 0,1 rad/s`). +2. Körper, die sich direkt oder über andere berühren, bilden eine **Insel** (Union-Find über + die Kontakte). Wände verbinden keine Inseln, sonst wäre alles am Boden eine einzige Insel. +3. Eine Insel schläft ein, wenn **alle** ihre Körper seit `time_to_sleep` (0,5 s) ruhen. + Jeder Körper merkt sich dabei seine Insel (`Body.sleep_island`). +4. Schlafende Körper werden weder integriert noch aufgelöst – das spart auch Rechenzeit. +5. **Aufwecken – immer die ganze Insel:** bei Berührung durch einen wachen, beweglichen + Körper, beim Greifen, beim Umschalten der Schwerkraft und wenn ein Körper der Insel + gelöscht wird. + +Warum Inseln statt einzelner Körper? Sonst könnte ein schlafender Körper unter einem wachen +liegen. Der wache weckt ihn, beide ruhen, einer schläft früher ein, der andere weckt ihn +wieder – ein endloses Hin und Her. + +Warum wacht die ganze Insel auf? Zwei schlafende Körper werden nicht auf Kontakte +untereinander geprüft. Wird die unterste Kiste eines schlafenden Stapels geweckt und im +selben Schritt weggestoßen – oder im Pausenmodus weggezogen –, bemerkte sonst niemand, dass +die Kisten darüber ihre Stütze verloren haben. Sie schliefen in der Luft weiter. Weil alle +Körper, die einander berühren, gemeinsam eingeschlafen sind, genügt es, ihre Insel +gemeinsam zu wecken. + +Zusammen mit Schwellwert (Abschnitt 7), Slop (Abschnitt 10), Warm-Starting (Abschnitt 6) +und Rollwiderstand (Abschnitt 9) kommen Stapel und Haufen vollständig zur Ruhe. Ein Test +lässt 60 zufällige Körper fallen und verlangt, dass nach 8 s alle schlafen. + +**Grenze des Verfahrens:** Wie jeder Schlafmodus mit Geschwindigkeitsschwellen kann er +eine Lage einfrieren, die kein echtes Gleichgewicht ist. Ein Beispiel: Ein Kreis liegt +wenige Pixel außermittig auf einem anderen Kreis. Ist die Kontaktnormale steiler geneigt, +als der Rollwiderstand hält (tan α > μ_r), rollt er ab. Anfangs so langsam, dass er +0,5 s lang unter 4 px/s bleibt und einschläft. Er bleibt dann liegen, bis ihn etwas +berührt, und rollt erst danach weiter. Die Auswirkung beschränkt sich auf wenige Pixel. +Strengere Schwellen würden solche Fälle seltener machen, ließen dafür aber auch ruhende +Haufen später einschlafen. + +## 12. Tunneln verhindern + +Die Kollisionserkennung ist diskret: Sie sieht nur die Positionen am Ende jedes Schritts. + +- **Wände** sind Halbebenen und damit ohnehin tunnelsicher (Abschnitt 5). +- **Körper untereinander:** Fliegen zwei der kleinsten Körper (Radius `MIN_BODY_SIZE` = + 12 px) frontal aufeinander zu, darf ihre Relativbewegung pro Schritt die Summe ihrer + Radien nicht übersteigen. Sonst springen die Mittelpunkte aneinander vorbei: + + ```text + 2·v·dt < 2·MIN_BODY_SIZE ⇒ v < MIN_BODY_SIZE / dt = 1440 px/s + ``` + + Mit Sicherheitsfaktor 0,9 ergibt das `MAX_SPEED ≈ 1296 px/s`. Jede Geschwindigkeit wird + nach dem Lösen der Impulse darauf begrenzt, ebenso die Wurfgeschwindigkeit. Aus demselben + Grund ist bei Rechtecken keine *halbe* Kantenlänge kleiner als `MIN_BODY_SIZE`, jede + Seite ist also mindestens 24 px lang. +- Auch die Winkelgeschwindigkeit ist begrenzt (`MAX_SPEED / MIN_BODY_SIZE`). + +## 13. Ziehen und Werfen + +Ein gezogener Körper wird **nicht** an die Mausposition teleportiert. Dann könnte er durch +andere Körper und Wände hindurchgeschoben werden. Stattdessen setzt `DragController` vor +jedem Physikschritt die Geschwindigkeit in Richtung Ziel: + +```text +v = (Ziel − Position) · rate (rate = 60/s; rate·dt ≤ 1 verhindert Überschwingen) +``` + +Danach löst der Solver die Kontakte ganz normal. Das gezogene Objekt **kollidiert also +weiterhin**: Leichtere Körper werden weggeschoben, Wände und schwere Stapel bremsen es. Das +Ziel wird so geklemmt, dass der Körper ganz in die Welt passt. Gehaltene Körper unterliegen +keiner Schwerkraft ("die Hand trägt sie"), prallen nicht ab ("die Hand fängt den Stoß ab", +siehe [Abschnitt 7](#7-restitution-elastizität)) und schlafen nie ein. Beim Greifen wird +die Schlaf-Insel des Körpers geweckt. + +Folge der fehlenden Restitution: Schlägt man mit einem gehaltenen Objekt einen Ball, stößt +es ihn wie ein unelastischer Schläger. Der Ball fliegt deshalb etwas langsamer davon als +bei einem freien, elastischen Stoß (in einer Messung des Reviews um etwa 13 %). Dafür +steht ein gegen ein Hindernis gedrücktes Objekt ruhig. + +**Werfen:** Beim Loslassen erhält der Körper die Mausgeschwindigkeit der letzten Frames. +`VelocityTracker` glättet sie in zwei Stufen: + +1. **Zeitfenster:** mittlere Geschwindigkeit über die letzten 0,1 s (Weg / Zeit zwischen + ältester und neuester Probe) – unabhängig von der Bildrate. +2. **Exponentielle Glättung:** `v ← v + (v_roh − v)·(1 − e^(−Δt/τ))` mit τ = 0,05 s. Der + Exponentialterm macht die Glättung ebenfalls bildratenunabhängig. + +Hält man die Maus vor dem Loslassen still, klingt die Geschwindigkeit schnell ab, und das +Objekt fällt einfach herunter. Im Pausenmodus wird direkt verschoben und nicht geworfen. + +## 14. Parameterübersicht + +Alle Werte stehen in [`config.py`](../src/sandbox/config.py) und lassen sich dort anpassen. + +| Parameter | Wert | Bedeutung | +|---|---|---| +| `gravity` | (0, 980) px/s² | Schwerkraft | +| `time_step` | 1/120 s | fester Zeitschritt | +| `velocity_iterations` | 8 | Solver-Durchläufe pro Schritt | +| `position_iterations` | 2 | Durchläufe der Positionskorrektur | +| `restitution_threshold` | 50 px/s | darunter kein Abprallen | +| `penetration_slop` | 0,5 px | geduldete Resttiefe | +| `position_correction_factor` | 0,4 | Anteil der Korrektur pro Durchlauf | +| `max_position_correction` | 6 px | Obergrenze der Korrektur pro Durchlauf | +| `max_speed` | ≈ 1296 px/s | Tunnel-Schutz | +| `rolling_resistance` | 0,1 | Rollwiderstandsbeiwert μ_r | +| `sleep_linear_speed` / `sleep_angular_speed` | 4 px/s / 0,1 rad/s | Ruheschwellen | +| `time_to_sleep` | 0,5 s | Ruhezeit bis zum Einschlafen | +| `wall_friction` / `wall_restitution` | 0,6 / 0 | Wandmaterial | +| `max_wall_penetration` | 12 px | Notbremse an den Wänden (> `MAX_SPEED · dt`) | diff --git a/docs/PHYSIK_EINSTIEG.md b/docs/PHYSIK_EINSTIEG.md new file mode 100644 index 0000000..f0feb0e --- /dev/null +++ b/docs/PHYSIK_EINSTIEG.md @@ -0,0 +1,466 @@ +# Physik für Einsteiger – wie die Sandbox funktioniert + +Dieses Dokument erklärt die Physik der Sandbox Schritt für Schritt und ohne Vorwissen. +Jeder Abschnitt sagt, **was** physikalisch passiert, **warum** das Programm es so macht und +**wo** es im Code steht. Die kompakte Formelsammlung für Fortgeschrittene ist +[PHYSICS.md](PHYSICS.md). + +## Inhalt + +1. [Die Grundidee: Die Welt als Daumenkino](#1-die-grundidee-die-welt-als-daumenkino) +2. [Vektoren: Pfeile mit Richtung und Länge](#2-vektoren-pfeile-mit-richtung-und-länge) +3. [Position, Geschwindigkeit, Beschleunigung](#3-position-geschwindigkeit-beschleunigung) +4. [Masse und Trägheit](#4-masse-und-trägheit) +5. [Der Simulationsschritt: Euler-Verfahren](#5-der-simulationsschritt-euler-verfahren) +6. [Warum ein fester Zeitschritt?](#6-warum-ein-fester-zeitschritt) +7. [Kollisionen erkennen](#7-kollisionen-erkennen) +8. [Kollisionen auflösen: Impulse](#8-kollisionen-auflösen-impulse) +9. [Elastizität: Wie stark etwas abprallt](#9-elastizität-wie-stark-etwas-abprallt) +10. [Reibung](#10-reibung) +11. [Drehung und Rollen](#11-drehung-und-rollen) +12. [Viele Kontakte gleichzeitig](#12-viele-kontakte-gleichzeitig) +13. [Wenn Körper ineinanderstecken: Positionskorrektur](#13-wenn-körper-ineinanderstecken-positionskorrektur) +14. [Ruhe: Warum nichts zittert](#14-ruhe-warum-nichts-zittert) +15. [Tunneln: Warum nichts durch Wände fliegt](#15-tunneln-warum-nichts-durch-wände-fliegt) +16. [Ziehen und Werfen mit der Maus](#16-ziehen-und-werfen-mit-der-maus) +17. [Alles zusammen: ein kompletter Schritt](#17-alles-zusammen-ein-kompletter-schritt) +18. [Zum Ausprobieren](#18-zum-ausprobieren) + +--- + +## 1. Die Grundidee: Die Welt als Daumenkino + +Ein Computer kann Bewegung nicht "fließend" berechnen. Er macht es wie ein Daumenkino: Er +berechnet viele Einzelbilder dicht hintereinander. Die Sandbox berechnet die Welt +**120-mal pro Sekunde**. Zwischen zwei Bildern liegt also die Zeit + +```text +dt = 1/120 s ≈ 0,0083 s ("dt" = Delta t = kleines Zeitstück) +``` + +In jedem dieser Schritte fragt das Programm für jeden Körper: + +1. Welche Kräfte wirken? (Schwerkraft) +2. Wie ändert sich dadurch die Geschwindigkeit? +3. Wie ändert sich dadurch die Position? +4. Stößt der Körper irgendwo an? Wenn ja: Wie reagieren beide Körper? + +Mehr ist Physik-Simulation im Kern nicht. Der Rest dieses Dokuments erklärt die einzelnen +Punkte genauer. + +## 2. Vektoren: Pfeile mit Richtung und Länge + +Viele Größen in der Physik haben nicht nur einen Betrag, sondern auch eine **Richtung**. +"Der Ball fliegt 5 m/s schnell" reicht nicht – man muss wissen, *wohin*. Deshalb rechnet man +mit **Vektoren**: Pfeilen mit x- und y-Anteil. + +```text +v = (3, 4) bedeutet: 3 nach rechts, 4 nach unten (y zeigt wie am Bildschirm nach unten) +``` + +Die wichtigsten Operationen (alle in [`physics/vector.py`](../src/sandbox/physics/vector.py)): + +| Operation | Rechnung | Bedeutung | +|---|---|---| +| Addition `a + b` | (a.x+b.x, a.y+b.y) | Pfeile aneinanderhängen: "erst 3 nach rechts, dann 2 nach oben" | +| Skalierung `a * 2` | (2·a.x, 2·a.y) | Pfeil doppelt so lang, gleiche Richtung | +| Länge `a.length()` | √(x² + y²) | Satz des Pythagoras: wie lang ist der Pfeil? (3,4) hat Länge 5 | +| Normalisieren `a.normalized()` | a / Länge | Pfeil auf Länge 1 bringen – übrig bleibt nur die *Richtung* | +| Skalarprodukt `a.dot(b)` | a.x·b.x + a.y·b.y | "Wie viel von a zeigt in Richtung b?" | +| Kreuzprodukt `a.cross(b)` | a.x·b.y − a.y·b.x | "Wie stark dreht a um b herum?" (für Drehungen) | + +**Das Skalarprodukt ist der wichtigste Trick der ganzen Engine.** Ist `n` ein Pfeil der +Länge 1 (z. B. "senkrecht vom Boden weg"), dann gibt `v.dot(n)` an, wie schnell sich etwas +*in Richtung n* bewegt. Ein Ball mit `v = (5, 8)` (schräg nach unten) und der +Boden-Richtung `n = (0, 1)` (nach unten) liefert `v·n = 8`: Er fällt mit 8 Einheiten pro +Sekunde auf den Boden zu. Die 5 nach rechts spielen für den Aufprall keine Rolle. + +## 3. Position, Geschwindigkeit, Beschleunigung + +Drei Größen hängen wie eine Kette zusammen: + +| Größe | Einheit hier | Was sie ändert | +|---|---|---| +| Position x | px | – | +| Geschwindigkeit v | px/s | die Position: "so viele Pixel pro Sekunde" | +| Beschleunigung a | px/s² | die Geschwindigkeit: "pro Sekunde wird man so viel schneller" | + +**Schwerkraft** ist eine Beschleunigung. Auf der Erde sind das 9,81 m/s². Die Sandbox rechnet +in Pixeln und nimmt an, dass 100 px einem Meter entsprechen, also: + +```text +g = (0, 980) px/s² → pro Sekunde wird ein fallender Körper 980 px/s schneller +``` + +Beispiel: Ein Ball, der aus der Ruhe fällt, ist nach 0,5 s 490 px/s schnell und hat +½·g·t² = ½ · 980 · 0,25 ≈ 122 px zurückgelegt. Genau das prüft der Test +`test_free_fall_matches_half_g_t_squared`. + +## 4. Masse und Trägheit + +**Masse** sagt, wie schwer es ist, einen Körper zu beschleunigen. Ein Stoß, der einen +Tischtennisball wegschießt, bewegt eine Bowlingkugel kaum. In der Sandbox gilt: + +```text +Masse = Fläche × Dichte +Kreis: Fläche = π · r² +Rechteck: Fläche = Breite · Höhe +``` + +Ein doppelt so großer Kreis (doppelter Radius) hat die vierfache Fläche und damit die +vierfache Masse. + +**Trägheitsmoment** ist dasselbe Prinzip für Drehungen: Wie schwer ist es, einen Körper in +Drehung zu versetzen? Für eine Scheibe gilt `I = ½ · m · r²`. Masse, die weit außen sitzt, +dreht sich schwerer an. Deshalb steht dort das r². + +Rechtecke drehen sich in dieser Sandbox **nie** (die Aufgabe verlangt achsenparallele +Rechtecke). Programmtechnisch bekommen sie ein unendliches Trägheitsmoment. Dann kann kein +noch so großer Stoß sie drehen. + +**Trick mit dem Kehrwert:** Der Code speichert zusätzlich `inv_mass = 1/Masse`. Wände +bekommen `inv_mass = 0`, das heißt "unendlich schwer". So braucht man für Wände keine +Sonderfälle: Die Rechnung "Geschwindigkeitsänderung = Stoß · inv_mass" ergibt bei einer Wand +automatisch 0 – sie bewegt sich nicht. + +## 5. Der Simulationsschritt: Euler-Verfahren + +Wie kommt man von "Beschleunigung" zu "neuer Position"? Mit dem einfachsten Verfahren der +Welt, benannt nach Leonhard Euler: + +```text +neue Geschwindigkeit = alte Geschwindigkeit + Beschleunigung · dt +neue Position = alte Position + neue Geschwindigkeit · dt +``` + +Beispiel mit dt = 1/120 s für einen fallenden Ball: + +| Schritt | Geschwindigkeit (px/s) | Position y (px) | +|---|---|---| +| 0 | 0 | 100,00 | +| 1 | 0 + 980/120 = 8,17 | 100 + 8,17/120 = 100,07 | +| 2 | 16,33 | 100,20 | +| 3 | 24,50 | 100,41 | + +**Wichtiges Detail:** In der zweiten Zeile steht die *neue* Geschwindigkeit. Das heißt +"semi-impliziter Euler". Die naheliegende Variante mit der *alten* Geschwindigkeit hat einen +fiesen Fehler: Sie erzeugt bei Schwingungen und Pendelbewegungen langsam immer mehr +Energie, bis die Simulation "explodiert". Die hier verwendete Variante kostet genauso wenig, +bleibt aber stabil. Im Code: `World._integrate_velocities` und `World._integrate_positions` +in [`physics/world.py`](../src/sandbox/physics/world.py). + +## 6. Warum ein fester Zeitschritt? + +Das Bild wird z. B. 60-mal pro Sekunde gezeichnet, aber nicht exakt regelmäßig. Mal dauert +ein Bild 15 ms, mal 18 ms, und wenn Windows gerade etwas anderes tut, auch einmal 200 ms. + +Würde die Physik diese schwankende Zeit als dt benutzen, gäbe es zwei Probleme: + +- Die Simulation liefe auf jedem Rechner etwas anders. +- Bei einem Ruckler von 200 ms würde ein Ball in *einem* Schritt 200 px weit springen und + dabei vielleicht durch einen anderen Körper hindurch. + +Deshalb gibt es einen **Akkumulator** ("Zeitsparschwein") in +[`physics/timestep.py`](../src/sandbox/physics/timestep.py): Die echte Zeit wird +gesammelt und in festen Portionen von 1/120 s an die Physik ausgezahlt. Bei 60 Bildern pro +Sekunde sind das meist genau 2 Physikschritte pro Bild. Bleibt ein Rest, wartet er auf das +nächste Bild. + +Beim Zeichnen wird zusätzlich zwischen dem vorletzten und dem letzten Physikzustand +**interpoliert** (Zwischenwert gebildet). Dadurch wirkt die Bewegung flüssig, auch wenn +Physik und Bildschirm nicht im selben Takt laufen. + +## 7. Kollisionen erkennen + +Nach jedem Schritt muss das Programm herausfinden, welche Körper sich überlappen. Für jede +Berührung erzeugt es einen **Kontakt** mit drei Angaben: + +- **Normale n**: ein Pfeil der Länge 1, der von Körper A zu Körper B zeigt. In diese + Richtung werden die Körper auseinandergedrückt. +- **Eindringtiefe**: wie weit sich die Körper überlappen. +- **Kontaktpunkt**: wo sie sich berühren. + +### Kreis gegen Kreis – der einfachste Fall + +Zwei Kreise berühren sich, wenn der Abstand ihrer Mittelpunkte kleiner ist als die Summe +der Radien: + +```text + A ●───────● B Abstand d = 30, Radien 20 + 20 = 40 + ╲___╱ ╲___╱ → Überlappung 40 − 30 = 10 px +Normale n = (B − A) / d → Pfeil von A nach B, Länge 1 +``` + +### Kreis gegen Rechteck + +Man sucht den Punkt des Rechtecks, der dem Kreismittelpunkt am nächsten liegt. Dafür +"klemmt" man die Kreismitte in das Rechteck: Liegt sie links vom Rechteck, nimmt man den +linken Rand, liegt sie darüber, den oberen Rand usw. Ist dieser nächste Punkt näher als der +Radius, berühren sich die beiden. + +### Rechteck gegen Rechteck + +Zwei achsenparallele Rechtecke überlappen genau dann, wenn sie sich **sowohl** in +x-Richtung **als auch** in y-Richtung überlappen. Getrennt werden sie entlang der Richtung +mit der kleineren Überlappung – das ist der kürzeste Weg nach draußen. + +### Wände + +Boden, Decke und Seiten sind "Halbebenen": Alles hinter der Wandlinie gilt als Wand. Der +Vorteil: Auch ein Körper, der in einem Schritt komplett hinter die Wand gesprungen ist, wird +noch erkannt und zurückgeschoben. + +### Schneller suchen: Broadphase + +Bei 150 Körpern gibt es über 11 000 mögliche Paare – zu viele, um jedes genau zu prüfen. +Deshalb sortiert [`physics/broadphase.py`](../src/sandbox/physics/broadphase.py) alle Körper +von links nach rechts. Zwei Körper, die in x-Richtung weit auseinanderliegen, können sich +nicht berühren und werden gar nicht erst genau geprüft ("Sweep-and-Prune"). + +## 8. Kollisionen auflösen: Impulse + +Jetzt der wichtigste Teil. Zwei Körper berühren sich und bewegen sich aufeinander zu. Was +passiert? + +### Impuls – "Wucht" + +Der **Impuls** eines Körpers ist Masse mal Geschwindigkeit: `p = m · v`. Ein schwerer, +langsamer Lkw und ein leichter, schneller Ball können gleich viel "Wucht" haben. + +Ein **Stoß** (ebenfalls Impuls genannt, Formelzeichen P) ist eine sehr kurze, starke Kraft, +etwa beim Aufprall. Er ändert die Geschwindigkeit sofort: + +```text +Geschwindigkeitsänderung = Stoß / Masse +``` + +### Actio = Reactio + +Newtons drittes Gesetz: Wenn A gegen B drückt, drückt B genauso stark gegen A zurück. Der +Stoß P wirkt also auf B in Richtung der Normalen und auf A genau entgegengesetzt: + +```text +v_A neu = v_A − P · n / m_A +v_B neu = v_B + P · n / m_B +``` + +Dadurch bleibt der Gesamtimpuls erhalten. Was A an Wucht verliert, gewinnt B. + +### Wie groß muss der Stoß sein? + +Man schaut, wie schnell sich die beiden Körper aufeinander zu bewegen – entlang der +Normalen, also mit dem Skalarprodukt aus Abschnitt 2: + +```text +v_n = (v_B − v_A) · n (negativ = sie nähern sich an) +``` + +Dann sucht man den Stoß P, der diese Annäherung genau stoppt (bzw. umkehrt, siehe nächster +Abschnitt). Weil der Stoß beide Körper beeinflusst, hängt die nötige Stärke von beiden +Massen ab: + +```text +P = −(1 + e) · v_n / (1/m_A + 1/m_B) +``` + +Den Nenner `1/(1/m_A + 1/m_B)` nennt man **effektive Masse**. Bei einer Wand +(`1/m = 0`) ist sie einfach die Masse des Körpers. Bei zwei gleich schweren Körpern ist sie +halb so groß, weil der andere Körper mit ausweicht. + +**Rechenbeispiel:** Eine Kugel (m = 1) fliegt mit 10 px/s senkrecht auf den Boden, der +völlig unelastisch ist (e = 0). Dann ist v_n = −10, die effektive Masse ist 1 (Wand), und +der Stoß ist P = −(1 + 0) · (−10) / 1 = 10. Die neue Geschwindigkeit ist 10 − 10 = 0: Die +Kugel bleibt liegen. + +Im Code: [`physics/solver.py`](../src/sandbox/physics/solver.py), die ausführliche +Herleitung steht am Dateianfang. + +## 9. Elastizität: Wie stark etwas abprallt + +Die **Stoßzahl e** (in der Sidebar "Elastizität") sagt, wie viel der Geschwindigkeit nach +dem Aufprall übrig bleibt: + +| e | Verhalten | Beispiel | +|---|---|---| +| 0 | bleibt kleben, prallt nicht ab | Knetmasse | +| 0,5 | prallt mit halber Geschwindigkeit ab | Holzklotz | +| 0,8 | springt kräftig zurück | Gummiball | +| 1 | prallt ohne Verlust ab | ideale Superkugel | + +Daraus folgt eine schöne Regel: Ein Ball springt nach dem Aufprall auf **e²** seiner +Fallhöhe. Mit e = 0,8 sind das 0,64, also 64 % der Höhe. (Die Energie hängt vom Quadrat der +Geschwindigkeit ab, deshalb das Quadrat.) + +Zwei praktische Sonderregeln: + +- **Unter 50 px/s kein Abprallen.** Ein Körper, der auf dem Boden liegt, "fällt" wegen der + Schwerkraft in jedem Schritt minimal in den Boden. Würde er davon jedes Mal ein winziges + bisschen abprallen, würde er ständig zittern. +- **Gehaltene Objekte prallen nicht ab.** Die "Hand" fängt den Stoß ab – sonst würde ein + gegen eine Wand gedrücktes Objekt dauernd zurückfedern. + +## 10. Reibung + +Reibung bremst, wenn zwei Flächen aneinander entlangrutschen. Sie wirkt **quer zur +Normalen**, also entlang der Berührfläche (dieser Richtung sagt man "Tangente"). + +Das Coulomb-Gesetz: Die Reibung kann höchstens so stark sein wie der Andruck mal dem +**Reibungskoeffizienten μ**: + +```text +Reibung ≤ μ · Andruck +``` + +- Ist wenig Schwung da, reicht die Reibung, um die Bewegung ganz zu stoppen: **Haftreibung**. + Eine Kiste bleibt auf dem Boden stehen. +- Ist mehr Schwung da, rutscht die Kiste trotz Reibung weiter und wird gleichmäßig langsamer: + **Gleitreibung**. Sie bremst mit `μ · g`. Eine Kiste mit 300 px/s und μ ≈ 0,5 rutscht + etwa 90 px weit. + +Der Test `test_sliding_box_decelerates_with_coulomb_friction` prüft genau diesen Bremsweg. + +## 11. Drehung und Rollen + +Kreise können sich drehen. Die **Winkelgeschwindigkeit ω** (Omega) sagt, wie schnell, in +Radiant pro Sekunde (eine volle Umdrehung sind 2π ≈ 6,28 rad). + +Warum dreht sich ein Kreis überhaupt? Die Reibung greift nicht in der Mitte an, sondern +unten am Kontaktpunkt. Ein Stoß, der nicht durch die Mitte geht, erzeugt ein +**Drehmoment**, ähnlich wie ein Schraubenschlüssel. Genau dafür braucht man das +Kreuzprodukt aus Abschnitt 2: `Hebelarm × Kraft` ergibt, wie stark gedreht wird. + +**Rollen** heißt: Der Kontaktpunkt steht still, der Ball dreht sich genau passend zu seiner +Geschwindigkeit: + +```text +v = ω · r +``` + +Ein Experiment, das die Sandbox exakt nachrechnet: Stößt man eine Scheibe an, sodass sie +ohne Drehung über den Boden rutscht, bremst die Reibung sie und bringt sie gleichzeitig zum +Drehen. Irgendwann rollt sie – und dann hat sie **genau ⅔ ihrer Startgeschwindigkeit**, +unabhängig davon, wie stark die Reibung ist. Den kleinen Strich in jedem Kreis zeichnet das +Programm, damit man die Drehung sehen kann. + +**Rollwiderstand:** Ein perfekt rollender Ball würde nie anhalten, denn reines Rollen +verbraucht keine Reibungsenergie. In der Realität verformen sich Ball und Boden ein wenig, +das bremst. Die Sandbox bildet das mit einem kleinen Gegen-Drehmoment nach. Ohne diese +Bremse würde ein Rechteck, das auf einem Kreis liegt, darauf wie auf einer Rolle +davonfahren. + +## 12. Viele Kontakte gleichzeitig + +Liegt eine Kiste in einem Stapel, hat sie Kontakte nach oben und nach unten. Jeder Stoß an +einem Kontakt verändert die Situation an den anderen Kontakten. Man kann sie nicht einzeln +nacheinander "fertig" rechnen. + +Die Lösung heißt **Sequential Impulses** und funktioniert wie beim Aufräumen eines Zimmers +in mehreren Runden: Man geht alle Kontakte durch und korrigiert jeden ein bisschen. Dann +geht man sie noch einmal durch, weil die Korrekturen sich gegenseitig beeinflusst haben. +Nach **8 Runden** pro Schritt ist das Ergebnis sehr genau. + +Zwei Tricks machen das Verfahren stabil: + +- **Kontakte können nur drücken, nicht ziehen.** Die Summe aller Stöße an einem Kontakt darf + nie negativ werden, sonst würden Körper aneinanderkleben. +- **Warm-Starting:** Ein ruhender Stapel sieht im nächsten Schritt fast genauso aus wie + jetzt. Deshalb startet man mit den Stößen des letzten Schritts statt bei null und ist + sofort fast fertig. + +## 13. Wenn Körper ineinanderstecken: Positionskorrektur + +Weil nur in festen Schritten gerechnet wird, landet ein fallender Ball meist schon ein +kleines Stück *im* Boden, bevor der Aufprall erkannt wird. Die Stöße korrigieren nur die +Geschwindigkeit, nicht die Überlappung. Ohne Gegenmaßnahme würden Stapel langsam +ineinandersinken. + +Deshalb schiebt der Solver die Körper nach jedem Schritt ein Stück auseinander – direkt über +ihre Position, **ohne** ihnen Geschwindigkeit zu geben. Würde man sie stattdessen +"wegschubsen", würden stark überlappende Körper (zum Beispiel, wenn man ein Objekt mitten +in einen Haufen setzt) davonfliegen. + +Eine winzige Überlappung von 0,5 px (der "Slop") lässt man absichtlich stehen. So bleibt der +Kontakt im nächsten Schritt sicher bestehen, statt ständig zwischen "berührt" und "berührt +nicht" zu wechseln – auch das wäre sichtbares Zittern. + +## 14. Ruhe: Warum nichts zittert + +Auch mit allen Tricks bleiben winzige Restbewegungen übrig. Die Lösung ist der +**Schlafmodus** ([`physics/sleep.py`](../src/sandbox/physics/sleep.py)): + +1. Jeder Körper zählt, wie lange er schon fast stillsteht (langsamer als 4 px/s). +2. Körper, die sich berühren, bilden eine Gruppe (eine "Insel"). +3. Hat **die ganze Insel** eine halbe Sekunde lang fast stillgestanden, wird sie + eingefroren: Sie bewegt sich nicht mehr und kostet keine Rechenzeit. +4. Stößt etwas gegen einen schlafenden Körper, wacht **die ganze Insel** wieder auf. + +Warum die ganze Insel? Weckt man nur die unterste Kiste eines Stapels und zieht sie weg, +würde die Kiste darüber sonst in der Luft weiterschlafen. Genau diesen Fehler hat das +unabhängige Review gefunden, und genau so wurde er behoben. + +## 15. Tunneln: Warum nichts durch Wände fliegt + +Weil nur in Schritten gerechnet wird, könnte ein sehr schneller, kleiner Körper in einem +Schritt komplett über einen anderen hinwegspringen, ohne dass je eine Überlappung auftritt. +Das nennt man **Tunneln**. + +Zwei Gegenmaßnahmen: + +- **Wände sind unendlich dick** (Halbebenen). Ein Körper kann nicht "hinter" sie gelangen, + ohne erkannt zu werden. +- **Geschwindigkeitsgrenze:** Die kleinsten Körper haben einen Radius von 12 px. Solange + sich zwei davon pro Schritt zusammen weniger als ihre Radien-Summe bewegen, können ihre + Mittelpunkte nicht aneinander vorbeispringen. Daraus ergibt sich eine Höchstgeschwindigkeit + von etwa 1300 px/s, auf die jeder Körper begrenzt wird. + +## 16. Ziehen und Werfen mit der Maus + +Würde man ein gezogenes Objekt einfach an die Mausposition "teleportieren", könnte man es +durch Wände und andere Objekte hindurchschieben. Stattdessen bekommt es in jedem +Physikschritt die **Geschwindigkeit**, die es zur Maus bringt: + +```text +v = (Mausziel − Position) · 60 +``` + +Danach behandelt die Physik es wie jeden anderen Körper. Es schiebt leichte Objekte weg und +wird von Wänden gestoppt. + +**Werfen:** Beim Loslassen bekommt das Objekt die Geschwindigkeit der Maus. Die Maus +zittert aber und liefert unregelmäßige Werte. Deshalb wird geglättet +([`interaction/velocity_tracker.py`](../src/sandbox/interaction/velocity_tracker.py)): +erst ein Mittelwert über die letzten 0,1 Sekunden, dann noch eine weiche Glättung. Hält man +die Maus vor dem Loslassen still, sinkt die Geschwindigkeit auf fast null – das Objekt fällt +dann einfach herunter. + +## 17. Alles zusammen: ein kompletter Schritt + +So sieht ein einzelner Physikschritt (1/120 s) in `World.step` aus: + +1. Zustand merken (für das flüssige Zeichnen und das Abprallen). +2. Schwerkraft anwenden: `v = v + g · dt`. +3. Mögliche Kollisionspaare grob vorsortieren (Broadphase). +4. Kontakte genau bestimmen; schlafende Inseln wecken, die berührt werden. +5. Kontakte mit den Wänden bestimmen. +6. Stöße berechnen – 8 Runden lang, mit Abprallen, Reibung und Rollwiderstand. +7. Geschwindigkeit begrenzen (gegen Tunneln). +8. Bewegen: `x = x + v · dt`. +9. Restliche Überlappungen durch Verschieben abbauen. +10. Notbremse: Niemand steckt tiefer als 12 px in einer Wand. +11. Schlafmodus aktualisieren. + +## 18. Zum Ausprobieren + +Am besten versteht man Physik, indem man damit spielt: + +- Setze die **Elastizität auf 1** und lass einen Ball fallen: Er springt immer wieder genau + auf seine Starthöhe. Bei 0,5 wird jeder Sprung deutlich niedriger. +- Wirf einen kleinen Kreis mit **Dichte 0,2** gegen einen großen mit **Dichte 5**: Der + leichte prallt ab, der schwere bewegt sich kaum (Impulserhaltung). +- Baue einen Turm aus Rechtecken und ziehe die unterste Kiste weg: Der Rest fällt. +- Schalte mit **G** die Schwerkraft aus und wirf Objekte: Sie fliegen geradeaus, bis sie + etwas treffen (Newtons erstes Gesetz: Trägheit). +- Drücke **F1**: Gelbe Punkte zeigen die Kontaktpunkte, rote Striche die Normalen, graue + Umrandungen schlafende Körper. +- Ändere Werte in [`config.py`](../src/sandbox/config.py), zum Beispiel `gravity` oder + `rolling_resistance`, und beobachte die Wirkung. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..0a22a56 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,124 @@ +# Tests + +## Tests ausführen + +Voraussetzung ist die Entwicklungsumgebung aus dem [README](../README.md): + +```bash +pip install -r requirements-dev.txt +``` + +Alle Tests ausführen (Dauer etwa 15 s): + +```bash +pytest +``` + +Mit Testabdeckung und Liste der nicht abgedeckten Zeilen: + +```bash +pytest --cov=sandbox --cov-report=term-missing +``` + +Nur die schnellen Tests (ohne Stresstest und Smoke-Test): + +```bash +pytest -m "not slow" +``` + +Lint und Formatierung prüfen: + +```bash +ruff check src tests +``` + +```bash +ruff format --check src tests +``` + +Ruff prüft neben Stil und typischen Fehlern auch, dass alle Module, Klassen und +öffentlichen Funktionen einen Docstring (`D`) und Type Hints (`ANN`) haben und dass in +Vergleichen keine Magic Numbers stehen (`PLR2004`). Die Konfiguration steht in +`pyproject.toml`. + +## Headless-Modus + +UI- und Integrationstests laufen **ohne sichtbares Fenster**. `tests/conftest.py` setzt vor +dem ersten Import von pygame: + +```python +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") +``` + +SDL rendert dann in einen unsichtbaren Puffer und braucht weder Bildschirm noch Soundkarte. +Die Tests laufen so auch in CI oder über SSH. + +Die UI-Tests bedienen die Anwendung wie ein Mensch: Sie legen echte pygame-Events +(`MOUSEBUTTONDOWN`, `MOUSEMOTION`, `KEYDOWN` …) in die Event-Queue und rufen dann +`App.frame()` auf. Die Hilfsfunktionen dafür stehen in `tests/app/driver.py`. So wird die +ganze Kette getestet: Event → Sidebar/InputHandler → Werkzeuge → Physik → Zeichnen. + +Die Anwendung selbst lässt sich ebenfalls headless starten, etwa für Screenshots. +PowerShell: + +```powershell +$env:SDL_VIDEODRIVER = "dummy"; python -m sandbox --demo --screenshot bild.png --seconds 2 +``` + +Bash: + +```bash +SDL_VIDEODRIVER=dummy python -m sandbox --demo --screenshot bild.png --seconds 2 +``` + +## Was wird getestet? + +Die Erwartungswerte der Physik-Tests stammen aus der klassischen Mechanik – +Impulserhaltung, Stoßgesetz, Coulomb-Reibung, Rollbedingung – und nicht aus der +Implementierung selbst. + +### Physik (`tests/physics/`, ohne pygame) + +| Datei | Inhalt | +|---|---| +| `test_vector.py` | alle Vektoroperationen, Nullvektor-Normalisierung ohne NaN, Unveränderlichkeit, Hash/Gleichheit, Kopieren/Pickle | +| `test_bodies.py` | Fläche und Trägheitsmoment, **Masse = Fläche × Dichte**, statische Körper, Materialprüfung, Impulse mit/ohne Drehung, Energie und Impuls | +| `test_collision.py` | jede Formpaarung mit Normalenrichtung, Tiefe und Kontaktpunkt; Randfälle (gleicher Mittelpunkt, Kreismittelpunkt im Rechteck, knapp verfehlte Ecke, Körper hinter der Wand); Zufallstest mit über 500 Kontakten: Verschieben um die Tiefe trennt die Körper | +| `test_broadphase.py` | Sweep-and-Prune liefert dieselben Paare wie der Brute-Force-Vergleich | +| `test_solver.py` | **elastischer Stoß:** Impuls- und Energieerhaltung, Lehrbuchformel; e = 0: gemeinsame Geschwindigkeit; **Energieverlust bei e < 1** exakt nach ΔE = ½·μ·(1 − e²)·v²; Restitutionsschwelle; schiefer Stoß ohne Reibung; Coulomb-Reibung (Bremsweg v²/2μg); Rutschen → Rollen mit ⅔·v₀; Rollwiderstand; Auflagekraft = m·g·dt; Impulsgrenzen; **Trennen überlappender Körper ohne Geschwindigkeit** | +| `test_world.py` | **freier Fall ≈ ½·g·t²** (und exakt nach semi-implizitem Euler); **Ball bleibt am Boden liegen und verlässt die Welt nie**; Sprunghöhe = e² · Fallhöhe (1 %); ein vollkommen elastischer Ball springt über viele Sprünge immer wieder genau auf seine Fallhöhe; Energie steigt ohne äußere Kräfte nie; Stapel bleibt stehen; gemischter Haufen schläft ein; kein Tunneln bei Höchstgeschwindigkeit; Notbremse; Schlafen/Aufwecken, auch wenn die Basis eines schlafenden Stapels weggezogen oder weggestoßen wird (nichts schläft in der Luft weiter); Determinismus; **100 Körper × 1000 Schritte ohne NaN** (Stresstest) | +| `test_timestep.py` | Akkumulator, keine verlorenen Schritte durch Rundung, Obergrenze gegen die "Spiral of Death" | +| `test_sleep.py` | Union-Find, Inselbildung (Wände verbinden nicht), Einschlafen und Aufwachen nur als ganze Insel | + +### Interaktion (`tests/interaction/`, ohne pygame) + +| Datei | Inhalt | +|---|---| +| `test_velocity_tracker.py` | exakte Messung konstanter Bewegung, Unabhängigkeit von der Bildrate, Abklingen beim Stillhalten, Glättung von Zittern | +| `test_drag_and_tools.py` | Seitenverhältnis mit gleicher Fläche, Mindestgröße, Körper erzeugen, Obergrenze, Vorschaufarbe = echte Farbe; Greifen mit Versatz, Ziel in der Welt, Folgen, Geschwindigkeitsgrenze, **gezogener Körper schiebt andere weg**, gegen ein Hindernis gedrückter Körper zittert nicht, Werfen, Pause | +| `test_demo.py` | Beispielszene passt in die Welt, der Turm bleibt stehen | + +### Anwendung (`tests/app/`, headless mit pygame) + +| Datei | Inhalt | +|---|---| +| `test_app_integration.py` | **Objekt erstellen** (Klick, Ziehen aus der Sidebar, Abbruch), **greifen**, ziehen, **werfen**, Stillhalten ohne Wurf, gezogener Körper schiebt andere, bleibt in der Welt; **löschen** (Rechtsklick, Entf, Alles löschen); Pause, Schwerkraft, Regler (Klick, Ziehen, Mausrad), Tastenkürzel, Fenster schließen, Debug-Ansicht, Screenshot, Kommandozeile | +| `test_widgets.py` | Regler-Abbildung und Rasterung, deaktivierte Regler, Umschalter, Farbwahl, Zeichnen aller Widgets, Viewport, Winkel-Interpolation | +| `test_entry_points_and_edge_cases.py` | `python -m sandbox`, CLI ohne Optionen, Ende der Hauptschleife, Randfälle der Eingabe, Schrift-Fallback | +| `test_smoke.py` | **Smoke-Test:** Die echte Hauptschleife läuft 3 s headless, während ein zufällig klickender "Affe" Klicks, Ziehen, Rechtsklicks, Mausrad und Tasten sendet – ohne Exception, danach ein gültiger Zustand | + +### Architektur (`tests/test_architecture.py`) + +- Die Physik-Module und `config.py` importieren nur erlaubte Module (statische Prüfung über + den Syntaxbaum). +- `import sandbox.physics` lädt in einem frischen Interpreter kein pygame. + +## Testabdeckung + +Stand der letzten Messung: **219 Tests, 100 % Zeilen- und Branch-Abdeckung** über das ganze +Paket `sandbox` (inklusive UI), davon 100 % für `sandbox.physics`. + +Die hohe Abdeckung ist kein Selbstzweck. Entscheidend sind die physikalischen +Erwartungswerte: Ein Test, der nur Code ausführt, ohne das Ergebnis gegen die Physik zu +prüfen, wäre wertlos. diff --git a/docs/images/debug.png b/docs/images/debug.png new file mode 100644 index 0000000000000000000000000000000000000000..86d959c74c10ddfaae998412df0f8f028df874b8 GIT binary patch literal 68031 zcmd43cU%;~wk`@PU;q(85D?H&KtMnR$qWXPM9Dcwh9OAK42nt;P)U+i81j&Vgdqr$ zgXGMp#GiZrXojmp6NUZ2?^Cx zd1-YL5;Aaj>gU;0;MYSo1yT}{>m*O5A8C3euTFWoX=;r${FaPv&)_U0dHneO^auKf zFP~)uK|TkTF_6hr%gbrLj~u=9EG#?ZY=4Hr#wCTv=PsS){c`5XW1qCHE?n!6dzl|7 zxn3Vic4CkM3#q<)b+p5k(~|p>u3CIDiswl__@p8)h!IU7@s`*4d5)y_Jr4tFM=Bc? zt0}ens`HlK**`9!~;{B+kwDt5)%5C|Nl7= zD9WLftof6RXqV^{Qp!&orAq=aZ?*GM9>7!*?psRFMypB<*p=Uui{v)KzNuCDHP!f} zB|y+|woMJ3%QdVD<4V|{W*e_?sG(H#u5pqYX!8^DoIWC_VzwF{t0x4Wzrsx?Z48<( zqDy?GLUMa|&<@ia8m)uG-V+hgm9|nMC`r@vVujcqi;IZ(;krk-xVSP?u^47S`@!3r zdk*DdL1~p8F)}DBuhtS!$w^`Qa5gcoTrl(U?R^5fs9I6SDFsr&Oa?pw5BT`BU zq*{8b{X_+(C1M4lUSL>djPl{+;LtZPFhTjaFZ8fL?mHg6UR<={o5k^)a6njDStWgTm4=s+ucgHC6K>qObEjLcxVSi+o|hGR6eRcsi9~AX z)I z6;3-_TWWbjj2v8$$jC_E+SODhc4ZT5BN>$?0eFPv&-x9dDCAxwL3jE zbx`O+ZyGHP47SqvWIE}=&Q3gc`wjVam>Z>g9a&=u|j znGukFlrJ@ZVO-q;-|pIk)B2<+dC(X650EEsYb#9~{b-*0$Awtv>dwbRM{8JEWP}_X zB>OKR;xDp}8Vq+MYlG!NFBd$2{zW-aP||HPFeF(D9xzgn0_m@N5d_z<*)$ zt9tR=-u|6!ZljRhOsTxmB?x9`AG~B_#Rm!(rAN}Bq6g+YKPLto9hf_FJu5I)q zc*yR8$J>^lhMqR84>(=lIda5jVt^S~mY~{wxA5KoTRWv%6&D(vd0Vn~VCaf$PfyPS z*bvlZwrIOJAc(v*l2t3$ghsDEz$iG9Mdp^sL0wKH%6FkBmPaRMZx{&|vK=o26J>gO znmp){H00(@e<=!}6u4JU`a;MJHGt_=+;ra^`eR^5sG zM%B#&0~*o_@qGS-V}Ba&&Fi|1^PXE@gM(WY`GlWapDBg>HIZa?SfK-o(%usqrdT+yKI& zj#UzUWRF#%Id`!Ro%6&1Wt_${P+>PTKd}*)u{2q1K9O;5#>Fy#&bY6y&(~vvRmiXT z;}3MGJeXci=xR%`#801WU@#uYjT<*Id)@~VDm2v91(}rFKeEGy^3_VMlo0|P^Uf&= z;JI&mew4{@nd1&3r_!$$T$36cEYo)=QOPR1dGls+a$Wge3xbBX(4-Z$DwFrDE;|qUYJ32aME?4?b3^z9J%ghGtQ!JRB zm7u6_m=Lr1DV|c}HdbTZ+d2Rh^_i6iW{2#qPx&rHve=T3jghbX2@VtTc9p%bJrwWY3JyvZ>t-570^mgdU)JYbq-%?4&wv3fp z$1F3n{mj*pie#3$!F_+ZHI_%}8cxm6Fj+QzYiDO?zy71uY*yw#v1i(%|67DUAu!yLa?TM7l|&>cay97)qf$P2XLP zYpWPJGAExYI}OY$JJlU#Zm+YWjM0@&YzV?Ms)n{jDj-vmo!zqW6+F#MBw+PZ2=I{p zJ;{0rZ;r8f{IRmbLmTT;@X!zdOt306AY{0k_}F&Fb?bEjGf5;OChr6)Q*R_x^xv}` zZ5!CwE+3TS(Ya%=yEf91*lU~Sx2IhQg={zNE7lq8M%Z?VZ(KZlu_{6{_U(66FBGpU zJzhJ$x8hW%D5)whFP~;C`Q!;(m^>a!6<~}{3Q3l9_4!G_T7SxrO+VbLL3AfoUiyF@ z&QBHawW8N?!|rk+)JnCHAhsW1u@*ul7VRHP=ewNxGi;Z8(&a8jvF$%MG)&&^i!t^= z^J1;Ws1;*nvN#GW9QKDs!b{}&%XiU5A-gLRxbKIqIllT{<%YGh!GcYZtdjTLw_k^Z zoTaht&sf@yl?s*j^75+DG2muqQcVy;_D!mfFta1GCzCq=!mR~t@YqAYrCWvV(U*Ctj0 zGoOTt*~7ZSWHMaiN0f#(3?$K?BwbrjzWXYv5{@wIq;AR~JItiF$?c(yYV-)SIL?=j zUI<^>T6q9Q%3y)7&{Al7#fgDuXd`mXK-|;bbGPj|jqUXJmFQk83y%(dslw%n1zv=t z+3e!+>En3p;wDWx=w||j_N&=XQmq!D`JjAd5=yZy?FNc9w-d1cb*H8UP%4v_`!n{$H zOBL`*3cWQ!XpM^5D!+FAU*ivGxQwdZW?G^yRJ24SeXFVoZ)2CL=h5K{mya)qTIqFW zi`d$)FY#DQj9i;gN)kJCl#RM+^$>xz$9CGYnC#R-F3mp}&&SExx!76gMeqx zK8K8sjm2_n=`T%|*#`A{tzm>?WP9O`gaU*8gXZjPd4$^6*P>1qwxdOT%u~K0Z52p( z0JeQla3!Mj#h-&>j#P{%p?V?s@Lr(dcHRr^n*P-|0L9hzmD8I9+QSRI*#` zcgVn_m3s%`kAoW1LSggo4h}>H+Q4eorvF-LKQ=y8o)ID~C*v>ZQRd<{lZmwKtPX7( zM+Zpy&b!g~5y#*c<9-?0X1fMd+;hzzq323-jfBv-i3ucZONXA5^x4uh(nrkQCLzsB|204v#^lfx# z$hU9bGVnrt_=SnH{s)^|l!T*e&j6T2Joh=P_vPrt`!9+C0hz%OGPl^-=f=zP6eduH zqBJ=g`|NWwTpQ6js!A#Ofi3#7sFPmja zrVku7r7&yFUAH(mGLCKTO4Q-(Fuh;V+1YLN9&4*Vi=#uzVCX(g!2%qyP&b%~>VRu8;m6y0|tj;dk&% zdYVnlzIQrkZ|~w*REhtBqos18_xGlz5&W^&)4k3@iMm$t#gs+`OpW(I%lL!5;Q~GP z!I0-V11J)qq+K7>wwN|eab=~df17^v5;eWHwk>r+Tzv3b=->)wi=bHqFutv9wTRSl zRioy)@U{tm0*Y$7MisqlDM7T7j<5F_>8k&Ba}Y%%r2q4yCI%BfHEET!?K=MzZS05r z4K!>Z_weOu9Kf=nm#3x|Q_~;9*Tx8f4_>JE&xrRkt+YPK(&^MzFrTQ9ihZk(EHKI{ z7`vgAw3{e2tgV@^DO#$Lt5sO=REw2?U+t?8rgbF&k1q^qL%@v%CW<~-FVi|WI7kx4 zyt>N#rUpu%q61DVnJyptILu$LevqY9tY zg7@8zd5d(YU?qq8&hO551HYKc2>}+O>l{{o{lbYVtLF_>z|d;8X`+||Qlo)P6BQK| zg><>zO5o$;2HKS?oF;N(3WKH@{kEH(=Rz&P+*+6PvGNE@NtrT0VUH@T%D0^lLevyy z;?s1vU~O;{T$1`CNc`6pKQx^?7ZtBWeN{x>c6Ge!$yjq|U!U_5b!Ti(U;114&YBrb zg2dd+O}UFxHNGRO;oNkqEdA50z2h-l(bm1GHzE2+kN%9Oo$>te&0mj%dRgvUv~ACI z3WUQPkjeuIKDCUQs%dqWopG)UJ)!dDc0=P8{#u12;-aFw#XN64_>YQPFAKJ_0IcxhyyboozUF${SohK26Sz$L+0m&mS2OCcl;s z2Z-TaX~t0d#MQ;MpDZA-3Ez0fETtyBj;`NdD$7AA*57sAoADgICg%2Q^LG$Isix{t z&|AoMcV)OQYA9VfNeAsq2(r(N`BGkwAFomz0c%Rgw%-lcGxcPeMKj$>c>wlOK)}1B z6>tXiM@Iu0Tp9)y_Pmh&^=Z6gpKwPX%Ga|H=kHKB?H?9@+Znrh_!5u4SWUaz&N*OE z>8&Nb>w`}oOXIo8sgn40$WCS!;NFX?=t|S+WRi1Fb_#9|znLTap?FN{baNm6YCvM}=2Z zR&M;ePdZL-bYJc2*-v%*TB$tv0y{3T|g{K{dwETt3XtS2a1*ZS&y<)PHIc zbmDHXHPf4UTv&JXXCW;>F!oiFkYiCr#j)48HKfE5-?GXX&6uLHUr*}as`_bn-T>7Z zyE6*jl#r@ljT51g;62sXLIv+i5}t6&(vh|bI8G?+9rM0G-vO_0TAQfoNqumWgF{xj zR=;9-PnMeI95uB`$$!dPC;n5&es>FMb+0L&}F&abYna;PL8&3)%LhNF^fkAOdsl#uY*T};Ry5ET{U zH93BwtNTeGXkN2`9hU7D!B{^1DorAt2x34yy=M90<3 z9;?{*ku0UDUXZD~?zCJO13w=`wN~=NYyn^*3k`RB`nb}R)0{bPFSbN5%SB$JC$c;M zzKg-;7oI+S3b}9E@X*8rtr&kK|q|w$kw0CH1r{=W~SV_Od>qGph{(m_eZNSsypg%Vjr_04I>qrk5nv8rgm!qlj zSX)jJ_mplny$fKOP5;`w19oKngk%ZsXBDzPaDt889&)myViwxo+tZ$&zrx698+$nY z3({ZXndXn%%M92bLMR4wCrV8>9KB)a(9BI<>ahdE-QYSurhiHjc!t{%CIQDe4{1qD z*u}(rde^6bGk(UJ&a0K{w(Q?YOH1_UGPfXq&{zR3uBUlJiS(j8cK;jc!Ch!rpq00` zHPia))ad~VUae|PybqP#aDI-T=5T>^TW6l7nc1H=I|~|~Z-ax`Bwm<~m#NZTro9RI zu9~tPP?xqsBr%mzLr;xPPk+(fGE0|>LTY3cgj^H#TijhrMoN>N?+B%B4QJq=t~4P$ zs4uwae^6wR^YPFA!FwdqXxX|*KqFI$v5?+aF`n-pQ^flET(!p25TgLPwN2Ds5d z5K&ezJ~Ynm?o?D%-7n@-#4D1<*CsrlrIM;heg=%0@BVswBZ627crylU=}i*%ot_*c zOCd9@)5;6DCLHis(_%1+M<+mJFa6+~NQCUn!otD^oV`>dl1)m;b~{EOOEnETBe09V z0pR!L*pAi(`zbMZW%Ihj`IV?F|I2J?zc%1M1Oot-2hTT=0yZsX-!6}h-EWWn^5u)( z>Zz3J1_BkgdPYcTY3GNV!F(pwF&9RtfkAZSy~BJzAClfTXIL~;TlDC?`CkIq1@lR3 zM+O^kAz(7ko)DTO4UlIfCxwDLr~Xe)yze9u_JLV9VSXskN}Lk09Pws!U+PYc`%0Vw z!o|%^!VMrlJgzILwXbifsn}d9?Vcnq%cgvsK;^Trhcp1=ch5anGZ$cIcV~}^ew%A7 zEb;hFe~>f3O_`rdc#A|%ZPMJN4=8?k$Qsk*@4Ju1VrM&!M74nD1I$up#-hEwgRtW$ z7$x>BfsErPl*~}G_CFgj$Ab>hU3I!q+jD?SivRR!@z3h^kd6t*%5KMj@bsc_o!?YsMXr)y-yOktX$znq(tR zIia=l2&ExUklJZb_hllp|7Up_TmNXouWB=#QoLLKKFVWlVq*+taEz0r`}}C5R@}YA z0%h#9N0~#o1W5DDzs8L=1_gJ0I!;i@a`oijKmPdzF#7Z#Jl4iJS3cHbmlaE0!OLhE zNGBn82`0qyAQWP?&x6tYuT=b@J@%cRfYiv68|2UI?C$YT>N?%KaNrm19URtJu2ndp zy$}1}oTKGL6x>+*XLv9(H)r702MdrQ$NUhB1ssT=&1jMI*5w==#s{Sy6)JCIlbw=c zWR+s8lyLunpx|I*UtfElZQoa?>E)pb;2Z$}n+v-FC*U7SD~&lGnB%*XgkwwXMhZ}v zUS7rk=9w1NkjO|67Z?4y)nFZCTWzHNgP^{}#YGXP_2kvpJDVUiOt&skS(sfc^E;#l zUO@He_yJRdaiOXPK8{<exIsZ(?e1$1 zC^dyB1%wZ%Q<(4>US(#!N$~4kB8b_RXyqH@J19SEEAoKC@)gM=5=OdU^Yaw7Y%!&y$p6b#83@4a85zJE0zrTK( zr$hJ{6LSkfN=gdGjD{w??FViOimVkq zZ-N`taNN4pyxKfDIq8d8Mg`!jIX$@fjH)vxYwF+|(|Fu|(5qL{AVF>E8CH?L-&OIX zRRxYO=fUiMKGLc6awgUooN_tYB+hCx1u*dYczH?OGi-VrQHw%Rkq3o|Dk@ay7T(>lsDfB)S@G^V;;EJZsnA}UH% zK>_%;J8IuC!`(@c%a<>gSnkX-3RnT8J))Q9nxk6y&*0nLlWX3pV%ZUwA_X@F^|j#? zjt->N4^U)Lc8FEzxA2_>gj;cGsYc!qu%@GE7yV>;8D)jkH|DL?v@1s<9Ul7~(Kf|$ z8|4O|$REIlHO0)$jx`#?LK;mEOc!yC#yG%aN3lwZIUXH=;spqy6qz`t0fM`_x_at- zxYnV{varZhV?m?Gxj*H&_i^_UxZIcmhrP2)}-!*@M30&+tRQ#F0LG6hUjw(K<)p=i%(5<#=G+u zHlMF`Hi6TeGbwC{Kh_38xmDvVU zvvq7{?hkZshGX{k3FyzBLJs4fVq!Gwj=~!o8zGljSk_kmfEYnXOKa}rv;iQ)(!3}b zy6tFDvKK>4?vtHbqvh_X?f0n)NXLPKJXv%9o$7ibv9>x^^;BArX=TJ`ccRQuD{(;i zbiYQ9=9)N0#M>UOY2l@5abPKBLbKG5RJEFU`S{j-?vDMmR~3)noOi-3v(V6dDX%zQ zS{PcQfbVpVu&Ls1L7~3^r?uqyQUyd&9`jty0`*cW~}L;1B@#)E?`b5OGvb;a%il%ig# zeER;8831YgQiP)=>wzbaAA?#jNKS3@9b{&48}X5N8$)TYZ^JEChb5G(rT`#6gg&dH z<4s7X9kg?gnhp}m$E#H#(u+z;miuri7+9QQu5T4&+o$&!?&x(SwXWBTxG9J>nwh%xe1%nbX_y-1bfVn)r0 zdwm~3v9eE%riV{v!hPA%0jZTYbR6HC#g;?)Y`isUH6lEMhQ>e~ci(YN<=RwyfW)&= z%*qJi$N4MtUGVKr`!s)0=|l(Mlpk3WY1mIUTghISJ@#8-=*Zmg^x$G*V7N)}Z?2qy zIZ2-Py-LFx=VS4%63D=FzruP!UV`1c~heVv+W-V%b3cnxZ>$uLJ z8?*0z{3N~D2w^&G6~Miq>ZBHV z!PH-&BUW+2*tTz2^K{57#ll$8eMGGHoi^DTr%Jox>HxP;{^s#`YTz<8Y^UL>9M_+5yL9V7gY)y+0UVKl@ z*g;HGwB{gH^f-+a0=ewRzu5nSP`k7{+lO}$ulw-+5?tUq1^*r5$dFuz&C@kNT=2_? z6&-#{uf@u=_>85eJGxF-QpiWFjGV8hL(l4^6C>u^w=Vw$?&oiwHzl45l=)0T%+^SB z&-~kozv=twO{MIGF|kb!el&3uQ&s19hVSk?kZ*A1g7S$Gg2u%~X#b%a`b%OCrrUFM#}(FB}@{7KCgt&G432$ip-6@ zmRu4$Q=(;z_g&8k4mlZr#`^m#)TeE>10I{tir=XVWMSt9mDFMY!qg(GdYbXH_+3Y)3fVM z-g<&Q_8KH`P;Cgi4>qXEQgd(Yy<&`3Vw&dnEbYLYBfg?vp@L*74H|tm`4gUgQ@DfQ zi`i}VsoSmNT0SVKYN^k#U(IsV;wKRrXI^cK??rVQ^Z3sLh}n*=l!;nD(O(|FrYNfJ zGZUX;Hs`T7h3*&aya^A#wU6kk_w_-g@Hm84ea@S=a>n)kz4$jDs~&twN2TZ^I*?<; zTdu2K`6J$7j1-sn2Hzi!fMupuuo&0O!*g89nbc@#7W`Dbg=`&RFl#|3_iBp`+F%S9 z7f`xrR8EOkhRDBB(CXC5z9mcZ;k2(O-A|cWVPrYS4K6+Dvf*KTb)~C|+uf^_=YS5x z{(wg>i_Yo-8|jcXG1p-)abb}S)jIp7`zE`mYl`=S1G;I zoE|Yji{um(!cZv=4M=elUV;%Qa5@WJjZi4&hAi-rxoz6rf$*tBA{6NGOHnBb^?f#i zOc9VjrcRYhg-sm{7|+F<%*-o-H(bQNgoucXQu^c#cuF|{#5gxML`=*Z%-@ugKwV5d zYa$DsrgnW@Tt4Oqh;rQnAVxZj!IM{`1!~@Jk;$_^XXj-kf;K4}JIU6>sUi zZd-ai%+yVRN9$Ht!R!sS2Z$^7As=@>+2&MDrIwvmjY{FhWObw41>t*KeS+6F_quoL zvPwQR?yt6g{h=UeJA8k(5g)$LJMU0UrktgCXpVc?+e$U&dQi4~c0J+3agqm%ia?hQ zosj}Lv$XYSf@~y*QXjKhbqs?7J$2N(p7jTyLjc0LnxpE;4N=$pHdMm3*N)e)*4_>l z7G631mZ>67q`gL`jo@+aJl5v0c)6!gjlFB4Qx$KrLe3Caw96k??VetTp3Rw66T;CZ z4ht-}Jx=c{TFj!F8n4WLAN_*Z!KUDOXVN>6I%^ch#~<}pxqr7+S}rsssU~<^Ew9{} zS5FK>0)ruCcMjGo8*K)&beqkyIsN$_I6TupY6ri56D~i+6dM!ZD?XtxHzhs$T=#Ws ztX4z^KR14OI5FSzYMCG|S}Vn@^7i;f5*SKMQwXSsg*jr+N>_Tnbo zxcjPhu}gWl;38d6|XT;D=Wav>=n@0d96!@CztCxgb%MpU#5+aH4h{pV(jm1 z`CrUf#MA|lDzA)41PnWE@h83A^+vj`#HX9j?26Mm-+W;r=BGvV+|wny4#6j3c6A zU*wC`y<@`IVda~PhZ~%9ZuGse@o#(3APPjPfhoYp3qG1mFXHl+ax664@oJBzl+ZPs@V*)Lr$4Do|Afs_eVXA+r2_@AAYlFT(BUX;-H$=}Mt>+Hsv z9(T~jG%;ZHe7%&8C5mU?Gwxh4{SAFF{rNS~l)%~szMsUK{u@(1yKw23e0C*CJ%^Dn zpqQ>z|EWj3u#FakP_JC&qQC_uATm0pDEEBvUu#66Nz~4Ir>KY*|L24K+mrs=3DbGl zdrfXFP=0pm`)U~m{UHl8H#c`({Mm?E8MfMSgTdAo@3D!AiGkG3%QtQG+i&Wfeqtvi z*OR*L1ZMefj{zZJOBC*>jbK=aK5}n!v+adNlFmfk=Hmnt9m3H}uE{i`;KQZW5qQIx zfb~TdvFnght6p({f`I^R-jcY+NgjH9(jcktLoHwe1F7Ko0vI5MoTzlN7V#p~p%j`l z@nQ07V(!}bZ;x%$#1{;T&DhVIUa&^=)lTLs>)M16=5`?4j~VyHFcKi|EEB2--d~ef zQo6l(hB%w(d;r)<5%XlkPS+%`7;XO00Tr@IP|#{ZKD?gxb7)gdA${g(NWh0qpK>qT`DI`1`H^1@*)ETTSLcaf)N;d;i0gzJy?~lk* z(Wp+chH*c0P*e-HFNHRTuDJO~;OK18W zwSRa*5yc|T4e{{sa9!Z9_1$;0S$txdQCjL6mFn~c>UWinE_;^}Z+cMe4%?Wnx(VU0 z@3;Rc9#n5qEwaGy=JCAa&+eK>!kw{&vx|!vyBUja9n09Z5V?y4x6I)Jjs4&o)M4~I zj+U0OpFVjVZKld9qt?+>wQ%8Q(qi?~zGxGJj~_4ZY(a?cL1M+F_JZ5izC2nFs@K*G*nNBm~r= zmpWoQb9zy%mp0dHo+z=6;Ojh^*j6Zxoqqamz>9#4AU03a_GeMwE@P?E=~qD;3rAs_ z0N3zmKcBI3b}sht*b7+b&1AcDe0%lLrV+w_>u9NhrJBqTQ{&c`<%s7%D8!GU%*~JI ziJAZBOKr}ien67O&{=PT_1c+_*qVXTeb|6|)#hkvYR2?G%NxSR#0)OvYC>`eU6~^* zaG)n_@3+)PYD-^a6SEn=c7eXLeGFlT8E1x@utS7|KxcJ_3vY*PTbiZi6359=#txS3 zke8LGu=V0D=mOru1YY7S`VMJT{7gmO^d_7E2*A4R1GfQ#Fj?!2lI>2+q1Hm`uK_Y` zN!5GzcGvXujoWvNR8p!%yD56pJ|a5KkU@_<0hjja<5vNPNJ}&84O6=baS@tPE&9lc z`}kZl^-TLJvmZvW71kPsAXsN3^Ot>v#62vWETp}vwy*3wFegsMH!nf*6BZT4Ea@`( ze*f(r^WD2QZa}&2wc7|59M5I#-el-X67cC7HTK8e;$#P%mLH^IS4kwByWsj;8d!u|z*!^pHFLEBgg8 zitDZ8?#p%73z?HCrK#2{V2gySXU}Sp=Znaw@PGBvS;U}Z*7J{?ise63DA_MDD=Q~j zc8UG}amPes@;=Ez*nO&TZ@~?{{6C8Nt&sNO55I+bDOjd1t(IneJ_(>C66o`pB#uJA z8Ag_smDtK7WfVpn^r{^@pTRfGcPQxOPZHF?1q0GMFV`#jrQ9(|i7*Vuls0z36-pd^ z62az#6^(dd^AQP1j*wKI6)(!FF4eS)O0m}kCXqYF=HSpO8I(rghi>_7e zQEoElopOP3)8vdhe&!au%fQ*}x_X>4c~nUO+WqAnEAAQST8X_vBUfoxC4M8-+_F=P zYmtuFDcz~wMc=>&dn#lpCt=}N)kkr%&G|=Fei!)Ip&Xu|rFZNnk+bCHNGfTM3e}`r zS4qFSft7Vd(F#=a1il3t#uO@k4vKVa!+an%N@5{bN8iv0^?i&-rHR}bM2nAFtNzp8 zL)5w9;=|ML$o=WT4?H;B=4B(^75L_MpwZ{bS_8>aT*H^+X@hBGYVRTOKO}cvb|7rp zPn|Q&d?X$CC!^?@npY`0Ac2i&5M^NC&}-1oS;>*fZc8|5SB|h zAAQYD{k zOtDvvKQYj2FF}JuC(n2$oNPyl4PZ(7AZ7NirKP3%7)s=~;U%{xiGEwJQ4GARu11EKRM!0Q=K9xgjaa%?QwDW&{X|dz}>y2D;*YldueQtM<(7LW?c`G z)Yk{!UOgom-cZ_+d}`E2ke)~Tj)a7Sdsak5H`v5r7)Ez*k;>WG8SRI<3rg71bw}8V zVslw4wtKlU7Ydh$VA3)&yvOTF+1c4$6< zj4=-jYx`@}=Q@Z|St}X_zpbHRDNr@(e{lEiCJqrUlL1_>)7~;KWY1%7XQB7pA5)eEl>KRz-l9AV(q{_34~(bvPitJUJfeV+ng zN3jmKm*m6%KR&e1(K$=~@7N*sk4Eu9jT6`d0b-IDCbvH19jky)Q)A@C$nM0-1Fz*< z>3zDzW(YNNXtI#|HX!`RJr@sfa>HNOt^*&XGI<_rlzMs%<034r>ajK%zFRcjQR#`&4uF{@9pOZ}^BEMlHZVm^Z} zXo@Q;gl2HkZ`aAEYMI?i4G$F}BUNlK>gnkrasrGL>n>gM(fLQF^$!U~PvK zER`{!ZU`!MGP7;Jn*fYbVl)MMdYCygJ$>SjTY&DJE;&Tkz*SUw^0W&8Nda-$ST`~d zH$b3U7xLBdVQotq(?fYfZFhcCmfkNeFIQWa2oF)o>Rb+glFFr4qB+_`kr~zgG<0}X zl{oKz)q}+04g|2~%=i2jVTGOsC2nIOG;Dx>Mu6JxfOXe>s%o7S(h$H7&4$`N(o36JMk@avW^V zuumv4sB~;Uu?xt7>H{QATNX-*2r)cQmhb!pD%(RlYe4d==TNUrd>H@-$+JwQIcvu$VT(TsoD%V%UcL(2)J3;71P2_ROZB!3bdnIf2|R3Fw= za$m1*6$j*b5qO)UHy0MP3EQq#JJ&0d_eFCA72>#w81W{B2Rl1^hVh=gxp`ja;s$Yx zLBmJMwTdobCqsVyeBEcJrl1sC%4$1)M@&3uKm^`X>9HopuJG+`u$20qagW)@d{2b7 zR}4P^)Obux*ycPO=Q^~yKHVx$5_Worvr)R)+X5t@E^d(2mZtZn6mpz2p4#nI}I_^wxmQ;EcmHQtV7C&;5 zS5W8*VDeJnk8y_6F+m`3sc$+x>91tXc_BX(W8Q^P!uxZ86(n~eMHN3&pae$S`<^=e zG%zr5LRVnEzuAG@H^*|)a;mQFc+>;wKMKl@SNMhU^?+}WEdaHuUY-3CEk#zP!$g`1 zd}j){EM-Y6C9`c%05_M5;sdV#;PnATW8(_k1ZED4)~nD{om*VIyEd?-rltmjm@C7) zp`mmQ-j5OLnC1QHjXe0J4<0GiD{f-xec1!-DI(P-((h!`_qT#lYA}(Zk#uS-(4x}M zJH$Y@*`)^R6Nj^Efn;WibASgD}I>_dg%4@o&rIzD{M{Rq?f7pI5ZP@2Oajcc9y%(&InpVgdZ=Y% z`pEiuCeHKVFaBwFS-5z2$m#jQqBiog#$A&!7p=cySdup+il1*lz7~6O|3yRBo#s?X zYrQX3{yJY7bMWbvN~7Ojj{H3fKv{PmYzkyAeX<14)Ya1l&`-<>+DUc#4@^263qaxn z)-59s*;K5fbWv|=ZO;Ou&VPeTYEHkieg^&zupwvNIj%u;(y|WC{|HbFg&KW&Sdyd~ zY<^nb13%c3q^|!3hJmjhlO7#SY&&8=OO(`au)a>-l|KI*PWGSlzekTqEt$%8Y^FUQ&t%i@qXa4Vr`2P_?|9{zq3^37v z)6Wo0J+CU*ygH?qVY5Y#6tkZyYZ=tYO0`~MnA}^P)GYPDm#ojpWca=5cv)Ex-ZnPS z`n`#@PV$AW?jpLwl+eilLLD6qLIa^bHa>=?{N}RsHR>Pb;3aDzOa>!yG)NvYfnZ)u zH>F6Gm%xiWZmM^srwomE1OA>6SY8{fzD-I)`7m;`-(?04M*h41AYjotx5e--SQ z!EUg?+SrOaHuqgpYt#E*f_POa0b;ierxHrf=u4k+BC8!vNB?lm0`5Z__#SJvD~!*@ za@F%pZX2xstYd+E9bryR4hLpurZtZtY-&`dV)wRg`p%e{E2U^NDZ8kD+%GAt6zt*;Jbi@E)yzXylmL!Fda;Qjhk7U==L0HT1N0Bdkj6df zX0glVu43MR_>BnU8L}Hz9w6)roH&U)(Fuy5Wq`zheEPi2#FwO!rqa+9R8VZ}(E1v) zlBC0U{kr!04_{Bm1)JkM9X>Z-*UtpRP8IYY<6;6t-H34L9FOKx4ft^Wz^}=%=p0`^ z6*kzmLf)>W-LiFH&;}V-;n4%|mcUff%2%JUYKZ0RN%qMN%nRt4cuNf2(CIoLruh3^ z0Ft{z7p(VFl%)6dU&(C1_P0r^|JeyQA6W+y8zH$Z{eQ7yyr}PP>Pq$wzZ5P6>@2aF zZRx|X2cUL!dXtjNkbnDM)8Cu_R_6PEcKvr39>`y)JLnkh0Xt*3*y8;vf%Iawee4+cOPim*I!#9Qm=mJtMVslGnCHpZaEn90xiVpqv9*AWl_Q zbeVGy*a|c9#WHW0JpB=SZjJ`W#edC3o@zi@#jRqJYH;swJW<{ebm}u3?qbC4uW8Zi zWQqPLx5yDx>!obIQ z`-7_Sl)_u&_B;Ok{6zsVUhh=6WRs{dIn{{>{Rl?G&G27AdLpSd*tC+1=1e_Fv-$_x}K!h|@#}uR}HH`HeFsr@8~^Tno_WXZ@#ey$H z9Dje~ng>4=4QB*S! znWRL=NDl5}$moRl==eBy3OVWq7nhp!*YxzTEBvYRyE$y|`|#=HE=P2NO8Z>}E7z|t z4sner7uG9+OfVWQ=ErC{(S3Afe7 zzBeWTv}u%j^3!V}tuBN_aIO4(!JQqT8 z?E%k*H#gEoZ$!h~-fX`1LBh_kVBG51;jj7ILMM&VUJiaJ*E$px>!w;ig}B07gDw83 zwoXAbfJ5Tc3%dN;1+EBf;Rccj?aJ-cELr3ULH6wQsZE~kc>pazkK8$ZqCrp2W2v2uV~$;^_%y#XU-~{9+5$?w4!n zKZ&AslI%02BIiAz-$bu&Qs2FPb#ue-#ZETnB(mE*B#DfScjQ@iV)<5V(W_W_CBo(8 z;qLynz5KC1eJaSulAk-ZcDZ`?)nUu1v}!OCvATK4((5|wT6Rabx}bJdx~zuS$z90y z1uL0V?w@C$fS#MD?)~T+gJNZn_qP6ZE_~q&{#&Zld`WTwyPlDqx=U>0;gz!u#NQ-Z z*Z)f9|6g3-e-(GM-y86};uFfaP*#v5dZ{a6Koz>BtEd>hz1#_(@W8w4_)4Tg_wk4P za6G2apZ$oa%_1Q=t0+RMD_w_6!GSNx#I}ah^B?{wJl>yn@-NNI)R!iguRnb-86nIj zQnaCIYwLw4X$bN875V!4s${Z%@C>c{2K*bZf&*yi-4z;f2o6G{QhyPXH3@f z3=^8rsHhP;5mx4)fhA1du%Wh~Z$Q*qd(ILNMB~}x;aH6kzaF!QPz5KVF)bx`bpm}w z4752Qp#BEbm_`($fb=(TL?K^TeQ2EtjS8_cDQtFt6M&vQYF1DCS^8V{SzIV5D-bADHfZiHgEVawTBWD{Y; z0Tnxzx!#rE86)_xPtzYTi3qy2Y#cgUGcYkF0ri?zIkA0SFEyU*0fgX+L?%;?l$NEO zy@lJDO5ODA#?jF2>o;+_Y7+f|e0FPF>gvSD>#9wN2U&}lTLt1aMa~-`(D1dr@GY1Z z`i!VRr{)qJE_l#P_Qr}$ZB=>s2nv0%n=A5l?CCSXP)Ud8-b#_Pc7K3d{TEFn zQFG(+Fbo6Yt2}nzdEW(2p6!+ap$xsTN2I7#tL$wG*jWSB9gaQ}mitw+b;euEWKZ}% z&mDCFC(~XXvf@eFn+?wvOY-lu&A+N6;$;tSDZQig`Of?zIhJ_a5QPKBam_Bmw3%IZ z;FKHKzQ*q$HsO$PP%WS`27_Xqgt*$Cne=Hz@!2XEcBZBf*@PJwUO(i$Yw-9nrpKWn zJ#y$qkra6r7)77m^e9BiPzVJ3FV#;2FIN`UC!hzHqn@m8;?iyJtYN>*Zszf+tJF{`}pjs9bbA%9VS zMcdF|=bJHjvomvG9Jgwv*J7_YjAu-O(gXe{9TYB(qkCB{#j#R&$Jl6S4J(uOx{(GD zn9N(M3D5@q6yE(P7tgQI&x{`o=X2$dffmEl^%M1ae;WX~0%7Z6YanevWdl-qjruzL zp(J~`;b_484rx+GsmDPuU^nCnP_I7|FNk^ZFeTh$>VKZV2Ttv$62Jay6-JTW<_02* z0F;=;OT^HMTqr$Mx4O{mKSX;b8WH^wH;jxoacDT`C727hf)sKi+>eyF z|Ikuw?*F`{R0be!b#kf}B;5yM+KATa)1O&EY^pDI)jU zcllgN%uoniMN0*-V%6-SbUiPigL{e~8(Yh*R40k2tZ>onp^ZbFwEjtGPfU^xzn*8? z^z>-?I~b3)(^fC5m1}xG;^tEUzi%gFB_~IYeymJm65ujiP8P-;exITwEx%pgy0U+I zQ9t*WpNm$Ua$?Du~ z_UIc$e5ARoxj|Ro{pV7#wAt!%*3UJT>^gVF$a}PEq3x79hLMpf>blxfPNDhR0=jl1 zrin?9G_?|Wac_)Fw!41N+XuJy<31QJ1o2ogPXA)CwQmeb$E$`Uo&9#|61;7}a*M zt<7|F3hL63_aC1_{=(eZtO_RF@`9hm0)e3{TE!Jm5Fw7wlBpb?Ne`od(|pE|2iK#F zMwgOBGyggTVOPRW3L~ydVU0JFAg;!XaU2tOSs*wLU#X*JMQA4JL6=Up4aGB=o?fjH zU5!C2gn4e{L99XYo8aEfrwnQSwwM3iQv9bu{r3a-bHWW3v8T7m6y^H@X02|T!4G+$ zfBl0uBmT!*A3oM>Cw|6#ddbGyalceo`O@p8I@nLM&i}m9*=u;@ik{FRuZ49|IMHHd zZ%(m%&ts{PpkIflC%3|V*>&l4+h|I2a-Qdc>{4U8Z8FvJdt6U$Ro9)>K@kFnJTC+^ zWAD4wG#uX{?+aZxp~GJWIwe!X_3Qt${pb564GUjhe>C3Zp9jic;DWTA1%u)^{+}nv ze?X3F^bz((hw!g(>;C{0e~0Mnj(a%qraSoL|E^#E zeZ%#&$S{>`oDz*ui=_|2aW)`RqH;H{dvL4Z@%KzK;luB>g!kPuv+`zsb117&9V3RN z6%JXUwjsmsZ%I*y4!^(d*q`TW|4?LXUjbWXF(hK+#;EruY5n_{`R^Olf1Bh7%?Lhr zK00L&5Xnc&-0;7&MO)5}y0;9R!soac`?@1(Y*=jUScYUG$=Cfly{`y*rmr8eAazX( ziQ0-0Hi+Ye;GKWJUbd^B^Z$3P|JPC9qJ=Z5e*b1^!_$(x_}0dH12|f4b6pRUwi*B& zYZ*u^Ha}keNhx>^*R#|cu=!8ix&2~ziP#Ky^nV2~|J{1PL}P?`pN(>h*W4nBpn<1Q z{{yRS8r=E^b}ax&^=xgBrmLPt@Jyzw-ca~$cz;jH|F)9&PdoEpSLXkN2W}J<$h*S@ z05%b<_vY@QMW)CQK!iR8=BYqkVA;UYe4N9<@5Vm%=(-2f6Mz9 z=3@*IPXVC`=Ieiea9)FOgiS&Nv;U$6!m@~G0w#n_|Ju#lw@2tfSTqif72%%mEae4n zm;=e2?H5xx3{BPWh$hOtjIoYc*~;i}#UI6J2Zn}0_6A$BIj?3Qy#oTe*QR#+M5WrFGktnyR zZOF6>SC9_D5Zf7%2YS!J*flD<;uR005+_G#R9$Lf-rXhhHwMajc+R z+7`x6;$4%(?%=$1;-Dy%9Z;a@kb5tLlr+jzhiwLhPLj{MpxLzN<&SCHCuiZ(Dj*9k zFI#L*T<^M+EYfr^m-0tiRmAsRYtzxu_lGYMzr{N?)=CJcsppSyagC<&yK0NilipkD z)7wO!;7b}x`PY>;PtTkKKy}`^Cgj=BC6RL#vZ%>CpoMCoZ6vm@@T- zW3?zPF)3xXAYdMK&0?6u!#|lznjDopAmHRiXsj&^{$gE}1x;l#E*2)hs@z`y<8R+uzlI_{#zIBCKsAOsVv5HVhtXj5Ktz)Zz zZQU~ZMBM1I2@jAmCFMF9bJc2Hr5^h1;!)^w5EHC$%j*(I+tl+|f^At>TuzVC5}1|O zU5~N`K!M@y)@+aJsFiGVJ&h$?)zlQNthA;4J(Q=TgZ52vQ|ERX>+E*#K%MI*O;>1fn^mI$I)CiHMBQSmN8+ozyU^?!`k|5!E)`ziO;&_r*heE9uvpB?)Le)Yj3^)zt-xHM}S3?C2`W zEy6eUyL_orf4Vw%K}B^WtOrOtH#cuJ!V|jrAHDQb^TrEaexJ*3*uI-;V@_W$Ep153&ACJanZDSqacxb!7K?Aw)rrwJD zXPy2!I5yw=HGS$Ywi%haSZmFkPO3N`g|?y4zzVtFm#kuco+FuY=lJ4B&!6mrkjNYYpNCq|tbYWvl_L>EwMWH68KW`Rt*Sz)kX~9TgUE@sj*v7!x!NUYTr}7c-J%GBv z@N$zhF%;7Y;5^9vQQx+LUQ$X?^E{GI!+K08B`?3(0QJ?Ox_&*_vfweK+6d7Fghm{l zC|f(V4u5(0s8_1+NKyM^$YxPio>z!A$iZ@az!js(RgeUAXRc z{n%}f8_ml0-q7HdOIr_ufx%oZu5fc=aNwgznOFGU_{Jf%fZNWzQ!7iHT*knA)o6&b z^8ugIDqYP!z)T<2c@5+_>B6M)M@Gg*Uf^XvS9)CMqEp`bDg4*MI;q4hC7U5nlt7)djUIws;V<58f!gY~#>6(o7W|#fT$ha59DVg(t zw#170Q0x(0f32ES3LWy66YE!7iva)RjY$DHcB1)B_wgXl z0a#JjZ4u`|OYcd2Izn5CO)nj#QgZvU2X-h~oQ+%dQ120dZclzw_x5-D_=J}6`1LIi z*YQ#>CyvWqkSNhy0ccWly^8Er8^<6kC?tnfu@kWDXiAZ3X~zxDB*jR*^Hv72>5EQ> zB@){^AT|?%6K-jpjs}XPCPg@2Y!MSH_waFup*CJS=(EmJEqmTG(XEIHof6%yzj`+v=PqVsuOrHeDqL3Gg=uo^k?Zw^S%@#^~dw7ogKv9&PliGF>L6`GppPP;Xm4)%@ zaLM};1sH7C^Sz)f#J7R$`g|Qd_n*49F9dYJV3ur$t)J?b7p)?P2)^qn&^MsmyZcKm z8xP0l!nh;ss3Ii9Dn)Uuy!8<&CB7^WG?-Df7+pqKbD@pMeaN|jKOL_o0J)6h(evWl z>7V}cxnc6k#du&arb{J!(#h=VjER+nFoX2E zb4P%KP-Mjn3Lq-yNh2pFH6=3l+!4Tek#+xmDiaey#Vggz^6oiMQ^$Ow|m@9pZ$hvhrh zXnT6mvIyJF?v9TC4huV)p4{5vE=3F;9$Zk_>auysSZTXcFyF8TZLm*$7-SI(Qbfso zPUnN&Fh0lGi}kKOY8WsY=43AP;x64j8MA=^yOg*Fup+A9)QlWiQ71|_Nud5D6hlX$&Dm}O*szbMzu#qsi5nwY|wFKnmXw0 z-8(W0p@$xQUXADUirE<(_x>QNqHqZtoXS;!NgFkLg$(~PxVZ6~GD~*>4+T)&|8j25qt5i#2SZ1GuU1g+2rx%)Iew|->p<7CkG2p4&`&3bp(`x`&M(ed6GVOYJkz!k<0skmDa7!4bo;|eX4ymypSO$b! z)H<#5Ttr@>pTF$tVq1WEyv6|$R1VnfBquvN^=q6fR>ubfy5Ul<9sxGu;w&zg>dM*U z+KoCd`M$K#6*-Xi6NU+1rU_t{FF5y*#(`ynnHM<;^v+;DOIPMT+ZoM zy_%{L7k_#@Y}hwgZwO^0O)Z_xN<1=n}vp=w}N;AZbh`ZK2(ZWz@lc_(K z>14MSc!UU0lmOp*y+Hpx55-2v^5yUXf>8jhLG_a#ULt^Hn4^n?r4wIOB>L#QT@mL*aSjv^m2(%FqD|fN^ zsNs5eggy%DXgegQ5^*Z3E#aO}m@|B+x&O*zlVDe6(%mL|hVt8YwO3>*b2&9X+fafF z8#zS3(~)$5NgM*ba?=1{ujJ(M?FJpifc#^D65sNyc0;GwfA) znTP9sR$L`2r^xbt1VdEGm~Pq5E;B43KrR1bIfdtP>`KV`6@|_8`^V&zq_?q?S@a^p32z4=FIdeqYxMC*#y(*`S}2|Cmh}$zjjiUR&C_lkK#!R z@mET|Y=@q4>)7oMo|J0a^ar()f9T2TDT~gHa^UBu>aYD>*E%}QWs0JbSe(3a+wY!) z=sNHE-ot+s>L+z|j^3`(=d~}3Wo;*)xY*`SV^TCtHd5?iYkjufOP2B)HgNg{BYVbj zziJ&x6~mGgVD-nCl{3r6Y4kmA^1wFL>e?JU?e{{L#=tg!Aw%9Ruz~>S^Ilz@{K12Z zd2%>VHuHYO*>;UBYT5%R^yd*)sae?C8aEA@@d^mgy&5>Xe&~2mj#X7%J-B1=r<_gg zZrk%Tcl+q+hMn`ZC9f$6dfET-*Z@uE5C4RGV9I2n>vBj%0{B0=>;}|IT@g(Pxa<6h zDDAg|*u4{lxU2aAX58?YzBDeKI}O1ry*-`sswVNNX^(0#N|8yO(sGwQF&mz{XV;QOQ)CS6>~OLQB(sp5OjxJ!Ic^T) z!RjXm)~qb71&%-F226cgIt8{^pW5(&OL!f`%EG)fu{W6I#rcR~|1F=!+Rc!DJ?IIH z8p<+H1G}qZB(GyFTY84nX#0&f&%vQ!Cxo-gHNjiQplF+Dr1&BEo61Usml=#a{9>Q| z1XV=|feu68T&JkDrPz^qj}>Y`j;s>e7l5c8>9IB-Y)UarxDF1odiAJA4dNl326$>S zv3)S;f!vMhy0fC{^&z?`mzL;cma7xG{%Ox4)s_5>G>;R4@UbLf@}!8wgGIdx2COfQ z-|e*>Lq_(REKvaUL6U1}#hn0TgDkcrz^2?TMk}3zr8Vsh6g*T6-Xs_H*6)0ID1fF? zPz)i$_K^j$WPyTWI-a|}T?@mDU;WcutDw(!?bs{bO`5NSPrghpY&rDos;pE~(=an6 zCB_X6-y5_m_1HEmkJoeG(pf509UgA~&?9y-{-<}`DXBA_ENlfgB%<*jq)t5)O0 zdB)@lh-QBw_fL8Yf=TV<)@xVVK_T5_2C|9xe5`i<+RV{+$DXzII@1zP1I|q#XX11~ ziiVWLr|}cQxRukC%)C6Yr>AF37g`7Ru;$j%Kp?IkRuVq(ude1NVR#YGZ#!LU>#?Dw zW4ONFHe|bVsK88gzTW9X&V0IkRkI}Gb+~xIQ1!f<(T%9Id~%B_jG8iqrBW9*wB}f> z7F>w*T(?@PO*cnv^F^mbsA&xS((emFaAr+;bUPs}${C6H9~Ui;hA-s5hQT-X#{~C! zX88FK}fbYAJo6gyY(o*yg(n@Nh+#g?}u{(MSj?n6g`H+sJCD3l-`Ap~JFm9{6t?|Z zUEKZBdu*dyR%y)>Y=dS8^5gR>b>LftLblt2#J924m{YQc*50`4U2Lrq9oG=y-NkVE zz`~KmxQ5Q!JOS^$@BNW&L1^7a{RW3DBB^AB>ESrpj{VD5*P|1*E~C8No+%TxzOexm zRLgrjBjB|D-WZBLx8z6|92%-gjg5=*LP2MM8h#&vidM$PBLy0zQk6?2@80Rq(2%QW zXo*^nWsu1;A|0!e>!3 z2#VXsNE?W?bz#6u;5sM-1u3b@hY!4o7ewuuo8iiJ*Y@tf7oT=n%0x{keq_O#O7s>G zhL=yZYRL>M=+{Lk* zyr@QyhqDl!Pn8nZb8vsj&RNnTt*W|tPCg)pS%) zZIoRcun~)kHlEA6IwW=UtR<{&+qp(!(NyFNpHB+iAN6R+`DdajT8X$iljXS^u9i z)f>;^+9&@EpRNl)LvX~xfjx7zqX+-A<-2bxgznPP@mKaQq4P5=dh)tz*&#e%*Sb%a zc>41nVDA<5@$QbVJi7N=GnzLkURqc#A#FH`#CjHZ#2*az4Q+dShr#L(>~!|6o9Lr)K3rY#ZWTJ zZgqKAawzUd>4G{^dU-DV7sn@=4~T@##IA|@JDAHeJ7Bm*04!X}kH=#ZtZ& z_@tw(W$3QPhS#PQ9}`#t-OGBiTUn#aSdGdC&)Y3Jmi0h*VtAP#w*)hWA6y5YTu-V( ziN=|`!bCD5ECYd5+p-cilx61lNyf(rsj?6ba2^Aur3+}gdUK!K_Kz`O>V)fqF8}Dl zdLHNA`TW^f=Tv0-$buxWTjy~cSEc3IDLJLMcs^+%{eZqqS=Rlh3Od#>Aq&`qZAw`a zahxHm#}f+$x>r8~_m>n&w93u^2IOrr7Jg3mJ6kQe29=FsbzZuCJQLp`wT_!gcp@`G7e~)oENzyX`Y9JU2m()Jf>G4Igd`P%Ehj?M0Jo~q ztcf99U(9>$`DTZ190BYKp_S99yYREClu?Hy<|oJnh(4g0RhA!LzHj&*U9OkHAndg4 z_Xj<=j-SiPGfd+#jj{=6nrh+S`Bqj}YwUP;#AEI`GA;I5U;lYCka_b6^qqPRcXoj? zSiRQk>6b!KGsNAH{h`a-5sVe+cH2l+nW+WgOteseaV+pda0l?WbY2cfsli$wr?c+Kp+^ZMhqCwNvZi()U0=8Z! zbXVBAn%7wDB-{q&cHYr1Vwdv8p|DMiD|S$FfAtVo-YgC2o*m%6(cz-WeGGGL1@CRD zz=NIXDB1xpy>BU|{Aufliunt=k1(w8i7l;a%HGK~N3>+fvHNtDUkjl93uO``O=a*s zX6|My@a>)Cj8*ii3S?FfxWR|1P)-g-$Hk>fR@&S1zlLA|W=?*;r&^ElE)t_%Q;cDR z@cckDU$`PNUH&SC$z!We)G6PLi-S)gEha}PaLCD%nB3tbZ~_o5O_hPix9`51yeQ|M zIgy-+e%c@XRLQ~gY~wiry;JoZnmB0pl20YB+VF=>%Rl)CAbcTHFT|s{vmvjfSJe{F zMLlV7+FU4ZcH8$jzaoc8k+QKdw@vU~YK>iGw)uO0-wE>rlcg}JAKHf&`gR%) zTdHRYxe{UfwzwWsznSh{{z7OCY+6EOCBNA8JvDsow@b=O)A@iTMa`fmk)o8F>79sa zq>Omix==f#5crak+*Nj?TnWV^@zWLHPus1(fmA2A%=u9pQ)ZoH;dK=V>TFI$j^`%v z2Be23!F}Jy%sd*shfw7w+K1a=vT?oJ=m2TChSCv@W>+``SKHznlHyGH;0~+UX!E{h zPT+S4mRCQODq{C@jan5`B7I|?&O5U{1?naP&)0s%w9Owz=8>xFj6Jsne+``cAyiQi z;3)-x@Ch&|-qx&p???2NRQY|S_q$KRH-Pw*Uit&x80v`@w&jEfHocJ3aLLa326bQ7F)YvXn!A%*rL-+{_%wD71oM9ZTs95y z*#k0vXm-wUdK(aOHmV$-jv}?rC`otD#Gg{^NdA^NSRzWQd(d0LHgg|wkl&%e4v2^9 zC1j>{yw~Z+XSTAJB4k@+ezYeSd;rBriUCAtco!?0hLDiJv+1H6We=$IsXin;C!VmP z2uqjuk|r&xfyap{k#I@EhyjY1zTI%)cJO5lZ$ZS)Z#>*YetVZz zuz5;oejkW^UMWDbf_0@F0v2%l8oC5Mr3}ATi@~?G*GMBC_|nCAKIl4+>ot<00kQF7 zewx|q$*@Fk+?7HA>5vQSxk%6jAnibE98(C?O8IucUW zY#}%t_Z-ElXKX3#qX-_}sg91HwIchB0rGhQLT{CnWr~cwT22g=3^ftQtD77=(zcEY znDR)s634@6I9@VmctB#hpKxuZX7mI`?4#n2C5-x>F1#34&U=}#N$INz^0o^N)<3jX zOg==%IMIx6lGC@shXzMy*rL?-f68dwaZAx_nL~?czuO?hmN2rGZR*PW3i!Jpo%C~F zuy9atC9*CVn^1(->;K6d5mcXaY?(%^Y(I*iEFH>jkFWczU!aR#;EZtw7;ckxO<12a z2}!BqS<+r2PB_H7$n%xqUhG6xK1U?|3(3Nxv853OT~Boo8zHs_3q&Q5p}a#E`U+4z z0nkI&*Ds-4??~);Vz51?b}g|r2pZSb;A(WY9%hhu<8g%8(m=>EaSHstHw%<_RpEd_ zEU{;WdqRWx5CHYWlV}A|>Z7Iw%|s^c8hEIbchiC8Nat;O6ur{(rt^l#vY#QfG+Dr> z806osm_?uv%QNaE9q}H`Ep+yZYMPS(=I7~}nV2}IyrC0$1)l&j@#sQ!e&qV%*R*I= z9C3U3Hi5koYF?EAJ@u|*<3KwRewBy!6AajABH|7+IKn{KY{G!)kf0pAsd{EO7+eOJ zpt)NSpRqh)brx`r$=gSFBi7bDNfMH|-QXtjibH9ZI|@o@n%rdwB}f#`i(O3=_w!jD z!iqFPv-0`$_u!$m)5ozEe{jE~FUpf3CQZ_8$^!yq@(;1UbV=&5jqAm{_Ys!NRvCl^ z{KowJJbxY5DZf<*jeMPE%w0cVrNWIPfeBQE%^wp|8Tu|*;>rrN{8s8vlAta z*Bunq3+hqmTOh-?4MW>GQ}kfw>?`(6s+u(Xg3XPbsRuh{;iBQk;}?`%;K&$WH;wnX zTzm{{77s8$3nX6jTQaZ#`S9Z8bw@@eNLg8-jLb1qOb!*HQW$%(%Ra*rKV1HEbc-ZH z4F>C(11{4YBvn0hvQhH#&HB*$GbLm5^Q|`=lxICt$7joDA7|8bH_DjMb zjyAd0>kfLMX6KF6TEhGlRlqeyqBLHE+YSpLofDCVu0BnG!?*1rbPW$Mh>mI|lN4>_ zQa#NB+TY-w>l$ijwd;}}K(~IfhB!u%dcQO5VKZ{)H&pSjM6NlwVWob@=1ONb3ytON zo;wojtzUMtt$RL>qH*2o19CBvq)?)qKh@HGuMa}e+YLewvgtk02PZ{h0RS@O%r>GR zI806Dp(t>yy53T2XNr+4$wjDC;VV7g6Bu{Jk#4psj&@|?xJP#xMK_;+K6-m4d7FzN zP?=_sfDL#1uBc0iN;&5qzTR(+&e3hB>hnv{x7i~fda8Y%r^FmysyvuZeCD}DO<#EC zMXLT=Mtq&{lAQ&z`+cmXWQz7ZEoxBi7a9EldIke$OEYS+@4eEX>iZe8($47*NdAjeA*borGc zh5I!Ol?vRr2#wl`9Dcfl`5wvOPsXYkfSeiqu_@4L#gAAkTQ9r({u>K}WH=G z%m7*xBfPznAR^}Jpk?!rTN5rQMffdA*t>fDkff}VW?Bra5Yv5tRIiZ2cJ9aN8D%48H?pL zKgu%ObEnyHr!fGCDkg5ao~35BKFvAQ5x`Sb>m&O0(H>G!`HgFkrxa0p2K$@^h`M$& zF3r+DtI>og`OYYVeTmHJcPaz#Sg19~DhozcYO)QLDjVGwdV#W%GnSY{YG|#vB6xfk;rj2^+=&#PBJglOg!9y8^ z$88f_}&EC}MevXvL!Kj1EEkI-gCQ5tBN5Gq!|#dX}rt8qh#c z_K`Yzv}T(mB6g`=5+3!79xfs!e(jZiixVCJI1>#EuTw>@(<4$V&Y!Nlcl8gruNR$~ zW=LQhx9KS73}D-dVh>33DgAOL?^ZC%etkaPa{ljFfX3PFUfIX)e`fp8(~gjK#eu-g z1c@H8#Es|WLg8464TA)0O!I019nrtdZ{6{oQ;0NBta;&}prN(Kca~6o#}a~oaJf*< zztr?^pV_|q^`Up%7(N$PWeq0Sz`-8>6-b}5JRm#17GxufqC%6P`BT@b2A|?Y2?mZe1 zpc2hhouQuV1|pH3Q2>#{vxb&riO~np$?}trO7_6_@QZTyIeEi;p4;mQ$-V4kc{v4Li`Meyi zmXnNwYL@%G2}XAvs-Li6j!n3O?dxOUn^+*6CO9c*Cqm{S|5EMDT&A8c$F*#eJD%cw zZE^N0KDRM1tD&HY-uEQWWKk#<-!pyH8ktpqweou`xZ^}p5BoVyyz+*lkIgV6Ca5g~ zV092$l_+3oKGnnk(}ytWV}OB+uSq2`kJEqXJo+WlC77m39ntA6eX4v!1?Hf)(agNg zY+}z=2uxZ=CnRY`VZLU_N8{Hkc|}Xi5=W0ff!{3$`D-5o1>-2Bd6^oF^h1m@^}d>Z z{PCl%IWEeEdfgR%(b-?ToS780z=elL0AT%qfR|!w!1l`ve|QP}mqGeP*}CBbrAcr{ zao0y|{X1M(OsYpID=jRb2HGY;>_q&K;=qI8Ur0CAF99!KtfN^nKE5^4V&|E1p#4np zi`yH$XIz*1{wSHbY{_x+0xF$UxqW1RMHKnsA0iOHV3DQf3(L z9WhuoW>W_nF)?z}_0xh+5D|PpQ|DG211ej9GUQU&4p1V*A9;{V37p-o#U~kWnOM26 znqx=(mfuHYT^JX&-V&>M;t+5YS2gkY^Prpcoo+bDoA}cMGtf6y?S!Xb4fBYrTRI62 z$~f>uy3>$HhQT!&bQAcN((18~vhXb{B+FE@gCrL9t7`IGDRAGQ9iDwsEk)~BX^E#! z_XpyDyuW>_x=s7^PFnqw31;^VIpJuX|(RwbK9YkAZvdr%4IDFm8J_=!Pw_Ns=d>|U%-2R2!Yr&+V?9d59ZcIQycHm%cGDHy>S%{t#!zjoY@BB^c@V0zy+Hx7a`ixVq3HSN?*Xe$KT;p;0l8nc zz(Elm(UpuNgz@2L!NcVy1r;i0LXtJJ)zt}VeC#?xTdOcG?)F4s5yZf(mGHoF4kXl! zlWN+TB#^5m+$?50T64GhxH+^s`{+>&ek}kEmroiX+ZU^iwRzSe;iM7KUk)hk%fZU_ z6&Ys@qN0yk;LS^V9NlF`u>sN=F}8YvXUB+vV=*j{{!2O#miKDeBQM(3eO$k2V32<( zHurJGTQ&?2WkSh3mcE3!)nv5(;9Q+AT_|K=Uv`O_w)(SPhT+w)vR6HU3|sCyN_4iY zZcVf)R_C*D<_ggkQA|@2e|g%Ww^=A6%befG2+pQ-BHCg@mUnsln$j7a{fkQIQvOwP zbf#44g}m$-I_b3mt|*BrE!41QW$cy#m|?{bFLVbt5$Nwa@gW*R~27AH`=gLP_jS1=1hVPZDs$tQl|u8zKs^b0JZS| z0XQ9vQGE|x{YIT_-K}!ulx0_`Ug1<;8*arTG!eq=EG=>Us zfAZw{kK(c)Yw{|5F0F0LDJjvV{Yqd3MAe)sF1C|Jy#kogW ztp32l!|Oi5>!;F~w9@Z}Q4a3z4NWKr3MPyFZl)RyvHd1&=fl$fJXk)H-k&YUt{e!q z<)Bcz%bw2q^5E97A`vTfH)6l})0DK*Rboo`&w=uqlK1Kwc^h^ErmSaqU30zap|(`F zGq$I0V`J_Tf5B$M{46S3DHpGkwYtx?WL|+u4fgT3oE0Xbo9u7nARz)VX1q~@k5;Lb z3f0-GoT%RV8uSVI38A(u@WpC$p@BmGo^<+CldR8D2KTMcKkP1mXr;xxu9D6Impu(^ ze*7v+hV_sNOu73*L&_HG*@{sqgF-XF&5hr<<34a|SOlt;LM=OgZd|r4&u-M%dphvC z)lmKv0}|a^ns41}0Q!hgsH455T;42G7;oFB@1~7IM086aIoe8uvRhmbG+s$2Cb_OI znYvUxS7OU-bJ7rjENA#c&(!uILX~uFQlY*geQnw)$%G;pTH9Zvr1-hN2 zXnL+H-;?#`Y#r8-jc+)Tf{W)%yQ#&v3J)*G4C&F(I{|ATpw}%rWz|nx->r4(Np(Hd z*~wH$Q}o1o<$`$+gmN6J_=0Z>^S8x!u3mfl6^)$GXJyYG>5a|wH5_Taq+J7f)x?epP2y;&kN0s;}MvApsEUd)a^KA-J68bIi<+8*``XpRIjS5zprgF+DxlKU0mG&n1}X?rjXY#fIM< zz{2ttt3h4i)q|AH+dI*9ePZnKB{klnC>MgP^2?U}5zUd^JoN=_F;CUkzj$((vC~o& zCMX$7y=I=R+51+5n|$q66O8zcyr*0k8crD)_h;Hra5HT0Qzs1-I0wCN;;GXpAmbTW z`}8=aJpc0NlgCfs9jN<9Nlwaw!TwwPbnx7$ULDcvB|JMg-gNTi4J^s-AgoewGu3pf z^ZJ3;8 zgxAi(QI~4RinmMqoplt`yD_%c<828S!d^|EezdoWo>(WE(~vF~_p?s43g$p>l@#L_ zu4gbIV{GpcJkFW7DR)aQn7fCV@cn?M190y@^(MKEJ=`$9>80LpV2Gg z)`$t>Dt(smJ7?3_+==_@1c-&eWasLv2NmUex!ud61&)lC-CYLFK3`$CK9AM+@FAg! zXs7BmvFy{Oi)KKE(ig&3JNQJPO#UPT)e1?AfR_{4-W$VCAhSDD6yXuM!TGsDIqupF+ceq`*+<_&QI;~y#nNh8F7oF=cu?po(nzD`ncPGBJ1r7m`>F= zVt{*4KSooh#&~5;Nmv?rc{*{b(oUFNlQ*)#ZCR$7N)DP`E|;wAz5~LRDbiHgIQ*Sx z`-(p0=i3)!W20UtCnywqxo7E{A5Ud&Nl5giTq+Bj^)Y>@uovntwg^2Mk5Dpaxp!Cr=r(CMu?w&-v1&!@I|EM(3QeJ>aK(fXy=wM z*7H@HxKHr7@OZ1I?=V5h(rd-``#+LKCYM9_T02}Wf&)Hd7e|z7{-?kvx0KoCs?^E=MA^Rd0*Ux*b2#YGi^QKtkWg; z3q6*vtTy?$f7mTXm@07b^QjEzm%V~@L}6J!f&gSWYAt-wdG>{NF>!y?X3Fj0&*un2 zFmZr6ps|kQ&~(9h_w#8~F&aij|BQ^12=rCqel1lu%vl%^!}BwYd=dC1UV`A|U4!#0 z#L}C^CY9xE{wC{ZF^Kd&C3hyLLnhqM{zOOYsbQazHt820)bF<6>V<$MT%*+E^Ij++ z2gG8??@5EMbZVBG`i{^~sAwlbWE zGcyqzlq$0Z5sj_y^|#@sspjiqLp%a*;(5~;IC}^5d((PnAVHmxCiV7KhEQA`f!*0d zPU1ySgMo5byR>FUeZlg}=gELTunqDeoUz9>KTIJJBxC*@Uudy0v2YA+UaYdh;#~{Z z42F1CF!USeV@mTZG;MXF{O7QXt(H#d{0XHaGY8g33t(sSrQx_UO}p*u&dL0j55-jU z(ge(3B`A5ie#&Hsd|G!kS+^kM1e6$jQC4*djjWf540@v#SpLWPqKh8X!Su@w2~nvU zr^tBA7)8A}j(;WuoNWxjLrna2Ihp$x?FY;6K(}k(LEz*Z_pi3T*MI2#Y9Om4>l>X{ z*&8u}dhtX5*?lY!cYLSM1Ny8o#sYytC8^)Uy+g?zva}z)89x2=8-C@>a^|8Z_WPqo zUrBeppPqXeIq2xBH9LV`L}b5TU~x^j%>e=U;!P9$L|s$YeXm}A#RNZy`9SuRgzd2k zqg`Rx)hW}bnje*v26UcTP4B2d_=t_qg2}*Pc+boIBlHd^-u|uKTR^+SFK1qiq>rqK zgJmK?%|E;~3-8>zz!HWfv*Cl(lOA!~dT_9KcW-eDOlTSJ%B#G43_s&4t*^M8_Z^yh zs~>=PmxbTHVP`#F7Wz~MzZno=z(N~YI*`063^@&+YqzFfac+Upx-U;zF$0y{UzGuQ8GUJWjfX0>=BTW(=z@R&CjC(HdE!d{c9n87N1 zWrV239rTBCOV(WuGw^}oOUnGW>hDEwfv}Go&S;3X-?=TPK{l^~m4^0ifp#>-+)pk0 z>+gcdxntahP4K<9tl*}ZPi5I91K7tmE#h?uK+1ZoWz%02+dt*wGl9G{6MS7ZiGK*( zD`9<)jrSQ?*+S-c`hHn=gM<8Ocqkk)2G~GPgFd_?{*)|DVaUjw_U^qlw^`7`f=CM{ z23t5%aMtNLXOg?D^5QQ}(EShAOk<;|!k38EmGrla?D)a0kyBc{Q2rF56wq7Mi`8?9 z?xgxE;^2Y6hkcAOi3{VIxKfayYYq9Nc4&^mp(hPKnRuMB)|+v)H@_LE`Sw)YE%(c< zI%T0F#S&|D*ScZ zcL?rIAOsHy?(XjH?!hg%2X}XupuvM%a0^Z#K#&b@_@8s{Jx}UYO%;33uG;kOneJZe z`+loO6}PJ|_f3NL8jOv8RVkJ|-Zs?X>8aPx}2 zj`cr2Xw^QIcq}aSR^?| zd;vYfy1>**7#S7kB{&U$D|>h&oP)pqRK~R{ekiq>NMfOA$*ee~A@IZE+>PnS(I0GA z_h(xwVyiCKuBN*cjLgZea$k4MR42ODI2sxIC6qr6pE|MVb(@VFIwvIe5Q=Ia$H;2u zzy{>{=eAaE%dIls@k%my#7cXyRTWWSpuhVB-N1|w))J@6w^8=`!hrEMV15)l_J z(QHPh3JijGFf^@#T}ISJ8U7vCYK7jAEOL@!x^{7kc2o0~u>*=amKAs2q-whKwHp9< zSbtB8I&-F!l@IwIEjkgcIciU)O&jzYws?A*klAuXeIgANu(8MUK4=C2qPLO)x(<(a zqPVfabV1}2hnAaNA|9^42IU!C!YC0jfs0@As4t<5`wVU`^wdVDpXYV^gt@%`8*2mN zVII46zu$cXo&i;@<2EB?7oLE@VWjI~2FuWan`^WE+SAEt3Q+3a0c(HN;?# z)_Qo*KVHW@=s#I*#~o_@wcPlj`*wNIowJnrhlkeBLnH%Ic*DcDM{a?Ya|f-(P0Du1;$sAukqD>SSocWE)&55nvgTkg|8~F;$pL0 zWZD0~~-nYYNqm7}PrRx9MtCbku#tZhl-pU!`0k;pC{3lF?9; z!I^1w>0*B2bc&5D#Lz?BTRJ~O!0!+G^?SDlo63#lS-%d?v1Z-a*i~kp8nm1s!=*F4 zTa=n$RrP1CVaEh2N1cBa>)A4T#e<~K6`ROvXc&)^&{X`lcUTbqN(C40)P4mShM zv^mIO(7;mJPMG-1TKdU6L{8QEM>rsuqPpb~p{AUxbijb)m7ww(HqbDLY7q8K-<4mA z^KcqzB#TnQN2&US+;>=JEMki6#hh}f2I3N zL|$(v7pg(TJVc>pK<-PoyXSmbHGGP;Cqmz{JzvPnL_z;C^cu&0quuXe;kwYCgkS+N zl^pSGbzpP->*SU|SS;xi2_TW+@+S zf~%0~7Z^Y;!|qkcdwQz^kp_*D`vJ1kEt*0e6r)IG91AN?e{_ELo<(-($%eD?H8=ar zRE5`O9n@=wzw0&D-`3dJ{`u{A-N9zBsTH{K=KF&mV~l}PI;%I4OyS}2_eEF?+!-Ts z6fMz;fh5>ZhzY$=WP6lv-t6x9vLJu6t%eIAsLEkPnn8S|`KT)pSG%ixAa9u2ETK>O z&SR*@Gt_cahZ`fy*pVl%b+@|7})4X)r`Up;t$?;-x5 zeARMt;HIM@bt(AP#&O=uRIzcH#mQteHK=wZ5d> z#~3Tnkv8DYX(0m!y0Kg0>EbbR>JM?;#hDlVKhJRl((t8=^=_C z5{ZRa)ff1gGG#o5c#dt~h(oV_HN9q}v^D@NGFxn?-0kf3Tupz4cYPDl%q;4)&yu5s z16n$i8Q%RXD@~1v0a|``t~gyCeSX<)LOCZVDeZl}VwBRIm}6*>DHi>_Rgb#i^M@K| z7U8yp$F}#9=1#ENf|zHKd_k4I)^#ltL?@{G>NwGwxQt6ge;QlgWxxQ#2VKVH8+g$h z*Bob&S?SA>5E>)J1NGhU6U3IPA2qEQZEQXP+ci%q?$>2zQzO;)BwTt5r5OSTlcPd} zj|30Ug%bb)HGc^)tlfERBt_%xMJXcg%NpTL^A`FgQ-3hSwt~$Nk+s-qaq5!OO_uEFXe*CPP%+#v7lOPMKJk2M?&z zmzHIjuW41Z?+CT;l5J>TcDO#IP?JcuS#C^hrg4MtM+RvDT=^&$k2SLFr)0}BQ~M7w z&Bd%ssUBHNH--2yr3cIz3g;h2-1?j;1Xxz~qV9tFV&2m|6a4evU*`JohsZ8R33e29Ee3pt?Qs2`i1%N;5|PT)l>A~ag?_#a@8c!kpV5-C3Cn^+ zA&+eHrGQ_iFm;=vOo}zL@6*fj|NiyJRD{Od-6vC)S_9>!tSn_gI_%W&X+*=xOi+E` zRSl$R+ejWq$Wc9C=R6FvEB{K)qi zXID9!zwnuQJv2f2hqP~31M5#z9=f9h4KypRIxx?_)TJw20;Lxr(pFBiebb<`r zxSR1_;V&N84pOAzs(;jq!sgyYHj+SmbbC3I`i-vW(_b;z%~RB-A#&92{0#GQC!}ji zRn0OLr}?EgeDfi6QlCG`yd4|6JLM(@elYFsZJOeS%uA%XeEt*>4?Dy|`2C|h5#i~B zrkpK~zV;wlXZ7E9&eIwL>SE&XJGS1cm59-DiGlJaU9wiCTsj3KDK#UJBFs*fUPn{M zWh##vBrdq;i`Sdm{$Y@qiEn*At`Xdls~1W5ZxDa($m|CtMJD`@MHY4G&bnyBdS#(QE$Ua_$vlalxXpUtj2_dLB#k09)HwEj>2Qoq(_1-Q_$&lsoq%T+&4;C83Fz+pI|q8VN$hO$yP1ySanZh z3xyjlCEy%$5i-Z_FeKlPk5H;`B`iz5XaH3rl+aA<@)AGH5T(c4bg`S>ipRjR8715; z(submK>_8F*jJ+}W&-RcfTV##5$ygg0O-(rnzcDpF)qvu+tzIDW54QY&y^a>RyBkR z@R=AeQ>99%p?8>tsD(6To`!BK0p!TEAH2uDguGo}zFL5rJ!XUslkYfM)RIPOC=T?# zKkPTx0WKB-9n{pKg!#iCYlfxHZZR>!M`*C1baWd64(`AXk>zhSTigGT)5(e`TK&%c zbf`)kodlnQ5&I8%L>Ud~^6x*$YuWCl7A9Z>D38|37-4jDENMl1+5jESF2iut(%3np z$3A=PYKZSPMTEcKI(y`J4FyJ;tyZ?DsX6`$-#9Q3)4^Qe{2cnmclW!;8AX*6EZw3N z=kaYO3w%hvPQ__q=|?S7%0amxaad6i386CcduRi0Orm{@=#{*gTT;I|6g57m-b&hq zscDv4WLo(PG#|9#-PkECD4t6G7rU8 zu4w#j+Y=axR%+_sYOKZHlXP%vt*xMHlU8_d|EBFWW<&cl5?YB6=ppPWxB&~OhGt1# z(LH#n6l8J5g@+Nqy@9N$eZnxz(`|B&@I$$Nk5Z7b)F#&Rqgn~`!d;sHF21}w(%}v8 zgAv8DhF94{AHEL&MCm}r)sbU{&jj8#ecV0kBC}r+;PqK?J!}{H;9SFv(%st)zNPNb z=@4G?q20b5r2F*cTu|&a-6w}TBk6GwY0cV0pk%XcNC`oekir?` zAmR}UG)((cVaw}2x^p)6kC?JbRm=Ve}T;_@DiyWzaOi5xn(;G%<34I0LOYUGR(>I`07%aB}%{y#SkCWvN|IN^$M=kC${BFD91C_P z(|z1+VD)vq3}4)lY78)3a%SziS+Ch3tS>Bd(}Bo62Y?!+WF$M697c<_y^Y@^y(WOS zBE_Q5muk$0vn<6WhUtg#m``!a6 zAT$pLjAj+4f6@KZ?I%)jK#~QmJY+PD^VTl)4MtrN_fSTk6W zBt+ub(ug5#mlkP@)9oz1Kkf#Rmt%NK-2hW##SjFhU(H9Gbnfq?eF{6SJ9yoFT5? zd)w(iXm)U78(+GQqPGtCQqlPkMp6CQkYcd*ggtJ2_sqwMt_josE}tHI3-V?%m-h6n ztztLdV(t$sFemgBDYW=TwtdeZ-7g8%t4!2*jEjUI_hYS^3Vjaa7Orh)I`rMLjKdO} z_|><&?>B^YGEmV_G=Wr7MrMS(@r9!9y!lkn0pzd18ZWd2x>us$Sr{sj@Mod;eYEa2 zN)C7Uwni;gO;NW$3M2x?;-b;7#`7 zb$6?9SM7=gBYt_V1T@bt$6!50yV>eAd(g!mn-BrarV{Us@zN7xO8aNYRi{on^s9Ns zcY)k;iFmvDifMMMh;oXL(qt`$H174bjeUnmIwO(nY9{kyUdxM9DiDF*4MFAW+XwBZ zw^g8C3Joxhj7&+<4@uad%oeu1YTJVdfM1&UoU;J#yPX+H{k+%X7zQ4T<#n1xCZ2B$ zxv`3gbt;A=pm*RRj0%M{Tu>3&(H6$lK?9tRQR1c%mo4i*t^49Y_!l=f?2Ak};n#P+ zJx>?1Rk%bGB5UMG;Q+}5mMnp~(~*O|JOFSz`nxr*R-jUTMCurjPnX4?l9#QR zYstW3afO70$Ey9|BPEsOSH7p^i*LhmPkDaBb}2D2GqL#nwQH}d|8pib~8nwrCG$nN`Dr9+Zs=*J#nP! zxqYn(!Q}0%ANzWy<6Y-A)o{~-&WkIM4+|6qo>RIt2sbF{q1i2zU_Ftz!Z;8&eEmN5 z4N9y-(*Y&gqKDq4O;q>wmS`J;Godv;g40gX7Yu0N>A0qLd0zB~kY!asNG=&bKHJCv z;H&iPoRIQICy2^I_>Q+`tHDv*S;xbB0}GB+VHtvAi|S2S!{%KC7(Yo-W+ja9S7J{M z6ooe;4|*X2PaC|DQBvhU?=368A`nz;@YbBvWAQS1iM1I{gWGm}MAp)zP3!1bu&`zCR60 z;^=v@4*_aRSJHH^8I_IZnabZn_eN>c)-vL}r~&npAE*LSt5#}_CX6?@d|Z3*f`nz9 zcU5*Z>P3GQaLQ3Lc^3}SQw2y@4Yc3k_qNW}MBC)Km9@|DxmNhd+Vj7G0xE|h+Mnw8 zFn27Vmj>H_-Vf$uyci5o);%_{tgZ_O}&K{ zrm_+4{V%F}kw=j-mxnKIy`#QIIIKf|TVj~doq>lx4Ja1sx==v@ul?$+`@fS0_hGNZ z-KMf;0J(OmB5IW*Zf>?&C-7rfvQd^IQI_8zdV@=_^iY6Idjw{0y=jzRBQ0;`K>NEE z*!(Cd$q+MzYlhc&dr87B&US5xxC2J5t8n?>Z`_JF1P)_2=7M`cCm=k3Zt=a+>&j`* zQ+i6EQqN+G54zc96TkJ8dyVCKy?39GJ|N6O0lKe; z)8NGwd+6ZUX1@=nt~rcw`cJMD&p(^u*ZYL zp^=FI12_z)F?=6+3i>oeVhr>K(>J#7-a91JT%o(sLEhLIHnB&&#Zx2lUK`JV%g`1u z-dK?Twqf4(S^XjreV6PXub_ju7%rse>9?<@1_L4{DnJ!q4+z@B1o*04kxGCbakC7j zaV014S}H#Pj%=r{l#}%nx7kMvvYl7;ep30+qv2|IrwLx(wGv{$=1)juP+xRc+^d5# zJoruxs{G$*0h55`(-q_woNP^ISvofXSzv}HeCkv~=cm=J(cZ#{{# zGxl!(H-&VWZRHwj=+{7>J|qdhSeV@V)-qaP&Sa4>MW8vfKj{lqXvztnJb5$!PqI<= zUn5%(zhTp6VdEc(K&K?kU1!3}K4`UcY4PA54md=P9Wc~M><)XiM+z2iRIE5O6> z#8uCj-D^kC!VkD zG5uw@=WQ#n1x~k>MN~D?SjWcS>Dh^Cfe6iE^t&OOd zu3bdb&EaRwlM?_1L8zSWc)AwL_g1d$M|l49n`CTU>W46#sx9iHH|htj}eOvvG!LS_QWDU7nC5o*kWme*-?;AB)r$ z?Up_NuEv<`n6FMA2!cDdo`zP%;7x@FJ=vs_plkL_0tyCA%sTZ-*dMdVv5`J{TRUIH z6u&VJ)sHl-cH5$9yw0!2Qktm?Z@0tWt4)X z94fyW@e&R;kV~6(6nu#F4GUEGL5l@fD@Bvbod+h4^KMz(o8^Jfm15$%a|^@+;$ zDamc`eqf@PzFa?jE8+)e6Y}{>HRp^RK>-Hj-=P8q3&)p4+d+iH>|X#H6gd_YG17e; z=~MROeME`=FPFxU58r+>i55vcu-ir()|dK0ZT_G=hv^+*MK_z?=N-9sWudPq{o1iP zMwx@SaZ{p5m++Ger3X8o95Dz0i19i$GXG+rqnP)Ne+u5833z6=cFO=_`ih`>P8{<| ziqIwyf!ukM=TMF2GI?rbJg6_`0~>de5e2E2VZ1==0w0&GRs#7D7?z@ZZkxYSz5J#V zyuE|9#n|%B{^IP@@mut6KN^?2R_bI*e{)pz9u9hoO%^g*Z^_?t(4w0*e@~9G=2Lx9 zk*`v^?iUz=+GkclSx2v|Z{!!&s6g%Qb&Kh>cc36^io;k>137M_BNuIL5S1i;=xZs_ zJo>Ya-Qq5p-Cae?580K0=i}>0fNEf#yo$023!sN%5m~WO&#C{^!XL9?5Od)pgCPbG z3nyDoC++L|<5ciO=1X9R&GO*=WT6F^^qWK~X04m)WeKU(ItLA4`NNl__!S@gGCpwn*iv*p+Qu@5RgGXGC^@D4y0rm}D2# zWF-3^(30&0F&@h7kmZCPk7dHx9mF7q>+A1z3vU=D_jF8@`)fX|HS2B}2!opnH?~)# z61BfSH}T0n5NvO)E!9Y%N|tioy?|20aGLOxR7`iAdfeETi!;>;uzFmI;>(Je(Spn1b~X_FfskA$`=VBPDGX9 zPMuZro_~CApByMnYGu~XwpZID#D2%8R6&!3L_Ex{n4}?Bh!0)^FyZ9HaQ4FY4_~d- z=rXKp?;pwO!n8izhU#S({!9R1AW#Hp=A2B2GC9V@)aH7yxNs2gW4!J(WFHSO-mEsQ z63tL0@2o>53P&{5GU1g>0)~aFdY!mVzE%7w#6fieB5;Z*9 z-FWm`DETwyB93Kw4=3X?3|z>g@S7EJcw zSuglQ4W|U3hjnA!Bojh&?EOR({q^PZ?_~=+jTXSi*rA0%U%$^Ga-z=yDI(znur~Nu z2O>Z`1t!bpk-}7$5kmLkp6`Zr&dybrXeYPnsatN6%rWXVn;JRJsR7wgkqK?E#N$H<)g35KCLa(7klTe3w;n^_#9|y&~7f-cA zx6-NC`-ly^2g&k&4|G10;266g;8V^-=<8{DpVU&tpQ6%*E8@ zzCh5iQmA@ckp$urSpVP>>_He?cY*k;Q*3M4s(DH(rPzLYyrgDY=gHqL@krw3>dtt} zgd(LrYDs7yBXXM;4WKr~$&PJ)(>^zU{s-LyN;shq|HBUK8zgt+NQG&O z_$uuF-(uO#|HkHi2+hul}aK_>Fch9bwu|C#f1^)!vgU((WZFS}XW zNveTLl6hkB6W;`JFgqq`otPEdCh}ijoaBZueI!lbivP9)-OrSfWwr5oq#z6}+6A1M zCS2WX;XhrHfNq(UEX*X$)f=mY?ho<`Kc?7A?hib27iIUN#cRBZ--EHFY9X;K%ETw_&0{oo-wE+t3HPt=IVf31-dX`*%&9 zge4`{vMu!;eU&fj*08Q&+^37Z{HW}co69)&3yIq%4Y`R;#hcJt9E88cj!Z!Db}_5P z%r|>+79D^uC~NfjG~J@_f${-Hxcc4Hfh&4CQOwVI1OBN**by4UO$WLka~@j}C0~0y zAx_$3;3A!z)Ushmif=|Mr^ISJDMdB><@oAnmGOu(ahU>&=D%kZ04L_BagIvu$(oKV zI7?LMOSol2^{VN-ZLneqHAD(YFg5;^xSUz*5lg}jmt^>>FCX)cJJc|f%pVCr0Vllk zE>s}Tz@Sh%-eO>RmD_xs0(?;FOgaU|2VSBGO$j5GUhiz(4d$tfuO3wE$0v~y+o%}= zMCqaO|j& z*Rj`e*VeEFRpk_Wp>^~BE>)lYw@}T@lMpL^WIC=HA=dYeIQW%8?Q*VY-mZq*cPSEGj*7!hMbYx%e)w-00uIVVb0U{)u6U$}uV zl^ETm7i+72>g<)h?Kq^ejl}$Is0VH^yVh-5a?1tuR&;HQZK{B`8+hX5Uv70e{YLD| zNz(OY&ietUoQEbdyT`#PIY1b^$|yRkTp1;cn<5ZXS$|E zWZtf0r<8L)hhgv(Oi_)z*s6bag5Co^hdC7B;@YgPDirCPiBK`c4lSHo&{ck~m?cqw z6p|&$D5)gw9rZ1Zk&OR&zK+A&G`)-CFe$z>T97ebPJFde+zQd@!rBRpMi*=J2GBI@r8aL+l)4$c&s^X|2{eL1b*yURFwN+_p^Zoa!xmtSD!fgz_}$;U9D=33SCZW zMmnAs=x1}*^JCl0$>sbgqwaTxww9|&Hr746^{aTOLcN3j2sDV|OznT1Os!GU+-&G; zb?^cXybY|ArkVA=pT~!vRg^mLVFFH%t2EVn9MvXy?Dg%tD*R0Ktbu?(eLWmk=dVHK z?fea(VWNJjm!6n%-!+zu*{W3ky?V6C1+;EWIiC)Tz89et_YdD$#N&d}9IRyS$Ccqb zzR5MpzyHMoq{T|dOVeTx>twd~F9;vj*QcXZ>;$b$ z>K5sII!O#b7!x)kpQ=UL%678DVSh^QX{#f~{0Zd%#(oYga@%1VpQvJNgzmL#@C%LP z_n?6Vx_e989qk?14m{KDyKV=fFUlRKgsX`$QkHMyKRmJeiQViGVz`75dS{~omuCB_ zc4_G#x>hi@s1Cm3;Y0^cT;J;E(ZEoGVniT*gd@(k1ga6ok|L*@teb_*7*u`RwI`Gh z%J`w~jV)q*BC*C({;oDxJMwkx@WTG!;8b`?7Z%-in0#LJ+WFCN=w_2=xCM}~}G18;9!Zr*z%A=ySSZoOkkcHYu(`|J9B zcXVC)k4LcW zP03etW0Wap{LrQr?l@2S-Ij4rIAmOAb-JVtA4oElB$i8YxaD{W)aWoWNb`Xq=s!3j zPw-Rko7fJn&GUS^mr$a_VBeXzMBN1)5!igGM66uv;ahW|>+3*(Ex}WbBH2Cgw`^O- zU9gT=)0XC_@J`~Be5U7A>=`N5&tENcHDMr@a_r$Krr8(c6lD=;Ggj=b(~ z@IcX+)HMOPG0Cs1Yw%?UQMxHk%)fPf4R-eHjaT$tpU(O>+4t6`xV5@%EPOppY&aVk%C%b5)@67tt z8Xv-a>~DwGZ3I{76fxv8uHW&(1AAJw$#2ZRpVok!{}(2L8Od4ZJO|p3P{I-zLchgl ztGu`$`Q*94DkFK@AGmm>Emf>CralaN=IGf+=dPS&IfaDUL*an=p3lUXKO)dvB!pj* z%?rvf)>V=#RdFsqJ%4XN8NA4GCKo&Y3}DaFICz+*3TQ_W6P-l;9rG0$t0Rm+b>_OA z8z77#r_Sn>Joni!i00tzQ?JF^4-ko0xoZs#oPlRAV`_+WDl zQFXd#mI(@(V-x)r=q=-o_UKzfT?4+X=~&$57>FKU$qSyl@B*79zQ*c8F`RcB$jYPV z=z7mnz{R3^Y%IU==(?&_BP+e1s#kf;BpY)ZzSw8&$9f{i)vf*|goYqGikQY{y`O?@a9{>0;M zQ9kb@CuNL$fQ-~-H#D7@EoLDO7TTKmh#1OTKYXb@QY<2V$X2;Vb~l~xMmN9H3}K}6 zaS(JmqT$9Mcvy5Ir^srT^mP#2Mndwx@<%@^Jt@}*=<4W>@GPJ8Y+@vDLj#S5~)n=i3pi8~;p2Sl~)_sIo#IsI}w*Ux{2Jzlvi}lJOwLS`M;7H2^Ip zP!NE6*pAC-TN{pNf5ob+_O|^##J;G8DR@{cCr7!x5(U^Dw|cP=qH`RJgkM-lO2^=3 zt{qV>w>N~^g{p$(in#P6Hxi8kn2;-XZLHxXKd(o9y)2UFy12cX85_#$0G!}=4ngZz zg-^fUxvOp8fp(v1<_-@xDj4I%)yx6CXI0d<-?|z#F{lD2e-flPA0VhD*E9R-NRy?e z2B&Ibt5F)xye6dngo)28!Nmxd59)d@cf9r3gj5m3l8LkVJjobfNwkT#@L5$z8Vkh%QTnz1#Qm zr($tjSj_Xrpts*pcYr<!nA? zUSz)kNM5`V5&eRMfm>0&Mmm*mW9nX4=Z`TYv=n3{bd%Lpuu}0pve8cP<_2n*w;;)$ z%mL`Fi$W=XX1bz%f1MAzx~NU-wQ(+i0o&}`b-hWycxl>Z|H#p3NYhf8g@DnuHQihs zMqu%aYR|7nS8%d)Fjb|T)*?ijVgA&vdXzy84`~A8E@b57ywcZ3Z@O3jaKqDBR5Gca&3*Vg2p3hyM!j#ay` z3$u*dpu<@C7^=6ozPyZe-Dlu!3GUQP!cdXj1@DE^@$2=T2iL_a0&x&xOv_SYjB?A_ zqRHj@b8VCB$AgKtCUw0U@JmQsS7t=jO`%^sWg4D&vuu=?Ci=#G>IUev{v_zp$HQJg zi)oDGee(+P$V6~QK|z{cT)22csHQHPbf2uLTO{a{zH*6(hu3?LS@kpPZ3FaR8MqEw zJ8{6MMtG-6-_*jf}?r{#g( zN)jBf)T~*4sEBiQqU%Xd*)*0Z;JJoEnyg=iC_&Y1lED%6vFEvnV|Ylv{Y+np^9{jb zYBjSa$GKfY?<=I|Q51i$?Vvm`E)0zy=z-sMPF*Z6R^Pu)o(8$~CehsAQEn7sT3}Hn z6F2}1)>$jx#uBu8zf!OWLaT)|ahW5V=yJ404nTGN!fXzn~ zGy7NBcjC8^Aq{WV9_s@5fJ0YKcmc~6%c#rwHuw;=4_{!Pf=kBew0^lL`H~K86^gzl z1$;!ddS7|1oIl8}z|hmHnil?}0*ua#{7@`Y&@8vMLIZ?V36^ID+j98AqZD8TpI0F4lBKI@OLhyHd7+4M}f03}<(L-d_Ol^1Tb0O7Fx zGy|;0zqYQT4$++L6@dKN;$#7Mi6`%bdUT$?@60dP^CLx|W5eRQLiZpjI|6)Xw=doa zOy3;Niv>g`@YwOt6-oasDVku5eYSXUU*V9R+sZyi?DMbRRn?tLcW0(3S z1<13y2tSa-gDv(|Nve=~Lf7F~xu} zw~>zX3(0~x@%BX6dOGBxKZE}|PNl7COCrz9=$4;WsZ`BfDTzhqg}4E?K-l7}2Dccf z90>H*2|jXvrVdP(+{a!ILqN)#RGCG^2%okD?Y#|YiD1({`xl;fHc+NJAjkhszU!;s zo$FN$BUq>VA9dU@?~rK`QEG>x%yl7M>CAkLC_+(=8GQ~qRSR5M@0SlwCT9r2$&ahB zF2veq%f|EX3CG8>ZkM%D4gAp`MEs3xn*Wp2fsHru7@zq3CdOrabAo|{uLFK0CNBrxr@PNQO&wBkEQ!0o57pw`N zw@HfUdsEMfzYn!p1!GvEuH*1bN+**c4z@qd&t$8jw4Fk_n$c|BHY4RZzeVufbA% z@^Kt7A31ooLc5{+ENV&+KHxGk9b|dl`Z|*WxJ-KQD$0vnhS`6j#qLxr%#l9zhw%>^ z@;;ro>mNFqq>bA!GnnDu&y;<83d9esT5Y`^qwI-em+={=D=Aj0IGE7)>ezz^HeuNx z3lm6T10o3;gzn??(f;y-V(h=*c?V+sCcD`Kgj;0(IK!oesb|O30)gwp^lU-s-wqTd z6g`h(cH{%o(d6NnfyRW~(;iWu%DyWj1*2Vs@g`%k)|LVW8{%kbQrF|iObp-&wqTL4 zko*}sN&-1(pvkE*s|=ff!bM#4rbrbu?SO!tbc^%-4?N;t)=B(&%Xps*E=$7kxs`3G zJ}I^pkUvnIU;jNjP;rH8Gi@Uz-@o*fKckWk_r7mkzmb}ohBmZ#5 z3Xgz^2>Tx2FBIPfYTZ^dle(!;jr9ofXF2t5XUR$1`aI?IR+-_KAmr=8JMkIDobw0% z^Q$E$lzxTsa^!>zqht%wjIXbUV6uv#gt^Mj1epHB>HXaICBVaG*pCO-rfNZ?*S)G= zGfvU{<~@8bf_o<6ZLU5hh&3#4Tc6^S5O6)I?zfGf2#weM8b2{x!5Y5r_IOsu#qPBw zS?m7*iDZKL!EKxSJdg#2{SgA;$$6?_g;*_jY;vTV=@WgNZ7+thustUvBbrp}i84&% z$Ykb{CF;n1&!;}oQwE0)jR;p_G>r)8F2V(tShiOcJp8=SR8@=)hJnyt@#)Tfzv2t5 z<>$Fg%kf&mk{65oh%&SPznUEXm_=h8_ny2{QX&GtsseSUl9x#k zm$vJr!RtlaySlR6#Frlf*uG~*V$Y>BLZj6klZ|DID{EGS*}I=oj~Tc)%6w*6zY6lS z^l7pqBZaVc>^1)g#7Ark%hI%)9;8Q?1};9%SSh{ z$a2R{Liv$T?plS2y&t@od?*h7$M(`AGr;cisjBXJW?4AnuCbp#DSX-YbHg!H@>x zGN!#OR;--^8c2bD>x1CO6b_q}z3S1`z9k7;-Tok+rlsu3umjFXPoCt5Ls)3TS7+VJ z?Y4=`gAB~5%`si1`u#t(cxghbou*Y;oWrIZS7q0Q(}Y%XU_X=SJDxs^{k0eW4y_z` z;txhzdaM_J#H8RAlwBq7ax|+lnFCnWHI`bN?=wsrzf8zDCa_4f&!)ryPfKA_2yVSF zux>s-qoxp?gjBz{09mjNYn9=Vm{H zG{xmt^Szb%fJC`;k(gtUpO1u~VB@c#kbX-|83=R;D;@sHz$qOWa z!jRgM7&E5pdhV+)=wUzxJpYxDKaZIUnO9DIc=T$nDO%Ikw(nebSof zK4;|4NA|qr0G@8(Y1+TP_RN+e$G|ndmDg^zt%q9j^X~uP&OM>tbM>2hiLAZC^5GN} z06!6MQh#+Ht{0r8-lNHSNzo|u*qcoYwBpe}H|*3+F4rsvcj%FDb|YPA07Ffx9jSL= z^R^{Wg`Y=HRoQ*0J-qH`*?>2yZ6AsIQ`5fO4k`gg+drAAE)D-iem9+qH0vM;7*{M0&q;ASV)K7Z$3wl% zhIO#}dxFa2J84vxAw%>0_nmR(@wB{<-{#gZ+}AM^je?a#I>g~P;`V3hxCm%;1B=b1 zud8TFARiSt@>`i!u)^A+-Si5t6$N|<6I$mj-p3T7r`0D`!HaMzB(4c!$e#ia8?!R) z0=lSWr{?F+j*u&RtGf5uade2ZvDa(2?r~E2xt1%N%gWL|1&FYsu_sTKW1IM6KmJZG z@?8nEGz`{(J%snf=5}?B3)GNprV8wpzquIk$F|IS*7whXxII&%c1;PUJ{;yj4%`b) zJbWZDR#HfWgqU>FVJ*XApf>ASu@)g$uWXKlxvKW3k^UNQP`}xjLf9wOOt8>`!;n(+ z(JIy3+s`MkFX0(>zCu-;42&`ZE;#!ND{Ve=(;;?!)lsh4|Lk*x3XK2<)my0Zsfrh3 z+3J<7EfIlcADBD>elW#ChrR#84hxZEtORyrhc(AshY7O1mIHf|j7&#~6zVijVvP*YTrXIN9rbto*{#Q?50aaBO zZGGsH?nX+wyG0R{lC}Z0(TwqK|6;v4+-&=Z0Qn?7`qrft(g>JLgJ?c_xBY^_dUx}D&E;^brAkh z0f=5PMNJP)3edKVz7z4ju~Ec5UA-$dJo+njzsw|dHJ^JAQ&fA)>$9^ZXVb${^bBH$ zDVpX(gUKH%K{KS6wM}?rg!g@3E$h7%C*fAU@|7l?-nKz7t#b0lnkHSjgFo}n%9H`5 z;xEfqf=Tt+T=VB=Qz#K#ZD?Wgi}9%Ov3$}{Oizpi!KKv@;f@b9gCSjp{p~J?=?Du-V$?d@T;gG+7CU=vyX9W+!yPI_FjfQAq`n1 zsTywqp`@`5uLTM3xDoT_Y;yirlW8^k6;;FG9Caz5w%W*EFruhtj!bV85UBt43^%Hg zAF*)O*mbPRyAT3nwr&wsZa9lSftbflb{35atPYcSPqWwgyn#A3?K3{5RhCU5Uh zp~QH*l915{-zGt(!FZdKm`t9N$_m6JH#Q8>P{whxhsJ640Ol6F-;2+#SYCMPjls)j z>Jfc0(sPSZk5x;aCHa|nN|mVxV`0W6Ti}TVyeu*``FEhSQXro`c7?PrXM#qq%>}}i zgq(?d&P4O0(Swq45_!c4Y&_>Yz8UgIFP?wy{LV+@_zc3QmE6YLE5@}CPq|KR3V zAp%V*+mBuF2|BBX=bFo_?`yPH zYnD)EPr|dRm|b+7u8k^}gqjvB9Ix~p=HZ-84$%F+6Yyxd9yXanT#rnL0Ryc<879U+ zAr9ZZ9j20K>-)d zoDMMy+?r!I2YbHnYTUEk_kA1bu;!G`1s5s}A*kG&mAUsio}1V6v(-?9UxCHY!^A^F z?Bl)%L|4Jqj^oyv+c&hK?U#bpKHKSHflX!tSRGgS)Kqen7hWvD|)%x`~Pjg5I;OIK>SZ7b2e&?e_DK%+V| zTuX)KHDCR}HK;OxrEt>jwo{5kpo(Y}Q}Mh#U21f%RJmu&H{; z%CpbYI;Q6r1YRj(L5YMB<$`~9WV9!Dyi{v`e2A3rPHLTOm&6S zdaPKzyPC%lv&f#fcB1UqHU#2EYQ+x{+%L}3p3xCZyale&vcvFJrptoF=mTqD&AriI zQVXT2@l*1I@V?wfc%%-LNd?C4aPHI2t{T->`Ztg%0pP%@_tr zBs(@P?!1uQ)!nqO%kM=fkSd*CG@GL(9a+IK10PeP&lsh@->cB_A~jzGVI zV-&wjI?(U56gGU$0Ggo=AtRy^7`Rv$Jg8%b?%aJq9&qO{Vp^zOob!FJ|2MEp13xWC z#bt0;T(Kk=+cxL;CeTK?bpD8^+OM-XG6PBnvDO5Vb>-MBq)ikuy*8KP4@W;_N;e^j z@G+~riHpKPh_O0=rX8oGBn0psf}HaRsauSAHKGK$guxj?l8fnHedM$MSW=}jXd zSS&~ZZ6?3qf!dIz(!v~ z$TFF6BlS=0^Y(IWVatyl*a5&%-kmm$%I>!}3o+doKq{K7dEg}G=u5d3YKxv@uRmBH znuP^x;5|~vpKgY(5G5$~p)rMyxTg=@YUsVkWR#j*-`||o1%-|O>5h>Kf$a$C1Z_~# z?r26@GuUk!wEqZ~MR@S^-%y^xu_4ZU%X#x&Z)gQN^eylhO_-UPpdEtb>omWE>utCU z57@0419rPsH@j9}O=>#Qk&EYQwx%11fJt9Nl4yV#kL$Oqwvk4#jtJ9j^JzF{uh~Sg zvn$tu&m08yQZ!FXQnlG_Qc1bACGcSW^XA<6`o3u($DEt0;?^mjSq(~0n040S-V#~` zt?4!q^t7IN{}-#8^^&>pqUu@cZ+-VSal^W9a;3zr4i%$kX;g`^*iCT; z-`~GaYgCZcS@*W|x~m6&!O2uW}Hx#T4S-Zk|5*Yk+f0-O}W-D|jmT z91vO6Ul&{xz%V`Udh-f?ETvWG4z=s@C>`zBVy$MS_rxYr&aH~?A5*3J)vC6YDg$TE zEeb}ESUdkc&K*?Avzr`Ni(eK`rD8BWM`JVlw5Ol{2}PS&PI>n8^yeypP4lXgw;Ogw zf#M8Ody(LSnolF$or0$y+T~}T>QDM#%z-zH-eAG9 z;+e1Tl=`I|W7m=LL~RImdykZ+p-n<^^`##7FI}++vG=_Usk);MJ-O>?JD|}rmkTMR z4XxQ1tgN)x)~U0QeVF-DzR7+Y3}x;qVY=>Uo$H?X;gX1lphRbA`2iZ38BN?}C7hcQ zXrMWU)ca#A=ToQWl7~=~GU2O5anqxF&XzktTm5-P2y$qHkJ==Eob|!@Q`dQR5blg5 z8DTL1;d?wv^4v>dewVT^(Admd4fMJDYLa3996`><9OB?6;!5?02OX}oEQ~8#vpo;R zcE>>}%pCqVr+vGDc@T8=#jKuX?fnN?%Q2Oa7hf?>IEhvIrah}>W78W@iX^!`N4T*iO$DTh=P(Sc3!nM85r@;J+9<><16p;lo3zxvUug_uWcGd*3yZ$<6ym z=kEpL_d-zdwwJ2!c`_ZBxW2_E0Igzj#N0HEWEiXZ88y6KYr`8gS-?5-MZu7L{O z_av}&vH&pD9`+mKB$5yrKopT;MA~ywFMJG7`Q215Vv9+A3E8pNsLm~~pA^EfdQibk zDIjRH)z4R9*~H%t@!gs7eN;}p?GNLg9Tg8wd%SHB{A_FB)zQCV>FukmE$mePUZB{a z0)if!mC=@!hJ%Um6lhT{!#7yd4E_`%+1!)>g7p2*UkB+op?#bByyRGb8aDze!jwta zoy=MEX9eGDOvMtV-q!w5LC4})iwE&>rpM!%VQpYhL7X*9j=NwhRg;QA`s`6H4@nfn zPI+4MZMx`Ng&0GD?@@X-sVj??nN#jtzMYZjC44Vee|{(+FLjFslfPqIO4i5aW8@cp>H?Xjl!l3PhA6ODDV$g9vg z76fY)2#S`}Or#vj%>F7wq1GM`G-OMSew@FzO9ZI4JO`g#=Wtzyw2hzs$eN zK{?L(LLJ}#O_!p&#&`zl<_JdJ@~~5~h|uF}j*WNALty72lH(`@o&C+1?FeQ|(<%KATR6DZsX;uCKg%M zwyJa;4I~Y0B5S{I9hDpFI8;uo1Q&mw-#eB2@Kffu zCw~s@nbdjkIXtr6btCVc`)TXKHb1p^ZdoHRpkB0YVM7SqeT)q8i@uiMaQkK8?^wMW z7`&a>VlD6H10TgcJt^Grh@o*1yDD@)xtWy1==yV6E4fS7wgPMVb9wqGPXU0TW?nz9 z9Yo4|hJW8wjM|XdFazV9jJP2B9w;BSTsgA4)Vo+np0$I<1V4Rj>Ulr`X|`=Ho(BQy zOTBEjM9STanBm0O^X5VEs~$d__0_eYq9Bjpx;eJO(&1g+lolf?5Yu@$nwZA)8Qt<- znk~%!F6${r?bVb>Lir$`WUM}GH^J{Bu(SNbsb2*a4(=488J7w8{Isae+329&;ZfVu zU6{{!y8+c~gD4H49VLujF@xjW-LcTBx< zQPag*;+az^E1_I@`?#II`Ye6D7%MwNxUBFJ2oO6xbSyxpa@>Kn>~vV=FmrUxA$Qf$bC&n|IVc%^({j+A3us*N9h}RL41wHMlQ@> z0>1>Tns~Wd9#)N!A$XV+NNDi$X65EOK&RfQm~;mE4Nd&cTZMR4poz>7+c|CovDero z|AJ@U)Va049=~^>easI9?7IV-)^NI{$=`?lUacpffvnx?-i&{*iI6YOlm{5ti2{ps zCld!hdc&oS0gn#y?U6HW@aoJP4M7V{_eU7Igsm02Un zZMC3u>$kHj-eatl1E^FZs=5bC=yP;(`3M{MCeZ0{+~B|CM8k<*S=!&R)4i~6e0^!6 zTo9V_%aI?=sC&=Y6Nxc+7~q6x*tB(^KK-~d(IFwpZM_RHI0Mb z^v1VygT&>gK^KD)Jb&5I=|eG)Wl^%R^Ph*1FHbA3_=^KZM7F94ZFSjabG*lF!}i5w zZ*;Vbh-N(BC5-(nvcv9;GN|1kfuG8wsXmi-Z`rgBsTV-hefrJxa^1uwn8pSG)A3f( zNIJI?Mf}fwav8Ep`kVcmzr3znbF}|+NeNa&0&VqR@y`F~l0cs;RPy9Z*w98wH0!{& z8Jg#npkBVF=MaVZ2!?s8tNJ%Il81$Wnt|A*gXu-%OidfNSnGVlW6Z)vAz0-OHqMpC za+RAfq3z}3blowT>>}P#pm@zK0`cg;Wr~GcZ&vuUI5`*YUHCX(V;|A57i}B1l0ck} zv#8=H&ukZSCQyEPzVZl^Fm9+|o9nTqY;%YmJ84&%g~^OdJ)jp0ZFMcWnh38^yU7H_ZilloIz6 z78;oLm|t8KBDvlQrD?+{bR{{t)=+^OL6hvK{hrws%=I@q8g{eLunB#y;!2F(;wzNy zl9mTmVW7-FOGp3GVZFjl8m%DrsXCT(^rR-+W^ETRL^xhl2G^FqKAh>oK0_#EP zQ2gh)MDGSk1s$J|}v2vV4JHq|)ciU~PeS7e0Pn^j08Cgd8yXe>LMG&L@ygRYlv zao-h%T?Y|8eq5{R@tC*%cx`T0E&8t2ms`}_FrsS}_4DGg<4hn|>%nZjXMjHyvrR)u z+oIv!PbK4c<;sxTEVSDD{2?o}%!ri}rDK1q%UfY9aB~(OHv3U|sus_|zFO0kR*Lm+ zMGozh+qbnT)l}*`3oS}yS8yUtcxEvn5LTGiL^wasTOFAf@U z;s}lE=Vja#xMR%Rfv9)-_qnJcX9;_53*-Du<5=>K*N8I8^1+l;8LL2Eg4v=+e!yb# zKdc#vZVH|{w^`UI+{_W=gqH~~iYn#=9gKXIM_SJVjLi(4=c%tz3m^8GTWa{)ywvJe}SX5_15QWgx4P*$8XqX(*r$a?{2>S zbgwvjRn7=L9lx!KHSy;zJK!8XP=B{HWr5_ncIggQ<~WcCe3wQjr}>Y>mrh2dZoVFh z0lAXfQxB+7@aj3mNax)v5L*2R;j0$>kZ(x$O#Fq-%LIB!;;?vPtBWbN)t zE%0iQ;@uKW5}1v6{z>wm+fpm*`E4ijWM1dJ)1SiA9 zd?t|gg1oc8ExP*{Y!>bY-zgwrI>9VXujFNYD$9$aZYNrpyMsg6zpJ6-4&!Uh`tpIKX1tUS_UVv%kBq8g?cyqnsD>sMLAxi>i9pWks@z_{ z3)QME(JEj4%^`p8AtdFX)zy!IA<29lnu5A+@AWeeM7z7QCoMqUC09Vg%4u2u$N?sC zLG!0|{(D6C&sE*ATLUiwEQ)z;g8Uph#Silh;~VC3c=WZUbpYr}z{z=p_A`^9>y?op zjL1p^@Fb0NNH&5d65DFr|xyI_%4s#^s!2tw=s1?Ga z-7A;F_NI;?ADDmpRU}c_?OqBKNyJ^-9}e_18zGoBb3y}mST<5dOgAZ%yr;XouKR!3 zc2qVrf*HA|hZT>3xW^E<+eyO&iqvh=V$vPVWQ1n%@adpm`0?rI<4;?CEYtrX!gNmi zA7Jc95iKyOp4GcPhs_Wl9Sc1NbCkY^Lfl9oFi<`4Z~F|9>nqq7P_w-iC8WqQIh-3~HI%iT5g0ATz4SD~!>!ET=H|A&)A`w1R#EWN zIq%b&)JExVHszv@Is(Mjj!9XKy_#t%>wLb&uQLKHOUHjILLE9}IjZ6ja8gQkAt(^eBF0MO1g$_f)T0REl`oPad=bM)h%-A?I+m!O9nhBe;zV8YW(Jc_T`q_XlZ!d9AE(nXTK16#0pZCmZ6EA{nbHPc{9_=BW{{1LC z3uD)B_s$roibVW)v4(ZurC+t8lKA)1!Z8*;_i8*fqP)n>dt_kgxH+XnEm6r5hb95@ z3#8&M&c$i~GMlTf2~c0A-wQVuuL@9TYYUgb%}D_RuYi6-SOx6EO;%g`VfzoFP$7eD z``zY6iA~wxVguy%JV>sBX~MUnlsXaY)JialQOCvhU7;LG1Wos)IT&SMVD03x$a7`Q zQM>N46wJ`Nt-4NKAS!4;7Q}#{G5te=q1SV?+k)NKi;A+|AnCH(C73kr6jzx);DlWS zC-sTEVocojGV>t2fhQL9tO?#{xV4S_F{%&o{Ne`Z4h}imp}EMQtOit`K~$b`RV?=L zUan)D{p@98ik3kT^HkBej=dj*%s#=_G?G>=dhD94o{E0fJ^qL~tn7frrF>&}9Ib1J zu~kO}vqDQ3tG8)kg%YnXRg2o%-Jha_baLN(eDS2({hygkqWn9PG=%j4J zZd%@^SB8z>)J2JxOx+T&VzdV~?%Xx{3SDP1d`xVG67_AwvYP!zZWtcjH+4$+9w{80 zZVg9^2J|U+BAOse=^UY6%4jnD+v$*~po#}AB#`*RjN}NyM~{@vT4~f9Jsv~JCf2Nc zK#Q&8mj~fRM*#4-l(aV);daf~D&+Fx-$&KRmpvf$T>k~>6R~8$94h=A95R7Fhr2-Q zJ!Vfn{IFoaZzqdifa0TZh3Z{8oQ^)-n4*Ak|hCr$F^7UvJ*Bc3+iH7@k6WGdKwqZc>=RX zZW*k%iGa+u&g~!dyn$N0=@+2rqT&n0-Y{SXr zKo1?VR%g;8u}t-{f)fWd?)Cr+{HGJ!-ha=fJI`D8;W*ai$}`u^QjLwtVEpsfb|OU2 zdts_m{B|yn!D9LVYBKV-F}Hcy^3Qp8JbKTp?EQh$rLlu@`r;n}QOEdxrD=f@yLLv0 zyw4E+cM?qU=H_(t(Nnh{PbNdbbco2h&Q7bs1w|;E0Xb>1&z~MkgKxsktq7VhK`kXD z9zFK?zM0d0-AY|8!Rzz#7@=!#}h)c1yNDy3}+ z62QWY=;eE+^yW;rVDgx`inPK}W)LQ#u^&$UE3Wo0;&m%qTV61%{m?%H4Qs2@QySQC z;6-*dd#%!6v6N51PHa-q2uh#eF8TW*he`>AOb2uzD@Or zJT@omrXsN=#|ND1iBH6GnY1A}E`9`nRef9zX74+l8s^_ zFU`b@NmYXvUKrolivc_+e}IYiXU7Z8z0iypR8MR`j`Td=MC!@>`7;xuG234VJetql z+SX7LNnGcIgD4TQSoG(;00E=uL&RpXzfz5Jr&@E$k7AzSC%!dTf(16bsEM`{znyHn z_Qx&U8d*BcRy_y4ZY^)&fM}QVzZhFrugsAG*c=bYTP;_5)*LvTVo`epxg2~q( zR;ikc(J=yC~tr&JXM*LE3@UZvK_;ZpiF*>vZ|g@`sli zzdXxwKsn+#sDcxx)tW|Np^%yUC1|99^-s0<0|(tmBjIW4v%p?O4KXAPOJ-IOh^=`~ zRC3r3vHM`Rj`&Len-r5T9Rm|oA~kXn=hT?>s~T`|_(HEO>9gCltQ>{&V=RH__y2)3 z{C`1j->@txSQu8Y=h=i6Qcpu(iz7FiS9T#jYZO6BI6YgY8)Yp$o2>dTqA?`{M&&et zp@FS@@{ZSdb{Gg8b_BrN%yTavFH?Svysua($Bo;Zp&pu@9(at@z>6>;J;CgGqOiIx zBLdO2phwgkdf*rF>O1~UNOaC`)AR8nLG!lTzH$P|I69WC-1G6i2Frop`Urpa zD<9^%ImF4#5Pw>Te@sK5zh6X7O{uy5!AlGI11Q3;z}lFq0!vawvSa=CF)&|E18Pb% zlhq&uu&v*%V0$(~^quI|G?z>zpxv-sW(uYi&c*xzB}^lXG4wC8jqoDV+b!anM%5`r z+s#%oq~7w9m@pH!);|I-%2{7}WHjJt3$eaL`m}C0urp_Aj6aQnzTT(yLjcfHY9nKZ zvf9b9$pEBp^pgQbuSG{6mM`=kDT~?z;NJO}hb*%(Ovb@E-Drepyy42E1Y3})xAxKs zl+a$&$Y35iJ0lC-YV#D02Qv@pxONd>KNxK?0c;r^kDz0L8JFW7?2$H@N&J>Ah0HQ7 z_mm1R*=lO>zD0R)>o2VmKC*6g`)!FDmd8Lu;LHASNeV@`EGt{kF%+=m(&n9y;!ck{ z8c{-w9#TwyBiMzg(197I4b@5E{*jo(n@+D*z*g7rt|Dj$mUWV>l1%<%Z z@oet_H(vb|_92w-nH$#>mJ%vRSt^>&NJSeC5-RMDKxi6QXOv$zSvQdoPq{nLM_>R> z5&AZr&;k?S$|=us03T~n`efHZhFLoBQ3D7sg(x~N*OJq!qziVcf-i>ezc4!;Wz!u= zZF)QQeO^l*+qb2Vzn+LPmUQZ!<%m2F6!Ul~uK@bMR`=E0)2cOxPenXPrV{F+2#z-t znEdYMM4_Kfbw-;u6jkC2H24AL$l?C6XC}Kd@pU?>*g7f!l-eSq8$fAA_4Y6(6K44RJuhh_2oLzM%7f0EDu-yp*++kfnscz8+FedBxBAi*FN? zVrAbcC^5#iL7N^BDg90sp1YW8exu@QR_!zrER^-VE%#PX=^6h*;l!rhN}X3 zV{NNEs8B|*7MA?!>pod_k!bc5@k^D&SMxUuwkU=db?^6b@~GyC!{)VmxNLt7-n_GX zOgeMJzROD4@X|`XRQfb8xUz6B!uVVuFQ+UrgQt^>1_6D85a27CCU$l5<&{7bDX{_m+OQ>Gn)-^e z6_UbYK%vBfE)$|p6zO*hz30>37&p;mcO00e>-i1+4yMYpi_nKs2Ho@?fCrm3qJh?1l6xU~wfmbW2?VVm&3*i=6^ z54M)Usb$Me`~AMC^EV^g>lF1ot;JBonA+LcZO>0J)@5qy`SIy3sl)##B9btnFiOO> zGk<6P-Mn7lP$cd-b19=x6()wLkR=s)6yE&T=roMzZO*SmcpCVug!5k!wmUKZ(@ z`eV?1^W$J_OY>_y--lD(Kn`Y);qZV3$o9USU5*AH!_X9znk=3XO@xV`Q(1Lnd-#1% z_#Q6b_xFk^(^Ax|nW&GRuNB;vWTKC8ji<;B6cgT_dBlEFQl@52J)D`rms5Cm=HVVF za)0wVg+_adRZb#H8=wB&>%F0}Kuaa$)AQ1~+rs*Y5aeFe#B>IHeI=E;FpCD`o!XCu z$gbho84UPhbP$!=Z+hGwS$90oO#eqWBtU7FfKwNh8T%6Qr~VY*%{FOmFF}A`g1<4u z^CE}don}VFe!TaN)dPPZGUP<+J^sT_~usRbkIU>?ne$G@|=Lv!{$ZZ+Bcnn zOsQ-5);*9o{#Wi+T}ny*_UB}Fx88lEUhriRaW5!0nzCOXDtox%b)I_+XH@NeMkm&) z!%~h)lAvzvP8Ebb8j=DP>SuW1O9LMa7%7ZXcR z9?PX2*_(!ipSgr*MQM-&!}z#ouOEUQH?E^lS4Ck@bJ-w!htngxb#ft(N^clHttly$ zM&aiX?kNG@LG<`fy;r4q)m6mM50QMW8<;!2fetlv- z+Iaw06B5$caH#M!P6plp;J|aI$trhVE^-6#1u(xOMzdx(dcB4mI)wC`H<-K~*+_5t zitmf7{f&a7?ZnL%TG7HHBk7Kn%A_WBDCB^z)YVMNTS)ZwrLFCR?~Q`e#>`025NNXf zFaO2$y5Ek#j$VG|9O5{6E;l-R(yXfSWBTmE;1+#jw6{+fHilvz`=21}pT%{1cQuZ-Qw&srV8 zt-~Ksu#dQdrClxQ9DfAAz>Va4*{$G!LYVP=SC9wHsDH}dVHn&bJoz}@B`Vrz6KHz9 z@T=eqTrrhS+E}V28wfXS0=%bG6{wVXj|g5*Gjr3Fds8ivylF@Ug8N!KtV=PHM-aL{sWU$usw4b*rX zCn;7ThbF~-Q?;%ar`hg{rwNe0ebk2(a)6vtpL>>) z0jNf15&jP_Auc{CAcZ2Xmc3=SRcNJkCr-oJ*B*Yn3U3#mjvFgy-}kIy(Rnfxi4oTOIpRRS@6;I>k~EAx z^m%b3E%2UwWvi^GQ>MmBEbd zVXye`w(N+z{7*Q3)3Lx=A0oWm~>0h~3c zHk)D{E$jiK=7bqZ3D&ZuOhV6OVhasw4`vA>1vc6|pNg;Mw0c3F%s;MSUT@6~Iz*#! zKVkwzgHi>Laq7)Yc(%gpVSr3~3QN07ACZ>}<A8~tD^8C7K{C9xQ~W1nrT=e% zWO-;H38Yr@y#RQIUH?^YS>?Kyw|ZjvQ_d?a#J}3^A&E;?h^y(MOD$3c(Xe-XgQM&) zbRj8m`JFZ$3bI4Bsd*&A`#6)++ukjrkzaY(ouwBhZ8{fbucq=lglq~+SoQtG6%!$f z>dk-g0?HPb10yz36-3D63S+6E|0~U^IC(rb7|4r1yan@WBf~nROkxvC; zwZ^kchtAe7f;^$1R`yD${Mv{c$)07XCnV>jOjuAS*M3Fm!ibbDa!nvx^hgmyU!Yp$ zMcP?3c>4}-Of6s{XV+6=nA-a^X(vZ3@k*RY#{B!42b$yT9}=I`#SJ)2*hOFfyh62c zyi)vsf-GrI>`*pFktZ|{F`Dghnqc>Yp|05_00W=(qUFXncWm0!&2Q|giTk007jl)^ zleKUQZ=~E#yD#KlsaP>j{ml3AR`ux-|lTUvK49Uf0N)hW|f?SmiDN literal 0 HcmV?d00001 diff --git a/docs/images/screenshot.png b/docs/images/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..7ee1c393ed3f36f762061e6b0a2aaeceb0c9167f GIT binary patch literal 50595 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7b503de --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,74 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "physics-sandbox" +version = "1.0.0" +description = "Interaktive 2D-Physik-Sandbox mit eigener Physik-Engine (Rendering mit pygame-ce)" +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["pygame-ce>=2.5"] + +[project.scripts] +physics-sandbox = "sandbox.main:main" + +[tool.setuptools.packages.find] +where = ["src"] + +# --------------------------------------------------------------------------- ruff +[tool.ruff] +line-length = 100 +target-version = "py310" +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", "W", # pycodestyle + "F", # pyflakes + "I", # Import-Sortierung + "B", # bugbear: typische Fehlerquellen + "UP", # moderne Python-Syntax + "SIM", # Vereinfachungen + "N", # Namenskonventionen + "C4", # Comprehensions + "PIE", + "RUF", + "D", # Docstrings für Module, Klassen und öffentliche Funktionen + "ANN", # Type Hints durchgehend + "PLR2004", # keine Magic Numbers in Vergleichen +] +ignore = [ + "D105", # Docstrings für Dunder-Methoden (__add__ usw.) wären reines Rauschen + "D107", # __init__ wird im Klassen-Docstring beschrieben + # Kommentare und Docstrings sind deutsch und nutzen bewusst Formelzeichen (·, ×, ², ω, μ): + "RUF001", "RUF002", "RUF003", +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +# Testfunktionen sprechen über ihre Namen für sich; Zahlenwerte in Asserts sind gewollt. +"tests/**" = ["D101", "D102", "D103", "PLR2004"] + +# --------------------------------------------------------------------------- pytest +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = "--strict-markers" +markers = [ + "slow: länger laufende Tests (Stresstest, Smoke-Test der Hauptschleife)", +] + +# --------------------------------------------------------------------------- coverage +[tool.coverage.run] +source = ["sandbox"] +branch = true + +[tool.coverage.report] +show_missing = true +exclude_also = [ + "if __name__ == .__main__.:", + "raise NotImplementedError", +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..dd03d58 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +# Entwicklungs-Abhängigkeiten: Tests, Coverage, Linting/Formatierung. +-r requirements.txt + +pytest==9.1.1 +pytest-cov==7.1.0 +ruff==0.16.8 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aedbdbb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# Laufzeit-Abhängigkeiten der Physik-Sandbox. +# +# pygame-ce ist der API-kompatible Community-Fork von pygame (Import bleibt `import pygame`). +# Im Gegensatz zu pygame 2.6.1 gibt es dafür fertige Wheels für Python 3.14. +pygame-ce==2.5.8 + +# Das Projekt selbst (src-Layout) im Editable-Modus installieren, damit `python -m sandbox` +# ohne PYTHONPATH-Anpassung funktioniert. Der Pfad "." bezieht sich auf das aktuelle +# Verzeichnis – den Befehl daher im Projektordner ausführen. +-e . diff --git a/src/sandbox/__init__.py b/src/sandbox/__init__.py new file mode 100644 index 0000000..13478c2 --- /dev/null +++ b/src/sandbox/__init__.py @@ -0,0 +1,18 @@ +"""Physik-Sandbox: interaktive 2D-Physiksimulation mit eigener Physik-Engine. + +Aufbau: + +* :mod:`sandbox.physics` – reine Python-Physik (nur ``math``), ohne pygame testbar. +* :mod:`sandbox.interaction` – Werkzeug-Logik (Erzeugen, Ziehen, Werfen), ebenfalls + pygame-frei. +* :mod:`sandbox.ui`, :mod:`sandbox.renderer`, :mod:`sandbox.input_handler`, + :mod:`sandbox.main` – Fenster, Zeichnen, Eingaben und Hauptschleife mit pygame-ce. +""" + +import os + +# pygame-ce gibt beim ersten Import eine Werbezeile aus. Das Paket wird immer vor pygame +# importiert, deshalb lässt sie sich hier zentral abschalten (ohne pygame zu importieren). +os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1") + +__version__ = "1.0.0" diff --git a/src/sandbox/__main__.py b/src/sandbox/__main__.py new file mode 100644 index 0000000..62ccb72 --- /dev/null +++ b/src/sandbox/__main__.py @@ -0,0 +1,5 @@ +"""Ermöglicht den Start mit ``python -m sandbox``.""" + +from sandbox.main import main + +raise SystemExit(main()) diff --git a/src/sandbox/config.py b/src/sandbox/config.py new file mode 100644 index 0000000..64c4062 --- /dev/null +++ b/src/sandbox/config.py @@ -0,0 +1,440 @@ +"""Zentrale Konfiguration der Physik-Sandbox. + +Alle einstellbaren Zahlenwerte des Projekts stehen in diesem Modul, damit der übrige Code +frei von "Magic Numbers" bleibt. Zusammengehörige Werte sind in unveränderlichen +Dataclasses gebündelt; Tests variieren einzelne Parameter mit :func:`dataclasses.replace`. + +Einheiten: Längen in Pixeln (px), Zeiten in Sekunden (s), Geschwindigkeiten in px/s. +Die y-Achse zeigt wie auf dem Bildschirm nach unten, die Schwerkraft wirkt also in +y. + +Das Modul importiert ausschließlich die Standardbibliothek, weil auch die pygame-freie +Physik-Schicht darauf zugreift. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +# ============================================================================= +# Grundgrößen der Simulation +# ============================================================================= + +#: Fester Physik-Zeitschritt in Sekunden (120 Hz), unabhängig von der Bildrate. +TIME_STEP: Final = 1.0 / 120.0 + +#: Kleinste und größte Halbausdehnung eines Körpers in px (Kreisradius bzw. halbe +#: Kantenlänge eines Rechtecks). +MIN_BODY_SIZE: Final = 12.0 +MAX_BODY_SIZE: Final = 80.0 + +#: Sicherheitsabstand (< 1) zur theoretischen Tunnel-Grenze, siehe MAX_SPEED. +TUNNELING_SAFETY_FACTOR: Final = 0.9 + +#: Geschwindigkeitsobergrenze in px/s (≈ 1296 px/s). +#: +#: Warum: Die Kollisionserkennung ist diskret, sie sieht nur die Positionen am Ende jedes +#: Schritts. Fliegen zwei der kleinsten Körper frontal aufeinander zu, darf ihre +#: Relativbewegung pro Schritt (2·v·dt) die Summe ihrer Radien (2·MIN_BODY_SIZE) nicht +#: überschreiten – sonst springen die Mittelpunkte aneinander vorbei ("Tunneln"). +#: Daraus folgt v < MIN_BODY_SIZE / dt. Die Wände sind Halbebenen und damit ohnehin +#: tunnelsicher: Ein Körper "hinter" der Wand wird immer als Durchdringung erkannt. +MAX_SPEED: Final = TUNNELING_SAFETY_FACTOR * MIN_BODY_SIZE / TIME_STEP + +#: Obergrenze der Winkelgeschwindigkeit in rad/s: So schnell dreht sich der kleinste Kreis, +#: wenn er mit MAX_SPEED rollt (ω = v / r). Verhindert numerisch explodierende Drehungen. +MAX_ANGULAR_SPEED: Final = MAX_SPEED / MIN_BODY_SIZE + +#: Toleranz für Gleitkomma-Vergleiche, z. B. "Vektor hat Länge 0". +NUMERIC_EPSILON: Final = 1e-9 + +#: Umrechnung Sekunden → Millisekunden (pygame misst Frame-Zeiten in ms). +MILLISECONDS_PER_SECOND: Final = 1000.0 + +#: Standard-Materialwerte für neue Körper. +DEFAULT_DENSITY: Final = 1.0 +DEFAULT_RESTITUTION: Final = 0.5 +DEFAULT_FRICTION: Final = 0.5 + + +@dataclass(frozen=True) +class PhysicsConfig: + """Parameter der Physik-Engine. + + Attributes: + gravity: Schwerkraftbeschleunigung (x, y) in px/s². 980 px/s² entspricht 9,8 m/s² + bei einem Maßstab von 100 px pro Meter. + time_step: Fester Zeitschritt in s. + velocity_iterations: Durchläufe des Impuls-Solvers pro Schritt. Mehr Durchläufe + lassen Stapel steifer werden, kosten aber Rechenzeit. + position_iterations: Durchläufe der Positionskorrektur pro Schritt. + restitution_threshold: Minimale Aufprallgeschwindigkeit in px/s, ab der Körper + abprallen. Darunter wird der Stoß als unelastisch behandelt, damit aufliegende + Körper nicht durch winzige Dauer-Hüpfer zittern. + penetration_slop: Geduldete Eindringtiefe in px. Wird nicht korrigiert, damit der + Kontakt zu einer Unterlage im nächsten Schritt sicher wieder erkannt wird – + sonst wechselten "Kontakt" und "kein Kontakt" ständig ab (Zittern). + position_correction_factor: Anteil der Eindringtiefe, der pro Durchlauf + korrigiert wird. Werte < 1 verteilen die Korrektur weich auf mehrere Schritte. + max_position_correction: Obergrenze der Korrektur pro Kontakt und Durchlauf in px. + Stark überlappende Körper (z. B. frisch erzeugt) werden so sanft getrennt. + max_speed: Geschwindigkeitsobergrenze in px/s (siehe MAX_SPEED). + max_angular_speed: Obergrenze der Winkelgeschwindigkeit in rad/s. + linear_damping: Lineare Dämpfung (Luftwiderstand) in 1/s. + angular_damping: Dämpfung der Drehung (Luftwiderstand) in 1/s. + rolling_resistance: Rollwiderstandsbeiwert μ_r (dimensionslos). An jedem Kontakt + bremst ein Moment von höchstens μ_r · Normalkraft · Radius die Drehung. Ohne + Rollwiderstand würde ein rollender Kreis nie anhalten, weil reines Rollen keine + Reibungsenergie verbraucht; ein Rechteck auf einem Kreis führe darauf wie auf + einer Rolle davon. + warm_starting: Startet den Solver mit den Impulsen des vorigen Schritts. + warm_start_min_alignment: Minimales Skalarprodukt zwischen alter und neuer + Kontaktnormale, damit der alte Impuls wiederverwendet wird. Dreht sich die + Normale stärker, passt der alte Impuls nicht mehr. + sleep_enabled: Schaltet den Schlafmodus für ruhende Körper ein. + sleep_linear_speed: Unterhalb dieser Geschwindigkeit (px/s) gilt ein Körper als + ruhend. + sleep_angular_speed: Unterhalb dieser Winkelgeschwindigkeit (rad/s) gilt ein Körper + als ruhend. + time_to_sleep: So lange (s) muss eine ganze Kontaktgruppe ruhen, bevor sie einschläft. + max_wall_penetration: Notbremse in px: Weiter darf ein Körper nie in eine Wand + ragen. Der Wert liegt über der größten Eindringtiefe, die ein frei fliegender + Körper in einem Schritt erreichen kann (MAX_SPEED · dt ≈ 10,8 px). Die Grenze + greift deshalb nie bei normalen Aufprallen – die löst der Solver –, sondern nur, + wenn ein Körper z. B. von einem gezogenen Objekt gegen die Wand gequetscht wird. + wall_restitution: Stoßzahl der Wände. 0 bedeutet: Beim Mischen mit max(e_A, e_B) + bestimmt allein das Material des Körpers, wie stark er abprallt. + wall_friction: Reibungskoeffizient der Wände. + """ + + gravity: tuple[float, float] = (0.0, 980.0) + time_step: float = TIME_STEP + velocity_iterations: int = 8 + position_iterations: int = 2 + restitution_threshold: float = 50.0 + penetration_slop: float = 0.5 + position_correction_factor: float = 0.4 + max_position_correction: float = 6.0 + max_speed: float = MAX_SPEED + max_angular_speed: float = MAX_ANGULAR_SPEED + linear_damping: float = 0.0 + angular_damping: float = 0.0 + rolling_resistance: float = 0.1 + warm_starting: bool = True + warm_start_min_alignment: float = 0.95 + sleep_enabled: bool = True + sleep_linear_speed: float = 4.0 + sleep_angular_speed: float = 0.1 + time_to_sleep: float = 0.5 + max_wall_penetration: float = MIN_BODY_SIZE + wall_restitution: float = 0.0 + wall_friction: float = 0.6 + + +#: Standardkonfiguration der Physik. +PHYSICS: Final = PhysicsConfig() + + +# ============================================================================= +# Fenster und Hauptschleife +# ============================================================================= + + +@dataclass(frozen=True) +class WindowConfig: + """Fenster und Hauptschleife. + + Attributes: + width: Fensterbreite in px. + height: Fensterhöhe in px. + title: Fenstertitel. + sidebar_width: Breite der Werkzeugleiste am linken Rand in px. Rechts davon + beginnt die Physikwelt. + fps_limit: Obergrenze der Bildrate. 60 Bilder/s genügen für flüssige Bewegung; die + Physik läuft unabhängig davon mit 120 Hz. + max_steps_per_frame: Höchstens so viele Physikschritte pro Bild (Schutz vor der + "Spiral of Death", siehe :class:`~sandbox.physics.timestep.FixedTimestep`). + stats_smoothing: Glättungsfaktor (0..1) der FPS-/Rechenzeit-Anzeige pro Bild. + screenshot_delay: Standard-Laufzeit (s) vor einem Bildschirmfoto mit + ``--screenshot``. + """ + + width: int = 1280 + height: int = 720 + title: str = "Physik-Sandbox" + sidebar_width: int = 260 + fps_limit: int = 60 + max_steps_per_frame: int = 8 + stats_smoothing: float = 0.1 + screenshot_delay: float = 3.0 + + +# ============================================================================= +# Werkzeuge und Interaktion +# ============================================================================= + + +@dataclass(frozen=True) +class InteractionConfig: + """Erzeugen, Ziehen und Werfen von Körpern. + + Attributes: + drag_response_rate: Wie schnell ein gezogener Körper der Maus folgt (1/s). Pro + Physikschritt wird der Anteil ``rate·dt`` des Abstands zum Ziel überbrückt – bei + 60/s und dt = 1/120 s also die Hälfte. Werte > 1/dt würden überschwingen. + throw_window: Zeitfenster (s) der Mausbewegung, aus dem die Wurfgeschwindigkeit + gemittelt wird ("die letzten Frames"). + throw_smoothing_time: Zeitkonstante (s) der zusätzlichen exponentiellen Glättung. + max_throw_speed: Obergrenze der Wurfgeschwindigkeit in px/s. + pick_tolerance: Zusätzlicher Rand (px), mit dem kleine Körper leichter zu greifen + sind. + max_bodies: Höchstzahl gleichzeitiger Körper (schützt die Bildrate). + size_wheel_step: Größenänderung (px) pro Mausrad-Raster. + min_size: Kleinste wählbare Größe (Radius bzw. halbe Kantenlänge) in px. + max_size: Größte wählbare Größe in px. + size_step: Rasterung des Größenreglers in px. + min_aspect_ratio: Kleinstes Seitenverhältnis Breite:Höhe eines Rechtecks. + max_aspect_ratio: Größtes Seitenverhältnis Breite:Höhe eines Rechtecks. + aspect_ratio_step: Rasterung des Seitenverhältnis-Reglers. + min_density: Kleinste wählbare Dichte. + max_density: Größte wählbare Dichte. + density_step: Rasterung des Dichte-Reglers. + max_restitution: Größte wählbare Elastizität. + restitution_step: Rasterung des Elastizitäts-Reglers. + default_size: Startwert der Größe in px. + default_aspect_ratio: Startwert des Seitenverhältnisses. + default_density: Startwert der Dichte. + default_restitution: Startwert der Elastizität. + friction: Reibungskoeffizient neu erzeugter Körper. + """ + + drag_response_rate: float = 60.0 + throw_window: float = 0.1 + throw_smoothing_time: float = 0.05 + max_throw_speed: float = MAX_SPEED + pick_tolerance: float = 4.0 + max_bodies: int = 300 + size_wheel_step: float = 2.0 + min_size: float = MIN_BODY_SIZE + max_size: float = MAX_BODY_SIZE + size_step: float = 1.0 + min_aspect_ratio: float = 0.5 + max_aspect_ratio: float = 2.0 + aspect_ratio_step: float = 0.05 + min_density: float = 0.2 + max_density: float = 5.0 + density_step: float = 0.1 + max_restitution: float = 1.0 + restitution_step: float = 0.05 + default_size: float = 28.0 + default_aspect_ratio: float = 1.5 + default_density: float = DEFAULT_DENSITY + default_restitution: float = DEFAULT_RESTITUTION + friction: float = DEFAULT_FRICTION + + +#: Standardkonfiguration der Interaktion. +INTERACTION: Final = InteractionConfig() + + +# ============================================================================= +# Darstellung +# ============================================================================= + +#: RGB-Farbe. +Color = tuple[int, int, int] + + +@dataclass(frozen=True) +class Theme: + """Farben der Oberfläche (dunkles Farbschema). + + Attributes: + world_background: Hintergrund der Physikwelt. + grid: Hilfsgitter der Welt. + world_border: Rahmen um die Welt. + sidebar_background: Hintergrund der Werkzeugleiste. + panel: Grundfarbe von Buttons und Reglern. + panel_hover: Button unter der Maus. + panel_active: Aktiver Button bzw. gewählte Option. + accent: Akzentfarbe (Reglerfüllung, aktive Umrandung). + text: Normaler Text. + text_dim: Nebensächlicher Text. + danger: Farbe für "Alles löschen". + highlight: Umrandung des Körpers unter der Maus. + held_outline: Umrandung des gezogenen Körpers. + throw_arrow: Pfeil der aktuellen Wurfgeschwindigkeit. + preview_alpha: Deckkraft (0–255) der Vorschau beim Erzeugen. + outline_shade: Faktor, um den die Umrandung dunkler ist als die Füllung. + debug_contact: Kontaktpunkte in der Debug-Ansicht. + debug_normal: Kontaktnormalen in der Debug-Ansicht. + debug_sleeping: Umrandung schlafender Körper in der Debug-Ansicht. + body_palette: Auswahl an Körperfarben. + """ + + world_background: Color = (22, 25, 32) + grid: Color = (32, 36, 46) + world_border: Color = (64, 71, 88) + sidebar_background: Color = (15, 17, 22) + panel: Color = (31, 35, 45) + panel_hover: Color = (42, 47, 60) + panel_active: Color = (48, 72, 102) + accent: Color = (96, 176, 240) + text: Color = (226, 230, 239) + text_dim: Color = (136, 145, 164) + danger: Color = (228, 92, 92) + highlight: Color = (255, 255, 255) + held_outline: Color = (255, 214, 102) + throw_arrow: Color = (255, 214, 102) + preview_alpha: int = 110 + outline_shade: float = 0.6 + debug_contact: Color = (255, 214, 10) + debug_normal: Color = (255, 110, 64) + debug_sleeping: Color = (120, 130, 150) + body_palette: tuple[Color, ...] = ( + (239, 83, 80), + (255, 167, 38), + (255, 213, 79), + (102, 187, 106), + (38, 198, 218), + (66, 133, 244), + (171, 71, 188), + (236, 64, 122), + ) + + +@dataclass(frozen=True) +class UiConfig: + """Schriften, Abstände und Maße der Oberfläche. + + Attributes: + font_names: Bevorzugte Systemschriften (die erste vorhandene wird genutzt). + title_font_size: Schriftgröße des Titels. + label_font_size: Schriftgröße von Beschriftungen. + small_font_size: Schriftgröße von Hinweisen und Werten. + padding: Innenabstand der Sidebar in px. + section_gap: Abstand zwischen Abschnitten in px. + row_gap: Abstand zwischen Elementen eines Abschnitts in px. + header_height: Höhe einer Abschnittsüberschrift in px. + button_height: Höhe von Buttons in px. + slider_height: Höhe eines Reglers (Beschriftung + Schiene) in px. + slider_track_height: Dicke der Reglerschiene in px. + slider_knob_radius: Radius des Reglerknopfs in px. + swatch_size: Kantenlänge eines Farbfelds in px. + swatch_gap: Abstand zwischen Farbfeldern in px. + spawn_tile_height: Höhe der Vorschau-Kachel in px. + corner_radius: Eckenradius von Buttons und Kacheln in px. + border_width: Linienstärke von Rahmen (Buttons, Umschalter, Weltrand) in px. + selection_ring_gap: Abstand des Auswahlrings um das gewählte Farbfeld in px. + selection_ring_width: Linienstärke des Auswahlrings in px. + grid_spacing: Abstand der Gitterlinien der Welt in px. + outline_width: Linienstärke von Körperumrandungen in px. + highlight_width: Linienstärke der Hervorhebung in px. + throw_arrow_time: Länge des Wurfpfeils als Flugzeit in s (Pfeil = v · Zeit). + debug_normal_length: Länge der gezeichneten Kontaktnormalen in px. + debug_contact_radius: Radius der gezeichneten Kontaktpunkte in px. + preview_scale_limit: Höchster Anteil der Kachelhöhe, den die Vorschau einnimmt. + marker_length: Länge des Drehmarkers eines Kreises als Anteil des Radius. + text_cache_size: So viele gerenderte Texte werden höchstens zwischengespeichert. + """ + + font_names: tuple[str, ...] = ("segoeui", "helveticaneue", "dejavusans", "arial") + title_font_size: int = 22 + label_font_size: int = 15 + small_font_size: int = 13 + padding: int = 16 + section_gap: int = 12 + row_gap: int = 6 + header_height: int = 20 + button_height: int = 32 + slider_height: int = 38 + slider_track_height: int = 4 + slider_knob_radius: int = 7 + swatch_size: int = 22 + swatch_gap: int = 3 + spawn_tile_height: int = 92 + corner_radius: int = 6 + border_width: int = 1 + selection_ring_gap: int = 2 + selection_ring_width: int = 2 + grid_spacing: int = 40 + outline_width: int = 2 + highlight_width: int = 3 + throw_arrow_time: float = 0.15 + debug_normal_length: float = 18.0 + debug_contact_radius: int = 3 + preview_scale_limit: float = 0.8 + marker_length: float = 0.85 + text_cache_size: int = 512 + + +#: Tastenbelegung: Tastenname (wie in ``pygame.key.key_code``) → Aktion. +KEY_BINDINGS: Final = ( + ("space", "toggle_pause"), + ("g", "toggle_gravity"), + ("c", "clear"), + ("delete", "delete_hovered"), + ("1", "shape_circle"), + ("2", "shape_box"), + ("f1", "toggle_debug"), + ("escape", "quit"), +) + + +@dataclass(frozen=True) +class DemoSceneConfig: + """Aufbau der Beispielszene (Start mit ``--demo``). + + x- und y-Positionen sind Anteile der Weltbreite bzw. -höhe, damit die Szene in jede + Fenstergröße passt. + + Attributes: + tower_x: Position des Rechteckturms. + tower_levels: Anzahl der Rechtecke im Turm. + box_size: Größe der Turm-Rechtecke in px. + box_aspect_ratio: Seitenverhältnis der Turm-Rechtecke. + pyramid_x: Mitte der Kreispyramide. + pyramid_rows: Anzahl der Reihen der Pyramide. + pyramid_circle_size: Radius der Pyramidenkreise in px. + pyramid_density: Dichte der Pyramidenkreise (schwer, damit sie stabil liegen). + pyramid_row_height: Vertikaler Reihenabstand in Radien. Dichteste Packung wäre + √3 ≈ 1,732; etwas mehr lässt einen hauchdünnen Spalt, damit nichts überlappt. + balls_x: Position des ersten Gummiballs. + balls_y: Starthöhe der Gummibälle. + ball_count: Anzahl der Gummibälle. + ball_size: Radius der Gummibälle in px. + ball_spacing: Abstand der Bälle in Radien. + ball_restitution: Elastizität der Gummibälle. + """ + + tower_x: float = 0.78 + tower_levels: int = 7 + box_size: float = 22.0 + box_aspect_ratio: float = 2.0 + pyramid_x: float = 0.45 + pyramid_rows: int = 5 + pyramid_circle_size: float = 20.0 + pyramid_density: float = 3.0 + pyramid_row_height: float = 1.75 + balls_x: float = 0.15 + balls_y: float = 0.2 + ball_count: int = 4 + ball_size: float = 16.0 + ball_spacing: float = 2.4 + ball_restitution: float = 0.8 + + +@dataclass(frozen=True) +class AppConfig: + """Gesamtkonfiguration der Anwendung.""" + + window: WindowConfig = WindowConfig() + physics: PhysicsConfig = PhysicsConfig() + interaction: InteractionConfig = InteractionConfig() + theme: Theme = Theme() + ui: UiConfig = UiConfig() + demo: DemoSceneConfig = DemoSceneConfig() + + +#: Standardkonfiguration der Anwendung. +APP: Final = AppConfig() diff --git a/src/sandbox/demo.py b/src/sandbox/demo.py new file mode 100644 index 0000000..6b695a9 --- /dev/null +++ b/src/sandbox/demo.py @@ -0,0 +1,51 @@ +"""Beispielszene zum Ausprobieren (Start mit ``python -m sandbox --demo``).""" + +from __future__ import annotations + +from dataclasses import replace + +from sandbox.config import DemoSceneConfig +from sandbox.interaction import BodyFactory, ShapeKind, ToolSettings +from sandbox.physics import Vector2 + + +def build_demo_scene(factory: BodyFactory, base: ToolSettings, scene: DemoSceneConfig) -> None: + """Baut einen Turm aus Rechtecken, eine Kreispyramide und ein paar Gummibälle auf. + + Args: + factory: Erzeugt die Körper (und fügt sie der Welt hinzu). + base: Ausgangseinstellungen (Farbe, Material), die je Gruppe abgewandelt werden. + scene: Aufbau der Szene. + """ + world = factory.world + floor = world.height + + boxes = replace( + base, shape=ShapeKind.BOX, size=scene.box_size, aspect_ratio=scene.box_aspect_ratio + ) + _, half_height = boxes.half_extents() + for level in range(scene.tower_levels): + y = floor - half_height - level * 2 * half_height + factory.create(boxes, Vector2(world.width * scene.tower_x, y)) + + circles = replace( + base, + shape=ShapeKind.CIRCLE, + size=scene.pyramid_circle_size, + density=scene.pyramid_density, + ) + radius = circles.size + rows = scene.pyramid_rows + for row in range(rows): + for column in range(rows - row): + # Jede Reihe ist um einen Radius eingerückt und liegt in den Mulden der unteren. + x = world.width * scene.pyramid_x + (2 * column + row - rows + 1) * radius + y = floor - radius - row * radius * scene.pyramid_row_height + factory.create(circles, Vector2(x, y)) + + balls = replace( + base, shape=ShapeKind.CIRCLE, size=scene.ball_size, restitution=scene.ball_restitution + ) + for index in range(scene.ball_count): + x = world.width * scene.balls_x + index * scene.ball_spacing * scene.ball_size + factory.create(balls, Vector2(x, world.height * scene.balls_y)) diff --git a/src/sandbox/input_handler.py b/src/sandbox/input_handler.py new file mode 100644 index 0000000..eb6c88c --- /dev/null +++ b/src/sandbox/input_handler.py @@ -0,0 +1,232 @@ +"""Übersetzt pygame-Eingaben in Aktionen von Sidebar, Welt und Werkzeugen. + +Maus-Events werden zuerst der Sidebar angeboten. Was sie nicht verbraucht und in der Welt +liegt, wird zur Werkzeug-Aktion: Linksklick auf einen Körper greift ihn, Linksklick ins +Leere erzeugt einen neuen Körper (und greift ihn sofort, sodass man ihn direkt werfen +kann), Rechtsklick löscht. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Callable +from functools import partial +from typing import Protocol + +import pygame + +from sandbox.config import KEY_BINDINGS, InteractionConfig +from sandbox.interaction import BodyFactory, DragController, ShapeKind, ToolSettings +from sandbox.physics import Body, Vector2, World +from sandbox.renderer import SpawnPreview +from sandbox.ui.sidebar import Sidebar, SidebarControls +from sandbox.viewport import Viewport + + +class AppControls(SidebarControls, Protocol): + """Aktionen der Anwendung, die per Tastatur ausgelöst werden können.""" + + def request_quit(self) -> None: + """Beendet die Anwendung nach dem aktuellen Frame.""" + ... + + def toggle_debug(self) -> None: + """Schaltet die Debug-Ansicht um.""" + ... + + +class InputHandler: + """Verteilt Maus- und Tastatur-Events. + + Args: + world: Die Physikwelt. + viewport: Lage der Welt auf dem Bildschirm. + drag: Steuerung für Greifen und Werfen. + factory: Erzeugt neue Körper. + tools: Aktuelle Werkzeug-Einstellungen. + config: Interaktionsparameter. + controls: Aktionen der Anwendung. + """ + + def __init__( + self, + *, + world: World, + viewport: Viewport, + drag: DragController, + factory: BodyFactory, + tools: ToolSettings, + config: InteractionConfig, + controls: AppControls, + ) -> None: + self.world = world + self.viewport = viewport + self.drag = drag + self.factory = factory + self.tools = tools + self.config = config + self.controls = controls + self.sidebar: Sidebar | None = None + self.mouse_pos: tuple[int, int] = pygame.mouse.get_pos() + self.hovered_body: Body | None = None + self.spawn_drag_active = False + self._cursor: int | None = None + actions: dict[str, Callable[[], None]] = { + "toggle_pause": controls.toggle_pause, + "toggle_gravity": controls.toggle_gravity, + "clear": controls.clear_world, + "delete_hovered": self.delete_hovered, + "shape_circle": partial(setattr, tools, "shape", ShapeKind.CIRCLE), + "shape_box": partial(setattr, tools, "shape", ShapeKind.BOX), + "toggle_debug": controls.toggle_debug, + "quit": controls.request_quit, + } + self._key_actions = { + pygame.key.key_code(key): actions[action] for key, action in KEY_BINDINGS + } + + # ------------------------------------------------------------------ Abfragen + + @property + def pointer_in_world(self) -> bool: + """Ob die Maus über der Welt steht.""" + return self.viewport.contains(self.mouse_pos) + + @property + def world_pointer(self) -> Vector2: + """Mausposition in Weltkoordinaten.""" + return self.viewport.to_world(self.mouse_pos) + + def spawn_preview(self) -> SpawnPreview | None: + """Vorschau des Objekts, das ein Klick an der aktuellen Mausposition erzeugen würde.""" + half_extents = self.tools.half_extents() + if self.spawn_drag_active: + center = (float(self.mouse_pos[0]), float(self.mouse_pos[1])) + elif ( + self.pointer_in_world + and self.drag.body is None + and self.hovered_body is None + and not self.factory.is_full + ): + clamped = self.world.clamp_inside(self.world_pointer, half_extents) + center = self.viewport.to_screen(clamped) + else: + return None + color = self.factory.color_for(self.tools) + return SpawnPreview(self.tools.shape, half_extents, color, center) + + # ------------------------------------------------------------------ Aktionen + + def begin_spawn_drag(self) -> None: + """Beginnt das Herausziehen eines neuen Objekts aus der Sidebar.""" + self.spawn_drag_active = True + + def delete_hovered(self) -> None: + """Löscht den Körper unter der Maus.""" + body = self._body_under_pointer() + if body is not None: + self.delete(body) + + def delete(self, body: Body) -> None: + """Entfernt einen Körper (und lässt ihn vorher los, falls er gehalten wird).""" + if self.drag.body is body: + self.drag.cancel() + if self.hovered_body is body: + self.hovered_body = None + self.world.remove_body(body) + + def forget_bodies(self) -> None: + """Vergisst Verweise auf Körper (nach "Alles löschen").""" + self.drag.cancel() + self.hovered_body = None + self.spawn_drag_active = False + + # ------------------------------------------------------------------ Events + + def handle_event(self, event: pygame.event.Event, time: float) -> None: + """Verarbeitet ein pygame-Event. + + Args: + event: Das Event. + time: Aktuelle Anwendungszeit in s (für die Wurfgeschwindigkeit). + """ + if event.type == pygame.QUIT: + self.controls.request_quit() + elif event.type == pygame.KEYDOWN: + action = self._key_actions.get(event.key) + if action is not None: + action() + elif event.type == pygame.MOUSEBUTTONDOWN: + self.mouse_pos = event.pos + if not self._offer_to_sidebar(event): + self._press_in_world(event.button, time) + elif event.type == pygame.MOUSEMOTION: + self.mouse_pos = event.pos + if not self._offer_to_sidebar(event): + self._move(time) + elif event.type == pygame.MOUSEBUTTONUP: + self.mouse_pos = event.pos + if event.button == pygame.BUTTON_LEFT: + # Loslassen beendet ein Ziehen immer – auch über der Sidebar. + self.drag.release(paused=self.controls.is_paused()) + self.spawn_drag_active = False + self._offer_to_sidebar(event) + elif event.type == pygame.MOUSEWHEEL and not self._offer_to_sidebar(event): + self._change_size(event.y) + + def update(self, time: float) -> None: + """Einmal pro Frame: Mausposition an den Wurf-Tracker melden, Hover bestimmen. + + Die Position wird auch ohne Maus-Event gemeldet: Steht die Maus still, kommen keine + Events – der Tracker muss aber merken, dass die Geschwindigkeit auf 0 fällt. + """ + if self.drag.body is not None: + self.drag.move(self.world_pointer, time) + self.hovered_body = None if self.drag.body is not None else self._body_under_pointer() + grabbing = self.drag.body is not None or self.hovered_body is not None + cursor = pygame.SYSTEM_CURSOR_HAND if grabbing else pygame.SYSTEM_CURSOR_ARROW + if cursor != self._cursor: + self._cursor = cursor + # Im Headless-Modus (Tests) gibt es keinen echten Mauszeiger. + with contextlib.suppress(pygame.error): + pygame.mouse.set_cursor(cursor) + + def _offer_to_sidebar(self, event: pygame.event.Event) -> bool: + """Bietet ein Event der Sidebar an; True, wenn sie es verbraucht hat.""" + if self.sidebar is None: + return False + return self.sidebar.handle_event(event, self.mouse_pos) + + def _press_in_world(self, button: int, time: float) -> None: + if not self.pointer_in_world: + return + pointer = self.world_pointer + if button == pygame.BUTTON_LEFT: + body = self._body_under_pointer() or self.factory.create(self.tools, pointer) + if body is not None: + self.drag.grab(body, pointer, time) + elif button == pygame.BUTTON_RIGHT: + body = self._body_under_pointer() + if body is not None: + self.delete(body) + + def _move(self, time: float) -> None: + if self.spawn_drag_active and self.pointer_in_world: + # Aus der Sidebar gezogenes Objekt erscheint, sobald die Maus die Welt erreicht, + # und hängt ab dann wie ein gegriffenes Objekt an der Maus. + self.spawn_drag_active = False + body = self.factory.create(self.tools, self.world_pointer) + if body is not None: + self.drag.grab(body, self.world_pointer, time) + if self.drag.body is not None: + self.drag.move(self.world_pointer, time) + + def _change_size(self, wheel_steps: int) -> None: + config = self.config + size = self.tools.size + wheel_steps * config.size_wheel_step + self.tools.size = min(max(size, config.min_size), config.max_size) + + def _body_under_pointer(self) -> Body | None: + if not self.pointer_in_world: + return None + return self.world.body_at(self.world_pointer, self.config.pick_tolerance) diff --git a/src/sandbox/interaction/__init__.py b/src/sandbox/interaction/__init__.py new file mode 100644 index 0000000..6424bbd --- /dev/null +++ b/src/sandbox/interaction/__init__.py @@ -0,0 +1,20 @@ +"""Werkzeug-Logik der Sandbox: Körper erzeugen, greifen, ziehen und werfen. + +Die Klassen hier arbeiten ausschließlich mit Weltkoordinaten und Zeitstempeln, nicht mit +pygame-Events. Dadurch lässt sich das Verhalten (z. B. die Glättung der +Wurfgeschwindigkeit) ohne Fenster testen; die Übersetzung von Maus-Events übernimmt +:mod:`sandbox.input_handler`. +""" + +from sandbox.interaction.drag import DragController +from sandbox.interaction.tools import BodyFactory, BodyStyle, ShapeKind, ToolSettings +from sandbox.interaction.velocity_tracker import VelocityTracker + +__all__ = [ + "BodyFactory", + "BodyStyle", + "DragController", + "ShapeKind", + "ToolSettings", + "VelocityTracker", +] diff --git a/src/sandbox/interaction/drag.py b/src/sandbox/interaction/drag.py new file mode 100644 index 0000000..473c49d --- /dev/null +++ b/src/sandbox/interaction/drag.py @@ -0,0 +1,116 @@ +"""Greifen, Ziehen und Werfen eines Körpers mit der Maus.""" + +from __future__ import annotations + +from sandbox.config import InteractionConfig +from sandbox.interaction.velocity_tracker import VelocityTracker +from sandbox.physics import ZERO, Body, Vector2, World + + +class DragController: + """Führt einen gegriffenen Körper zur Maus, ohne die Physik auszuhebeln. + + Der Körper wird *nicht* einfach an die Mausposition gesetzt – dann könnte er durch + andere Körper und Wände hindurchgeschoben werden. Stattdessen bekommt er vor jedem + Physikschritt die Geschwindigkeit, die ihn in Richtung Ziel bringt:: + + v = (Ziel − Position) · rate + + Danach löst der Solver ganz normal die Kontakte: Leichte Körper werden weggeschoben, + Wände und schwere Stapel bremsen den gezogenen Körper. Das gezogene Objekt kollidiert + also weiterhin. Beim Loslassen erhält es die geglättete Mausgeschwindigkeit + (:class:`~sandbox.interaction.velocity_tracker.VelocityTracker`) – das ist der Wurf. + + Args: + world: Die Welt, in der gezogen wird. + config: Folgegeschwindigkeit, Wurf-Glättung und -Obergrenze. + """ + + def __init__(self, world: World, config: InteractionConfig) -> None: + self.world = world + self.config = config + self.tracker = VelocityTracker(config.throw_window, config.throw_smoothing_time) + self._body: Body | None = None + self._offset = ZERO + self._target = ZERO + + @property + def body(self) -> Body | None: + """Der aktuell gehaltene Körper (oder ``None``).""" + return self._body + + @property + def target(self) -> Vector2: + """Zielposition des Schwerpunkts (Mausposition plus Griffversatz).""" + return self._target + + @property + def throw_velocity(self) -> Vector2: + """Geschwindigkeit, die der Körper beim Loslassen erhielte (px/s).""" + return self.tracker.velocity.clamped(self.config.max_throw_speed) + + def grab(self, body: Body, pointer: Vector2, time: float) -> None: + """Greift einen Körper an der Stelle ``pointer``. + + Der Versatz zwischen Griffpunkt und Schwerpunkt bleibt beim Ziehen erhalten, damit + der Körper nicht mit seinem Mittelpunkt zur Maus springt. + """ + self.cancel() + self._body = body + body.held = True + body.wake() + self._offset = body.position - pointer + self._target = body.position + self.tracker.reset(time, pointer) + + def move(self, pointer: Vector2, time: float) -> None: + """Meldet die aktuelle Mausposition (bei jedem Event und einmal pro Frame).""" + body = self._body + if body is None: + return + self.tracker.add_sample(time, pointer) + # Das Ziel so klemmen, dass der Körper ganz in die Welt passt; sonst würde er + # dauerhaft gegen die Wand gedrückt. + self._target = self.world.clamp_inside(pointer + self._offset, body.half_extents()) + + def apply(self, dt: float) -> None: + """Setzt vor einem Physikschritt die Geschwindigkeit Richtung Ziel.""" + body = self._body + if body is None: + return + # rate·dt ≤ 1, sonst würde der Körper über das Ziel hinausschießen und pendeln. + rate = min(self.config.drag_response_rate, 1.0 / dt) + velocity = (self._target - body.position) * rate + body.velocity = velocity.clamped(self.world.config.max_speed) + body.wake() + + def place(self) -> None: + """Setzt den Körper direkt aufs Ziel – nur im Pausenmodus, wenn keine Physik läuft.""" + body = self._body + if body is None: + return + body.teleport(self._target) + body.velocity = ZERO + + def release(self, *, paused: bool) -> Body | None: + """Lässt den Körper los und wirft ihn mit der geglätteten Mausgeschwindigkeit. + + Args: + paused: Im Pausenmodus wird nicht geworfen; der Körper bleibt liegen. + + Returns: + Den losgelassenen Körper (oder ``None``, wenn keiner gehalten wurde). + """ + body = self._body + if body is None: + return None + body.velocity = ZERO if paused else self.throw_velocity + self.cancel() + return body + + def cancel(self) -> None: + """Beendet das Halten, ohne die Geschwindigkeit zu ändern (z. B. beim Löschen).""" + if self._body is not None: + self._body.held = False + self._body.wake() + self._body = None diff --git a/src/sandbox/interaction/tools.py b/src/sandbox/interaction/tools.py new file mode 100644 index 0000000..f5ec96b --- /dev/null +++ b/src/sandbox/interaction/tools.py @@ -0,0 +1,137 @@ +"""Werkzeug-Einstellungen der Sidebar und das Erzeugen neuer Körper.""" + +from __future__ import annotations + +import math +import random +from dataclasses import dataclass +from enum import Enum + +from sandbox.config import MIN_BODY_SIZE, Color, InteractionConfig +from sandbox.physics import Body, Material, Vector2, World + + +class ShapeKind(Enum): + """Form neu erzeugter Körper.""" + + CIRCLE = "circle" + BOX = "box" + + +@dataclass +class ToolSettings: + """Aktuelle Auswahl in der Werkzeugleiste. + + Attributes: + shape: Form neuer Körper. + size: Radius bzw. "mittlere" halbe Kantenlänge in px. + aspect_ratio: Seitenverhältnis Breite:Höhe (nur für Rechtecke). + density: Dichte (Masse = Fläche × Dichte). + restitution: Elastizität (Stoßzahl). + color_index: Index in die Farbpalette oder ``None`` für eine Zufallsfarbe. + """ + + shape: ShapeKind + size: float + aspect_ratio: float + density: float + restitution: float + color_index: int | None = None + + @classmethod + def from_config(cls, config: InteractionConfig) -> ToolSettings: + """Startwerte aus der Konfiguration.""" + return cls( + shape=ShapeKind.CIRCLE, + size=config.default_size, + aspect_ratio=config.default_aspect_ratio, + density=config.default_density, + restitution=config.default_restitution, + ) + + def half_extents(self) -> tuple[float, float]: + """Halbe Breite und Höhe eines neuen Körpers. + + Für Rechtecke wird die Größe mit √(Seitenverhältnis) gestreckt bzw. gestaucht. So + bleibt die Fläche – und damit die Masse – beim Ändern des Seitenverhältnisses gleich, + nur die Form ändert sich. Ausnahme: Keine halbe Kantenlänge darf unter + MIN_BODY_SIZE fallen, weil die Geschwindigkeitsgrenze gegen Tunneln auf dieser + Mindestgröße beruht. Bei sehr kleinen Rechtecken wird die kurze Seite deshalb + angehoben, und die Fläche wächst etwas. + """ + if self.shape is ShapeKind.CIRCLE: + return (self.size, self.size) + stretch = math.sqrt(self.aspect_ratio) + return (max(self.size * stretch, MIN_BODY_SIZE), max(self.size / stretch, MIN_BODY_SIZE)) + + +@dataclass(frozen=True) +class BodyStyle: + """Darstellungsdaten eines Körpers (liegen in ``Body.user_data``). + + Attributes: + color: Füllfarbe. + """ + + color: Color + + +class BodyFactory: + """Erzeugt Körper gemäß den Werkzeug-Einstellungen und fügt sie der Welt hinzu. + + Args: + world: Zielwelt. + config: Grenzen und Materialwerte. + palette: Verfügbare Farben. + rng: Zufallsgenerator für Zufallsfarben (injizierbar für reproduzierbare Tests). + """ + + def __init__( + self, + world: World, + config: InteractionConfig, + palette: tuple[Color, ...], + rng: random.Random | None = None, + ) -> None: + self.world = world + self.config = config + self.palette = palette + self._rng = rng or random.Random() + # Die nächste Zufallsfarbe wird im Voraus gewürfelt, damit die Vorschau-Kachel + # genau die Farbe zeigt, die das nächste Objekt bekommt. + self._next_random_color = self._rng.choice(palette) + + @property + def is_full(self) -> bool: + """True, wenn die Höchstzahl an Körpern erreicht ist.""" + return len(self.world.bodies) >= self.config.max_bodies + + def color_for(self, settings: ToolSettings) -> Color: + """Farbe, die das nächste Objekt mit diesen Einstellungen bekommt.""" + if settings.color_index is None: + return self._next_random_color + return self.palette[settings.color_index] + + def create(self, settings: ToolSettings, position: Vector2) -> Body | None: + """Erzeugt einen Körper an ``position`` (wird bei Bedarf in die Welt geschoben). + + Returns: + Den neuen Körper oder ``None``, wenn die Höchstzahl erreicht ist. + """ + if self.is_full: + return None + half_width, half_height = settings.half_extents() + center = self.world.clamp_inside(position, (half_width, half_height)) + material = Material( + density=settings.density, + restitution=settings.restitution, + friction=self.config.friction, + ) + style = BodyStyle(self.color_for(settings)) + if settings.color_index is None: + self._next_random_color = self._rng.choice(self.palette) + if settings.shape is ShapeKind.CIRCLE: + body = Body.circle(center, half_width, material, user_data=style) + else: + body = Body.box(center, half_width, half_height, material, user_data=style) + return self.world.add_body(body) diff --git a/src/sandbox/interaction/velocity_tracker.py b/src/sandbox/interaction/velocity_tracker.py new file mode 100644 index 0000000..70a8416 --- /dev/null +++ b/src/sandbox/interaction/velocity_tracker.py @@ -0,0 +1,79 @@ +"""Geglättete Zeigergeschwindigkeit für das Werfen.""" + +from __future__ import annotations + +import math +from collections import deque + +from sandbox.physics import ZERO, Vector2 + + +class VelocityTracker: + """Schätzt, wie schnell sich die Maus zuletzt bewegt hat. + + Die Geschwindigkeit zwischen nur zwei aufeinanderfolgenden Frames ist sehr unruhig: + Maus-Events kommen unregelmäßig, und ein einzelner kurzer Frame liefert einen riesigen + Wert. Deshalb zwei Stufen: + + 1. **Zeitfenster:** Mittlere Geschwindigkeit über die letzten ``window`` Sekunden + (Weg / Zeit zwischen ältester und neuester Probe) – das entspricht "der Bewegung der + letzten Frames" und ist unabhängig von der Bildrate. + 2. **Exponentielle Glättung:** ``v ← v + (v_roh − v)·(1 − e^(−Δt/τ))``. Der + Exponentialterm macht die Glättung ebenfalls bildratenunabhängig: Zwei Frames à + 8 ms glätten genauso stark wie ein Frame à 16 ms. + + Hält man die Maus vor dem Loslassen still, sinkt die Geschwindigkeit schnell gegen 0 – + das Objekt fällt dann einfach herunter, statt mit einem veralteten Schwung davonzufliegen. + + Args: + window: Länge des Mittelungsfensters in s. + smoothing_time: Zeitkonstante τ der exponentiellen Glättung in s. + """ + + def __init__(self, window: float, smoothing_time: float) -> None: + if not (window > 0.0 and smoothing_time > 0.0): + raise ValueError("Fenster und Glättungszeit müssen positiv sein") + self.window = window + self.smoothing_time = smoothing_time + self._samples: deque[tuple[float, Vector2]] = deque() + self._velocity = ZERO + + @property + def velocity(self) -> Vector2: + """Aktuelle geglättete Geschwindigkeit in px/s.""" + return self._velocity + + def reset(self, time: float, position: Vector2) -> None: + """Beginnt eine neue Messung (z. B. beim Greifen eines Körpers).""" + self._samples.clear() + self._samples.append((time, position)) + self._velocity = ZERO + + def add_sample(self, time: float, position: Vector2) -> None: + """Nimmt eine neue Zeigerposition auf. + + Args: + time: Zeitstempel in s (monoton steigend). + position: Zeigerposition in Weltkoordinaten. + """ + samples = self._samples + if not samples: + self.reset(time, position) + return + last_time = samples[-1][0] + elapsed = time - last_time + if elapsed <= 0.0: + # Mehrere Events im selben Frame: nur die neueste Position zählt. + samples[-1] = (last_time, position) + return + samples.append((time, position)) + # Proben vor dem Fenster verwerfen – die jüngste davon bleibt als Bezugspunkt, + # damit das Fenster auch bei langen Frames nie leer ist. (Die neueste Probe liegt + # immer im Fenster, es bleiben also stets mindestens zwei Proben übrig.) + while len(samples) > 1 and samples[1][0] <= time - self.window: + samples.popleft() + + oldest_time, oldest_position = samples[0] + raw = (position - oldest_position) / (time - oldest_time) + blend = 1.0 - math.exp(-elapsed / self.smoothing_time) + self._velocity = self._velocity + (raw - self._velocity) * blend diff --git a/src/sandbox/main.py b/src/sandbox/main.py new file mode 100644 index 0000000..3cbeb9d --- /dev/null +++ b/src/sandbox/main.py @@ -0,0 +1,283 @@ +"""Einstiegspunkt und Hauptschleife der Physik-Sandbox. + +Ablauf eines Frames (:meth:`App.frame`): + +1. Events verarbeiten (Sidebar, Werkzeuge, Tastatur). +2. Die vergangene Zeit an den festen Zeitschritt geben; er liefert die Zahl fälliger + Physikschritte (bei 60 Bildern/s meist zwei Schritte à 1/120 s). +3. Vor jedem Physikschritt die Geschwindigkeit eines gezogenen Körpers setzen, dann + ``World.step`` ausführen. +4. Zeichnen – mit Interpolation zwischen den letzten beiden Physikzuständen. +""" + +from __future__ import annotations + +import argparse +import random +import time +from pathlib import Path + +import pygame + +from sandbox.config import APP, MILLISECONDS_PER_SECOND, AppConfig, Color +from sandbox.demo import build_demo_scene +from sandbox.input_handler import InputHandler +from sandbox.interaction import BodyFactory, DragController, ToolSettings +from sandbox.physics import FixedTimestep, World +from sandbox.renderer import Renderer, WorldOverlay +from sandbox.ui.sidebar import Sidebar +from sandbox.ui.style import UiStyle +from sandbox.viewport import Viewport + + +class FrameStats: + """Geglättete Bildrate und Physik-Rechenzeit für die Anzeige. + + Args: + smoothing: Anteil (0..1), mit dem ein neuer Messwert pro Frame einfließt. + """ + + def __init__(self, smoothing: float) -> None: + self.smoothing = smoothing + self.fps = 0.0 + self.physics_ms = 0.0 + + def update(self, frame_time: float, physics_seconds: float) -> None: + """Nimmt die Messwerte eines Frames auf.""" + if frame_time > 0.0: + fps = 1.0 / frame_time + self.fps = fps if self.fps == 0.0 else self.fps + (fps - self.fps) * self.smoothing + physics_ms = physics_seconds * MILLISECONDS_PER_SECOND + self.physics_ms += (physics_ms - self.physics_ms) * self.smoothing + + +class App: + """Die Anwendung: Fenster, Welt, Werkzeuge, Sidebar und Hauptschleife. + + Args: + config: Gesamtkonfiguration. + rng: Zufallsgenerator für Zufallsfarben (für reproduzierbare Tests). + """ + + def __init__(self, config: AppConfig = APP, *, rng: random.Random | None = None) -> None: + pygame.init() + self.config = config + window = config.window + self.screen = pygame.display.set_mode((window.width, window.height)) + pygame.display.set_caption(window.title) + self.clock = pygame.time.Clock() + + self.viewport = Viewport( + window.sidebar_width, 0, window.width - window.sidebar_width, window.height + ) + self.world = World(self.viewport.width, self.viewport.height, config.physics) + self.timestep = FixedTimestep(config.physics.time_step, window.max_steps_per_frame) + self.tools = ToolSettings.from_config(config.interaction) + self.factory = BodyFactory( + self.world, config.interaction, config.theme.body_palette, rng or random.Random() + ) + self.drag = DragController(self.world, config.interaction) + self.style = UiStyle(config.theme, config.ui) + self.input = InputHandler( + world=self.world, + viewport=self.viewport, + drag=self.drag, + factory=self.factory, + tools=self.tools, + config=config.interaction, + controls=self, + ) + self.sidebar = Sidebar( + pygame.Rect(0, 0, window.sidebar_width, window.height), + self.style, + self.tools, + config.interaction, + config.theme.body_palette, + self, + ) + self.input.sidebar = self.sidebar + self.renderer = Renderer(self.screen, self.viewport, self.style) + self.stats = FrameStats(window.stats_smoothing) + + self.paused = False + self.debug = False + self.running = True + self.time = 0.0 + + # ------------------------------------------------------------------ Steuerung + + def is_paused(self) -> bool: + """Ob die Simulation pausiert ist.""" + return self.paused + + def toggle_pause(self) -> None: + """Pausiert bzw. setzt die Simulation fort.""" + self.paused = not self.paused + # Angesammelte Zeit verwerfen, sonst holte die Physik nach der Pause Schritte nach. + self.timestep.reset() + + def gravity_enabled(self) -> bool: + """Ob die Schwerkraft wirkt.""" + return self.world.gravity_enabled + + def toggle_gravity(self) -> None: + """Schaltet die Schwerkraft an bzw. aus.""" + self.world.gravity_enabled = not self.world.gravity_enabled + + def clear_world(self) -> None: + """Entfernt alle Körper.""" + self.input.forget_bodies() + self.world.clear() + + def begin_spawn_drag(self) -> None: + """Beginnt das Herausziehen eines neuen Objekts aus der Vorschau-Kachel.""" + self.input.begin_spawn_drag() + + def preview_color(self) -> Color: + """Farbe des nächsten Objekts.""" + return self.factory.color_for(self.tools) + + def status_lines(self) -> list[str]: + """Statuszeilen am unteren Rand der Sidebar.""" + limit = self.config.interaction.max_bodies + count = len(self.world.bodies) + full = " (voll)" if count >= limit else "" + return [ + f"Objekte: {count} / {limit}{full}", + f"{self.stats.fps:.0f} FPS · Physik {self.stats.physics_ms:.1f} ms", + "Rechtsklick: löschen · Rad: Größe", + "F1: Debug · Esc: Beenden", + ] + + def request_quit(self) -> None: + """Beendet die Hauptschleife nach dem aktuellen Frame.""" + self.running = False + + def toggle_debug(self) -> None: + """Schaltet die Debug-Ansicht (Kontakte, Schlafzustand) um.""" + self.debug = not self.debug + + def load_demo_scene(self) -> None: + """Ersetzt den Inhalt der Welt durch die Beispielszene.""" + self.clear_world() + build_demo_scene(self.factory, self.tools, self.config.demo) + + # ------------------------------------------------------------------ Hauptschleife + + def frame(self, frame_time: float) -> None: + """Verarbeitet Eingaben, rechnet die Physik und zeichnet ein Bild. + + Args: + frame_time: Real vergangene Zeit seit dem letzten Frame in s. + """ + self.time += frame_time + for event in pygame.event.get(): + self.input.handle_event(event, self.time) + self.input.update(self.time) + + physics_start = time.perf_counter() + if self.paused: + self.drag.place() # Im Pausenmodus wird direkt verschoben, ohne Physik. + else: + dt = self.timestep.step + for _ in range(self.timestep.advance(frame_time)): + self.drag.apply(dt) + self.world.step(dt) + self.stats.update(frame_time, time.perf_counter() - physics_start) + + self.draw() + pygame.display.flip() + + def draw(self) -> None: + """Zeichnet Welt, Sidebar und – obenauf – die Vorschau eines neuen Objekts.""" + overlay = WorldOverlay( + hovered=self.input.hovered_body, + held=self.drag.body, + throw_velocity=self.drag.throw_velocity, + paused=self.paused, + debug=self.debug, + debug_lines=self._debug_lines() if self.debug else [], + ) + alpha = 1.0 if self.paused else self.timestep.alpha + self.renderer.draw_world(self.world, alpha, overlay) + self.sidebar.draw(self.screen) + preview = self.input.spawn_preview() + if preview is not None: + self.renderer.draw_preview(preview) + + def _debug_lines(self) -> list[str]: + awake = sum(not body.sleeping for body in self.world.bodies) + return [ + f"Körper: {len(self.world.bodies)} (wach: {awake})", + f"Kontakte: {len(self.world.contacts)}", + f"Physik: {self.stats.physics_ms:.2f} ms/Frame · Schritt {self.world.step_count}", + ] + + def run(self, max_seconds: float | None = None) -> None: + """Führt die Hauptschleife aus, bis das Fenster geschlossen wird. + + Args: + max_seconds: Optional; beendet die Schleife nach dieser Laufzeit (für + Screenshots und den Smoke-Test). + """ + start = time.perf_counter() + self.clock.tick() # Zeitmessung ab jetzt, nicht ab dem Erzeugen der App + while self.running: + frame_time = self.clock.tick(self.config.window.fps_limit) / MILLISECONDS_PER_SECOND + self.frame(frame_time) + if max_seconds is not None and time.perf_counter() - start >= max_seconds: + break + + def save_screenshot(self, path: Path) -> None: + """Speichert den aktuellen Fensterinhalt als Bild.""" + path.parent.mkdir(parents=True, exist_ok=True) + pygame.image.save(self.screen, str(path)) + + def close(self) -> None: + """Gibt Fenster und pygame-Ressourcen frei.""" + pygame.quit() + + +def main(argv: list[str] | None = None) -> int: + """Startet die Sandbox (Kommandozeilen-Einstiegspunkt). + + Args: + argv: Kommandozeilenargumente (Standard: ``sys.argv[1:]``). + + Returns: + Exit-Code 0. + """ + parser = argparse.ArgumentParser( + prog="physics-sandbox", description="Interaktive 2D-Physik-Sandbox" + ) + parser.add_argument("--demo", action="store_true", help="mit einer Beispielszene starten") + parser.add_argument( + "--screenshot", + type=Path, + metavar="DATEI", + help="nach --seconds Sekunden ein Bildschirmfoto speichern und beenden", + ) + parser.add_argument( + "--seconds", + type=float, + default=APP.window.screenshot_delay, + help="Laufzeit vor dem Bildschirmfoto (Standard: %(default)s)", + ) + args = parser.parse_args(argv) + + app = App() + try: + if args.demo: + app.load_demo_scene() + if args.screenshot is not None: + app.run(max_seconds=args.seconds) + app.save_screenshot(args.screenshot) + else: + app.run() + finally: + app.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/sandbox/physics/__init__.py b/src/sandbox/physics/__init__.py new file mode 100644 index 0000000..314b4ad --- /dev/null +++ b/src/sandbox/physics/__init__.py @@ -0,0 +1,36 @@ +"""Pygame-freie 2D-Physik-Engine. + +Die Physik verwendet ausschließlich das Standardmodul ``math`` (plus ``typing`` und +``dataclasses`` für Typangaben und Datenstrukturen). Ein Architektur-Test stellt sicher, +dass hier nie pygame oder eine andere Bibliothek importiert wird. + +Wichtigste Bausteine: + +* :class:`Vector2` – eigene Vektorklasse. +* :class:`Circle`, :class:`Box`, :class:`HalfPlane` – Formen. +* :class:`Body`, :class:`Material` – starre Körper. +* :class:`World` – verwaltet Körper und führt Simulationsschritte aus. +* :class:`FixedTimestep` – übersetzt Frame-Zeiten in feste Physikschritte. +""" + +from sandbox.physics.body import Body, Material +from sandbox.physics.collision import Contact, collide +from sandbox.physics.shapes import Box, Circle, HalfPlane, Shape +from sandbox.physics.timestep import FixedTimestep +from sandbox.physics.vector import ZERO, Vector2 +from sandbox.physics.world import World + +__all__ = [ + "ZERO", + "Body", + "Box", + "Circle", + "Contact", + "FixedTimestep", + "HalfPlane", + "Material", + "Shape", + "Vector2", + "World", + "collide", +] diff --git a/src/sandbox/physics/body.py b/src/sandbox/physics/body.py new file mode 100644 index 0000000..db8b8a7 --- /dev/null +++ b/src/sandbox/physics/body.py @@ -0,0 +1,278 @@ +"""Starre Körper und ihre Materialeigenschaften.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import ClassVar + +from sandbox.physics.shapes import Box, Circle, HalfPlane, Shape +from sandbox.physics.vector import ZERO, Vector2 + + +@dataclass(frozen=True, slots=True) +class Material: + """Stoffeigenschaften eines Körpers. + + Attributes: + density: Masse pro Fläche. Die Masse eines Körpers ist Fläche × Dichte, größere + Körper sind bei gleicher Dichte also schwerer. + restitution: Stoßzahl e ∈ [0, 1]. Verhältnis von Trenn- zu Aufprallgeschwindigkeit: + 0 = völlig unelastisch (kein Abprallen), 1 = vollkommen elastisch. + friction: Reibungskoeffizient μ ≥ 0 (Coulomb: Reibkraft ≤ μ · Normalkraft). + """ + + density: float + restitution: float + friction: float + + def __post_init__(self) -> None: + if self.density < 0.0: + raise ValueError(f"Dichte darf nicht negativ sein: {self.density}") + if not 0.0 <= self.restitution <= 1.0: + raise ValueError(f"Restitution muss in [0, 1] liegen: {self.restitution}") + if self.friction < 0.0: + raise ValueError(f"Reibung darf nicht negativ sein: {self.friction}") + + +class Body: + """Ein starrer Körper der Simulation. + + Dynamische Körper haben eine endliche Masse (Fläche × Dichte) und werden von Schwerkraft + und Kontakten bewegt. Statische Körper (die Wände) haben unendliche Masse; gespeichert + wird dafür die *inverse* Masse 0. Mit inversen Massen lassen sich beide Fälle ohne + Sonderbehandlung in dieselben Formeln einsetzen: Ein Impuls ändert die Geschwindigkeit + um ``Impuls · inv_mass`` – bei statischen Körpern also gar nicht. + + Attributes: + id: Eindeutige, fortlaufende Kennung (für Kontakt-Caches und deterministische + Reihenfolgen). + shape: Geometrie des Körpers. + material: Dichte, Restitution und Reibung. + position: Position des Schwerpunkts in px. + velocity: Geschwindigkeit des Schwerpunkts in px/s. + angle: Drehwinkel in rad (nur Kreise drehen sich). + angular_velocity: Winkelgeschwindigkeit in rad/s. + is_static: True für unbewegliche Körper (Wände). + mass: Masse (``math.inf`` bei statischen Körpern). + inv_mass: 1 / Masse (0 bei statischen Körpern). + inertia: Trägheitsmoment (``math.inf``, wenn sich der Körper nicht drehen kann). + inv_inertia: 1 / Trägheitsmoment (0, wenn sich der Körper nicht drehen kann). + sleeping: True, solange der Körper im Schlafmodus eingefroren ist. + sleep_time: Wie lange (s) der Körper schon ununterbrochen fast stillsteht. + sleep_island: Die Körper, mit denen dieser Körper gemeinsam eingeschlafen ist + (einschließlich ihm selbst), oder ``None``. Sie werden nur gemeinsam geweckt, + siehe :meth:`wake`. + held: True, solange der Körper mit der Maus gezogen wird. Gehaltene Körper + unterliegen keiner Schwerkraft und schlafen nie ein. + previous_position: Position zu Beginn des letzten Schritts (für die + Zeichen-Interpolation zwischen zwei Physikschritten). + previous_angle: Winkel zu Beginn des letzten Schritts. + previous_velocity: Geschwindigkeit zu Beginn des letzten Schritts, also vor + Schwerkraft und Kontakten. Daraus bestimmt der Solver die Aufprallgeschwindigkeit + für das Abprallen. + user_data: Beliebige Zusatzdaten der Anwendung (z. B. die Farbe). Die Physik + liest dieses Feld nie. + """ + + __slots__ = ( + "angle", + "angular_velocity", + "held", + "id", + "inertia", + "inv_inertia", + "inv_mass", + "is_static", + "mass", + "material", + "position", + "previous_angle", + "previous_position", + "previous_velocity", + "shape", + "sleep_island", + "sleep_time", + "sleeping", + "user_data", + "velocity", + ) + + _next_id: ClassVar[int] = 1 + + def __init__( + self, + shape: Shape, + position: Vector2, + material: Material, + *, + is_static: bool = False, + velocity: Vector2 = ZERO, + angular_velocity: float = 0.0, + user_data: object = None, + ) -> None: + self.id = Body._next_id + Body._next_id += 1 + + self.shape = shape + self.material = material + self.position = position + self.previous_position = position + self.angle = 0.0 + self.previous_angle = 0.0 + self.is_static = is_static + self.sleeping = False + self.sleep_time = 0.0 + self.sleep_island: list[Body] | None = None + self.held = False + self.user_data = user_data + + if is_static: + self.mass = math.inf + self.inv_mass = 0.0 + self.inertia = math.inf + self.inv_inertia = 0.0 + else: + if isinstance(shape, HalfPlane): + raise ValueError("Halbebenen können nur statische Körper sein") + mass = shape.area() * material.density + if not mass > 0.0: + raise ValueError("Dynamische Körper brauchen eine Dichte > 0") + self.mass = mass + self.inv_mass = 1.0 / mass + self.inertia = shape.moment_of_inertia(mass) + self.inv_inertia = 0.0 if math.isinf(self.inertia) else 1.0 / self.inertia + + # Statische Körper bewegen sich nie; Körper ohne Drehfreiheit drehen sich nie. + self.velocity = ZERO if is_static else velocity + self.previous_velocity = self.velocity + self.angular_velocity = angular_velocity if self.inv_inertia > 0.0 else 0.0 + + # ------------------------------------------------------------------ Fabrikmethoden + + @classmethod + def circle( + cls, + position: Vector2, + radius: float, + material: Material, + *, + velocity: Vector2 = ZERO, + user_data: object = None, + ) -> Body: + """Erzeugt einen dynamischen Kreis.""" + return cls(Circle(radius), position, material, velocity=velocity, user_data=user_data) + + @classmethod + def box( + cls, + position: Vector2, + half_width: float, + half_height: float, + material: Material, + *, + velocity: Vector2 = ZERO, + user_data: object = None, + ) -> Body: + """Erzeugt ein dynamisches achsenparalleles Rechteck.""" + return cls( + Box(half_width, half_height), position, material, velocity=velocity, user_data=user_data + ) + + @classmethod + def wall(cls, normal: Vector2, offset: float, material: Material) -> Body: + """Erzeugt eine statische Wand (Halbebene ``normal·p ≥ offset`` ist frei).""" + return cls(HalfPlane(normal, offset), ZERO, material, is_static=True) + + # ------------------------------------------------------------------ Abfragen + + @property + def can_rotate(self) -> bool: + """True, wenn Drehimpulse den Körper drehen können (nur dynamische Kreise).""" + return self.inv_inertia > 0.0 + + def half_extents(self) -> tuple[float, float]: + """Halbe Breite und Höhe der umschließenden achsenparallelen Box.""" + return self.shape.half_extents() + + def bounds(self) -> tuple[float, float, float, float]: + """Umschließende Box als ``(min_x, min_y, max_x, max_y)``.""" + half_x, half_y = self.shape.half_extents() + x = self.position.x + y = self.position.y + return (x - half_x, y - half_y, x + half_x, y + half_y) + + def contains_point(self, point: Vector2, tolerance: float = 0.0) -> bool: + """Prüft, ob ein Weltpunkt im Körper liegt (``tolerance`` vergrößert den Körper).""" + return self.shape.contains(point - self.position, tolerance) + + def kinetic_energy(self) -> float: + """Bewegungsenergie ``½·m·v² + ½·I·ω²`` (0 für statische Körper).""" + if self.is_static: + return 0.0 + energy = 0.5 * self.mass * self.velocity.length_squared() + if self.can_rotate: + energy += 0.5 * self.inertia * self.angular_velocity * self.angular_velocity + return energy + + def momentum(self) -> Vector2: + """Impuls ``p = m·v``.""" + if self.is_static: + return ZERO + return self.velocity * self.mass + + # ------------------------------------------------------------------ Zustandsänderungen + + def apply_impulse(self, impulse: Vector2, offset: Vector2 = ZERO) -> None: + """Wendet einen Impuls an einem Punkt an. + + Args: + impulse: Impuls (Kraftstoß) in Masse·px/s. + offset: Angriffspunkt relativ zum Schwerpunkt. Ein Impuls, der nicht durch den + Schwerpunkt geht, erzeugt zusätzlich einen Drehimpuls ``offset × impulse``. + """ + if self.is_static: + return + self.velocity = self.velocity + impulse * self.inv_mass + self.angular_velocity += self.inv_inertia * offset.cross(impulse) + + def wake(self) -> None: + """Weckt den Körper – und alle Körper, mit denen er eingeschlafen ist. + + Warum die ganze Insel? Zwei schlafende Körper werden nicht auf Kontakte + untereinander geprüft. Würde nur die unterste Kiste eines schlafenden Stapels + geweckt und im selben Schritt weggestoßen (oder in der Pause weggezogen), bemerkte + niemand, dass die Kisten darüber ihre Stütze verloren haben – sie schwebten. + """ + island = self.sleep_island + for body in island if island is not None else (self,): + body.sleeping = False + body.sleep_time = 0.0 + body.sleep_island = None + + def put_to_sleep(self, island: list[Body] | None = None) -> None: + """Friert den Körper ein: keine Bewegung, bis ihn etwas aufweckt. + + Args: + island: Die Körper, die gemeinsam mit diesem einschlafen (einschließlich + ihm selbst). Weckt später einer von ihnen auf, wachen alle auf. + """ + self.sleeping = True + self.sleep_island = island + self.velocity = ZERO + self.previous_velocity = ZERO + self.angular_velocity = 0.0 + # Ohne diese Angleichung würde die Zeichen-Interpolation einen längst ruhenden + # Körper noch zwischen zwei alten Positionen hin- und herschieben. + self.previous_position = self.position + self.previous_angle = self.angle + + def teleport(self, position: Vector2) -> None: + """Setzt die Position direkt (ohne Interpolations-Schlieren) und weckt den Körper.""" + self.position = position + self.previous_position = position + self.wake() + + def __repr__(self) -> str: + state = " schläft" if self.sleeping else "" + return f"Body(id={self.id}, {self.shape!r}, position={self.position!r}{state})" diff --git a/src/sandbox/physics/broadphase.py b/src/sandbox/physics/broadphase.py new file mode 100644 index 0000000..c686f17 --- /dev/null +++ b/src/sandbox/physics/broadphase.py @@ -0,0 +1,51 @@ +"""Grobe Vorauswahl möglicher Kollisionspaare ("Broadphase") per Sweep-and-Prune. + +Warum: Jedes Paar von n Körpern genau zu prüfen kostet n·(n−1)/2 Tests – bei 150 Körpern +über 11 000 pro Schritt, 120-mal pro Sekunde. In reinem Python ist das zu langsam. + +Sweep-and-Prune sortiert die umschließenden Boxen nach ihrer linken Kante und läuft einmal +von links nach rechts. Für jede Box müssen nur die Nachfolger geprüft werden, deren linke +Kante noch vor ihrer rechten Kante liegt; alle weiteren beginnen noch weiter rechts und +können nicht überlappen. Der Aufwand sinkt so auf etwa O(n·log n + k) für k tatsächlich +benachbarte Paare. Da sich die Körper zwischen zwei Schritten kaum bewegen, ist die Liste +fast sortiert, und Pythons Timsort sortiert sie besonders schnell. +""" + +from __future__ import annotations + +from sandbox.physics.body import Body + + +def find_candidate_pairs(bodies: list[Body]) -> list[tuple[Body, Body]]: + """Findet alle Körperpaare, deren umschließende Boxen sich überlappen oder berühren. + + Paare aus zwei schlafenden Körpern werden übersprungen: Sie bewegen sich nicht, ihr + Kontakt ändert sich also nicht. Jedes Paar ist nach ``id`` geordnet, damit die + Reihenfolge (und damit die ganze Simulation) reproduzierbar ist. + + Args: + bodies: Die dynamischen Körper der Welt. + + Returns: + Kandidatenpaare für die genaue Prüfung mit :func:`~sandbox.physics.collision.collide`. + """ + entries = sorted(((body.bounds(), body) for body in bodies), key=_left_edge) + pairs: list[tuple[Body, Body]] = [] + count = len(entries) + for i in range(count): + (_, min_y, max_x, max_y), body = entries[i] + for j in range(i + 1, count): + (other_min_x, other_min_y, _, other_max_y), other = entries[j] + if other_min_x > max_x: + break # Alle weiteren Boxen beginnen noch weiter rechts. + if other_min_y > max_y or other_max_y < min_y: + continue + if body.sleeping and other.sleeping: + continue + pairs.append((body, other) if body.id < other.id else (other, body)) + return pairs + + +def _left_edge(entry: tuple[tuple[float, float, float, float], Body]) -> float: + """Sortierschlüssel: linke Kante der umschließenden Box.""" + return entry[0][0] diff --git a/src/sandbox/physics/collision.py b/src/sandbox/physics/collision.py new file mode 100644 index 0000000..f81c48c --- /dev/null +++ b/src/sandbox/physics/collision.py @@ -0,0 +1,266 @@ +"""Kollisionserkennung ("Narrowphase"): Berühren sich zwei Körper, und wenn ja, wie? + +Jede Testfunktion liefert entweder ``None`` (keine Berührung) oder einen :class:`Contact` +mit drei Informationen, die die Kollisionsauflösung braucht: + +* **Normale** n – Einheitsvektor, der von Körper A zu Körper B zeigt. Entlang n werden + die Körper auseinandergedrückt. +* **Eindringtiefe** – wie weit sich die Körper entlang n überlappen (≥ 0). +* **Kontaktpunkt** – wo die Kraft angreift. Er bestimmt die Hebelarme für die Drehung. + +Konvention: :func:`collide` ordnet die Körper so, dass Körper A die "einfachere" Form hat +(Kreis vor Rechteck vor Wand). Für ein Körperpaar ist die Orientierung dadurch in jedem +Schritt gleich, was das Warm-Starting des Solvers (Wiederverwenden alter Impulse) erlaubt. +""" + +from __future__ import annotations + +import math +from typing import Final + +from sandbox.config import NUMERIC_EPSILON +from sandbox.physics.body import Body +from sandbox.physics.shapes import Box, Circle, HalfPlane +from sandbox.physics.vector import Vector2 + +#: Ersatznormale, wenn zwei Kreismittelpunkte exakt aufeinanderliegen und die Richtung +#: daher unbestimmt ist. Jede Richtung wäre richtig; eine feste macht das Ergebnis +#: reproduzierbar. (0, −1) schiebt Körper B nach oben, A nach unten. +COINCIDENT_NORMAL: Final = Vector2(0.0, -1.0) + +#: Reihenfolge der Formen für die kanonische Anordnung in :func:`collide`. +_SHAPE_ORDER: Final = {Circle: 0, Box: 1, HalfPlane: 2} + + +class Contact: + """Berührung zweier Körper in einem Simulationsschritt. + + Attributes: + body_a: Erster Körper. + body_b: Zweiter Körper. + normal: Einheitsnormale von A nach B. + penetration: Eindringtiefe entlang der Normalen in px (≥ 0). + point: Kontaktpunkt in Weltkoordinaten. + normal_impulse: Vom Solver aufgebrachter Normalimpuls (Ergebnis, ≥ 0). + tangent_impulse: Vom Solver aufgebrachter Reibungsimpuls (Ergebnis). + """ + + __slots__ = ( + "body_a", + "body_b", + "normal", + "normal_impulse", + "penetration", + "point", + "tangent_impulse", + ) + + def __init__( + self, body_a: Body, body_b: Body, normal: Vector2, penetration: float, point: Vector2 + ) -> None: + self.body_a = body_a + self.body_b = body_b + self.normal = normal + self.penetration = penetration + self.point = point + self.normal_impulse = 0.0 + self.tangent_impulse = 0.0 + + @property + def key(self) -> tuple[int, int]: + """Schlüssel des Körperpaares, um Kontakte über Schritte hinweg wiederzuerkennen.""" + return (self.body_a.id, self.body_b.id) + + def __repr__(self) -> str: + return ( + f"Contact({self.body_a.id}->{self.body_b.id}, normal={self.normal!r}, " + f"penetration={self.penetration:.3f})" + ) + + +def collide(first: Body, second: Body) -> Contact | None: + """Prüft zwei beliebige Körper auf Berührung. + + Die Körper werden vorher kanonisch angeordnet (siehe Moduldokumentation). Das Ergebnis + kann ``body_a`` und ``body_b`` daher in vertauschter Reihenfolge enthalten. + + Returns: + Den Kontakt oder ``None``, wenn sich die Körper nicht berühren. + """ + body_a, body_b = first, second + if _SHAPE_ORDER[type(body_b.shape)] < _SHAPE_ORDER[type(body_a.shape)]: + body_a, body_b = body_b, body_a + + shape_a = body_a.shape + shape_b = body_b.shape + if isinstance(shape_a, Circle): + if isinstance(shape_b, Circle): + return collide_circles(body_a, body_b) + if isinstance(shape_b, Box): + return collide_circle_box(body_a, body_b) + return collide_circle_half_plane(body_a, body_b) + if isinstance(shape_a, Box): + if isinstance(shape_b, Box): + return collide_boxes(body_a, body_b) + return collide_box_half_plane(body_a, body_b) + return None # Zwei Wände kollidieren nie miteinander. + + +def collide_circles(body_a: Body, body_b: Body) -> Contact | None: + """Kreis gegen Kreis. + + Zwei Kreise überlappen, wenn der Mittelpunktabstand kleiner als die Summe der Radien + ist. Die Normale zeigt entlang der Verbindungslinie der Mittelpunkte. + """ + radius_a = body_a.shape.radius + radius_b = body_b.shape.radius + dx = body_b.position.x - body_a.position.x + dy = body_b.position.y - body_a.position.y + distance_sq = dx * dx + dy * dy + radius_sum = radius_a + radius_b + if distance_sq > radius_sum * radius_sum: + return None + + if distance_sq > NUMERIC_EPSILON * NUMERIC_EPSILON: + distance = math.sqrt(distance_sq) + normal = Vector2(dx / distance, dy / distance) + else: + distance = 0.0 + normal = COINCIDENT_NORMAL + penetration = radius_sum - distance + # Kontaktpunkt: Mitte des überlappenden Bereichs auf der Verbindungslinie. + point = body_a.position + normal * (radius_a - 0.5 * penetration) + return Contact(body_a, body_b, normal, penetration, point) + + +def collide_circle_box(circle_body: Body, box_body: Body) -> Contact | None: + """Kreis (Körper A) gegen achsenparalleles Rechteck (Körper B). + + Idee: Den Kreismittelpunkt komponentenweise auf das Rechteck klemmen. Das liefert den + Punkt des Rechtecks, der dem Kreismittelpunkt am nächsten liegt. Ist er weniger als + einen Radius entfernt, berühren sich die Körper. + + Sonderfall: Liegt der Mittelpunkt *im* Rechteck, ist der geklemmte Punkt der + Mittelpunkt selbst und liefert keine Richtung. Dann wird der Kreis über die nächste + Kante hinausgeschoben (Achse mit der kleinsten Eindringtiefe). + """ + radius = circle_body.shape.radius + half_width = box_body.shape.half_width + half_height = box_body.shape.half_height + box_x = box_body.position.x + box_y = box_body.position.y + # Kreismittelpunkt relativ zum Rechteckmittelpunkt. + dx = circle_body.position.x - box_x + dy = circle_body.position.y - box_y + closest_x = min(max(dx, -half_width), half_width) + closest_y = min(max(dy, -half_height), half_height) + + if closest_x != dx or closest_y != dy: + # Mittelpunkt außerhalb: Abstand zum nächsten Punkt des Rechtecks. + offset_x = dx - closest_x + offset_y = dy - closest_y + distance_sq = offset_x * offset_x + offset_y * offset_y + if distance_sq > radius * radius: + return None + distance = math.sqrt(distance_sq) # > 0, weil der Mittelpunkt außerhalb liegt + # (offset) zeigt vom Rechteck zum Kreis; die Normale soll von A (Kreis) zu B zeigen. + normal = Vector2(-offset_x / distance, -offset_y / distance) + penetration = radius - distance + point = Vector2(box_x + closest_x, box_y + closest_y) + return Contact(circle_body, box_body, normal, penetration, point) + + # Mittelpunkt innerhalb (oder genau auf dem Rand): über die nächste Kante hinausdrücken. + gap_x = half_width - abs(dx) + gap_y = half_height - abs(dy) + if gap_x < gap_y: + side = 1.0 if dx >= 0.0 else -1.0 + # Der Kreis muss in Richtung "side" hinaus, er bewegt sich entlang −n → n = −side. + normal = Vector2(-side, 0.0) + penetration = radius + gap_x + point = Vector2(box_x + side * half_width, circle_body.position.y) + else: + side = 1.0 if dy >= 0.0 else -1.0 + normal = Vector2(0.0, -side) + penetration = radius + gap_y + point = Vector2(circle_body.position.x, box_y + side * half_height) + return Contact(circle_body, box_body, normal, penetration, point) + + +def collide_boxes(body_a: Body, body_b: Body) -> Contact | None: + """Rechteck gegen Rechteck (beide achsenparallel). + + Zwei achsenparallele Rechtecke überlappen genau dann, wenn sich ihre Projektionen auf + beide Achsen überlappen (Trennachsensatz). Getrennt wird entlang der Achse mit der + kleineren Überlappung – das ist der kürzeste Weg aus der Durchdringung. Bei Gleichstand + gewinnt die y-Achse, weil aufeinanderliegende Körper der häufigste Fall sind. + """ + shape_a = body_a.shape + shape_b = body_b.shape + dx = body_b.position.x - body_a.position.x + dy = body_b.position.y - body_a.position.y + overlap_x = shape_a.half_width + shape_b.half_width - abs(dx) + if overlap_x < 0.0: + return None + overlap_y = shape_a.half_height + shape_b.half_height - abs(dy) + if overlap_y < 0.0: + return None + + if overlap_x < overlap_y: + normal = Vector2(1.0 if dx >= 0.0 else -1.0, 0.0) + penetration = overlap_x + else: + normal = Vector2(0.0, 1.0 if dy >= 0.0 else -1.0) + penetration = overlap_y + + # Kontaktpunkt: Mittelpunkt des Überlappungsrechtecks. Da Rechtecke sich nie drehen, + # beeinflusst er nur die Debug-Anzeige, nicht die Physik. + a_x = body_a.position.x + a_y = body_a.position.y + b_x = body_b.position.x + b_y = body_b.position.y + left = max(a_x - shape_a.half_width, b_x - shape_b.half_width) + right = min(a_x + shape_a.half_width, b_x + shape_b.half_width) + top = max(a_y - shape_a.half_height, b_y - shape_b.half_height) + bottom = min(a_y + shape_a.half_height, b_y + shape_b.half_height) + point = Vector2(0.5 * (left + right), 0.5 * (top + bottom)) + return Contact(body_a, body_b, normal, penetration, point) + + +def collide_circle_half_plane(circle_body: Body, wall_body: Body) -> Contact | None: + """Kreis gegen Wand (Halbebene). + + Der Kreis berührt die Wand, wenn der Abstand seines Mittelpunkts zur Wandlinie kleiner + als der Radius ist. Liegt der Mittelpunkt sogar hinter der Wand, ist der Abstand + negativ und die Eindringtiefe entsprechend groß – der Kreis wird trotzdem korrekt in + den freien Raum zurückgeschoben. + """ + wall = wall_body.shape + center = circle_body.position + distance = wall.distance(center) + penetration = circle_body.shape.radius - distance + if penetration < 0.0: + return None + # Die Wandnormale zeigt in den freien Raum, also zum Kreis; A→B ist die Gegenrichtung. + normal = -wall.normal + point = center - wall.normal * distance # Lotfußpunkt auf der Wandlinie + return Contact(circle_body, wall_body, normal, penetration, point) + + +def collide_box_half_plane(box_body: Body, wall_body: Body) -> Contact | None: + """Rechteck gegen Wand (Halbebene). + + Die "Reichweite" eines achsenparallelen Rechtecks entlang der Normalen n ist + ``|n.x|·hw + |n.y|·hh`` (Projektion der Halbdiagonale). Ist der Mittelpunkt näher an der + Wand als diese Reichweite, ragt das Rechteck in die Wand. + """ + wall = wall_body.shape + shape = box_body.shape + center = box_body.position + reach = abs(wall.normal.x) * shape.half_width + abs(wall.normal.y) * shape.half_height + distance = wall.distance(center) + penetration = reach - distance + if penetration < 0.0: + return None + normal = -wall.normal + point = center - wall.normal * distance + return Contact(box_body, wall_body, normal, penetration, point) diff --git a/src/sandbox/physics/shapes.py b/src/sandbox/physics/shapes.py new file mode 100644 index 0000000..4c46332 --- /dev/null +++ b/src/sandbox/physics/shapes.py @@ -0,0 +1,139 @@ +"""Geometrische Formen der Körper: Kreis, achsenparalleles Rechteck und Halbebene. + +Formen beschreiben nur die Geometrie relativ zum Schwerpunkt. Position, Geschwindigkeit +und Masse gehören zum :class:`~sandbox.physics.body.Body`. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import TypeAlias + +from sandbox.config import NUMERIC_EPSILON +from sandbox.physics.vector import Vector2 + + +@dataclass(frozen=True, slots=True) +class Circle: + """Homogene Kreisscheibe. Kreise können sich drehen (z. B. rollen). + + Attributes: + radius: Radius in px (> 0). + """ + + radius: float + + def __post_init__(self) -> None: + if not self.radius > 0.0: + raise ValueError(f"Radius muss positiv sein, ist aber {self.radius}") + + def area(self) -> float: + """Fläche ``π·r²`` – bestimmt zusammen mit der Dichte die Masse.""" + return math.pi * self.radius * self.radius + + def moment_of_inertia(self, mass: float) -> float: + """Trägheitsmoment einer homogenen Scheibe um ihren Mittelpunkt: ``I = ½·m·r²``. + + Herleitung: Die Scheibe besteht aus dünnen Ringen mit Radius s und Masse + ``dm = m/(π·r²) · 2π·s·ds``. Jeder Ring trägt ``s²·dm`` bei, also + ``I = (2m/r²)·∫₀ʳ s³ ds = (2m/r²)·r⁴/4 = ½·m·r²``. + """ + return 0.5 * mass * self.radius * self.radius + + def half_extents(self) -> tuple[float, float]: + """Halbe Breite und Höhe der umschließenden achsenparallelen Box.""" + return (self.radius, self.radius) + + def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool: + """Prüft, ob ein Punkt (relativ zum Mittelpunkt) im Kreis liegt.""" + reach = self.radius + tolerance + return offset.length_squared() <= reach * reach + + +@dataclass(frozen=True, slots=True) +class Box: + """Achsenparalleles Rechteck (AABB). + + Rechtecke bleiben per Definition achsenparallel. Deshalb liefert + :meth:`moment_of_inertia` "unendlich": Das inverse Trägheitsmoment ist dann 0, und kein + Drehimpuls kann das Rechteck je drehen. + + Attributes: + half_width: Halbe Breite in px (> 0). + half_height: Halbe Höhe in px (> 0). + """ + + half_width: float + half_height: float + + def __post_init__(self) -> None: + if not (self.half_width > 0.0 and self.half_height > 0.0): + raise ValueError( + f"Halbe Kantenlängen müssen positiv sein: {self.half_width}, {self.half_height}" + ) + + def area(self) -> float: + """Fläche ``(2·hw)·(2·hh)``.""" + return 4.0 * self.half_width * self.half_height + + def moment_of_inertia(self, mass: float) -> float: + """Unendlich, damit sich das Rechteck nie dreht (siehe Klassenbeschreibung). + + ``mass`` wird nur für eine einheitliche Schnittstelle mit :class:`Circle` angenommen. + """ + del mass + return math.inf + + def half_extents(self) -> tuple[float, float]: + """Halbe Breite und Höhe.""" + return (self.half_width, self.half_height) + + def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool: + """Prüft, ob ein Punkt (relativ zum Mittelpunkt) im Rechteck liegt.""" + return ( + abs(offset.x) <= self.half_width + tolerance + and abs(offset.y) <= self.half_height + tolerance + ) + + +@dataclass(frozen=True, slots=True) +class HalfPlane: + """Unendliche Halbebene – modelliert Boden, Decke und Seitenwände. + + Die Ebene ist durch ``normal·p = offset`` gegeben. Die Normale zeigt in den freien + Raum; alles mit ``normal·p < offset`` ist "in der Wand". Weil eine Halbebene unendlich + dick ist, kann kein Körper durch sie hindurchtunneln: Selbst ein Körper, der in einem + Schritt komplett hinter die Wandlinie springt, wird als (tiefe) Durchdringung erkannt + und zurückgeschoben. + + Attributes: + normal: Einheitsnormale, zeigt in den freien Raum. + offset: Abstand der Ebene vom Ursprung entlang der Normalen. + """ + + normal: Vector2 + offset: float + + def __post_init__(self) -> None: + if abs(self.normal.length() - 1.0) > NUMERIC_EPSILON: + raise ValueError(f"Die Normale muss Länge 1 haben: {self.normal}") + + def distance(self, point: Vector2) -> float: + """Vorzeichenbehafteter Abstand eines Punktes (positiv = im freien Raum).""" + return self.normal.dot(point) - self.offset + + def half_extents(self) -> tuple[float, float]: + """Eine Halbebene ist unbegrenzt.""" + return (math.inf, math.inf) + + def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool: + """Prüft, ob ein Punkt in der Wand (hinter der Ebene) liegt. + + Wandkörper liegen im Ursprung, deshalb ist ``offset`` hier der Punkt selbst. + """ + return self.distance(offset) <= tolerance + + +#: Alle unterstützten Formen. +Shape: TypeAlias = Circle | Box | HalfPlane diff --git a/src/sandbox/physics/sleep.py b/src/sandbox/physics/sleep.py new file mode 100644 index 0000000..e244ef7 --- /dev/null +++ b/src/sandbox/physics/sleep.py @@ -0,0 +1,126 @@ +"""Schlafmodus: ruhende Körper einfrieren, bis etwas sie anstößt. + +Warum: Ein Solver mit endlich vielen Iterationen lässt immer winzige Restbewegungen übrig. +Ein Stapel "atmet" dadurch sichtbar oder kriecht. Friert man Körper ein, die eine Weile +praktisch stillstehen, liegen sie wirklich ruhig – und sparen Rechenzeit, weil sie weder +integriert noch aufgelöst werden müssen. + +Warum gruppenweise ("Inseln")? Würde jeder Körper einzeln einschlafen, könnte ein +schlafender Körper unter einem wachen liegen: Der wache weckt ihn, beide ruhen, der eine +schläft früher ein, der andere weckt ihn wieder – endloses Hin und Her. Deshalb werden +Körper, die sich (direkt oder über andere) berühren, zu einer Insel zusammengefasst, und +eine Insel schläft nur als Ganzes ein – und wacht auch nur als Ganzes wieder auf. Wände +verbinden keine Inseln, sonst wäre alles auf dem Boden eine einzige Insel. +""" + +from __future__ import annotations + +from sandbox.config import PhysicsConfig +from sandbox.physics.body import Body +from sandbox.physics.collision import Contact + + +class UnionFind: + """Disjunkte Mengen ("Union-Find") zum Bilden von Kontakt-Inseln. + + Jede Menge wird durch einen Repräsentanten identifiziert. :meth:`union` verschmilzt zwei + Mengen, :meth:`find` liefert den Repräsentanten – dank Pfadverkürzung in nahezu + konstanter Zeit. + """ + + def __init__(self, keys: list[int]) -> None: + self._parent = {key: key for key in keys} + + def find(self, key: int) -> int: + """Repräsentant der Menge, zu der ``key`` gehört.""" + root = key + while self._parent[root] != root: + root = self._parent[root] + # Pfadverkürzung: alle besuchten Elemente direkt an die Wurzel hängen. + while self._parent[key] != root: + next_key = self._parent[key] + self._parent[key] = root + key = next_key + return root + + def union(self, first: int, second: int) -> None: + """Verschmilzt die Mengen von ``first`` und ``second``.""" + root_first = self.find(first) + root_second = self.find(second) + if root_first != root_second: + self._parent[root_second] = root_first + + +def wake_touched_bodies(contacts: list[Contact]) -> list[Body]: + """Weckt schlafende Körper, die von einem wachen dynamischen Körper berührt werden. + + Mit dem berührten Körper wacht seine ganze Schlaf-Insel auf (siehe + :meth:`Body.wake `). Die geweckten Körper gehören ab + sofort zur Insel des wachen Körpers und schlafen erst wieder ein, wenn die ganze Insel + zur Ruhe gekommen ist. + + Returns: + Alle in diesem Aufruf geweckten Körper. + """ + woken: list[Body] = [] + for contact in contacts: + body_a = contact.body_a + body_b = contact.body_b + if body_a.sleeping == body_b.sleeping: + continue # beide wach, beide schlafend – oder schon von einer Insel geweckt + sleeper, other = (body_a, body_b) if body_a.sleeping else (body_b, body_a) + if other.is_static: + continue # Wände wecken niemanden – sonst schliefe nichts am Boden je ein. + island = sleeper.sleep_island + sleeper.wake() + woken.extend(island if island is not None else (sleeper,)) + return woken + + +def find_islands(bodies: list[Body], contacts: list[Contact]) -> list[list[Body]]: + """Gruppiert wache dynamische Körper nach Berührung zu Inseln. + + Args: + bodies: Die wachen dynamischen Körper. + contacts: Die Kontakte des aktuellen Schritts. + + Returns: + Inseln in der Reihenfolge ihres ersten Körpers in ``bodies``. + """ + union_find = UnionFind([body.id for body in bodies]) + for contact in contacts: + if contact.body_a.is_static or contact.body_b.is_static: + continue + union_find.union(contact.body_a.id, contact.body_b.id) + + islands: dict[int, list[Body]] = {} + for body in bodies: + islands.setdefault(union_find.find(body.id), []).append(body) + return list(islands.values()) + + +def update_sleep( + bodies: list[Body], contacts: list[Contact], dt: float, config: PhysicsConfig +) -> None: + """Aktualisiert die Ruhezeiten und schickt vollständig ruhende Inseln schlafen. + + Args: + bodies: Die wachen dynamischen Körper dieses Schritts. + contacts: Die Kontakte dieses Schritts. + dt: Zeitschritt in s. + config: Schwellwerte für Ruhe und Einschlafzeit. + """ + linear_limit_sq = config.sleep_linear_speed * config.sleep_linear_speed + angular_limit = config.sleep_angular_speed + for body in bodies: + resting = ( + not body.held + and body.velocity.length_squared() <= linear_limit_sq + and abs(body.angular_velocity) <= angular_limit + ) + body.sleep_time = body.sleep_time + dt if resting else 0.0 + + for island in find_islands(bodies, contacts): + if all(body.sleep_time >= config.time_to_sleep for body in island): + for body in island: + body.put_to_sleep(island) # alle teilen sich dieselbe Liste diff --git a/src/sandbox/physics/solver.py b/src/sandbox/physics/solver.py new file mode 100644 index 0000000..9625005 --- /dev/null +++ b/src/sandbox/physics/solver.py @@ -0,0 +1,499 @@ +"""Impulsbasierte Kollisionsauflösung ("Sequential Impulses") und Positionskorrektur. + +Bedingungen pro Kontakt +----------------------- +1. **Nichtdurchdringung** entlang der Normalen n: Am Kontaktpunkt dürfen sich die Körper + nicht weiter annähern, ``v_n ≥ 0``. Bei einem echten Aufprall gilt zusätzlich Newtons + Stoßgesetz ``v_n' = −e·v_n`` (e = Restitution). +2. **Reibung** entlang der Tangente t: Das Gleiten ``v_t`` soll verschwinden, der + Reibungsimpuls ist aber nach Coulomb auf ``|P_t| ≤ μ·P_n`` begrenzt. Reicht das nicht, + rutschen die Körper weiter (Gleitreibung). + +Herleitung des Kollisionsimpulses +--------------------------------- +Relativgeschwindigkeit des Kontaktpunkts (B relativ zu A) mit den Hebelarmen r_A, r_B vom +Schwerpunkt zum Kontaktpunkt und ``ω × r = ω·(−r.y, r.x)``:: + + v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), v_n = v_rel · n + +Ein Impuls ``P = λ·n`` wirkt auf B positiv und auf A negativ (actio = reactio):: + + v_A' = v_A − λ·n/m_A ω_A' = ω_A − (r_A × λn)/I_A + v_B' = v_B + λ·n/m_B ω_B' = ω_B + (r_B × λn)/I_B + +Eingesetzt ändert sich die Normalgeschwindigkeit linear in λ:: + + v_n' = v_n + λ·K, K = 1/m_A + 1/m_B + (r_A × n)²/I_A + (r_B × n)²/I_B + +Aus ``v_n' = −e·v_n`` folgt ``λ = −(1 + e)·v_n / K``. Der Kehrwert 1/K ist die *effektive +Masse* des Kontakts: die träge Masse, die der Stoß entlang n "spürt". Für die Reibung gilt +dasselbe mit t statt n und dem Ziel ``v_t' = 0``. + +In dieser Engine verschwinden die Drehterme von K für die Normale: Bei Kreisen geht die +Normale durch den Mittelpunkt (r ∥ n, also r × n = 0), und Rechtecke drehen sich nie +(1/I = 0). Normalimpulse erzeugen daher kein Drehmoment – nur die Reibung bringt Kreise +zum Rollen. + +Rollwiderstand +-------------- +Reines Rollen verbraucht keine Reibungsenergie: Ohne weitere Kraft rollte ein Kreis ewig, +und ein Rechteck auf einem Kreis führe darauf wie auf einer Rolle davon. Real verformen +sich Kreis und Unterlage ein wenig, was ein bremsendes Moment ``M ≤ μ_r·N·r`` erzeugt +(μ_r = Rollwiderstandsbeiwert, N = Normalkraft, r = Radius). Pro Schritt entspricht das +einem Drehimpuls ``≤ μ_r·P_n·r``. Umgesetzt ist es wie die Reibung als begrenzter Impuls, +nur auf die relative Winkelgeschwindigkeit ``ω_B − ω_A`` mit der effektiven Masse +``1/(1/I_A + 1/I_B)``. Weil die Grenze mit der Auflagekraft wächst, kommen schwer +belastete "Rollen" schnell zur Ruhe, während ein frei rollender Ball weit rollt. + +Warum iterativ? +--------------- +Liegt ein Körper auf mehreren anderen (Stapel), beeinflussen sich die Kontakte gegenseitig. +Statt ein Gleichungssystem zu lösen, werden die Kontakte reihum mehrfach aufgelöst +(Gauß-Seidel-Verfahren). Geklemmt wird dabei der über alle Durchläufe *akkumulierte* +Impuls (≥ 0), nicht der einzelne Teilimpuls: Ein späterer Durchlauf darf einen zu großen +früheren Impuls wieder zurücknehmen. So konvergiert das Verfahren gegen die physikalische +Lösung, in der Kontakte nur drücken, aber nie ziehen. + +Warm-Starting: Die akkumulierten Impulse des vorigen Schritts sind ein sehr guter +Startwert, weil sich ruhende Kontakte kaum ändern. Ein Stapel steht so schon nach wenigen +Durchläufen stabil, statt jeden Schritt bei 0 beginnen zu müssen. + +Leistung: Die inneren Schleifen laufen tausendfach pro Schritt. Deshalb arbeiten sie auf +lokalen Float-Listen statt auf Vektorobjekten; die Formeln sind dieselben wie oben, +komponentenweise ausgeschrieben. +""" + +from __future__ import annotations + +import math +from typing import TypeAlias + +from sandbox.config import PhysicsConfig +from sandbox.physics.body import Body +from sandbox.physics.collision import Contact +from sandbox.physics.vector import Vector2 + +#: Gespeicherte Impulse eines Kontakts für das Warm-Starting: +#: (Normalimpuls, Reibungsimpuls, Rollwiderstandsimpuls, Normale x, Normale y). +_CachedImpulses: TypeAlias = tuple[float, float, float, float, float] + + +class _Constraint: + """Vorberechnete Solver-Daten eines Kontakts (nur Floats, für schnelle Iterationen).""" + + __slots__ = ( + "contact", + "friction", + "index_a", + "index_b", + "inv_inertia_a", + "inv_inertia_b", + "inv_mass_a", + "inv_mass_b", + "normal_impulse", + "normal_mass", + "normal_x", + "normal_y", + "offset_a_x", + "offset_a_y", + "offset_b_x", + "offset_b_y", + "penetration", + "rolling_impulse", + "rolling_limit", + "rolling_mass", + "tangent_impulse", + "tangent_mass", + "tangent_x", + "tangent_y", + "velocity_bias", + ) + + contact: Contact + friction: float + index_a: int + index_b: int + inv_inertia_a: float + inv_inertia_b: float + inv_mass_a: float + inv_mass_b: float + normal_impulse: float + normal_mass: float + normal_x: float + normal_y: float + offset_a_x: float + offset_a_y: float + offset_b_x: float + offset_b_y: float + penetration: float + rolling_impulse: float + rolling_limit: float + rolling_mass: float + tangent_impulse: float + tangent_mass: float + tangent_x: float + tangent_y: float + velocity_bias: float + + +class ContactSolver: + """Löst Kontakte über Impulse auf und korrigiert verbleibende Durchdringungen. + + Ablauf pro Simulationsschritt (gesteuert von :class:`~sandbox.physics.world.World`): + + 1. :meth:`solve_velocities` – Geschwindigkeiten so ändern, dass kein Kontakt sich weiter + schließt (mit Restitution und Reibung). + 2. Die Welt integriert die Positionen mit den neuen Geschwindigkeiten. + 3. :meth:`correct_positions` – verbliebene Überlappungen direkt über die Positionen + abbauen, ohne Geschwindigkeiten zu ändern. + """ + + def __init__(self, config: PhysicsConfig) -> None: + self._config = config + self._constraints: list[_Constraint] = [] + self._bodies: list[Body] = [] + self._velocity_x: list[float] = [] + self._velocity_y: list[float] = [] + self._angular_velocity: list[float] = [] + self._start_x: list[float] = [] + self._start_y: list[float] = [] + # Akkumulierte Impulse des letzten Schritts je Körperpaar (für das Warm-Starting). + self._impulse_cache: dict[tuple[int, int], _CachedImpulses] = {} + + def reset(self) -> None: + """Vergisst alle gespeicherten Impulse (z. B. nachdem die Welt geleert wurde).""" + self._constraints = [] + self._bodies = [] + self._impulse_cache = {} + + # ------------------------------------------------------------------ Geschwindigkeiten + + def solve_velocities(self, contacts: list[Contact]) -> None: + """Berechnet die Kontaktimpulse und schreibt die neuen Geschwindigkeiten zurück. + + Nach dem Aufruf enthalten die Kontakte die aufgebrachten Impulse + (``normal_impulse``, ``tangent_impulse``). + """ + self._prepare(contacts) + if self._config.warm_starting: + self._warm_start() + for _ in range(self._config.velocity_iterations): + self._solve_velocity_iteration() + self._store_velocities() + + def _prepare(self, contacts: list[Contact]) -> None: + """Legt die Float-Arbeitskopien an und berechnet die effektiven Massen.""" + bodies: list[Body] = [] + index_of: dict[int, int] = {} + index_pairs: list[tuple[int, int]] = [] + for contact in contacts: + indices = [] + for body in (contact.body_a, contact.body_b): + index = index_of.get(body.id) + if index is None: + index = len(bodies) + index_of[body.id] = index + bodies.append(body) + indices.append(index) + index_pairs.append((indices[0], indices[1])) + + self._bodies = bodies + self._velocity_x = [body.velocity.x for body in bodies] + self._velocity_y = [body.velocity.y for body in bodies] + self._angular_velocity = [body.angular_velocity for body in bodies] + # Positionen bei der Kollisionserkennung – Bezug für die spätere Positionskorrektur. + self._start_x = [body.position.x for body in bodies] + self._start_y = [body.position.y for body in bodies] + self._constraints = [ + self._make_constraint(contact, index_a, index_b) + for contact, (index_a, index_b) in zip(contacts, index_pairs, strict=True) + ] + + def _make_constraint(self, contact: Contact, index_a: int, index_b: int) -> _Constraint: + """Berechnet alle Größen eines Kontakts, die sich während der Iterationen nicht ändern. + + Läuft für jeden Kontakt in jedem Schritt und rechnet deshalb – wie die Iterationen – + komponentenweise mit Floats statt mit temporären Vektorobjekten. + """ + body_a = contact.body_a + body_b = contact.body_b + normal_x = contact.normal.x + normal_y = contact.normal.y + # Tangente = Normale um 90° gedreht (−n.y, n.x). Ihr Vorzeichen ist beliebig, weil + # der Reibungsimpuls symmetrisch auf [−μ·P_n, +μ·P_n] geklemmt wird. + tangent_x = -normal_y + tangent_y = normal_x + # Hebelarme r_A, r_B: vom jeweiligen Schwerpunkt zum Kontaktpunkt. + point = contact.point + offset_a_x = point.x - body_a.position.x + offset_a_y = point.y - body_a.position.y + offset_b_x = point.x - body_b.position.x + offset_b_y = point.y - body_b.position.y + + constraint = _Constraint() + constraint.contact = contact + constraint.index_a = index_a + constraint.index_b = index_b + constraint.normal_x = normal_x + constraint.normal_y = normal_y + constraint.tangent_x = tangent_x + constraint.tangent_y = tangent_y + constraint.offset_a_x = offset_a_x + constraint.offset_a_y = offset_a_y + constraint.offset_b_x = offset_b_x + constraint.offset_b_y = offset_b_y + constraint.inv_mass_a = body_a.inv_mass + constraint.inv_mass_b = body_b.inv_mass + constraint.inv_inertia_a = body_a.inv_inertia + constraint.inv_inertia_b = body_b.inv_inertia + + # Effektive Massen 1/K (siehe Moduldokumentation). Für die Normale entfallen die + # Drehterme, weil in dieser Engine stets r × n = 0 oder 1/I = 0 gilt. + inv_mass_sum = body_a.inv_mass + body_b.inv_mass + constraint.normal_mass = 1.0 / inv_mass_sum if inv_mass_sum > 0.0 else 0.0 + arm_a = offset_a_x * tangent_y - offset_a_y * tangent_x # r_A × t + arm_b = offset_b_x * tangent_y - offset_b_y * tangent_x # r_B × t + k_tangent = ( + inv_mass_sum + body_a.inv_inertia * arm_a * arm_a + body_b.inv_inertia * arm_b * arm_b + ) + constraint.tangent_mass = 1.0 / k_tangent if k_tangent > 0.0 else 0.0 + + # Rollwiderstand (siehe Moduldokumentation): nur, wenn sich mindestens ein Körper + # drehen kann. Als Radius dient der längere Hebelarm eines drehbaren Körpers. + inv_inertia_sum = body_a.inv_inertia + body_b.inv_inertia + if inv_inertia_sum > 0.0: + lever = max( + math.hypot(offset_a_x, offset_a_y) if body_a.can_rotate else 0.0, + math.hypot(offset_b_x, offset_b_y) if body_b.can_rotate else 0.0, + ) + constraint.rolling_mass = 1.0 / inv_inertia_sum + constraint.rolling_limit = self._config.rolling_resistance * lever + else: + constraint.rolling_mass = 0.0 + constraint.rolling_limit = 0.0 + + # Materialwerte mischen: Die Reibung als geometrisches Mittel (ein glatter Partner + # macht den Kontakt glatt), die Restitution als Maximum (ein Gummiball springt auch + # von einem Holzklotz ab). + constraint.friction = math.sqrt(body_a.material.friction * body_b.material.friction) + restitution = max(body_a.material.restitution, body_b.material.restitution) + + # Abprallen nach Newton: v_n' = −e·v_n. Die Aufprallgeschwindigkeit v_n stammt vom + # Beginn des Schritts – vor Schwerkraft und Warm-Start. Mit der Geschwindigkeit nach + # der Schwerkraft prallte ein Ball mit e·(v + g·dt) statt e·v ab und gewänne bei + # e = 1 mit jedem Aufprall Höhe (Energie aus dem Nichts). + velocity_a = body_a.previous_velocity + velocity_b = body_b.previous_velocity + normal_speed = (velocity_b.x - velocity_a.x) * normal_x + ( + velocity_b.y - velocity_a.y + ) * normal_y + # Kein Abprallen, (1) unterhalb der Schwelle: Aufliegende Körper würden sonst durch + # winzige Dauer-Hüpfer zittern; (2) wenn ein Körper gehalten wird: Die Hand fängt + # den Stoß ab. Sonst prallte ein gegen ein Hindernis gedrückter Körper in jedem + # Schritt ab und würde gleich wieder hingezogen – er zitterte. + bounces = normal_speed < -self._config.restitution_threshold + if bounces and not (body_a.held or body_b.held): + constraint.velocity_bias = -restitution * normal_speed + else: + constraint.velocity_bias = 0.0 + + constraint.normal_impulse = 0.0 + constraint.tangent_impulse = 0.0 + constraint.rolling_impulse = 0.0 + constraint.penetration = contact.penetration + return constraint + + def _warm_start(self) -> None: + """Wendet die Impulse des letzten Schritts erneut an (bessere Startwerte).""" + cache = self._impulse_cache + min_alignment = self._config.warm_start_min_alignment + velocity_x = self._velocity_x + velocity_y = self._velocity_y + angular = self._angular_velocity + for c in self._constraints: + cached = cache.get(c.contact.key) + if cached is None: + continue + normal_impulse, tangent_impulse, rolling_impulse, old_normal_x, old_normal_y = cached + if old_normal_x * c.normal_x + old_normal_y * c.normal_y < min_alignment: + continue # Normale hat sich zu stark gedreht, der alte Impuls passt nicht. + c.normal_impulse = normal_impulse + c.tangent_impulse = tangent_impulse + c.rolling_impulse = rolling_impulse + impulse_x = normal_impulse * c.normal_x + tangent_impulse * c.tangent_x + impulse_y = normal_impulse * c.normal_y + tangent_impulse * c.tangent_y + a = c.index_a + b = c.index_b + velocity_x[a] -= impulse_x * c.inv_mass_a + velocity_y[a] -= impulse_y * c.inv_mass_a + angular[a] -= c.inv_inertia_a * ( + c.offset_a_x * impulse_y - c.offset_a_y * impulse_x + rolling_impulse + ) + velocity_x[b] += impulse_x * c.inv_mass_b + velocity_y[b] += impulse_y * c.inv_mass_b + angular[b] += c.inv_inertia_b * ( + c.offset_b_x * impulse_y - c.offset_b_y * impulse_x + rolling_impulse + ) + + def _solve_velocity_iteration(self) -> None: + """Ein Gauß-Seidel-Durchlauf über alle Kontakte. + + Reihenfolge je Kontakt: Normale, dann Reibung, dann Rollwiderstand. Reibung und + Rollwiderstand sind durch den Normalimpuls begrenzt; werden sie danach gelöst, gilt + ihre Grenze nach jedem Durchlauf exakt für den aktuellen Normalimpuls. (Andersherum + könnte der Normalimpuls hinterher noch sinken und die Reibung über der + Coulomb-Grenze zurücklassen.) + """ + velocity_x = self._velocity_x + velocity_y = self._velocity_y + angular = self._angular_velocity + for c in self._constraints: + a = c.index_a + b = c.index_b + + # --- Normale: Drehterme entfallen hier (siehe Moduldokumentation). + normal_speed = (velocity_x[b] - velocity_x[a]) * c.normal_x + ( + velocity_y[b] - velocity_y[a] + ) * c.normal_y + # λ = (Ziel − v_n)/K; Ziel ist die Abprallgeschwindigkeit (oder 0). + old_impulse = c.normal_impulse + new_impulse = old_impulse + (c.velocity_bias - normal_speed) * c.normal_mass + if new_impulse < 0.0: + new_impulse = 0.0 # Kontakte können nur drücken, nie ziehen. + c.normal_impulse = new_impulse + delta = new_impulse - old_impulse + impulse_x = delta * c.normal_x + impulse_y = delta * c.normal_y + velocity_x[a] -= impulse_x * c.inv_mass_a + velocity_y[a] -= impulse_y * c.inv_mass_a + velocity_x[b] += impulse_x * c.inv_mass_b + velocity_y[b] += impulse_y * c.inv_mass_b + + # --- Reibung: v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), ω × r = (−ω·r.y, ω·r.x) + rel_x = (velocity_x[b] - angular[b] * c.offset_b_y) - ( + velocity_x[a] - angular[a] * c.offset_a_y + ) + rel_y = (velocity_y[b] + angular[b] * c.offset_b_x) - ( + velocity_y[a] + angular[a] * c.offset_a_x + ) + tangent_speed = rel_x * c.tangent_x + rel_y * c.tangent_y + # λ so, dass das Gleiten verschwindet; danach Coulomb-Grenze auf den Gesamtimpuls. + max_friction = c.friction * c.normal_impulse + old_impulse = c.tangent_impulse + new_impulse = old_impulse - tangent_speed * c.tangent_mass + if new_impulse > max_friction: + new_impulse = max_friction + elif new_impulse < -max_friction: + new_impulse = -max_friction + c.tangent_impulse = new_impulse + delta = new_impulse - old_impulse + impulse_x = delta * c.tangent_x + impulse_y = delta * c.tangent_y + velocity_x[a] -= impulse_x * c.inv_mass_a + velocity_y[a] -= impulse_y * c.inv_mass_a + angular[a] -= c.inv_inertia_a * (c.offset_a_x * impulse_y - c.offset_a_y * impulse_x) + velocity_x[b] += impulse_x * c.inv_mass_b + velocity_y[b] += impulse_y * c.inv_mass_b + angular[b] += c.inv_inertia_b * (c.offset_b_x * impulse_y - c.offset_b_y * impulse_x) + + # --- Rollwiderstand: bremst die relative Drehung, höchstens μ_r·P_n·r. + if c.rolling_mass > 0.0: + max_rolling = c.rolling_limit * c.normal_impulse + old_impulse = c.rolling_impulse + new_impulse = old_impulse - (angular[b] - angular[a]) * c.rolling_mass + if new_impulse > max_rolling: + new_impulse = max_rolling + elif new_impulse < -max_rolling: + new_impulse = -max_rolling + c.rolling_impulse = new_impulse + delta = new_impulse - old_impulse + angular[a] -= c.inv_inertia_a * delta + angular[b] += c.inv_inertia_b * delta + + def _store_velocities(self) -> None: + """Überträgt die Ergebnisse auf Körper und Kontakte und merkt sich die Impulse.""" + for index, body in enumerate(self._bodies): + if body.is_static: + continue + body.velocity = Vector2(self._velocity_x[index], self._velocity_y[index]) + body.angular_velocity = self._angular_velocity[index] + + cache: dict[tuple[int, int], _CachedImpulses] = {} + for c in self._constraints: + contact = c.contact + contact.normal_impulse = c.normal_impulse + contact.tangent_impulse = c.tangent_impulse + cache[contact.key] = ( + c.normal_impulse, + c.tangent_impulse, + c.rolling_impulse, + c.normal_x, + c.normal_y, + ) + self._impulse_cache = cache + + # ------------------------------------------------------------------ Positionen + + def correct_positions(self) -> None: + """Baut verbliebene Durchdringungen der zuletzt gelösten Kontakte ab. + + Warum überhaupt? Impulse korrigieren nur Geschwindigkeiten. Durch den diskreten + Zeitschritt überlappen Körper trotzdem ein wenig (ein fallender Körper "landet" + meist schon etwas in der Unterlage), und ohne Korrektur würden Stapel langsam + ineinander einsinken. + + Warum über Positionen statt über Zusatzgeschwindigkeit? Eine Geschwindigkeit, die + Körper auseinandertreibt, bleibt nach der Trennung erhalten und schleudert sie + davon – besonders bei großen Überlappungen, etwa wenn ein Objekt mitten in einen + Haufen gesetzt wird. Die direkte Verschiebung ("Projektion") erzeugt dagegen keine + Bewegungsenergie. + + Verschoben wird im Verhältnis der inversen Massen – der leichtere Körper weicht + weiter aus, eine Wand gar nicht. Die kleine Toleranz ``penetration_slop`` bleibt + stehen, damit der Kontakt im nächsten Schritt sicher wieder erkannt wird. + """ + constraints = self._constraints + if not constraints: + return + config = self._config + slop = config.penetration_slop + factor = config.position_correction_factor + max_correction = config.max_position_correction + bodies = self._bodies + position_x = [body.position.x for body in bodies] + position_y = [body.position.y for body in bodies] + start_x = self._start_x + start_y = self._start_y + + for _ in range(config.position_iterations): + for c in constraints: + a = c.index_a + b = c.index_b + # Aktuelle Tiefe = Tiefe bei der Erkennung minus die seither erfolgte + # Relativbewegung entlang der Normalen. Für Wände und Rechtecke ist das exakt + # (keine Drehung), für Kreise eine sehr gute Näherung – und viel billiger als + # eine erneute Kollisionserkennung. + moved_x = (position_x[b] - start_x[b]) - (position_x[a] - start_x[a]) + moved_y = (position_y[b] - start_y[b]) - (position_y[a] - start_y[a]) + penetration = c.penetration - (moved_x * c.normal_x + moved_y * c.normal_y) + correction = (penetration - slop) * factor + if correction <= 0.0: + continue + correction = min(correction, max_correction) + # Aufteilen im Verhältnis der inversen Massen (Summe > 0, da nie zwei + # statische Körper einen Kontakt bilden). + share = correction / (c.inv_mass_a + c.inv_mass_b) + shift_a = share * c.inv_mass_a + shift_b = share * c.inv_mass_b + position_x[a] -= c.normal_x * shift_a + position_y[a] -= c.normal_y * shift_a + position_x[b] += c.normal_x * shift_b + position_y[b] += c.normal_y * shift_b + + for index, body in enumerate(bodies): + if body.is_static: + continue + new_x = position_x[index] + new_y = position_y[index] + if new_x != body.position.x or new_y != body.position.y: + body.position = Vector2(new_x, new_y) diff --git a/src/sandbox/physics/timestep.py b/src/sandbox/physics/timestep.py new file mode 100644 index 0000000..6b145a7 --- /dev/null +++ b/src/sandbox/physics/timestep.py @@ -0,0 +1,69 @@ +"""Fester Zeitschritt, entkoppelt von der Bildrate ("Fixed Timestep"). + +Warum: Die Stabilität der Integration hängt vom Zeitschritt ab. Würde die Physik einfach +die (schwankende) Frame-Dauer als dt verwenden, verhielte sich die Simulation auf +schnellen und langsamen Rechnern unterschiedlich, und ein einzelner Ruckler (z. B. 200 ms +beim Verschieben des Fensters) ließe Körper durcheinander hindurchspringen. + +Stattdessen sammelt ein Akkumulator die real vergangene Zeit und gibt sie in festen +Portionen ``dt`` an die Physik weiter. Der Rest bleibt für den nächsten Frame liegen; sein +Anteil an dt (``alpha``) erlaubt es, beim Zeichnen zwischen den letzten beiden +Physikzuständen zu interpolieren, damit die Bewegung trotzdem flüssig aussieht. +""" + +from __future__ import annotations + +import math + +from sandbox.config import NUMERIC_EPSILON + + +class FixedTimestep: + """Akkumulator, der Frame-Zeiten in eine Anzahl fester Physikschritte übersetzt. + + Args: + step: Fester Zeitschritt in s. + max_steps_per_frame: Obergrenze der Schritte pro Frame. Schafft der Rechner die + Physik nicht in Echtzeit, würde der Rückstand sonst immer weiter wachsen + ("Spiral of Death"). Mit Obergrenze läuft die Simulation dann nur langsamer. + """ + + def __init__(self, step: float, max_steps_per_frame: int) -> None: + if not step > 0.0: + raise ValueError(f"Der Zeitschritt muss positiv sein: {step}") + if max_steps_per_frame < 1: + raise ValueError("Es muss mindestens ein Schritt pro Frame erlaubt sein") + self.step = step + self.max_steps_per_frame = max_steps_per_frame + self._accumulator = 0.0 + + @property + def alpha(self) -> float: + """Anteil des angebrochenen Schritts in [0, 1) – Gewicht für die Interpolation.""" + return self._accumulator / self.step + + def advance(self, frame_time: float) -> int: + """Nimmt die Dauer eines Frames entgegen und liefert die Zahl fälliger Schritte. + + Args: + frame_time: Real vergangene Zeit seit dem letzten Aufruf in s. + + Returns: + Anzahl der jetzt auszuführenden Physikschritte. + """ + if frame_time < 0.0: + raise ValueError(f"Die Frame-Zeit darf nicht negativ sein: {frame_time}") + self._accumulator += frame_time + # Das kleine Epsilon fängt Rundungsfehler ab: 2·(1/120) ergibt als Gleitkommazahl + # nicht exakt 1/60, und ohne Toleranz ginge gelegentlich ein Schritt "verloren". + steps = math.floor(self._accumulator / self.step + NUMERIC_EPSILON) + if steps > self.max_steps_per_frame: + steps = self.max_steps_per_frame + self._accumulator = 0.0 # Rückstand verwerfen statt ihn endlos mitzuschleppen. + else: + self._accumulator = max(0.0, self._accumulator - steps * self.step) + return steps + + def reset(self) -> None: + """Verwirft angesammelte Zeit (z. B. beim Pausieren).""" + self._accumulator = 0.0 diff --git a/src/sandbox/physics/vector.py b/src/sandbox/physics/vector.py new file mode 100644 index 0000000..7e1c681 --- /dev/null +++ b/src/sandbox/physics/vector.py @@ -0,0 +1,162 @@ +"""Eigene 2D-Vektorklasse der Physik-Schicht. + +Die Physik nutzt bewusst weder ``pygame.Vector2`` noch externe Mathematik-Bibliotheken, +sondern nur diese Klasse und das Standardmodul :mod:`math`. Dadurch hängt die Physik nicht +von einer Grafikbibliothek ab und lässt sich isoliert testen. +""" + +from __future__ import annotations + +import math +from typing import Final + +from sandbox.config import NUMERIC_EPSILON + +# ``object.__setattr__`` einmal nachschlagen statt bei jedem Konstruktoraufruf: Vektoren +# entstehen tausendfach pro Simulationsschritt, und das normale Setzen ist gesperrt (s. u.). +_set_slot = object.__setattr__ + + +class Vector2: + """Unveränderlicher zweidimensionaler Vektor. + + Jede Operation liefert einen neuen Vektor, kein Operand wird verändert. Vektoren + verhalten sich damit wie Zahlen: Ein Körper kann seine Position herausgeben, ohne dass + jemand sie über die geteilte Referenz versehentlich mitverändert. Zuweisungen wie + ``v.x = 1.0`` lösen deshalb einen :class:`AttributeError` aus. + + Attributes: + x: x-Komponente. + y: y-Komponente (zeigt wie auf dem Bildschirm nach unten). + """ + + __slots__ = ("x", "y") + + x: float + y: float + + def __init__(self, x: float = 0.0, y: float = 0.0) -> None: + _set_slot(self, "x", x) + _set_slot(self, "y", y) + + # ------------------------------------------------------------------ Unveränderlichkeit + + def __setattr__(self, name: str, value: object) -> None: + raise AttributeError(f"Vector2 ist unveränderlich ('{name}' kann nicht gesetzt werden)") + + def __delattr__(self, name: str) -> None: + raise AttributeError(f"Vector2 ist unveränderlich ('{name}' kann nicht gelöscht werden)") + + def __reduce__(self) -> tuple[type[Vector2], tuple[float, float]]: + # copy und pickle würden die Slots sonst per setattr befüllen – das ist gesperrt. + return (Vector2, (self.x, self.y)) + + # ------------------------------------------------------------------ Arithmetik + + def __add__(self, other: Vector2) -> Vector2: + return Vector2(self.x + other.x, self.y + other.y) + + def __sub__(self, other: Vector2) -> Vector2: + return Vector2(self.x - other.x, self.y - other.y) + + def __mul__(self, scalar: float) -> Vector2: + return Vector2(self.x * scalar, self.y * scalar) + + def __rmul__(self, scalar: float) -> Vector2: + return Vector2(self.x * scalar, self.y * scalar) + + def __truediv__(self, scalar: float) -> Vector2: + return Vector2(self.x / scalar, self.y / scalar) + + def __neg__(self) -> Vector2: + return Vector2(-self.x, -self.y) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Vector2): + return NotImplemented + return self.x == other.x and self.y == other.y + + def __hash__(self) -> int: + return hash((self.x, self.y)) + + def __repr__(self) -> str: + return f"Vector2({self.x!r}, {self.y!r})" + + # ------------------------------------------------------------------ Vektoroperationen + + def dot(self, other: Vector2) -> float: + """Skalarprodukt ``a·b = a.x·b.x + a.y·b.y``. + + Liefert die Länge der Projektion von ``other`` auf diesen Vektor (mal dessen Länge). + Mit einer Einheitsnormalen n misst ``v.dot(n)`` also, wie schnell sich etwas + entlang n bewegt. + """ + return self.x * other.x + self.y * other.y + + def cross(self, other: Vector2) -> float: + """z-Komponente des Kreuzprodukts ``a × b = a.x·b.y − a.y·b.x``. + + In 2D ist das Kreuzprodukt ein Skalar. Physikalisch ist ``r × F`` das Drehmoment, + das eine Kraft (bzw. ein Impuls) F am Hebelarm r erzeugt. + """ + return self.x * other.y - self.y * other.x + + def length(self) -> float: + """Euklidische Länge (Betrag) des Vektors.""" + return math.hypot(self.x, self.y) + + def length_squared(self) -> float: + """Quadrat der Länge – spart die Wurzel, wenn nur Längen verglichen werden.""" + return self.x * self.x + self.y * self.y + + def normalized(self) -> Vector2: + """Einheitsvektor gleicher Richtung. + + Der Nullvektor hat keine Richtung. Statt eine Division durch 0 (und damit NaN-Werte, + die sich durch die ganze Simulation fressen würden) zu riskieren, wird dann der + Nullvektor zurückgegeben; der Aufrufer entscheidet über eine Ersatzrichtung. + """ + length = math.hypot(self.x, self.y) + if length <= NUMERIC_EPSILON: + return Vector2(0.0, 0.0) + return Vector2(self.x / length, self.y / length) + + def perpendicular(self) -> Vector2: + """Um +90° gedrehter Vektor ``(−y, x)``. + + Nützlich für Tangenten und für ``ω × r``: Die Bahngeschwindigkeit eines Punktes im + Abstand r eines mit ω drehenden Körpers ist ``ω · r.perpendicular()``. + """ + return Vector2(-self.y, self.x) + + def distance_to(self, other: Vector2) -> float: + """Abstand zwischen zwei Punkten.""" + return math.hypot(other.x - self.x, other.y - self.y) + + def clamped(self, max_length: float) -> Vector2: + """Vektor gleicher Richtung, dessen Länge höchstens ``max_length`` beträgt.""" + length_sq = self.x * self.x + self.y * self.y + if length_sq <= max_length * max_length: + return self + scale = max_length / math.sqrt(length_sq) + return Vector2(self.x * scale, self.y * scale) + + def lerp(self, other: Vector2, t: float) -> Vector2: + """Lineare Interpolation: ``t = 0`` liefert ``self``, ``t = 1`` liefert ``other``.""" + return Vector2(self.x + (other.x - self.x) * t, self.y + (other.y - self.y) * t) + + def is_finite(self) -> bool: + """True, wenn keine Komponente NaN oder unendlich ist.""" + return math.isfinite(self.x) and math.isfinite(self.y) + + def is_close(self, other: Vector2, tolerance: float = NUMERIC_EPSILON) -> bool: + """Komponentenweiser Vergleich mit absoluter Toleranz.""" + return abs(self.x - other.x) <= tolerance and abs(self.y - other.y) <= tolerance + + def to_tuple(self) -> tuple[float, float]: + """Komponenten als Tupel ``(x, y)``, z. B. für Zeichenfunktionen.""" + return (self.x, self.y) + + +#: Der Nullvektor. Da Vektoren unveränderlich sind, darf er überall geteilt werden. +ZERO: Final = Vector2(0.0, 0.0) diff --git a/src/sandbox/physics/world.py b/src/sandbox/physics/world.py new file mode 100644 index 0000000..8981c4b --- /dev/null +++ b/src/sandbox/physics/world.py @@ -0,0 +1,280 @@ +"""Die Physikwelt: verwaltet Körper und Wände und führt Simulationsschritte aus.""" + +from __future__ import annotations + +import math + +from sandbox.config import PhysicsConfig +from sandbox.physics.body import Body, Material +from sandbox.physics.broadphase import find_candidate_pairs +from sandbox.physics.collision import Contact, collide +from sandbox.physics.sleep import update_sleep, wake_touched_bodies +from sandbox.physics.solver import ContactSolver +from sandbox.physics.vector import ZERO, Vector2 + + +class World: + """Rechteckige Welt mit Wänden an allen vier Seiten. + + Koordinaten: Ursprung oben links, x nach rechts, y nach unten, Einheit px. Die Welt + kennt keinen Bildschirm – wo sie gezeichnet wird, entscheidet die Anwendung. + + Ein Aufruf von :meth:`step` rückt die Simulation um einen festen Zeitschritt vor + (semi-impliziter Euler mit Kontaktauflösung, Details in :meth:`step`). + + Args: + width: Breite der Welt in px. + height: Höhe der Welt in px. + config: Physikparameter. + + Attributes: + width: Breite der Welt in px. + height: Höhe der Welt in px. + config: Physikparameter. + bodies: Alle dynamischen Körper in Einfügereihenfolge (später eingefügte liegen beim + Zeichnen oben). + walls: Die vier statischen Wände (links, rechts, oben, unten). + contacts: Die Kontakte des letzten Schritts (für die Debug-Anzeige). + step_count: Anzahl bisher ausgeführter Schritte. + """ + + def __init__(self, width: float, height: float, config: PhysicsConfig) -> None: + if not (width > 0.0 and height > 0.0): + raise ValueError(f"Die Welt braucht eine positive Größe: {width} x {height}") + self.width = width + self.height = height + self.config = config + self.bodies: list[Body] = [] + self.contacts: list[Contact] = [] + self.step_count = 0 + self._gravity_enabled = True + self._solver = ContactSolver(config) + + wall_material = Material( + density=0.0, restitution=config.wall_restitution, friction=config.wall_friction + ) + # Jede Wandnormale zeigt in die Welt hinein; frei ist die Seite normal·p ≥ offset. + self.walls: list[Body] = [ + Body.wall(Vector2(1.0, 0.0), 0.0, wall_material), # links: x ≥ 0 + Body.wall(Vector2(-1.0, 0.0), -width, wall_material), # rechts: x ≤ width + Body.wall(Vector2(0.0, 1.0), 0.0, wall_material), # oben: y ≥ 0 + Body.wall(Vector2(0.0, -1.0), -height, wall_material), # unten: y ≤ height + ] + + # ------------------------------------------------------------------ Schwerkraft + + @property + def gravity_enabled(self) -> bool: + """Ob die Schwerkraft wirkt.""" + return self._gravity_enabled + + @gravity_enabled.setter + def gravity_enabled(self, enabled: bool) -> None: + if enabled != self._gravity_enabled: + self._gravity_enabled = enabled + # Schlafende Körper würden die Änderung sonst nicht bemerken und z. B. ohne + # Schwerkraft am Boden kleben bleiben bzw. mit Schwerkraft in der Luft hängen. + self.wake_all() + + @property + def gravity(self) -> Vector2: + """Aktuell wirkende Schwerkraftbeschleunigung (Nullvektor, wenn abgeschaltet).""" + if not self._gravity_enabled: + return ZERO + return Vector2(*self.config.gravity) + + # ------------------------------------------------------------------ Körperverwaltung + + def add_body(self, body: Body) -> Body: + """Fügt einen dynamischen Körper hinzu und gibt ihn zurück.""" + if body.is_static: + raise ValueError("Statische Körper werden von der Welt selbst verwaltet (Wände)") + self.bodies.append(body) + return body + + def remove_body(self, body: Body) -> None: + """Entfernt einen Körper und weckt alles, was auf ihm gelegen haben könnte. + + Dafür genügt es, seine Schlaf-Insel zu wecken: Schlafende Körper, die einander + berühren, sind immer gemeinsam eingeschlafen und gehören damit zur selben Insel. + """ + self.bodies.remove(body) + body.wake() + + def clear(self) -> None: + """Entfernt alle Körper.""" + self.bodies.clear() + self.contacts = [] + self._solver.reset() + + def wake_all(self) -> None: + """Weckt alle Körper auf.""" + for body in self.bodies: + body.wake() + + def body_at(self, point: Vector2, tolerance: float = 0.0) -> Body | None: + """Oberster Körper an einem Punkt (später eingefügte Körper liegen oben). + + Args: + point: Punkt in Weltkoordinaten. + tolerance: Vergrößert die Körper beim Test, damit kleine Objekte leichter zu + treffen sind. + """ + for body in reversed(self.bodies): + if body.contains_point(point, tolerance): + return body + return None + + def clamp_inside(self, position: Vector2, half_extents: tuple[float, float]) -> Vector2: + """Verschiebt eine Mittelpunktsposition so, dass ein Körper der Größe ganz hineinpasst.""" + half_x, half_y = half_extents + x = min(max(position.x, half_x), self.width - half_x) + y = min(max(position.y, half_y), self.height - half_y) + if x == position.x and y == position.y: + return position + return Vector2(x, y) + + # ------------------------------------------------------------------ Simulation + + def step(self, dt: float) -> None: + """Rückt die Simulation um ``dt`` Sekunden vor. + + Ablauf (semi-impliziter Euler mit Kontaktauflösung): + + 1. Zustand merken: Position und Winkel für die Zeichen-Interpolation, die + Geschwindigkeit für die Aufprallgeschwindigkeit beim Abprallen. + 2. Geschwindigkeiten integrieren: ``v += g·dt`` (plus Dämpfung). + 3. Kontakte suchen: Sweep-and-Prune, dann genaue Tests; schlafende Körper, die + berührt werden, samt ihrer Schlaf-Insel wecken; danach Wandkontakte der wachen + Körper. + 4. Impulse lösen (Restitution, Reibung, Rollwiderstand) und Geschwindigkeiten + begrenzen. + 5. Positionen integrieren: ``x += v·dt`` – mit der *neuen* Geschwindigkeit. Genau + das macht den Euler "semi-implizit" (symplektisch): Anders als beim expliziten + Euler wächst die Energie dabei nicht systematisch an, Umlaufbahnen und + Schwingungen bleiben stabil. + 6. Verbliebene Durchdringungen über die Positionen abbauen. + 7. Notbremse: kein Körper ragt weiter als ``max_wall_penetration`` in eine Wand. + 8. Schlafmodus aktualisieren. + """ + awake = [body for body in self.bodies if not body.sleeping] + self._remember_state(awake) + + self._integrate_velocities(awake, dt) + + contacts = self._find_body_contacts() + woken = wake_touched_bodies(contacts) + self._remember_state(woken) + awake.extend(woken) + contacts.extend(self._find_wall_contacts(awake)) + + self._solver.solve_velocities(contacts) + self._clamp_velocities(awake) + self._integrate_positions(awake, dt) + self._solver.correct_positions() + self._enforce_bounds(awake) + + if self.config.sleep_enabled: + update_sleep(awake, contacts, dt, self.config) + self.contacts = contacts + self.step_count += 1 + + @staticmethod + def _remember_state(bodies: list[Body]) -> None: + """Merkt sich den Zustand zu Beginn des Schritts (siehe ``Body.previous_*``).""" + for body in bodies: + body.previous_position = body.position + body.previous_angle = body.angle + body.previous_velocity = body.velocity + + def _integrate_velocities(self, bodies: list[Body], dt: float) -> None: + """Wendet Schwerkraft und Dämpfung auf die Geschwindigkeiten an.""" + gravity = self.gravity + gravity_dx = gravity.x * dt + gravity_dy = gravity.y * dt + # Dämpfung als v ← v / (1 + c·dt): Näherung von v·e^(−c·dt), die anders als + # v·(1 − c·dt) auch bei großem c·dt nie das Vorzeichen umkehrt. + linear_factor = 1.0 / (1.0 + dt * self.config.linear_damping) + angular_factor = 1.0 / (1.0 + dt * self.config.angular_damping) + for body in bodies: + if body.held: + continue # Die Geschwindigkeit gehaltener Körper gibt die Maus vor. + velocity = body.velocity + body.velocity = Vector2( + (velocity.x + gravity_dx) * linear_factor, + (velocity.y + gravity_dy) * linear_factor, + ) + body.angular_velocity *= angular_factor + + def _find_body_contacts(self) -> list[Contact]: + """Kontakte zwischen dynamischen Körpern (grobe Vorauswahl, dann genauer Test).""" + contacts: list[Contact] = [] + for body_a, body_b in find_candidate_pairs(self.bodies): + contact = collide(body_a, body_b) + if contact is not None: + contacts.append(contact) + return contacts + + def _find_wall_contacts(self, bodies: list[Body]) -> list[Contact]: + """Kontakte der wachen Körper mit den Wänden.""" + left, right, top, bottom = self.walls + width = self.width + height = self.height + contacts: list[Contact] = [] + for body in bodies: + min_x, min_y, max_x, max_y = body.bounds() + # Billiger Vortest über die umschließende Box, genauer Test nur bei Bedarf. + for touches, wall in ( + (min_x <= 0.0, left), + (max_x >= width, right), + (min_y <= 0.0, top), + (max_y >= height, bottom), + ): + if touches: + contact = collide(body, wall) + if contact is not None: + contacts.append(contact) + return contacts + + def _clamp_velocities(self, bodies: list[Body]) -> None: + """Begrenzt Geschwindigkeit und Drehung (Schutz vor Tunneln und Explosionen).""" + max_speed = self.config.max_speed + max_speed_sq = max_speed * max_speed + max_angular = self.config.max_angular_speed + for body in bodies: + velocity = body.velocity + speed_sq = velocity.length_squared() + if speed_sq > max_speed_sq: + body.velocity = velocity * (max_speed / math.sqrt(speed_sq)) + if abs(body.angular_velocity) > max_angular: + body.angular_velocity = math.copysign(max_angular, body.angular_velocity) + + def _integrate_positions(self, bodies: list[Body], dt: float) -> None: + """``x += v·dt`` und ``θ += ω·dt`` mit den bereits aktualisierten Geschwindigkeiten.""" + full_turn = math.tau + for body in bodies: + velocity = body.velocity + position = body.position + body.position = Vector2(position.x + velocity.x * dt, position.y + velocity.y * dt) + if body.angular_velocity != 0.0: + # Winkel im Bereich [−π, π] halten, damit er über lange Zeit nicht an + # Genauigkeit verliert. + body.angle = math.remainder(body.angle + body.angular_velocity * dt, full_turn) + + def _enforce_bounds(self, bodies: list[Body]) -> None: + """Notbremse: Kein Körper ragt weiter als ``max_wall_penetration`` in eine Wand. + + Im Normalfall halten die Wandkontakte die Körper in der Welt. Die Grenze liegt über + der größten Eindringtiefe eines frei fliegenden Körpers pro Schritt und greift daher + nur in Extremfällen, etwa wenn ein Körper von einem gezogenen Objekt gegen die Wand + gequetscht wird. Geschwindigkeiten bleiben unangetastet: Den eigentlichen Stoß mit + der Wand löst im nächsten Schritt der Solver. + """ + allowed = self.config.max_wall_penetration + for body in bodies: + half_x, half_y = body.half_extents() + position = body.position + x = min(max(position.x, half_x - allowed), self.width - half_x + allowed) + y = min(max(position.y, half_y - allowed), self.height - half_y + allowed) + if x != position.x or y != position.y: + body.position = Vector2(x, y) diff --git a/src/sandbox/renderer.py b/src/sandbox/renderer.py new file mode 100644 index 0000000..b018da5 --- /dev/null +++ b/src/sandbox/renderer.py @@ -0,0 +1,243 @@ +"""Zeichnet die Physikwelt: Hintergrund, Körper, Hervorhebungen, Vorschau und Debug-Ansicht.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +import pygame + +from sandbox.config import Color +from sandbox.interaction import BodyStyle, ShapeKind +from sandbox.physics import ZERO, Body, Circle, Vector2, World +from sandbox.ui.drawing import draw_box, draw_circle +from sandbox.ui.style import UiStyle, shade +from sandbox.viewport import Viewport + + +@dataclass(frozen=True) +class SpawnPreview: + """Halbtransparente Vorschau des Objekts, das ein Klick erzeugen würde. + + Attributes: + shape: Form des Objekts. + half_extents: Halbe Breite und Höhe in px. + color: Farbe des Objekts. + screen_center: Mittelpunkt auf dem Bildschirm (kann über der Sidebar liegen, + wenn ein Objekt gerade aus der Vorschau-Kachel gezogen wird). + """ + + shape: ShapeKind + half_extents: tuple[float, float] + color: Color + screen_center: tuple[float, float] + + +@dataclass +class WorldOverlay: + """Zustand der Bedienung, der über der Welt angezeigt wird. + + Attributes: + hovered: Körper unter der Maus. + held: Gezogener Körper. + throw_velocity: Aktuelle Wurfgeschwindigkeit des gezogenen Körpers. + paused: Ob die Simulation pausiert ist. + debug: Ob die Debug-Ansicht (Kontakte, Schlafzustand) aktiv ist. + debug_lines: Textzeilen der Debug-Ansicht. + """ + + hovered: Body | None = None + held: Body | None = None + throw_velocity: Vector2 = ZERO + paused: bool = False + debug: bool = False + debug_lines: list[str] = field(default_factory=list) + + +def interpolate_angle(previous: float, current: float, alpha: float) -> float: + """Interpoliert einen Winkel auf dem kürzeren Weg. + + Der Winkel wird in der Physik auf [−π, π] gehalten. Springt er von +3,1 auf −3,1, hat + sich der Körper nur um 0,08 rad gedreht – naive Interpolation würde ihn für einen Frame + fast eine ganze Umdrehung zurückdrehen lassen. + """ + return previous + math.remainder(current - previous, math.tau) * alpha + + +class Renderer: + """Zeichnet die Welt in ihren Bildschirmbereich. + + Args: + screen: Zielfläche (das Fenster). + viewport: Lage der Welt auf dem Bildschirm. + style: Farben, Schriften, Maße. + """ + + def __init__(self, screen: pygame.Surface, viewport: Viewport, style: UiStyle) -> None: + self.screen = screen + self.viewport = viewport + self.style = style + self._background = self._render_background() + + def _render_background(self) -> pygame.Surface: + """Hintergrund mit Hilfsgitter – einmal vorberechnet statt jedes Bild neu.""" + theme = self.style.theme + spacing = self.style.ui.grid_spacing + width, height = self.viewport.width, self.viewport.height + surface = pygame.Surface((width, height)) + surface.fill(theme.world_background) + for x in range(spacing, width, spacing): + pygame.draw.line(surface, theme.grid, (x, 0), (x, height)) + for y in range(spacing, height, spacing): + pygame.draw.line(surface, theme.grid, (0, y), (width, y)) + return surface + + # ------------------------------------------------------------------ Welt + + def draw_world(self, world: World, alpha: float, overlay: WorldOverlay) -> None: + """Zeichnet Hintergrund, alle Körper und die Bedien-Hinweise. + + Args: + world: Die Welt. + alpha: Interpolationsgewicht zwischen letztem und aktuellem Physikzustand. + overlay: Hervorhebungen und Zusatzanzeigen. + """ + screen = self.screen + area = pygame.Rect(self.viewport.rect) + screen.blit(self._background, area.topleft) + screen.set_clip(area) # Körper, die leicht in eine Wand ragen, nicht über die Sidebar + for body in world.bodies: + self._draw_body(body, alpha, overlay) + if overlay.held is not None: + self._draw_throw_arrow(overlay.held, overlay.throw_velocity) + if overlay.debug: + self._draw_debug(world, overlay.debug_lines) + screen.set_clip(None) + pygame.draw.rect(screen, self.style.theme.world_border, area, self.style.ui.border_width) + if overlay.paused: + self._draw_pause_badge() + + def _draw_body(self, body: Body, alpha: float, overlay: WorldOverlay) -> None: + theme = self.style.theme + ui = self.style.ui + # Zwischen zwei Physikschritten interpolieren: Die Physik läuft mit 120 Hz, der + # Bildschirm z. B. mit 60 Hz – ohne Interpolation ruckelt die Bewegung sichtbar. + position = body.previous_position.lerp(body.position, alpha) + center = self.viewport.to_screen(position) + style = body.user_data + color = style.color if isinstance(style, BodyStyle) else theme.body_palette[0] + outline = shade(color, theme.outline_shade) + if body.sleeping and overlay.debug: + outline = theme.debug_sleeping + + if body is overlay.held: + highlight: Color | None = theme.held_outline + elif body is overlay.hovered: + highlight = theme.highlight + else: + highlight = None + + shape = body.shape + if isinstance(shape, Circle): + angle = interpolate_angle(body.previous_angle, body.angle, alpha) + draw_circle( + self.screen, + color, + outline, + center, + shape.radius, + outline_width=ui.outline_width, + angle=angle, + marker_length=ui.marker_length, + ) + if highlight is not None: + pygame.draw.aacircle( + self.screen, highlight, center, shape.radius, ui.highlight_width + ) + else: + half_extents = shape.half_extents() + draw_box( + self.screen, color, outline, center, half_extents, outline_width=ui.outline_width + ) + if highlight is not None: + draw_box( + self.screen, + color, + highlight, + center, + half_extents, + outline_width=ui.highlight_width, + ) + + def _draw_throw_arrow(self, body: Body, velocity: Vector2) -> None: + """Linie in Wurfrichtung; ihre Länge zeigt, wie weit der Körper in kurzer Zeit flöge.""" + if velocity.length_squared() == 0.0: + return + ui = self.style.ui + start = self.viewport.to_screen(body.position) + end = ( + start[0] + velocity.x * ui.throw_arrow_time, + start[1] + velocity.y * ui.throw_arrow_time, + ) + color = self.style.theme.throw_arrow + pygame.draw.line(self.screen, color, start, end, ui.outline_width) + pygame.draw.aacircle(self.screen, color, end, ui.debug_contact_radius) + + def _draw_debug(self, world: World, lines: list[str]) -> None: + """Kontaktpunkte, Kontaktnormalen und Kennzahlen.""" + theme = self.style.theme + ui = self.style.ui + for contact in world.contacts: + point = self.viewport.to_screen(contact.point) + end = ( + point[0] + contact.normal.x * ui.debug_normal_length, + point[1] + contact.normal.y * ui.debug_normal_length, + ) + pygame.draw.line(self.screen, theme.debug_normal, point, end) + pygame.draw.circle(self.screen, theme.debug_contact, point, ui.debug_contact_radius) + x = self.viewport.left + ui.padding + y = self.viewport.top + ui.padding + for line in lines: + rendered = self.style.render_text(line, self.style.small_font, theme.text) + self.screen.blit(rendered, (x, y)) + y += rendered.get_height() + + def _draw_pause_badge(self) -> None: + theme = self.style.theme + ui = self.style.ui + text = self.style.render_text( + "PAUSE – Leertaste zum Fortsetzen", self.style.label_font, theme.text + ) + badge = text.get_rect().inflate(2 * ui.padding, ui.padding) + badge.midtop = (self.viewport.left + self.viewport.width // 2, ui.padding) + pygame.draw.rect(self.screen, theme.panel_active, badge, border_radius=ui.corner_radius) + self.screen.blit(text, text.get_rect(center=badge.center)) + + # ------------------------------------------------------------------ Vorschau + + def draw_preview(self, preview: SpawnPreview) -> None: + """Zeichnet die halbtransparente Vorschau eines neuen Objekts.""" + theme = self.style.theme + ui = self.style.ui + half_width, half_height = preview.half_extents + margin = ui.highlight_width + size = (math.ceil(2 * half_width) + 2 * margin, math.ceil(2 * half_height) + 2 * margin) + layer = pygame.Surface(size, pygame.SRCALPHA) + fill = (*preview.color, theme.preview_alpha) + outline = (*theme.highlight, theme.preview_alpha) + local_center = (size[0] / 2, size[1] / 2) + if preview.shape is ShapeKind.CIRCLE: + pygame.draw.aacircle(layer, fill, local_center, half_width) + pygame.draw.aacircle(layer, outline, local_center, half_width, ui.outline_width) + else: + draw_box( + layer, + fill, + outline, + local_center, + preview.half_extents, + outline_width=ui.outline_width, + ) + self.screen.blit( + layer, (preview.screen_center[0] - size[0] / 2, preview.screen_center[1] - size[1] / 2) + ) diff --git a/src/sandbox/ui/__init__.py b/src/sandbox/ui/__init__.py new file mode 100644 index 0000000..9987320 --- /dev/null +++ b/src/sandbox/ui/__init__.py @@ -0,0 +1 @@ +"""Bedienoberfläche: Widgets und die Werkzeugleiste (Sidebar).""" diff --git a/src/sandbox/ui/drawing.py b/src/sandbox/ui/drawing.py new file mode 100644 index 0000000..7875c6b --- /dev/null +++ b/src/sandbox/ui/drawing.py @@ -0,0 +1,65 @@ +"""Zeichenhelfer für Körperformen – gemeinsam genutzt von Renderer und Vorschau-Kachel.""" + +from __future__ import annotations + +import math +from typing import TypeAlias + +import pygame + +#: RGB- oder RGBA-Farbe (RGBA für halbtransparente Vorschauen). +DrawColor: TypeAlias = tuple[int, ...] + + +def draw_circle( + surface: pygame.Surface, + color: DrawColor, + outline: DrawColor, + center: tuple[float, float], + radius: float, + *, + outline_width: int, + angle: float | None = None, + marker_length: float = 0.0, +) -> None: + """Zeichnet einen geglätteten Kreis mit Umrandung und optionalem Drehmarker. + + Args: + surface: Zielfläche. + color: Füllfarbe. + outline: Farbe von Umrandung und Drehmarker. + center: Mittelpunkt in Bildschirmkoordinaten. + radius: Radius in px. + outline_width: Linienstärke der Umrandung. + angle: Drehwinkel in rad. Ohne Marker wäre die Drehung eines einfarbigen Kreises + unsichtbar – man sähe nicht, ob er rollt oder rutscht. + marker_length: Länge des Markers als Anteil des Radius. + """ + # Die Füllung braucht keine Kantenglättung: Die geglättete Umrandung liegt genau auf + # ihrem Rand und verdeckt die Treppenstufen. Das halbiert die teuren aacircle-Aufrufe. + pygame.draw.circle(surface, color, center, radius) + pygame.draw.aacircle(surface, outline, center, radius, outline_width) + if angle is not None: + end = ( + center[0] + math.cos(angle) * radius * marker_length, + center[1] + math.sin(angle) * radius * marker_length, + ) + pygame.draw.line(surface, outline, center, end, outline_width) + + +def draw_box( + surface: pygame.Surface, + color: DrawColor, + outline: DrawColor, + center: tuple[float, float], + half_extents: tuple[float, float], + *, + outline_width: int, + corner_radius: int = 0, +) -> None: + """Zeichnet ein achsenparalleles Rechteck mit Umrandung.""" + half_width, half_height = half_extents + rect = pygame.Rect(0, 0, round(2 * half_width), round(2 * half_height)) + rect.center = (round(center[0]), round(center[1])) + pygame.draw.rect(surface, color, rect, border_radius=corner_radius) + pygame.draw.rect(surface, outline, rect, outline_width, border_radius=corner_radius) diff --git a/src/sandbox/ui/sidebar.py b/src/sandbox/ui/sidebar.py new file mode 100644 index 0000000..bf3cbc2 --- /dev/null +++ b/src/sandbox/ui/sidebar.py @@ -0,0 +1,298 @@ +"""Werkzeugleiste am linken Fensterrand. Sie gehört nicht zur Physikwelt.""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import partial +from typing import Protocol + +import pygame + +from sandbox.config import Color, InteractionConfig +from sandbox.interaction import ShapeKind, ToolSettings +from sandbox.ui.style import UiStyle +from sandbox.ui.widgets import ( + Button, + ColorPicker, + Label, + LabelKind, + SegmentedControl, + Slider, + SpawnTile, + Widget, +) + + +class SidebarControls(Protocol): + """Was die Sidebar von der Anwendung braucht (erfüllt von :class:`~sandbox.main.App`).""" + + def is_paused(self) -> bool: + """Ob die Simulation pausiert ist.""" + ... + + def toggle_pause(self) -> None: + """Pausiert bzw. setzt fort.""" + ... + + def gravity_enabled(self) -> bool: + """Ob die Schwerkraft wirkt.""" + ... + + def toggle_gravity(self) -> None: + """Schaltet die Schwerkraft um.""" + ... + + def clear_world(self) -> None: + """Entfernt alle Körper.""" + ... + + def begin_spawn_drag(self) -> None: + """Beginnt das Herausziehen eines neuen Objekts aus der Vorschau-Kachel.""" + ... + + def preview_color(self) -> Color: + """Farbe des nächsten Objekts.""" + ... + + def status_lines(self) -> list[str]: + """Statuszeilen für den unteren Rand (Objektzahl, FPS …).""" + ... + + +def _format_size(value: float) -> str: + return f"{value:.0f} px" + + +def _format_ratio(value: float) -> str: + return f"{value:.2f}" + + +def _format_density(value: float) -> str: + return f"{value:.1f}" + + +class Sidebar: + """Baut die Bedienelemente auf, verteilt Events an sie und zeichnet sie. + + Args: + rect: Bereich der Sidebar auf dem Bildschirm. + style: Farben, Schriften, Maße. + tools: Die Werkzeug-Einstellungen, die die Regler verändern. + limits: Wertebereiche der Regler. + palette: Farbpalette. + controls: Anbindung an die Anwendung. + """ + + def __init__( + self, + rect: pygame.Rect, + style: UiStyle, + tools: ToolSettings, + limits: InteractionConfig, + palette: tuple[Color, ...], + controls: SidebarControls, + ) -> None: + self.rect = pygame.Rect(rect) + self.style = style + self.tools = tools + self.controls = controls + self.widgets: list[Widget] = [] + self._status_top = 0 + self._build(limits, palette) + + # ------------------------------------------------------------------ Aufbau + + def _build(self, limits: InteractionConfig, palette: tuple[Color, ...]) -> None: + """Ordnet die Widgets von oben nach unten an.""" + ui = self.style.ui + tools = self.tools + controls = self.controls + left = self.rect.x + ui.padding + width = self.rect.width - 2 * ui.padding + y = self.rect.y + ui.padding + + def row(height: int) -> pygame.Rect: + return pygame.Rect(left, y, width, height) + + def add(widget: Widget, gap: int = ui.row_gap) -> None: + nonlocal y + self.widgets.append(widget) + y = widget.rect.bottom + gap + + def slider( + name: str, + label: str, + value_format: Callable[[float], str], + *, + minimum: float, + maximum: float, + step: float, + is_enabled: Callable[[], bool] | None = None, + ) -> Slider: + # Der Regler liest und schreibt direkt das gleichnamige Attribut der Einstellungen. + return Slider( + row(ui.slider_height), + label, + minimum=minimum, + maximum=maximum, + step=step, + get_value=partial(getattr, tools, name), + set_value=partial(setattr, tools, name), + value_format=value_format, + knob_radius=ui.slider_knob_radius, + is_enabled=is_enabled, + name=name, + ) + + add(Label(row(ui.header_height + ui.padding), "Physik-Sandbox", LabelKind.TITLE)) + + add(Label(row(ui.header_height), "OBJEKT", LabelKind.HEADER)) + add( + SegmentedControl( + row(ui.button_height), + [(ShapeKind.CIRCLE, "Kreis"), (ShapeKind.BOX, "Rechteck")], + partial(getattr, tools, "shape"), + partial(setattr, tools, "shape"), + name="shape", + ) + ) + add( + slider( + "size", + "Größe", + _format_size, + minimum=limits.min_size, + maximum=limits.max_size, + step=limits.size_step, + ) + ) + add( + slider( + "aspect_ratio", + "Seitenverhältnis", + _format_ratio, + minimum=limits.min_aspect_ratio, + maximum=limits.max_aspect_ratio, + step=limits.aspect_ratio_step, + is_enabled=lambda: tools.shape is ShapeKind.BOX, + ), + ui.section_gap, + ) + + add(Label(row(ui.header_height), "MATERIAL", LabelKind.HEADER)) + add( + slider( + "restitution", + "Elastizität", + _format_ratio, + minimum=0.0, + maximum=limits.max_restitution, + step=limits.restitution_step, + ) + ) + add( + slider( + "density", + "Dichte", + _format_density, + minimum=limits.min_density, + maximum=limits.max_density, + step=limits.density_step, + ) + ) + add(Label(row(ui.header_height), "FARBE", LabelKind.HEADER)) + add( + ColorPicker( + row(ui.swatch_size), + palette, + partial(getattr, tools, "color_index"), + partial(setattr, tools, "color_index"), + swatch_size=ui.swatch_size, + swatch_gap=ui.swatch_gap, + name="color", + ), + ui.section_gap, + ) + add( + SpawnTile( + row(ui.spawn_tile_height), + lambda: tools, + controls.preview_color, + controls.begin_spawn_drag, + hint="Ziehen oder in die Welt klicken", + name="spawn_tile", + ), + ui.section_gap, + ) + + add(Label(row(ui.header_height), "SIMULATION", LabelKind.HEADER)) + half = (width - ui.row_gap) // 2 + self.widgets.append( + Button( + pygame.Rect(left, y, half, ui.button_height), + lambda: "Weiter" if controls.is_paused() else "Pause", + controls.toggle_pause, + is_active=controls.is_paused, + name="pause", + ) + ) + add( + Button( + pygame.Rect( + left + half + ui.row_gap, y, width - half - ui.row_gap, ui.button_height + ), + lambda: "Schwerkraft an" if controls.gravity_enabled() else "Schwerkraft aus", + controls.toggle_gravity, + is_active=controls.gravity_enabled, + name="gravity", + ) + ) + add( + Button( + row(ui.button_height), + "Alles löschen", + controls.clear_world, + danger=True, + name="clear", + ), + ui.section_gap, + ) + self._status_top = y + + # ------------------------------------------------------------------ Laufzeit + + def widget(self, name: str) -> Widget: + """Findet ein Widget über seinen Namen.""" + for widget in self.widgets: + if widget.name == name: + return widget + raise KeyError(name) + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Leitet ein Event an die Widgets weiter. + + Returns: + True, wenn ein Widget das Event verbraucht hat oder ein Klick auf die Sidebar + fiel – solche Klicks dürfen nie in der Welt landen. + """ + if event.type == pygame.MOUSEMOTION: + # Alle Widgets sehen jede Mausbewegung (Hover-Zustand, gezogene Regler). + results = [widget.handle_event(event, mouse_pos) for widget in self.widgets] + return any(results) + for widget in self.widgets: + if widget.handle_event(event, mouse_pos): + return True + is_click = event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP) + return is_click and self.rect.collidepoint(mouse_pos) + + def draw(self, surface: pygame.Surface) -> None: + """Zeichnet Hintergrund, Widgets und Statuszeilen.""" + style = self.style + pygame.draw.rect(surface, style.theme.sidebar_background, self.rect) + for widget in self.widgets: + widget.draw(surface, style) + y = self._status_top + for line in self.controls.status_lines(): + rendered = style.render_text(line, style.small_font, style.theme.text_dim) + surface.blit(rendered, (self.rect.x + style.ui.padding, y)) + y += rendered.get_height() diff --git a/src/sandbox/ui/style.py b/src/sandbox/ui/style.py new file mode 100644 index 0000000..deda35d --- /dev/null +++ b/src/sandbox/ui/style.py @@ -0,0 +1,59 @@ +"""Gemeinsamer Zeichenstil: Farben, Schriften und ein kleiner Text-Cache.""" + +from __future__ import annotations + +import pygame + +from sandbox.config import Color, Theme, UiConfig + + +def _load_font(names: tuple[str, ...], size: int, *, bold: bool = False) -> pygame.font.Font: + """Lädt die erste verfügbare Systemschrift, sonst die eingebaute pygame-Schrift.""" + for name in names: + path = pygame.font.match_font(name) + if path: + font = pygame.font.Font(path, size) + break + else: + font = pygame.font.Font(None, size) + font.bold = bold + return font + + +class UiStyle: + """Farben, Maße und Schriften, die alle Widgets und der Renderer gemeinsam nutzen. + + Args: + theme: Farbschema. + ui: Schriften und Maße. + """ + + def __init__(self, theme: Theme, ui: UiConfig) -> None: + if not pygame.font.get_init(): + pygame.font.init() + self.theme = theme + self.ui = ui + self.title_font = _load_font(ui.font_names, ui.title_font_size, bold=True) + self.label_font = _load_font(ui.font_names, ui.label_font_size) + self.small_font = _load_font(ui.font_names, ui.small_font_size) + self._text_cache: dict[tuple[int, str, Color], pygame.Surface] = {} + + def render_text(self, text: str, font: pygame.font.Font, color: Color) -> pygame.Surface: + """Rendert Text (mit Zwischenspeicher, da sich die meisten Texte selten ändern).""" + key = (id(font), text, color) + surface = self._text_cache.get(key) + if surface is None: + if len(self._text_cache) >= self.ui.text_cache_size: + self._text_cache.clear() # einfach und ausreichend: selten, dann komplett + surface = font.render(text, True, color) + self._text_cache[key] = surface + return surface + + +def shade(color: Color, factor: float) -> Color: + """Verdunkelt (factor < 1) oder erhellt (factor > 1) eine Farbe.""" + return ( + max(0, min(255, round(color[0] * factor))), + max(0, min(255, round(color[1] * factor))), + max(0, min(255, round(color[2] * factor))), + ) diff --git a/src/sandbox/ui/widgets.py b/src/sandbox/ui/widgets.py new file mode 100644 index 0000000..2a03ff6 --- /dev/null +++ b/src/sandbox/ui/widgets.py @@ -0,0 +1,500 @@ +"""Selbst gezeichnete Bedienelemente: Beschriftung, Button, Umschalter, Regler, Farbwahl. + +Jedes Widget hat ein Rechteck, verarbeitet pygame-Events (:meth:`Widget.handle_event` +liefert True, wenn es das Event verbraucht hat) und zeichnet sich selbst. Zustände wie +"ist die Pause aktiv?" fragen die Widgets über Callbacks ab, statt sie zu kopieren – so +zeigen sie immer den aktuellen Stand, auch wenn er per Tastenkürzel geändert wurde. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from enum import Enum +from typing import Generic, TypeAlias, TypeVar + +import pygame + +from sandbox.config import Color +from sandbox.interaction import ShapeKind, ToolSettings +from sandbox.ui.drawing import draw_box, draw_circle +from sandbox.ui.style import UiStyle, shade + +T = TypeVar("T") + +#: Fester Text oder Funktion, die den aktuellen Text liefert. +TextSource: TypeAlias = str | Callable[[], str] + + +def _resolve(text: TextSource) -> str: + return text() if callable(text) else text + + +def _is_left_press(event: pygame.event.Event, rect: pygame.Rect) -> bool: + return ( + event.type == pygame.MOUSEBUTTONDOWN + and event.button == pygame.BUTTON_LEFT + and rect.collidepoint(event.pos) + ) + + +def _is_left_release(event: pygame.event.Event) -> bool: + return event.type == pygame.MOUSEBUTTONUP and event.button == pygame.BUTTON_LEFT + + +class Widget: + """Basisklasse aller Bedienelemente. + + Args: + rect: Position und Größe auf dem Bildschirm. + name: Eindeutiger Name, über den z. B. Tests das Widget finden. + """ + + def __init__(self, rect: pygame.Rect, name: str = "") -> None: + self.rect = pygame.Rect(rect) + self.name = name + self.hovered = False + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Verarbeitet ein Event; True, wenn das Widget es verbraucht hat.""" + if event.type == pygame.MOUSEMOTION: + self.hovered = self.rect.collidepoint(mouse_pos) + return False + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet das Widget.""" + raise NotImplementedError + + +class LabelKind(Enum): + """Darstellungsart einer Beschriftung.""" + + TITLE = "title" + HEADER = "header" + TEXT = "text" + HINT = "hint" + + +class Label(Widget): + """Einzeilige Beschriftung (Text fest oder dynamisch über eine Funktion).""" + + def __init__( + self, rect: pygame.Rect, text: TextSource, kind: LabelKind = LabelKind.TEXT, name: str = "" + ) -> None: + super().__init__(rect, name) + self.text = text + self.kind = kind + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet den Text linksbündig und vertikal zentriert.""" + theme = style.theme + font, color = { + LabelKind.TITLE: (style.title_font, theme.text), + LabelKind.HEADER: (style.small_font, theme.text_dim), + LabelKind.TEXT: (style.label_font, theme.text), + LabelKind.HINT: (style.small_font, theme.text_dim), + }[self.kind] + rendered = style.render_text(_resolve(self.text), font, color) + surface.blit(rendered, (self.rect.x, self.rect.centery - rendered.get_height() // 2)) + + +class Button(Widget): + """Schaltfläche. Löst beim Loslassen über dem Button aus (übliches UI-Verhalten). + + Args: + rect: Position und Größe. + text: Beschriftung (fest oder dynamisch). + on_click: Wird beim Klick aufgerufen. + is_active: Optional; hebt den Button hervor, solange die Funktion True liefert. + danger: Rote Darstellung für zerstörerische Aktionen. + name: Name des Widgets. + """ + + def __init__( + self, + rect: pygame.Rect, + text: TextSource, + on_click: Callable[[], None], + *, + is_active: Callable[[], bool] | None = None, + danger: bool = False, + name: str = "", + ) -> None: + super().__init__(rect, name) + self.text = text + self.on_click = on_click + self.is_active = is_active + self.danger = danger + self.pressed = False + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Drücken merkt sich den Button, Loslassen über dem Button löst aus.""" + super().handle_event(event, mouse_pos) + if _is_left_press(event, self.rect): + self.pressed = True + return True + if _is_left_release(event) and self.pressed: + self.pressed = False + if self.rect.collidepoint(event.pos): + self.on_click() + return True + return False + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet Hintergrund (je nach Zustand) und zentrierten Text.""" + theme = style.theme + active = self.is_active is not None and self.is_active() + if active: + background = theme.panel_active + elif self.hovered: + background = theme.panel_hover + else: + background = theme.panel + radius = style.ui.corner_radius + pygame.draw.rect(surface, background, self.rect, border_radius=radius) + text_color = theme.danger if self.danger else theme.text + if self.danger or active: + border = theme.danger if self.danger else theme.accent + pygame.draw.rect( + surface, border, self.rect, style.ui.border_width, border_radius=radius + ) + rendered = style.render_text(_resolve(self.text), style.label_font, text_color) + surface.blit(rendered, rendered.get_rect(center=self.rect.center)) + + +class SegmentedControl(Widget, Generic[T]): + """Reihe gleich breiter Optionen, von denen genau eine gewählt ist. + + Args: + rect: Position und Größe. + options: Paare aus Wert und Beschriftung. + get_value: Liefert den aktuell gewählten Wert. + set_value: Setzt einen neuen Wert. + name: Name des Widgets. + """ + + def __init__( + self, + rect: pygame.Rect, + options: Sequence[tuple[T, str]], + get_value: Callable[[], T], + set_value: Callable[[T], None], + name: str = "", + ) -> None: + super().__init__(rect, name) + self.options = list(options) + self.get_value = get_value + self.set_value = set_value + + def segment_rects(self) -> list[pygame.Rect]: + """Rechtecke der einzelnen Optionen.""" + count = len(self.options) + rects = [] + for index in range(count): + left = self.rect.x + self.rect.width * index // count + right = self.rect.x + self.rect.width * (index + 1) // count + rects.append(pygame.Rect(left, self.rect.y, right - left, self.rect.height)) + return rects + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Ein Klick auf eine Option wählt sie sofort aus.""" + super().handle_event(event, mouse_pos) + for (value, _), rect in zip(self.options, self.segment_rects(), strict=True): + if _is_left_press(event, rect): + self.set_value(value) + return True + return False + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet alle Optionen, die gewählte hervorgehoben.""" + theme = style.theme + radius = style.ui.corner_radius + pygame.draw.rect(surface, theme.panel, self.rect, border_radius=radius) + current = self.get_value() + for (value, text), rect in zip(self.options, self.segment_rects(), strict=True): + selected = value == current + if selected: + pygame.draw.rect(surface, theme.panel_active, rect, border_radius=radius) + pygame.draw.rect( + surface, theme.accent, rect, style.ui.border_width, border_radius=radius + ) + color = theme.text if selected else theme.text_dim + rendered = style.render_text(text, style.label_font, color) + surface.blit(rendered, rendered.get_rect(center=rect.center)) + + +class Slider(Widget): + """Schieberegler mit Beschriftung und Wertanzeige. + + Oben stehen Beschriftung und aktueller Wert, darunter die Schiene mit Knopf. Der Wert + lässt sich klicken, ziehen oder mit dem Mausrad (in ``step``-Schritten) ändern. + + Args: + rect: Position und Größe. + label: Beschriftung. + minimum: Kleinster Wert. + maximum: Größter Wert. + step: Rasterung der Werte. + get_value: Liefert den aktuellen Wert. + set_value: Setzt einen neuen Wert. + value_format: Formatiert den Wert für die Anzeige. + knob_radius: Radius des Knopfs in px (bestimmt auch den Rand der Schiene). + is_enabled: Optional; ein deaktivierter Regler ist ausgegraut und reagiert nicht. + name: Name des Widgets. + """ + + def __init__( + self, + rect: pygame.Rect, + label: str, + *, + minimum: float, + maximum: float, + step: float, + get_value: Callable[[], float], + set_value: Callable[[float], None], + value_format: Callable[[float], str], + knob_radius: int, + is_enabled: Callable[[], bool] | None = None, + name: str = "", + ) -> None: + super().__init__(rect, name) + if not (maximum > minimum and step > 0.0): + raise ValueError("Der Regler braucht maximum > minimum und step > 0") + self.label = label + self.minimum = minimum + self.maximum = maximum + self.step = step + self.get_value = get_value + self.set_value = set_value + self.value_format = value_format + self.knob_radius = knob_radius + self.is_enabled = is_enabled + self.dragging = False + + @property + def enabled(self) -> bool: + """Ob der Regler bedienbar ist.""" + return self.is_enabled is None or self.is_enabled() + + @property + def track_left(self) -> int: + """x-Koordinate des linken Schienenendes.""" + return self.rect.x + self.knob_radius + + @property + def track_width(self) -> int: + """Breite der Schiene in px.""" + return self.rect.width - 2 * self.knob_radius + + @property + def track_y(self) -> int: + """y-Koordinate der Schienenmitte.""" + return self.rect.bottom - self.knob_radius + + def snap(self, value: float) -> float: + """Rastet einen Wert auf das Raster ein und begrenzt ihn auf [minimum, maximum].""" + steps = round((value - self.minimum) / self.step) + snapped = self.minimum + steps * self.step + return min(max(snapped, self.minimum), self.maximum) + + def value_at(self, x: float) -> float: + """Wert, der zur Bildschirmposition ``x`` auf der Schiene gehört.""" + fraction = (x - self.track_left) / self.track_width + fraction = min(max(fraction, 0.0), 1.0) + return self.snap(self.minimum + fraction * (self.maximum - self.minimum)) + + def x_of(self, value: float) -> float: + """Bildschirmposition ``x`` eines Werts (Umkehrung von :meth:`value_at`).""" + fraction = (value - self.minimum) / (self.maximum - self.minimum) + return self.track_left + fraction * self.track_width + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Klicken, Ziehen und Mausrad ändern den Wert.""" + super().handle_event(event, mouse_pos) + if not self.enabled: + self.dragging = False + return _is_left_press(event, self.rect) # Klick verschlucken, nichts tun + if _is_left_press(event, self.rect): + self.dragging = True + self.set_value(self.value_at(event.pos[0])) + return True + if event.type == pygame.MOUSEMOTION and self.dragging: + self.set_value(self.value_at(mouse_pos[0])) + return True + if _is_left_release(event) and self.dragging: + self.dragging = False + return True + if event.type == pygame.MOUSEWHEEL and self.rect.collidepoint(mouse_pos): + self.set_value(self.snap(self.get_value() + event.y * self.step)) + return True + return False + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet Beschriftung, Wert, Schiene und Knopf.""" + theme = style.theme + enabled = self.enabled + text_color = theme.text if enabled else theme.text_dim + label = style.render_text(self.label, style.label_font, text_color) + surface.blit(label, (self.rect.x, self.rect.y)) + value = style.render_text( + self.value_format(self.get_value()), style.small_font, theme.text_dim + ) + # Unterkanten bündig: Der kleinere Wert steht so auf derselben Grundlinie wie der Text. + label_bottom = self.rect.y + label.get_height() + surface.blit(value, value.get_rect(bottomright=(self.rect.right, label_bottom))) + + track = pygame.Rect(0, 0, self.track_width, style.ui.slider_track_height) + track.midleft = (self.track_left, self.track_y) + pygame.draw.rect(surface, theme.panel_hover, track, border_radius=track.height) + knob_x = round(self.x_of(self.get_value())) + filled = track.copy() + filled.width = max(0, knob_x - track.x) + fill_color = theme.accent if enabled else theme.text_dim + pygame.draw.rect(surface, fill_color, filled, border_radius=track.height) + knob_color = theme.text if (self.hovered or self.dragging) and enabled else fill_color + pygame.draw.aacircle(surface, knob_color, (knob_x, self.track_y), self.knob_radius) + + +class ColorPicker(Widget): + """Farbfelder zur Auswahl; das erste Feld steht für "Zufallsfarbe". + + Args: + rect: Position und Größe. + colors: Farbpalette. + get_index: Liefert den gewählten Palettenindex (``None`` = Zufall). + set_index: Setzt den Palettenindex (``None`` = Zufall). + swatch_size: Kantenlänge eines Farbfelds in px. + swatch_gap: Abstand zwischen den Feldern in px. + name: Name des Widgets. + """ + + def __init__( + self, + rect: pygame.Rect, + colors: Sequence[Color], + get_index: Callable[[], int | None], + set_index: Callable[[int | None], None], + *, + swatch_size: int, + swatch_gap: int, + name: str = "", + ) -> None: + super().__init__(rect, name) + self.colors = list(colors) + self.get_index = get_index + self.set_index = set_index + self.swatch_size = swatch_size + self.swatch_gap = swatch_gap + + def swatches(self) -> list[tuple[int | None, pygame.Rect]]: + """Alle Farbfelder mit ihrem Index (zuerst das Zufallsfeld).""" + indices: list[int | None] = [None, *range(len(self.colors))] + pitch = self.swatch_size + self.swatch_gap + return [ + ( + index, + pygame.Rect( + self.rect.x + i * pitch, self.rect.y, self.swatch_size, self.swatch_size + ), + ) + for i, index in enumerate(indices) + ] + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Ein Klick auf ein Farbfeld wählt es aus.""" + super().handle_event(event, mouse_pos) + for index, rect in self.swatches(): + if _is_left_press(event, rect): + self.set_index(index) + return True + return False + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet die Felder; das gewählte bekommt einen hellen Ring.""" + theme = style.theme + selected = self.get_index() + for index, rect in self.swatches(): + if index is None: + # Zufallsfeld: vier Palettenfarben als Viertel. + half = rect.width // 2 + quarters = self.colors[:: max(1, len(self.colors) // 4)][:4] + for k, color in enumerate(quarters): + quarter = pygame.Rect( + rect.x + (k % 2) * half, rect.y + (k // 2) * half, half, half + ) + pygame.draw.rect(surface, color, quarter) + else: + pygame.draw.rect(surface, self.colors[index], rect) + if index == selected: + gap = style.ui.selection_ring_gap + ring = rect.inflate(2 * gap, 2 * gap) + pygame.draw.rect(surface, theme.highlight, ring, style.ui.selection_ring_width) + + +class SpawnTile(Widget): + """Vorschau-Kachel des nächsten Objekts; aus ihr zieht man Objekte in die Welt. + + Args: + rect: Position und Größe. + get_settings: Liefert die aktuellen Werkzeug-Einstellungen. + get_color: Liefert die Farbe des nächsten Objekts. + on_drag_start: Wird beim Drücken auf die Kachel aufgerufen. + hint: Hinweistext unter der Vorschau. + name: Name des Widgets. + """ + + def __init__( + self, + rect: pygame.Rect, + get_settings: Callable[[], ToolSettings], + get_color: Callable[[], Color], + on_drag_start: Callable[[], None], + *, + hint: str, + name: str = "", + ) -> None: + super().__init__(rect, name) + self.get_settings = get_settings + self.get_color = get_color + self.on_drag_start = on_drag_start + self.hint = hint + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Drücken auf die Kachel beginnt das Herausziehen eines neuen Objekts.""" + super().handle_event(event, mouse_pos) + if _is_left_press(event, self.rect): + self.on_drag_start() + return True + return False + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet Kachel, maßstäblich verkleinerte Vorschau und Hinweis.""" + theme = style.theme + ui = style.ui + background = theme.panel_hover if self.hovered else theme.panel + pygame.draw.rect(surface, background, self.rect, border_radius=ui.corner_radius) + hint = style.render_text(self.hint, style.small_font, theme.text_dim) + hint_rect = hint.get_rect(midbottom=(self.rect.centerx, self.rect.bottom - ui.row_gap)) + surface.blit(hint, hint_rect) + + settings = self.get_settings() + half_width, half_height = settings.half_extents() + area_height = hint_rect.top - self.rect.top + available = min(self.rect.width, area_height) * ui.preview_scale_limit / 2 + scale = min(1.0, available / max(half_width, half_height)) + center = (self.rect.centerx, self.rect.top + area_height / 2) + color = self.get_color() + outline = shade(color, theme.outline_shade) + if settings.shape is ShapeKind.CIRCLE: + draw_circle( + surface, color, outline, center, half_width * scale, outline_width=ui.outline_width + ) + else: + draw_box( + surface, + color, + outline, + center, + (half_width * scale, half_height * scale), + outline_width=ui.outline_width, + ) diff --git a/src/sandbox/viewport.py b/src/sandbox/viewport.py new file mode 100644 index 0000000..d2268f5 --- /dev/null +++ b/src/sandbox/viewport.py @@ -0,0 +1,44 @@ +"""Abbildung zwischen Bildschirm- und Weltkoordinaten. + +Die Welt liegt rechts neben der Sidebar. Weil beide Koordinatensysteme in Pixeln rechnen +und die y-Achse gleich ausrichten, besteht die Abbildung nur aus einer Verschiebung. +""" + +from __future__ import annotations + +from sandbox.physics import Vector2 + + +class Viewport: + """Rechteckiger Bildschirmbereich, in dem die Welt gezeichnet wird. + + Args: + left: Linker Rand auf dem Bildschirm in px. + top: Oberer Rand auf dem Bildschirm in px. + width: Breite in px (= Breite der Welt). + height: Höhe in px (= Höhe der Welt). + """ + + def __init__(self, left: int, top: int, width: int, height: int) -> None: + self.left = left + self.top = top + self.width = width + self.height = height + + @property + def rect(self) -> tuple[int, int, int, int]: + """Bereich als ``(left, top, width, height)``.""" + return (self.left, self.top, self.width, self.height) + + def contains(self, screen_pos: tuple[float, float]) -> bool: + """True, wenn ein Bildschirmpunkt in der Welt liegt.""" + x, y = screen_pos + return self.left <= x < self.left + self.width and self.top <= y < self.top + self.height + + def to_world(self, screen_pos: tuple[float, float]) -> Vector2: + """Bildschirmpunkt → Weltpunkt.""" + return Vector2(screen_pos[0] - self.left, screen_pos[1] - self.top) + + def to_screen(self, point: Vector2) -> tuple[float, float]: + """Weltpunkt → Bildschirmpunkt.""" + return (point.x + self.left, point.y + self.top) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e270519 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Testsuite der Physik-Sandbox.""" diff --git a/tests/app/__init__.py b/tests/app/__init__.py new file mode 100644 index 0000000..7e0f919 --- /dev/null +++ b/tests/app/__init__.py @@ -0,0 +1 @@ +"""Headless-Tests für Oberfläche, Eingaben und Hauptschleife (SDL-Dummy-Treiber).""" diff --git a/tests/app/conftest.py b/tests/app/conftest.py new file mode 100644 index 0000000..541c354 --- /dev/null +++ b/tests/app/conftest.py @@ -0,0 +1,18 @@ +"""Fixture für die headless bediente Anwendung.""" + +import random +from collections.abc import Iterator + +import pygame +import pytest + +from sandbox.main import App + + +@pytest.fixture +def app() -> Iterator[App]: + """Frische Anwendung mit reproduzierbaren Zufallsfarben.""" + application = App(rng=random.Random(0)) + pygame.event.clear() + yield application + application.close() diff --git a/tests/app/driver.py b/tests/app/driver.py new file mode 100644 index 0000000..1906e6a --- /dev/null +++ b/tests/app/driver.py @@ -0,0 +1,68 @@ +"""Hilfsfunktionen, um die Anwendung headless per Event zu bedienen. + +Die Tests posten echte pygame-Events in die Event-Queue und rufen dann +:meth:`sandbox.main.App.frame` auf – genau wie die Hauptschleife. So wird die komplette +Kette Event → Sidebar/InputHandler → Werkzeuge → Physik → Zeichnen durchlaufen. +""" + +import pygame + +from sandbox.main import App +from sandbox.physics import Vector2 + +#: Dauer eines Test-Frames (60 Bilder/s → je zwei Physikschritte). +FRAME = 1 / 60 + + +def screen_pos(app: App, x: float, y: float) -> tuple[int, int]: + """Bildschirmposition eines Weltpunkts (auf ganze Pixel gerundet).""" + sx, sy = app.viewport.to_screen(Vector2(x, y)) + return (round(sx), round(sy)) + + +def frames(app: App, count: int = 1) -> None: + """Lässt die Anwendung einige Frames laufen.""" + for _ in range(count): + app.frame(FRAME) + + +def post(event_type: int, **attributes: object) -> None: + """Legt ein Event in die pygame-Queue.""" + pygame.event.post(pygame.event.Event(event_type, **attributes)) + + +def press(app: App, pos: tuple[int, int], button: int = pygame.BUTTON_LEFT) -> None: + """Maustaste an ``pos`` drücken (ein Frame).""" + post(pygame.MOUSEBUTTONDOWN, pos=pos, button=button) + frames(app) + + +def move(app: App, pos: tuple[int, int]) -> None: + """Maus nach ``pos`` bewegen (ein Frame).""" + post(pygame.MOUSEMOTION, pos=pos, rel=(0, 0), buttons=(1, 0, 0)) + frames(app) + + +def release(app: App, pos: tuple[int, int], button: int = pygame.BUTTON_LEFT) -> None: + """Maustaste an ``pos`` loslassen (ein Frame).""" + post(pygame.MOUSEBUTTONUP, pos=pos, button=button) + frames(app) + + +def click(app: App, pos: tuple[int, int], button: int = pygame.BUTTON_LEFT) -> None: + """Klick (Drücken und Loslassen) an ``pos``.""" + press(app, pos, button) + release(app, pos, button) + + +def key(app: App, name: str) -> None: + """Taste drücken (Name wie in ``pygame.key.key_code``).""" + post(pygame.KEYDOWN, key=pygame.key.key_code(name), mod=0, unicode="", scancode=0) + frames(app) + + +def wheel(app: App, pos: tuple[int, int], steps: int) -> None: + """Mausrad über ``pos`` drehen.""" + move(app, pos) + post(pygame.MOUSEWHEEL, x=0, y=steps, flipped=False) + frames(app) diff --git a/tests/app/test_app_integration.py b/tests/app/test_app_integration.py new file mode 100644 index 0000000..0a615d9 --- /dev/null +++ b/tests/app/test_app_integration.py @@ -0,0 +1,332 @@ +"""Integrationstests: Die Anwendung wird headless über echte pygame-Events bedient.""" + +from dataclasses import replace +from pathlib import Path + +import pygame +import pytest + +from sandbox.config import APP, INTERACTION, PHYSICS +from sandbox.interaction import ShapeKind +from sandbox.main import App, main +from sandbox.physics import Vector2 +from sandbox.ui.widgets import ColorPicker, SegmentedControl, Slider +from tests.app.driver import click, frames, key, move, post, press, release, screen_pos, wheel + +# --------------------------------------------------------------------- Erstellen + + +def test_click_into_the_world_creates_a_body(app: App) -> None: + click(app, screen_pos(app, 400, 300)) + (body,) = app.world.bodies + assert body.position.is_close(Vector2(400, 300), tolerance=1.0) + assert body.shape.radius == app.tools.size + assert not body.held + + +def test_created_body_falls_and_lands_on_the_floor(app: App) -> None: + click(app, screen_pos(app, 400, 300)) + frames(app, 240) # Elastizität 0,5: nach ≈ 2,6 s sind alle Rücksprünge abgeklungen + (body,) = app.world.bodies + floor = app.world.height - app.tools.size + assert body.position.y == pytest.approx(floor, abs=PHYSICS.penetration_slop + 1e-3) + + +def test_sidebar_settings_apply_to_new_bodies(app: App) -> None: + shape = app.sidebar.widget("shape") + colors = app.sidebar.widget("color") + assert isinstance(shape, SegmentedControl) + assert isinstance(colors, ColorPicker) + click(app, shape.segment_rects()[1].center) # "Rechteck" + swatch_index, swatch = colors.swatches()[3] + click(app, swatch.center) + click(app, screen_pos(app, 400, 300)) + (body,) = app.world.bodies + assert app.tools.shape is ShapeKind.BOX + assert body.shape.half_extents() == pytest.approx(app.tools.half_extents()) + assert body.user_data.color == app.config.theme.body_palette[swatch_index] + + +def test_drag_from_the_sidebar_creates_a_body_when_entering_the_world(app: App) -> None: + tile = app.sidebar.widget("spawn_tile").rect + press(app, tile.center) + move(app, (tile.right - 5, tile.centery)) # noch über der Sidebar + assert app.world.bodies == [] + target = screen_pos(app, 200, 250) + move(app, target) + (body,) = app.world.bodies + assert body.held + release(app, target) + assert not body.held + + +def test_drag_from_the_sidebar_is_cancelled_when_released_over_the_sidebar(app: App) -> None: + tile = app.sidebar.widget("spawn_tile").rect + press(app, tile.center) + move(app, (tile.centerx, tile.top - 20)) + release(app, (tile.centerx, tile.top - 20)) + frames(app, 3) + assert app.world.bodies == [] + assert not app.input.spawn_drag_active + + +def test_body_limit_is_respected(app: App) -> None: + app.close() + limited = App(replace(APP, interaction=replace(INTERACTION, max_bodies=2))) + try: + for x in (200, 400, 600): + click(limited, screen_pos(limited, x, 300)) + assert len(limited.world.bodies) == 2 + assert "(voll)" in limited.status_lines()[0] + finally: + limited.close() + + +# --------------------------------------------------------------------- Greifen, Ziehen, Werfen + + +def _resting_body(app: App, x: float) -> tuple[int, int]: + """Erzeugt einen Körper, lässt ihn landen und liefert seine Bildschirmposition.""" + click(app, screen_pos(app, x, 600)) + frames(app, 90) + body = app.world.bodies[-1] + return screen_pos(app, body.position.x, body.position.y) + + +def test_grabbed_body_follows_the_mouse(app: App) -> None: + start = _resting_body(app, 300) + press(app, start) + body = app.drag.body + assert body is not None + for step in range(1, 21): + move(app, (start[0] + 5 * step, start[1] - 10 * step)) + frames(app, 10) + expected = app.viewport.to_world((start[0] + 100, start[1] - 200)) + assert body.position.is_close(expected, tolerance=1.0) + release(app, (start[0] + 100, start[1] - 200)) + + +def test_release_throws_the_body_with_the_mouse_velocity(app: App) -> None: + start = _resting_body(app, 300) + press(app, start) + body = app.drag.body + assert body is not None + x, y = start + for _ in range(20): # 10 px pro Frame bei 60 Bildern/s = 600 px/s nach rechts + x += 10 + move(app, (x, y - 150)) + release(app, (x, y - 150)) + assert body.velocity.x == pytest.approx(600, rel=0.05) + frames(app, 10) + assert body.position.x > app.viewport.to_world((x, y)).x # fliegt weiter + + +def test_holding_still_before_release_does_not_throw(app: App) -> None: + start = _resting_body(app, 300) + press(app, start) + body = app.drag.body + assert body is not None + target = (start[0] + 200, start[1] - 150) + for step in range(1, 11): + move(app, (start[0] + 20 * step, start[1] - 15 * step)) + frames(app, 30) # eine halbe Sekunde stillhalten + release(app, target) + assert abs(body.velocity.x) < 1 + + +def test_dragged_body_pushes_other_bodies(app: App) -> None: + obstacle_pos = _resting_body(app, 300) + obstacle = app.world.bodies[-1] + start = _resting_body(app, 600) + press(app, start) + held = app.drag.body + assert held is not None and held is not obstacle + for step in range(1, 31): # nach links durch das Hindernis hindurch ziehen + move(app, (start[0] - 15 * step, start[1])) + assert obstacle.position.x < app.viewport.to_world(obstacle_pos).x - 50 + overlap = ( + held.shape.radius + obstacle.shape.radius - held.position.distance_to(obstacle.position) + ) + assert overlap < PHYSICS.max_position_correction + release(app, (start[0] - 450, start[1])) + + +def test_dragged_body_cannot_leave_the_world(app: App) -> None: + start = _resting_body(app, 300) + press(app, start) + body = app.drag.body + assert body is not None + move(app, (5, 5)) # über die Sidebar hinaus + frames(app, 30) + radius = body.shape.radius + assert body.position.x >= radius - PHYSICS.max_wall_penetration + assert body.position.y >= radius - PHYSICS.max_wall_penetration + release(app, (5, 5)) + + +def test_paused_drag_places_the_body_without_throwing(app: App) -> None: + start = _resting_body(app, 300) + key(app, "space") + assert app.paused + # Versatz zwischen Griffpunkt (ganze Pixel) und Schwerpunkt, gemessen vor dem Greifen. + grip_offset = app.world.bodies[-1].position - app.viewport.to_world(start) + press(app, start) + body = app.drag.body + assert body is app.world.bodies[-1] + target = (start[0] + 120, start[1] - 200) + move(app, target) + expected = app.viewport.to_world(target) + grip_offset + assert body.position.is_close(expected, tolerance=1e-6) + release(app, target) + assert body.velocity == Vector2(0, 0) + frames(app, 10) + assert body.position.is_close(expected, tolerance=1e-6) # bleibt: Simulation pausiert + + +# --------------------------------------------------------------------- Löschen + + +def test_right_click_deletes_a_body(app: App) -> None: + pos = _resting_body(app, 300) + click(app, pos, pygame.BUTTON_RIGHT) + assert app.world.bodies == [] + + +def test_delete_key_removes_the_body_under_the_mouse(app: App) -> None: + pos = _resting_body(app, 300) + move(app, pos) + assert app.input.hovered_body is app.world.bodies[0] + key(app, "delete") + assert app.world.bodies == [] + + +def test_right_click_on_the_held_body_releases_and_deletes_it(app: App) -> None: + pos = _resting_body(app, 300) + press(app, pos) + press(app, pos, pygame.BUTTON_RIGHT) + assert app.world.bodies == [] + assert app.drag.body is None + release(app, pos) + + +def test_clear_button_removes_everything(app: App) -> None: + for x in (200, 400, 600): + click(app, screen_pos(app, x, 300)) + click(app, app.sidebar.widget("clear").rect.center) + assert app.world.bodies == [] + + +# --------------------------------------------------------------------- Sidebar und Tastatur + + +def test_pause_button_freezes_and_resumes_the_simulation(app: App) -> None: + click(app, screen_pos(app, 400, 100)) + body = app.world.bodies[0] + pause = app.sidebar.widget("pause").rect.center + click(app, pause) + assert app.paused + frozen = body.position + frames(app, 30) + assert body.position == frozen + click(app, pause) + frames(app, 10) + assert body.position.y > frozen.y + + +def test_gravity_button_toggles_gravity(app: App) -> None: + click(app, app.sidebar.widget("gravity").rect.center) + assert not app.world.gravity_enabled + click(app, screen_pos(app, 400, 100)) + frames(app, 30) + assert app.world.bodies[0].position.is_close(Vector2(400, 100), tolerance=1e-6) + + +def test_button_only_triggers_when_released_over_it(app: App) -> None: + button = app.sidebar.widget("pause").rect + press(app, button.center) + release(app, (button.centerx, button.bottom + 40)) + assert not app.paused + + +def test_size_slider_can_be_clicked_dragged_and_scrolled(app: App) -> None: + slider = app.sidebar.widget("size") + assert isinstance(slider, Slider) + press(app, (slider.rect.right - 1, slider.track_y)) + assert app.tools.size == INTERACTION.max_size + move(app, (slider.rect.left - 50, slider.track_y)) # Ziehen darf die Sidebar verlassen + assert app.tools.size == INTERACTION.min_size + release(app, (slider.rect.left - 50, slider.track_y)) + wheel(app, slider.rect.center, 3) + assert app.tools.size == INTERACTION.min_size + 3 * INTERACTION.size_step + + +def test_aspect_slider_is_disabled_for_circles(app: App) -> None: + slider = app.sidebar.widget("aspect_ratio") + assert isinstance(slider, Slider) + before = app.tools.aspect_ratio + click(app, (slider.rect.right - 1, slider.track_y)) + assert app.tools.aspect_ratio == before + key(app, "2") + click(app, (slider.rect.right - 1, slider.track_y)) + assert app.tools.aspect_ratio == INTERACTION.max_aspect_ratio + + +def test_mouse_wheel_over_the_world_changes_the_size(app: App) -> None: + before = app.tools.size + wheel(app, screen_pos(app, 400, 300), 2) + assert app.tools.size == before + 2 * INTERACTION.size_wheel_step + wheel(app, screen_pos(app, 400, 300), -1000) + assert app.tools.size == INTERACTION.min_size + + +def test_keyboard_shortcuts(app: App) -> None: + key(app, "space") + assert app.paused + key(app, "space") + assert not app.paused + key(app, "g") + assert not app.world.gravity_enabled + key(app, "2") + assert app.tools.shape is ShapeKind.BOX + key(app, "1") + assert app.tools.shape is ShapeKind.CIRCLE + key(app, "f1") + assert app.debug + click(app, screen_pos(app, 400, 300)) + key(app, "c") + assert app.world.bodies == [] + key(app, "x") # nicht belegt: keine Wirkung + key(app, "escape") + assert not app.running + + +def test_closing_the_window_stops_the_main_loop(app: App) -> None: + post(pygame.QUIT) + frames(app) + assert not app.running + + +# --------------------------------------------------------------------- Darstellung und Start + + +def test_debug_view_and_demo_scene_render_without_errors(app: App) -> None: + app.load_demo_scene() + key(app, "f1") + frames(app, 30) + lines = app._debug_lines() + assert lines[0].startswith("Körper: 26") + move(app, screen_pos(app, 100, 100)) # Vorschau über leerer Welt + assert app.input.spawn_preview() is not None + + +def test_screenshot_is_written(app: App, tmp_path: Path) -> None: + path = tmp_path / "shot.png" + frames(app) + app.save_screenshot(path) + assert path.stat().st_size > 0 + + +def test_command_line_screenshot_mode(tmp_path: Path) -> None: + path = tmp_path / "cli.png" + assert main(["--demo", "--screenshot", str(path), "--seconds", "0.2"]) == 0 + assert path.exists() diff --git a/tests/app/test_entry_points_and_edge_cases.py b/tests/app/test_entry_points_and_edge_cases.py new file mode 100644 index 0000000..c9820d2 --- /dev/null +++ b/tests/app/test_entry_points_and_edge_cases.py @@ -0,0 +1,111 @@ +"""Einstiegspunkte (``python -m sandbox``, CLI) und Randfälle der Eingabeverarbeitung.""" + +import runpy +import sys +from dataclasses import replace +from pathlib import Path + +import pygame +import pytest + +from sandbox import main as main_module +from sandbox.config import APP, INTERACTION, Theme, UiConfig +from sandbox.main import App, FrameStats, main +from sandbox.ui.style import UiStyle, _load_font +from tests.app.driver import click, frames, key, move, post, press, release, screen_pos + + +def test_python_dash_m_starts_the_app(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + screenshot = tmp_path / "module.png" + monkeypatch.setattr( + sys, "argv", ["sandbox", "--screenshot", str(screenshot), "--seconds", "0.1"] + ) + with pytest.raises(SystemExit) as exit_info: + runpy.run_module("sandbox", run_name="__main__") + assert exit_info.value.code == 0 + assert screenshot.exists() + + +def test_main_without_options_runs_the_interactive_loop(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[float | None] = [] + monkeypatch.setattr( + main_module.App, "run", lambda self, max_seconds=None: calls.append(max_seconds) + ) + assert main([]) == 0 + assert calls == [None] + + +def test_main_loop_ends_when_the_window_is_closed(app: App) -> None: + post(pygame.QUIT) + app.run() # kehrt nach dem ersten Frame zurück + assert not app.running + + +def test_frame_stats_are_smoothed_and_ignore_empty_frames() -> None: + stats = FrameStats(smoothing=0.5) + stats.update(1 / 50, 0.002) + assert stats.fps == pytest.approx(50) + stats.update(1 / 100, 0.004) + assert stats.fps == pytest.approx(75) + stats.update(0.0, 0.0) # z. B. allererster Frame: keine Division durch 0 + assert stats.fps == pytest.approx(75) + assert stats.physics_ms > 0 + + +def test_clicks_outside_the_world_without_sidebar_are_ignored(app: App) -> None: + app.input.sidebar = None # InputHandler auch ohne Sidebar nutzbar + click(app, (10, 10)) # links neben der Welt + assert app.world.bodies == [] + click(app, screen_pos(app, 400, 300)) + assert len(app.world.bodies) == 1 + + +def test_middle_click_and_delete_without_target_do_nothing(app: App) -> None: + click(app, screen_pos(app, 400, 300), pygame.BUTTON_MIDDLE) + assert app.world.bodies == [] + move(app, screen_pos(app, 600, 300)) + key(app, "delete") # nichts unter der Maus + click(app, screen_pos(app, 600, 300), pygame.BUTTON_RIGHT) + assert app.world.bodies == [] + + +def test_spawn_drag_is_ignored_when_the_world_is_full(app: App) -> None: + app.close() + full = App(replace(APP, interaction=replace(INTERACTION, max_bodies=1))) + try: + click(full, screen_pos(full, 400, 300)) + tile = full.sidebar.widget("spawn_tile").rect + press(full, tile.center) + move(full, screen_pos(full, 200, 200)) + assert len(full.world.bodies) == 1 + assert full.drag.body is None + assert full.input.spawn_preview() is None # voll: keine Vorschau mehr + release(full, screen_pos(full, 200, 200)) + frames(full) + finally: + full.close() + + +def test_unknown_widget_names_raise(app: App) -> None: + with pytest.raises(KeyError): + app.sidebar.widget("gibt-es-nicht") + + +def test_missing_system_fonts_fall_back_to_the_builtin_font() -> None: + pygame.font.init() + try: + font = _load_font(("keine-solche-schrift-xyz",), 14, bold=True) + assert font.bold + assert font.size("Test")[0] > 0 + finally: + pygame.font.quit() + + +def test_style_initializes_the_font_module_itself() -> None: + pygame.font.quit() + style = UiStyle(Theme(), UiConfig()) + try: + assert pygame.font.get_init() + assert style.render_text("x", style.label_font, (0, 0, 0)).get_width() > 0 + finally: + pygame.font.quit() diff --git a/tests/app/test_smoke.py b/tests/app/test_smoke.py new file mode 100644 index 0000000..c1b1e31 --- /dev/null +++ b/tests/app/test_smoke.py @@ -0,0 +1,77 @@ +"""Smoke-Test: Die echte Hauptschleife läuft einige Sekunden headless mit Zufallseingaben. + +Ein simulierter, zufällig klickender "Affe" bedient dabei alles, was ein Mensch auch +bedienen könnte: Klicks in Welt und Sidebar, Ziehen und Werfen, Rechtsklicks, Mausrad +und Tastenkürzel. Der Test gilt als bestanden, wenn keine Exception auftritt und die +Simulation danach in einem gültigen Zustand ist. +""" + +import random + +import pygame +import pytest + +from sandbox.config import PHYSICS +from sandbox.main import App + +SMOKE_SECONDS = 3.0 +SAFE_KEYS = ("space", "g", "c", "delete", "1", "2", "f1") # alles außer "Beenden" + + +class MonkeyApp(App): + """Die echte Anwendung, die sich vor jedem Frame selbst zufällige Events schickt.""" + + def __init__(self, seed: int) -> None: + super().__init__(rng=random.Random(seed)) + self.monkey = random.Random(seed) + self.frame_count = 0 + + def frame(self, frame_time: float) -> None: + self._post_random_events() + super().frame(frame_time) + self.frame_count += 1 + + def _random_position(self) -> tuple[int, int]: + return ( + self.monkey.randrange(self.config.window.width), + self.monkey.randrange(self.config.window.height), + ) + + def _post_random_events(self) -> None: + roll = self.monkey.random() + position = self._random_position() + if roll < 0.25: + button = self.monkey.choice([pygame.BUTTON_LEFT, pygame.BUTTON_RIGHT]) + pygame.event.post( + pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=position, button=button) + ) + elif roll < 0.45: + pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONUP, pos=position, button=1)) + elif roll < 0.85: + pygame.event.post( + pygame.event.Event(pygame.MOUSEMOTION, pos=position, rel=(0, 0), buttons=(1, 0, 0)) + ) + elif roll < 0.93: + pygame.event.post( + pygame.event.Event(pygame.MOUSEWHEEL, x=0, y=self.monkey.choice([-1, 1])) + ) + else: + key = pygame.key.key_code(self.monkey.choice(SAFE_KEYS)) + pygame.event.post( + pygame.event.Event(pygame.KEYDOWN, key=key, mod=0, unicode="", scancode=0) + ) + + +@pytest.mark.slow +def test_main_loop_survives_seconds_of_random_input() -> None: + app = MonkeyApp(seed=7) + try: + app.load_demo_scene() + app.run(max_seconds=SMOKE_SECONDS) + assert app.running + assert app.frame_count > 0 + for body in app.world.bodies: + assert body.position.is_finite() + assert body.velocity.length() <= PHYSICS.max_speed + 1e-6 + finally: + app.close() diff --git a/tests/app/test_widgets.py b/tests/app/test_widgets.py new file mode 100644 index 0000000..73c141b --- /dev/null +++ b/tests/app/test_widgets.py @@ -0,0 +1,193 @@ +"""Tests einzelner Widgets (headless) sowie kleiner Hilfsbausteine der Oberfläche.""" + +import math +from collections.abc import Iterator + +import pygame +import pytest + +from sandbox.config import INTERACTION, Theme, UiConfig +from sandbox.interaction import ShapeKind, ToolSettings +from sandbox.renderer import interpolate_angle +from sandbox.ui.style import UiStyle, shade +from sandbox.ui.widgets import ( + Button, + ColorPicker, + Label, + LabelKind, + SegmentedControl, + Slider, + SpawnTile, + Widget, +) +from sandbox.viewport import Viewport + + +@pytest.fixture +def style() -> Iterator[UiStyle]: + pygame.init() + yield UiStyle(Theme(), UiConfig()) + pygame.quit() + + +def _event(event_type: int, **attributes: object) -> pygame.event.Event: + return pygame.event.Event(event_type, **attributes) + + +def _press(pos: tuple[int, int]) -> pygame.event.Event: + return _event(pygame.MOUSEBUTTONDOWN, pos=pos, button=pygame.BUTTON_LEFT) + + +def _release(pos: tuple[int, int]) -> pygame.event.Event: + return _event(pygame.MOUSEBUTTONUP, pos=pos, button=pygame.BUTTON_LEFT) + + +def _slider(value: list[float], enabled: bool = True) -> Slider: + return Slider( + pygame.Rect(0, 0, 214, 38), + "Test", + minimum=10, + maximum=110, + step=5, + get_value=lambda: value[0], + set_value=lambda new: value.__setitem__(0, new), + value_format=str, + knob_radius=7, + is_enabled=lambda: enabled, + ) + + +def test_slider_maps_positions_to_snapped_values() -> None: + slider = _slider([10.0]) + assert slider.value_at(slider.track_left) == 10 + assert slider.value_at(slider.track_left + slider.track_width) == 110 + assert slider.value_at(slider.track_left + slider.track_width / 2) == 60 + assert slider.value_at(-1000) == 10 # außerhalb wird begrenzt + assert slider.snap(23) == 25 + assert slider.x_of(60) == pytest.approx(slider.track_left + slider.track_width / 2) + + +def test_slider_drag_changes_the_value_until_released() -> None: + value = [10.0] + slider = _slider(value) + assert slider.handle_event(_press((slider.track_left, slider.track_y)), (0, 0)) + motion = _event(pygame.MOUSEMOTION, pos=(500, 20)) + assert slider.handle_event(motion, (500, 20)) + assert value[0] == 110 + assert slider.handle_event(_release((500, 20)), (500, 20)) + assert not slider.dragging + assert not slider.handle_event(motion, (500, 20)) # nach dem Loslassen: kein Ziehen mehr + + +def test_disabled_slider_swallows_clicks_but_does_nothing() -> None: + value = [10.0] + slider = _slider(value, enabled=False) + assert slider.handle_event(_press((100, 30)), (100, 30)) + assert value[0] == 10 + assert not slider.handle_event(_event(pygame.MOUSEWHEEL, x=0, y=1), (100, 30)) + + +def test_slider_rejects_an_empty_range() -> None: + with pytest.raises(ValueError, match="maximum > minimum"): + Slider( + pygame.Rect(0, 0, 100, 38), + "x", + minimum=1, + maximum=1, + step=1, + get_value=lambda: 1.0, + set_value=lambda _: None, + value_format=str, + knob_radius=7, + ) + + +def test_segmented_control_and_color_picker_select_on_click() -> None: + selected: list[object] = [ShapeKind.CIRCLE, None] + control = SegmentedControl( + pygame.Rect(0, 0, 200, 30), + [(ShapeKind.CIRCLE, "Kreis"), (ShapeKind.BOX, "Rechteck")], + lambda: selected[0], + lambda value: selected.__setitem__(0, value), + ) + assert control.handle_event(_press(control.segment_rects()[1].center), (0, 0)) + assert selected[0] is ShapeKind.BOX + assert not control.handle_event(_press((500, 500)), (500, 500)) + + picker = ColorPicker( + pygame.Rect(0, 40, 250, 22), + Theme().body_palette, + lambda: selected[1], # type: ignore[return-value] + lambda index: selected.__setitem__(1, index), + swatch_size=22, + swatch_gap=3, + ) + swatches = picker.swatches() + assert swatches[0][0] is None # erstes Feld = Zufallsfarbe + assert picker.handle_event(_press(swatches[2][1].center), (0, 0)) + assert selected[1] == 1 + assert picker.handle_event(_press(swatches[0][1].center), (0, 0)) + assert selected[1] is None + + +def test_all_widgets_draw_in_every_state(style: UiStyle) -> None: + surface = pygame.Surface((300, 600)) + clicks: list[str] = [] + tools = ToolSettings.from_config(INTERACTION) + widgets: list[Widget] = [ + Label(pygame.Rect(0, 0, 200, 20), "Titel", LabelKind.TITLE), + Label(pygame.Rect(0, 20, 200, 20), lambda: "dynamisch", LabelKind.HINT), + Button( + pygame.Rect(0, 40, 100, 30), "Aktiv", lambda: clicks.append("a"), is_active=lambda: True + ), + Button(pygame.Rect(0, 80, 100, 30), "Gefahr", lambda: None, danger=True), + _slider([60.0]), + _slider([60.0], enabled=False), + SpawnTile( + pygame.Rect(0, 300, 228, 92), + lambda: tools, + lambda: (200, 100, 50), + lambda: clicks.append("spawn"), + hint="Hinweis", + ), + ] + for widget in widgets: + widget.handle_event(_event(pygame.MOUSEMOTION, pos=widget.rect.center), widget.rect.center) + widget.draw(surface, style) + tools.shape = ShapeKind.BOX + widgets[-1].draw(surface, style) + assert widgets[-1].handle_event(_press(widgets[-1].rect.center), (0, 0)) + assert clicks == ["spawn"] + + +def test_base_widget_cannot_be_drawn(style: UiStyle) -> None: + with pytest.raises(NotImplementedError): + Widget(pygame.Rect(0, 0, 1, 1)).draw(pygame.Surface((1, 1)), style) + + +def test_text_cache_is_bounded(style: UiStyle) -> None: + for number in range(style.ui.text_cache_size + 10): + style.render_text(str(number), style.small_font, (255, 255, 255)) + assert len(style._text_cache) <= style.ui.text_cache_size + + +def test_shade_clamps_to_valid_colors() -> None: + assert shade((100, 200, 250), 0.5) == (50, 100, 125) + assert shade((100, 200, 250), 2.0) == (200, 255, 255) + + +def test_viewport_maps_between_screen_and_world() -> None: + viewport = Viewport(260, 0, 1020, 720) + point = viewport.to_world((300, 50)) + assert (point.x, point.y) == (40, 50) + assert viewport.to_screen(point) == (300, 50) + assert viewport.contains((260, 0)) + assert not viewport.contains((259, 10)) + assert not viewport.contains((1280, 10)) + + +def test_angle_interpolation_takes_the_short_way_around() -> None: + assert interpolate_angle(0.0, 1.0, 0.5) == pytest.approx(0.5) + # Von knapp unter +π nach knapp über −π ist nur ein kleiner Schritt. + halfway = interpolate_angle(math.pi - 0.1, -math.pi + 0.1, 0.5) + assert math.cos(halfway) == pytest.approx(-1.0) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4c0dce4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Gemeinsame pytest-Konfiguration. + +Die Umgebungsvariablen müssen gesetzt sein, *bevor* irgendein Test pygame importiert: +Mit dem Dummy-Treiber öffnet SDL kein echtes Fenster und braucht keine Soundkarte – so +laufen UI- und Integrationstests auch headless (z. B. in CI). +""" + +import os + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") +os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1") diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..b5a9acf --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,81 @@ +"""Hilfsfunktionen für die Physik-Tests (Szenen aufbauen, simulieren, Erhaltungsgrößen).""" + +from __future__ import annotations + +from dataclasses import replace + +from sandbox.config import PHYSICS, TIME_STEP +from sandbox.physics import Body, Material, Vector2, World + +WORLD_WIDTH = 1000.0 +WORLD_HEIGHT = 700.0 + + +def make_world( + width: float = WORLD_WIDTH, height: float = WORLD_HEIGHT, **overrides: object +) -> World: + """Welt mit Standardkonfiguration; einzelne Parameter lassen sich überschreiben.""" + return World(width, height, replace(PHYSICS, **overrides)) + + +def material(density: float = 1.0, restitution: float = 0.5, friction: float = 0.5) -> Material: + """Material mit gut lesbaren Standardwerten.""" + return Material(density=density, restitution=restitution, friction=friction) + + +def add_circle( + world: World, + x: float, + y: float, + radius: float, + *, + velocity: tuple[float, float] = (0.0, 0.0), + angular_velocity: float = 0.0, + **material_values: float, +) -> Body: + """Fügt einen Kreis hinzu.""" + body = Body.circle( + Vector2(x, y), radius, material(**material_values), velocity=Vector2(*velocity) + ) + body.angular_velocity = angular_velocity + return world.add_body(body) + + +def add_box( + world: World, + x: float, + y: float, + half_width: float, + half_height: float, + *, + velocity: tuple[float, float] = (0.0, 0.0), + **material_values: float, +) -> Body: + """Fügt ein achsenparalleles Rechteck hinzu.""" + body = Body.box( + Vector2(x, y), + half_width, + half_height, + material(**material_values), + velocity=Vector2(*velocity), + ) + return world.add_body(body) + + +def simulate(world: World, seconds: float, dt: float = TIME_STEP) -> None: + """Führt so viele feste Schritte aus, wie in ``seconds`` passen.""" + for _ in range(round(seconds / dt)): + world.step(dt) + + +def total_momentum(bodies: list[Body]) -> Vector2: + """Summe der Impulse m·v.""" + total = Vector2() + for body in bodies: + total = total + body.momentum() + return total + + +def total_kinetic_energy(bodies: list[Body]) -> float: + """Summe der Bewegungsenergien (translatorisch + rotatorisch).""" + return sum(body.kinetic_energy() for body in bodies) diff --git a/tests/interaction/__init__.py b/tests/interaction/__init__.py new file mode 100644 index 0000000..4e29fd9 --- /dev/null +++ b/tests/interaction/__init__.py @@ -0,0 +1 @@ +"""Tests der pygame-freien Werkzeug-Logik.""" diff --git a/tests/interaction/test_demo.py b/tests/interaction/test_demo.py new file mode 100644 index 0000000..3ac3aac --- /dev/null +++ b/tests/interaction/test_demo.py @@ -0,0 +1,29 @@ +"""Tests der Beispielszene.""" + +import random + +from sandbox.config import INTERACTION, TIME_STEP, DemoSceneConfig, Theme +from sandbox.demo import build_demo_scene +from sandbox.interaction import BodyFactory, ToolSettings +from tests.helpers import make_world + + +def test_demo_scene_fits_into_the_world_and_settles() -> None: + world = make_world(1020, 720) + factory = BodyFactory(world, INTERACTION, Theme().body_palette, random.Random(0)) + scene = DemoSceneConfig() + build_demo_scene(factory, ToolSettings.from_config(INTERACTION), scene) + + pyramid = scene.pyramid_rows * (scene.pyramid_rows + 1) // 2 + assert len(world.bodies) == scene.tower_levels + pyramid + scene.ball_count + for body in world.bodies: + min_x, min_y, max_x, max_y = body.bounds() + assert min_x >= 0 and max_x <= world.width + assert min_y >= 0 and max_y <= world.height + + for _ in range(round(4 / TIME_STEP)): + world.step(TIME_STEP) + tower = world.bodies[: scene.tower_levels] + assert all(body.sleeping for body in tower) # der Turm steht ruhig + tops = [body.position.y for body in tower] + assert tops == sorted(tops, reverse=True) # und ist nicht eingestürzt diff --git a/tests/interaction/test_drag_and_tools.py b/tests/interaction/test_drag_and_tools.py new file mode 100644 index 0000000..51ac7b7 --- /dev/null +++ b/tests/interaction/test_drag_and_tools.py @@ -0,0 +1,226 @@ +"""Tests für Greifen/Ziehen/Werfen und das Erzeugen von Körpern (ohne pygame).""" + +import math +import random +from dataclasses import replace + +import pytest + +from sandbox.config import INTERACTION, MIN_BODY_SIZE, PHYSICS, TIME_STEP, Theme +from sandbox.interaction import BodyFactory, BodyStyle, DragController, ShapeKind, ToolSettings +from sandbox.physics import Box, Circle, Vector2 +from tests.helpers import add_box, add_circle, make_world + +PALETTE = Theme().body_palette + + +def _factory(**overrides: object) -> BodyFactory: + return BodyFactory(make_world(), replace(INTERACTION, **overrides), PALETTE, random.Random(1)) + + +def _settings(**overrides: object) -> ToolSettings: + return replace(ToolSettings.from_config(INTERACTION), **overrides) + + +# --------------------------------------------------------------------- Werkzeug-Einstellungen + + +def test_circle_half_extents_are_the_radius() -> None: + assert _settings(shape=ShapeKind.CIRCLE, size=30).half_extents() == (30, 30) + + +def test_box_aspect_ratio_changes_shape_but_not_area() -> None: + wide = _settings(shape=ShapeKind.BOX, size=30, aspect_ratio=2.0).half_extents() + square = _settings(shape=ShapeKind.BOX, size=30, aspect_ratio=1.0).half_extents() + assert wide[0] > wide[1] + assert square == pytest.approx((30, 30)) + assert wide[0] * wide[1] == pytest.approx(square[0] * square[1]) + + +def test_box_sides_never_drop_below_the_minimum_size() -> None: + thin = _settings(shape=ShapeKind.BOX, size=MIN_BODY_SIZE, aspect_ratio=2.0).half_extents() + assert min(thin) == pytest.approx(MIN_BODY_SIZE) + + +# --------------------------------------------------------------------- Körper erzeugen + + +def test_factory_creates_circles_and_boxes_with_the_selected_material() -> None: + factory = _factory() + circle = factory.create(_settings(size=20, density=2.0, restitution=0.9), Vector2(300, 300)) + assert circle is not None + assert isinstance(circle.shape, Circle) + assert circle.mass == pytest.approx(math.pi * 20**2 * 2.0) + assert circle.material.restitution == 0.9 + assert circle.material.friction == INTERACTION.friction + + box = factory.create(_settings(shape=ShapeKind.BOX, size=20), Vector2(500, 300)) + assert box is not None + assert isinstance(box.shape, Box) + assert factory.world.bodies == [circle, box] + + +def test_factory_moves_new_bodies_completely_into_the_world() -> None: + factory = _factory() + body = factory.create(_settings(size=30), Vector2(-100, 5000)) + assert body is not None + assert body.position == Vector2(30, factory.world.height - 30) + + +def test_factory_respects_the_body_limit() -> None: + factory = _factory(max_bodies=2) + settings = _settings() + assert factory.create(settings, Vector2(100, 100)) is not None + assert factory.create(settings, Vector2(200, 100)) is not None + assert factory.is_full + assert factory.create(settings, Vector2(300, 100)) is None + assert len(factory.world.bodies) == 2 + + +def test_preview_color_is_the_color_of_the_next_body() -> None: + factory = _factory() + settings = _settings(color_index=None) + for _ in range(10): + announced = factory.color_for(settings) + body = factory.create(settings, Vector2(300, 300)) + assert body is not None + assert body.user_data == BodyStyle(announced) + + +def test_selected_palette_color_is_used() -> None: + factory = _factory() + body = factory.create(_settings(color_index=3), Vector2(300, 300)) + assert body is not None + assert body.user_data == BodyStyle(PALETTE[3]) + + +# --------------------------------------------------------------------- Ziehen und Werfen + + +def test_grab_marks_the_body_as_held_and_keeps_the_grip_offset() -> None: + world = make_world() + body = add_circle(world, 300, 300, 20) + body.put_to_sleep() + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(310, 295), time=0.0) + assert drag.body is body + assert body.held + assert not body.sleeping + drag.move(Vector2(410, 295), time=0.1) + assert drag.target == Vector2(400, 300) # Griffversatz (−10, +5) bleibt erhalten + + +def test_target_is_clamped_so_the_body_fits_into_the_world() -> None: + world = make_world() + body = add_circle(world, 300, 300, 20) + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(300, 300), time=0.0) + drag.move(Vector2(-500, -500), time=0.1) + assert drag.target == Vector2(20, 20) + + +def test_held_body_follows_the_pointer_through_the_physics() -> None: + world = make_world() + body = add_circle(world, 300, 300, 20) + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(300, 300), time=0.0) + drag.move(Vector2(450, 250), time=0.0) + for _ in range(60): + drag.apply(TIME_STEP) + world.step(TIME_STEP) + assert body.position.is_close(Vector2(450, 250), tolerance=0.5) + + +def test_held_body_speed_is_limited() -> None: + world = make_world() + body = add_circle(world, 30, 300, 20) + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(30, 300), time=0.0) + drag.move(Vector2(980, 300), time=0.0) + drag.apply(TIME_STEP) + assert body.velocity.length() == pytest.approx(PHYSICS.max_speed) + + +def test_held_body_still_collides_and_pushes_other_bodies() -> None: + world = make_world() + obstacle = add_box(world, 500, 680, 20, 20) + held = add_box(world, 300, 680, 20, 20) + drag = DragController(world, INTERACTION) + drag.grab(held, Vector2(300, 680), time=0.0) + drag.move(Vector2(700, 680), time=0.0) + for _ in range(120): + drag.apply(TIME_STEP) + world.step(TIME_STEP) + assert obstacle.position.x > 520 # wurde weggeschoben + assert obstacle.position.x - held.position.x >= 40 - PHYSICS.penetration_slop - 1e-3 + + +def test_held_body_pressed_against_an_obstacle_does_not_jitter() -> None: + """Ein gegen ein blockiertes Hindernis gedrückter, gehaltener Körper steht ruhig. + + Würde er abprallen, zöge ihn die Maus im nächsten Schritt wieder heran – er zitterte. + """ + world = make_world() + add_box(world, 1000 - 20, 680, 20, 20, restitution=1.0) # liegt an der rechten Wand + held = add_box(world, 700, 680, 20, 20, restitution=1.0) + drag = DragController(world, INTERACTION) + drag.grab(held, Vector2(700, 680), time=0.0) + drag.move(Vector2(990, 680), time=0.0) # Ziel liegt hinter dem Hindernis + positions = [] + for step in range(180): + drag.apply(TIME_STEP) + world.step(TIME_STEP) + if step >= 120: + positions.append(held.position.x) + assert max(positions) - min(positions) < 0.1 + # Wand → Hindernis → gehaltener Körper: Jeder der beiden Kontakte behält höchstens den + # Slop als Resttiefe, der Körper steht also bei x ≤ 1000 − 40 − 20 + 2·Slop. + assert max(positions) <= 1000 - 40 - 20 + 2 * PHYSICS.penetration_slop + 1e-3 + + +def test_release_throws_with_the_smoothed_pointer_velocity() -> None: + world = make_world(gravity=(0.0, 0.0)) + body = add_circle(world, 200, 300, 20) + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(200, 300), time=0.0) + for frame in range(1, 31): + t = frame / 60 + drag.move(Vector2(200 + 600 * t, 300 - 150 * t), time=t) + assert drag.release(paused=False) is body + assert body.velocity.is_close(Vector2(600, -150), tolerance=5.0) + assert not body.held + assert drag.body is None + + +def test_throw_speed_is_limited() -> None: + world = make_world() + body = add_circle(world, 200, 300, 20) + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(200, 300), time=0.0) + for frame in range(1, 11): + drag.move(Vector2(200 + 100 * frame, 300), time=frame / 120) # 12 000 px/s + drag.release(paused=False) + assert body.velocity.length() == pytest.approx(INTERACTION.max_throw_speed) + + +def test_release_while_paused_does_not_throw() -> None: + world = make_world() + body = add_circle(world, 200, 300, 20) + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(200, 300), time=0.0) + drag.move(Vector2(260, 300), time=0.05) + drag.place() + assert body.position == Vector2(260, 300) + drag.release(paused=True) + assert body.velocity == Vector2(0, 0) + + +def test_cancel_and_release_without_body_are_harmless() -> None: + world = make_world() + drag = DragController(world, INTERACTION) + drag.cancel() + drag.apply(TIME_STEP) + drag.place() + drag.move(Vector2(1, 1), time=0.0) + assert drag.release(paused=False) is None + assert drag.throw_velocity == Vector2(0, 0) diff --git a/tests/interaction/test_velocity_tracker.py b/tests/interaction/test_velocity_tracker.py new file mode 100644 index 0000000..3b5bbfd --- /dev/null +++ b/tests/interaction/test_velocity_tracker.py @@ -0,0 +1,75 @@ +"""Tests der geglätteten Wurfgeschwindigkeit.""" + +import random + +import pytest + +from sandbox.interaction import VelocityTracker +from sandbox.physics import Vector2 + +WINDOW = 0.1 +SMOOTHING = 0.05 + + +def _track(velocity: Vector2, fps: float, seconds: float) -> VelocityTracker: + """Bewegt einen Zeiger mit konstanter Geschwindigkeit und liefert den Tracker.""" + tracker = VelocityTracker(WINDOW, SMOOTHING) + tracker.reset(0.0, Vector2(0, 0)) + frames = round(seconds * fps) + for frame in range(1, frames + 1): + t = frame / fps + tracker.add_sample(t, velocity * t) + return tracker + + +def test_constant_motion_is_measured_exactly() -> None: + tracker = _track(Vector2(600, -200), fps=60, seconds=0.5) + assert tracker.velocity.is_close(Vector2(600, -200), tolerance=1.0) + + +def test_result_does_not_depend_on_the_frame_rate() -> None: + slow = _track(Vector2(500, 0), fps=30, seconds=0.5) + fast = _track(Vector2(500, 0), fps=144, seconds=0.5) + assert slow.velocity.x == pytest.approx(fast.velocity.x, rel=0.01) + + +def test_velocity_decays_when_the_pointer_stops() -> None: + tracker = _track(Vector2(800, 0), fps=60, seconds=0.3) + resting = Vector2(800 * 0.3, 0) + for frame in range(1, 31): # eine halbe Sekunde stillhalten + tracker.add_sample(0.3 + frame / 60, resting) + if frame == 12: # nach 0,2 s ist der Schwung auf wenige Prozent abgeklungen + assert tracker.velocity.length() < 0.1 * 800 + assert tracker.velocity.length() < 1.0 + + +def test_jitter_is_smoothed() -> None: + rng = random.Random(0) + tracker = VelocityTracker(WINDOW, SMOOTHING) + tracker.reset(0.0, Vector2(0, 0)) + for frame in range(1, 61): + t = frame / 60 + noise = Vector2(rng.uniform(-3, 3), rng.uniform(-3, 3)) # zittrige Hand + tracker.add_sample(t, Vector2(400 * t, 0) + noise) + assert tracker.velocity.x == pytest.approx(400, rel=0.1) + assert abs(tracker.velocity.y) < 60 + + +def test_samples_with_the_same_timestamp_replace_each_other() -> None: + tracker = VelocityTracker(WINDOW, SMOOTHING) + tracker.reset(0.0, Vector2(0, 0)) + tracker.add_sample(0.0, Vector2(50, 0)) # gleicher Frame: keine Division durch 0 + assert tracker.velocity == Vector2(0, 0) + tracker.add_sample(0.1, Vector2(50, 0)) + assert tracker.velocity.x == 0 # die ersetzte Position ist der neue Bezugspunkt + + +def test_first_sample_without_reset_starts_a_measurement() -> None: + tracker = VelocityTracker(WINDOW, SMOOTHING) + tracker.add_sample(1.0, Vector2(5, 5)) + assert tracker.velocity == Vector2(0, 0) + + +def test_invalid_parameters_are_rejected() -> None: + with pytest.raises(ValueError, match="positiv"): + VelocityTracker(0, SMOOTHING) diff --git a/tests/physics/__init__.py b/tests/physics/__init__.py new file mode 100644 index 0000000..5bc0e17 --- /dev/null +++ b/tests/physics/__init__.py @@ -0,0 +1 @@ +"""Tests der pygame-freien Physik-Schicht.""" diff --git a/tests/physics/test_bodies.py b/tests/physics/test_bodies.py new file mode 100644 index 0000000..ad3ef26 --- /dev/null +++ b/tests/physics/test_bodies.py @@ -0,0 +1,171 @@ +"""Tests für Formen, Materialien und Körper (Masse, Trägheit, Impulse, Zustände).""" + +import math +from collections.abc import Callable + +import pytest + +from sandbox.physics import Body, Box, Circle, HalfPlane, Material, Vector2 +from sandbox.physics.vector import ZERO +from tests.helpers import material + + +def test_circle_area_and_moment_of_inertia() -> None: + circle = Circle(10) + assert circle.area() == pytest.approx(math.pi * 100) + assert circle.moment_of_inertia(mass=2) == pytest.approx(0.5 * 2 * 100) + assert circle.half_extents() == (10, 10) + + +def test_box_area_and_infinite_inertia() -> None: + box = Box(3, 2) + assert box.area() == 24 + assert math.isinf(box.moment_of_inertia(mass=5)) + assert box.half_extents() == (3, 2) + + +@pytest.mark.parametrize( + "factory", + [lambda: Circle(0), lambda: Circle(-1), lambda: Box(0, 1), lambda: Box(1, -2)], +) +def test_invalid_shapes_are_rejected(factory: Callable[[], object]) -> None: + with pytest.raises(ValueError, match="positiv"): + factory() + + +def test_half_plane_requires_unit_normal() -> None: + with pytest.raises(ValueError, match="Länge 1"): + HalfPlane(Vector2(0, 2), 0) + + +def test_half_plane_distance_and_contains() -> None: + floor = HalfPlane(Vector2(0, -1), -100) # frei ist y ≤ 100 + assert floor.distance(Vector2(0, 90)) == pytest.approx(10) + assert floor.distance(Vector2(0, 110)) == pytest.approx(-10) + assert floor.contains(Vector2(0, 110)) + assert not floor.contains(Vector2(0, 90)) + assert floor.contains(Vector2(0, 90), tolerance=10) + assert math.isinf(floor.half_extents()[0]) + + +def test_point_containment_with_tolerance() -> None: + circle = Body.circle(Vector2(100, 100), 10, material()) + assert circle.contains_point(Vector2(107, 107)) + assert not circle.contains_point(Vector2(110, 110)) + assert circle.contains_point(Vector2(110, 110), tolerance=5) + + box = Body.box(Vector2(0, 0), 10, 5, material()) + assert box.contains_point(Vector2(10, -5)) + assert not box.contains_point(Vector2(10.5, 0)) + assert box.contains_point(Vector2(10.5, 0), tolerance=1) + + +def test_mass_is_area_times_density() -> None: + circle = Body.circle(Vector2(), 10, material(density=2)) + assert circle.mass == pytest.approx(math.pi * 100 * 2) + assert circle.inv_mass == pytest.approx(1 / circle.mass) + box = Body.box(Vector2(), 10, 5, material(density=0.5)) + assert box.mass == pytest.approx(20 * 10 * 0.5) + + +def test_bigger_bodies_are_heavier() -> None: + small = Body.circle(Vector2(), 10, material()) + large = Body.circle(Vector2(), 20, material()) + assert large.mass == pytest.approx(4 * small.mass) # Fläche wächst mit r² + + +def test_static_bodies_have_infinite_mass_and_never_move() -> None: + wall = Body.wall(Vector2(0, 1), 0, material()) + assert wall.is_static + assert wall.inv_mass == 0 + assert wall.inv_inertia == 0 + assert math.isinf(wall.mass) + wall.apply_impulse(Vector2(100, 100)) + assert wall.velocity == ZERO + assert wall.kinetic_energy() == 0 + assert wall.momentum() == ZERO + + +def test_half_planes_cannot_be_dynamic() -> None: + with pytest.raises(ValueError, match="statisch"): + Body(HalfPlane(Vector2(0, 1), 0), Vector2(), material()) + + +def test_dynamic_bodies_need_positive_density() -> None: + with pytest.raises(ValueError, match="Dichte"): + Body.circle(Vector2(), 10, material(density=0)) + + +@pytest.mark.parametrize( + ("density", "restitution", "friction"), + [(-1, 0.5, 0.5), (1, -0.1, 0.5), (1, 1.5, 0.5), (1, 0.5, -1)], +) +def test_invalid_materials_are_rejected( + density: float, restitution: float, friction: float +) -> None: + with pytest.raises(ValueError, match=r"Dichte|Restitution|Reibung"): + Material(density, restitution, friction) + + +def test_circles_can_rotate_but_boxes_cannot() -> None: + circle = Body.circle(Vector2(), 10, material()) + box = Body(Box(10, 10), Vector2(), material(), angular_velocity=5) + assert circle.can_rotate + assert not box.can_rotate + assert box.angular_velocity == 0 # Drehung wird bei Rechtecken ignoriert + + +def test_off_center_impulse_spins_a_circle() -> None: + circle = Body.circle(Vector2(), 10, material()) + # Stoß nach rechts an der Unterseite: erzeugt Vorwärtsbewegung und Drehung. + circle.apply_impulse(Vector2(circle.mass * 10, 0), offset=Vector2(0, 10)) + assert circle.velocity.is_close(Vector2(10, 0)) + expected_spin = circle.inv_inertia * Vector2(0, 10).cross(Vector2(circle.mass * 10, 0)) + assert circle.angular_velocity == pytest.approx(expected_spin) + assert circle.angular_velocity < 0 + + +def test_off_center_impulse_does_not_spin_a_box() -> None: + box = Body.box(Vector2(), 10, 10, material()) + box.apply_impulse(Vector2(box.mass, 0), offset=Vector2(0, 10)) + assert box.angular_velocity == 0 + assert box.velocity.is_close(Vector2(1, 0)) + + +def test_kinetic_energy_and_momentum() -> None: + circle = Body.circle(Vector2(), 10, material(), velocity=Vector2(3, 4)) + circle.angular_velocity = 2 + expected = 0.5 * circle.mass * 25 + 0.5 * circle.inertia * 4 + assert circle.kinetic_energy() == pytest.approx(expected) + assert circle.momentum().is_close(Vector2(3, 4) * circle.mass) + + +def test_bounds() -> None: + assert Body.circle(Vector2(10, 20), 5, material()).bounds() == (5, 15, 15, 25) + assert Body.box(Vector2(10, 20), 4, 2, material()).bounds() == (6, 18, 14, 22) + + +def test_sleep_wake_and_teleport() -> None: + body = Body.circle(Vector2(0, 0), 10, material(), velocity=Vector2(5, 0)) + body.angular_velocity = 1 + body.sleep_time = 1 + body.put_to_sleep() + assert body.sleeping + assert body.velocity == ZERO + assert body.angular_velocity == 0 + body.teleport(Vector2(50, 60)) + assert not body.sleeping + assert body.sleep_time == 0 + assert body.position == body.previous_position == Vector2(50, 60) + + +def test_ids_are_unique() -> None: + bodies = [Body.circle(Vector2(), 10, material()) for _ in range(5)] + assert len({body.id for body in bodies}) == 5 + + +def test_repr_mentions_id_and_sleep_state() -> None: + body = Body.circle(Vector2(1, 2), 10, material()) + assert f"id={body.id}" in repr(body) + body.put_to_sleep() + assert "schläft" in repr(body) diff --git a/tests/physics/test_broadphase.py b/tests/physics/test_broadphase.py new file mode 100644 index 0000000..59e2a70 --- /dev/null +++ b/tests/physics/test_broadphase.py @@ -0,0 +1,56 @@ +"""Tests der Broadphase (Sweep-and-Prune).""" + +import random + +from sandbox.physics import Body, Vector2 +from sandbox.physics.broadphase import find_candidate_pairs +from tests.helpers import material + + +def _random_scene(seed: int, count: int) -> list[Body]: + rng = random.Random(seed) + bodies = [] + for _ in range(count): + position = Vector2(rng.uniform(0, 400), rng.uniform(0, 300)) + if rng.random() < 0.5: + bodies.append(Body.circle(position, rng.uniform(5, 30), material())) + else: + bodies.append(Body.box(position, rng.uniform(5, 30), rng.uniform(5, 30), material())) + return bodies + + +def _overlap(a: Body, b: Body) -> bool: + a_min_x, a_min_y, a_max_x, a_max_y = a.bounds() + b_min_x, b_min_y, b_max_x, b_max_y = b.bounds() + return a_min_x <= b_max_x and b_min_x <= a_max_x and a_min_y <= b_max_y and b_min_y <= a_max_y + + +def test_matches_brute_force_on_random_scenes() -> None: + for seed in range(5): + bodies = _random_scene(seed, 60) + expected = { + (min(a.id, b.id), max(a.id, b.id)) + for i, a in enumerate(bodies) + for b in bodies[i + 1 :] + if _overlap(a, b) + } + found = [(a.id, b.id) for a, b in find_candidate_pairs(bodies)] + assert len(found) == len(set(found)), "kein Paar doppelt" + assert set(found) == expected + + +def test_pairs_are_ordered_by_id() -> None: + bodies = _random_scene(7, 40) + for a, b in find_candidate_pairs(bodies): + assert a.id < b.id + + +def test_pairs_of_sleeping_bodies_are_skipped() -> None: + a = Body.circle(Vector2(0, 0), 10, material()) + b = Body.circle(Vector2(15, 0), 10, material()) + c = Body.circle(Vector2(30, 0), 10, material()) + a.put_to_sleep() + b.put_to_sleep() + pairs = {(x.id, y.id) for x, y in find_candidate_pairs([a, b, c])} + assert (a.id, b.id) not in pairs # beide schlafen + assert (b.id, c.id) in pairs # schlafend + wach wird geprüft diff --git a/tests/physics/test_collision.py b/tests/physics/test_collision.py new file mode 100644 index 0000000..e1ccc54 --- /dev/null +++ b/tests/physics/test_collision.py @@ -0,0 +1,244 @@ +"""Tests der Kollisionserkennung für alle Formpaarungen einschließlich Randfällen.""" + +import random + +import pytest + +from sandbox.physics import Body, Vector2, collide +from sandbox.physics.collision import ( + COINCIDENT_NORMAL, + collide_box_half_plane, + collide_boxes, + collide_circle_box, + collide_circle_half_plane, + collide_circles, +) +from tests.helpers import material + + +def circle(x: float, y: float, radius: float) -> Body: + return Body.circle(Vector2(x, y), radius, material()) + + +def box(x: float, y: float, half_width: float, half_height: float) -> Body: + return Body.box(Vector2(x, y), half_width, half_height, material()) + + +def floor_at(y: float) -> Body: + return Body.wall(Vector2(0, -1), -y, material()) # frei ist alles oberhalb von y + + +# --------------------------------------------------------------------- Kreis–Kreis + + +def test_separated_circles_do_not_collide() -> None: + assert collide_circles(circle(0, 0, 10), circle(21, 0, 10)) is None + + +def test_touching_circles_form_a_contact_without_penetration() -> None: + contact = collide_circles(circle(0, 0, 10), circle(20, 0, 10)) + assert contact is not None + assert contact.penetration == pytest.approx(0) + + +def test_overlapping_circles() -> None: + a = circle(0, 0, 10) + b = circle(15, 0, 10) + contact = collide_circles(a, b) + assert contact is not None + assert contact.body_a is a + assert contact.body_b is b + assert contact.normal.is_close(Vector2(1, 0)) + assert contact.penetration == pytest.approx(5) + assert contact.point.is_close(Vector2(7.5, 0)) # Mitte des Überlappungsbereichs + + +def test_coincident_circles_use_a_fixed_fallback_normal() -> None: + contact = collide_circles(circle(5, 5, 10), circle(5, 5, 8)) + assert contact is not None + assert contact.normal == COINCIDENT_NORMAL + assert contact.penetration == pytest.approx(18) + + +# --------------------------------------------------------------------- Kreis–Rechteck + + +def test_circle_touching_the_top_face_of_a_box() -> None: + contact = collide_circle_box(circle(0, -14, 5), box(0, 0, 20, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(0, 1)) # vom Kreis (oben) zur Box (unten) + assert contact.penetration == pytest.approx(1) + assert contact.point.is_close(Vector2(0, -10)) + + +def test_circle_touching_the_right_face_of_a_box() -> None: + contact = collide_circle_box(circle(24, 0, 5), box(0, 0, 20, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(-1, 0)) + assert contact.penetration == pytest.approx(1) + + +def test_circle_touching_a_corner_gets_a_diagonal_normal() -> None: + contact = collide_circle_box(circle(23, -13, 5), box(0, 0, 20, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(-1, 1).normalized()) + assert contact.penetration == pytest.approx(5 - 18**0.5) + assert contact.point.is_close(Vector2(20, -10)) + + +def test_circle_near_a_corner_but_outside_does_not_collide() -> None: + # Die umschließenden Boxen überlappen, der Kreis verfehlt die Ecke aber knapp. + assert collide_circle_box(circle(24, -14, 5), box(0, 0, 20, 10)) is None + + +def test_circle_center_inside_box_is_pushed_out_through_the_nearest_side_face() -> None: + contact = collide_circle_box(circle(15, 2, 5), box(0, 0, 20, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(-1, 0)) # Kreis bewegt sich entlang −n nach rechts + assert contact.penetration == pytest.approx(5 + 5) # Radius + Abstand zur Kante + assert contact.point.is_close(Vector2(20, 2)) + + +def test_circle_center_inside_box_is_pushed_out_through_the_nearest_top_face() -> None: + contact = collide_circle_box(circle(3, -8, 5), box(0, 0, 20, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(0, 1)) # Kreis bewegt sich nach oben + assert contact.penetration == pytest.approx(5 + 2) + assert contact.point.is_close(Vector2(3, -10)) + + +# --------------------------------------------------------------------- Rechteck–Rechteck + + +def test_boxes_are_separated_along_the_axis_of_least_overlap() -> None: + a = box(0, 0, 10, 10) + contact = collide_boxes(a, box(15, 5, 10, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(1, 0)) + assert contact.penetration == pytest.approx(5) + assert contact.point.is_close(Vector2(7.5, 2.5)) # Mitte des Überlappungsrechtecks + + contact = collide_boxes(a, box(5, -18, 10, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(0, -1)) + assert contact.penetration == pytest.approx(2) + + +def test_equal_overlap_prefers_the_vertical_axis() -> None: + contact = collide_boxes(box(0, 0, 10, 10), box(15, 15, 10, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(0, 1)) + + +@pytest.mark.parametrize(("x", "y"), [(21, 0), (0, 25), (-21, 5), (3, -20.5)]) +def test_separated_boxes_do_not_collide(x: float, y: float) -> None: + assert collide_boxes(box(0, 0, 10, 10), box(x, y, 10, 10)) is None + + +# --------------------------------------------------------------------- Wände + + +def test_circle_resting_on_the_floor() -> None: + contact = collide_circle_half_plane(circle(50, 95, 10), floor_at(100)) + assert contact is not None + assert contact.normal.is_close(Vector2(0, 1)) # vom Kreis in die Wand + assert contact.penetration == pytest.approx(5) + assert contact.point.is_close(Vector2(50, 100)) + + +def test_circle_above_the_floor_does_not_collide() -> None: + assert collide_circle_half_plane(circle(50, 80, 10), floor_at(100)) is None + + +def test_circle_completely_behind_the_wall_is_still_detected() -> None: + # Der "Tunnel"-Fall: Selbst ein Körper hinter der Wandlinie wird erkannt + # und in den freien Raum zurückgeschoben. + contact = collide_circle_half_plane(circle(50, 130, 10), floor_at(100)) + assert contact is not None + assert contact.penetration == pytest.approx(40) + assert contact.normal.is_close(Vector2(0, 1)) + + +def test_box_against_floor_and_side_wall() -> None: + contact = collide_box_half_plane(box(50, 95, 20, 10), floor_at(100)) + assert contact is not None + assert contact.penetration == pytest.approx(5) + + left_wall = Body.wall(Vector2(1, 0), 0, material()) + contact = collide_box_half_plane(box(15, 50, 20, 10), left_wall) + assert contact is not None + assert contact.normal.is_close(Vector2(-1, 0)) + assert contact.penetration == pytest.approx(5) + assert collide_box_half_plane(box(25, 50, 20, 10), left_wall) is None + + +# --------------------------------------------------------------------- Zuordnung + + +def test_collide_orders_bodies_canonically() -> None: + c = circle(0, -14, 5) + b = box(0, 0, 20, 10) + for first, second in ((c, b), (b, c)): + contact = collide(first, second) + assert contact is not None + assert contact.body_a is c + assert contact.body_b is b + assert contact.normal.is_close(Vector2(0, 1)) + + wall = floor_at(100) + contact = collide(wall, circle(50, 95, 10)) + assert contact is not None + assert contact.body_b is wall + + +def test_two_walls_never_collide() -> None: + assert collide(floor_at(100), floor_at(50)) is None + + +def test_contact_key_and_repr() -> None: + a = circle(0, 0, 10) + b = circle(15, 0, 10) + contact = collide(a, b) + assert contact is not None + assert contact.key == (a.id, b.id) + assert "penetration=5.000" in repr(contact) + + +# --------------------------------------------------------------------- Eigenschaften + + +def _random_body(rng: random.Random) -> Body: + x = rng.uniform(-40, 40) + y = rng.uniform(-40, 40) + if rng.random() < 0.5: + return circle(x, y, rng.uniform(5, 30)) + return box(x, y, rng.uniform(5, 30), rng.uniform(5, 30)) + + +def _random_wall(rng: random.Random) -> Body: + normal = rng.choice([Vector2(1, 0), Vector2(-1, 0), Vector2(0, 1), Vector2(0, -1)]) + return Body.wall(normal, rng.uniform(-40, 40), material()) + + +def test_random_contacts_are_consistent_and_resolvable() -> None: + """Zufällige Kontakte sind in sich stimmig und lassen sich exakt auflösen. + + Für viele Zufallspaare gilt: Die Normale ist ein Einheitsvektor, die Tiefe ist ≥ 0, und + verschiebt man Körper A um (Tiefe + ε) entgegen der Normalen, berühren sich die Körper + nicht mehr. Genau diese Information braucht die Kollisionsauflösung. + """ + rng = random.Random(1234) + checked = 0 + for _ in range(3000): + first = _random_body(rng) + second = _random_wall(rng) if rng.random() < 0.25 else _random_body(rng) + contact = collide(first, second) + if contact is None: + continue + checked += 1 + assert contact.normal.length() == pytest.approx(1) + assert contact.penetration >= 0 + mover = contact.body_a + mover.position = mover.position - contact.normal * (contact.penetration + 1e-6) + assert collide(contact.body_a, contact.body_b) is None + assert checked > 500 # genug echte Kontakte geprüft diff --git a/tests/physics/test_sleep.py b/tests/physics/test_sleep.py new file mode 100644 index 0000000..2595e2a --- /dev/null +++ b/tests/physics/test_sleep.py @@ -0,0 +1,105 @@ +"""Tests des Schlafmodus (Inseln, Einschlafen, Aufwecken).""" + +from dataclasses import replace + +from sandbox.config import PHYSICS +from sandbox.physics import Body, Contact, Vector2, collide +from sandbox.physics.sleep import UnionFind, find_islands, update_sleep, wake_touched_bodies +from tests.helpers import material + + +def _circle(x: float, y: float = 0.0, radius: float = 10.0) -> Body: + return Body.circle(Vector2(x, y), radius, material()) + + +def _contacts(*pairs: tuple[Body, Body]) -> list[Contact]: + contacts = [collide(a, b) for a, b in pairs] + assert all(contact is not None for contact in contacts) + return [contact for contact in contacts if contact is not None] + + +def test_union_find_merges_sets() -> None: + union_find = UnionFind([1, 2, 3, 4, 5]) + union_find.union(1, 2) + union_find.union(3, 4) + union_find.union(2, 4) + assert union_find.find(1) == union_find.find(3) == union_find.find(4) + assert union_find.find(5) != union_find.find(1) + + +def test_touching_bodies_form_islands_but_walls_do_not_connect_them() -> None: + a, b, c, d = _circle(0), _circle(15), _circle(30), _circle(200) + floor = Body.wall(Vector2(0, -1), -5, material()) # berührt alle Kreise + contacts = _contacts((a, b), (b, c), (a, floor), (d, floor)) + islands = find_islands([a, b, c, d], contacts) + assert [set(island) for island in islands] == [{a, b, c}, {d}] + + +def test_an_island_sleeps_only_when_all_its_bodies_rest() -> None: + config = replace(PHYSICS, time_to_sleep=0.5) + resting, moving = _circle(0), _circle(15) + moving.velocity = Vector2(50, 0) + contacts = _contacts((resting, moving)) + for _ in range(100): + update_sleep([resting, moving], contacts, 0.01, config) + assert resting.sleep_time >= 0.5 + assert not resting.sleeping # der bewegte Nachbar hält die Insel wach + + moving.velocity = Vector2(0, 0) + for _ in range(60): + update_sleep([resting, moving], contacts, 0.01, config) + assert resting.sleeping + assert moving.sleeping + + +def test_waking_one_body_wakes_its_whole_island() -> None: + a, b, c, outsider = _circle(0), _circle(15), _circle(30), _circle(300) + island = [a, b, c] + for body in island: + body.put_to_sleep(island) + outsider.put_to_sleep() + c.wake() + assert not any(body.sleeping for body in island) + assert all(body.sleep_island is None for body in island) + assert outsider.sleeping + + +def test_touching_a_sleeper_wakes_and_reports_its_whole_island() -> None: + a, b, c = _circle(0), _circle(15), _circle(30) + island = [b, c] + for body in island: + body.put_to_sleep(island) + woken = wake_touched_bodies(_contacts((a, b))) + assert set(woken) == {b, c} + assert not c.sleeping + + +def test_held_bodies_keep_their_island_awake() -> None: + held, other = _circle(0), _circle(15) + held.held = True + contacts = _contacts((held, other)) + for _ in range(200): + update_sleep([held, other], contacts, 0.01, PHYSICS) + assert not held.sleeping + assert not other.sleeping + assert held.sleep_time == 0 + + +def test_sleepers_touched_by_awake_bodies_are_woken_but_not_by_walls() -> None: + sleeper, awake = _circle(0), _circle(15) + sleeper.put_to_sleep() + floor = Body.wall(Vector2(0, -1), -5, material()) + lonely = _circle(300) + lonely.put_to_sleep() + both_asleep = _circle(500) + both_asleep.put_to_sleep() + neighbour = _circle(515) + neighbour.put_to_sleep() + + woken = wake_touched_bodies( + _contacts((sleeper, awake), (lonely, floor), (both_asleep, neighbour)) + ) + assert woken == [sleeper] + assert not sleeper.sleeping + assert lonely.sleeping + assert both_asleep.sleeping diff --git a/tests/physics/test_solver.py b/tests/physics/test_solver.py new file mode 100644 index 0000000..340ec4b --- /dev/null +++ b/tests/physics/test_solver.py @@ -0,0 +1,186 @@ +"""Tests der Kollisionsauflösung: Stöße, Restitution, Reibung, Rollen, Positionskorrektur. + +Die Erwartungswerte stammen aus der klassischen Mechanik (Impuls- und Energieerhaltung, +Coulomb-Reibung, Rollbedingung v = ω·r) und nicht aus der Implementierung selbst. +""" + +import math + +import pytest + +from sandbox.config import PHYSICS +from tests.helpers import ( + add_box, + add_circle, + make_world, + simulate, + total_kinetic_energy, + total_momentum, +) + +GRAVITY = PHYSICS.gravity[1] +FLOOR = 700.0 + + +def _head_on_pair(restitution: float) -> tuple: + """Zwei Kreise ohne Schwerkraft, Massenverhältnis 1:3, frontal aufeinander zu.""" + world = make_world(gravity=(0.0, 0.0)) + light = add_circle(world, 300, 350, 20, velocity=(200, 0), restitution=restitution) + heavy = add_circle( + world, 500, 350, 20, velocity=(-100, 0), density=3.0, restitution=restitution + ) + return world, light, heavy + + +def test_elastic_collision_conserves_momentum_and_energy() -> None: + world, light, heavy = _head_on_pair(restitution=1.0) + momentum_before = total_momentum(world.bodies) + energy_before = total_kinetic_energy(world.bodies) + simulate(world, 1.0) # Stoß bei t ≈ 0,53 s, danach noch weit weg von den Wänden + + assert total_momentum(world.bodies).is_close(momentum_before, tolerance=1e-6) + assert total_kinetic_energy(world.bodies) == pytest.approx(energy_before, rel=1e-9) + # Lehrbuchformel für den 1D-Stoß: v1' = ((m1 − m2)·v1 + 2·m2·v2) / (m1 + m2) + m1, m2, v1, v2 = light.mass, heavy.mass, 200.0, -100.0 + assert light.velocity.x == pytest.approx(((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2)) + assert heavy.velocity.x == pytest.approx(((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2)) + assert light.velocity.x == pytest.approx(-250) + assert heavy.velocity.x == pytest.approx(50) + + +def test_equal_masses_exchange_velocities_in_an_elastic_collision() -> None: + world = make_world(gravity=(0.0, 0.0)) + a = add_circle(world, 300, 350, 20, velocity=(150, 0), restitution=1.0) + b = add_circle(world, 400, 350, 20, restitution=1.0) + simulate(world, 0.8) + assert a.velocity.x == pytest.approx(0, abs=1e-9) + assert b.velocity.x == pytest.approx(150) + + +def test_perfectly_inelastic_collision_leaves_a_common_velocity() -> None: + world, light, heavy = _head_on_pair(restitution=0.0) + momentum_before = total_momentum(world.bodies) + energy_before = total_kinetic_energy(world.bodies) + simulate(world, 1.0) + + common = momentum_before.x / (light.mass + heavy.mass) # = −25 px/s + assert light.velocity.x == pytest.approx(common) + assert heavy.velocity.x == pytest.approx(common) + assert total_momentum(world.bodies).is_close(momentum_before, tolerance=1e-6) + assert total_kinetic_energy(world.bodies) < energy_before + + +def test_restitution_below_one_loses_energy_as_predicted() -> None: + world, light, heavy = _head_on_pair(restitution=0.5) + energy_before = total_kinetic_energy(world.bodies) + simulate(world, 1.0) + # Newtons Stoßgesetz: Trenngeschwindigkeit = e · Aufprallgeschwindigkeit (300 px/s). + assert heavy.velocity.x - light.velocity.x == pytest.approx(0.5 * 300) + # Energieverlust beim zentralen Stoß: ΔE = ½·μ·(1 − e²)·v_rel² mit μ = m1·m2/(m1 + m2). + reduced_mass = light.mass * heavy.mass / (light.mass + heavy.mass) + expected_loss = 0.5 * reduced_mass * (1 - 0.5**2) * 300**2 + energy_loss = energy_before - total_kinetic_energy(world.bodies) + assert energy_loss == pytest.approx(expected_loss, rel=1e-6) + + +def test_slow_impacts_below_the_threshold_do_not_bounce() -> None: + world = make_world(gravity=(0.0, 0.0)) + approach = 0.8 * PHYSICS.restitution_threshold + a = add_circle(world, 300, 350, 20, velocity=(approach, 0), restitution=1.0) + b = add_circle(world, 345, 350, 20, restitution=1.0) + simulate(world, 0.5) + assert b.velocity.x - a.velocity.x == pytest.approx(0, abs=1e-9) + + +def test_frictionless_oblique_impact_keeps_the_tangential_velocity() -> None: + world = make_world(gravity=(0.0, 0.0), wall_friction=0.0) + ball = add_circle(world, 100, 350, 20, velocity=(-300, 120), restitution=1.0, friction=0.0) + simulate(world, 0.4) # trifft die linke Wand bei t ≈ 0,27 s + assert ball.velocity.x == pytest.approx(300) + assert ball.velocity.y == pytest.approx(120) + assert ball.angular_velocity == 0 # Normalimpulse erzeugen kein Drehmoment + + +def test_friction_on_an_oblique_impact_makes_a_ball_spin() -> None: + world = make_world(gravity=(0.0, 0.0)) + ball = add_circle(world, 100, 350, 20, velocity=(-300, 120), friction=0.8) + simulate(world, 0.4) + assert ball.angular_velocity != 0 + assert abs(ball.velocity.y) < 120 # Reibung hat die Tangentialgeschwindigkeit gebremst + + +def test_sliding_box_decelerates_with_coulomb_friction() -> None: + world = make_world(wall_friction=0.6) + box = add_box(world, 200, FLOOR - 20, 20, 20, velocity=(300, 0), friction=0.4) + simulate(world, 1.5) + mu = math.sqrt(0.4 * 0.6) # gemischter Reibwert Box/Boden + expected_distance = 300**2 / (2 * mu * GRAVITY) # s = v² / (2·μ·g) + assert box.position.x - 200 == pytest.approx(expected_distance, rel=0.03) + assert box.velocity.x == pytest.approx(0, abs=1e-6) + + +def test_sliding_disk_starts_rolling_at_two_thirds_of_its_speed() -> None: + """Eine rutschende Scheibe geht ins Rollen über und behält dabei ⅔ ihres Tempos. + + Reibung bremst die Scheibe (I = ½·m·r²) und versetzt sie in Drehung, bis v = ω·r gilt. + Drehimpulserhaltung um den Kontaktpunkt liefert dann v = v0 / (1 + I/(m·r²)) = ⅔·v0 – + unabhängig vom Reibwert. + """ + world = make_world(rolling_resistance=0.0, sleep_enabled=False) + radius = 20 + disk = add_circle(world, 200, FLOOR - radius, radius, velocity=(300, 0), friction=0.5) + simulate(world, 1.0) + assert disk.velocity.x == pytest.approx(200, rel=0.01) + assert disk.angular_velocity * radius == pytest.approx(disk.velocity.x, rel=0.01) + + +def test_rolling_resistance_decelerates_a_rolling_ball() -> None: + """Rollwiderstand: Moment μ_r·N·r ⇒ Verzögerung a = μ_r·g / (1 + I/(m·r²)) = μ_r·g/1,5.""" + world = make_world(sleep_enabled=False) + radius = 25 + ball = add_circle( + world, 200, FLOOR - radius, radius, velocity=(200, 0), angular_velocity=200 / radius + ) + simulate(world, 1.0) + expected_deceleration = PHYSICS.rolling_resistance * GRAVITY / 1.5 + assert 200 - ball.velocity.x == pytest.approx(expected_deceleration, rel=0.02) + + +def test_resting_contact_carries_exactly_the_weight() -> None: + world = make_world(sleep_enabled=False) + box = add_box(world, 300, FLOOR - 20, 30, 20) + simulate(world, 0.5) + (contact,) = world.contacts + # Pro Schritt muss der Boden den Impuls m·g·dt aufbringen, damit die Box liegen bleibt. + assert contact.normal_impulse == pytest.approx(box.mass * GRAVITY * PHYSICS.time_step) + assert contact.tangent_impulse == pytest.approx(0, abs=1e-9) + + +def test_impulses_respect_non_penetration_and_coulomb_bounds() -> None: + world = make_world() + for i in range(12): + add_circle(world, 300 + 25 * (i % 4), 200 + 30 * (i // 4), 14, velocity=(40 * i, 0)) + add_box(world, 600 + 30 * (i % 3), 150 + 35 * (i // 3), 14, 16) + for _ in range(240): + world.step(PHYSICS.time_step) + for contact in world.contacts: + assert contact.normal_impulse >= 0 + mu = math.sqrt(contact.body_a.material.friction * contact.body_b.material.friction) + assert abs(contact.tangent_impulse) <= mu * contact.normal_impulse + 1e-9 + + +def test_position_correction_separates_without_adding_velocity() -> None: + world = make_world(gravity=(0.0, 0.0), sleep_enabled=False) + a = add_circle(world, 500, 350, 20) + b = add_circle(world, 510, 350, 20) # 30 px Überlappung + simulate(world, 1.0) + assert a.position.distance_to(b.position) >= 40 - PHYSICS.penetration_slop - 1e-9 + assert a.velocity.length() == 0 + assert b.velocity.length() == 0 + + +def test_warm_starting_can_be_disabled() -> None: + world = make_world(warm_starting=False) + box = add_box(world, 300, FLOOR - 20, 30, 20) + simulate(world, 2.0) + assert box.position.y == pytest.approx(FLOOR - 20, abs=PHYSICS.penetration_slop) diff --git a/tests/physics/test_timestep.py b/tests/physics/test_timestep.py new file mode 100644 index 0000000..dd8d1cc --- /dev/null +++ b/tests/physics/test_timestep.py @@ -0,0 +1,57 @@ +"""Tests des festen Zeitschritts (Akkumulator).""" + +import pytest + +from sandbox.physics import FixedTimestep + +STEP = 1 / 120 + + +def test_a_60_fps_frame_runs_two_physics_steps() -> None: + timestep = FixedTimestep(STEP, max_steps_per_frame=8) + assert timestep.advance(1 / 60) == 2 + assert timestep.alpha == pytest.approx(0, abs=1e-6) + + +def test_short_frames_accumulate() -> None: + timestep = FixedTimestep(STEP, max_steps_per_frame=8) + assert timestep.advance(1 / 240) == 0 + assert timestep.alpha == pytest.approx(0.5) + assert timestep.advance(1 / 240) == 1 + + +def test_no_steps_are_lost_through_rounding() -> None: + timestep = FixedTimestep(STEP, max_steps_per_frame=8) + total = sum(timestep.advance(1 / 60) for _ in range(1000)) + assert total == 2000 + + +def test_alpha_stays_in_the_unit_interval() -> None: + timestep = FixedTimestep(STEP, max_steps_per_frame=8) + for frame in (0.003, 0.011, 0.017, 0.0001, 0.02): + timestep.advance(frame) + assert 0 <= timestep.alpha < 1 + + +def test_huge_frames_are_capped_to_avoid_the_spiral_of_death() -> None: + timestep = FixedTimestep(STEP, max_steps_per_frame=5) + assert timestep.advance(2.0) == 5 + assert timestep.alpha == 0 # der Rückstand wurde verworfen + assert timestep.advance(1 / 60) == 2 + + +def test_reset_discards_accumulated_time() -> None: + timestep = FixedTimestep(STEP, max_steps_per_frame=8) + timestep.advance(STEP * 0.9) + timestep.reset() + assert timestep.alpha == 0 + assert timestep.advance(STEP * 0.5) == 0 + + +def test_invalid_arguments_are_rejected() -> None: + with pytest.raises(ValueError, match="positiv"): + FixedTimestep(0, 8) + with pytest.raises(ValueError, match="mindestens"): + FixedTimestep(STEP, 0) + with pytest.raises(ValueError, match="negativ"): + FixedTimestep(STEP, 8).advance(-0.1) diff --git a/tests/physics/test_vector.py b/tests/physics/test_vector.py new file mode 100644 index 0000000..9930dd5 --- /dev/null +++ b/tests/physics/test_vector.py @@ -0,0 +1,127 @@ +"""Tests der eigenen Vektorklasse.""" + +import copy +import math +import pickle + +import pytest + +from sandbox.physics.vector import ZERO, Vector2 + + +def test_addition_and_subtraction() -> None: + assert Vector2(1, 2) + Vector2(3, -5) == Vector2(4, -3) + assert Vector2(1, 2) - Vector2(3, -5) == Vector2(-2, 7) + + +def test_scaling_from_both_sides_and_division() -> None: + assert Vector2(1.5, -2) * 2 == Vector2(3, -4) + assert 2 * Vector2(1.5, -2) == Vector2(3, -4) + assert Vector2(3, -4) / 2 == Vector2(1.5, -2) + + +def test_negation() -> None: + assert -Vector2(1, -2) == Vector2(-1, 2) + + +def test_dot_product() -> None: + assert Vector2(1, 2).dot(Vector2(3, 4)) == 11 + assert Vector2(1, 0).dot(Vector2(0, 5)) == 0 # orthogonal + + +def test_cross_product_sign_and_value() -> None: + assert Vector2(1, 0).cross(Vector2(0, 1)) == 1 + assert Vector2(0, 1).cross(Vector2(1, 0)) == -1 + assert Vector2(2, 3).cross(Vector2(4, 6)) == 0 # parallel + + +def test_length_and_length_squared() -> None: + v = Vector2(3, 4) + assert v.length() == 5 + assert v.length_squared() == 25 + + +def test_normalized_has_unit_length_and_same_direction() -> None: + n = Vector2(3, 4).normalized() + assert n.length() == pytest.approx(1.0) + assert n.is_close(Vector2(0.6, 0.8)) + + +def test_normalizing_the_zero_vector_returns_zero_instead_of_nan() -> None: + assert Vector2(0, 0).normalized() == ZERO + + +def test_perpendicular_is_rotated_by_plus_90_degrees() -> None: + v = Vector2(2, 1) + p = v.perpendicular() + assert p == Vector2(-1, 2) + assert v.dot(p) == 0 + assert v.cross(p) > 0 + + +def test_distance() -> None: + assert Vector2(1, 1).distance_to(Vector2(4, 5)) == 5 + + +def test_clamped_limits_only_long_vectors() -> None: + short = Vector2(1, 1) + assert short.clamped(10) is short + clamped = Vector2(30, 40).clamped(10) + assert clamped.length() == pytest.approx(10) + assert clamped.is_close(Vector2(6, 8)) + + +def test_lerp() -> None: + a = Vector2(0, 10) + b = Vector2(10, 20) + assert a.lerp(b, 0) == a + assert a.lerp(b, 1) == b + assert a.lerp(b, 0.5) == Vector2(5, 15) + + +def test_is_finite() -> None: + assert Vector2(1, 2).is_finite() + assert not Vector2(math.nan, 0).is_finite() + assert not Vector2(0, math.inf).is_finite() + + +def test_is_close_uses_absolute_tolerance() -> None: + assert Vector2(1, 1).is_close(Vector2(1 + 1e-12, 1)) + assert not Vector2(1, 1).is_close(Vector2(1.1, 1)) + assert Vector2(1, 1).is_close(Vector2(1.1, 1), tolerance=0.2) + + +def test_equality_hash_and_comparison_with_other_types() -> None: + assert Vector2(1, 2) == Vector2(1.0, 2.0) + assert Vector2(1, 2) != Vector2(2, 1) + assert Vector2(1, 2) != (1, 2) + assert len({Vector2(1, 2), Vector2(1, 2), Vector2(2, 1)}) == 2 + + +def test_vectors_are_immutable() -> None: + v = Vector2(1, 2) + with pytest.raises(AttributeError): + v.x = 5 # type: ignore[misc] + with pytest.raises(AttributeError): + del v.y + assert v == Vector2(1, 2) + + +def test_operations_do_not_modify_operands() -> None: + a = Vector2(1, 2) + b = Vector2(3, 4) + _ = (a + b, a - b, a * 3, a / 2, -a, a.normalized(), a.clamped(0.1), a.lerp(b, 0.5)) + assert a == Vector2(1, 2) + assert b == Vector2(3, 4) + + +def test_copy_and_pickle_round_trip() -> None: + v = Vector2(1.5, -2.5) + assert copy.copy(v) == v + assert copy.deepcopy(v) == v + assert pickle.loads(pickle.dumps(v)) == v + + +def test_repr_and_tuple() -> None: + assert repr(Vector2(1.5, 2)) == "Vector2(1.5, 2)" + assert Vector2(1.5, 2).to_tuple() == (1.5, 2) diff --git a/tests/physics/test_world.py b/tests/physics/test_world.py new file mode 100644 index 0000000..341fe23 --- /dev/null +++ b/tests/physics/test_world.py @@ -0,0 +1,446 @@ +"""Szenario-Tests der Physikwelt: Fallen, Aufprallen, Stapeln, Ruhen, Grenzen, Stabilität.""" + +import itertools +import random + +import pytest + +from sandbox.config import MIN_BODY_SIZE, PHYSICS, TIME_STEP +from sandbox.physics import Body, Vector2, World +from tests.helpers import ( + WORLD_HEIGHT, + WORLD_WIDTH, + add_box, + add_circle, + make_world, + material, + simulate, + total_kinetic_energy, +) + +GRAVITY = PHYSICS.gravity[1] +FLOOR = WORLD_HEIGHT +#: Geduldete Rest-Überlappung ruhender Körper: die Toleranz ``penetration_slop`` plus +#: ein tausendstel Pixel für die nie ganz auskonvergierte Iteration. +MAX_RESTING_OVERLAP = PHYSICS.penetration_slop + 1e-3 + + +# --------------------------------------------------------------------- Fallen und Aufprallen + + +def test_free_fall_matches_half_g_t_squared() -> None: + world = make_world(height=2000) + ball = add_circle(world, 500, 100, 10) + steps = 60 + simulate(world, steps * TIME_STEP) + t = steps * TIME_STEP + + fallen = ball.position.y - 100 + assert fallen == pytest.approx(0.5 * GRAVITY * t * t, rel=0.02) + # Semi-impliziter Euler exakt: v_k = k·g·dt und s_n = g·dt²·n·(n+1)/2. + assert ball.velocity.y == pytest.approx(GRAVITY * t) + assert fallen == pytest.approx(GRAVITY * TIME_STEP**2 * steps * (steps + 1) / 2) + + +def test_bodies_without_gravity_stay_put() -> None: + world = make_world() + world.gravity_enabled = False + ball = add_circle(world, 500, 100, 10) + simulate(world, 1.0) + assert ball.position == Vector2(500, 100) + assert world.gravity == Vector2(0, 0) + + +def test_fallen_ball_comes_to_rest_on_the_floor_and_never_leaves_the_world() -> None: + world = make_world() + radius = 20 + ball = add_circle(world, 500, 100, radius, restitution=0.6) + lowest = 0.0 + for _ in range(round(8 / TIME_STEP)): + world.step(TIME_STEP) + lowest = max(lowest, ball.position.y + radius) + assert ball.position.x - radius >= 0 + assert ball.position.x + radius <= WORLD_WIDTH + # Tiefer als die Notbremse erlaubt, sinkt der Ball nie in den Boden ein. + assert lowest <= FLOOR + PHYSICS.max_wall_penetration + 1e-9 + assert ball.sleeping + assert ball.velocity == Vector2(0, 0) + assert ball.position.y == pytest.approx(FLOOR - radius, abs=PHYSICS.penetration_slop) + + +def _first_bounce_apex(restitution: float) -> tuple[float, float]: + """Lässt einen Ball fallen und liefert (Fallhöhe, Steighöhe nach dem 1. Aufprall).""" + world = make_world() + radius = 20 + start_y = 200.0 + ball = add_circle(world, 500, start_y, radius, restitution=restitution) + resting_y = FLOOR - radius + bounced = False + apex = resting_y + for _ in range(round(4 / TIME_STEP)): + world.step(TIME_STEP) + if ball.velocity.y < 0: + bounced = True + apex = min(apex, ball.position.y) + elif bounced: + break # Scheitelpunkt überschritten + return resting_y - start_y, resting_y - apex + + +def test_bounce_height_follows_the_restitution_squared() -> None: + # Energieerhaltung beim Flug: h' / h = (v'/v)² = e². + drop, rise = _first_bounce_apex(restitution=0.8) + assert rise < drop # Energie geht verloren + assert rise == pytest.approx(0.8**2 * drop, rel=0.01) + + +def test_perfectly_elastic_ball_keeps_its_bounce_height() -> None: + """Ein vollkommen elastischer Ball springt immer wieder genau auf seine Fallhöhe. + + Er darf nie höher springen – sonst entstünde Energie aus dem Nichts, etwa durch die im + Aufprallschritt schon addierte Schwerkraft. + """ + world = make_world() + radius = 20 + start_y = 250.0 + ball = add_circle(world, 500, start_y, radius, restitution=1.0) + apexes = [] + rising = False + for _ in range(round(8 / TIME_STEP)): + world.step(TIME_STEP) + if ball.velocity.y < 0: + rising = True + elif rising: + apexes.append(ball.position.y) + rising = False + assert len(apexes) >= 4 + for apex in apexes: + assert apex == pytest.approx(start_y, abs=2.0) # 2 px bei 430 px Fallhöhe + + +def test_ball_without_restitution_does_not_bounce() -> None: + drop, rise = _first_bounce_apex(restitution=0.0) + assert drop > 0 + assert rise == pytest.approx(0, abs=1.0) + + +def test_total_energy_never_increases_without_external_forces() -> None: + world = make_world(gravity=(0.0, 0.0)) + rng = random.Random(42) + for _ in range(30): + velocity = (rng.uniform(-300, 300), rng.uniform(-300, 300)) + x, y = rng.uniform(60, 940), rng.uniform(60, 640) + if rng.random() < 0.5: + add_circle(world, x, y, rng.uniform(12, 35), velocity=velocity) + else: + add_box(world, x, y, rng.uniform(12, 35), rng.uniform(12, 35), velocity=velocity) + energy = total_kinetic_energy(world.bodies) + for _ in range(round(5 / TIME_STEP)): + world.step(TIME_STEP) + new_energy = total_kinetic_energy(world.bodies) + assert new_energy <= energy * (1 + 1e-9) + 1e-6 + energy = new_energy + + +# --------------------------------------------------------------------- Überlappen und Stapeln + + +def test_overlapping_bodies_are_pushed_apart() -> None: + world = make_world() + bottom = add_box(world, 500, FLOOR - 30, 40, 30) + top = add_box(world, 510, FLOOR - 70, 30, 30) # steckt 20 px in der unteren Box + simulate(world, 2.0) + gap = (bottom.position.y - 30) - (top.position.y + 30) + assert gap >= -MAX_RESTING_OVERLAP + + +def test_circle_spawned_inside_a_box_pops_out() -> None: + world = make_world() + base = add_box(world, 500, FLOOR - 40, 60, 40) + ball = add_circle(world, 520, FLOOR - 50, 15) # Mittelpunkt liegt in der Box + simulate(world, 2.0) + assert ball.position.y + 15 <= base.position.y - 40 + MAX_RESTING_OVERLAP + + +def test_box_stack_is_stable_and_falls_asleep() -> None: + world = make_world() + half_height = 20 + boxes = [ + add_box(world, 500, FLOOR - half_height - i * 2 * half_height, 30, half_height) + for i in range(6) + ] + simulate(world, 5.0) + for lower, upper in itertools.pairwise(boxes): + assert upper.position.x == pytest.approx(500, abs=1e-6) # kein seitliches Wandern + gap = (lower.position.y - half_height) - (upper.position.y + half_height) + assert -MAX_RESTING_OVERLAP <= gap <= 1e-9 + assert all(box.sleeping for box in boxes) + + +def test_pile_of_mixed_bodies_settles_completely() -> None: + world = make_world() + rng = random.Random(3) + for _ in range(60): + x, y = rng.uniform(60, 940), rng.uniform(40, 500) + if rng.random() < 0.5: + add_circle(world, x, y, rng.uniform(12, 35)) + else: + add_box(world, x, y, rng.uniform(12, 35), rng.uniform(12, 35)) + simulate(world, 8.0) + assert all(body.sleeping for body in world.bodies) + + +# --------------------------------------------------------------------- Geschwindigkeit und Tunneln + + +def test_speed_is_clamped_to_the_maximum() -> None: + world = make_world(gravity=(0.0, 0.0)) + ball = add_circle(world, 500, 350, 20, velocity=(50_000, 0)) + world.step(TIME_STEP) + assert ball.velocity.length() == pytest.approx(PHYSICS.max_speed) + + +def test_spin_is_clamped_to_the_maximum_in_both_directions() -> None: + world = make_world(gravity=(0.0, 0.0)) + clockwise = add_circle(world, 300, 350, 20, angular_velocity=10_000) + counter = add_circle(world, 600, 350, 20, angular_velocity=-10_000) + world.step(TIME_STEP) + assert clockwise.angular_velocity == pytest.approx(PHYSICS.max_angular_speed) + assert counter.angular_velocity == pytest.approx(-PHYSICS.max_angular_speed) + + +def test_emergency_brake_never_interferes_with_free_flight() -> None: + # Die Notbremse muss über der größten Eindringtiefe pro Schritt liegen, sonst würde sie + # normale Aufpralle verfälschen (Positionen verschieben und Energie hinzufügen). + assert PHYSICS.max_wall_penetration > PHYSICS.max_speed * PHYSICS.time_step + + +def test_emergency_brake_limits_deep_wall_penetration() -> None: + world = make_world(gravity=(0.0, 0.0)) + box = add_box(world, -10, 350, 20, 20) # steckt 30 px in der linken Wand + world.step(TIME_STEP) + assert box.bounds()[0] >= -PHYSICS.max_wall_penetration - 1e-9 + simulate(world, 0.5) + assert box.bounds()[0] >= -MAX_RESTING_OVERLAP + + +def test_fast_small_ball_does_not_tunnel_through_a_wall() -> None: + world = make_world(gravity=(0.0, 0.0)) + radius = MIN_BODY_SIZE + ball = add_circle(world, 500, 350, radius, velocity=(-50_000, -30_000), restitution=1.0) + for _ in range(round(3 / TIME_STEP)): + world.step(TIME_STEP) + allowed = PHYSICS.max_wall_penetration + 1e-9 + assert -allowed <= ball.position.x - radius + assert ball.position.x + radius <= WORLD_WIDTH + allowed + assert -allowed <= ball.position.y - radius + assert ball.position.y + radius <= WORLD_HEIGHT + allowed + + +def test_two_smallest_bodies_at_max_speed_do_not_pass_through_each_other() -> None: + world = make_world(gravity=(0.0, 0.0)) + radius = MIN_BODY_SIZE + speed = PHYSICS.max_speed + left = add_circle(world, 300, 350, radius, velocity=(speed, 0), restitution=1.0) + right = add_circle(world, 700, 350, radius, velocity=(-speed, 0), restitution=1.0) + simulate(world, 0.25) # Zusammenstoß nach ≈ 0,15 s + assert left.position.x < right.position.x + assert left.velocity.x < 0 < right.velocity.x + + +# --------------------------------------------------------------------- Schlafmodus + + +def test_sleeping_body_is_woken_by_an_impact() -> None: + world = make_world() + resting = add_box(world, 500, FLOOR - 20, 40, 20) + simulate(world, 1.5) + assert resting.sleeping + falling = add_circle(world, 500, 600, 15) # trifft die Box nach ≈ 0,3 s + simulate(world, 0.4) + assert not falling.sleeping + assert not resting.sleeping + + +def test_removing_a_support_wakes_the_body_on_top() -> None: + world = make_world() + support = add_box(world, 500, FLOOR - 20, 40, 20) + load = add_box(world, 500, FLOOR - 60, 30, 20) + simulate(world, 2.0) + assert support.sleeping + assert load.sleeping + world.remove_body(support) + assert not load.sleeping + simulate(world, 1.5) + assert load.position.y == pytest.approx(FLOOR - 20, abs=PHYSICS.penetration_slop) + + +def test_removing_a_body_only_wakes_sleeping_neighbours() -> None: + world = make_world() + removed = add_box(world, 300, FLOOR - 20, 40, 20) + neighbour = add_box(world, 360, FLOOR - 20, 20, 20) + far_away = add_box(world, 800, FLOOR - 20, 20, 20) + simulate(world, 2.0) + awake = add_circle(world, 300, 300, 15) # fällt noch, ist also wach + world.remove_body(removed) + assert not neighbour.sleeping + assert far_away.sleeping # weit weg: bleibt ungestört liegen + assert not awake.sleeping + assert removed not in world.bodies + + +def _is_supported(body: Body, world: World) -> bool: + """Liegt der Körper auf dem Boden oder auf einem tiefer liegenden Körper auf?""" + min_x, _, max_x, max_y = body.bounds() + if max_y >= world.height - 1.0: + return True + for other in world.bodies: + if other is body or other.position.y <= body.position.y: + continue # nur Körper darunter können tragen + other_min_x, other_min_y, other_max_x, _ = other.bounds() + overlaps_horizontally = other_min_x <= max_x and min_x <= other_max_x + if overlaps_horizontally and other_min_y <= max_y + 1.0: + return True + return False + + +def test_moving_the_base_of_a_sleeping_stack_wakes_the_whole_stack() -> None: + """Ein schlafender Stapel verliert seine Basis – die Kisten darüber fallen. + + Die unterste Kiste wird (wie beim Ziehen im Pausenmodus) ohne Physikschritt weggesetzt. + Die Kisten darüber dürfen danach nicht in der Luft weiterschlafen. + """ + world = make_world() + stack = [add_box(world, 500, FLOOR - 20 - 40 * i, 30, 20) for i in range(3)] + simulate(world, 2.0) + assert all(box.sleeping for box in stack) + stack[0].teleport(Vector2(150, FLOOR - 20)) + assert not any(box.sleeping for box in stack) + simulate(world, 2.0) + assert stack[1].position.y == pytest.approx(FLOOR - 20, abs=MAX_RESTING_OVERLAP) + assert stack[2].position.y == pytest.approx(FLOOR - 60, abs=2 * MAX_RESTING_OVERLAP) + + +def test_knocking_away_a_support_does_not_leave_bodies_floating() -> None: + """Wird die Unterlage eines schlafenden Körpers weggestoßen, fällt er herunter. + + Die flache Box verliert den Kontakt zur außermittig liegenden Kugel schon im ersten + Schritt nach dem Treffer. Ohne gemeinsames Wecken der Insel bliebe die Kugel schlafend + in der Luft hängen. + """ + world = make_world() + base = add_box(world, 500, FLOOR - 12, 40, 12) # flache Box am Boden + ball = add_circle(world, 530, FLOOR - 24 - 15, 15) # liegt 30 px außermittig darauf + simulate(world, 2.0) + assert base.sleeping + assert ball.sleeping + # Schwerer, flacher Rammbock trifft nur die Box, nicht die Kugel. + add_box(world, 800, FLOOR - 10, 20, 10, velocity=(-PHYSICS.max_speed, 0), density=5.0) + simulate(world, 2.0) + assert abs(ball.position.x - base.position.x) > 40 + 15 # Box ist unter der Kugel weg + assert ball.position.y == pytest.approx(FLOOR - 15, abs=MAX_RESTING_OVERLAP) + for body in world.bodies: + if body.sleeping: + assert _is_supported(body, world), f"{body} schläft in der Luft" + + +def test_toggling_gravity_wakes_everything() -> None: + world = make_world() + box = add_box(world, 500, FLOOR - 20, 40, 20) + simulate(world, 1.5) + assert box.sleeping + world.gravity_enabled = False + assert not box.sleeping + world.gravity_enabled = False # keine Änderung, kein erneutes Wecken nötig + assert not world.gravity_enabled + + +def test_held_bodies_ignore_gravity_and_never_sleep() -> None: + world = make_world() + ball = add_circle(world, 500, 300, 20) + ball.held = True + simulate(world, 2.0) + assert ball.position == Vector2(500, 300) + assert not ball.sleeping + + +def test_sleeping_can_be_disabled() -> None: + world = make_world(sleep_enabled=False) + box = add_box(world, 500, FLOOR - 20, 40, 20) + simulate(world, 3.0) + assert not box.sleeping + + +# --------------------------------------------------------------------- Verwaltung + + +def test_world_rejects_invalid_sizes_and_static_bodies() -> None: + with pytest.raises(ValueError, match="positive Größe"): + World(0, 100, PHYSICS) + world = make_world() + with pytest.raises(ValueError, match="Wände"): + world.add_body(Body.wall(Vector2(0, 1), 0, material())) + + +def test_body_at_returns_the_topmost_body() -> None: + world = make_world() + lower = add_box(world, 100, 100, 30, 30) + upper = add_circle(world, 110, 100, 20) + assert world.body_at(Vector2(110, 100)) is upper + assert world.body_at(Vector2(80, 80)) is lower + assert world.body_at(Vector2(300, 300)) is None + assert world.body_at(Vector2(133, 100), tolerance=5) is upper + + +def test_clamp_inside_keeps_the_whole_body_in_the_world() -> None: + world = make_world() + assert world.clamp_inside(Vector2(-50, 900), (20, 10)) == Vector2(20, WORLD_HEIGHT - 10) + inside = Vector2(300, 300) + assert world.clamp_inside(inside, (20, 10)) is inside + + +def test_clear_removes_all_bodies() -> None: + world = make_world() + for i in range(5): + add_circle(world, 100 + 50 * i, 600, 20) + simulate(world, 0.2) + world.clear() + assert world.bodies == [] + assert world.contacts == [] + simulate(world, 0.1) # auch eine leere Welt lässt sich weiter simulieren + + +def test_simulation_is_deterministic() -> None: + def run() -> list[tuple[float, float]]: + world = make_world() + rng = random.Random(5) + for _ in range(25): + add_circle(world, rng.uniform(50, 950), rng.uniform(50, 400), rng.uniform(12, 30)) + simulate(world, 2.0) + return [body.position.to_tuple() for body in world.bodies] + + assert run() == run() + + +@pytest.mark.slow +def test_hundred_bodies_stay_stable_for_a_thousand_steps() -> None: + world = make_world() + rng = random.Random(99) + for _ in range(100): + x, y = rng.uniform(40, 960), rng.uniform(40, 660) + velocity = (rng.uniform(-600, 600), rng.uniform(-600, 600)) + if rng.random() < 0.5: + add_circle(world, x, y, rng.uniform(12, 30), velocity=velocity) + else: + add_box(world, x, y, rng.uniform(12, 30), rng.uniform(12, 30), velocity=velocity) + + allowed = PHYSICS.max_wall_penetration + 1e-6 + for _ in range(1000): + world.step(TIME_STEP) + for body in world.bodies: + assert body.position.is_finite() + assert body.velocity.is_finite() + assert body.velocity.length() <= PHYSICS.max_speed + 1e-6 + min_x, min_y, max_x, max_y = body.bounds() + assert min_x >= -allowed and max_x <= WORLD_WIDTH + allowed + assert min_y >= -allowed and max_y <= WORLD_HEIGHT + allowed diff --git a/tests/test_architecture.py b/tests/test_architecture.py new file mode 100644 index 0000000..7050204 --- /dev/null +++ b/tests/test_architecture.py @@ -0,0 +1,60 @@ +"""Architektur-Tests: Die Physik-Schicht bleibt frei von pygame und fremden Bibliotheken. + +Anforderung: Die gesamte Physik darf nur das Standardmodul ``math`` verwenden +(zusätzlich ``typing``/``dataclasses`` für Typangaben und Datenstrukturen). Diese Tests +prüfen das statisch über den Syntaxbaum und zur Laufzeit in einem frischen Interpreter. +""" + +import ast +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +SOURCE_ROOT = Path(__file__).resolve().parents[1] / "src" / "sandbox" +PHYSICS_FILES = sorted((SOURCE_ROOT / "physics").glob("*.py")) +PURE_FILES = [*PHYSICS_FILES, SOURCE_ROOT / "config.py"] + +ALLOWED_STDLIB = {"__future__", "math", "typing", "dataclasses"} +ALLOWED_PROJECT = {"sandbox.config"} + + +def _imported_modules(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + modules.add(node.module or "") + return modules + + +@pytest.mark.parametrize("path", PURE_FILES, ids=lambda path: path.name) +def test_physics_and_config_import_only_allowed_modules(path: Path) -> None: + forbidden = { + module + for module in _imported_modules(path) + if module not in ALLOWED_STDLIB + and module not in ALLOWED_PROJECT + and not module.startswith("sandbox.physics") + } + assert not forbidden, f"{path.name} importiert nicht erlaubte Module: {forbidden}" + + +def test_physics_files_were_found() -> None: + assert len(PHYSICS_FILES) >= 8 + + +def test_importing_the_physics_package_does_not_load_pygame() -> None: + code = "import sys, sandbox.physics, sandbox.config; print('pygame' in sys.modules)" + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=True, + env={**os.environ, "PYTHONPATH": str(SOURCE_ROOT.parent)}, + ) + assert result.stdout.strip() == "False"