Files
gta6/tools/extract/tests/test_emit.py
T
knso ca01141c5b fix(extract): Zebrabalken mittig verteilt, Kantenbreite gerundet
Befund 8, zwei kleine, aber datenwirksame Funde:

- Zebrastreifen: Der Startversatz (-halb + BAR/2, Takt 2*BAR) liess die
  Balkenreihe an einem Fahrbahnrand bündig sitzen und am gegenüber-
  liegenden bis zu eine Taktbreite frei (0,6 m bei Hauptstraße/Boulevard,
  0,4 m bei Wohnstraße), statt der laut Entwurf verlangten Aufteilung
  "Balken 60 cm, Lücke 60 cm" über die ganze Breite. n = floor((2*halb +
  BAR) / (2*BAR)) Balken, symmetrisch bei -(n-1)*BAR gestartet, lassen an
  beiden Rändern denselben Rest frei (nachgemessen: 0,3 m statt 0/0,6 m
  bei Hauptstraße, 0,2 m statt 0/0,4 m bei Wohnstraße), ohne die Balkenzahl
  je Arm zu ändern.
- edges[].breite (Median aus der Distanztransformation) kam ungerundet an
  (Werte wie 31.622776601683793), während jede andere Zahl in der Ausgabe
  auf 2 Stellen gerundet ist. fahrbahn bleibt unverändert, weil es schon
  eine der vier festen SECTION-Konstanten ist.

Zwei Tests, beide ohne die jeweilige Korrektur rot: die Zebrabalken einer
Kreuzungszufahrt lassen ungleiche Ränder (0,0/0,6 m statt 0,3/0,3 m), und
eine krumme Kantenbreite blieb ungerundet in der Ausgabe.
2026-09-18 07:09:15 +02:00

255 lines
15 KiB
Python

