Files
gta6/docs/superpowers/plans/2026-09-15-flamingo-park.md
T
knsoandClaude Opus 5 3c74202b5e fix(extract): Chunk-Ausgabe lauf-stabil, Eckenprüfung in beide Richtungen
emit.py normalisiert jede ausgegebene Fläche und sortiert die Listen nach der
Ausgabestruktur selbst. unary_union() in apply_street() gab das kartenweite
Straßenpolygon mit anderem Ringstartpunkt und an anderer Listenstelle zurück und
änderte damit 88 Chunkdateien, in denen sich geometrisch nichts bewegt hatte
(symmetrische Differenz 0,00 m²). Belagsarten und Mittellinien behalten ihre
Reihenfolge: overview.py malt die Beläge in Listenreihenfolge übereinander, und
vergeLampPoints() hängt an Reihenfolge und Laufrichtung der Mittellinien —
Sortieren verschöbe dort 1.848 von 3.921 Laternen auf die andere Straßenseite.

check_corners() misst gegen den Straßenrand statt gegen die Straßenfläche. Alle
vier Ecken liegen in der Fahrbahn, ihr Flächenabstand ist 0,000 m; die alte
Prüfung schlug deshalb nur an, wenn eine Straße sich zurückzieht. Eine nach
innen verbreiterte Straße hätte apply_park() still auf das veraltete Viereck
zurückgeschnitten.

Die Parkwege werden am Parkviereck abgeschnitten — die runden Kappen von
LineString.buffer() lagen mit 95,96 m² darüber hinaus, rund 41 m² davon sichtbar
als Betonzungen auf den umgebenden Belägen. load_elements() prüft den OSM-Abzug
so streng wie die Parkdatei, mit Dateiname statt nacktem KeyError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:43:01 +02:00

105 KiB
Raw Blame History

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 <noreply@anthropic.com>.
  • 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,5 m vom Rand einer Straße (Fixrunde 3, Befund A; ursprünglich 2 m von der Straßenfläche), Mittellinienstücke unter 15 m entfallen, Straßenstücke unter 20 m² entfallen, Palmenabstand 6 m (PALM_CLEARANCE), Palmen zur neuen Straße 1,5 m (PALM_KERB), Wege nur bis 5 m vor dem echten West- oder Ostrand verlängern.
  • Hilfsdateien für die Browserprüfung (index-debug.html o. ä.) nie committen.

Dateien

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):

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:

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:

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:

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:

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:

"""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:

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
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 <noreply@anthropic.com>"

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:

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:

"""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:

# 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
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 <noreply@anthropic.com>"

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:

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:

import math
import tomllib
from dataclasses import dataclass
from pathlib import Path

from shapely.geometry import Polygon

Und ans Dateiende anhängen:

# --- 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
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 <noreply@anthropic.com>"

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:

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:

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:

# --- 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:

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
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 <noreply@anthropic.com>"

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:

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:

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:

# --- 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
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 <noreply@anthropic.com>"

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:

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:

from .heights import PALM_CLEARANCE, PALM_KERB, palm_points

Ans Dateiende anhängen:

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
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 <noreply@anthropic.com>"

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:

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:

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:

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:

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:

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 …):
from leonida.park import World, apply_flamingo_park
  1. Nach OUT = … eine Konstante ergänzen:
PARK = Path("data/flamingo-park.toml")
  1. Den Block ab payloads = [] bis zum Ende von write_chunks(…) ersetzen durch:
    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
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 <noreply@anthropic.com>"

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:

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):

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:

    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:

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:

    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:

    _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
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 <noreply@anthropic.com>"

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:

    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:
import { applyPolygonOffsets, MATERIAL_KEYS, POLYGON_OFFSETS, POLYGON_OFFSET_ORDER, SURFACE_COLORS, TEXTURES,
         waterNormalData } from "../src/world/materials.js";
  1. Den Test „nennt für jedes Material eine Textur“ ersetzen durch zwei Tests:
    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"]);
    });
  1. Im zweiten describe den ersten Test ersetzen:
    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"]);
    });
  1. Im Test „trägt den Versatz nur in …“ die Schlüsselliste der Attrappe ersetzen:
            ["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:

    // 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:

const ROAD_OFFSET = 0.03;       // Straßen und Parkwege wiederum knapp über den Flächen
export const SURFACE_KINDS = ["sand", "park", "urban", "field", "court", "pool", "parking"];

In buildChunkGeometry das Objekt out ersetzen:

    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:

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:

// 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:

// 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:

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:

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:

// 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
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 <noreply@anthropic.com>"

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:

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:

    // 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:

// 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";):
import { SPAWN, VEHICLE_SPAWN_POINTS } from "./world/spawn.js";
  1. Den Block von // Vice Beach, Rasen am Südrand des Parks … bis einschließlich der schließenden ]; von VEHICLE_SPAWNS ersetzen durch:
// 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
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 <noreply@anthropic.com>"

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 genau eine Warnzeile: Warnung: Zufahrt schneidet die Sperrfläche tribuene. Das ist der bekannte, gewollte Zustand — die Zufahrt zum Parkplatz quert die Sperrfläche der Tribüne und wird dort ausgeschnitten. Jede weitere oder andere Warnung ist ein Befund. Bricht der Lauf mit Spielecke … liegt weiter als 2 m ab: anhalten und dem Nutzer melden.

  • Step 2: Änderungen auf Park und Jefferson Avenue begrenzt?

Run:

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

        // Der 18,6 m hohe Gebäudekasten aus der Extraktion an dieser Stelle (J6).
        replaces: [[1403.25, 1057.75]],

ersetzen durch:

        // 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:

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:

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
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 <noreply@anthropic.com>"

Vor dem Commit prüfen, dass git status --short weder index-debug.html noch die Kit-Ordner als hinzugefügt zeigt.