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>
163 lines
7.3 KiB
Python
163 lines
7.3 KiB
Python
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_paths_stop_at_the_park_edge_instead_of_bulging_over_it():
|
||
"""LineString.buffer() setzt an jedes Wegende eine runde Kappe von einer halben Wegbreite.
|
||
|
||
Der letzte Stützpunkt liegt schon auf der Parkkante (_extend zielt dorthin), die Kappe ragte
|
||
also bis zu 1,5 m (Fußweg) bzw. 3 m (Hauptweg) darüber hinaus und lag als Betonzunge auf dem
|
||
Belag daneben — an den echten Daten 95,96 m² außerhalb des Vierecks.
|
||
"""
|
||
cfg = config()
|
||
quad = ParkFit(cfg).game_quad
|
||
(path,) = build_layout([way({"highway": "footway"}, (50, 150), (50, 260))], cfg, ParkFit(cfg)).paths
|
||
assert path.difference(quad).area == 0
|
||
assert path.bounds[3] == pytest.approx(1400.0)
|
||
|
||
|
||
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 == []
|
||
|
||
|
||
def test_keepouts_cut_service_surfaces_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([way({"highway": "service"}, (30, 50), (70, 50))], # Welt x 1080–1120, y 1100
|
||
keepouts=[keepout])
|
||
hole = keepout_polygon(keepout)
|
||
assert result.service and all(poly.intersection(hole).area < 1e-6 for poly in result.service)
|
||
assert result.warnings == ["Zufahrt schneidet die Sperrfläche ort"]
|