fix(extract): Chunk-Ausgabe lauf-stabil, Eckenprüfung in beide Richtungen
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>
This commit is contained in:
1 parent
a3504df43d
commit
3c74202b5e
10 files changed
+333
-102
No files matched your search
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
from itertools import groupby
|
||||
from pathlib import Path
|
||||
|
||||
import shapely
|
||||
from shapely.geometry import Polygon, box
|
||||
|
||||
from .coords import CHUNK_SIZE, SLICE
|
||||
@@ -31,29 +33,76 @@ def _rings(poly: Polygon, i: int, j: int) -> list[list[list[float]]]:
|
||||
im Park) ist genauso echte Flaeche wie der Aussenring und darf beim Zuschneiden nicht
|
||||
verschwinden - sonst wird der Block zu Asphalt, der Innenhof zu Gebaeudemasse, der Teich
|
||||
zu Wiese.
|
||||
|
||||
normalize() zuerst, weil dieselbe Flaeche sonst je nach Rechenweg anders in der Datei
|
||||
steht: GEOS waehlt Ringstartpunkt und Wicklung nach Belieben. unary_union() in
|
||||
apply_street() fasst dafuer das kartenweite Strassenpolygon neu zusammen und aenderte so
|
||||
88 Chunkdateien, in denen sich geometrisch nichts bewegt hatte - symmetrische Differenz
|
||||
der Strassenvereinigung exakt 0,00 m2 in allen 88. normalize() legt beides fest
|
||||
(lexikographisch kleinster Punkt zuerst, Aussenring im Uhrzeigersinn, Loecher dagegen)
|
||||
und macht die Ausgabe damit vom Rechenweg unabhaengig.
|
||||
|
||||
Die vereinheitlichte Wicklung ist unbedenklich, nachgeprueft an allen drei Verbrauchern:
|
||||
geometry.js stellt sie beim Laden ohnehin selbst her (clockwise(), Zeile 50), das
|
||||
Halbstrahl-Verfahren von pointInPolygon() in props.js kommt ohne sie aus, und PIL fuellt
|
||||
in overview.py unabhaengig von Wicklung und Startpunkt (nachgemessen, Bild bytegleich).
|
||||
"""
|
||||
ox = SLICE["x0"] + i * CHUNK_SIZE
|
||||
oy = SLICE["y1"] - j * CHUNK_SIZE
|
||||
poly = shapely.normalize(poly)
|
||||
# Welt (X, Y) -> three (x, z) -> relativ zur Chunk-Ecke
|
||||
return [[[round(x - ox, 2), round(-y - (-oy), 2)] for x, y in ring.coords]
|
||||
for ring in (poly.exterior, *poly.interiors)]
|
||||
|
||||
|
||||
def _sorted_surfaces(entries: list[dict]) -> list[dict]:
|
||||
"""Belaege nur innerhalb einer Art sortieren - zwischen den Arten traegt die Reihenfolge Bedeutung.
|
||||
|
||||
overview.py malt die Belaege in Listenreihenfolge uebereinander, der spaetere gewinnt. An den
|
||||
ausgelieferten Chunks ueberlappen sich verschiedene Arten auf 36.873 m2: der Parkrasen liegt
|
||||
unter Feldern (19.573 m2), Plaetzen (11.872 m2), Parkplatz (2.878 m2), Pool (539 m2) und
|
||||
Spielplatzsand (270 m2), die Stadtflaeche ueber dem Strandsand (1.729 m2). Ein Sortieren ueber
|
||||
die Arten hinweg kehrte das um und liesse die Felder unter dem Rasen verschwinden - und eine
|
||||
feste Rangfolge je Art gaebe es dafuer nicht, weil "sand" als Strand vor und als Spielplatz
|
||||
hinter "park" steht. Die Abfolge der Arten selbst steht fest im Code (run.py, SURFACE_ORDER in
|
||||
park.py) und ist damit schon eindeutig; GEOS mischt nur innerhalb einer Art.
|
||||
"""
|
||||
return [entry for _, run in groupby(entries, key=lambda e: e["kind"])
|
||||
for entry in sorted(run, key=lambda e: e["poly"])]
|
||||
|
||||
|
||||
def chunk_payload(i, j, *, land, surfaces, roads, centerlines, buildings, palms, paths=()) -> dict:
|
||||
"""Ein Chunk als Ausgabestruktur, deterministisch sortiert.
|
||||
|
||||
Sortiert wird nach der gerundeten Ausgabestruktur selbst, nicht nach Flaeche, Schwerpunkt oder
|
||||
Bounding-Box: nur sie ist total geordnet und ohne Sonderfall, denn zwei Eintraege mit gleichem
|
||||
Schluessel sind dann bytegleich und ihre Reihenfolge wieder egal. Die abgeleiteten Zahlen
|
||||
koennen sich dagegen zwei verschiedene Flaechen teilen, und die Ausgabe hinge fuer diese
|
||||
beiden weiterhin an der Listenposition, die GEOS vergibt.
|
||||
|
||||
centerlines bleiben bewusst unsortiert und unnormalisiert: dort traegt sowohl die Reihenfolge
|
||||
als auch die Laufrichtung Bedeutung. vergeLampPoints() in props.js setzt jede Laterne
|
||||
abwechselnd links und rechts der Fahrbahn (gerader Index links, ungerader rechts) und verwirft
|
||||
sie, wenn auf ihrer Seite kein freier Platz ist - an den ausgelieferten Chunks wechselten beim
|
||||
Sortieren 1.848 von 3.921 Laternen die Strassenseite. Die Mittellinien sind auch ohne
|
||||
Sortieren stabil: sie entstehen aus centerlines() und difference(), nie aus unary_union().
|
||||
"""
|
||||
ox = SLICE["x0"] + i * CHUNK_SIZE
|
||||
oy = SLICE["y1"] - j * CHUNK_SIZE
|
||||
return {
|
||||
"chunk": [i, j],
|
||||
"origin": [ox, -oy],
|
||||
"land": [_rings(p, i, j) for p in land],
|
||||
"surfaces": [{"poly": _rings(p, i, j), "kind": k} for p, k in surfaces],
|
||||
"roads": [{"poly": _rings(p, i, j), "bridge": b} for p, b in roads],
|
||||
"land": sorted(_rings(p, i, j) for p in land),
|
||||
"surfaces": _sorted_surfaces([{"poly": _rings(p, i, j), "kind": k} for p, k in surfaces]),
|
||||
"roads": sorted(({"poly": _rings(p, i, j), "bridge": b} for p, b in roads),
|
||||
key=lambda r: (r["poly"], r["bridge"])),
|
||||
"centerlines": [[[round(x - ox, 2), round(-y + oy, 2)] for x, y in ln.coords] for ln in centerlines],
|
||||
"buildings": [{"poly": _rings(p, i, j), "height": h["height"],
|
||||
"source": h["source"], "speculative": s} for p, h, s in buildings],
|
||||
"palms": [[round(x - ox, 2), round(-y + oy, 2)] for x, y in palms],
|
||||
"buildings": sorted(({"poly": _rings(p, i, j), "height": h["height"],
|
||||
"source": h["source"], "speculative": s} for p, h, s in buildings),
|
||||
key=lambda b: (b["poly"], b["height"], b["source"], b["speculative"])),
|
||||
"palms": sorted([round(x - ox, 2), round(-y + oy, 2)] for x, y in palms),
|
||||
# Parkwege (Flamingo Park, docs/superpowers/specs/2026-09-15-flamingo-park-design.md), aufgebaut wie roads.
|
||||
"paths": [{"poly": _rings(p, i, j)} for p in paths],
|
||||
"paths": sorted(({"poly": _rings(p, i, j)} for p in paths), key=lambda p: p["poly"]),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -279,7 +279,31 @@ class ParkLayout:
|
||||
|
||||
|
||||
def load_elements(path: Path) -> list[dict]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))["elements"]
|
||||
"""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:
|
||||
@@ -344,7 +368,12 @@ def build_layout(elements: list[dict], config, fit: ParkFit) -> ParkLayout:
|
||||
if footways:
|
||||
union = unary_union(footways)
|
||||
warnings += _keepout_warnings(union, config.keepouts, keepouts, "Parkweg")
|
||||
paths = _polygons(union.difference(blocked))
|
||||
# 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")
|
||||
@@ -487,13 +516,26 @@ def apply_street(world: World, street: Street) -> World:
|
||||
surfaces=surfaces, palms=palms)
|
||||
|
||||
|
||||
def check_corners(config, roads: list[tuple[Polygon, bool]], tolerance: float = 2.0) -> None:
|
||||
"""Jede Spielecke muss an einer Straße liegen, sonst hat sich die Extraktion geändert."""
|
||||
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.
|
||||
"""
|
||||
for name, (x, y) in config.game.items():
|
||||
corner = Point(x, y)
|
||||
if not any(poly.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 von jeder Straße: "
|
||||
"Ecken in flamingo-park.toml neu messen")
|
||||
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:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.geometry import LineString, Polygon
|
||||
from leonida.emit import chunk_payload, split_by_chunk, split_by_chunk_all
|
||||
|
||||
BIG = Polygon([(-1000, 2500), (-700, 2500), (-700, 2200), (-1000, 2200)]) # Chunk (0,0) und Nachbarn
|
||||
HEIGHT = {"height": 9.0, "source": "district"}
|
||||
|
||||
def test_polygon_is_clipped_into_its_chunks():
|
||||
parts = split_by_chunk(BIG)
|
||||
@@ -48,6 +49,56 @@ def test_polygon_holes_survive_into_the_payload():
|
||||
assert max(x for x, _ in exterior) > max(x for x, _ in hole)
|
||||
assert all(0 <= x <= 250 and 0 <= z <= 250 for x, z in hole)
|
||||
|
||||
def test_the_same_geometry_in_another_order_gives_the_same_output():
|
||||
"""Die Ausgabe darf nicht am Rechenweg haengen, nur an der Geometrie.
|
||||
|
||||
unary_union() in park.py fasst das kartenweite Strassenpolygon neu zusammen und gibt dieselbe
|
||||
Flaeche danach an anderer Listenstelle und mit anderem Ringstartpunkt zurueck. Das aenderte
|
||||
88 Chunkdateien, in denen sich geometrisch nichts bewegt hatte (symmetrische Differenz der
|
||||
Strassenvereinigung 0,00 m2 in allen 88). Hier steht dieselbe Welt zweimal: einmal in der
|
||||
einen, einmal in der anderen Reihenfolge, und `zweite` beschreibt `a` ausserdem mit anderem
|
||||
Startpunkt und anderer Wicklung."""
|
||||
a = Polygon([(-990, 2490), (-980, 2490), (-980, 2480), (-990, 2480)])
|
||||
b = Polygon([(-960, 2460), (-950, 2460), (-950, 2450), (-960, 2450)])
|
||||
a_gedreht = Polygon([(-980, 2480), (-980, 2490), (-990, 2490), (-990, 2480)])
|
||||
assert a.equals(a_gedreht) and list(a.exterior.coords) != list(a_gedreht.exterior.coords)
|
||||
erste = chunk_payload(0, 0, land=[a, b], surfaces=[(a, "urban"), (b, "urban")],
|
||||
roads=[(a, False), (b, True)], centerlines=[],
|
||||
buildings=[(a, HEIGHT, False), (b, HEIGHT, True)],
|
||||
palms=[(-990.0, 2490.0), (-960.0, 2460.0)], paths=[a, b])
|
||||
zweite = chunk_payload(0, 0, land=[b, a_gedreht], surfaces=[(b, "urban"), (a_gedreht, "urban")],
|
||||
roads=[(b, True), (a_gedreht, False)], centerlines=[],
|
||||
buildings=[(b, HEIGHT, True), (a_gedreht, HEIGHT, False)],
|
||||
palms=[(-960.0, 2460.0), (-990.0, 2490.0)], paths=[b, a_gedreht])
|
||||
assert erste == zweite
|
||||
|
||||
|
||||
def test_surfaces_keep_the_order_of_their_kinds():
|
||||
"""Gegenprobe zum Sortieren: overview.py malt die Belaege in Listenreihenfolge uebereinander.
|
||||
|
||||
Der Parkrasen liegt unter den Feldern; sortierte man ueber die Arten hinweg, deckte der grosse
|
||||
Rasen sie auf map.png wieder zu (19.573 m2 Feld allein im Flamingo Park)."""
|
||||
rasen = Polygon([(-1000, 2500), (-900, 2500), (-900, 2400), (-1000, 2400)])
|
||||
feld = Polygon([(-980, 2480), (-940, 2480), (-940, 2440), (-980, 2440)])
|
||||
payload = chunk_payload(0, 0, land=[], surfaces=[(rasen, "park"), (feld, "field")], roads=[],
|
||||
centerlines=[], buildings=[], palms=[])
|
||||
assert [s["kind"] for s in payload["surfaces"]] == ["park", "field"]
|
||||
|
||||
|
||||
def test_centerlines_keep_their_order_and_direction():
|
||||
"""Die zweite Gegenprobe: Mittellinien werden bewusst nicht sortiert und nicht normalisiert.
|
||||
|
||||
vergeLampPoints() in src/world/props.js setzt jede Laterne abwechselnd links und rechts der
|
||||
Fahrbahn (gerader Index links, ungerader rechts) und dreht sie in Laufrichtung der Linie.
|
||||
Sortieren verschob an den ausgelieferten Chunks 1.848 von 3.921 Laternen auf die andere
|
||||
Strassenseite; normalize() wuerde zusaetzlich jede zweite Linie umdrehen."""
|
||||
spaet = LineString([(-900, 2400), (-950, 2400)])
|
||||
frueh = LineString([(-990, 2490), (-960, 2490)])
|
||||
payload = chunk_payload(0, 0, land=[], surfaces=[], roads=[], centerlines=[spaet, frueh],
|
||||
buildings=[], palms=[])
|
||||
assert payload["centerlines"] == [[[100, 100], [50, 100]], [[10, 10], [40, 10]]]
|
||||
|
||||
|
||||
def test_split_by_chunk_all_yields_polygons_for_a_multi_part_intersection():
|
||||
"""split_by_chunk_all() ist die Variante, die run.py tatsaechlich benutzt (nicht
|
||||
split_by_chunk), und hat einen MultiPolygon-Zweig, den kein anderer Test trifft: ein
|
||||
|
||||
@@ -72,12 +72,33 @@ def test_same_seed_gives_the_same_palms():
|
||||
assert apply_park(world(), layout(), seed=7).palms == apply_park(world(), layout(), seed=7).palms
|
||||
|
||||
|
||||
def test_corners_must_lie_at_a_road():
|
||||
def corners(**moved):
|
||||
game = {"sw": (0.0, 0.0), "se": (100.0, 0.0), "ne": (100.0, 200.0), "nw": (0.0, 200.0)}
|
||||
return SimpleNamespace(game={**game, **moved})
|
||||
|
||||
|
||||
def test_corners_must_lie_at_a_road_when_it_pulls_back():
|
||||
"""Die eine Richtung: die Straße bleibt, wo sie ist, die Ecke steht daneben."""
|
||||
roads = [(box(-20, -20, 0, 220), False), (box(100, -20, 120, 220), False)]
|
||||
check_corners(SimpleNamespace(game={"sw": (0.0, 0.0), "se": (100.0, 0.0), "ne": (100.0, 200.0), "nw": (0.0, 200.0)}), roads)
|
||||
far = SimpleNamespace(game={"sw": (0.0, 0.0), "se": (100.0, 0.0), "ne": (100.0, 200.0), "nw": (30.0, 200.0)})
|
||||
check_corners(corners(), roads)
|
||||
with pytest.raises(ParkError, match="Spielecke nw"):
|
||||
check_corners(far, roads)
|
||||
check_corners(corners(nw=(30.0, 200.0)), roads)
|
||||
|
||||
|
||||
def test_a_road_that_grew_inward_over_the_corner_is_caught():
|
||||
"""Die andere Richtung, und der Grund für die Messung gegen den Rand statt gegen die Fläche.
|
||||
|
||||
Die Westkante der linken Straße ist von x = 0 auf x = 6 nach innen gewandert; die SW- und die
|
||||
NW-Ecke liegen jetzt 6 m tief in der Fahrbahn. apply_park() schnitte die verbreiterte Straße
|
||||
wortlos wieder auf das alte Viereck zurück — deshalb muss die Prüfung hier abbrechen. Mit dem
|
||||
Flächenabstand tut sie das nicht: er ist für eine Ecke mitten in der Straße 0 (die Zusicherung
|
||||
unten misst genau das), und die alte Bedingung poly.distance(corner) <= 2.0 ließ den Fall
|
||||
durchlaufen.
|
||||
"""
|
||||
grown = [(box(-20, -20, 6, 220), False), (box(100, -20, 120, 220), False)]
|
||||
assert min(poly.distance(Point(0, 0)) for poly, _ in grown) == 0
|
||||
with pytest.raises(ParkError, match="Spielecke sw"):
|
||||
check_corners(corners(), grown)
|
||||
|
||||
|
||||
def test_flamingo_park_goes_into_a_world_with_roads_around_the_park():
|
||||
@@ -93,4 +114,10 @@ def test_flamingo_park_goes_into_a_world_with_roads_around_the_park():
|
||||
# Bekannter Zustand: die Zufahrt zum Parkplatz quert die Sperrfläche der Tribüne und wird dort
|
||||
# ausgeschnitten (Task 5). Jede weitere oder andere Warnung ist ein echter Befund.
|
||||
assert [m for m in messages if m.startswith("Warnung")] == ["Warnung: Zufahrt schneidet die Sperrfläche tribuene"]
|
||||
assert any(message.startswith("Flamingo Park:") for message in messages)
|
||||
# Die Flächenzahl gehört mit in den Vergleich: _surface_kind() kennt nur die Sportarten aus
|
||||
# FIELD_SPORTS und COURT_SPORTS und lässt jede andere wortlos weg. Taggt OpenStreetMap beim
|
||||
# nächsten Abruf ein Feld um — aus "tennis" wird "pickleball", aus "soccer" ein "multi" —,
|
||||
# verschwindet es aus dem Park, ohne dass eine Warnung oder ein anderer Test das bemerkt;
|
||||
# hier sinkt dann die 14, und der Abzug wird geprüft statt der Park stillschweigend kleiner.
|
||||
assert [m for m in messages if m.startswith("Flamingo Park:")] == [
|
||||
"Flamingo Park: 2 Wegflächen, 14 Flächen, 11 Gebäude, Jefferson Avenue 12 m breit"]
|
||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from leonida.park import Anchor, Keepout, ParkError, load_park, parse_park
|
||||
from leonida.park import Anchor, Keepout, ParkError, load_elements, load_park, parse_park
|
||||
|
||||
DATA = Path(__file__).resolve().parents[1] / "data"
|
||||
|
||||
@@ -92,3 +92,27 @@ def test_committed_park_file_loads():
|
||||
assert [k.name for k in config.keepouts] == ["hood-yard", "tribuene"]
|
||||
assert (config.scale_u, config.offset_u) == (1.0, 6.25)
|
||||
assert len(config.street.alley_cut) == 12
|
||||
assert len(load_elements(config.osm_path)) > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text, message", [
|
||||
("{ abgeschnitten", "kein gültiges JSON"),
|
||||
('{"license": "ODbL 1.0"}', "Feld 'elements' fehlt oder ist keine Liste"),
|
||||
('{"elements": {"1": {}}}', "Feld 'elements' fehlt oder ist keine Liste"),
|
||||
('{"elements": [42]}', "elements[1] braucht eine Tabelle 'tags'"),
|
||||
('{"elements": [{"id": 7, "geometry": [[25.78, -80.14], [25.79, -80.14]]}]}',
|
||||
"elements[1] (id 7) braucht eine Tabelle 'tags'"),
|
||||
('{"elements": [{"id": 7, "tags": {"highway": "footway"}}]}',
|
||||
"elements[1] (id 7) braucht 'geometry' als Liste von Punkten"),
|
||||
('{"elements": [{"id": 7, "tags": {}, "geometry": [[25.78]]}]}',
|
||||
"elements[1] (id 7) braucht 'geometry' als Liste von Punkten"),
|
||||
])
|
||||
def test_a_broken_osm_dump_aborts_with_the_file_name(tmp_path, text, message):
|
||||
"""osm.py wird von Hand aufgerufen; ein alter oder halb geschriebener Abzug ist der Normalfall,
|
||||
nicht die Ausnahme. Er soll denselben ParkError mit Dateinamen geben wie die Parkdatei, statt
|
||||
als KeyError irgendwo in build_layout() zu landen."""
|
||||
path = tmp_path / "flamingo-park.json"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
with pytest.raises(ParkError, match="flamingo-park.json") as err:
|
||||
load_elements(path)
|
||||
assert message in str(err.value)
|
||||
@@ -53,6 +53,20 @@ def test_path_is_cut_at_the_real_park_edge():
|
||||
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))
|
||||
|
||||
Reference in new issue
Block a user