Files
gta6/tools/extract/leonida/park.py
T
knsoandClaude Sonnet 5 8bc8ca19fc feat(extract): Flamingo Park hängt seine Straße an den Graphen
World.centerlines wird zu World.graph: apply_street() und apply_park() schneiden und
verlängern jetzt den Straßengraphen statt einer losen Liste von Mittellinien, mit den neuen
roads.cut_edges()/attach() als Gegenstück zum bestehenden Schneiden der Straßenflächen.
apply_park() schneidet dabei einmal am Parkviereck (nicht je Sperrfläche - das schneidet
tatsächlich nichts, was der Parkumriss nicht schon geschnitten hätte). check_corners() bleibt
unverändert gegen world.roads (den Korridor) messen, mit einem erklärenden Docstring-Absatz und
einer Regressionswache dafür in den Tests.

MIN_CENTERLINE (15 m) entfällt zugunsten von MIN_EDGE (5 m): Der alte Grund - Laternen wählten
ihre Straßenseite über den Listenindex - fällt weg, sobald eine spätere Aufgabe die Seite aus
der Geometrie bestimmt. test_park_street.py dokumentiert das jetzt: die beiden 11-m-Enden der
geschnittenen Gasse bleiben deshalb stehen, statt wie vorher zu verschwinden.

Beim Nachrechnen der Prüfkörper aus dem Auftrag fielen zwei echte Fehler auf: Der Reste-unter-
Mindestlänge-Test schnitt ursprünglich bis übers Streckenende hinaus, sodass der Rest schon
geometrisch verschwand und der MIN_EDGE-Filter nie geprüft wurde - jetzt schneidet er innerhalb
der Strecke, sodass tatsächlich ein zu kurzes Stück verworfen wird. Und die neue
Korridor-Regressionswache verglich (12-7)/2 > 2.5, was mit den echten Maßen immer falsch ist
(2.5 > 2.5); korrigiert auf >=, denn die Halbdifferenz trifft die Toleranz exakt.

Ausserdem musste eine Test-Fixture in test_park_apply.py von einem 5-m-Reststück (exakt an der
neuen MIN_EDGE-Grenze, hätte also entgegen der ursprünglichen Testabsicht überlebt) auf 3 m
verkürzt werden, damit der Test weiterhin prüft, dass echte Kurzreste wegfallen.

run.py bleibt unangetastet: Es ist bereits durch eine frühere Aufgabe gebrochen und wird von der
nächsten Aufgabe repariert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:41:08 +02:00

591 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
from .roads import CLASS_WIDTH, Edge, Graph, attach, cut_edges
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_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]]
graph: Graph
buildings: list[tuple[Polygon, dict, bool]] # Grundriss, {"height", "source"}, spekulativ
palms: list[tuple[float, float]]
paths: list[Polygon] = field(default_factory=list)
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)]
# Die Gasse verschwindet auch aus dem Graphen, und die Jefferson Avenue kommt als Kante
# dazu - mit attach() an die Querstraße gehängt, damit dort eine echte Kreuzung entsteht
# statt eines freien Endes. Die Klasse folgt der Breite aus der Parkdatei wie bei jeder
# anderen Kante auch.
graph = cut_edges(world.graph, cut)
klass = min(CLASS_WIDTH, key=lambda name: abs(CLASS_WIDTH[name] - street.width))
graph.edges.append(Edge(a=attach(graph, street.start), b=attach(graph, street.end),
points=[tuple(street.start), tuple(street.end)],
width=street.width, klass=klass))
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, graph=graph, 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.
Seit Teil 2 ist world.roads ausdrücklich der gemessene *Korridor*, nicht die gebaute
Fahrbahn: Die Fahrbahn einer 12-m-Straße ist nur 7 m breit, und gegen sie gemessen lägen
die Parkecken plötzlich 2,5 m daneben. Diese Prüfung gehört an den Korridor, weil sie
fragt, ob sich die Extraktion verschoben hat — nicht, wie breit wir bauen.
"""
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])]
# Ein einziger Schnitt für das ganze Parkviereck, nicht je Sperrfläche: quad ist die Anlage
# selbst, die die alten Kanten ersetzt - die Sperrflächen (Tribüne usw.) liegen innerhalb und
# kappen für sich genommen nichts, das nicht schon der Parkumriss gekappt hätte.
graph = cut_edges(world.graph, quad)
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, graph=graph, 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