feat(extract): Parkanlage aus OpenStreetMap mit Wegen, Flächen und Gebäuden
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ad5d0468db
commit
26aafbbbd5
2 files changed
+306
-2
No files matched your search
@@ -7,12 +7,14 @@ Ein Formfehler in der Parkdatei bricht mit ParkError ab, wie bei annotations.tom
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.geometry import LineString, Polygon
|
||||
from shapely.ops import unary_union
|
||||
|
||||
|
||||
class ParkError(ValueError):
|
||||
@@ -250,3 +252,166 @@ class ParkFit:
|
||||
"""Das echte Parkviereck in lokalen Metern."""
|
||||
(ox, oy), (ux, uy), (vx, vy) = self.origin, self.real_u, self.real_v
|
||||
return Polygon([(ox, oy), (ox + ux, oy + uy), (ox + ux + vx, oy + uy + vy), (ox + vx, oy + vy)])
|
||||
|
||||
|
||||
# --- Anlage aus OpenStreetMap ------------------------------------------------------------------
|
||||
|
||||
FIELD_SPORTS = {"baseball", "soccer"}
|
||||
COURT_SPORTS = {"tennis", "basketball", "racquet"}
|
||||
PATH_HIGHWAYS = {"footway", "path", "pedestrian", "steps", "cycleway"}
|
||||
MAIN_PATHS = {"Flamingo Park Path (13th St)", "Flamingo Park Path (Jefferson Ave)"}
|
||||
SURFACE_ORDER = ("field", "court", "pool", "parking", "sand")
|
||||
EDGE_REACH = 5.0 # m: so nah am echten West- oder Ostrand endende Wege laufen bis zur Straßenkante
|
||||
EXTEND_MAX = 90.0 # m: der Rasenstreifen im Osten ist 37 m breit, schräg ankommende Wege brauchen bis zu doppelt so viel
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParkLayout:
|
||||
lawn: Polygon # das ganze Parkpolygon im Spiel
|
||||
surfaces: list[tuple[Polygon, str]] # Felder, Plätze, Pool, Parkplatz, Spielplatz, ohne Rasen
|
||||
paths: list[Polygon]
|
||||
service: list[Polygon]
|
||||
buildings: list[tuple[Polygon, float]]
|
||||
keepouts: list[Polygon]
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def load_elements(path: Path) -> list[dict]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))["elements"]
|
||||
|
||||
|
||||
def keepout_polygon(keepout: Keepout) -> Polygon:
|
||||
"""Außenmaße eines Orts in Weltkoordinaten, gedreht wie modelToWorld in tests/placesSite.test.js."""
|
||||
r = math.radians(keepout.rotation)
|
||||
cos, sin = math.cos(r), math.sin(r)
|
||||
min_x, max_x, min_z, max_z = keepout.bounds
|
||||
ax, ay = keepout.at
|
||||
corners = [(min_x, min_z), (max_x, min_z), (max_x, max_z), (min_x, max_z)]
|
||||
return Polygon([(ax + x * cos + z * sin, ay + x * sin - z * cos) for x, z in corners])
|
||||
|
||||
|
||||
def building_height(tags: dict, default: float) -> float | None:
|
||||
"""Höhe aus OSM, sonst default; None für Dächer ohne Wände (height = 0)."""
|
||||
raw = tags.get("height")
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
height = float(str(raw).split()[0])
|
||||
except (ValueError, IndexError):
|
||||
return default
|
||||
return None if height <= 0 else height
|
||||
|
||||
|
||||
def build_layout(elements: list[dict], config, fit: ParkFit) -> ParkLayout:
|
||||
real_quad = fit.real_quad()
|
||||
keepouts = [keepout_polygon(k) for k in config.keepouts]
|
||||
blocked = unary_union(keepouts)
|
||||
surfaces = {kind: [] for kind in SURFACE_ORDER}
|
||||
footways, service, buildings = [], [], []
|
||||
for element in elements:
|
||||
tags, coords = element["tags"], element["geometry"]
|
||||
highway = tags.get("highway")
|
||||
if highway in PATH_HIGHWAYS or highway == "service":
|
||||
if len(coords) < 2:
|
||||
continue
|
||||
width = (config.service_width if highway == "service"
|
||||
else config.main_width if tags.get("name") in MAIN_PATHS else config.path_width)
|
||||
real_line = LineString([local_metres(lat, lon) for lat, lon in coords])
|
||||
for piece in _pieces(real_line.intersection(real_quad)):
|
||||
if piece.geom_type != "LineString" or piece.length == 0:
|
||||
continue
|
||||
game = _extend(fit, piece, [fit.local_to_game(x, y) for x, y in piece.coords])
|
||||
(service if highway == "service" else footways).append(LineString(game).buffer(width / 2))
|
||||
continue
|
||||
if len(coords) < 4:
|
||||
continue
|
||||
real_poly = Polygon([local_metres(lat, lon) for lat, lon in coords])
|
||||
if not real_poly.is_valid or not real_quad.contains(real_poly.centroid):
|
||||
continue
|
||||
game_poly = Polygon([fit.local_to_game(x, y) for x, y in real_poly.exterior.coords])
|
||||
if "building" in tags:
|
||||
height = building_height(tags, config.default_height)
|
||||
if height is not None and not game_poly.intersects(blocked):
|
||||
buildings.append((game_poly, height))
|
||||
continue
|
||||
kind = _surface_kind(tags)
|
||||
if kind is not None:
|
||||
surfaces[kind].extend(_polygons(game_poly.difference(blocked)))
|
||||
|
||||
warnings, paths = [], []
|
||||
if footways:
|
||||
union = unary_union(footways)
|
||||
warnings = [f"Parkweg schneidet die Sperrfläche {k.name}"
|
||||
for k, polygon in zip(config.keepouts, keepouts) if union.intersects(polygon)]
|
||||
paths = _polygons(union.difference(blocked))
|
||||
return ParkLayout(
|
||||
lawn=fit.game_quad,
|
||||
surfaces=[(poly, kind) for kind in SURFACE_ORDER for poly in surfaces[kind]],
|
||||
paths=paths,
|
||||
service=_polygons(unary_union(service)) if service else [],
|
||||
buildings=buildings,
|
||||
keepouts=keepouts,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def _surface_kind(tags: dict) -> str | None:
|
||||
sports = set(tags.get("sport", "").split(";"))
|
||||
leisure = tags.get("leisure")
|
||||
if leisure == "pitch":
|
||||
return "field" if sports & FIELD_SPORTS else "court" if sports & COURT_SPORTS else None
|
||||
if leisure == "sports_centre":
|
||||
return "court"
|
||||
if leisure == "swimming_pool":
|
||||
return "pool"
|
||||
if leisure == "playground":
|
||||
return "sand"
|
||||
if tags.get("amenity") == "parking":
|
||||
return "parking"
|
||||
return None
|
||||
|
||||
|
||||
def _extend(fit: ParkFit, real_line: LineString, coords: list) -> list:
|
||||
"""Enden höchstens EDGE_REACH vor dem echten West- oder Ostrand laufen nach außen bis zur Straßenkante."""
|
||||
coords = list(coords)
|
||||
ux, uy = fit.game_u[0] / fit.game_width, fit.game_u[1] / fit.game_width
|
||||
for end, inner in ((0, 1), (-1, -2)):
|
||||
if len(coords) < 2:
|
||||
return coords
|
||||
s, _ = fit.park_coords(*real_line.coords[end])
|
||||
west, east = s * fit.real_width, (1 - s) * fit.real_width
|
||||
side = -1 if west <= EDGE_REACH else 1 if east <= EDGE_REACH else 0
|
||||
if side == 0:
|
||||
continue
|
||||
(x0, y0), (x1, y1) = coords[inner], coords[end]
|
||||
length = math.hypot(x1 - x0, y1 - y0)
|
||||
if length == 0:
|
||||
continue
|
||||
dx, dy = (x1 - x0) / length, (y1 - y0) / length
|
||||
if (dx * ux + dy * uy) * side < 0.5: # läuft längs der Kante statt auf sie zu
|
||||
continue
|
||||
ray = LineString([(x1, y1), (x1 + dx * EXTEND_MAX, y1 + dy * EXTEND_MAX)])
|
||||
hits = _points(ray.intersection(fit.game_quad.exterior))
|
||||
if hits:
|
||||
coords[end] = min(hits, key=lambda p: math.hypot(p[0] - x1, p[1] - y1))
|
||||
return coords
|
||||
|
||||
|
||||
def _pieces(geometry) -> list:
|
||||
if geometry.is_empty:
|
||||
return []
|
||||
return list(getattr(geometry, "geoms", [geometry]))
|
||||
|
||||
|
||||
def _polygons(geometry, min_area: float = 1.0) -> list[Polygon]:
|
||||
return [g for g in _pieces(geometry) if g.geom_type == "Polygon" and g.area >= min_area]
|
||||
|
||||
|
||||
def _points(geometry) -> list[tuple[float, float]]:
|
||||
out = []
|
||||
for g in _pieces(geometry):
|
||||
if g.geom_type == "Point":
|
||||
out.append((g.x, g.y))
|
||||
elif g.geom_type in ("LineString", "LinearRing"):
|
||||
out.extend(g.coords)
|
||||
return out
|
||||
Reference in new issue
Block a user