"""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, replace from pathlib import Path from shapely.geometry import LineString, Point, Polygon from shapely.ops import unary_union from .heights import PALM_CLEARANCE, PALM_KERB, palm_points 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, service_polys = [], [], [] if footways: union = unary_union(footways) warnings += _keepout_warnings(union, config.keepouts, keepouts, "Parkweg") paths = _polygons(union.difference(blocked)) if service: service_union = unary_union(service) warnings += _keepout_warnings(service_union, config.keepouts, keepouts, "Zufahrt") service_polys = _polygons(service_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=service_polys, buildings=buildings, keepouts=keepouts, warnings=warnings, ) def _keepout_warnings(union, config_keepouts, keepout_polys, label: str) -> list[str]: """Warnungstexte für alle Sperrflächen, die `union` (Parkwege oder Zufahrten) schneiden.""" return [f"{label} schneidet die Sperrfläche {k.name}" for k, polygon in zip(config_keepouts, keepout_polys) if union.intersects(polygon)] 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 # --- 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) 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) 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