fix(extract): Chunk-Ausgabe lauf-stabil, Eckenprüfung in beide Richtungen
emit.py normalisiert jede ausgegebene Fläche und sortiert die Listen nach der Ausgabestruktur selbst. unary_union() in apply_street() gab das kartenweite Straßenpolygon mit anderem Ringstartpunkt und an anderer Listenstelle zurück und änderte damit 88 Chunkdateien, in denen sich geometrisch nichts bewegt hatte (symmetrische Differenz 0,00 m²). Belagsarten und Mittellinien behalten ihre Reihenfolge: overview.py malt die Beläge in Listenreihenfolge übereinander, und vergeLampPoints() hängt an Reihenfolge und Laufrichtung der Mittellinien — Sortieren verschöbe dort 1.848 von 3.921 Laternen auf die andere Straßenseite. check_corners() misst gegen den Straßenrand statt gegen die Straßenfläche. Alle vier Ecken liegen in der Fahrbahn, ihr Flächenabstand ist 0,000 m; die alte Prüfung schlug deshalb nur an, wenn eine Straße sich zurückzieht. Eine nach innen verbreiterte Straße hätte apply_park() still auf das veraltete Viereck zurückgeschnitten. Die Parkwege werden am Parkviereck abgeschnitten — die runden Kappen von LineString.buffer() lagen mit 95,96 m² darüber hinaus, rund 41 m² davon sichtbar als Betonzungen auf den umgebenden Belägen. load_elements() prüft den OSM-Abzug so streng wie die Parkdatei, mit Dateiname statt nacktem KeyError. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a3504df43d
commit
3c74202b5e
10 files changed
+333
-102
No files matched your search
@@ -19,7 +19,7 @@
|
||||
- Python-Aufrufe unter Windows (Git Bash) immer mit `PYTHONUTF8=1`, aus `tools/extract`: `PYTHONUTF8=1 uv run …`. Neue Python-Dateien lesen und schreiben Text immer mit `encoding="utf-8"`.
|
||||
- Keine neuen Abhängigkeiten, weder in `pyproject.toml` noch in `package.json`.
|
||||
- Welt-x/Welt-y wie auf der Minimap; three: x = X, z = −Y; chunk-lokal u = X − origin[0], v = −Y − origin[1].
|
||||
- Werte aus der Spec, wörtlich: `scale_u` 1.0, `offset_u` 6.25, Hauptwege 6 m, übrige Wege 3 m, Parkplatzgasse und Zufahrt 6 m, Gebäudehöhe ohne OSM-Angabe 4 m, Jefferson Avenue 12 m von (1516,5 | 664) bis (1545,2 | 334,2), Anker höchstens 1,5 m, Spielecken höchstens 2 m von einer Straße, Mittellinienstücke unter 15 m entfallen, Straßenstücke unter 20 m² entfallen, Palmenabstand 6 m (`PALM_CLEARANCE`), Palmen zur neuen Straße 1,5 m (`PALM_KERB`), Wege nur bis 5 m vor dem echten West- oder Ostrand verlängern.
|
||||
- Werte aus der Spec, wörtlich: `scale_u` 1.0, `offset_u` 6.25, Hauptwege 6 m, übrige Wege 3 m, Parkplatzgasse und Zufahrt 6 m, Gebäudehöhe ohne OSM-Angabe 4 m, Jefferson Avenue 12 m von (1516,5 | 664) bis (1545,2 | 334,2), Anker höchstens 1,5 m, Spielecken höchstens 2,5 m vom Rand einer Straße (Fixrunde 3, Befund A; ursprünglich 2 m von der Straßenfläche), Mittellinienstücke unter 15 m entfallen, Straßenstücke unter 20 m² entfallen, Palmenabstand 6 m (`PALM_CLEARANCE`), Palmen zur neuen Straße 1,5 m (`PALM_KERB`), Wege nur bis 5 m vor dem echten West- oder Ostrand verlängern.
|
||||
- Hilfsdateien für die Browserprüfung (`index-debug.html` o. ä.) nie committen.
|
||||
|
||||
## Dateien
|
||||
@@ -52,7 +52,7 @@ Prüfaufgabe ohne Codeänderung. Sie klärt, ob ein Lauf auf diesem Rechner die
|
||||
- Consumes: nichts
|
||||
- Produces: eine funktionierende `tools/extract/.venv` mit Python 3.12 und die Aussage „Lauf reproduziert die Daten“ (oder ein Stopp)
|
||||
|
||||
- [ ] **Step 1: Umgebung anlegen**
|
||||
- [x] **Step 1: Umgebung anlegen**
|
||||
|
||||
Run (Git Bash, aus dem Repo-Wurzelordner):
|
||||
```bash
|
||||
@@ -60,7 +60,7 @@ cd tools/extract && uv sync --python 3.12
|
||||
```
|
||||
Expected: `uv` lädt bei Bedarf Python 3.12 und installiert die Pakete aus `uv.lock` ohne Fehler.
|
||||
|
||||
- [ ] **Step 2: Bestehende Tests als Ausgangslage**
|
||||
- [x] **Step 2: Bestehende Tests als Ausgangslage**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
@@ -69,7 +69,7 @@ cd ../.. && npm test
|
||||
```
|
||||
Expected: beide grün. Anzahl der Tests notieren. Schlägt schon hier etwas fehl, anhalten und dem Nutzer die Ausgabe zeigen.
|
||||
|
||||
- [ ] **Step 3: Pipeline unverändert laufen lassen**
|
||||
- [x] **Step 3: Pipeline unverändert laufen lassen**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
@@ -77,7 +77,7 @@ cd tools/extract && PYTHONUTF8=1 uv run python run.py
|
||||
```
|
||||
Expected: lädt 144 Kacheln nach `cache/`, gibt den Entfernen-Bericht, „Gebäude: …“ und am Ende das Ergebnis von `render_map` aus. Dauer: einige Minuten, Speicherbedarf bis etwa 2 GB.
|
||||
|
||||
- [ ] **Step 4: Ergebnis mit dem eingecheckten Stand vergleichen**
|
||||
- [x] **Step 4: Ergebnis mit dem eingecheckten Stand vergleichen**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
@@ -102,7 +102,7 @@ EOF
|
||||
```
|
||||
Expected: `identisch`.
|
||||
|
||||
- [ ] **Step 5: Entscheiden**
|
||||
- [x] **Step 5: Entscheiden**
|
||||
|
||||
- `identisch`: Run `cd ../.. && git status --short public/data/vice-a`. Zeigt Git dort Änderungen (nur Bytes der PNGs), mit `git checkout -- public/data/vice-a` zurücksetzen. Weiter mit Task 2. Kein Commit.
|
||||
- Sonst: **anhalten.** Dem Nutzer die Ausgabe von Step 4 zeigen, `git checkout -- public/data/vice-a` ausführen und auf seine Entscheidung warten (Spec, Fehlerfälle: „Windows baut die Chunks nicht gleich“).
|
||||
@@ -120,7 +120,7 @@ Expected: `identisch`.
|
||||
- Consumes: nichts
|
||||
- Produces: `tools/extract/data/osm/flamingo-park.json` mit `{"source", "license", "fetched", "elements": [{"id": int, "tags": dict, "geometry": [[lat, lon], …]}]}`; im Modul `overpass_query(bbox) -> str`, `keep(element) -> bool`, `snapshot(raw) -> dict`, `dumps(snap) -> str`
|
||||
|
||||
- [ ] **Step 1: Failing test schreiben**
|
||||
- [x] **Step 1: Failing test schreiben**
|
||||
|
||||
`tools/extract/tests/test_osm.py`:
|
||||
```python
|
||||
@@ -178,12 +178,12 @@ def test_query_asks_for_ways_with_geometry_in_the_box():
|
||||
assert "way(1,2,3,4)[amenity=parking]" in query and query.endswith("out geom;")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
- [x] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_osm.py -q`
|
||||
Expected: FAIL mit `ModuleNotFoundError: No module named 'osm'`
|
||||
|
||||
- [ ] **Step 3: `osm.py` schreiben**
|
||||
- [x] **Step 3: `osm.py` schreiben**
|
||||
|
||||
`tools/extract/osm.py`:
|
||||
```python
|
||||
@@ -269,12 +269,12 @@ if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
- [x] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_osm.py -q`
|
||||
Expected: 7 passed
|
||||
|
||||
- [ ] **Step 5: Abruf ausführen (Netz)**
|
||||
- [x] **Step 5: Abruf ausführen (Netz)**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run python osm.py`
|
||||
Expected: etwa `230 Elemente, Stand 2026-…` (am 2026-09-15 gezählt: 53 footway, 18 service, 10 pitch, 3 parking, 1 dog_park, 1 playground, 2 swimming_pool, 1 sports_centre, 140 building). Dann prüfen:
|
||||
@@ -283,7 +283,7 @@ grep -c '"Flamingo Park Path' data/osm/flamingo-park.json
|
||||
```
|
||||
Expected: mindestens 5 Zeilen. Weicht die Gesamtzahl um mehr als 20 % ab, anhalten und dem Nutzer melden (OpenStreetMap hat sich geändert).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/extract/osm.py tools/extract/tests/test_osm.py tools/extract/data/osm/flamingo-park.json
|
||||
@@ -311,7 +311,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
- `@dataclass(frozen=True) ParkConfig(osm_path: Path, real: dict[str, tuple] (sw, se, nw), game: dict[str, tuple] (sw, se, ne, nw), scale_u: float, offset_u: float, anchors: tuple[Anchor, ...], keepouts: tuple[Keepout, ...], main_width: float, path_width: float, service_width: float, default_height: float, street: Street)`
|
||||
- `load_park(path: Path) -> ParkConfig`, `parse_park(text: str, base: Path, name: str = "flamingo-park.toml") -> ParkConfig`
|
||||
|
||||
- [ ] **Step 1: Failing test schreiben**
|
||||
- [x] **Step 1: Failing test schreiben**
|
||||
|
||||
`tools/extract/tests/test_park_config.py`:
|
||||
```python
|
||||
@@ -411,12 +411,12 @@ def test_committed_park_file_loads():
|
||||
assert len(config.street.alley_cut) == 12
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
- [x] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_park_config.py -q`
|
||||
Expected: FAIL mit `ModuleNotFoundError: No module named 'leonida.park'`
|
||||
|
||||
- [ ] **Step 3: `park.py` mit dem Leseteil anlegen**
|
||||
- [x] **Step 3: `park.py` mit dem Leseteil anlegen**
|
||||
|
||||
`tools/extract/leonida/park.py`:
|
||||
```python
|
||||
@@ -615,7 +615,7 @@ def _is_point(value) -> bool:
|
||||
return isinstance(value, list) and len(value) == 2 and all(_is_number(c) for c in value)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Parkdatei anlegen**
|
||||
- [x] **Step 4: Parkdatei anlegen**
|
||||
|
||||
`tools/extract/data/flamingo-park.toml`:
|
||||
```toml
|
||||
@@ -677,12 +677,12 @@ alley_cut = [[1470, 644], [1496, 644], [1500, 600], [1506, 540], [1514, 460], [1
|
||||
[1497, 340.5], [1486, 380], [1480, 460], [1474, 540], [1468, 600]]
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Tests laufen lassen, sie müssen bestehen**
|
||||
- [x] **Step 5: Tests laufen lassen, sie müssen bestehen**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_park_config.py -q`
|
||||
Expected: 12 passed
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/extract/leonida/park.py tools/extract/data/flamingo-park.toml tools/extract/tests/test_park_config.py
|
||||
@@ -706,7 +706,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
- `local_metres(lat: float, lon: float) -> tuple[float, float]`
|
||||
- `class ParkFit(config)` mit Attributen `real_width`, `game_width`, `game_u`, `game_quad: Polygon` und Methoden `park_coords(x, y) -> (s, t)`, `game_point(s, t) -> (X, Y)`, `local_to_game(x, y) -> (X, Y)`, `to_game(lat, lon) -> (X, Y)`, `real_quad() -> Polygon`
|
||||
|
||||
- [ ] **Step 1: Failing test schreiben**
|
||||
- [x] **Step 1: Failing test schreiben**
|
||||
|
||||
`tools/extract/tests/test_park_fit.py`:
|
||||
```python
|
||||
@@ -775,12 +775,12 @@ def test_committed_fit_keeps_the_south_edge_true_to_scale_and_offset_from_the_co
|
||||
assert math.dist(nw, config.game["nw"]) == pytest.approx(config.offset_u, abs=0.01)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
- [x] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_park_fit.py -q`
|
||||
Expected: FAIL mit `ImportError: cannot import name 'M_PER_DEG_LAT' from 'leonida.park'`
|
||||
|
||||
- [ ] **Step 3: Einpassung implementieren**
|
||||
- [x] **Step 3: Einpassung implementieren**
|
||||
|
||||
In `tools/extract/leonida/park.py` den Importblock ergänzen, sodass er so aussieht:
|
||||
```python
|
||||
@@ -851,12 +851,12 @@ class ParkFit:
|
||||
return Polygon([(ox, oy), (ox + ux, oy + uy), (ox + ux + vx, oy + uy + vy), (ox + vx, oy + vy)])
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
- [x] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_park_fit.py tests/test_park_config.py -q`
|
||||
Expected: 18 passed. Scheitert nur der Anker-Test, `offset_u` nicht still ändern: anhalten und dem Nutzer die beiden Abstände zeigen.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/extract/leonida/park.py tools/extract/tests/test_park_fit.py
|
||||
@@ -883,7 +883,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
- `build_layout(elements: list[dict], config, fit: ParkFit) -> ParkLayout`
|
||||
- Hilfen `_pieces(geometry) -> list`, `_polygons(geometry, min_area=1.0) -> list[Polygon]`, `_points(geometry) -> list[tuple]`
|
||||
|
||||
- [ ] **Step 1: Failing test schreiben**
|
||||
- [x] **Step 1: Failing test schreiben**
|
||||
|
||||
`tools/extract/tests/test_park_layout.py`:
|
||||
```python
|
||||
@@ -1028,12 +1028,12 @@ def test_service_ways_become_six_metre_service_surfaces():
|
||||
assert result.paths == []
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
- [x] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_park_layout.py -q`
|
||||
Expected: FAIL mit `ImportError: cannot import name 'build_layout' from 'leonida.park'`
|
||||
|
||||
- [ ] **Step 3: Anlage implementieren**
|
||||
- [x] **Step 3: Anlage implementieren**
|
||||
|
||||
Importblock von `park.py` so ersetzen:
|
||||
```python
|
||||
@@ -1211,12 +1211,12 @@ def _points(geometry) -> list[tuple[float, float]]:
|
||||
return out
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
- [x] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_park_layout.py -q`
|
||||
Expected: 14 passed
|
||||
|
||||
- [ ] **Step 5: Gegen den echten OSM-Stand prüfen**
|
||||
- [x] **Step 5: Gegen den echten OSM-Stand prüfen**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
@@ -1232,7 +1232,7 @@ EOF
|
||||
```
|
||||
Expected: `field` 3 (oder mehr Stücke, falls eine Sperrfläche ein Feld teilt), `court` 7, `pool` 2, `parking` mindestens 1, `sand` 1, `Gebäude 11`, `Warnungen []`. Abweichungen dem Nutzer zeigen, nicht still hinnehmen.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/extract/leonida/park.py tools/extract/tests/test_park_layout.py
|
||||
@@ -1257,7 +1257,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
- `apply_street(world: World, street: Street) -> World`
|
||||
- Hilfe `_lines(geometry, min_length=MIN_CENTERLINE) -> list[LineString]`
|
||||
|
||||
- [ ] **Step 1: Failing test schreiben**
|
||||
- [x] **Step 1: Failing test schreiben**
|
||||
|
||||
`tools/extract/tests/test_park_street.py`:
|
||||
```python
|
||||
@@ -1325,12 +1325,12 @@ def test_palms_on_the_new_street_go():
|
||||
assert apply_street(world(), STREET).palms == [(60.0, 500.0), (140.0, 500.0)]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
- [x] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_park_street.py -q`
|
||||
Expected: FAIL mit `ImportError: cannot import name 'World' from 'leonida.park'`
|
||||
|
||||
- [ ] **Step 3: Jefferson Avenue implementieren**
|
||||
- [x] **Step 3: Jefferson Avenue implementieren**
|
||||
|
||||
Importblock von `park.py` so ersetzen:
|
||||
```python
|
||||
@@ -1406,12 +1406,12 @@ def apply_street(world: World, street: Street) -> World:
|
||||
surfaces=surfaces, palms=palms)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
- [x] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_park_street.py tests/test_park_layout.py -q`
|
||||
Expected: 19 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/extract/leonida/park.py tools/extract/tests/test_park_street.py
|
||||
@@ -1434,7 +1434,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
- `check_corners(config, roads: list[tuple[Polygon, bool]], tolerance: float = 2.0) -> None` (liest nur `config.game`)
|
||||
- `apply_park(world: World, layout: ParkLayout, seed: int) -> World`
|
||||
|
||||
- [ ] **Step 1: Failing test schreiben**
|
||||
- [x] **Step 1: Failing test schreiben**
|
||||
|
||||
`tools/extract/tests/test_park_apply.py`:
|
||||
```python
|
||||
@@ -1517,12 +1517,12 @@ def test_corners_must_lie_at_a_road():
|
||||
check_corners(far, roads)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
- [x] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_park_apply.py -q`
|
||||
Expected: FAIL mit `ImportError: cannot import name 'apply_park' from 'leonida.park'`
|
||||
|
||||
- [ ] **Step 3: Einsetzen implementieren**
|
||||
- [x] **Step 3: Einsetzen implementieren**
|
||||
|
||||
In `park.py` die Zeile `from .heights import PALM_KERB` ersetzen durch:
|
||||
```python
|
||||
@@ -1567,12 +1567,12 @@ def apply_park(world: World, layout: ParkLayout, seed: int) -> World:
|
||||
buildings=buildings, palms=palms, paths=world.paths + layout.paths)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
- [x] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_park_apply.py -q`
|
||||
Expected: 6 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/extract/leonida/park.py tools/extract/tests/test_park_apply.py
|
||||
@@ -1599,7 +1599,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
- `chunk_payload(i, j, *, land, surfaces, roads, centerlines, buildings, palms, paths=()) -> dict` mit neuem Schlüssel `"paths": [{"poly": rings}]`
|
||||
- `manifest.json` → `attribution` endet auf „Flamingo Park: © OpenStreetMap-Mitwirkende (ODbL).“
|
||||
|
||||
- [ ] **Step 1: Failing tests schreiben**
|
||||
- [x] **Step 1: Failing tests schreiben**
|
||||
|
||||
In `tools/extract/tests/test_emit.py` den Test `test_payload_matches_schema` ersetzen und einen Test anhängen:
|
||||
```python
|
||||
@@ -1646,12 +1646,12 @@ def test_flamingo_park_goes_into_a_world_with_roads_around_the_park():
|
||||
assert any(message.startswith("Flamingo Park:") for message in messages)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern**
|
||||
- [x] **Step 2: Tests laufen lassen, sie müssen scheitern**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_emit.py tests/test_park_apply.py -q`
|
||||
Expected: FAIL: `test_payload_matches_schema` (Schlüssel `paths` fehlt), `test_paths_are_chunk_local_rings_like_roads` (`TypeError: chunk_payload() got an unexpected keyword argument 'paths'`), Integrationstest mit `ImportError: cannot import name 'apply_flamingo_park'`
|
||||
|
||||
- [ ] **Step 3: `chunk_payload` erweitern**
|
||||
- [x] **Step 3: `chunk_payload` erweitern**
|
||||
|
||||
In `tools/extract/leonida/emit.py` die Funktion `chunk_payload` ersetzen:
|
||||
```python
|
||||
@@ -1673,7 +1673,7 @@ def chunk_payload(i, j, *, land, surfaces, roads, centerlines, buildings, palms,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Gesamtablauf in `park.py`**
|
||||
- [x] **Step 4: Gesamtablauf in `park.py`**
|
||||
|
||||
Ans Dateiende von `park.py` anhängen:
|
||||
```python
|
||||
@@ -1691,7 +1691,7 @@ def apply_flamingo_park(world: World, config_path: Path, seed: int) -> tuple[Wor
|
||||
return world, messages
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `run.py` umstellen**
|
||||
- [x] **Step 5: `run.py` umstellen**
|
||||
|
||||
In `tools/extract/run.py`:
|
||||
|
||||
@@ -1736,12 +1736,12 @@ PARK = Path("data/flamingo-park.toml")
|
||||
```
|
||||
Die Zeile `print(render_map(OUT, OUT), "->", OUT)` bleibt danach unverändert stehen.
|
||||
|
||||
- [ ] **Step 6: Tests laufen lassen, sie müssen bestehen**
|
||||
- [x] **Step 6: Tests laufen lassen, sie müssen bestehen**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest -q`
|
||||
Expected: alle bestehen, einschließlich `test_committed_map_files_match_the_committed_chunks` (die Chunks sind noch nicht neu erzeugt). `run.py` selbst läuft erst in Task 12.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
- [x] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/extract/leonida/park.py tools/extract/leonida/emit.py tools/extract/run.py tools/extract/tests/test_emit.py tools/extract/tests/test_park_apply.py
|
||||
@@ -1762,7 +1762,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
- Consumes: Chunk-Format mit `paths` und neuen `surfaces`-Arten (Task 8)
|
||||
- Produces: `overview.PATH`, `overview.SURFACE["field" | "court" | "pool" | "parking"]`, `_paint_layers(…, path=None)`
|
||||
|
||||
- [ ] **Step 1: Failing test schreiben**
|
||||
- [x] **Step 1: Failing test schreiben**
|
||||
|
||||
An `tools/extract/tests/test_overview.py` anhängen:
|
||||
```python
|
||||
@@ -1796,12 +1796,12 @@ def test_map_picture_draws_paths_and_the_new_surface_kinds(tmp_path):
|
||||
assert Image.open(dst / "map-walkable.png").getpixel((25, 31)) == overview.WALKABLE # Weg auf Land
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
- [x] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_overview.py -q`
|
||||
Expected: FAIL mit `KeyError: 'field'`
|
||||
|
||||
- [ ] **Step 3: `overview.py` erweitern**
|
||||
- [x] **Step 3: `overview.py` erweitern**
|
||||
|
||||
Die Farbkonstanten so ordnen und ergänzen (ROAD vor SURFACE):
|
||||
```python
|
||||
@@ -1837,12 +1837,12 @@ In `render_map` ebenso:
|
||||
```
|
||||
`_walkable_mask` bleibt unverändert: ohne `path` zählen Wege dort nicht, wie Beläge.
|
||||
|
||||
- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
- [x] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_overview.py -q`
|
||||
Expected: 8 passed (auch `test_committed_map_files_match_the_committed_chunks`: die eingecheckten Chunks haben noch keine Wege, das Bild bleibt gleich)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/extract/overview.py tools/extract/tests/test_overview.py
|
||||
@@ -1867,7 +1867,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
- Consumes: Chunk-Format aus Task 8 (`chunk.paths[].poly`, `surfaces[].kind` ∈ `field|court|pool|parking`)
|
||||
- Produces: `SURFACE_KINDS` mit sieben Arten, `buildChunkGeometry(chunk).paths`, `MATERIAL_KEYS` mit `field, court, pool, parking, paths`, `SURFACE_COLORS = { court, pool }`, neue `POLYGON_OFFSET_ORDER`, `DECALS` mit den neuen Schichten
|
||||
|
||||
- [ ] **Step 1: Failing tests schreiben**
|
||||
- [x] **Step 1: Failing tests schreiben**
|
||||
|
||||
In `tests/geometry.test.js` im `describe("geometry")` vor dem Test „gibt Gebäuden eine Farbe je Haus“ einfügen:
|
||||
```js
|
||||
@@ -1944,12 +1944,12 @@ In `tests/streaming.test.js` im `describe("ChunkStreamer")` als letzten Test ein
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern**
|
||||
- [x] **Step 2: Tests laufen lassen, sie müssen scheitern**
|
||||
|
||||
Run: `npx vitest run tests/geometry.test.js tests/materials.test.js tests/streaming.test.js`
|
||||
Expected: FAIL: `out.field` ist `undefined`, `SURFACE_COLORS` fehlt, neue Reihenfolge fehlt, Collider hat 6 statt 1 Kind
|
||||
|
||||
- [ ] **Step 3: `geometry.js` erweitern**
|
||||
- [x] **Step 3: `geometry.js` erweitern**
|
||||
|
||||
Zeile 5 und 15 ändern:
|
||||
```js
|
||||
@@ -1971,7 +1971,7 @@ In `buildChunkGeometry` das Objekt `out` ersetzen:
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `materials.js` erweitern**
|
||||
- [x] **Step 4: `materials.js` erweitern**
|
||||
|
||||
`MATERIAL_KEYS`, `TEXTURES` und `FALLBACK_COLORS` ersetzen:
|
||||
```js
|
||||
@@ -2039,19 +2039,19 @@ export async function createMaterials(maxAnisotropy) {
|
||||
applyPolygonOffsets(materials);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `streaming.js` erweitern**
|
||||
- [x] **Step 5: `streaming.js` erweitern**
|
||||
|
||||
Zeile 7 ersetzen:
|
||||
```js
|
||||
const DECALS = new Set(["roads", "sand", "park", "urban", "field", "court", "pool", "parking", "paths"]);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Tests laufen lassen, sie müssen bestehen**
|
||||
- [x] **Step 6: Tests laufen lassen, sie müssen bestehen**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: alle bestehen
|
||||
|
||||
- [ ] **Step 7: Textur laden**
|
||||
- [x] **Step 7: Textur laden**
|
||||
|
||||
`tools/assets/textures.mjs` ersetzen:
|
||||
```js
|
||||
@@ -2083,7 +2083,7 @@ for (const slug of SLUGS) {
|
||||
Run: `node tools/assets/textures.mjs`
|
||||
Expected: drei Zeilen `concrete_pavement_….jpg x.x MB`, sonst nichts. `git status --short public/textures` zeigt nur die drei neuen Dateien.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
- [x] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/world/geometry.js src/world/materials.js src/world/streaming.js tools/assets/textures.mjs tests/geometry.test.js tests/materials.test.js tests/streaming.test.js public/textures/concrete_pavement_Diffuse.jpg public/textures/concrete_pavement_nor_gl.jpg public/textures/concrete_pavement_arm.jpg
|
||||
@@ -2106,7 +2106,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
- Consumes: `PLACES` (`src/world/places.js`), `pointInPolygon` (`src/world/props.js`), `chunkForWorld` (`src/world/coords.js`), `tools/extract/data/flamingo-park.toml` (Task 3)
|
||||
- Produces: `src/world/spawn.js` mit `SPAWN = { x, y, z }` und `VEHICLE_SPAWN_POINTS = [[x, y, z], …]`
|
||||
|
||||
- [ ] **Step 1: Failing test schreiben**
|
||||
- [x] **Step 1: Failing test schreiben**
|
||||
|
||||
`tests/parkData.test.js`:
|
||||
```js
|
||||
@@ -2182,12 +2182,12 @@ In `tests/placesSite.test.js` nach dem Test „liegt ganz auf Land, auf keiner S
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
- [x] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
|
||||
Run: `npx vitest run tests/parkData.test.js tests/placesSite.test.js`
|
||||
Expected: FAIL mit `Failed to resolve import "../src/world/spawn.js"`
|
||||
|
||||
- [ ] **Step 3: `spawn.js` anlegen und `main.js` umstellen**
|
||||
- [x] **Step 3: `spawn.js` anlegen und `main.js` umstellen**
|
||||
|
||||
`src/world/spawn.js`:
|
||||
```js
|
||||
@@ -2221,12 +2221,12 @@ import { SPAWN, VEHICLE_SPAWN_POINTS } from "./world/spawn.js";
|
||||
const VEHICLE_SPAWNS = VEHICLE_SPAWN_POINTS.map(([x, y, z]) => new Vector3(x, y, z));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
- [x] **Step 4: Tests laufen lassen, sie müssen bestehen**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: alle bestehen. Mit den noch alten Chunk-Daten gibt es keine Wege, der neue Test in `placesSite.test.js` besteht deshalb trivial; scharf wird er in Task 12.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/world/spawn.js src/main.js tests/parkData.test.js tests/placesSite.test.js
|
||||
@@ -2251,12 +2251,12 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
- Consumes: alles aus Task 1 bis 11
|
||||
- Produces: ausgelieferte Daten mit Flamingo Park und Jefferson Avenue
|
||||
|
||||
- [ ] **Step 1: Pipeline laufen lassen**
|
||||
- [x] **Step 1: Pipeline laufen lassen**
|
||||
|
||||
Run: `cd tools/extract && PYTHONUTF8=1 uv run python run.py`
|
||||
Expected: wie in Task 1, dazu die Zeile `Flamingo Park: … Wegflächen, … Flächen, 11 Gebäude, Jefferson Avenue 12 m breit` und genau eine Warnzeile: `Warnung: Zufahrt schneidet die Sperrfläche tribuene`. Das ist der bekannte, gewollte Zustand — die Zufahrt zum Parkplatz quert die Sperrfläche der Tribüne und wird dort ausgeschnitten. Jede weitere oder andere Warnung ist ein Befund. Bricht der Lauf mit `Spielecke … liegt weiter als 2 m` ab: anhalten und dem Nutzer melden.
|
||||
|
||||
- [ ] **Step 2: Änderungen auf Park und Jefferson Avenue begrenzt?**
|
||||
- [x] **Step 2: Änderungen auf Park und Jefferson Avenue begrenzt?**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
@@ -2300,7 +2300,7 @@ EOF
|
||||
```
|
||||
Expected: Ausgaben nur für J6–K9 (`chunk_9_5` … `chunk_10_8`) und am Ende `nur erwartete Chunks`. Sonst anhalten und dem Nutzer die Liste zeigen.
|
||||
|
||||
- [ ] **Step 3: Tribüne ohne `replaces`**
|
||||
- [x] **Step 3: Tribüne ohne `replaces`**
|
||||
|
||||
Mit den neuen Daten ist der alte Gebäudekasten der Tribüne weg, weil er zu mehr als der Hälfte im Park lag. `npx vitest run tests/placesSite.test.js` scheitert deshalb jetzt an „ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich“. In `src/world/places.js` im Eintrag `tribuene` die zwei Zeilen
|
||||
```js
|
||||
@@ -2313,7 +2313,7 @@ ersetzen durch:
|
||||
// OpenStreetMap schon die Pipeline (docs/superpowers/specs/2026-09-15-flamingo-park-design.md).
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Alle Tests**
|
||||
- [x] **Step 4: Alle Tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
@@ -2322,7 +2322,7 @@ cd ../.. && npm test
|
||||
```
|
||||
Expected: beide vollständig grün, auch `test_committed_map_files_match_the_committed_chunks`, `tests/placesSite.test.js` (der replaces-Test ist für beide Orte übersprungen) und `tests/parkData.test.js`.
|
||||
|
||||
- [ ] **Step 5: Kartenausschnitt ansehen**
|
||||
- [x] **Step 5: Kartenausschnitt ansehen**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
@@ -2335,7 +2335,7 @@ img.crop((x0 + 1000, 2500 - y1, x1 + 1000, 2500 - y0)).resize(((x1 - x0) * 2, (y
|
||||
```
|
||||
Das Bild `tools/extract/debug/flamingo-park.png` mit dem Read-Werkzeug ansehen und prüfen: Wege glatt, Kreis mit 13th-St-Weg, Felder oben, Tennisanlage, Pool und Parkplatz unten, Jefferson Avenue gerade nach Süden, keine Gasse mehr, Tribüne- und Hood-Yard-Fläche frei. Dem Nutzer das Bild mit `SendUserFile` schicken.
|
||||
|
||||
- [ ] **Step 6: Im Browser prüfen**
|
||||
- [x] **Step 6: Im Browser prüfen**
|
||||
|
||||
Vorgehen nach `.claude/skills/placing-editor-models/browser-checks.md`, Abschnitte 2, 3, 6, 7 (nur das Setzen der Kapsel) und 8. Der Server ist der Eintrag `leonida` aus `.claude/launch.json` (Hauptordner, kein Arbeitsbaum). Die Debug-Seite `index-debug.html` nur lokal anlegen und am Ende löschen.
|
||||
|
||||
@@ -2349,15 +2349,15 @@ Dazu festhalten:
|
||||
- Die Konsole zeigt keine Fehler; Pointer-Lock-Meldungen des verborgenen Bereichs sind ausgenommen.
|
||||
- Im Blick 1: Draw Calls (`renderer.info.render.calls` direkt nach `renderer.render`) und Bildzeit nach Abschnitt 8 als Median aus 60 Bildern.
|
||||
|
||||
- [ ] **Step 7: Farben und Textur abstimmen, falls nötig**
|
||||
- [x] **Step 7: Farben und Textur abstimmen, falls nötig**
|
||||
|
||||
Wirkt etwas falsch (Felder kaum vom Rasen zu unterscheiden, Wege zu dunkel, Kachelmuster zu grob), mit dem Nutzer entscheiden und dann nur diese Werte ändern: `FIELD_TINT` und `SURFACE_COLORS` in `src/world/materials.js`, `uvScale` der Wege in `src/world/geometry.js`. Vorher-Nachher-Bild desselben Blicks schicken. Danach `npm test`.
|
||||
|
||||
- [ ] **Step 8: Messwerte in die Spec**
|
||||
- [x] **Step 8: Messwerte in die Spec**
|
||||
|
||||
In `docs/superpowers/specs/2026-09-15-flamingo-park-design.md` im Abschnitt „Fehlerfälle und Risiken“ einen Punkt **Gemessen im Spiel** mit dem Datum des Laufs ergänzen: Zahl der Wegflächen, Flächen und Gebäude aus Step 1, geänderte Chunks aus Step 2, Draw Calls und Bildzeit aus Step 6 und jede Anpassung aus Step 7.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
- [x] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add public/data/vice-a src/world/places.js docs/superpowers/specs/2026-09-15-flamingo-park-design.md src/world/materials.js src/world/geometry.js
|
||||
|
||||
@@ -99,7 +99,7 @@ Bei D bleibt östlich der Anlage ein 37 m breiter Rasenstreifen.
|
||||
### OSM-Abruf (`tools/extract/osm.py`)
|
||||
|
||||
- Neues Skript, das von Hand aufgerufen wird: `cd tools/extract && uv run python osm.py`.
|
||||
- Es fragt die Overpass-API (`https://overpass-api.de/api/interpreter`, POST, User-Agent wie `tiles.py`) für den Kasten 25,7765–25,7885° N, −80,1425 bis −80,1325° O ab. Abgefragt werden Wege mit `highway`, `leisure`, `amenity=parking` und `building`, jeweils mit Geometrie.
|
||||
- Es fragt die Overpass-API (`https://overpass-api.de/api/interpreter`, POST, User-Agent wie `tiles.py`) für den Kasten 25,7812–25,7872° N, −80,1392 bis −80,1358° O ab (`BBOX` in `osm.py`; der ursprünglich hier genannte, größere Kasten holte weit über den Park hinaus). Abgefragt werden Wege mit `highway`, `leisure`, `amenity=parking` und `building`, jeweils mit Geometrie.
|
||||
- Es behält nur, was der Park braucht, und schreibt `tools/extract/data/osm/flamingo-park.json`:
|
||||
|
||||
```json
|
||||
@@ -184,7 +184,7 @@ alley_cut = [[1470, 644], [1496, 644], [1500, 600], [1506, 540], [1514, 460], [1
|
||||
|
||||
**Zeitpunkt:** in `run.py` nach Land, Belägen, Straßen, Mittellinien, Gebäuden mit Höhen und Palmen, vor den Chunk-Nutzlasten. So sehen Chunk-Dateien, Kollision, Minimap und Landemaske das Ergebnis.
|
||||
|
||||
**Sicherheitsprüfung vorab:** Jede Spielecke aus der Parkdatei muss höchstens 2 m neben einer Straßenfläche liegen. Sonst hat sich die Extraktion geändert, und der Lauf bricht mit einer Meldung ab.
|
||||
**Sicherheitsprüfung vorab:** Jede Spielecke aus der Parkdatei muss höchstens 2,5 m vom **Rand** einer Straßenfläche liegen. Sonst hat sich die Extraktion geändert, und der Lauf bricht mit einer Meldung ab. Gegen den Rand und nicht gegen die Fläche, weil alle vier Ecken heute in der Fahrbahn liegen (Flächenabstand 0,000 m): eine Prüfung auf die Fläche schlüge nur an, wenn eine Straße sich zurückzieht, und ließe eine nach innen verbreiterte Straße stillschweigend auf das veraltete Viereck zurückschneiden. Gemessen am Referenzstand `f32ea27`: 0,45 m (sw), 0,14 m (se), 1,46 m (ne), 1,14 m (nw).
|
||||
|
||||
### Schritt 1: Jefferson Avenue
|
||||
|
||||
@@ -286,7 +286,9 @@ Das Parkpolygon ist das Viereck aus den Spielecken.
|
||||
|
||||
## Tests
|
||||
|
||||
### pytest (`tools/extract/tests/test_park.py`)
|
||||
### pytest (`tools/extract/tests/test_park_config.py`, `test_park_fit.py`, `test_park_layout.py`, `test_park_street.py`, `test_park_apply.py`)
|
||||
|
||||
Die Umsetzung hat die hier als eine Datei geplanten Tests auf fünf aufgeteilt, eine je Abschnitt.
|
||||
|
||||
- **Einpassung:**
|
||||
- Die echte SW-Ecke landet `offset_u` Meter östlich der Spiel-SW-Ecke auf der Südkante, die echte NW-Ecke ebenso weit östlich der Spiel-NW-Ecke.
|
||||
@@ -307,8 +309,9 @@ Das Parkpolygon ist das Viereck aus den Spielecken.
|
||||
- Die Straße ist mit Mittellinie da, die beiden Gebäude sind weg.
|
||||
- `alley_cut` gehört nicht mehr zur Straßenfläche, die Fahrbahnen der Querstraßen bleiben unversehrt.
|
||||
- Frei gewordene Flächen sind `urban`.
|
||||
- **Parkdatei:** Jeder Formfehler bricht mit Dateiname und Feld ab. Liegt eine Spielecke weiter als 2 m von einer Straße, bricht der Lauf ab.
|
||||
- **`test_emit.py`:** Das Schema enthält `paths` und die neuen Flächenarten.
|
||||
- **Parkdatei:** Jeder Formfehler bricht mit Dateiname und Feld ab. Liegt eine Spielecke weiter als 2,5 m vom Rand jeder Straße, bricht der Lauf ab.
|
||||
- **OSM-Stand:** Fehlt `elements`, ist die Datei kein gültiges JSON oder fehlt einem Element `tags` oder `geometry`, bricht der Lauf mit demselben `ParkError` ab — mit Dateiname und dem Hinweis auf `osm.py`, statt mit einem nackten `KeyError`.
|
||||
- **`test_emit.py`:** Das Schema enthält `paths` und die neuen Flächenarten. Dieselbe Welt in anderer Listenreihenfolge und mit anderem Ringstartpunkt ergibt dieselbe Ausgabe; die Arten der Beläge und die Mittellinien behalten dabei ihre Reihenfolge.
|
||||
- **`test_overview.py`:** Wege und neue Flächenarten erscheinen im Kartenbild, die eingecheckten Kartendateien passen zu den eingecheckten Chunks.
|
||||
|
||||
### vitest
|
||||
@@ -336,8 +339,8 @@ Das Parkpolygon ist das Viereck aus den Spielecken.
|
||||
- **Farben und Tönung passen nicht:** Das wird im Browser abgestimmt, nicht still.
|
||||
- **Gemessen im Spiel** (Lauf am 2026-09-16):
|
||||
- Pipeline: 2 Wegflächen, 14 Flächen, 11 Gebäude, Jefferson Avenue 12 m breit; genau eine Warnung (`Zufahrt schneidet die Sperrfläche tribuene`, bekannt und gewollt).
|
||||
- Geänderte Chunks: nur die acht erwarteten `chunk_9_5`, `chunk_10_5`, `chunk_9_6`, `chunk_10_6`, `chunk_9_7`, `chunk_10_7`, `chunk_9_8`, `chunk_10_8`.
|
||||
- Tests: pytest 174 bestanden, vitest 199 bestanden / 3 übersprungen (der replaces-Test läuft jetzt für beide Orte nicht mehr, siehe `src/world/places.js`).
|
||||
- Geänderte Chunks: geometrisch nur die acht erwarteten `chunk_9_5`, `chunk_10_5`, `chunk_9_6`, `chunk_10_6`, `chunk_9_7`, `chunk_10_7`, `chunk_9_8`, `chunk_10_8` — auf Dateiebene dagegen alle 144. Das lag nicht an der Geometrie: `unary_union()` in `apply_street()` fasst das kartenweite Straßenpolygon neu zusammen, und GEOS gab es mit anderem Ringstartpunkt und an anderer Listenstelle zurück. In 88 Chunks änderte sich dadurch der Text von `roads`, ohne dass sich die Fläche bewegte (symmetrische Differenz 0,00 m²), in 48 weiteren kam nur der leere Schlüssel `"paths":[]` hinzu. Seit Fixrunde 3 normalisiert `emit.py` jede ausgegebene Fläche (`shapely.normalize`) und sortiert jede Liste nach der Ausgabestruktur selbst; die Chunkdateien ändern sich dadurch einmalig alle, danach nur noch die, in denen sich wirklich etwas ändert. Zwei Listen bleiben bewusst unsortiert bzw. unnormalisiert: die Belagsarten untereinander (`overview.py` malt sie in Listenreihenfolge übereinander) und die Mittellinien (`vergeLampPoints()` in `src/world/props.js` hängt an Reihenfolge und Laufrichtung).
|
||||
- Tests: pytest 174 bestanden (nach Fixrunde 3: 186), vitest 199 bestanden / 3 übersprungen (der replaces-Test läuft jetzt für beide Orte nicht mehr, siehe `src/world/places.js`).
|
||||
- Browser (1280 × 800, Debug-Seite mit sichtbarem Fenster, Median aus 31 Bildern):
|
||||
|
||||
| Blick | Draw Calls | Dreiecke | Bild | davon neue Schichten |
|
||||
|
||||
@@ -14,7 +14,12 @@ const BRIDGE_THICKNESS = 0.6;
|
||||
const BRIDGE_OFFSET = SURFACE_OFFSET;
|
||||
export const SURFACE_KINDS = ["sand", "park", "urban", "field", "court", "pool", "parking"];
|
||||
|
||||
/** Pastellton je Gebäude, reproduzierbar aus seiner Lage. */
|
||||
/** Pastellton je Gebäude, reproduzierbar aus seiner Lage.
|
||||
*
|
||||
* Der Ton hängt am ersten Punkt des Außenrings, nicht an der Listenposition. Welcher Punkt das
|
||||
* ist, legt seit Fix-Runde 3 die Ausgabe fest: emit.py normalisiert jede Fläche vor dem
|
||||
* Schreiben (kleinster Punkt zuerst), sonst vergäbe GEOS den Startpunkt je nach Rechenweg neu
|
||||
* und ein Extraktionslauf färbte die halbe Stadt ohne erkennbaren Grund um. */
|
||||
function buildingTint(poly) {
|
||||
const [x, z] = poly[0][0];
|
||||
const h = Math.abs(Math.sin(x * 12.9898 + z * 78.233) * 43758.5453) % 1;
|
||||
|
||||
@@ -120,6 +120,22 @@ async function loadSet(loader, slug, fallback, { tint, maxAnisotropy } = {}) {
|
||||
// Fläche), units den senkrechten Blick von oben (Steigung ~ 0) — der Spieler sieht im Spiel
|
||||
// beides, je nach Kamerawinkel (Vogelperspektive in fly mode wie im Befund, aber auch normale
|
||||
// Bodenperspektive).
|
||||
//
|
||||
// Die Beträge sind der Listenindex: jede neue Schicht schiebt alles über ihr eine Stufe weiter
|
||||
// von der Kamera weg — genauer, weiter davor. Mit den fünf Park-Schichten (sand kam dazu, dann
|
||||
// field, court, pool, parking, paths) ist die oberste Stufe von −4 auf −9 gewachsen, die
|
||||
// Straßen bekommen also mehr als das Doppelte des früheren Versatzes. Für den senkrechten Blick
|
||||
// ist das gleichgültig: units zählt in der kleinsten auflösbaren Tiefendifferenz, neun davon
|
||||
// sind auch bei 300 m weit unter einem Millimeter. Der streifende Blick ist die Stelle, an der
|
||||
// es auffallen kann, denn dort wird factor mit der Tiefensteigung multipliziert, und die geht
|
||||
// bei fast waagerechtem Blick über eine Bodenfläche gegen unendlich: eine Straße wird dann um
|
||||
// ein Vielfaches ihres nominellen Versatzes nach vorn gezogen und kann vor Geometrie geraten,
|
||||
// die gar nicht an der Rangfolge teilnimmt (Gebäude, Brücken, Wasser — alle mit Versatz 0) und
|
||||
// in Wahrheit vor ihr liegt. Wer hier eine Schicht ergänzt, verschiebt damit nicht nur die neue,
|
||||
// sondern alle darüber und muss den streifenden Blick nachsehen. Bei −9 zeigte die Browser-
|
||||
// prüfung im Park (Augenhöhe auf dem 13th-St-Weg, Blick über Rasen und Felder) nichts davon;
|
||||
// wächst die Liste weiter, ist die Stufenhöhe zu entkoppeln, statt den Index weiterzuzählen.
|
||||
//
|
||||
// Flamingo Park: Felder, Plätze, Pool und Parkplatz liegen über dem Rasen, die Wege über allen Flächen
|
||||
// und unter den Straßen, die sie an der Parkkante berühren.
|
||||
export const POLYGON_OFFSET_ORDER = ["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "paths", "roads"];
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
from itertools import groupby
|
||||
from pathlib import Path
|
||||
|
||||
import shapely
|
||||
from shapely.geometry import Polygon, box
|
||||
|
||||
from .coords import CHUNK_SIZE, SLICE
|
||||
@@ -31,29 +33,76 @@ def _rings(poly: Polygon, i: int, j: int) -> list[list[list[float]]]:
|
||||
im Park) ist genauso echte Flaeche wie der Aussenring und darf beim Zuschneiden nicht
|
||||
verschwinden - sonst wird der Block zu Asphalt, der Innenhof zu Gebaeudemasse, der Teich
|
||||
zu Wiese.
|
||||
|
||||
normalize() zuerst, weil dieselbe Flaeche sonst je nach Rechenweg anders in der Datei
|
||||
steht: GEOS waehlt Ringstartpunkt und Wicklung nach Belieben. unary_union() in
|
||||
apply_street() fasst dafuer das kartenweite Strassenpolygon neu zusammen und aenderte so
|
||||
88 Chunkdateien, in denen sich geometrisch nichts bewegt hatte - symmetrische Differenz
|
||||
der Strassenvereinigung exakt 0,00 m2 in allen 88. normalize() legt beides fest
|
||||
(lexikographisch kleinster Punkt zuerst, Aussenring im Uhrzeigersinn, Loecher dagegen)
|
||||
und macht die Ausgabe damit vom Rechenweg unabhaengig.
|
||||
|
||||
Die vereinheitlichte Wicklung ist unbedenklich, nachgeprueft an allen drei Verbrauchern:
|
||||
geometry.js stellt sie beim Laden ohnehin selbst her (clockwise(), Zeile 50), das
|
||||
Halbstrahl-Verfahren von pointInPolygon() in props.js kommt ohne sie aus, und PIL fuellt
|
||||
in overview.py unabhaengig von Wicklung und Startpunkt (nachgemessen, Bild bytegleich).
|
||||
"""
|
||||
ox = SLICE["x0"] + i * CHUNK_SIZE
|
||||
oy = SLICE["y1"] - j * CHUNK_SIZE
|
||||
poly = shapely.normalize(poly)
|
||||
# Welt (X, Y) -> three (x, z) -> relativ zur Chunk-Ecke
|
||||
return [[[round(x - ox, 2), round(-y - (-oy), 2)] for x, y in ring.coords]
|
||||
for ring in (poly.exterior, *poly.interiors)]
|
||||
|
||||
|
||||
def _sorted_surfaces(entries: list[dict]) -> list[dict]:
|
||||
"""Belaege nur innerhalb einer Art sortieren - zwischen den Arten traegt die Reihenfolge Bedeutung.
|
||||
|
||||
overview.py malt die Belaege in Listenreihenfolge uebereinander, der spaetere gewinnt. An den
|
||||
ausgelieferten Chunks ueberlappen sich verschiedene Arten auf 36.873 m2: der Parkrasen liegt
|
||||
unter Feldern (19.573 m2), Plaetzen (11.872 m2), Parkplatz (2.878 m2), Pool (539 m2) und
|
||||
Spielplatzsand (270 m2), die Stadtflaeche ueber dem Strandsand (1.729 m2). Ein Sortieren ueber
|
||||
die Arten hinweg kehrte das um und liesse die Felder unter dem Rasen verschwinden - und eine
|
||||
feste Rangfolge je Art gaebe es dafuer nicht, weil "sand" als Strand vor und als Spielplatz
|
||||
hinter "park" steht. Die Abfolge der Arten selbst steht fest im Code (run.py, SURFACE_ORDER in
|
||||
park.py) und ist damit schon eindeutig; GEOS mischt nur innerhalb einer Art.
|
||||
"""
|
||||
return [entry for _, run in groupby(entries, key=lambda e: e["kind"])
|
||||
for entry in sorted(run, key=lambda e: e["poly"])]
|
||||
|
||||
|
||||
def chunk_payload(i, j, *, land, surfaces, roads, centerlines, buildings, palms, paths=()) -> dict:
|
||||
"""Ein Chunk als Ausgabestruktur, deterministisch sortiert.
|
||||
|
||||
Sortiert wird nach der gerundeten Ausgabestruktur selbst, nicht nach Flaeche, Schwerpunkt oder
|
||||
Bounding-Box: nur sie ist total geordnet und ohne Sonderfall, denn zwei Eintraege mit gleichem
|
||||
Schluessel sind dann bytegleich und ihre Reihenfolge wieder egal. Die abgeleiteten Zahlen
|
||||
koennen sich dagegen zwei verschiedene Flaechen teilen, und die Ausgabe hinge fuer diese
|
||||
beiden weiterhin an der Listenposition, die GEOS vergibt.
|
||||
|
||||
centerlines bleiben bewusst unsortiert und unnormalisiert: dort traegt sowohl die Reihenfolge
|
||||
als auch die Laufrichtung Bedeutung. vergeLampPoints() in props.js setzt jede Laterne
|
||||
abwechselnd links und rechts der Fahrbahn (gerader Index links, ungerader rechts) und verwirft
|
||||
sie, wenn auf ihrer Seite kein freier Platz ist - an den ausgelieferten Chunks wechselten beim
|
||||
Sortieren 1.848 von 3.921 Laternen die Strassenseite. Die Mittellinien sind auch ohne
|
||||
Sortieren stabil: sie entstehen aus centerlines() und difference(), nie aus unary_union().
|
||||
"""
|
||||
ox = SLICE["x0"] + i * CHUNK_SIZE
|
||||
oy = SLICE["y1"] - j * CHUNK_SIZE
|
||||
return {
|
||||
"chunk": [i, j],
|
||||
"origin": [ox, -oy],
|
||||
"land": [_rings(p, i, j) for p in land],
|
||||
"surfaces": [{"poly": _rings(p, i, j), "kind": k} for p, k in surfaces],
|
||||
"roads": [{"poly": _rings(p, i, j), "bridge": b} for p, b in roads],
|
||||
"land": sorted(_rings(p, i, j) for p in land),
|
||||
"surfaces": _sorted_surfaces([{"poly": _rings(p, i, j), "kind": k} for p, k in surfaces]),
|
||||
"roads": sorted(({"poly": _rings(p, i, j), "bridge": b} for p, b in roads),
|
||||
key=lambda r: (r["poly"], r["bridge"])),
|
||||
"centerlines": [[[round(x - ox, 2), round(-y + oy, 2)] for x, y in ln.coords] for ln in centerlines],
|
||||
"buildings": [{"poly": _rings(p, i, j), "height": h["height"],
|
||||
"source": h["source"], "speculative": s} for p, h, s in buildings],
|
||||
"palms": [[round(x - ox, 2), round(-y + oy, 2)] for x, y in palms],
|
||||
"buildings": sorted(({"poly": _rings(p, i, j), "height": h["height"],
|
||||
"source": h["source"], "speculative": s} for p, h, s in buildings),
|
||||
key=lambda b: (b["poly"], b["height"], b["source"], b["speculative"])),
|
||||
"palms": sorted([round(x - ox, 2), round(-y + oy, 2)] for x, y in palms),
|
||||
# Parkwege (Flamingo Park, docs/superpowers/specs/2026-09-15-flamingo-park-design.md), aufgebaut wie roads.
|
||||
"paths": [{"poly": _rings(p, i, j)} for p in paths],
|
||||
"paths": sorted(({"poly": _rings(p, i, j)} for p in paths), key=lambda p: p["poly"]),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -279,7 +279,31 @@ class ParkLayout:
|
||||
|
||||
|
||||
def load_elements(path: Path) -> list[dict]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))["elements"]
|
||||
"""Die Elemente des OSM-Abzugs, so streng geprüft wie die Parkdatei.
|
||||
|
||||
osm.py wird von Hand aufgerufen, der Abzug liegt also beliebig lange herum und kann
|
||||
abgeschnitten oder aus einer älteren Fassung des Skripts sein. Ohne diese Prüfung fällt das
|
||||
erst als nackter KeyError mitten in build_layout() auf — ohne Dateinamen und ohne den Hinweis,
|
||||
wie man den Abzug erneuert.
|
||||
"""
|
||||
def fail(message: str):
|
||||
raise ParkError(f"{path.name}: {message}")
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as err:
|
||||
fail(f"kein gültiges JSON: {err}")
|
||||
elements = data.get("elements") if isinstance(data, dict) else None
|
||||
if not isinstance(elements, list):
|
||||
fail("Feld 'elements' fehlt oder ist keine Liste: cd tools/extract && uv run python osm.py")
|
||||
for n, element in enumerate(elements, start=1):
|
||||
where = f"elements[{n}]" if not isinstance(element, dict) else f"elements[{n}] (id {element.get('id')})"
|
||||
if not isinstance(element, dict) or not isinstance(element.get("tags"), dict):
|
||||
fail(f"{where} braucht eine Tabelle 'tags'")
|
||||
geometry = element.get("geometry")
|
||||
if not (isinstance(geometry, list) and all(_is_point(point) for point in geometry)):
|
||||
fail(f"{where} braucht 'geometry' als Liste von Punkten [Breite, Länge]")
|
||||
return elements
|
||||
|
||||
|
||||
def keepout_polygon(keepout: Keepout) -> Polygon:
|
||||
@@ -344,7 +368,12 @@ def build_layout(elements: list[dict], config, fit: ParkFit) -> ParkLayout:
|
||||
if footways:
|
||||
union = unary_union(footways)
|
||||
warnings += _keepout_warnings(union, config.keepouts, keepouts, "Parkweg")
|
||||
paths = _polygons(union.difference(blocked))
|
||||
# Am Parkviereck abschneiden: _extend() legt den letzten Stützpunkt genau auf die Kante,
|
||||
# die runde Kappe von LineString.buffer() setzt danach bis zu eine halbe Wegbreite (3 m)
|
||||
# darüber hinaus. Gemessen lagen so 95,96 m² Weg außerhalb des Vierecks, davon rund 41 m²
|
||||
# als Betonzungen sichtbar auf den umgebenden Belägen. Die gewollte Reichweite bis zur
|
||||
# Straßenkante geht dabei nicht verloren, weil _extend() ohnehin auf diese Kante zielt.
|
||||
paths = _polygons(union.difference(blocked).intersection(fit.game_quad))
|
||||
if service:
|
||||
service_union = unary_union(service)
|
||||
warnings += _keepout_warnings(service_union, config.keepouts, keepouts, "Zufahrt")
|
||||
@@ -487,13 +516,26 @@ def apply_street(world: World, street: Street) -> World:
|
||||
surfaces=surfaces, palms=palms)
|
||||
|
||||
|
||||
def check_corners(config, roads: list[tuple[Polygon, bool]], tolerance: float = 2.0) -> None:
|
||||
"""Jede Spielecke muss an einer Straße liegen, sonst hat sich die Extraktion geändert."""
|
||||
def check_corners(config, roads: list[tuple[Polygon, bool]], tolerance: float = 2.5) -> None:
|
||||
"""Jede Spielecke muss am Rand einer Straße liegen, sonst hat sich die Extraktion geändert.
|
||||
|
||||
Gemessen wird gegen den Rand, nicht gegen die Fläche: alle vier Ecken liegen heute innerhalb
|
||||
der Straßenfläche, ihr Flächenabstand ist 0,000 m. Eine Prüfung auf die Fläche schlägt deshalb
|
||||
nur an, wenn eine Straße sich zurückzieht. Verbreitert ein künftiger Extraktionslauf die
|
||||
Straßen nach innen, bliebe der Flächenabstand 0, und apply_park() schnitte die verbreiterte
|
||||
Straße still auf das veraltete Viereck zurück — genau der Fehlerfall, den diese Prüfung
|
||||
abfangen soll.
|
||||
|
||||
Am Referenzstand f32ea27 liegen die Ecken 0,45 m (sw), 0,14 m (se), 1,46 m (ne) und 1,14 m (nw)
|
||||
vom Straßenrand entfernt. 2,5 m Toleranz lässt der weitesten Ecke damit 1,04 m Luft, bevor die
|
||||
Prüfung grundlos abbricht, und schlägt umgekehrt an, sobald eine Straßenkante sich um mehr als
|
||||
ihren heutigen Abstand plus rund einen Meter verschiebt.
|
||||
"""
|
||||
for name, (x, y) in config.game.items():
|
||||
corner = Point(x, y)
|
||||
if not any(poly.distance(corner) <= tolerance for poly, bridge in roads if not bridge):
|
||||
raise ParkError(f"Spielecke {name} ({x:g} | {y:g}) liegt weiter als {tolerance:g} m von jeder Straße: "
|
||||
"Ecken in flamingo-park.toml neu messen")
|
||||
if not any(poly.boundary.distance(corner) <= tolerance for poly, bridge in roads if not bridge):
|
||||
raise ParkError(f"Spielecke {name} ({x:g} | {y:g}) liegt weiter als {tolerance:g} m vom Rand jeder "
|
||||
"Straße: Ecken in flamingo-park.toml neu messen")
|
||||
|
||||
|
||||
def apply_park(world: World, layout: ParkLayout, seed: int) -> World:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.geometry import LineString, Polygon
|
||||
from leonida.emit import chunk_payload, split_by_chunk, split_by_chunk_all
|
||||
|
||||
BIG = Polygon([(-1000, 2500), (-700, 2500), (-700, 2200), (-1000, 2200)]) # Chunk (0,0) und Nachbarn
|
||||
HEIGHT = {"height": 9.0, "source": "district"}
|
||||
|
||||
def test_polygon_is_clipped_into_its_chunks():
|
||||
parts = split_by_chunk(BIG)
|
||||
@@ -48,6 +49,56 @@ def test_polygon_holes_survive_into_the_payload():
|
||||
assert max(x for x, _ in exterior) > max(x for x, _ in hole)
|
||||
assert all(0 <= x <= 250 and 0 <= z <= 250 for x, z in hole)
|
||||
|
||||
def test_the_same_geometry_in_another_order_gives_the_same_output():
|
||||
"""Die Ausgabe darf nicht am Rechenweg haengen, nur an der Geometrie.
|
||||
|
||||
unary_union() in park.py fasst das kartenweite Strassenpolygon neu zusammen und gibt dieselbe
|
||||
Flaeche danach an anderer Listenstelle und mit anderem Ringstartpunkt zurueck. Das aenderte
|
||||
88 Chunkdateien, in denen sich geometrisch nichts bewegt hatte (symmetrische Differenz der
|
||||
Strassenvereinigung 0,00 m2 in allen 88). Hier steht dieselbe Welt zweimal: einmal in der
|
||||
einen, einmal in der anderen Reihenfolge, und `zweite` beschreibt `a` ausserdem mit anderem
|
||||
Startpunkt und anderer Wicklung."""
|
||||
a = Polygon([(-990, 2490), (-980, 2490), (-980, 2480), (-990, 2480)])
|
||||
b = Polygon([(-960, 2460), (-950, 2460), (-950, 2450), (-960, 2450)])
|
||||
a_gedreht = Polygon([(-980, 2480), (-980, 2490), (-990, 2490), (-990, 2480)])
|
||||
assert a.equals(a_gedreht) and list(a.exterior.coords) != list(a_gedreht.exterior.coords)
|
||||
erste = chunk_payload(0, 0, land=[a, b], surfaces=[(a, "urban"), (b, "urban")],
|
||||
roads=[(a, False), (b, True)], centerlines=[],
|
||||
buildings=[(a, HEIGHT, False), (b, HEIGHT, True)],
|
||||
palms=[(-990.0, 2490.0), (-960.0, 2460.0)], paths=[a, b])
|
||||
zweite = chunk_payload(0, 0, land=[b, a_gedreht], surfaces=[(b, "urban"), (a_gedreht, "urban")],
|
||||
roads=[(b, True), (a_gedreht, False)], centerlines=[],
|
||||
buildings=[(b, HEIGHT, True), (a_gedreht, HEIGHT, False)],
|
||||
palms=[(-960.0, 2460.0), (-990.0, 2490.0)], paths=[b, a_gedreht])
|
||||
assert erste == zweite
|
||||
|
||||
|
||||
def test_surfaces_keep_the_order_of_their_kinds():
|
||||
"""Gegenprobe zum Sortieren: overview.py malt die Belaege in Listenreihenfolge uebereinander.
|
||||
|
||||
Der Parkrasen liegt unter den Feldern; sortierte man ueber die Arten hinweg, deckte der grosse
|
||||
Rasen sie auf map.png wieder zu (19.573 m2 Feld allein im Flamingo Park)."""
|
||||
rasen = Polygon([(-1000, 2500), (-900, 2500), (-900, 2400), (-1000, 2400)])
|
||||
feld = Polygon([(-980, 2480), (-940, 2480), (-940, 2440), (-980, 2440)])
|
||||
payload = chunk_payload(0, 0, land=[], surfaces=[(rasen, "park"), (feld, "field")], roads=[],
|
||||
centerlines=[], buildings=[], palms=[])
|
||||
assert [s["kind"] for s in payload["surfaces"]] == ["park", "field"]
|
||||
|
||||
|
||||
def test_centerlines_keep_their_order_and_direction():
|
||||
"""Die zweite Gegenprobe: Mittellinien werden bewusst nicht sortiert und nicht normalisiert.
|
||||
|
||||
vergeLampPoints() in src/world/props.js setzt jede Laterne abwechselnd links und rechts der
|
||||
Fahrbahn (gerader Index links, ungerader rechts) und dreht sie in Laufrichtung der Linie.
|
||||
Sortieren verschob an den ausgelieferten Chunks 1.848 von 3.921 Laternen auf die andere
|
||||
Strassenseite; normalize() wuerde zusaetzlich jede zweite Linie umdrehen."""
|
||||
spaet = LineString([(-900, 2400), (-950, 2400)])
|
||||
frueh = LineString([(-990, 2490), (-960, 2490)])
|
||||
payload = chunk_payload(0, 0, land=[], surfaces=[], roads=[], centerlines=[spaet, frueh],
|
||||
buildings=[], palms=[])
|
||||
assert payload["centerlines"] == [[[100, 100], [50, 100]], [[10, 10], [40, 10]]]
|
||||
|
||||
|
||||
def test_split_by_chunk_all_yields_polygons_for_a_multi_part_intersection():
|
||||
"""split_by_chunk_all() ist die Variante, die run.py tatsaechlich benutzt (nicht
|
||||
split_by_chunk), und hat einen MultiPolygon-Zweig, den kein anderer Test trifft: ein
|
||||
|
||||
@@ -72,12 +72,33 @@ def test_same_seed_gives_the_same_palms():
|
||||
assert apply_park(world(), layout(), seed=7).palms == apply_park(world(), layout(), seed=7).palms
|
||||
|
||||
|
||||
def test_corners_must_lie_at_a_road():
|
||||
def corners(**moved):
|
||||
game = {"sw": (0.0, 0.0), "se": (100.0, 0.0), "ne": (100.0, 200.0), "nw": (0.0, 200.0)}
|
||||
return SimpleNamespace(game={**game, **moved})
|
||||
|
||||
|
||||
def test_corners_must_lie_at_a_road_when_it_pulls_back():
|
||||
"""Die eine Richtung: die Straße bleibt, wo sie ist, die Ecke steht daneben."""
|
||||
roads = [(box(-20, -20, 0, 220), False), (box(100, -20, 120, 220), False)]
|
||||
check_corners(SimpleNamespace(game={"sw": (0.0, 0.0), "se": (100.0, 0.0), "ne": (100.0, 200.0), "nw": (0.0, 200.0)}), roads)
|
||||
far = SimpleNamespace(game={"sw": (0.0, 0.0), "se": (100.0, 0.0), "ne": (100.0, 200.0), "nw": (30.0, 200.0)})
|
||||
check_corners(corners(), roads)
|
||||
with pytest.raises(ParkError, match="Spielecke nw"):
|
||||
check_corners(far, roads)
|
||||
check_corners(corners(nw=(30.0, 200.0)), roads)
|
||||
|
||||
|
||||
def test_a_road_that_grew_inward_over_the_corner_is_caught():
|
||||
"""Die andere Richtung, und der Grund für die Messung gegen den Rand statt gegen die Fläche.
|
||||
|
||||
Die Westkante der linken Straße ist von x = 0 auf x = 6 nach innen gewandert; die SW- und die
|
||||
NW-Ecke liegen jetzt 6 m tief in der Fahrbahn. apply_park() schnitte die verbreiterte Straße
|
||||
wortlos wieder auf das alte Viereck zurück — deshalb muss die Prüfung hier abbrechen. Mit dem
|
||||
Flächenabstand tut sie das nicht: er ist für eine Ecke mitten in der Straße 0 (die Zusicherung
|
||||
unten misst genau das), und die alte Bedingung poly.distance(corner) <= 2.0 ließ den Fall
|
||||
durchlaufen.
|
||||
"""
|
||||
grown = [(box(-20, -20, 6, 220), False), (box(100, -20, 120, 220), False)]
|
||||
assert min(poly.distance(Point(0, 0)) for poly, _ in grown) == 0
|
||||
with pytest.raises(ParkError, match="Spielecke sw"):
|
||||
check_corners(corners(), grown)
|
||||
|
||||
|
||||
def test_flamingo_park_goes_into_a_world_with_roads_around_the_park():
|
||||
@@ -93,4 +114,10 @@ def test_flamingo_park_goes_into_a_world_with_roads_around_the_park():
|
||||
# Bekannter Zustand: die Zufahrt zum Parkplatz quert die Sperrfläche der Tribüne und wird dort
|
||||
# ausgeschnitten (Task 5). Jede weitere oder andere Warnung ist ein echter Befund.
|
||||
assert [m for m in messages if m.startswith("Warnung")] == ["Warnung: Zufahrt schneidet die Sperrfläche tribuene"]
|
||||
assert any(message.startswith("Flamingo Park:") for message in messages)
|
||||
# Die Flächenzahl gehört mit in den Vergleich: _surface_kind() kennt nur die Sportarten aus
|
||||
# FIELD_SPORTS und COURT_SPORTS und lässt jede andere wortlos weg. Taggt OpenStreetMap beim
|
||||
# nächsten Abruf ein Feld um — aus "tennis" wird "pickleball", aus "soccer" ein "multi" —,
|
||||
# verschwindet es aus dem Park, ohne dass eine Warnung oder ein anderer Test das bemerkt;
|
||||
# hier sinkt dann die 14, und der Abzug wird geprüft statt der Park stillschweigend kleiner.
|
||||
assert [m for m in messages if m.startswith("Flamingo Park:")] == [
|
||||
"Flamingo Park: 2 Wegflächen, 14 Flächen, 11 Gebäude, Jefferson Avenue 12 m breit"]
|
||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from leonida.park import Anchor, Keepout, ParkError, load_park, parse_park
|
||||
from leonida.park import Anchor, Keepout, ParkError, load_elements, load_park, parse_park
|
||||
|
||||
DATA = Path(__file__).resolve().parents[1] / "data"
|
||||
|
||||
@@ -92,3 +92,27 @@ def test_committed_park_file_loads():
|
||||
assert [k.name for k in config.keepouts] == ["hood-yard", "tribuene"]
|
||||
assert (config.scale_u, config.offset_u) == (1.0, 6.25)
|
||||
assert len(config.street.alley_cut) == 12
|
||||
assert len(load_elements(config.osm_path)) > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text, message", [
|
||||
("{ abgeschnitten", "kein gültiges JSON"),
|
||||
('{"license": "ODbL 1.0"}', "Feld 'elements' fehlt oder ist keine Liste"),
|
||||
('{"elements": {"1": {}}}', "Feld 'elements' fehlt oder ist keine Liste"),
|
||||
('{"elements": [42]}', "elements[1] braucht eine Tabelle 'tags'"),
|
||||
('{"elements": [{"id": 7, "geometry": [[25.78, -80.14], [25.79, -80.14]]}]}',
|
||||
"elements[1] (id 7) braucht eine Tabelle 'tags'"),
|
||||
('{"elements": [{"id": 7, "tags": {"highway": "footway"}}]}',
|
||||
"elements[1] (id 7) braucht 'geometry' als Liste von Punkten"),
|
||||
('{"elements": [{"id": 7, "tags": {}, "geometry": [[25.78]]}]}',
|
||||
"elements[1] (id 7) braucht 'geometry' als Liste von Punkten"),
|
||||
])
|
||||
def test_a_broken_osm_dump_aborts_with_the_file_name(tmp_path, text, message):
|
||||
"""osm.py wird von Hand aufgerufen; ein alter oder halb geschriebener Abzug ist der Normalfall,
|
||||
nicht die Ausnahme. Er soll denselben ParkError mit Dateinamen geben wie die Parkdatei, statt
|
||||
als KeyError irgendwo in build_layout() zu landen."""
|
||||
path = tmp_path / "flamingo-park.json"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
with pytest.raises(ParkError, match="flamingo-park.json") as err:
|
||||
load_elements(path)
|
||||
assert message in str(err.value)
|
||||
@@ -53,6 +53,20 @@ def test_path_is_cut_at_the_real_park_edge():
|
||||
assert path.bounds[3] <= 1400 + 1.5 + 1e-6
|
||||
|
||||
|
||||
def test_paths_stop_at_the_park_edge_instead_of_bulging_over_it():
|
||||
"""LineString.buffer() setzt an jedes Wegende eine runde Kappe von einer halben Wegbreite.
|
||||
|
||||
Der letzte Stützpunkt liegt schon auf der Parkkante (_extend zielt dorthin), die Kappe ragte
|
||||
also bis zu 1,5 m (Fußweg) bzw. 3 m (Hauptweg) darüber hinaus und lag als Betonzunge auf dem
|
||||
Belag daneben — an den echten Daten 95,96 m² außerhalb des Vierecks.
|
||||
"""
|
||||
cfg = config()
|
||||
quad = ParkFit(cfg).game_quad
|
||||
(path,) = build_layout([way({"highway": "footway"}, (50, 150), (50, 260))], cfg, ParkFit(cfg)).paths
|
||||
assert path.difference(quad).area == 0
|
||||
assert path.bounds[3] == pytest.approx(1400.0)
|
||||
|
||||
|
||||
def test_path_ending_near_the_west_edge_runs_on_to_the_road():
|
||||
(path,) = layout([way({"highway": "footway"}, (40, 100), (3, 100))]).paths
|
||||
assert path.contains(Point(1001, 1200))
|
||||
|
||||
Reference in new issue
Block a user