194 lines
7.2 KiB
Python
194 lines
7.2 KiB
Python
"""Flamingo Park nach OpenStreetMap in die extrahierte Welt einsetzen, dazu die Jefferson Avenue.
|
|
|
|
Spec: docs/superpowers/specs/2026-09-15-flamingo-park-design.md
|
|
|
|
Welt-x und Welt-y in Metern wie auf der Minimap; echte Punkte in flamingo-park.toml als Breite/Länge.
|
|
Ein Formfehler in der Parkdatei bricht mit ParkError ab, wie bei annotations.toml.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import tomllib
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from shapely.geometry import Polygon
|
|
|
|
|
|
class ParkError(ValueError):
|
|
"""Formfehler in der Parkdatei oder eine Welt, zu der sie nicht mehr passt: der Lauf bricht ab."""
|
|
|
|
|
|
# --- Parkdatei ---------------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class Anchor:
|
|
name: str
|
|
real: tuple[float, float] # Breite, Länge
|
|
game: tuple[float, float] # Welt-x, Welt-y
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Keepout:
|
|
name: str
|
|
at: tuple[float, float]
|
|
rotation: float # Grad gegen den Uhrzeigersinn, wie PLACES
|
|
bounds: tuple[float, float, float, float] # min_x, max_x, min_z, max_z in Modellkoordinaten
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Street:
|
|
start: tuple[float, float]
|
|
end: tuple[float, float]
|
|
width: float
|
|
remove_buildings: tuple[tuple[float, float], ...]
|
|
alley_cut: tuple[tuple[float, float], ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ParkConfig:
|
|
osm_path: Path
|
|
real: dict[str, tuple[float, float]] # sw, se, nw als Breite, Länge
|
|
game: dict[str, tuple[float, float]] # sw, se, ne, nw als Welt-x, Welt-y
|
|
scale_u: float
|
|
offset_u: float
|
|
anchors: tuple[Anchor, ...]
|
|
keepouts: tuple[Keepout, ...]
|
|
main_width: float
|
|
path_width: float
|
|
service_width: float
|
|
default_height: float
|
|
street: Street
|
|
|
|
|
|
def load_park(path: Path) -> ParkConfig:
|
|
if not path.exists():
|
|
raise ParkError(f"{path.name} fehlt")
|
|
return parse_park(path.read_text(encoding="utf-8"), path.parent, path.name)
|
|
|
|
|
|
def parse_park(text: str, base: Path, name: str = "flamingo-park.toml") -> ParkConfig:
|
|
def fail(message: str):
|
|
raise ParkError(f"{name}: {message}")
|
|
|
|
try:
|
|
data = tomllib.loads(text)
|
|
except tomllib.TOMLDecodeError as err:
|
|
fail(f"kein gültiges TOML: {err}")
|
|
root = _Table(data, "", fail)
|
|
root.only("osm", "fit", "anchor", "keepout", "paths", "buildings", "street")
|
|
|
|
fit = root.table("fit")
|
|
fit.only("real", "game", "scale_u", "offset_u")
|
|
real, game = fit.table("real"), fit.table("game")
|
|
real.only("sw", "se", "nw")
|
|
game.only("sw", "se", "ne", "nw")
|
|
|
|
anchors = []
|
|
for entry in root.tables("anchor"):
|
|
entry.only("name", "real", "game")
|
|
anchors.append(Anchor(entry.text("name"), entry.point("real"), entry.point("game")))
|
|
|
|
keepouts = []
|
|
for entry in root.tables("keepout"):
|
|
entry.only("name", "at", "rotation", "bounds")
|
|
bounds = entry.table("bounds")
|
|
bounds.only("min_x", "max_x", "min_z", "max_z")
|
|
box = tuple(bounds.number(key) for key in ("min_x", "max_x", "min_z", "max_z"))
|
|
if box[0] >= box[1] or box[2] >= box[3]:
|
|
fail(f"{entry.path}.bounds: min muss kleiner als max sein, ist {box!r}")
|
|
keepouts.append(Keepout(entry.text("name"), entry.point("at"), entry.number("rotation"), box))
|
|
|
|
paths = root.table("paths")
|
|
paths.only("main_width", "width", "service_width")
|
|
buildings = root.table("buildings")
|
|
buildings.only("default_height")
|
|
street = root.table("street")
|
|
street.only("from", "to", "width", "remove_buildings", "alley_cut")
|
|
cut = street.points("alley_cut", 3)
|
|
if not Polygon(cut).is_valid:
|
|
fail("street.alley_cut muss ein Vieleck ohne sich schneidende Kanten sein")
|
|
|
|
osm = root.text("osm")
|
|
if not (base / osm).exists():
|
|
fail(f"OSM-Datei {osm} fehlt: cd tools/extract && uv run python osm.py")
|
|
|
|
return ParkConfig(
|
|
osm_path=base / osm,
|
|
real={key: real.point(key) for key in ("sw", "se", "nw")},
|
|
game={key: game.point(key) for key in ("sw", "se", "ne", "nw")},
|
|
scale_u=fit.number("scale_u", positive=True),
|
|
offset_u=fit.number("offset_u"),
|
|
anchors=tuple(anchors),
|
|
keepouts=tuple(keepouts),
|
|
main_width=paths.number("main_width", positive=True),
|
|
path_width=paths.number("width", positive=True),
|
|
service_width=paths.number("service_width", positive=True),
|
|
default_height=buildings.number("default_height", positive=True),
|
|
street=Street(street.point("from"), street.point("to"), street.number("width", positive=True),
|
|
street.points("remove_buildings", 0), cut),
|
|
)
|
|
|
|
|
|
class _Table:
|
|
"""Eine TOML-Tabelle mit Pfad für Meldungen; jede Abfrage prüft Vorhandensein und Typ."""
|
|
|
|
def __init__(self, data, path: str, fail):
|
|
if not isinstance(data, dict):
|
|
fail(f"{path or 'Datei'} muss eine Tabelle sein")
|
|
self.data, self.path, self.fail = data, path, fail
|
|
|
|
def _key(self, key: str) -> str:
|
|
return f"{self.path}.{key}" if self.path else key
|
|
|
|
def only(self, *keys: str) -> None:
|
|
for key in self.data:
|
|
if key not in keys:
|
|
self.fail(f"Feld {self._key(key)!r} gibt es nicht")
|
|
|
|
def get(self, key: str):
|
|
if key not in self.data:
|
|
self.fail(f"Feld {self._key(key)!r} fehlt")
|
|
return self.data[key]
|
|
|
|
def table(self, key: str) -> _Table:
|
|
return _Table(self.get(key), self._key(key), self.fail)
|
|
|
|
def tables(self, key: str) -> list[_Table]:
|
|
value = self.get(key)
|
|
if not isinstance(value, list):
|
|
self.fail(f"{self._key(key)} muss als [[{key}]] geschrieben sein")
|
|
return [_Table(item, f"{self._key(key)}[{i}]", self.fail) for i, item in enumerate(value, start=1)]
|
|
|
|
def text(self, key: str) -> str:
|
|
value = self.get(key)
|
|
if not isinstance(value, str) or not value.strip():
|
|
self.fail(f"Feld {self._key(key)!r} muss Text sein, ist {value!r}")
|
|
return value
|
|
|
|
def number(self, key: str, positive: bool = False) -> float:
|
|
value = self.get(key)
|
|
if not _is_number(value) or (positive and value <= 0):
|
|
bound = " größer als 0" if positive else ""
|
|
self.fail(f"Feld {self._key(key)!r} muss eine Zahl{bound} sein, ist {value!r}")
|
|
return float(value)
|
|
|
|
def point(self, key: str) -> tuple[float, float]:
|
|
value = self.get(key)
|
|
if not _is_point(value):
|
|
self.fail(f"Feld {self._key(key)!r} muss ein Punkt [a, b] sein, ist {value!r}")
|
|
return float(value[0]), float(value[1])
|
|
|
|
def points(self, key: str, minimum: int) -> tuple[tuple[float, float], ...]:
|
|
value = self.get(key)
|
|
if not (isinstance(value, list) and len(value) >= minimum and all(_is_point(p) for p in value)):
|
|
self.fail(f"Feld {self._key(key)!r} muss mindestens {minimum} Punkte [a, b] enthalten, ist {value!r}")
|
|
return tuple((float(a), float(b)) for a, b in value)
|
|
|
|
|
|
def _is_number(value) -> bool:
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
|
|
|
|
def _is_point(value) -> bool:
|
|
return isinstance(value, list) and len(value) == 2 and all(_is_number(c) for c in value)
|