From 26aafbbbd54fcf5bf21adb3b1fb2d5a5de460d77 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 16 Sep 2026 07:03:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(extract):=20Parkanlage=20aus=20OpenStreetM?= =?UTF-8?q?ap=20mit=20Wegen,=20Fl=C3=A4chen=20und=20Geb=C3=A4uden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- tools/extract/leonida/park.py | 169 +++++++++++++++++++++++- tools/extract/tests/test_park_layout.py | 139 +++++++++++++++++++ 2 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 tools/extract/tests/test_park_layout.py diff --git a/tools/extract/leonida/park.py b/tools/extract/leonida/park.py index e7d9a03..5666ce6 100644 --- a/tools/extract/leonida/park.py +++ b/tools/extract/leonida/park.py @@ -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 diff --git a/tools/extract/tests/test_park_layout.py b/tools/extract/tests/test_park_layout.py new file mode 100644 index 0000000..f3bdf37 --- /dev/null +++ b/tools/extract/tests/test_park_layout.py @@ -0,0 +1,139 @@ +from types import SimpleNamespace + +import pytest +from shapely.geometry import Point + +from leonida.park import (M_PER_DEG_LAT, M_PER_DEG_LON, REF_LAT, REF_LON, Keepout, ParkFit, build_layout, + building_height, keepout_polygon) + + +def latlon(x, y): + return REF_LAT + y / M_PER_DEG_LAT, REF_LON + x / M_PER_DEG_LON + + +def config(keepouts=()): + """Echter Park 100 × 200 m, Spielpark 200 × 400 m ab (1000 | 1000), Anlage 50 m vom Westrand: + Welt-x = 1050 + x, Welt-y = 1000 + 2 · y.""" + return SimpleNamespace( + real={"sw": latlon(0, 0), "se": latlon(100, 0), "nw": latlon(0, 200)}, + game={"sw": (1000.0, 1000.0), "se": (1200.0, 1000.0), "ne": (1200.0, 1400.0), "nw": (1000.0, 1400.0)}, + scale_u=1.0, offset_u=50.0, keepouts=tuple(keepouts), + main_width=6.0, path_width=3.0, service_width=6.0, default_height=4.0, + ) + + +def way(tags, *points, id=1): + return {"id": id, "tags": tags, "geometry": [list(latlon(x, y)) for x, y in points]} + + +def square(tags, x, y, size=10, id=1): + return way(tags, (x, y), (x + size, y), (x + size, y + size), (x, y + size), (x, y), id=id) + + +def layout(elements, keepouts=()): + cfg = config(keepouts) + return build_layout(elements, cfg, ParkFit(cfg)) + + +def test_footway_is_three_metres_wide_and_a_named_main_path_six(): + (plain,) = layout([way({"highway": "footway"}, (20, 100), (80, 100))]).paths + assert plain.contains(Point(1100, 1201.4)) and not plain.contains(Point(1100, 1201.6)) + (main,) = layout([way({"highway": "footway", "name": "Flamingo Park Path (13th St)"}, (20, 100), (80, 100))]).paths + assert main.contains(Point(1100, 1202.9)) and not main.contains(Point(1100, 1203.1)) + + +def test_crossing_paths_become_one_surface(): + result = layout([way({"highway": "footway"}, (20, 100), (80, 100), id=1), + way({"highway": "path"}, (50, 60), (50, 140), id=2)]) + assert len(result.paths) == 1 + + +def test_path_is_cut_at_the_real_park_edge(): + (path,) = layout([way({"highway": "footway"}, (50, 150), (50, 260))]).paths + assert path.bounds[3] <= 1400 + 1.5 + 1e-6 + + +def test_path_ending_near_the_west_edge_runs_on_to_the_road(): + (path,) = layout([way({"highway": "footway"}, (40, 100), (3, 100))]).paths + assert path.contains(Point(1001, 1200)) + + +def test_path_along_the_edge_is_not_stretched(): + (path,) = layout([way({"highway": "footway"}, (3, 50), (3, 150))]).paths + assert not path.contains(Point(1010, 1200)) + assert path.bounds[1] == pytest.approx(1100 - 1.5) + + +def test_path_ending_far_from_the_edge_stays_as_it_is(): + (path,) = layout([way({"highway": "footway"}, (40, 100), (20, 100))]).paths + assert path.bounds[0] == pytest.approx(1070 - 1.5) + + +def test_surfaces_are_sorted_by_kind_and_dog_parks_stay_lawn(): + elements = [ + square({"leisure": "playground"}, 10, 10, id=1), + square({"leisure": "pitch", "sport": "tennis"}, 30, 10, id=2), + square({"leisure": "pitch", "sport": "baseball"}, 50, 10, id=3), + square({"amenity": "parking"}, 70, 10, id=4), + square({"leisure": "swimming_pool"}, 10, 40, id=5), + square({"leisure": "sports_centre"}, 30, 40, id=6), + square({"leisure": "pitch", "sport": "soccer;american_football"}, 50, 40, id=7), + square({"leisure": "dog_park"}, 70, 40, id=8), + ] + assert [kind for _, kind in layout(elements).surfaces] == ["field", "field", "court", "court", "pool", "parking", "sand"] + + +def test_features_centred_outside_the_real_park_are_left_out(): + assert layout([square({"leisure": "pitch", "sport": "soccer"}, 40, 195, size=20)]).surfaces == [] + + +def test_lawn_is_the_whole_game_park(): + assert layout([]).lawn.area == pytest.approx(200 * 400) + + +def test_buildings_take_their_height_or_the_default_and_roofs_without_walls_are_dropped(): + result = layout([ + square({"building": "yes", "height": "8.5"}, 10, 10, id=1), + square({"building": "yes"}, 30, 10, id=2), + square({"building": "yes", "height": "0.0"}, 50, 10, id=3), + square({"building": "yes", "leisure": "sports_centre"}, 70, 10, id=4), + ]) + assert sorted(height for _, height in result.buildings) == [4.0, 4.0, 8.5] + assert result.surfaces == [] + + +def test_building_height_reads_plain_numbers_and_units(): + assert building_height({"height": "10.4"}, 4.0) == 10.4 + assert building_height({"height": "12 m"}, 4.0) == 12.0 + assert building_height({"height": "hoch"}, 4.0) == 4.0 + assert building_height({}, 4.0) == 4.0 + assert building_height({"height": "0"}, 4.0) is None + + +def test_keepout_polygon_turns_like_places_js(): + plain = keepout_polygon(Keepout("ort", (100.0, 200.0), 0.0, (-10.0, 20.0, -5.0, 8.0))) + assert plain.bounds == pytest.approx((90.0, 192.0, 120.0, 205.0)) + turned = keepout_polygon(Keepout("ort", (0.0, 0.0), 90.0, (0.0, 10.0, -1.0, 1.0))) + assert turned.bounds == pytest.approx((-1.0, 0.0, 1.0, 10.0)) # +x zeigt nach 90° nach Norden + + +def test_keepouts_cut_fields_and_paths_keep_buildings_out_and_warn(): + keepout = Keepout("ort", (1100.0, 1100.0), 0.0, (-10.0, 10.0, -10.0, 10.0)) # Welt x 1090–1110, y 1090–1110 + result = layout([ + square({"leisure": "pitch", "sport": "baseball"}, 40, 40, size=20, id=1), # Welt x 1090–1110, y 1080–1120 + way({"highway": "footway"}, (50, 30), (50, 70), id=2), # Welt x 1100, y 1060–1140 + square({"building": "yes"}, 55, 48, size=4, id=3), # Welt x 1105–1109, y 1096–1104 + ], keepouts=[keepout]) + hole = keepout_polygon(keepout) + assert all(poly.intersection(hole).area < 1e-6 for poly, _ in result.surfaces) + assert sum(poly.area for poly, _ in result.surfaces) == pytest.approx(20 * 40 - 20 * 20) + assert all(poly.intersection(hole).area < 1e-6 for poly in result.paths) + assert result.buildings == [] + assert result.warnings == ["Parkweg schneidet die Sperrfläche ort"] + + +def test_service_ways_become_six_metre_service_surfaces(): + result = layout([way({"highway": "service"}, (20, 20), (80, 20))]) + (service,) = result.service + assert service.contains(Point(1100, 1042.9)) and not service.contains(Point(1100, 1043.1)) + assert result.paths == []