import pytest
from shapely.geometry import Polygon
from leonida.emit import _rings, 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)
assert set(parts) >= {(0, 0), (1, 0), (0, 1), (1, 1)}
assert abs(sum(p.area for p in parts.values()) - BIG.area) < 1.0
def test_coordinates_are_chunk_local_three_js():
# chunk_payload() nimmt (wie in run.py) bereits chunk-geschnittene Geometrie entgegen;
# BIG selbst überspannt vier Chunks (siehe Test oben), darum hier zuerst zuschneiden.
clipped = split_by_chunk(BIG)[(0, 0)]
payload = chunk_payload(0, 0, land=[clipped], surfaces=[], carriageway=[], curb=[], sidewalk=[],
markings=[], lots=[], bridges=[], edges=[], buildings=[], palms=[])
ring = payload["land"][0][0] # Ruling 34: [0] ist jetzt der Aussenring
assert payload["origin"] == [-1000, -2500] # three-z = -Y
assert min(x for x, _ in ring) >= -0.01
assert max(x for x, _ in ring) <= 250.01
def test_payload_matches_schema():
payload = chunk_payload(2, 3, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[], markings=[],
lots=[], bridges=[], edges=[], buildings=[], palms=[])
assert set(payload) == {"chunk", "origin", "land", "surfaces", "carriageway", "curb", "sidewalk",
"markings", "lots", "bridges", "edges", "buildings", "palms", "paths"}
assert payload["chunk"] == [2, 3]
def test_paths_are_chunk_local_rings_like_roads():
path = Polygon([(-990, 2490), (-980, 2490), (-980, 2480), (-990, 2480)])
payload = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[], markings=[],
lots=[], bridges=[], edges=[], buildings=[], palms=[], paths=[path])
(entry,) = payload["paths"]
assert set(entry) == {"poly"}
ring = entry["poly"][0]
assert (min(x for x, _ in ring), max(x for x, _ in ring)) == (10, 20)
assert (min(z for _, z in ring), max(z for _, z in ring)) == (10, 20)
def test_polygon_holes_survive_into_the_payload():
"""Ein von Strassen umschlossener Block darf nicht zu Asphalt werden. An echten Daten
gehen sonst 449.876 m2 Strassenloch und 208.657 m2 Stadtloch verloren."""
outer = Polygon([(-1000, 2250), (-800, 2250), (-800, 2450), (-1000, 2450)],
[[(-950, 2300), (-870, 2300), (-870, 2400), (-950, 2400)]])
payload = chunk_payload(0, 0, land=[outer], surfaces=[], carriageway=[], curb=[], sidewalk=[],
markings=[], lots=[], bridges=[], edges=[], buildings=[], palms=[])
assert len(payload["land"]) == 1
rings = payload["land"][0]
assert len(rings) == 2 # Aussenring und Loch
exterior, hole = rings
# Das Loch liegt innerhalb des Aussenrings, nicht daneben.
assert min(x for x, _ in exterior) < min(x for x, _ in hole)
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_rings_bleiben_gueltig_wenn_koordinatenweises_runden_selbstschnitte_erzeugen_wuerde():
"""Befund 5 der Fixwelle: _rings() rundete bislang jede Koordinate unabhaengig auf 2 Stellen,
NACH normalize(). Das kann zwei Ringpunkte so verschieben, dass sich der Ring selbst
schneidet, obwohl das Ausgangspolygon gueltig war. Am echten Netz betraf das 8 von 144
Chunks (carriageway 3, land 2, sidewalk 2, surfaces 1) - je zwei ungueltige Polygone, alle
neu in diesem Zweig, Ursache 2-Stellen-Rundung statt einer echten Geometrieaenderung.
Das folgende Polygon ist gueltig; unabhaengig gerundete Ecken (die alte Methode) kreuzen
sich trotzdem - der Befund ist damit am Beispiel nachvollzogen, nicht nur behauptet.
set_precision() vor normalize() (die neue Methode) haelt das Ergebnis gueltig, weil es eine
echte Geometrieoperation ist statt eines blinden round() je Zahl.
"""
poly = Polygon([(16.083798, 6.586106), (20.804728, 8.005357),
(18.203748, 0.906454), (18.305129, 1.203848)])
assert poly.is_valid
naiv = Polygon([(round(x, 2), round(y, 2)) for x, y in poly.exterior.coords])
assert not naiv.is_valid, "das Beispiel muss den Befund tatsaechlich zeigen"
rings = _rings(poly, 0, 0)
ergebnis = Polygon(rings[0], rings[1:])
assert ergebnis.is_valid
assert ergebnis.area == pytest.approx(poly.area, rel=0.01)
def test_rings_behaelt_nur_das_groessere_teilstueck_an_einer_engstelle_unter_einem_zentimeter():
"""set_precision() kann ein Polygon mit einem Steg unter 1 cm Breite beim Schnappen auf das
1-cm-Raster in zwei Teile spalten (an einer Stichprobe der ausgelieferten Daten: 3 von
29.037 Polygonen, groesster abgeschnittener Teil 25,9 m²). _rings() liefert aber immer genau
ein Ringset - eine MultiPolygon ungeprueft an poly.exterior gereicht waere ein
AttributeError. Hier bleibt das groessere der beiden Teilstuecke.
Am unkorrigierten _rings() (kein set_precision) kollabiert derselbe Steg unter der
Koordinatenrundung auf exakt dieselbe Zeile und macht das EINE Ergebnis-Polygon ungueltig,
statt es sauber zu teilen - auch das ist also gegen den Befund rot.
"""
hantel = Polygon([
(0.0, 0.0), (10.0, 0.0), (10.0, 4.997), (20.0, 4.997), (20.0, 2.0), (26.0, 2.0), (26.0, 8.0),
(20.0, 8.0), (20.0, 5.003), (10.0, 5.003), (10.0, 10.0), (0.0, 10.0),
])
assert hantel.is_valid and hantel.area == pytest.approx(136.06)
rings = _rings(hantel, 0, 0) # darf nicht mit AttributeError abstuerzen
ergebnis = Polygon(rings[0], rings[1:])
assert ergebnis.is_valid
assert ergebnis.area == pytest.approx(100.0, abs=0.5), "das groessere der beiden Teilstuecke"
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")],
carriageway=[a, b], curb=[], sidewalk=[],
markings=[], lots=[], bridges=[b], edges=[],
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")],
carriageway=[b, a_gedreht], curb=[], sidewalk=[],
markings=[], lots=[], bridges=[b], edges=[],
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")],
carriageway=[], curb=[], sidewalk=[], markings=[], lots=[], bridges=[],
edges=[], buildings=[], palms=[])
assert [s["kind"] for s in payload["surfaces"]] == ["park", "field"]
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
Polygon, dessen Schnitt mit der Chunk-Zelle in mehrere Teile zerfaellt. Hier eine
Klammerform (Steg ausserhalb der Zelle, zwei Zinken, die je einmal hineinreichen)."""
staple = Polygon([
(-1100, 2495), (-850, 2495), (-850, 2470), (-1010, 2470),
(-1010, 2280), (-850, 2280), (-850, 2255), (-1010, 2255),
(-1100, 2255),
])
parts = list(split_by_chunk_all([staple], 0, 0))
assert len(parts) == 2
assert all(p.geom_type == "Polygon" for p in parts)
def test_neue_schichten_stehen_im_chunk():
from shapely.geometry import LineString, box
payload = chunk_payload(
0, 0,
land=[box(0, 2400, 100, 2500)], surfaces=[],
carriageway=[box(10, 2400, 20, 2500)],
curb=[LineString([(10, 2400), (10, 2500)])],
sidewalk=[box(20, 2400, 23, 2500)],
markings=[(box(14, 2440, 16, 2443), "gelb_gestrichelt")],
lots=[box(40, 2400, 60, 2420)],
bridges=[], edges=[(LineString([(15, 2400), (15, 2500)]), "wohnstrasse", 12.0, 7.0)],
buildings=[], palms=[])
assert set(payload["carriageway"][0]) == {"poly"}, "Befund 6 der Fixwelle: klasse entfaellt ganz"
assert payload["markings"][0]["art"] == "gelb_gestrichelt"
assert payload["edges"][0]["klasse"] == "wohnstrasse"
assert payload["edges"][0]["fahrbahn"] == 7.0
assert len(payload["curb"][0]) == 2, "der Bordstein ist ein Linienzug, keine Fläche"
assert "roads" not in payload and "centerlines" not in payload
def test_edges_breite_wird_gerundet():
"""Befund 8 der Fixwelle: breite ist die gemessene Kantenbreite (Median aus der
Distanztransformation) und kommt deshalb ungerundet an - am echten Netz Werte wie
31.622776601683793 -, während jede andere Zahl in der Ausgabe auf 2 Stellen gerundet ist.
fahrbahn bleibt unverändert: Es ist schon eine der vier festen SECTION-Konstanten."""
from shapely.geometry import LineString
payload = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[],
markings=[], lots=[], bridges=[],
edges=[(LineString([(0, 2400), (10, 2400)]), "wohnstrasse",
31.622776601683793, 7.0)],
buildings=[], palms=[])
assert payload["edges"][0]["breite"] == 31.62
def test_neue_schichten_sind_lauf_stabil():
"""Zwei Aufrufe mit vertauschter Eingabe liefern dieselbe Ausgabe (Fixrunde 3 aus Teil 1)."""
from shapely.geometry import LineString, box
a, b = box(10, 2400, 20, 2450), box(30, 2400, 40, 2450)
l1, l2 = LineString([(0, 2400), (0, 2450)]), LineString([(5, 2400), (5, 2450)])
eins = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[a, b],
curb=[l1, l2], sidewalk=[a], markings=[(a, "zebra"), (b, "zebra")],
lots=[a, b], bridges=[], edges=[], buildings=[], palms=[])
zwei = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[b, a],
curb=[l2, l1], sidewalk=[a], markings=[(b, "zebra"), (a, "zebra")],
lots=[b, a], bridges=[], edges=[], buildings=[], palms=[])
assert eins == zwei
def test_edges_sortierschluessel_deckt_alle_felder_ab():
"""Regressionsschutz fuer den Fund aus Fixrunde 1: Zwei Kanten mit gleicher Linie und
gleicher Klasse, aber unterschiedlicher Breite, duerfen nicht an der Eingabereihenfolge
haengen bleiben. Mit dem Schluessel (line, klasse) allein - wie im urspruenglichen
Auftragscode - loest keiner der beiden Faelle den Tie auf, sorted() bleibt dann stabil und
reicht die Eingabereihenfolge unveraendert durch; erst breite/fahrbahn im Schluessel machen
die zwei Eintraege wieder unterscheidbar und damit total geordnet."""
from shapely.geometry import LineString
linie = LineString([(70, 2400), (70, 2450)])
schmal = (linie, "gasse", 6.0, 6.0)
breit = (linie, "gasse", 99.0, 99.0)
eins = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[],
markings=[], lots=[], bridges=[], edges=[schmal, breit],
buildings=[], palms=[])
zwei = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[],
markings=[], lots=[], bridges=[], edges=[breit, schmal],
buildings=[], palms=[])
assert eins == zwei
def test_bridges_sind_lauf_stabil():
"""Gegenprobe zu test_neue_schichten_sind_lauf_stabil, die bridges leer laesst: zwei
Bruecken in vertauschter Reihenfolge muessen dieselbe Ausgabe ergeben."""
a = Polygon([(10, 2400), (20, 2400), (20, 2450), (10, 2450)])
b = Polygon([(30, 2400), (40, 2400), (40, 2450), (30, 2450)])
eins = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[],
markings=[], lots=[], bridges=[a, b], edges=[], buildings=[], palms=[])
zwei = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[],
markings=[], lots=[], bridges=[b, a], edges=[], buildings=[], palms=[])
assert eins == zwei
def test_curb_und_edges_sind_richtungsunabhaengig():
"""Die Zusicherung, auf der die ganze Normalisierung in _line() beruht (siehe dessen
Docstring): eine Linie und ihre exakte Umkehrung muessen fuer curb wie fuer edges dieselbe
Ausgabe ergeben. Einzeln geprueft (nur curb bzw. nur edges besetzt), damit ein kuenftiger
Bruch das betroffene Feld benennt statt nur "irgendwas stimmt nicht"."""
from shapely.geometry import LineString
hin = LineString([(10, 2400), (18, 2470), (22, 2500)])
zurueck = LineString(list(reversed(list(hin.coords))))
rest = dict(land=[], surfaces=[], carriageway=[], sidewalk=[], markings=[], lots=[],
bridges=[], buildings=[], palms=[])
eins_curb = chunk_payload(0, 0, curb=[hin], edges=[], **rest)
zwei_curb = chunk_payload(0, 0, curb=[zurueck], edges=[], **rest)
assert eins_curb["curb"] == zwei_curb["curb"]
eins_edges = chunk_payload(0, 0, curb=[], edges=[(hin, "wohnstrasse", 12.0, 7.0)], **rest)
zwei_edges = chunk_payload(0, 0, curb=[], edges=[(zurueck, "wohnstrasse", 12.0, 7.0)], **rest)
assert eins_edges["edges"] == zwei_edges["edges"]