Files
gta6/tools/extract/leonida/park.py
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

581 lines
26 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, 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]:
"""Die Elemente des OSM-Abzugs, so streng geprüft wie die Parkdatei.
osm.py wird von Hand aufgerufen, der Abzug liegt also beliebig lange herum und kann
abgeschnitten oder aus einer älteren Fassung des Skripts sein. Ohne diese Prüfung fällt das
erst als nackter KeyError mitten in build_layout() auf — ohne Dateinamen und ohne den Hinweis,
wie man den Abzug erneuert.
"""
def fail(message: str):
raise ParkError(f"{path.name}: {message}")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as err:
fail(f"kein gültiges JSON: {err}")
elements = data.get("elements") if isinstance(data, dict) else None
if not isinstance(elements, list):
fail("Feld 'elements' fehlt oder ist keine Liste: cd tools/extract && uv run python osm.py")
for n, element in enumerate(elements, start=1):
where = f"elements[{n}]" if not isinstance(element, dict) else f"elements[{n}] (id {element.get('id')})"
if not isinstance(element, dict) or not isinstance(element.get("tags"), dict):
fail(f"{where} braucht eine Tabelle 'tags'")
geometry = element.get("geometry")
if not (isinstance(geometry, list) and all(_is_point(point) for point in geometry)):
fail(f"{where} braucht 'geometry' als Liste von Punkten [Breite, Länge]")
return 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")
# Am Parkviereck abschneiden: _extend() legt den letzten Stützpunkt genau auf die Kante,
# die runde Kappe von LineString.buffer() setzt danach bis zu eine halbe Wegbreite (3 m)
# darüber hinaus. Gemessen lagen so 95,96 m² Weg außerhalb des Vierecks, davon rund 41 m²
# als Betonzungen sichtbar auf den umgebenden Belägen. Die gewollte Reichweite bis zur
# Straßenkante geht dabei nicht verloren, weil _extend() ohnehin auf diese Kante zielt.
paths = _polygons(union.difference(blocked).intersection(fit.game_quad))
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.5) -> None:
"""Jede Spielecke muss am Rand einer Straße liegen, sonst hat sich die Extraktion geändert.
Gemessen wird gegen den Rand, nicht gegen die Fläche: alle vier Ecken liegen heute innerhalb
der Straßenfläche, ihr Flächenabstand ist 0,000 m. Eine Prüfung auf die Fläche schlägt deshalb
nur an, wenn eine Straße sich zurückzieht. Verbreitert ein künftiger Extraktionslauf die
Straßen nach innen, bliebe der Flächenabstand 0, und apply_park() schnitte die verbreiterte
Straße still auf das veraltete Viereck zurück — genau der Fehlerfall, den diese Prüfung
abfangen soll.
Am Referenzstand f32ea27 liegen die Ecken 0,45 m (sw), 0,14 m (se), 1,46 m (ne) und 1,14 m (nw)
vom Straßenrand entfernt. 2,5 m Toleranz lässt der weitesten Ecke damit 1,04 m Luft, bevor die
Prüfung grundlos abbricht, und schlägt umgekehrt an, sobald eine Straßenkante sich um mehr als
ihren heutigen Abstand plus rund einen Meter verschiebt.
"""
for name, (x, y) in config.game.items():
corner = Point(x, y)
if not any(poly.boundary.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 vom Rand 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