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>
591 lines
26 KiB
Python
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
|