Befund 6, Controller-Entscheidung: carriageway_area() vereinigt das ganze Straßennetz zu wenigen Zusammenhangskomponenten, und _classified() gab jeder Komponente die Klasse ihrer breitesten enthaltenen Straße - am echten Netz trugen dadurch 91,2 % der Fahrbahnfläche "boulevard", auch wo tatsächlich eine Wohnstraße lag. Kein Verbraucher las das Feld (Browser und Minimap färben die Fahrbahn einheitlich); die Klasse je Straße steht weiterhin in edges. _classified() samt Aufruf aus run.py entfernt, chunk_payload() nimmt carriageway jetzt als reine Liste von Polygonen statt (poly, klasse)- Tupeln entgegen, Sortierschlüssel entsprechend auf poly allein verkürzt. Fixtures in test_emit.py, test_overview.py, tests/geometry.test.js und tests/streaming.test.js sowie das Chunk-Format im Entwurf nachgezogen; tests/props.test.js bleibt unverändert, weil dort edges betroffen sind, nicht carriageway.
285 lines
13 KiB
Python
285 lines
13 KiB
Python
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
|
|
import overview
|
|
from leonida.coords import SLICE
|
|
|
|
|
|
def _ring(x0, z0, x1, z1):
|
|
return [[x0, z0], [x1, z0], [x1, z1], [x0, z1], [x0, z0]]
|
|
|
|
|
|
def _fixture(tmp_path: Path) -> Path:
|
|
"""Ein einziger Chunk A1 (Nordwest): Land, eine Straße, ein Gebäude, eine Palme.
|
|
|
|
Chunk-lokale Koordinaten von chunk_0_0 fallen bei scale 1 genau auf die Kartenpixel,
|
|
weil der Chunk-Ursprung die Nordwest-Ecke des Ausschnitts ist.
|
|
"""
|
|
chunk = {
|
|
"chunk": [0, 0],
|
|
"origin": [SLICE["x0"], -SLICE["y1"]],
|
|
"land": [[_ring(0, 0, 250, 250)]],
|
|
"surfaces": [],
|
|
"carriageway": [{"poly": [_ring(100, 0, 120, 250)]}],
|
|
"curb": [], "sidewalk": [], "markings": [], "lots": [], "bridges": [], "edges": [],
|
|
"buildings": [{"poly": [_ring(20, 20, 60, 60)], "height": 12.0, "source": "district", "speculative": False}],
|
|
"palms": [[200.0, 200.0]],
|
|
}
|
|
(tmp_path / "chunk_0_0.json").write_text(json.dumps(chunk))
|
|
(tmp_path / "manifest.json").write_text('{"slice": "vice-a"}\n')
|
|
return tmp_path
|
|
|
|
|
|
def test_map_picture_has_one_pixel_per_metre_and_no_palms(tmp_path):
|
|
src = tmp_path / "chunks"
|
|
src.mkdir()
|
|
_fixture(src)
|
|
dst = tmp_path / "out"
|
|
overview.render_map(src, dst)
|
|
picture = Image.open(dst / "map.png")
|
|
assert picture.size == (3000, 3000)
|
|
assert picture.getpixel((110, 125)) == overview.ROAD
|
|
assert picture.getpixel((40, 40)) == overview._building_shade(12.0)
|
|
assert picture.getpixel((200, 200)) == overview.LAND # Palme wird nicht gezeichnet
|
|
assert picture.getpixel((1500, 1500)) == overview.WATER
|
|
|
|
|
|
def test_walkable_mask_blocks_buildings_and_water(tmp_path):
|
|
src = tmp_path / "chunks"
|
|
src.mkdir()
|
|
_fixture(src)
|
|
dst = tmp_path / "out"
|
|
overview.render_map(src, dst)
|
|
mask = Image.open(dst / "map-walkable.png")
|
|
assert mask.mode == "L"
|
|
assert mask.size == (750, 750)
|
|
assert mask.getpixel((10, 10)) == overview.BLOCKED # Gebäude bei 40 m / 40 m
|
|
assert mask.getpixel((27, 31)) == overview.WALKABLE # Fahrbahn bei 110 m / 125 m: kollidierende Mulde, nicht nur Auflage
|
|
assert mask.getpixel((50, 50)) == overview.WALKABLE # Land bei 200 m / 200 m
|
|
assert mask.getpixel((375, 375)) == overview.BLOCKED # Wasser
|
|
|
|
|
|
def test_map_json_carries_scale_slice_and_manifest_fingerprint(tmp_path):
|
|
src = tmp_path / "chunks"
|
|
src.mkdir()
|
|
_fixture(src)
|
|
dst = tmp_path / "out"
|
|
overview.render_map(src, dst)
|
|
info = json.loads((dst / "map.json").read_text())
|
|
assert info["scale"] == 1.0
|
|
assert info["walkable_scale"] == 0.25
|
|
assert info["slice"] == SLICE
|
|
assert info["manifest_sha256"] == hashlib.sha256((src / "manifest.json").read_bytes()).hexdigest()
|
|
|
|
|
|
def test_debug_overview_still_draws_palms(tmp_path):
|
|
src = tmp_path / "chunks"
|
|
src.mkdir()
|
|
_fixture(src)
|
|
dst = tmp_path / "debug.png"
|
|
stats = overview.render(src, dst)
|
|
assert stats["palmen"] == 1
|
|
assert Image.open(dst).getpixel((100, 100)) == overview.PALM # 200 m bei 0,5 px/m
|
|
|
|
|
|
def test_walkable_mask_counts_only_ground_with_collision(tmp_path):
|
|
"""Begehbar ist aller Boden mit Kollision, ganz ohne Landplatte im Chunk: der Gehweg, die
|
|
Fahrbahn als kollidierende Mulde, Restflaechen auf der Bodenplatte und eine Bruecke mit eigenem
|
|
Deck tragen alle fuer sich - eine Bruecke ueber Wasser ist deshalb ausdruecklich begehbar. Nur
|
|
ein Belag ist reine Auflage ohne eigene Kollision, und natuerlich sperren Gebaeude und Wasser.
|
|
"""
|
|
src = tmp_path / "chunks"
|
|
src.mkdir()
|
|
chunk = {
|
|
"chunk": [0, 0],
|
|
"origin": [SLICE["x0"], -SLICE["y1"]],
|
|
"land": [],
|
|
"surfaces": [{"poly": [_ring(0, 0, 40, 40)], "kind": "sand"}],
|
|
"curb": [], "edges": [], "markings": [],
|
|
"carriageway": [{"poly": [_ring(100, 0, 120, 40)]}],
|
|
"lots": [{"poly": [_ring(140, 0, 160, 40)]}],
|
|
"bridges": [{"poly": [_ring(180, 0, 200, 40)]}],
|
|
"sidewalk": [{"poly": [_ring(220, 0, 240, 40)]}],
|
|
"buildings": [],
|
|
"palms": [],
|
|
}
|
|
(src / "chunk_0_0.json").write_text(json.dumps(chunk))
|
|
(src / "manifest.json").write_text('{"slice": "vice-a"}\n')
|
|
dst = tmp_path / "out"
|
|
overview.render_map(src, dst)
|
|
mask = Image.open(dst / "map-walkable.png")
|
|
assert mask.getpixel((5, 5)) == overview.BLOCKED # Sand ohne Land bei 20 m / 20 m: reine Auflage, keine Kollision
|
|
assert mask.getpixel((27, 5)) == overview.WALKABLE # Fahrbahn ohne Land bei 108 m / 20 m: kollidierende Mulde
|
|
assert mask.getpixel((37, 5)) == overview.WALKABLE # Restflaeche ohne Land bei 148 m / 20 m: liegt auf der Bodenplatte
|
|
assert mask.getpixel((42, 5)) == overview.BLOCKED # Wasser bei 168 m / 20 m
|
|
assert mask.getpixel((47, 5)) == overview.WALKABLE # Bruecke ohne Land bei 188 m / 20 m: eigenes Deck, auch ueber Wasser begehbar
|
|
assert mask.getpixel((57, 5)) == overview.WALKABLE # Gehweg bei 228 m / 20 m: traegt auch ohne Land
|
|
|
|
|
|
def test_walkable_mask_blocks_pixels_that_are_only_partly_free(tmp_path):
|
|
"""Ein 4-m-Pixel, den Wasser oder ein Gebäude auch nur anschneidet, ist kein Landeplatz."""
|
|
src = tmp_path / "chunks"
|
|
src.mkdir()
|
|
chunk = {
|
|
"chunk": [0, 0],
|
|
"origin": [SLICE["x0"], -SLICE["y1"]],
|
|
"land": [[_ring(0, 0, 102, 250)]], # Küste bei x = 102 m, mitten in Pixel 25
|
|
"surfaces": [],
|
|
"carriageway": [], "curb": [], "sidewalk": [], "markings": [], "lots": [], "bridges": [], "edges": [],
|
|
"buildings": [{"poly": [_ring(38, 40, 50, 50)], "height": 8.0, "source": "district", "speculative": False}],
|
|
"palms": [],
|
|
}
|
|
(src / "chunk_0_0.json").write_text(json.dumps(chunk))
|
|
(src / "manifest.json").write_text('{"slice": "vice-a"}\n')
|
|
dst = tmp_path / "out"
|
|
overview.render_map(src, dst)
|
|
mask = Image.open(dst / "map-walkable.png")
|
|
assert mask.getpixel((24, 30)) == overview.WALKABLE # 96..100 m: ganz Land
|
|
assert mask.getpixel((25, 30)) == overview.BLOCKED # 100..104 m: halb Wasser
|
|
assert mask.getpixel((9, 11)) == overview.BLOCKED # 36..40 m: das Gebäude beginnt bei 38 m
|
|
assert mask.getpixel((8, 11)) == overview.WALKABLE # 32..36 m: frei
|
|
|
|
|
|
def test_committed_map_files_match_the_committed_chunks(tmp_path):
|
|
"""Kartenbild, Maske und map.json müssen aus genau den ausgelieferten Chunks stammen.
|
|
|
|
Ändert ein Pipeline-Lauf die Chunks, ohne dass overview.py läuft, zeigte die Minimap eine alte
|
|
Welt und landete nach einer alten Maske. Die Laufzeitprüfung über manifest_sha256 bemerkt das
|
|
erst im Browser, dieser Test schon beim Testlauf.
|
|
"""
|
|
shipped = Path(overview.__file__).resolve().parents[2] / "public" / "data" / "vice-a"
|
|
overview.render_map(shipped, tmp_path)
|
|
for name in ("map.png", "map-walkable.png"):
|
|
fresh, committed = Image.open(tmp_path / name), Image.open(shipped / name)
|
|
assert (fresh.size, fresh.mode) == (committed.size, committed.mode), name
|
|
assert fresh.tobytes() == committed.tobytes(), f"{name} ist veraltet: cd tools/extract && uv run python overview.py"
|
|
assert json.loads((tmp_path / "map.json").read_text()) == json.loads((shipped / "map.json").read_text())
|
|
|
|
|
|
def test_map_picture_draws_paths_and_the_new_surface_kinds(tmp_path):
|
|
src = tmp_path / "chunks"
|
|
src.mkdir()
|
|
chunk = {
|
|
"chunk": [0, 0],
|
|
"origin": [SLICE["x0"], -SLICE["y1"]],
|
|
"land": [[_ring(0, 0, 250, 250)]],
|
|
"surfaces": [{"poly": [_ring(0, 0, 40, 40)], "kind": "field"},
|
|
{"poly": [_ring(50, 0, 90, 40)], "kind": "court"},
|
|
{"poly": [_ring(0, 50, 40, 90)], "kind": "pool"},
|
|
{"poly": [_ring(50, 50, 90, 90)], "kind": "parking"}],
|
|
"carriageway": [], "curb": [], "sidewalk": [], "markings": [], "lots": [], "bridges": [], "edges": [],
|
|
"buildings": [],
|
|
"palms": [],
|
|
"paths": [{"poly": [_ring(100, 0, 104, 250)]}],
|
|
}
|
|
(src / "chunk_0_0.json").write_text(json.dumps(chunk))
|
|
(src / "manifest.json").write_text('{"slice": "vice-a"}\n')
|
|
dst = tmp_path / "out"
|
|
overview.render_map(src, dst)
|
|
picture = Image.open(dst / "map.png")
|
|
assert picture.getpixel((20, 20)) == overview.SURFACE["field"]
|
|
assert picture.getpixel((70, 20)) == overview.SURFACE["court"]
|
|
assert picture.getpixel((20, 70)) == overview.SURFACE["pool"]
|
|
assert picture.getpixel((70, 70)) == overview.ROAD # Parkplatz in Straßenfarbe
|
|
assert picture.getpixel((102, 125)) == overview.PATH
|
|
assert Image.open(dst / "map-walkable.png").getpixel((25, 31)) == overview.WALKABLE # Weg auf Land
|
|
|
|
|
|
def _strassen_fixture(tmp_path: Path) -> Path:
|
|
"""Chunk A1 mit den Schichten aus Teil 2. Aufbau wie _fixture() oben in dieser Datei:
|
|
|
|
Bei scale 1 fallen die chunk-lokalen Koordinaten von chunk_0_0 genau auf die Kartenpixel.
|
|
|
|
Der Gehweg ist hier 12 statt 3 m breit (anders als im Planentwurf): _draw() zeichnet Fuellung
|
|
und Umriss in derselben Farbe (Kommentar dort erklaert, warum), damit angrenzende Flaechen keine
|
|
Lueckenpixel lassen - an der gemeinsamen Kante mit der Fahrbahn bei x=112 gewinnt dadurch je nach
|
|
Zeichenreihenfolge die eine oder die andere Flaeche die Grenzspalte. Bei 4 m je Maskenpixel zaehlt
|
|
eine Zelle nur als frei, wenn alle 16 Meterzellen frei sind; ein 3 m breiter Streifen ist schmaler
|
|
als eine Maskenzelle und kann darum nie eine ganz umschliessen, unabhaengig von der Rundung.
|
|
"""
|
|
chunk = {
|
|
"chunk": [0, 0],
|
|
"origin": [SLICE["x0"], -SLICE["y1"]],
|
|
"land": [[_ring(0, 0, 250, 250)]],
|
|
"surfaces": [], "buildings": [], "palms": [], "paths": [], "edges": [], "curb": [],
|
|
"bridges": [], "lots": [{"poly": [_ring(10, 150, 60, 200)]}],
|
|
"carriageway": [{"poly": [_ring(100, 0, 112, 250)]}],
|
|
"sidewalk": [{"poly": [_ring(112, 0, 124, 250)]}],
|
|
"markings": [{"poly": [_ring(105, 0, 105.1, 250)], "art": "gelb_doppelt"}],
|
|
}
|
|
(tmp_path / "chunk_0_0.json").write_text(json.dumps(chunk))
|
|
(tmp_path / "manifest.json").write_text('{"slice": "vice-a"}\n')
|
|
return tmp_path
|
|
|
|
|
|
def test_karte_zeichnet_fahrbahn_gehweg_und_restflaeche(tmp_path):
|
|
src = tmp_path / "chunks"
|
|
src.mkdir()
|
|
_strassen_fixture(src)
|
|
dst = tmp_path / "out"
|
|
dst.mkdir()
|
|
overview.render_map(src, dst)
|
|
bild = Image.open(dst / "map.png").convert("RGB")
|
|
assert bild.getpixel((105, 10)) == overview.ROAD, "die Fahrbahn"
|
|
assert bild.getpixel((113, 10)) == overview.SIDEWALK, "der Gehweg daneben"
|
|
assert bild.getpixel((30, 170)) == overview.ROAD, "die Restflaeche im Strassenton"
|
|
|
|
|
|
def test_markierung_wird_nicht_gezeichnet(tmp_path):
|
|
"""Bei 1 px je 3 m waere eine 10-cm-Linie ein Drittel Pixel und damit nur Rauschen."""
|
|
src = tmp_path / "chunks"
|
|
src.mkdir()
|
|
_strassen_fixture(src)
|
|
dst = tmp_path / "out"
|
|
dst.mkdir()
|
|
overview.render_map(src, dst)
|
|
bild = Image.open(dst / "map.png").convert("RGB")
|
|
# Genau auf der Markierung: trotzdem Fahrbahnfarbe, kein Gelb.
|
|
assert bild.getpixel((105, 120)) == overview.ROAD
|
|
|
|
|
|
def test_gehweg_und_fahrbahn_sind_beide_begehbar(tmp_path):
|
|
"""Gehweg und Fahrbahn sehen im Kartenbild verschieden aus (SIDEWALK/ROAD), tragen in der
|
|
Begehbarkeitsmaske aber beide: 'begehbar' heisst hier Kollisionsboden ohne Gebaeude
|
|
(nearestWalkable() in src/ui/mapMath.js), nicht Fussgaengerzone.
|
|
"""
|
|
src = tmp_path / "chunks"
|
|
src.mkdir()
|
|
_strassen_fixture(src)
|
|
dst = tmp_path / "out"
|
|
dst.mkdir()
|
|
overview.render_map(src, dst)
|
|
maske = Image.open(dst / "map-walkable.png").convert("L")
|
|
step = round(1 / overview.WALKABLE_SCALE)
|
|
# x=117 statt 113: 4 m Abstand zu jeder Kante des 12 m breiten Gehwegs, genau das Muster der
|
|
# Fahrbahnprobe (x=105, 4 m Abstand zu deren Kanten 100/112) - keine Grenzzelle.
|
|
assert maske.getpixel((117 // step, 10 // step)) == overview.WALKABLE, "Gehweg"
|
|
assert maske.getpixel((105 // step, 10 // step)) == overview.WALKABLE, "Fahrbahn"
|
|
|
|
|
|
def test_bruecke_bleibt_im_kartenbild_als_bruecke_sichtbar(tmp_path):
|
|
"""chunk["bridges"] wird nach der Fahrbahn gezeichnet: sonst zeigte eine Bruecke ueber Wasser nur
|
|
ihre Fahrbahn (carriageway liegt an derselben Stelle) und saehe wie eine Strasse im Wasser aus
|
|
statt wie eine Bruecke.
|
|
"""
|
|
src = tmp_path / "chunks"
|
|
src.mkdir()
|
|
chunk = {
|
|
"chunk": [0, 0],
|
|
"origin": [SLICE["x0"], -SLICE["y1"]],
|
|
"land": [], "surfaces": [], "paths": [], "curb": [], "edges": [], "markings": [],
|
|
"sidewalk": [], "lots": [],
|
|
"carriageway": [{"poly": [_ring(100, 0, 120, 40)]}],
|
|
"bridges": [{"poly": [_ring(100, 0, 120, 40)]}],
|
|
"buildings": [], "palms": [],
|
|
}
|
|
(src / "chunk_0_0.json").write_text(json.dumps(chunk))
|
|
(src / "manifest.json").write_text('{"slice": "vice-a"}\n')
|
|
dst = tmp_path / "out"
|
|
overview.render_map(src, dst)
|
|
bild = Image.open(dst / "map.png").convert("RGB")
|
|
assert bild.getpixel((110, 20)) == overview.BRIDGE, "Bruecke ueber Wasser, nicht Strasse im Wasser"
|