feat(extract): Parkdatei für Flamingo Park lesen und prüfen
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3d4d8380c8
commit
1b0011874e
3 files changed
+343
No files matched your search
@@ -0,0 +1,56 @@
|
||||
# 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]]
|
||||
@@ -0,0 +1,193 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,94 @@
|
||||
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
|
||||
Reference in new issue
Block a user