diff --git a/docs/superpowers/plans/2026-09-15-flamingo-park.md b/docs/superpowers/plans/2026-09-15-flamingo-park.md new file mode 100644 index 0000000..722e17e --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-flamingo-park.md @@ -0,0 +1,2369 @@ +# Flamingo Park nach OpenStreetMap — Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Flamingo Park bekommt im Spiel die echte Anlage aus OpenStreetMap (Wege, Felder, Plätze, Pool, Parkplatz, Gebäude), eingepasst nach Variante D, und die Jefferson Avenue läuft als gerade Straße vom Park nach Süden. + +**Architecture:** Ein OSM-Stand liegt als Datei im Repo. Das neue Modul `tools/extract/leonida/park.py` liest die Parkdatei `flamingo-park.toml`, rechnet die OSM-Geometrie affin in den Spielpark um und verändert die Weltgeometrie der Extraktion zwischen Palmen und Chunk-Ausgabe: erst die Jefferson Avenue, dann den Park. Die Chunk-Dateien bekommen die Liste `paths` und vier neue Flächenarten, die der Browser als Auflagen zeichnet und die Minimap mitmalt. + +**Tech Stack:** Python 3.12 über uv (shapely 2.1.2, numpy, pillow, pytest; `tomllib` aus der Standardbibliothek), three.js 0.184 und vitest 3, Poly-Haven-Textur `concrete_pavement`. + +**Spec:** `docs/superpowers/specs/2026-09-15-flamingo-park-design.md` + +## Global Constraints + +- Branch `leonida/flamingo-park` im Hauptordner `A:\developer\GitHub-desktop\gta6`; `main` bleibt unberührt. +- Die Ordner `modular_city_road_pack__game_ready/` und `modular_sidewalk_curb_kit/` im Repo-Wurzelordner bleiben unversioniert und unberührt (Teil 2). +- Kommentare, Meldungen, Testnamen und Commit-Nachrichten auf Deutsch wie im übrigen Projekt; Bezeichner im Code englisch. +- Jeder Commit endet mit der Zeile `Co-Authored-By: Claude Opus 5 `. +- 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. +- Hilfsdateien für die Browserprüfung (`index-debug.html` o. ä.) nie committen. + +## Dateien + +| Datei | Aufgabe | +|---|---| +| `tools/extract/osm.py` | Neu. Overpass-Abruf, Filter, Schreiben von `data/osm/flamingo-park.json` (Task 2) | +| `tools/extract/data/osm/flamingo-park.json` | Neu, generiert und versioniert. OSM-Stand mit Lizenzhinweis | +| `tools/extract/data/flamingo-park.toml` | Neu. Ecken, Einpassung, Anker, Sperrflächen, Breiten, Jefferson Avenue | +| `tools/extract/leonida/park.py` | Neu. Parkdatei lesen (Task 3), Einpassung (Task 4), Anlage aus OSM (Task 5), Jefferson Avenue (Task 6), Park einsetzen (Task 7), Gesamtablauf (Task 8) | +| `tools/extract/leonida/emit.py` | `chunk_payload` bekommt `paths` (Task 8) | +| `tools/extract/run.py` | Ruft `apply_flamingo_park`, schreibt `paths`, Namensnennung (Task 8) | +| `tools/extract/overview.py` | Farben für neue Flächen, Ebene für Wege (Task 9) | +| `src/world/geometry.js`, `materials.js`, `streaming.js` | Neue Schichten (Task 10) | +| `tools/assets/textures.mjs`, `public/textures/concrete_pavement_*.jpg` | Neue Textur (Task 10) | +| `src/world/places.js` | Tribüne ohne `replaces` (Task 12, zusammen mit den neuen Daten) | +| `src/world/spawn.js`, `src/main.js` | Startpunkt und Fahrzeugpunkte als Modul (Task 11) | +| `tests/parkData.test.js`, `tests/placesSite.test.js` | Datentests (Task 11) | +| `public/data/vice-a/*` | Neu erzeugt (Task 12) | + +--- + +### Task 1: Pipeline auf Windows nachbauen + +Prüfaufgabe ohne Codeänderung. Sie klärt, ob ein Lauf auf diesem Rechner die eingecheckten Daten unverändert erzeugt. Erst danach dürfen spätere Tasks Daten neu schreiben. + +**Files:** keine Änderung (erzeugt `tools/extract/.venv`, `tools/extract/cache/`, `tools/extract/debug/`, alle gitignored) + +**Interfaces:** +- 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** + +Run (Git Bash, aus dem Repo-Wurzelordner): +```bash +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** + +Run: +```bash +cd tools/extract && PYTHONUTF8=1 uv run pytest -q +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** + +Run: +```bash +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** + +Run: +```bash +cd tools/extract && PYTHONUTF8=1 uv run python - <<'EOF' +import io, json, subprocess +from pathlib import Path +from PIL import Image + +ROOT = Path("../../public/data/vice-a") + +def head(name: str) -> bytes: + return subprocess.run(["git", "show", f"HEAD:public/data/vice-a/{name}"], capture_output=True, check=True).stdout + +bad = [p.name for p in sorted(ROOT.glob("*.json")) + if json.loads(p.read_text(encoding="utf-8")) != json.loads(head(p.name))] +for name in ("map.png", "map-walkable.png"): + fresh, old = Image.open(ROOT / name), Image.open(io.BytesIO(head(name))) + if (fresh.size, fresh.mode, fresh.tobytes()) != (old.size, old.mode, old.tobytes()): + bad.append(name) +print("identisch" if not bad else f"{len(bad)} Dateien weichen ab: {bad[:12]}") +EOF +``` +Expected: `identisch`. + +- [ ] **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“). + +--- + +### Task 2: OSM-Stand abrufen + +**Files:** +- Create: `tools/extract/osm.py` +- Create: `tools/extract/tests/test_osm.py` +- Create (generiert): `tools/extract/data/osm/flamingo-park.json` + +**Interfaces:** +- 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** + +`tools/extract/tests/test_osm.py`: +```python +import json + +from osm import dumps, keep, overpass_query, snapshot + + +def way(tags, geometry=((25.78, -80.137), (25.781, -80.137)), id=1): + return {"type": "way", "id": id, "tags": tags, "geometry": [{"lat": a, "lon": b} for a, b in geometry]} + + +def test_keep_takes_park_paths_fields_pools_parking_and_buildings(): + for tags in ({"highway": "footway"}, {"highway": "service"}, {"highway": "steps"}, + {"leisure": "pitch", "sport": "tennis"}, {"leisure": "swimming_pool"}, {"leisure": "dog_park"}, + {"amenity": "parking"}, {"building": "yes"}, {"building": "yes", "leisure": "sports_centre"}): + assert keep(way(tags)), tags + + +def test_keep_drops_streets_street_sidewalks_and_the_park_outline(): + for tags in ({"highway": "residential"}, {"highway": "footway", "footway": "sidewalk"}, + {"leisure": "park"}, {"amenity": "restaurant"}): + assert not keep(way(tags)), tags + + +def test_keep_takes_the_sidewalk_tagged_piece_of_the_13th_street_path(): + assert keep(way({"highway": "footway", "footway": "sidewalk", "name": "Flamingo Park Path (13th St)"})) + + +def test_keep_needs_a_way_with_geometry(): + assert not keep({"type": "node", "id": 1, "tags": {"building": "yes"}, "lat": 25.78, "lon": -80.13}) + assert not keep({"type": "way", "id": 1, "tags": {"building": "yes"}, "geometry": []}) + + +def test_snapshot_keeps_id_tags_and_lat_lon_pairs_with_licence_and_timestamp(): + raw = {"osm3s": {"timestamp_osm_base": "2026-09-15T20:28:44Z"}, + "elements": [way({"highway": "residential"}, id=7), way({"highway": "footway"}, id=3)]} + snap = snapshot(raw) + assert snap["fetched"] == "2026-09-15T20:28:44Z" + assert "ODbL" in snap["license"] and "OpenStreetMap" in snap["license"] + assert snap["elements"] == [{"id": 3, "tags": {"highway": "footway"}, + "geometry": [[25.78, -80.137], [25.781, -80.137]]}] + + +def test_dumps_writes_one_line_per_element_and_reads_back(): + snap = snapshot({"elements": [way({"building": "yes"}, id=2), way({"highway": "path"}, id=1)]}) + text = dumps(snap) + assert json.loads(text) == snap + assert sum(1 for line in text.splitlines() if line.lstrip().startswith('{"id"')) == 2 + + +def test_query_asks_for_ways_with_geometry_in_the_box(): + query = overpass_query((1, 2, 3, 4)) + assert "way(1,2,3,4)[highway]" in query and "way(1,2,3,4)[building]" in query + assert "way(1,2,3,4)[amenity=parking]" in query and query.endswith("out geom;") +``` + +- [ ] **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** + +`tools/extract/osm.py`: +```python +"""Holt die OpenStreetMap-Daten für Flamingo Park einmal und legt sie ins Repo. + + cd tools/extract && PYTHONUTF8=1 uv run python osm.py + +Spec: docs/superpowers/specs/2026-09-15-flamingo-park-design.md +run.py liest nur die geschriebene Datei und braucht kein Netz; ein neuer Abruf ist eine bewusste +Handlung mit eigenem Commit. +""" +import json +import urllib.parse +import urllib.request +from pathlib import Path + +URL = "https://overpass-api.de/api/interpreter" +UA = {"User-Agent": "leonida-extract/0.1 (local hobby project)"} +BBOX = (25.7812, -80.1392, 25.7872, -80.1358) # Süd, West, Nord, Ost: Flamingo Park mit Rand +OUT = Path(__file__).resolve().parent / "data" / "osm" / "flamingo-park.json" +PATH_HIGHWAYS = ("footway", "path", "pedestrian", "steps", "cycleway", "service") +LEISURE = ("pitch", "playground", "dog_park", "sports_centre", "swimming_pool") + + +def overpass_query(bbox=BBOX) -> str: + box = "({},{},{},{})".format(*bbox) + return (f"[out:json][timeout:60];(" + f"way{box}[highway];way{box}[leisure];way{box}[amenity=parking];way{box}[building];" + f");out geom;") + + +def keep(element: dict) -> bool: + """Nur was der Park braucht: Wege, Sportflächen, Pool, Parkplatz, Gebäude.""" + if element.get("type") != "way" or not element.get("geometry"): + return False + tags = element.get("tags", {}) + if "building" in tags: + return True + highway = tags.get("highway") + if highway: + if highway not in PATH_HIGHWAYS: + return False + # Gehwege an Straßen gehören zu Teil 2; ein Stück des 13th-St-Wegs ist trotzdem als sidewalk getaggt. + return tags.get("footway") != "sidewalk" or tags.get("name", "").startswith("Flamingo Park Path") + return tags.get("leisure") in LEISURE or tags.get("amenity") == "parking" + + +def snapshot(raw: dict) -> dict: + """Gefilterter Stand mit Herkunft, Lizenz und Abrufzeit; Geometrie als [Breite, Länge].""" + elements = [{"id": e["id"], "tags": e.get("tags", {}), + "geometry": [[g["lat"], g["lon"]] for g in e["geometry"]]} + for e in raw.get("elements", []) if keep(e)] + elements.sort(key=lambda e: e["id"]) + return { + "source": "https://www.openstreetmap.org", + "license": "ODbL 1.0, © OpenStreetMap-Mitwirkende", + "fetched": raw.get("osm3s", {}).get("timestamp_osm_base", ""), + "elements": elements, + } + + +def dumps(snap: dict) -> str: + """Eine Zeile je Element, damit ein neuer Abruf als lesbarer Diff erscheint.""" + head = [f' "{key}": {json.dumps(value, ensure_ascii=False)}' for key, value in snap.items() if key != "elements"] + rows = ",\n".join(" " + json.dumps(e, ensure_ascii=False, separators=(",", ":")) for e in snap["elements"]) + return "{\n" + ",\n".join(head) + ',\n "elements": [\n' + rows + "\n ]\n}\n" + + +def fetch(query: str) -> dict: + request = urllib.request.Request(URL, data=urllib.parse.urlencode({"data": query}).encode(), headers=UA) + with urllib.request.urlopen(request, timeout=90) as response: + return json.loads(response.read().decode("utf-8")) + + +def main() -> None: + snap = snapshot(fetch(overpass_query())) + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(dumps(snap), encoding="utf-8") + print(f"{len(snap['elements'])} Elemente, Stand {snap['fetched']} -> {OUT}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **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)** + +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: +```bash +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** + +```bash +git add tools/extract/osm.py tools/extract/tests/test_osm.py tools/extract/data/osm/flamingo-park.json +git commit -m "feat(extract): OpenStreetMap-Stand für Flamingo Park abrufen und ablegen + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 3: Parkdatei lesen und prüfen + +**Files:** +- Create: `tools/extract/leonida/park.py` +- Create: `tools/extract/data/flamingo-park.toml` +- Create: `tools/extract/tests/test_park_config.py` + +**Interfaces:** +- Consumes: `tools/extract/data/osm/flamingo-park.json` aus Task 2 (nur Existenz) +- Produces: + - `class ParkError(ValueError)` + - `@dataclass(frozen=True) Anchor(name: str, real: tuple[float, float], game: tuple[float, float])` + - `@dataclass(frozen=True) Keepout(name: str, at: tuple[float, float], rotation: float, bounds: tuple[float, float, float, float])` mit `bounds = (min_x, max_x, min_z, max_z)` + - `@dataclass(frozen=True) Street(start, end: tuple[float, float], width: float, remove_buildings: tuple[tuple[float, float], ...], alley_cut: tuple[tuple[float, float], ...])` + - `@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** + +`tools/extract/tests/test_park_config.py`: +```python +from pathlib import Path + +import pytest + +from leonida.park import Anchor, Keepout, ParkError, load_park, parse_park + +DATA = Path(__file__).resolve().parents[1] / "data" + +VALID = """ +osm = "osm.json" + +[fit] +real = { sw = [25.78, -80.14], se = [25.78, -80.13], nw = [25.79, -80.14] } +game = { sw = [0, 0], se = [100, 0], ne = [100, 200], nw = [0, 200] } +scale_u = 1.0 +offset_u = 5 + +[[anchor]] +name = "Ort" +real = [25.785, -80.135] +game = [50, 100] + +[[keepout]] +name = "ort" +at = [50, 100] +rotation = 45 +bounds = { min_x = -10, max_x = 10, min_z = -5, max_z = 5 } + +[paths] +main_width = 6 +width = 3 +service_width = 6 + +[buildings] +default_height = 4 + +[street] +from = [10, 0] +to = [10, -100] +width = 12 +remove_buildings = [[12, -50]] +alley_cut = [[0, -1], [5, -1], [5, -99], [0, -99]] +""" + + +def parse(text: str, tmp_path: Path): + (tmp_path / "osm.json").write_text('{"elements": []}', encoding="utf-8") + return parse_park(text, tmp_path, "park.toml") + + +def test_valid_file_parses_into_a_config(tmp_path): + config = parse(VALID, tmp_path) + assert config.osm_path == tmp_path / "osm.json" + assert config.real == {"sw": (25.78, -80.14), "se": (25.78, -80.13), "nw": (25.79, -80.14)} + assert config.game["ne"] == (100.0, 200.0) + assert (config.scale_u, config.offset_u) == (1.0, 5.0) + assert config.anchors == (Anchor("Ort", (25.785, -80.135), (50.0, 100.0)),) + assert config.keepouts == (Keepout("ort", (50.0, 100.0), 45.0, (-10.0, 10.0, -5.0, 5.0)),) + assert (config.main_width, config.path_width, config.service_width) == (6.0, 3.0, 6.0) + assert config.default_height == 4.0 + assert (config.street.start, config.street.end, config.street.width) == ((10.0, 0.0), (10.0, -100.0), 12.0) + assert config.street.remove_buildings == ((12.0, -50.0),) + assert config.street.alley_cut[1] == (5.0, -1.0) + + +@pytest.mark.parametrize("old, new, message", [ + ("offset_u = 5\n", "", "Feld 'fit.offset_u' fehlt"), + ("offset_u = 5", "offest_u = 5", "Feld 'fit.offest_u' gibt es nicht"), + ("width = 3\n", 'width = "3"\n', "Feld 'paths.width' muss eine Zahl"), + ("main_width = 6", "main_width = 0", "Feld 'paths.main_width' muss eine Zahl größer als 0"), + ("default_height = 4", "default_height = -1", "Feld 'buildings.default_height' muss eine Zahl größer als 0"), + ("min_x = -10, max_x = 10", "min_x = 10, max_x = -10", "min muss kleiner als max"), + ("game = [50, 100]", "game = [50]", "Feld 'anchor[1].game' muss ein Punkt"), + ("[5, -1], [5, -99]", "[5, -99], [5, -1]", "ohne sich schneidende Kanten"), + ('osm = "osm.json"', 'osm = "fehlt.json"', "uv run python osm.py"), +]) +def test_form_errors_abort_with_file_and_field(tmp_path, old, new, message): + assert old in VALID + with pytest.raises(ParkError, match="park.toml") as err: + parse(VALID.replace(old, new, 1), tmp_path) + assert message in str(err.value) + + +def test_missing_park_file_aborts(tmp_path): + with pytest.raises(ParkError, match="fehlt"): + load_park(tmp_path / "flamingo-park.toml") + + +def test_committed_park_file_loads(): + config = load_park(DATA / "flamingo-park.toml") + assert config.osm_path.exists() + 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 +``` + +- [ ] **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** + +`tools/extract/leonida/park.py`: +```python +"""Flamingo Park nach OpenStreetMap in die extrahierte Welt einsetzen, dazu die Jefferson Avenue. + +Spec: docs/superpowers/specs/2026-09-15-flamingo-park-design.md + +Welt-x und Welt-y in Metern wie auf der Minimap; echte Punkte in flamingo-park.toml als Breite/Länge. +Ein Formfehler in der Parkdatei bricht mit ParkError ab, wie bei annotations.toml. +""" +from __future__ import annotations + +import tomllib +from dataclasses import dataclass +from pathlib import Path + +from shapely.geometry import Polygon + + +class ParkError(ValueError): + """Formfehler in der Parkdatei oder eine Welt, zu der sie nicht mehr passt: der Lauf bricht ab.""" + + +# --- Parkdatei --------------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Anchor: + name: str + real: tuple[float, float] # Breite, Länge + game: tuple[float, float] # Welt-x, Welt-y + + +@dataclass(frozen=True) +class Keepout: + name: str + at: tuple[float, float] + rotation: float # Grad gegen den Uhrzeigersinn, wie PLACES + bounds: tuple[float, float, float, float] # min_x, max_x, min_z, max_z in Modellkoordinaten + + +@dataclass(frozen=True) +class Street: + start: tuple[float, float] + end: tuple[float, float] + width: float + remove_buildings: tuple[tuple[float, float], ...] + alley_cut: tuple[tuple[float, float], ...] + + +@dataclass(frozen=True) +class ParkConfig: + osm_path: Path + real: dict[str, tuple[float, float]] # sw, se, nw als Breite, Länge + game: dict[str, tuple[float, float]] # sw, se, ne, nw als Welt-x, Welt-y + 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 + + +def load_park(path: Path) -> ParkConfig: + if not path.exists(): + raise ParkError(f"{path.name} fehlt") + return parse_park(path.read_text(encoding="utf-8"), path.parent, path.name) + + +def parse_park(text: str, base: Path, name: str = "flamingo-park.toml") -> ParkConfig: + def fail(message: str): + raise ParkError(f"{name}: {message}") + + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError as err: + fail(f"kein gültiges TOML: {err}") + root = _Table(data, "", fail) + root.only("osm", "fit", "anchor", "keepout", "paths", "buildings", "street") + + fit = root.table("fit") + fit.only("real", "game", "scale_u", "offset_u") + real, game = fit.table("real"), fit.table("game") + real.only("sw", "se", "nw") + game.only("sw", "se", "ne", "nw") + + anchors = [] + for entry in root.tables("anchor"): + entry.only("name", "real", "game") + anchors.append(Anchor(entry.text("name"), entry.point("real"), entry.point("game"))) + + keepouts = [] + for entry in root.tables("keepout"): + entry.only("name", "at", "rotation", "bounds") + bounds = entry.table("bounds") + bounds.only("min_x", "max_x", "min_z", "max_z") + box = tuple(bounds.number(key) for key in ("min_x", "max_x", "min_z", "max_z")) + if box[0] >= box[1] or box[2] >= box[3]: + fail(f"{entry.path}.bounds: min muss kleiner als max sein, ist {box!r}") + keepouts.append(Keepout(entry.text("name"), entry.point("at"), entry.number("rotation"), box)) + + paths = root.table("paths") + paths.only("main_width", "width", "service_width") + buildings = root.table("buildings") + buildings.only("default_height") + street = root.table("street") + street.only("from", "to", "width", "remove_buildings", "alley_cut") + cut = street.points("alley_cut", 3) + if not Polygon(cut).is_valid: + fail("street.alley_cut muss ein Vieleck ohne sich schneidende Kanten sein") + + osm = root.text("osm") + if not (base / osm).exists(): + fail(f"OSM-Datei {osm} fehlt: cd tools/extract && uv run python osm.py") + + return ParkConfig( + osm_path=base / osm, + real={key: real.point(key) for key in ("sw", "se", "nw")}, + game={key: game.point(key) for key in ("sw", "se", "ne", "nw")}, + scale_u=fit.number("scale_u", positive=True), + offset_u=fit.number("offset_u"), + anchors=tuple(anchors), + keepouts=tuple(keepouts), + main_width=paths.number("main_width", positive=True), + path_width=paths.number("width", positive=True), + service_width=paths.number("service_width", positive=True), + default_height=buildings.number("default_height", positive=True), + street=Street(street.point("from"), street.point("to"), street.number("width", positive=True), + street.points("remove_buildings", 0), cut), + ) + + +class _Table: + """Eine TOML-Tabelle mit Pfad für Meldungen; jede Abfrage prüft Vorhandensein und Typ.""" + + def __init__(self, data, path: str, fail): + if not isinstance(data, dict): + fail(f"{path or 'Datei'} muss eine Tabelle sein") + self.data, self.path, self.fail = data, path, fail + + def _key(self, key: str) -> str: + return f"{self.path}.{key}" if self.path else key + + def only(self, *keys: str) -> None: + for key in self.data: + if key not in keys: + self.fail(f"Feld {self._key(key)!r} gibt es nicht") + + def get(self, key: str): + if key not in self.data: + self.fail(f"Feld {self._key(key)!r} fehlt") + return self.data[key] + + def table(self, key: str) -> _Table: + return _Table(self.get(key), self._key(key), self.fail) + + def tables(self, key: str) -> list[_Table]: + value = self.get(key) + if not isinstance(value, list): + self.fail(f"{self._key(key)} muss als [[{key}]] geschrieben sein") + return [_Table(item, f"{self._key(key)}[{i}]", self.fail) for i, item in enumerate(value, start=1)] + + def text(self, key: str) -> str: + value = self.get(key) + if not isinstance(value, str) or not value.strip(): + self.fail(f"Feld {self._key(key)!r} muss Text sein, ist {value!r}") + return value + + def number(self, key: str, positive: bool = False) -> float: + value = self.get(key) + if not _is_number(value) or (positive and value <= 0): + bound = " größer als 0" if positive else "" + self.fail(f"Feld {self._key(key)!r} muss eine Zahl{bound} sein, ist {value!r}") + return float(value) + + def point(self, key: str) -> tuple[float, float]: + value = self.get(key) + if not _is_point(value): + self.fail(f"Feld {self._key(key)!r} muss ein Punkt [a, b] sein, ist {value!r}") + return float(value[0]), float(value[1]) + + def points(self, key: str, minimum: int) -> tuple[tuple[float, float], ...]: + value = self.get(key) + if not (isinstance(value, list) and len(value) >= minimum and all(_is_point(p) for p in value)): + self.fail(f"Feld {self._key(key)!r} muss mindestens {minimum} Punkte [a, b] enthalten, ist {value!r}") + return tuple((float(a), float(b)) for a, b in value) + + +def _is_number(value) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +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** + +`tools/extract/data/flamingo-park.toml`: +```toml +# Flamingo Park nach OpenStreetMap: echte Wege, Felder, Plätze, Pool, Parkplatz und Gebäude im Park +# J6 bis K8, dazu die Jefferson Avenue als gerade Verlängerung nach Süden. +# Spec: docs/superpowers/specs/2026-09-15-flamingo-park-design.md +# Welt-x/Welt-y wie auf der Minimap; echte Punkte als [Breite, Länge]. + +osm = "osm/flamingo-park.json" + +[fit] +# Innenkante des echten Parks zwischen Michigan Ave, Meridian Ave, 11th Street und der Feldkante im +# Norden. Die Umrechnung ist affin über diese drei Ecken (Variante D). +real = { sw = [25.781905, -80.138373], se = [25.781960, -80.136373], nw = [25.786524, -80.138527] } +# Innenkante der vier Straßen um den Park im Spiel, gemessen an map.png. +game = { sw = [1418.3, 658.6], se = [1661.5, 672.8], ne = [1626.0, 1096.0], nw = [1382.1, 1074.2] } +scale_u = 1.0 # quer wie in echt +offset_u = 6.25 # Meter vom Westrand: echte Tribüne 1,19 m, echte Kleinbauten 1,21 m neben den Orten + +[[anchor]] # Prüfung der Einpassung, höchstens 1,5 m Abweichung (tests/test_park_fit.py) +name = "Tribüne" +real = [25.786336, -80.138358] # Mitte von OSM way 436030691 +game = [1405.0, 1058.6] # Mitte der echten Tribüne im Spiel laut Tribünen-Spec + +[[anchor]] +name = "Hood Yard" +real = [25.786407, -80.137540] # Übergang Container/Schuppen laut Hood-Yard-Spec +game = [1489.0, 1067.5] + +# Sperrflächen: Außenmaße der Orte aus src/world/places.js (tests/parkData.test.js vergleicht beide). +[[keepout]] +name = "hood-yard" +at = [1489.0, 1067.5] +rotation = 0 +bounds = { min_x = -17.15, max_x = 17.0, min_z = -13.4, max_z = 8.6 } + +[[keepout]] +name = "tribuene" +at = [1409.9, 1051.1] +rotation = 45 +bounds = { min_x = -20.8, max_x = 20.8, min_z = -14.82, max_z = 0.8 } + +[paths] +main_width = 6.0 # "Flamingo Park Path (13th St)" und "Flamingo Park Path (Jefferson Ave)" +width = 3.0 # alle übrigen Fußwege +service_width = 6.0 # Parkplatzgasse und Zufahrt + +[buildings] +default_height = 4.0 # wenn OpenStreetMap keine Höhe kennt + +[street] # Jefferson Avenue südlich des Parks, bis zur Querstraße bei y ≈ 332 +from = [1516.5, 664.0] +to = [1545.2, 334.2] +width = 12.0 +remove_buildings = [[1513.0, 628.0], [1532.0, 426.0]] +# Gassenstreifen zwischen 11th Street (Fahrbahnkante y 644,5–645,5) und der Querstraße (Fahrbahnkante +# y 339,5). Die ausgerundeten Ecken bleiben bis auf je 1–3 m stehen, die Fahrbahnen berührt es nicht. +alley_cut = [[1470, 644], [1496, 644], [1500, 600], [1506, 540], [1514, 460], [1520, 380], [1520, 340.5], + [1497, 340.5], [1486, 380], [1480, 460], [1474, 540], [1468, 600]] +``` + +- [ ] **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** + +```bash +git add tools/extract/leonida/park.py tools/extract/data/flamingo-park.toml tools/extract/tests/test_park_config.py +git commit -m "feat(extract): Parkdatei für Flamingo Park lesen und prüfen + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 4: Einpassung echter Park → Spielpark + +**Files:** +- Modify: `tools/extract/leonida/park.py` (Import `math`, neuer Abschnitt nach der Parkdatei) +- Create: `tools/extract/tests/test_park_fit.py` + +**Interfaces:** +- Consumes: `ParkConfig`, `load_park` aus Task 3 (liest nur `real`, `game`, `scale_u`, `offset_u`) +- Produces: + - `REF_LAT = 25.7840`, `REF_LON = -80.1375`, `M_PER_DEG_LAT = 111_320.0`, `M_PER_DEG_LON` + - `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** + +`tools/extract/tests/test_park_fit.py`: +```python +import math +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from leonida.park import M_PER_DEG_LAT, M_PER_DEG_LON, REF_LAT, REF_LON, ParkFit, load_park, local_metres + +DATA = Path(__file__).resolve().parents[1] / "data" + + +def latlon(x: float, y: float) -> tuple[float, float]: + """Lokale Meter -> Breite/Länge, die Umkehrung von local_metres.""" + return REF_LAT + y / M_PER_DEG_LAT, REF_LON + x / M_PER_DEG_LON + + +# Echter Park 100 × 200 m, Spielpark 200 × 400 m ab (1000 | 1000), Anlage 50 m vom Westrand: +# Welt-x = 1050 + x, Welt-y = 1000 + 2 · y. +FAKE = SimpleNamespace( + real={"sw": latlon(0, 0), "se": latlon(100, 0), "nw": latlon(0, 200)}, + game={"sw": (1000.0, 1000.0), "se": (1200.0, 1000.0), "ne": (1200.0, 1400.0), "nw": (1000.0, 1400.0)}, + scale_u=1.0, offset_u=50.0, +) + + +def test_reference_point_is_the_local_origin(): + assert math.dist(local_metres(REF_LAT, REF_LON), (0.0, 0.0)) < 1e-9 + + +def test_park_coords_run_west_to_east_and_south_to_north(): + s, t = ParkFit(FAKE).park_coords(25.0, 150.0) + assert (s, t) == pytest.approx((0.25, 0.75)) + + +def test_real_corners_and_centre_land_in_the_game_park(): + fit = ParkFit(FAKE) + assert fit.to_game(*latlon(0, 0)) == pytest.approx((1050.0, 1000.0)) + assert fit.to_game(*latlon(100, 0)) == pytest.approx((1150.0, 1000.0)) + assert fit.to_game(*latlon(0, 200)) == pytest.approx((1050.0, 1400.0)) + assert fit.to_game(*latlon(50, 100)) == pytest.approx((1100.0, 1200.0)) + + +def test_quads_cover_real_and_game_park(): + fit = ParkFit(FAKE) + assert fit.real_quad().area == pytest.approx(100 * 200) + assert fit.game_quad.area == pytest.approx(200 * 400) + + +def test_committed_fit_puts_tribune_and_hood_yard_within_one_and_a_half_metres(): + config = load_park(DATA / "flamingo-park.toml") + fit = ParkFit(config) + for anchor in config.anchors: + assert math.dist(fit.to_game(*anchor.real), anchor.game) <= 1.5, anchor.name + + +def test_committed_fit_keeps_the_south_edge_true_to_scale_and_offset_from_the_corner(): + config = load_park(DATA / "flamingo-park.toml") + fit = ParkFit(config) + sw, se, nw = (fit.to_game(*config.real[key]) for key in ("sw", "se", "nw")) + assert math.dist(sw, se) == pytest.approx(fit.real_width, abs=0.01) + assert fit.real_width == pytest.approx(200.57, abs=0.01) + assert math.dist(sw, config.game["sw"]) == pytest.approx(config.offset_u, abs=0.01) + assert math.dist(nw, config.game["nw"]) == pytest.approx(config.offset_u, abs=0.01) +``` + +- [ ] **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** + +In `tools/extract/leonida/park.py` den Importblock ergänzen, sodass er so aussieht: +```python +import math +import tomllib +from dataclasses import dataclass +from pathlib import Path + +from shapely.geometry import Polygon +``` +Und ans Dateiende anhängen: +```python +# --- Einpassung -------------------------------------------------------------------------------- + +REF_LAT, REF_LON = 25.7840, -80.1375 # Bezugspunkt der lokalen Meter (Spec, Befund) +M_PER_DEG_LAT = 111_320.0 +M_PER_DEG_LON = M_PER_DEG_LAT * math.cos(math.radians(REF_LAT)) + + +def local_metres(lat: float, lon: float) -> tuple[float, float]: + """Breite/Länge -> Meter östlich und nördlich des Bezugspunkts.""" + return (lon - REF_LON) * M_PER_DEG_LON, (lat - REF_LAT) * M_PER_DEG_LAT + + +class ParkFit: + """Echter Park -> Spielpark, affin über die Ecken SW, SE und NW (Variante D). + + Bilinear über alle vier Ecken folgte die Anlage der steileren Nordkante im Spiel und verfehlte den + Hood Yard um 4,2 m; affin bleibt sie im Nordosten bis zu 6,4 m vor der Nordstraße, der Rasen füllt + das ganze Parkpolygon trotzdem. + """ + + def __init__(self, config): + self.origin = local_metres(*config.real["sw"]) + se, nw = local_metres(*config.real["se"]), local_metres(*config.real["nw"]) + self.real_u = (se[0] - self.origin[0], se[1] - self.origin[1]) + self.real_v = (nw[0] - self.origin[0], nw[1] - self.origin[1]) + game = config.game + self.game_origin = game["sw"] + self.game_u = (game["se"][0] - game["sw"][0], game["se"][1] - game["sw"][1]) + self.game_v = (game["nw"][0] - game["sw"][0], game["nw"][1] - game["sw"][1]) + self.real_width = math.hypot(*self.real_u) + self.game_width = math.hypot(*self.game_u) + self.scale_u, self.offset_u = config.scale_u, config.offset_u + self.game_quad = Polygon([game["sw"], game["se"], game["ne"], game["nw"]]) + + def park_coords(self, x: float, y: float) -> tuple[float, float]: + """Lokale Meter -> (s, t): s von West nach Ost, t von Süd nach Nord, das echte Parkviereck ist [0, 1]².""" + dx, dy = x - self.origin[0], y - self.origin[1] + (ux, uy), (vx, vy) = self.real_u, self.real_v + det = ux * vy - uy * vx + return (dx * vy - dy * vx) / det, (ux * dy - uy * dx) / det + + def game_point(self, s: float, t: float) -> tuple[float, float]: + u = (self.offset_u + self.scale_u * s * self.real_width) / self.game_width + return (self.game_origin[0] + u * self.game_u[0] + t * self.game_v[0], + self.game_origin[1] + u * self.game_u[1] + t * self.game_v[1]) + + def local_to_game(self, x: float, y: float) -> tuple[float, float]: + return self.game_point(*self.park_coords(x, y)) + + def to_game(self, lat: float, lon: float) -> tuple[float, float]: + return self.local_to_game(*local_metres(lat, lon)) + + def real_quad(self) -> Polygon: + """Das echte Parkviereck in lokalen Metern.""" + (ox, oy), (ux, uy), (vx, vy) = self.origin, self.real_u, self.real_v + 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** + +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** + +```bash +git add tools/extract/leonida/park.py tools/extract/tests/test_park_fit.py +git commit -m "feat(extract): Flamingo Park affin in den Spielpark einpassen + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 5: Anlage aus OpenStreetMap bauen + +**Files:** +- Modify: `tools/extract/leonida/park.py` (Importe, neuer Abschnitt) +- Create: `tools/extract/tests/test_park_layout.py` + +**Interfaces:** +- Consumes: `ParkFit` (Task 4), `Keepout` und die Felder `keepouts`, `main_width`, `path_width`, `service_width`, `default_height` von `ParkConfig` (Task 3) +- Produces: + - `load_elements(path: Path) -> list[dict]` + - `keepout_polygon(keepout: Keepout) -> Polygon` + - `building_height(tags: dict, default: float) -> float | None` + - `@dataclass ParkLayout(lawn: Polygon, surfaces: list[tuple[Polygon, str]], paths: list[Polygon], service: list[Polygon], buildings: list[tuple[Polygon, float]], keepouts: list[Polygon], warnings: list[str])`; `surfaces` ohne Rasen, geordnet nach `SURFACE_ORDER = ("field", "court", "pool", "parking", "sand")` + - `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** + +`tools/extract/tests/test_park_layout.py`: +```python +from types import SimpleNamespace + +import pytest +from shapely.geometry import Point + +from leonida.park import (M_PER_DEG_LAT, M_PER_DEG_LON, REF_LAT, REF_LON, Keepout, ParkFit, build_layout, + building_height, keepout_polygon) + + +def latlon(x, y): + return REF_LAT + y / M_PER_DEG_LAT, REF_LON + x / M_PER_DEG_LON + + +def config(keepouts=()): + """Echter Park 100 × 200 m, Spielpark 200 × 400 m ab (1000 | 1000), Anlage 50 m vom Westrand: + Welt-x = 1050 + x, Welt-y = 1000 + 2 · y.""" + return SimpleNamespace( + real={"sw": latlon(0, 0), "se": latlon(100, 0), "nw": latlon(0, 200)}, + game={"sw": (1000.0, 1000.0), "se": (1200.0, 1000.0), "ne": (1200.0, 1400.0), "nw": (1000.0, 1400.0)}, + scale_u=1.0, offset_u=50.0, keepouts=tuple(keepouts), + main_width=6.0, path_width=3.0, service_width=6.0, default_height=4.0, + ) + + +def way(tags, *points, id=1): + return {"id": id, "tags": tags, "geometry": [list(latlon(x, y)) for x, y in points]} + + +def square(tags, x, y, size=10, id=1): + return way(tags, (x, y), (x + size, y), (x + size, y + size), (x, y + size), (x, y), id=id) + + +def layout(elements, keepouts=()): + cfg = config(keepouts) + return build_layout(elements, cfg, ParkFit(cfg)) + + +def test_footway_is_three_metres_wide_and_a_named_main_path_six(): + (plain,) = layout([way({"highway": "footway"}, (20, 100), (80, 100))]).paths + assert plain.contains(Point(1100, 1201.4)) and not plain.contains(Point(1100, 1201.6)) + (main,) = layout([way({"highway": "footway", "name": "Flamingo Park Path (13th St)"}, (20, 100), (80, 100))]).paths + assert main.contains(Point(1100, 1202.9)) and not main.contains(Point(1100, 1203.1)) + + +def test_crossing_paths_become_one_surface(): + result = layout([way({"highway": "footway"}, (20, 100), (80, 100), id=1), + way({"highway": "path"}, (50, 60), (50, 140), id=2)]) + assert len(result.paths) == 1 + + +def test_path_is_cut_at_the_real_park_edge(): + (path,) = layout([way({"highway": "footway"}, (50, 150), (50, 260))]).paths + assert path.bounds[3] <= 1400 + 1.5 + 1e-6 + + +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)) + + +def test_path_along_the_edge_is_not_stretched(): + (path,) = layout([way({"highway": "footway"}, (3, 50), (3, 150))]).paths + assert not path.contains(Point(1010, 1200)) + assert path.bounds[1] == pytest.approx(1100 - 1.5) + + +def test_path_ending_far_from_the_edge_stays_as_it_is(): + (path,) = layout([way({"highway": "footway"}, (40, 100), (20, 100))]).paths + assert path.bounds[0] == pytest.approx(1070 - 1.5) + + +def test_surfaces_are_sorted_by_kind_and_dog_parks_stay_lawn(): + elements = [ + square({"leisure": "playground"}, 10, 10, id=1), + square({"leisure": "pitch", "sport": "tennis"}, 30, 10, id=2), + square({"leisure": "pitch", "sport": "baseball"}, 50, 10, id=3), + square({"amenity": "parking"}, 70, 10, id=4), + square({"leisure": "swimming_pool"}, 10, 40, id=5), + square({"leisure": "sports_centre"}, 30, 40, id=6), + square({"leisure": "pitch", "sport": "soccer;american_football"}, 50, 40, id=7), + square({"leisure": "dog_park"}, 70, 40, id=8), + ] + assert [kind for _, kind in layout(elements).surfaces] == ["field", "field", "court", "court", "pool", "parking", "sand"] + + +def test_features_centred_outside_the_real_park_are_left_out(): + assert layout([square({"leisure": "pitch", "sport": "soccer"}, 40, 195, size=20)]).surfaces == [] + + +def test_lawn_is_the_whole_game_park(): + assert layout([]).lawn.area == pytest.approx(200 * 400) + + +def test_buildings_take_their_height_or_the_default_and_roofs_without_walls_are_dropped(): + result = layout([ + square({"building": "yes", "height": "8.5"}, 10, 10, id=1), + square({"building": "yes"}, 30, 10, id=2), + square({"building": "yes", "height": "0.0"}, 50, 10, id=3), + square({"building": "yes", "leisure": "sports_centre"}, 70, 10, id=4), + ]) + assert sorted(height for _, height in result.buildings) == [4.0, 4.0, 8.5] + assert result.surfaces == [] + + +def test_building_height_reads_plain_numbers_and_units(): + assert building_height({"height": "10.4"}, 4.0) == 10.4 + assert building_height({"height": "12 m"}, 4.0) == 12.0 + assert building_height({"height": "hoch"}, 4.0) == 4.0 + assert building_height({}, 4.0) == 4.0 + assert building_height({"height": "0"}, 4.0) is None + + +def test_keepout_polygon_turns_like_places_js(): + plain = keepout_polygon(Keepout("ort", (100.0, 200.0), 0.0, (-10.0, 20.0, -5.0, 8.0))) + assert plain.bounds == pytest.approx((90.0, 192.0, 120.0, 205.0)) + turned = keepout_polygon(Keepout("ort", (0.0, 0.0), 90.0, (0.0, 10.0, -1.0, 1.0))) + assert turned.bounds == pytest.approx((-1.0, 0.0, 1.0, 10.0)) # +x zeigt nach 90° nach Norden + + +def test_keepouts_cut_fields_and_paths_keep_buildings_out_and_warn(): + keepout = Keepout("ort", (1100.0, 1100.0), 0.0, (-10.0, 10.0, -10.0, 10.0)) # Welt x 1090–1110, y 1090–1110 + result = layout([ + square({"leisure": "pitch", "sport": "baseball"}, 40, 40, size=20, id=1), # Welt x 1090–1110, y 1080–1120 + way({"highway": "footway"}, (50, 30), (50, 70), id=2), # Welt x 1100, y 1060–1140 + square({"building": "yes"}, 55, 48, size=4, id=3), # Welt x 1105–1109, y 1096–1104 + ], keepouts=[keepout]) + hole = keepout_polygon(keepout) + assert all(poly.intersection(hole).area < 1e-6 for poly, _ in result.surfaces) + assert sum(poly.area for poly, _ in result.surfaces) == pytest.approx(20 * 40 - 20 * 20) + assert all(poly.intersection(hole).area < 1e-6 for poly in result.paths) + assert result.buildings == [] + assert result.warnings == ["Parkweg schneidet die Sperrfläche ort"] + + +def test_service_ways_become_six_metre_service_surfaces(): + result = layout([way({"highway": "service"}, (20, 20), (80, 20))]) + (service,) = result.service + assert service.contains(Point(1100, 1042.9)) and not service.contains(Point(1100, 1043.1)) + assert result.paths == [] +``` + +- [ ] **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** + +Importblock von `park.py` so ersetzen: +```python +import json +import math +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + +from shapely.geometry import LineString, Polygon +from shapely.ops import unary_union +``` +Ans Dateiende anhängen: +```python +# --- Anlage aus OpenStreetMap ------------------------------------------------------------------ + +FIELD_SPORTS = {"baseball", "soccer"} +COURT_SPORTS = {"tennis", "basketball", "racquet"} +PATH_HIGHWAYS = {"footway", "path", "pedestrian", "steps", "cycleway"} +MAIN_PATHS = {"Flamingo Park Path (13th St)", "Flamingo Park Path (Jefferson Ave)"} +SURFACE_ORDER = ("field", "court", "pool", "parking", "sand") +EDGE_REACH = 5.0 # m: so nah am echten West- oder Ostrand endende Wege laufen bis zur Straßenkante +EXTEND_MAX = 90.0 # m: der Rasenstreifen im Osten ist 37 m breit, schräg ankommende Wege brauchen bis zu doppelt so viel + + +@dataclass +class ParkLayout: + lawn: Polygon # das ganze Parkpolygon im Spiel + surfaces: list[tuple[Polygon, str]] # Felder, Plätze, Pool, Parkplatz, Spielplatz, ohne Rasen + paths: list[Polygon] + service: list[Polygon] + buildings: list[tuple[Polygon, float]] + keepouts: list[Polygon] + warnings: list[str] = field(default_factory=list) + + +def load_elements(path: Path) -> list[dict]: + return json.loads(path.read_text(encoding="utf-8"))["elements"] + + +def keepout_polygon(keepout: Keepout) -> Polygon: + """Außenmaße eines Orts in Weltkoordinaten, gedreht wie modelToWorld in tests/placesSite.test.js.""" + r = math.radians(keepout.rotation) + cos, sin = math.cos(r), math.sin(r) + min_x, max_x, min_z, max_z = keepout.bounds + ax, ay = keepout.at + corners = [(min_x, min_z), (max_x, min_z), (max_x, max_z), (min_x, max_z)] + return Polygon([(ax + x * cos + z * sin, ay + x * sin - z * cos) for x, z in corners]) + + +def building_height(tags: dict, default: float) -> float | None: + """Höhe aus OSM, sonst default; None für Dächer ohne Wände (height = 0).""" + raw = tags.get("height") + if raw is None: + return default + try: + height = float(str(raw).split()[0]) + except (ValueError, IndexError): + return default + return None if height <= 0 else height + + +def build_layout(elements: list[dict], config, fit: ParkFit) -> ParkLayout: + real_quad = fit.real_quad() + keepouts = [keepout_polygon(k) for k in config.keepouts] + blocked = unary_union(keepouts) + surfaces = {kind: [] for kind in SURFACE_ORDER} + footways, service, buildings = [], [], [] + for element in elements: + tags, coords = element["tags"], element["geometry"] + highway = tags.get("highway") + if highway in PATH_HIGHWAYS or highway == "service": + if len(coords) < 2: + continue + width = (config.service_width if highway == "service" + else config.main_width if tags.get("name") in MAIN_PATHS else config.path_width) + real_line = LineString([local_metres(lat, lon) for lat, lon in coords]) + for piece in _pieces(real_line.intersection(real_quad)): + if piece.geom_type != "LineString" or piece.length == 0: + continue + game = _extend(fit, piece, [fit.local_to_game(x, y) for x, y in piece.coords]) + (service if highway == "service" else footways).append(LineString(game).buffer(width / 2)) + continue + if len(coords) < 4: + continue + real_poly = Polygon([local_metres(lat, lon) for lat, lon in coords]) + if not real_poly.is_valid or not real_quad.contains(real_poly.centroid): + continue + game_poly = Polygon([fit.local_to_game(x, y) for x, y in real_poly.exterior.coords]) + if "building" in tags: + height = building_height(tags, config.default_height) + if height is not None and not game_poly.intersects(blocked): + buildings.append((game_poly, height)) + continue + kind = _surface_kind(tags) + if kind is not None: + surfaces[kind].extend(_polygons(game_poly.difference(blocked))) + + warnings, paths = [], [] + if footways: + union = unary_union(footways) + warnings = [f"Parkweg schneidet die Sperrfläche {k.name}" + for k, polygon in zip(config.keepouts, keepouts) if union.intersects(polygon)] + paths = _polygons(union.difference(blocked)) + return ParkLayout( + lawn=fit.game_quad, + surfaces=[(poly, kind) for kind in SURFACE_ORDER for poly in surfaces[kind]], + paths=paths, + service=_polygons(unary_union(service)) if service else [], + buildings=buildings, + keepouts=keepouts, + warnings=warnings, + ) + + +def _surface_kind(tags: dict) -> str | None: + sports = set(tags.get("sport", "").split(";")) + leisure = tags.get("leisure") + if leisure == "pitch": + return "field" if sports & FIELD_SPORTS else "court" if sports & COURT_SPORTS else None + if leisure == "sports_centre": + return "court" + if leisure == "swimming_pool": + return "pool" + if leisure == "playground": + return "sand" + if tags.get("amenity") == "parking": + return "parking" + return None + + +def _extend(fit: ParkFit, real_line: LineString, coords: list) -> list: + """Enden höchstens EDGE_REACH vor dem echten West- oder Ostrand laufen nach außen bis zur Straßenkante.""" + coords = list(coords) + ux, uy = fit.game_u[0] / fit.game_width, fit.game_u[1] / fit.game_width + for end, inner in ((0, 1), (-1, -2)): + if len(coords) < 2: + return coords + s, _ = fit.park_coords(*real_line.coords[end]) + west, east = s * fit.real_width, (1 - s) * fit.real_width + side = -1 if west <= EDGE_REACH else 1 if east <= EDGE_REACH else 0 + if side == 0: + continue + (x0, y0), (x1, y1) = coords[inner], coords[end] + length = math.hypot(x1 - x0, y1 - y0) + if length == 0: + continue + dx, dy = (x1 - x0) / length, (y1 - y0) / length + if (dx * ux + dy * uy) * side < 0.5: # läuft längs der Kante statt auf sie zu + continue + ray = LineString([(x1, y1), (x1 + dx * EXTEND_MAX, y1 + dy * EXTEND_MAX)]) + hits = _points(ray.intersection(fit.game_quad.exterior)) + if hits: + coords[end] = min(hits, key=lambda p: math.hypot(p[0] - x1, p[1] - y1)) + return coords + + +def _pieces(geometry) -> list: + if geometry.is_empty: + return [] + return list(getattr(geometry, "geoms", [geometry])) + + +def _polygons(geometry, min_area: float = 1.0) -> list[Polygon]: + return [g for g in _pieces(geometry) if g.geom_type == "Polygon" and g.area >= min_area] + + +def _points(geometry) -> list[tuple[float, float]]: + out = [] + for g in _pieces(geometry): + if g.geom_type == "Point": + out.append((g.x, g.y)) + elif g.geom_type in ("LineString", "LinearRing"): + out.extend(g.coords) + return out +``` + +- [ ] **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** + +Run: +```bash +cd tools/extract && PYTHONUTF8=1 uv run python - <<'EOF' +from pathlib import Path +from collections import Counter +from leonida.park import ParkFit, build_layout, load_elements, load_park +config = load_park(Path("data/flamingo-park.toml")) +result = build_layout(load_elements(config.osm_path), config, ParkFit(config)) +print("Flächen", Counter(kind for _, kind in result.surfaces), "Wegflächen", len(result.paths), + "Gebäude", len(result.buildings), "Zufahrt", len(result.service), "Warnungen", result.warnings) +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** + +```bash +git add tools/extract/leonida/park.py tools/extract/tests/test_park_layout.py +git commit -m "feat(extract): Parkanlage aus OpenStreetMap mit Wegen, Flächen und Gebäuden + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 6: Jefferson Avenue + +**Files:** +- Modify: `tools/extract/leonida/park.py` (Importe, neuer Abschnitt) +- Create: `tools/extract/tests/test_park_street.py` + +**Interfaces:** +- Consumes: `Street` (Task 3), `_pieces`, `_polygons` (Task 5), `PALM_KERB` aus `leonida/heights.py` +- Produces: + - `MIN_CENTERLINE = 15.0`, `MIN_ROAD_AREA = 20.0` + - `@dataclass World(land: list[Polygon], surfaces: list[tuple[Polygon, str]], roads: list[tuple[Polygon, bool]], centerlines: list[LineString], buildings: list[tuple[Polygon, dict, bool]], palms: list[tuple[float, float]], paths: list[Polygon] = [])` — `buildings` als (Grundriss, {"height", "source"}, spekulativ) wie `chunk_payload` sie erwartet + - `apply_street(world: World, street: Street) -> World` + - Hilfe `_lines(geometry, min_length=MIN_CENTERLINE) -> list[LineString]` + +- [ ] **Step 1: Failing test schreiben** + +`tools/extract/tests/test_park_street.py`: +```python +from shapely.geometry import LineString, Point, box +from shapely.ops import unary_union + +from leonida.park import Street, World, apply_street + +STREET = Street(start=(150.0, 660.0), end=(150.0, 320.0), width=12.0, + remove_buildings=((155.0, 615.0),), + alley_cut=((90.0, 639.0), (110.0, 639.0), (110.0, 341.0), (90.0, 341.0))) + + +def world(): + """Querstraßen bei y 640–660 und 320–340, dazwischen eine Gasse bei x 95–105.""" + removed = box(140, 600, 170, 630) + return World( + land=[box(-100, 200, 300, 800)], + surfaces=[(box(0, 340, 95, 640), "urban"), (box(105, 340, 200, 640).difference(removed), "urban")], + roads=[(unary_union([box(0, 640, 200, 660), box(0, 320, 200, 340), box(95, 340, 105, 640)]), False)], + centerlines=[LineString([(0, 650), (200, 650)]), LineString([(0, 330), (200, 330)]), + LineString([(100, 650), (100, 330)])], + buildings=[(removed, {"height": 20.9, "source": "district"}, False), + (box(20, 400, 60, 450), {"height": 10.8, "source": "district"}, False), + (box(84, 450, 93, 470), {"height": 8.0, "source": "district"}, False)], + palms=[(150.0, 500.0), (60.0, 500.0), (140.0, 500.0)], + ) + + +def on_road(result, x, y): + return any(poly.contains(Point(x, y)) for poly, _ in result.roads) + + +def test_buildings_under_the_street_go_and_the_others_stay(): + result = apply_street(world(), STREET) + assert [b[0].bounds for b in result.buildings] == [(20.0, 400.0, 60.0, 450.0), (84.0, 450.0, 93.0, 470.0)] + + +def test_alley_is_cut_and_the_straight_street_joins_both_cross_streets(): + result = apply_street(world(), STREET) + assert not on_road(result, 100, 500) + assert on_road(result, 150, 500) and not on_road(result, 143, 500) + assert on_road(result, 100, 650) and on_road(result, 100, 330) + assert on_road(result, 150, 650) and on_road(result, 150, 330) + + +def test_alley_centerline_and_short_stubs_go_and_the_street_gets_its_own(): + result = apply_street(world(), STREET) + assert all(line.distance(Point(100, 500)) > 40 for line in result.centerlines) + assert LineString([(150, 660), (150, 320)]) in result.centerlines + assert all(line.length >= 15 for line in result.centerlines) + + +def test_freed_ground_becomes_urban_but_not_under_the_street_buildings_or_old_urban(): + result = apply_street(world(), STREET) + new = [poly for poly, kind in result.surfaces[2:] if kind == "urban"] + assert any(poly.contains(Point(100, 500)) for poly in new) # alte Gasse + assert any(poly.contains(Point(165, 615)) for poly in new) # alter Grundriss neben der Straße + assert not any(poly.contains(Point(150, 615)) for poly in new) # unter der neuen Straße + assert not any(poly.contains(Point(91, 460)) for poly in new) # stehendes Gebäude + assert not any(poly.contains(Point(92, 500)) for poly in new) # war schon Stadtfläche + + +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** + +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** + +Importblock von `park.py` so ersetzen: +```python +import json +import math +import tomllib +from dataclasses import dataclass, field, replace +from pathlib import Path + +from shapely.geometry import LineString, Point, Polygon +from shapely.ops import unary_union + +from .heights import PALM_KERB +``` +Ans Dateiende anhängen: +```python +# --- Welt verändern ---------------------------------------------------------------------------- + +MIN_CENTERLINE = 15.0 # m, wie centerlines() in vectors.py: kürzere Stummel setzten Laternen auf die Kreuzung +MIN_ROAD_AREA = 20.0 # m², wie road_polygons() in vectors.py + + +@dataclass +class World: + """Weltgeometrie zwischen Extraktion und Chunk-Ausgabe, in Welt-x/Welt-y.""" + land: list[Polygon] + surfaces: list[tuple[Polygon, str]] + roads: list[tuple[Polygon, bool]] + centerlines: list[LineString] + buildings: list[tuple[Polygon, dict, bool]] # Grundriss, {"height", "source"}, spekulativ + palms: list[tuple[float, float]] + paths: list[Polygon] = field(default_factory=list) + + +def _lines(geometry, min_length: float = MIN_CENTERLINE) -> list[LineString]: + return [g for g in _pieces(geometry) if g.geom_type == "LineString" and g.length >= min_length] + + +def apply_street(world: World, street: Street) -> World: + """Gebäude und Gasse weg, gerade Straße mit Mittellinie, frei gewordene Fläche wird Stadtfläche.""" + cut = Polygon(street.alley_cut) + + def hit(building) -> bool: + return any(building[0].contains(Point(p)) for p in street.remove_buildings) + + removed = [b[0] for b in world.buildings if hit(b)] + buildings = [b for b in world.buildings if not hit(b)] + line = LineString([street.start, street.end]) + corridor = line.buffer(street.width / 2, cap_style="flat") + + kept, joined = [], [corridor] + for poly, bridge in world.roads: + rest = poly.difference(cut) if poly.intersects(cut) else poly + for piece in _polygons(rest, MIN_ROAD_AREA): + if not bridge and piece.intersects(corridor): + joined.append(piece) + else: + kept.append((piece, bridge)) + roads = kept + [(piece, False) for piece in _polygons(unary_union(joined), MIN_ROAD_AREA)] + + centerlines = [piece for l in world.centerlines + for piece in (_lines(l.difference(cut)) if l.intersects(cut) else [l])] + [line] + + freed = unary_union(removed + [cut]).difference(corridor) + covered = [b[0] for b in buildings if b[0].intersects(freed)] + \ + [poly for poly, kind in world.surfaces if kind == "urban" and poly.intersects(freed)] + if covered: + freed = freed.difference(unary_union(covered)) + surfaces = world.surfaces + [(poly, "urban") for poly in _polygons(freed)] + + palms = [p for p in world.palms if corridor.distance(Point(p)) >= PALM_KERB] + return replace(world, roads=roads, centerlines=centerlines, buildings=buildings, + surfaces=surfaces, palms=palms) +``` + +- [ ] **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** + +```bash +git add tools/extract/leonida/park.py tools/extract/tests/test_park_street.py +git commit -m "feat(extract): Jefferson Avenue als gerade Straße statt der Gasse + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 7: Park in die Welt einsetzen + +**Files:** +- Modify: `tools/extract/leonida/park.py` (Import, zwei Funktionen) +- Create: `tools/extract/tests/test_park_apply.py` + +**Interfaces:** +- Consumes: `World`, `_lines`, `MIN_ROAD_AREA` (Task 6), `ParkLayout`, `_polygons` (Task 5), `ParkError` (Task 3), `palm_points` und `PALM_CLEARANCE` aus `leonida/heights.py` +- Produces: + - `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** + +`tools/extract/tests/test_park_apply.py`: +```python +from types import SimpleNamespace + +import pytest +from shapely.geometry import LineString, Point, box + +from leonida.heights import PALM_CLEARANCE +from leonida.park import ParkError, ParkLayout, World, apply_park, check_corners + +QUAD = box(0, 0, 100, 200) +SERVICE = box(60, 0, 66, 30) + + +def layout(): + return ParkLayout(lawn=QUAD, surfaces=[(box(10, 10, 40, 40), "field")], paths=[box(0, 148, 100, 152)], + service=[SERVICE], buildings=[(box(70, 60, 80, 70), 5.0)], keepouts=[box(60, 160, 90, 190)]) + + +def world(): + return World( + land=[box(-100, -100, 200, 300)], + surfaces=[(box(-30, -30, 130, 230), "urban")], + roads=[(box(-50, 95, 150, 105), False), (box(-20, -20, 0, 220), False)], + centerlines=[LineString([(-50, 100), (150, 100)]), LineString([(-5, 150), (10, 150)])], + buildings=[(box(20, 60, 30, 70), {"height": 9.0, "source": "district"}, False), # ganz im Park + (box(-6, 80, 4, 90), {"height": 9.0, "source": "district"}, False), # 40 % im Park + (box(-4, 120, 6, 130), {"height": 9.0, "source": "district"}, False)], # 60 % im Park + palms=[(50.0, 50.0), (-10.0, 50.0)], + ) + + +def test_old_roads_and_centerlines_end_at_the_park_edge(): + result = apply_park(world(), layout(), seed=1) + assert all(poly.intersection(QUAD).area < 1e-6 for poly, _ in result.roads if not poly.equals(SERVICE)) + assert any(poly.contains(Point(-30, 100)) for poly, _ in result.roads) + assert any(poly.contains(Point(120, 100)) for poly, _ in result.roads) + assert sorted(line.bounds for line in result.centerlines) == [(-50.0, 100.0, 0.0, 100.0), (100.0, 100.0, 150.0, 100.0)] + + +def test_old_surfaces_buildings_and_palms_in_the_park_go(): + result = apply_park(world(), layout(), seed=1) + assert all(poly.intersection(QUAD).area < 1e-6 for poly, kind in result.surfaces if kind == "urban") + assert sorted((b[0].bounds, b[1]["source"]) for b in result.buildings) == [ + ((-6.0, 80.0, 4.0, 90.0), "district"), ((70.0, 60.0, 80.0, 70.0), "osm")] + assert (-10.0, 50.0) in result.palms and (50.0, 50.0) not in result.palms + + +def test_layout_goes_in_with_the_lawn_before_the_fields(): + result = apply_park(world(), layout(), seed=1) + kinds = [kind for _, kind in result.surfaces] + assert kinds.index("park") < kinds.index("field") + assert any(kind == "park" and poly.equals(QUAD) for poly, kind in result.surfaces) + assert result.paths == layout().paths + assert any(poly.equals(SERVICE) and not bridge for poly, bridge in result.roads) + + +def test_new_palms_stand_on_lawn_away_from_roads_paths_buildings_and_keepouts(): + result = apply_park(world(), layout(), seed=1) + new = [Point(p) for p in result.palms if p != (-10.0, 50.0)] + assert len(new) > 5 + for palm in new: + assert QUAD.contains(palm) and not box(10, 10, 40, 40).contains(palm) + assert palm.distance(box(0, 148, 100, 152)) > PALM_CLEARANCE + assert palm.distance(box(60, 160, 90, 190)) > PALM_CLEARANCE + assert all(palm.distance(poly) > PALM_CLEARANCE for poly, _ in result.roads) + assert all(palm.distance(b[0]) > PALM_CLEARANCE for b in result.buildings) + + +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(): + 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)}) + with pytest.raises(ParkError, match="Spielecke nw"): + check_corners(far, roads) +``` + +- [ ] **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** + +In `park.py` die Zeile `from .heights import PALM_KERB` ersetzen durch: +```python +from .heights import PALM_CLEARANCE, PALM_KERB, palm_points +``` +Ans Dateiende anhängen: +```python +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.""" + 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") + + +def apply_park(world: World, layout: ParkLayout, seed: int) -> World: + """Alte Straßen, Beläge, Gebäude und Palmen im Park weg, neue Anlage hinein, Palmen neu gesetzt.""" + quad = layout.lawn + roads = [(piece, bridge) for poly, bridge in world.roads + for piece in (_polygons(poly.difference(quad), MIN_ROAD_AREA) if poly.intersects(quad) else [poly])] + centerlines = [piece for line in world.centerlines + for piece in (_lines(line.difference(quad)) if line.intersects(quad) else [line])] + surfaces = [(piece, kind) for poly, kind in world.surfaces + for piece in (_polygons(poly.difference(quad)) if poly.intersects(quad) else [poly])] + buildings = [b for b in world.buildings + if not b[0].intersects(quad) or b[0].intersection(quad).area <= 0.5 * b[0].area] + palms = [p for p in world.palms if not quad.contains(Point(p))] + + roads += [(poly, False) for poly in layout.service] + surfaces += [(quad, "park")] + layout.surfaces + buildings += [(poly, {"height": height, "source": "osm"}, False) for poly, height in layout.buildings] + + reach = quad.buffer(PALM_CLEARANCE + 1) + blocked = unary_union([poly for poly, _ in roads if poly.intersects(reach)] + + [b[0] for b in buildings if b[0].intersects(reach)] + + layout.paths + layout.keepouts).intersection(reach) + lawn = quad.difference(unary_union([poly for poly, _ in layout.surfaces])) + # Abstand zu einer leeren Geometrie ist in shapely NaN, und NaN > 6 verwürfe jede Palme. + palms += palm_points([], _polygons(lawn), [], [] if blocked.is_empty else [blocked], [], [], seed) + return replace(world, roads=roads, centerlines=centerlines, surfaces=surfaces, + buildings=buildings, palms=palms, paths=world.paths + layout.paths) +``` + +- [ ] **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** + +```bash +git add tools/extract/leonida/park.py tools/extract/tests/test_park_apply.py +git commit -m "feat(extract): Parkanlage in die Welt einsetzen, Palmen neu setzen, Ecken prüfen + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 8: In `run.py` und die Chunk-Ausgabe einbinden + +**Files:** +- Modify: `tools/extract/leonida/park.py` (Gesamtablauf) +- Modify: `tools/extract/leonida/emit.py:42-55` +- Modify: `tools/extract/run.py` +- Modify: `tools/extract/tests/test_emit.py:21-24` +- Modify: `tools/extract/tests/test_park_apply.py` (Integrationstest) + +**Interfaces:** +- Consumes: alles aus Task 3 bis 7 +- Produces: + - `apply_flamingo_park(world: World, config_path: Path, seed: int) -> tuple[World, list[str]]` + - `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** + +In `tools/extract/tests/test_emit.py` den Test `test_payload_matches_schema` ersetzen und einen Test anhängen: +```python +def test_payload_matches_schema(): + payload = chunk_payload(2, 3, land=[], surfaces=[], roads=[], centerlines=[], buildings=[], palms=[]) + assert set(payload) == {"chunk", "origin", "land", "surfaces", "roads", "centerlines", "buildings", "palms", "paths"} + assert payload["chunk"] == [2, 3] + +def test_paths_are_chunk_local_rings_like_roads(): + path = Polygon([(-990, 2490), (-980, 2490), (-980, 2480), (-990, 2480)]) + payload = chunk_payload(0, 0, land=[], surfaces=[], roads=[], centerlines=[], buildings=[], palms=[], paths=[path]) + (entry,) = payload["paths"] + assert set(entry) == {"poly"} + ring = entry["poly"][0] + assert (min(x for x, _ in ring), max(x for x, _ in ring)) == (10, 20) + assert (min(z for _, z in ring), max(z for _, z in ring)) == (10, 20) +``` +In `tools/extract/tests/test_park_apply.py` den Importblock am Dateianfang ersetzen: +```python +from pathlib import Path +from types import SimpleNamespace + +import pytest +from shapely.geometry import LineString, Point, Polygon, box + +from leonida.heights import PALM_CLEARANCE +from leonida.park import ParkError, ParkLayout, World, apply_flamingo_park, apply_park, check_corners, load_park + +DATA = Path(__file__).resolve().parents[1] / "data" +``` +Und ans Dateiende anhängen: +```python +def test_flamingo_park_goes_into_a_world_with_roads_around_the_park(): + config = load_park(DATA / "flamingo-park.toml") + park = Polygon([config.game[key] for key in ("sw", "se", "ne", "nw")]) + ring = park.buffer(15, join_style="mitre").difference(park) + start = World(land=[park.buffer(400)], surfaces=[], roads=[(ring, False)], centerlines=[], buildings=[], palms=[]) + result, messages = apply_flamingo_park(start, DATA / "flamingo-park.toml", seed=20260912) + assert {"park", "field", "court", "pool", "parking", "sand"} <= {kind for _, kind in result.surfaces} + assert len([b for b in result.buildings if b[1]["source"] == "osm"]) == 11 + assert result.paths and result.palms + assert LineString([config.street.start, config.street.end]) in result.centerlines + assert not any(message.startswith("Warnung") for message in messages) + assert any(message.startswith("Flamingo Park:") for message in messages) +``` + +- [ ] **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** + +In `tools/extract/leonida/emit.py` die Funktion `chunk_payload` ersetzen: +```python +def chunk_payload(i, j, *, land, surfaces, roads, centerlines, buildings, palms, paths=()) -> dict: + 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], + "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], + # 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], + } +``` + +- [ ] **Step 4: Gesamtablauf in `park.py`** + +Ans Dateiende von `park.py` anhängen: +```python +def apply_flamingo_park(world: World, config_path: Path, seed: int) -> tuple[World, list[str]]: + """Alles für Flamingo Park in der Reihenfolge der Spec: Ecken prüfen, Jefferson Avenue, Park.""" + config = load_park(config_path) + check_corners(config, world.roads) + world = apply_street(world, config.street) + fit = ParkFit(config) + layout = build_layout(load_elements(config.osm_path), config, fit) + world = apply_park(world, layout, seed) + messages = [f"Warnung: {warning}" for warning in layout.warnings] + messages.append(f"Flamingo Park: {len(layout.paths)} Wegflächen, {len(layout.surfaces)} Flächen, " + f"{len(layout.buildings)} Gebäude, Jefferson Avenue {config.street.width:g} m breit") + return world, messages +``` + +- [ ] **Step 5: `run.py` umstellen** + +In `tools/extract/run.py`: + +1. Import ergänzen (nach `from leonida.heights import …`): +```python +from leonida.park import World, apply_flamingo_park +``` +2. Nach `OUT = …` eine Konstante ergänzen: +```python +PARK = Path("data/flamingo-park.toml") +``` +3. Den Block ab `payloads = []` bis zum Ende von `write_chunks(…)` ersetzen durch: +```python + world = World(land=land, surfaces=surfaces, roads=roads, centerlines=lines, + buildings=[(p, h, s) for (p, s), h in zip(buildings, heights)], palms=palms) + world, messages = apply_flamingo_park(world, PARK, SEED) + for message in messages: + print(message) + + payloads = [] + for j in range(12): + for i in range(12): + payloads.append(chunk_payload( + i, j, + land=list(split_by_chunk_all(world.land, i, j)), + surfaces=[(part, kind) for (p, kind) in world.surfaces for part in split_by_chunk_all([p], i, j)], + roads=[(part, b) for (p, b) in world.roads for part in split_by_chunk_all([p], i, j)], + centerlines=[ln for ln in world.centerlines if ln.intersects(chunk_box_of(i, j))], + buildings=[(p, h, s) for (p, h, s) in world.buildings if p.intersects(chunk_box_of(i, j))], + palms=[pt for pt in world.palms if in_chunk(pt, i, j)], + paths=[part for p in world.paths for part in split_by_chunk_all([p], i, j)], + )) + write_debug_png(Path("debug"), mosaic, payloads) + write_chunks(OUT, payloads, { + "slice": "vice-a", "source_commit": COMMIT, "seed": SEED, + "chunk_size": 250, "chunks": [p["chunk"] for p in payloads], + "class_shares": shares, + "attribution": "Kartenkacheln: GTA VI Map (CC BY 4.0) — https://map.gtadb.org, YANIS v15. " + "Wahrzeichen: rolux/gtamaplib-vc (Unlicense). " + "Flamingo Park: © OpenStreetMap-Mitwirkende (ODbL).", + }) +``` +Die Zeile `print(render_map(OUT, OUT), "->", OUT)` bleibt danach unverändert stehen. + +- [ ] **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** + +```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 +git commit -m "feat(extract): Flamingo Park in run.py, Parkwege in der Chunk-Ausgabe + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 9: Minimap zeichnet Wege und neue Flächen + +**Files:** +- Modify: `tools/extract/overview.py:25-35` (Farben), `_paint_layers`, `render`, `render_map` +- Modify: `tools/extract/tests/test_overview.py` (neuer Test) + +**Interfaces:** +- 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** + +An `tools/extract/tests/test_overview.py` anhängen: +```python +def test_map_picture_draws_paths_and_the_new_surface_kinds(tmp_path): + src = tmp_path / "chunks" + src.mkdir() + chunk = { + "chunk": [0, 0], + "origin": [SLICE["x0"], -SLICE["y1"]], + "land": [[_ring(0, 0, 250, 250)]], + "surfaces": [{"poly": [_ring(0, 0, 40, 40)], "kind": "field"}, + {"poly": [_ring(50, 0, 90, 40)], "kind": "court"}, + {"poly": [_ring(0, 50, 40, 90)], "kind": "pool"}, + {"poly": [_ring(50, 50, 90, 90)], "kind": "parking"}], + "roads": [], + "centerlines": [], + "buildings": [], + "palms": [], + "paths": [{"poly": [_ring(100, 0, 104, 250)]}], + } + (src / "chunk_0_0.json").write_text(json.dumps(chunk)) + (src / "manifest.json").write_text('{"slice": "vice-a"}\n') + dst = tmp_path / "out" + overview.render_map(src, dst) + picture = Image.open(dst / "map.png") + assert picture.getpixel((20, 20)) == overview.SURFACE["field"] + assert picture.getpixel((70, 20)) == overview.SURFACE["court"] + assert picture.getpixel((20, 70)) == overview.SURFACE["pool"] + assert picture.getpixel((70, 70)) == overview.ROAD # Parkplatz in Straßenfarbe + assert picture.getpixel((102, 125)) == overview.PATH + assert Image.open(dst / "map-walkable.png").getpixel((25, 31)) == overview.WALKABLE # Weg auf Land +``` + +- [ ] **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** + +Die Farbkonstanten so ordnen und ergänzen (ROAD vor SURFACE): +```python +WATER = (36, 72, 104) +LAND = (150, 162, 120) +ROAD = (74, 74, 78) +# field, court, pool und parking: Flamingo Park nach OpenStreetMap (docs/superpowers/specs/2026-09-15-flamingo-park-design.md) +SURFACE = {"sand": (226, 210, 150), "park": (120, 160, 90), "urban": (176, 176, 172), + "field": (96, 176, 78), "court": (92, 138, 118), "pool": (70, 150, 220), "parking": ROAD} +PATH = (226, 216, 190) +BRIDGE = (196, 92, 92) +``` +In `_paint_layers` die Signatur um `path=None` erweitern und direkt nach der Schleife über `chunk["surfaces"]` einfügen: +```python + if path is not None: + for chunk, ox, oy in placed: + for item in chunk.get("paths", []): + _draw(img, item["poly"], ox, oy, path, scale=scale) +``` +Neue Signatur: +```python +def _paint_layers(img, chunks, scale, *, land, surface, road, bridge, building, outline=None, palm=None, path=None) -> dict: +``` +In `render` den Aufruf um `path=PATH` ergänzen: +```python + stats = _paint_layers(img, _load_chunks(out_dir), SCALE, land=LAND, surface=SURFACE.__getitem__, + road=ROAD, bridge=BRIDGE, building=_building_shade, outline=OUTLINE, palm=PALM, path=PATH) +``` +In `render_map` ebenso: +```python + _paint_layers(picture, chunks, MAP_SCALE, land=LAND, surface=SURFACE.__getitem__, road=ROAD, + bridge=BRIDGE, building=_building_shade, outline=OUTLINE, path=PATH) +``` +`_walkable_mask` bleibt unverändert: ohne `path` zählen Wege dort nicht, wie Beläge. + +- [ ] **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** + +```bash +git add tools/extract/overview.py tools/extract/tests/test_overview.py +git commit -m "feat(extract): Minimap zeichnet Parkwege, Felder, Plätze, Pool und Parkplatz + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 10: Neue Schichten im Browser + +**Files:** +- Modify: `src/world/geometry.js:5,15,127-141` +- Modify: `src/world/materials.js:4-26,105,124-133` +- Modify: `src/world/streaming.js:7` +- Modify: `tools/assets/textures.mjs` +- Modify: `tests/geometry.test.js`, `tests/materials.test.js`, `tests/streaming.test.js` +- Create (geladen): `public/textures/concrete_pavement_Diffuse.jpg`, `…_nor_gl.jpg`, `…_arm.jpg` + +**Interfaces:** +- 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** + +In `tests/geometry.test.js` im `describe("geometry")` vor dem Test „gibt Gebäuden eine Farbe je Haus“ einfügen: +```js + it("baut Parkwege flach auf Straßenhöhe und die neuen Flächenarten wie Beläge", () => { + const chunk = { + chunk: [0, 0], origin: [0, 0], land: [], roads: [], centerlines: [], palms: [], buildings: [], + surfaces: ["field", "court", "pool", "parking"].map((kind) => ({ poly: PLOT, kind })), + paths: [{ poly: COURTYARD }], + }; + const out = buildChunkGeometry(chunk); + for (const kind of ["field", "court", "pool", "parking"]) { + const box = new Box3().setFromBufferAttribute(out[kind].getAttribute("position")); + expect(box.max.y, kind).toBeCloseTo(0.02); + } + const paths = new Box3().setFromBufferAttribute(out.paths.getAttribute("position")); + expect(paths.min.y).toBeCloseTo(0.03); + expect(footprintArea(out.paths)).toBeCloseTo(800, 6); // Weg um einen Hof: das Loch bleibt offen + }); + + it("liefert ohne Parkwege null statt einer leeren Geometrie", () => { + const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], roads: [], centerlines: [], buildings: [], palms: [] }; + expect(buildChunkGeometry(chunk).paths).toBeNull(); + }); +``` +In `tests/materials.test.js`: + +1. Den Import ersetzen: +```js +import { applyPolygonOffsets, MATERIAL_KEYS, POLYGON_OFFSETS, POLYGON_OFFSET_ORDER, SURFACE_COLORS, TEXTURES, + waterNormalData } from "../src/world/materials.js"; +``` +2. Den Test „nennt für jedes Material eine Textur“ ersetzen durch zwei Tests: +```js + it("nennt für jedes Material genau eine Textur oder eine Farbe", () => { + for (const key of MATERIAL_KEYS) expect(Boolean(TEXTURES[key]) !== Boolean(SURFACE_COLORS[key]), key).toBe(true); + }); + + it("gibt Feldern das Gras des Parks, dem Parkplatz den Asphalt der Straßen und Wegen Betonplatten", () => { + expect(TEXTURES.field).toBe(TEXTURES.park); + expect(TEXTURES.parking).toBe(TEXTURES.roads); + expect(TEXTURES.paths).toBe("concrete_pavement"); + expect(Object.keys(SURFACE_COLORS).sort()).toEqual(["court", "pool"]); + }); +``` +3. Im zweiten `describe` den ersten Test ersetzen: +```js + it("ordnet die Schichten Boden < Stadtfläche < Park < Strand < Feld < Platz < Pool < Parkplatz < Weg < Straße", () => { + expect(POLYGON_OFFSET_ORDER).toEqual(["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "paths", "roads"]); + }); +``` +4. Im Test „trägt den Versatz nur in …“ die Schlüsselliste der Attrappe ersetzen: +```js + ["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "paths", "roads", + "bridges", "buildings", "water", "seafloor"] +``` +In `tests/streaming.test.js` im `describe("ChunkStreamer")` als letzten Test einfügen: +```js + // Bruch: eine neue Schicht fehlt in DECALS — Weg oder Feld landete in der Kollision und die Kapsel stolperte + // über 2–3 cm hohe Kanten, oder die Schicht fehlte ganz in der Szene. + it("legt Parkwege und die neuen Flächenarten als Auflagen ohne Kollision in die Szene", async () => { + const square = [[[0, 0], [20, 0], [20, 20], [0, 20]]]; + const chunk = { + chunk: [4, 6], origin: [0, -1000], land: [square], roads: [], buildings: [], + surfaces: ["field", "court", "pool", "parking"].map((kind) => ({ poly: square, kind })), + paths: [{ poly: square }], + }; + const { streamer, player, scene } = makeStreamer(chunk); + streamer.update(emptyChunkCenter); + await flush(); + + expect(player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(1); // nur die Landplatte + const group = scene.add.mock.calls[0][0]; + expect(group.children.filter((child) => child.isMesh)).toHaveLength(5); + }); +``` + +- [ ] **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** + +Zeile 5 und 15 ändern: +```js +const ROAD_OFFSET = 0.03; // Straßen und Parkwege wiederum knapp über den Flächen +``` +```js +export const SURFACE_KINDS = ["sand", "park", "urban", "field", "court", "pool", "parking"]; +``` +In `buildChunkGeometry` das Objekt `out` ersetzen: +```js + const out = { + ground: extrudePolygons(chunk.land, { base: -GROUND_THICKNESS, top: 0, uvScale: 8 }), + roads: flatPolygons(roads, { y: ROAD_OFFSET, uvScale: 8 }), + bridges: extrudePolygons(bridges, { base: -BRIDGE_THICKNESS, top: BRIDGE_OFFSET, uvScale: 8 }), + buildings: buildBuildings(chunk.buildings), + // Parkwege (docs/superpowers/specs/2026-09-15-flamingo-park-design.md): flach wie Straßen, Textur in + // echter Größe — Poly Haven misst concrete_pavement mit 1,8 × 1,8 m. + paths: flatPolygons((chunk.paths ?? []).map((path) => path.poly), { y: ROAD_OFFSET, uvScale: 1.8 }), + }; +``` + +- [ ] **Step 4: `materials.js` erweitern** + +`MATERIAL_KEYS`, `TEXTURES` und `FALLBACK_COLORS` ersetzen: +```js +export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "field", "court", "pool", "parking", "paths", + "roads", "bridges", "buildings"]; +export const TEXTURES = { + ground: "grass_path_2", // unbebautes Land + sand: "sand_01", // Strand + park: "leafy_grass", // Grünflächen + urban: "concrete_pavement_03", // versiegelte Stadtfläche + // Flamingo Park (docs/superpowers/specs/2026-09-15-flamingo-park-design.md): Felder im Gras des Parks, der + // Parkplatz im Asphalt der Straßen. Beide lädt createMaterials nicht neu, sondern klont park und roads. + field: "leafy_grass", + parking: "asphalt_02", + paths: "concrete_pavement", // helle Betonplatten, auch für die Gehwege aus Teil 2 gedacht + roads: "asphalt_02", + // Ruling 56: eine Brücke ist in den Daten dasselbe Straßenstück, nur über Wasser + // (geometry.js trennt sie einzig über roads.filter(r => r.bridge)) — dieselbe + // Textur wie roads, sonst zeigt jeder Straße-Brücke-Übergang eine Farbnaht. + bridges: "asphalt_02", + buildings: "white_stucco", +}; +// Plätze und Pool sind einfarbig: Sandplätze, Hartplätze und Poolwasser hat Poly Haven nicht. +export const SURFACE_COLORS = { court: 0x6f8c79, pool: 0x3f9fd8 }; +const FALLBACK_COLORS = { + ground: 0xa8b878, sand: 0xe8d888, park: 0x8bc34a, urban: 0xd8d8d8, paths: 0xd8d2c4, + roads: 0x505050, bridges: 0xb0b0b0, buildings: 0xe8e4dc, +}; +``` +Nach `const ROAD_TINT = 0x999999;` einfügen: +```js +// Felder heben sich vom Rasen ab. material.color kann nur abdunkeln, also Rot und Blau stärker als Grün; +// abgestimmt wird im Browser. +const FIELD_TINT = 0xc8f0b0; +// Klone statt zweiter Ladevorgang: dieselben Texturen, eigene Tönung und eigener polygonOffset. +const CLONES = { field: { from: "park", tint: FIELD_TINT }, parking: { from: "roads" } }; +``` +`POLYGON_OFFSET_ORDER` ersetzen und im Kommentarblock darüber vor `export const POLYGON_OFFSET_ORDER` diese Zeilen ergänzen: +```js +// 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"]; +``` +In `applyPolygonOffsets` den Doc-Kommentar „Nur die fünf benannten Schichten“ auf „Nur die benannten Schichten“ ändern. + +In `createMaterials` den Anfang bis einschließlich `applyPolygonOffsets(materials);` ersetzen: +```js +export async function createMaterials(maxAnisotropy) { + const loader = new TextureLoader(); + const tints = { roads: ROAD_TINT, bridges: ROAD_TINT }; + const textured = MATERIAL_KEYS.filter((key) => !CLONES[key] && !SURFACE_COLORS[key]); + const entries = await Promise.all( + textured.map(async (key) => + [key, await loadSet(loader, TEXTURES[key], FALLBACK_COLORS[key], { tint: tints[key], maxAnisotropy })]), + ); + const materials = Object.fromEntries(entries); + for (const [key, { from, tint }] of Object.entries(CLONES)) { + materials[key] = materials[from].clone(); + if (tint) materials[key].color.set(tint); + } + for (const [key, color] of Object.entries(SURFACE_COLORS)) { + materials[key] = new MeshStandardMaterial({ color, roughness: key === "pool" ? 0.25 : 0.9, metalness: 0 }); + } + materials.buildings.vertexColors = true; // pastellige Tönung je Gebäude + applyPolygonOffsets(materials); +``` + +- [ ] **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** + +Run: `npm test` +Expected: alle bestehen + +- [ ] **Step 7: Textur laden** + +`tools/assets/textures.mjs` ersetzen: +```js +// Lädt die benötigten Poly-Haven-Texturen in 1k nach public/textures/. Vorhandene Dateien bleiben +// unangetastet, damit ein neuer Eintrag die übrigen Texturen nicht still ändert. +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; + +const UA = { "User-Agent": "leonida-assets/0.1 (local hobby project)" }; +const SLUGS = ["asphalt_02", "concrete_pavement_03", "white_stucco", "sand_01", "leafy_grass", "grass_path_2", "palm_bark", + "concrete_pavement"]; +const MAPS = ["Diffuse", "nor_gl", "arm"]; + +await mkdir("public/textures", { recursive: true }); +for (const slug of SLUGS) { + if (MAPS.every((map) => existsSync(`public/textures/${slug}_${map}.jpg`))) continue; + const files = await (await fetch(`https://api.polyhaven.com/files/${slug}`, { headers: UA })).json(); + for (const map of MAPS) { + const target = `public/textures/${slug}_${map}.jpg`; + if (existsSync(target)) continue; + const entry = files[map]?.["1k"]?.jpg; + if (!entry) { console.warn(`${slug}: ${map} fehlt`); continue; } + const buf = Buffer.from(await (await fetch(entry.url, { headers: UA })).arrayBuffer()); + await writeFile(target, buf); + console.log(`${slug}_${map}.jpg ${(buf.length / 1e6).toFixed(1)} MB`); + } +} +``` +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** + +```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 +git commit -m "feat(world): Parkwege, Felder, Plätze, Pool und Parkplatz als eigene Schichten + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 11: Startpunkt und Datentests + +**Files:** +- Create: `src/world/spawn.js` +- Modify: `src/main.js:66-85` (Startpunkt und Fahrzeugpunkte) +- Create: `tests/parkData.test.js` +- Modify: `tests/placesSite.test.js` (neuer Test) + +**Interfaces:** +- 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** + +`tests/parkData.test.js`: +```js +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { chunkForWorld } from "../src/world/coords.js"; +import { PLACES } from "../src/world/places.js"; +import { pointInPolygon } from "../src/world/props.js"; +import { SPAWN, VEHICLE_SPAWN_POINTS } from "../src/world/spawn.js"; + +const PARK_FILE = readFileSync(new URL("../tools/extract/data/flamingo-park.toml", import.meta.url), "utf8"); + +/** Die [[keepout]]-Blöcke der Parkdatei. Ihr Format steht in der Spec fest; ein allgemeiner TOML-Leser wäre zu viel. */ +function keepouts(text) { + const number = "(-?[\\d.]+)"; + return text.split("[[keepout]]").slice(1).map((block) => { + const body = block.split(/\n\[/)[0]; + const at = body.match(new RegExp(`\\nat\\s*=\\s*\\[${number},\\s*${number}\\]`)); + const rotation = body.match(new RegExp(`\\nrotation\\s*=\\s*${number}`)); + const bounds = body.match(new RegExp( + `bounds\\s*=\\s*\\{\\s*min_x\\s*=\\s*${number},\\s*max_x\\s*=\\s*${number},\\s*min_z\\s*=\\s*${number},\\s*max_z\\s*=\\s*${number}\\s*\\}`)); + return { + name: body.match(/name\s*=\s*"([^"]+)"/)[1], + at: [Number(at[1]), Number(at[2])], + rotation: Number(rotation[1]), + bounds: { minX: Number(bounds[1]), maxX: Number(bounds[2]), minZ: Number(bounds[3]), maxZ: Number(bounds[4]) }, + }; + }); +} + +function chunkAt(X, Y) { + const { i, j } = chunkForWorld(X, Y); + return JSON.parse(readFileSync(new URL(`../public/data/vice-a/chunk_${i}_${j}.json`, import.meta.url), "utf8")); +} + +describe("Flamingo-Park-Daten", () => { + // Bruch: ein Ort wird verschoben oder gedreht, die Pipeline hält aber die alte Fläche frei — Palmen, + // Wege oder OSM-Gebäude landeten dann im Ort. + it("hat für jeden Ort eine Sperrfläche mit dessen Lage, Drehung und Außenmaßen", () => { + const byName = Object.fromEntries(keepouts(PARK_FILE).map((keepout) => [keepout.name, keepout])); + for (const place of PLACES) { + expect(byName[place.id], place.id).toEqual({ name: place.id, at: place.at, rotation: place.rotation, bounds: place.bounds }); + } + }); + + // Bruch: der Startpunkt liegt nach dem Umbau des Parks in einem Gebäude oder über Wasser. + it("setzt den Startpunkt auf Land und in kein Gebäude", () => { + const c = chunkAt(SPAWN.x, -SPAWN.z); + const u = SPAWN.x - c.origin[0], v = SPAWN.z - c.origin[1]; + expect(c.land.some((rings) => pointInPolygon(u, v, rings))).toBe(true); + expect(c.buildings.some((building) => pointInPolygon(u, v, building.poly))).toBe(false); + }); + + // Bruch: das Abschneiden am Park nimmt der 11th Street ein Stück, und ein Auto fällt beim Start durch. + it("lässt die vier Autos auf einer Straße stehen", () => { + for (const [x, , z] of VEHICLE_SPAWN_POINTS) { + const c = chunkAt(x, -z); + expect(c.roads.some((road) => pointInPolygon(x - c.origin[0], z - c.origin[1], road.poly)), `${x} / ${-z}`).toBe(true); + } + }); +}); +``` +In `tests/placesSite.test.js` nach dem Test „liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“ einfügen: +```js + // Bruch: ein OSM-Weg läuft durch einen Ort, weil seine Sperrfläche in flamingo-park.toml fehlt oder verrutscht ist. + it("liegt auf keinem Parkweg", () => { + const bad = points.filter(({ X, Y }) => { + const { i, j } = chunkForWorld(X, Y); + const c = chunk(i, j); + return (c.paths ?? []).some((path) => pointInPolygon(X - c.origin[0], -Y - c.origin[1], path.poly)); + }); + expect(bad.slice(0, 5)).toEqual([]); + }); +``` + +- [ ] **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** + +`src/world/spawn.js`: +```js +// Startpunkt und Fahrzeugpunkte, aus src/main.js herausgelöst, damit tests/parkData.test.js sie gegen die +// Chunk-Daten prüfen kann: main.js lädt beim Import Texturen und Modelle und lässt sich nicht in Tests laden. + +// Vice Beach, Rasen am Südrand des Parks (Beschriftungs-Plan, Task 7). Seit Flamingo Park nach OpenStreetMap +// (docs/superpowers/specs/2026-09-15-flamingo-park-design.md) liegt er im neu eingesetzten Park. Auch initPos, +// damit der Streamer von Anfang an den richtigen Chunk anfragt statt erst nach dem späteren reset() umzuschwenken. +export const SPAWN = { x: 1600, y: 3, z: -690 }; + +// Task 11, Schritt 5: vier SUVs an echten Mittellinienpunkten aus chunk_10_7.json (derselbe Chunk wie SPAWN), +// auf der Straße verteilt und weit genug von SPAWN entfernt, um den Spieler beim Start nicht zu überlappen. +// y=1: kleine Fallhöhe bis zur Fahrbahn (y=0), die Federung übernimmt den Rest. Als [x, y, three-z]. +export const VEHICLE_SPAWN_POINTS = [ + [1560, 1, -658], + [1590, 1, -659], + [1630, 1, -661], + [1655, 1, -663], +]; +``` +In `src/main.js`: + +1. Import ergänzen (nach `import { PLACES, PlaceLayer } from "./world/places.js";`): +```js +import { SPAWN, VEHICLE_SPAWN_POINTS } from "./world/spawn.js"; +``` +2. Den Block von `// Vice Beach, Rasen am Südrand des Parks …` bis einschließlich der schließenden `];` von `VEHICLE_SPAWNS` ersetzen durch: +```js +// Startpunkt und Fahrzeugpunkte stehen in src/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** + +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** + +```bash +git add src/world/spawn.js src/main.js tests/parkData.test.js tests/placesSite.test.js +git commit -m "test(world): Sperrflächen, Startpunkt und Parkwege gegen die Daten prüfen + +Startpunkt und Fahrzeugpunkte stehen jetzt in src/world/spawn.js, damit ein +Datentest sie lesen kann. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 12: Welt neu erzeugen und abnehmen + +**Files:** +- Modify (generiert): `public/data/vice-a/chunk_*.json`, `manifest.json`, `map.png`, `map-walkable.png`, `map.json` +- Modify: `src/world/places.js:48-49` (Tribüne ohne `replaces`) +- Modify: `docs/superpowers/specs/2026-09-15-flamingo-park-design.md` (Messwerte) + +**Interfaces:** +- Consumes: alles aus Task 1 bis 11 +- Produces: ausgelieferte Daten mit Flamingo Park und Jefferson Avenue + +- [ ] **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 **keine** Zeile `Warnung:`. 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?** + +Run: +```bash +cd tools/extract && PYTHONUTF8=1 uv run python - <<'EOF' +import json, subprocess +from pathlib import Path +from shapely.geometry import Polygon +from shapely.ops import unary_union + +ROOT = Path("../../public/data/vice-a") +EXPECTED = {"chunk_9_5", "chunk_10_5", "chunk_9_6", "chunk_10_6", "chunk_9_7", "chunk_10_7", "chunk_9_8", "chunk_10_8"} + +def head(name): + return json.loads(subprocess.run(["git", "show", f"HEAD:public/data/vice-a/{name}"], capture_output=True, check=True).stdout) + +def union(items): + return unary_union([Polygon(i["poly"][0], i["poly"][1:]).buffer(0) for i in items]) if items else Polygon() + +surprises = [] +for path in sorted(ROOT.glob("chunk_*.json")): + new, old = json.loads(path.read_text(encoding="utf-8")), head(path.name) + diffs = {} + for key in ("roads", "buildings", "paths"): + area = union(new.get(key, [])).symmetric_difference(union(old.get(key, []))).area + if area > 0.5: + diffs[key] = round(area) + for kind in {s["kind"] for s in new["surfaces"] + old["surfaces"]}: + area = union([s for s in new["surfaces"] if s["kind"] == kind]).symmetric_difference( + union([s for s in old["surfaces"] if s["kind"] == kind])).area + if area > 0.5: + diffs[kind] = round(area) + for key in ("palms", "centerlines"): + if len(new[key]) != len(old[key]): + diffs[key] = f"{len(old[key])}->{len(new[key])}" + if diffs: + print(path.stem, diffs) + if path.stem not in EXPECTED: + surprises.append(path.stem) +print("nur erwartete Chunks" if not surprises else f"UNERWARTET: {surprises}") +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`** + +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 + // Der 18,6 m hohe Gebäudekasten aus der Extraktion an dieser Stelle (J6). + replaces: [[1403.25, 1057.75]], +``` +ersetzen durch: +```js + // Kein replaces mehr: den 18,6 m hohen Gebäudekasten aus der Extraktion entfernt seit Flamingo Park nach + // OpenStreetMap schon die Pipeline (docs/superpowers/specs/2026-09-15-flamingo-park-design.md). +``` + +- [ ] **Step 4: Alle Tests** + +Run: +```bash +cd tools/extract && PYTHONUTF8=1 uv run pytest -q +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** + +Run: +```bash +cd tools/extract && PYTHONUTF8=1 uv run python -c " +from PIL import Image +img = Image.open('../../public/data/vice-a/map.png') +x0, x1, y0, y1 = 1340, 1710, 300, 1130 +img.crop((x0 + 1000, 2500 - y1, x1 + 1000, 2500 - y0)).resize(((x1 - x0) * 2, (y1 - y0) * 2), Image.NEAREST).save('debug/flamingo-park.png') +" +``` +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** + +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. + +Blicke aufnehmen und dem Nutzer schicken. Vor jedem Blick die Kapsel an die genannte Stelle setzen (`capsule.position.set(…)`), damit die Chunks dort geladen sind, und 2–3 s warten: +1. **Draufsicht Park:** Kapsel `(1520, 1, -880)`; `position [1520, 450, -880]`, `target [1520, 0, -880]`, `up [0, 0, -1]`. 450 m zeigen die ganzen 430 m Parklänge und liegen vor dem Nebelbeginn bei 500 m. +2. **Augenhöhe auf dem 13th-St-Weg** nach Osten: Kapsel `(1470, 1, -885)`; `position [1470, 1.7, -885]`, `target [1600, 1.7, -895]`. +3. **Jefferson Avenue** von der 11th Street nach Süden: Kapsel `(1520, 1, -640)`; `position [1520, 1.7, -650]`, `target [1535, 1.7, -450]`. +4. **Tribüne und Hood Yard** vom Baseballfeld: Kapsel `(1470, 1, -1020)`; `position [1470, 1.7, -1020]`, `target [1430, 5, -1060]`. + +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** + +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** + +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** + +```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 +git status --short +git commit -m "feat(world): Flamingo Park nach OpenStreetMap und Jefferson Avenue im Spiel + +Co-Authored-By: Claude Opus 5 " +``` +Vor dem Commit prüfen, dass `git status --short` weder `index-debug.html` noch die Kit-Ordner als hinzugefügt zeigt. diff --git a/docs/superpowers/specs/2026-09-15-flamingo-park-design.md b/docs/superpowers/specs/2026-09-15-flamingo-park-design.md index 46c79ba..ba826e6 100644 --- a/docs/superpowers/specs/2026-09-15-flamingo-park-design.md +++ b/docs/superpowers/specs/2026-09-15-flamingo-park-design.md @@ -115,19 +115,19 @@ Bei D bleibt östlich der Anlage ein 37 m breiter Rasenstreifen. ### Parkdatei (`tools/extract/data/flamingo-park.toml`) -Alles, was von Hand entschieden ist. Die Werte unten sind die Werte der Umsetzung; nur `offset_u` wird beim Umsetzen mit der bilinearen Umrechnung neu eingestellt (siehe Einpassung). +Alles, was von Hand entschieden ist. Die Werte unten sind die Werte der Umsetzung. ```toml # Flamingo Park nach OpenStreetMap. Spec: docs/superpowers/specs/2026-09-15-flamingo-park-design.md osm = "osm/flamingo-park.json" [fit] -# Innenkante des echten Parks, Breite/Länge in Grad -real = { sw = [25.781905, -80.138373], se = [25.781960, -80.136373], ne = [25.786554, -80.136523], nw = [25.786524, -80.138527] } +# Innenkante des echten Parks, Breite/Länge in Grad. Die Umrechnung ist affin über diese drei Ecken. +real = { sw = [25.781905, -80.138373], se = [25.781960, -80.136373], nw = [25.786524, -80.138527] } # Innenkante der Straßen um den Park im Spiel, Welt-x/Welt-y wie auf der Minimap game = { sw = [1418.3, 658.6], se = [1661.5, 672.8], ne = [1626.0, 1096.0], nw = [1382.1, 1074.2] } scale_u = 1.0 # quer wie in echt -offset_u = 6.3 # Meter vom Westrand; echte Tribüne und Kleinbauten auf Tribüne und Hood Yard +offset_u = 6.25 # Meter vom Westrand; echte Tribüne und Kleinbauten auf Tribüne und Hood Yard [[anchor]] # Prüfung der Einpassung, höchstens 1,5 m Abweichung name = "Tribüne" @@ -162,19 +162,22 @@ from = [1516.5, 664.0] to = [1545.2, 334.2] width = 12.0 remove_buildings = [[1513.0, 628.0], [1532.0, 426.0]] -# Gassenstreifen zwischen den Kanten von 11th Street und der Querstraße bei y ≈ 332, samt der -# ausgerundeten Ecken an beiden Einmündungen. Wird beim Planen einmal aus den Chunk-Daten gemessen. -alley_cut = [[x, y], ...] +# Gassenstreifen zwischen 11th Street (Fahrbahnkante y 644,5–645,5) und der Querstraße bei y ≈ 332 +# (Fahrbahnkante y 339,5), gemessen an den Chunk-Daten. Die ausgerundeten Ecken bleiben bis auf je +# einen Streifen von 1–3 m stehen; die Fahrbahnen der Querstraßen berührt das Vieleck nicht. +alley_cut = [[1470, 644], [1496, 644], [1500, 600], [1506, 540], [1514, 460], [1520, 380], [1520, 340.5], + [1497, 340.5], [1486, 380], [1480, 460], [1474, 540], [1468, 600]] ``` **Prüfregeln beim Einlesen:** Fehlt ein Pflichtfeld, hat ein Feld den falschen Typ, fehlt die OSM-Datei oder ist eine Breite oder Höhe ≤ 0, bricht `run.py` mit einer Meldung ab, die Datei und Feld nennt. Fehlt die OSM-Datei, nennt die Meldung den Aufruf von `osm.py`. ## Einpassung -- **Echte Lage:** Jeder OSM-Punkt wird zuerst in lokale Meter umgerechnet (Bezugspunkt und Maß wie im Befund). Danach bekommt er Parkkoordinaten (s, t), mit der Umkehrung der bilinearen Abbildung über die vier echten Ecken: s läuft von West nach Ost, t von Süd nach Nord. Das echte Parkviereck ist s, t ∈ [0, 1]. -- **Lage im Spiel:** Quer wird s zu s' = (offset_u + scale_u · s · B_echt) / B_Spiel umgerechnet, mit B_echt = 200,6 m und B_Spiel = 243,6 m, jeweils entlang der Südkante. Längs bleibt t. Der Spielpunkt ist die bilineare Abbildung von (s', t) über die vier Spielecken. Die Nordkante des Parks folgt so auch der schrägen Nordkante im Spiel. +- **Echte Lage:** Jeder OSM-Punkt wird zuerst in lokale Meter umgerechnet (Bezugspunkt und Maß wie im Befund). Danach bekommt er Parkkoordinaten (s, t) aus p − SW = s · (SE − SW) + t · (NW − SW), über die echten Ecken SW, SE und NW: s läuft von West nach Ost, t von Süd nach Nord. Das echte Parkviereck ist s, t ∈ [0, 1]. +- **Lage im Spiel:** Quer wird s zu s' = (offset_u + scale_u · s · B_echt) / B_Spiel umgerechnet, mit B_echt = |SE − SW| echt (200,57 m) und B_Spiel = |SE − SW| im Spiel (243,61 m). Der Spielpunkt ist SW + s' · (SE − SW) + t · (NW − SW) über die Spielecken. +- **Warum affin und nicht über alle vier Ecken:** Eine bilineare Umrechnung folgte der steileren Nordkante im Spiel und schöbe den Hood-Yard-Anker 4,2 m nach Norden; beide Anker gleichzeitig unter 1,5 m zu bringen, geht damit nicht. Die affine Umrechnung ist genau die Variante D aus dem Vergleichsbild. Folge: Die Anlage endet im Nordosten bis zu 6,4 m vor der Nordstraße; der Rasen füllt das ganze Parkpolygon, es bleibt also kein nackter Boden. - **Länge:** Sie ergibt sich aus den Ecken, 417,2 / 514,4 ≈ 0,811. -- **`offset_u`:** Der Wert wird so gewählt, dass beide Anker aus der Parkdatei höchstens 1,5 m neben ihrer Spielposition landen. Die 6,3 m aus dem Befund sind mit affiner Näherung gemessen, die Umsetzung stellt den Wert bilinear neu ein. +- **`offset_u`:** 6,25 m. Damit landen die echte Tribüne 1,19 m und die echten Kleinbauten 1,21 m neben Tribüne und Hood Yard. Ein Test hält beide unter 1,5 m. - **Zuschneiden:** Wege werden am echten Parkviereck abgeschnitten. Endet ein Weg danach höchstens 5 m vor dessen West- oder Ostrand, läuft er im Spiel in seiner letzten Richtung bis an die Straßenkante weiter. Flächen und Gebäude zählen nur, wenn ihr Mittelpunkt im echten Parkviereck liegt. ## Anwendung in der Pipeline (`tools/extract/leonida/park.py`) @@ -188,7 +191,7 @@ alley_cut = [[x, y], ...] 1. **Gebäude:** Jedes Gebäude, in dem ein Punkt aus `remove_buildings` liegt, entfällt. 2. **Gasse:** - Das Vieleck `alley_cut` wird aus der Straßenfläche geschnitten. Es reicht von der Kante der 11th Street bis zur Kante der Querstraße bei y ≈ 332 und schließt die ausgerundeten Ecken der Einmündungen ein, aber keine Fahrbahn der Querstraßen. - - Die Mittellinienstücke in `alley_cut` entfallen. + - Die Mittellinienstücke in `alley_cut` entfallen, ebenso Reststücke unter 15 m. 3. **Neue Straße:** - Ein Rechteck von `width` Breite von `from` nach `to` wird mit der Straßenfläche vereinigt, `bridge = false`. Die Querstraßen sind danach durchgehend. - Dazu kommt eine Mittellinie von `from` nach `to`. @@ -200,7 +203,7 @@ alley_cut = [[x, y], ...] Das Parkpolygon ist das Viereck aus den Spielecken. **Entfernen** -- Straßenflächen werden am Parkpolygon abgeschnitten, Mittellinien ebenso. Stücke unter 1 m entfallen. +- Straßenflächen werden am Parkpolygon abgeschnitten, Mittellinien ebenso. Mittellinienstücke unter 15 m entfallen, wie in `centerlines()` der Extraktion; kürzere Stummel setzten sonst Laternen mitten auf die Kreuzung an der Parkkante. Straßenstücke unter 20 m² entfallen, wie in `road_polygons()`. - Beläge (`sand`, `park`, `urban`) werden am Parkpolygon abgeschnitten. - Gebäude mit mehr als der Hälfte ihrer Fläche im Park entfallen. - Palmen im Park entfallen. @@ -256,7 +259,8 @@ Das Parkpolygon ist das Viereck aus den Spielecken. - `POLYGON_OFFSET_ORDER` wird zu `ground`, `urban`, `park`, `sand`, `field`, `court`, `pool`, `parking`, `paths`, `roads`. - Die Regel, dass jeder Schlüssel eine Textur hat, gilt künftig nur für texturierte Schlüssel. `court` und `pool` stehen in einer eigenen Farbliste. - **`src/world/streaming.js`:** `DECALS` bekommt `paths`, `field`, `court`, `pool` und `parking`. Das sind reine Auflagen ohne eigene Kollision, wie heute Straßen und Beläge. Die Landplatte trägt, Gebäude kollidieren wie alle anderen. Man läuft also auch über den Pool. -- **`tools/assets/textures.mjs`:** `SLUGS` bekommt `concrete_pavement` (Poly Haven, CC0, 1k). Die Textur ist auch für die Gehwege in Teil 2 gedacht. +- **`tools/assets/textures.mjs`:** `SLUGS` bekommt `concrete_pavement` (Poly Haven, CC0, 1k). Die Textur ist auch für die Gehwege in Teil 2 gedacht. Vorhandene Dateien lädt das Skript nicht erneut, damit sich die übrigen Texturen nicht still ändern. +- **`src/world/places.js`:** Die Tribüne verliert `replaces`. Den alten Gebäudekasten entfernt künftig schon die Pipeline, weil er zu mehr als der Hälfte im Park liegt; ein `replaces`-Punkt träfe dann kein Gebäude mehr, und `placesSite.test.js` schlüge an. `withoutReplacedBuildings` bleibt als Mechanismus für spätere Orte. - **Kosten:** Bis zu fünf Draw Calls mehr, nur in den Chunks von Park und Jefferson Avenue (J6, K6, J7, K7, J8, K8, J9, K9). ## Minimap (`tools/extract/overview.py`) @@ -269,8 +273,8 @@ Das Parkpolygon ist das Viereck aus den Spielecken. 1. **Branch:** Die Arbeit läuft auf `leonida/flamingo-park`, `main` bleibt bis zur Abnahme unberührt. 2. **Pipeline auf Windows nachbauen:** - - `cd tools/extract && uv sync`. `uv` lädt dafür Python 3.12 und die Pakete aus `uv.lock`. - - Danach `uv run python run.py` mit unverändertem Code. + - `cd tools/extract && uv sync --python 3.12`. `uv` lädt dafür Python 3.12 und die Pakete aus `uv.lock`. + - Danach `PYTHONUTF8=1 uv run python run.py` mit unverändertem Code. `PYTHONUTF8=1` ist unter Windows nötig: Python 3.12 liest Textdateien dort sonst als cp1252, und `annotations.toml` ist UTF-8. - Verglichen werden alle Chunk-Dateien und `manifest.json` als geparstes JSON, dazu `map.png` und `map-walkable.png` Pixel für Pixel sowie `map.json`. - Weicht etwas ab, geht es erst weiter, wenn die Ursache geklärt und mit dem Nutzer entschieden ist. 3. **OSM-Daten:** `osm.py` schreiben, einmal abrufen, dazu `flamingo-park.toml` anlegen. @@ -284,8 +288,8 @@ Das Parkpolygon ist das Viereck aus den Spielecken. ### pytest (`tools/extract/tests/test_park.py`) - **Einpassung:** - - Südkante (t = 0) und Nordkante (t = 1) des echten Parks landen auf Süd- und Nordkante im Spiel. - - s = 0 landet `offset_u` Meter östlich der Westkante. + - 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. + - Die echte Südkante bleibt 1:1 lang (200,57 m). - Beide Anker liegen höchstens 1,5 m neben ihrer Spielposition. - **Zuschneiden und Verlängern:** - Ein Weg über den echten Parkrand endet an der Kante. @@ -313,7 +317,7 @@ Das Parkpolygon ist das Viereck aus den Spielecken. - **`tests/streaming.test.js`:** Die neuen Schichten landen in der Szene, aber nicht in der Kollisionsgruppe. - **`tests/placesSite.test.js`:** Mit den neuen Daten bleibt der Test grün. Neu: Unter Tribüne und Hood Yard liegt kein Punkt einer `paths`-Fläche. - **Neuer Datentest:** Die `keepout`-Einträge in `flamingo-park.toml` stimmen in `at`, `rotation` und `bounds` mit `PLACES` überein. Der Test liest die wenigen Felder aus der TOML-Datei. -- **Startpunkt:** `SPAWN` in `src/main.js` (1600 | 690) liegt danach im Park. Ein Datentest prüft, dass er auf Land und in keinem Gebäude liegt; die vier Fahrzeugpunkte liegen weiter auf einer Straßenfläche. +- **Startpunkt:** `SPAWN` (1600 | 690) liegt danach im Park. Ein Datentest prüft, dass er auf Land und in keinem Gebäude liegt; die vier Fahrzeugpunkte liegen weiter auf einer Straßenfläche. Damit der Test die Punkte lesen kann, wandern `SPAWN` und die Fahrzeugpunkte aus `src/main.js` in ein kleines Modul `src/world/spawn.js`; `main.js` lässt sich wegen seiner Ladeschritte auf oberster Ebene nicht in Tests importieren. ### Sichtprüfung