Files
gta6/tools/extract/leonida/park.py
T

418 lines
17 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 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
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)
# --- 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)])
# --- 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