From f1c83713654a47f87edc5e1b4e33a5afc37ac062 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 18:41:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(extract):=20Minimap=20zeichnet=20Fahrbahn,?= =?UTF-8?q?=20Gehweg=20und=20Restfl=C3=A4chen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit overview.py las chunk["roads"] und item["bridge"] ohne Rückfall und riss mit Chunks im Format aus Task 10 (carriageway/sidewalk/lots/bridges) einen KeyError auf. _paint_layers() liest jetzt carriageway, sidewalk und lots getrennt; chunk["bridges"] braucht keine eigene Regel mehr, weil seine Fahrbahn schon in carriageway steckt und sein begehbarer Streifen im sidewalk. Markierungen werden nicht gezeichnet: bei 1 px je 3 m wäre eine 10-cm-Linie ein Drittel Pixel und nur Rauschen. In der Begehbarkeitsmaske sperren Fahrbahn und Restflächen jetzt aktiv, auch über der Landplatte - dort fährt Verkehr, kein Fußgänger soll dort landen. Der Gehweg trägt dafür auch ohne Landplatte, wie zuvor nur die Brücke; die alte Sonderregel für Brücken entfällt darum ersatzlos. Alle Fixtures in test_overview.py, die von Hand im alten Format standen, sind mitgezogen; eine davon musste dabei die Zusicherung wechseln (Fahrbahn auf Land ist jetzt gesperrt statt begehbar), zwei weitere kamen dazu (Restfläche und Brücke ohne Land). Der Gehweg-Streifen im neuen Prüfkörper war im Planentwurf 3 statt 12 m breit: schmaler als eine 4-m-Maskenzelle, die deshalb nie ganz hineinpasste, unabhängig von der Rundung - jetzt auf 12 m korrigiert wie die Fahrbahn. test_committed_map_files_match_the_committed_chunks ist bis Task 16 übersprungen: die ausgelieferten Chunks unter public/data/vice-a bleiben laut Plan bis dahin im alten Format, render_map() kennt das nicht mehr. Co-Authored-By: Claude Sonnet 5 --- tools/extract/overview.py | 59 +++++++++----- tools/extract/tests/test_overview.py | 114 +++++++++++++++++++++++---- 2 files changed, 138 insertions(+), 35 deletions(-) diff --git a/tools/extract/overview.py b/tools/extract/overview.py index 2488323..0dfb7bf 100644 --- a/tools/extract/overview.py +++ b/tools/extract/overview.py @@ -28,11 +28,11 @@ WALKABLE_SCALE = 0.25 # Pixel je Meter, map-walkable.png (4 m je Pixe WATER = (36, 72, 104) LAND = (150, 162, 120) ROAD = (74, 74, 78) +SIDEWALK = (206, 202, 194) # field, court, pool und parking: Flamingo Park nach OpenStreetMap (docs/superpowers/specs/2026-09-15-flamingo-park-design.md) SURFACE = {"sand": (226, 210, 150), "park": (120, 160, 90), "urban": (176, 176, 172), "field": (96, 176, 78), "court": (92, 138, 118), "pool": (70, 150, 220), "parking": ROAD} PATH = (226, 216, 190) -BRIDGE = (196, 92, 92) PALM = (40, 190, 70) OUTLINE = (30, 30, 30) TALL = 120.0 # Meter, ab der ein Gebäude die hellste Färbung bekommt @@ -78,15 +78,24 @@ def _load_chunks(out_dir: Path) -> list[dict]: return [json.loads(f.read_text()) for f in sorted(out_dir.glob("chunk_*.json"))] -def _paint_layers(img, chunks, scale, *, land, surface, road, bridge, building, outline=None, palm=None, path=None) -> dict: +def _paint_layers(img, chunks, scale, *, land, surface, sidewalk, lots, road, building, + outline=None, palm=None, path=None) -> dict: """Ebene für Ebene über alle Chunks, damit nichts von einem Nachbarchunk überdeckt wird. - surface ist eine Funktion der Belagsart, building eine Funktion der Gebäudehöhe. - palm=None zeichnet keine Palmen; eine Füllung None lässt Beläge oder Straßen aus. - Reihenfolge und Füllung entsprechen exakt dem bisherigen Prüfbild, damit dessen - Ausgabe bytegleich bleibt. + surface ist eine Funktion der Belagsart, building eine Funktion der Gebäudehöhe; beide können + None liefern und die Fläche so aussparen (path=None lässt ebenso die ganze Ebene aus). land, + sidewalk, lots und road sind dagegen an jedem Aufrufort eine feste Füllung — in der + Begehbarkeitsmaske ausdrücklich WALKABLE oder BLOCKED, denn Fahrbahn und Restflächen sollen dort + aktiv sperren statt nur wirkungslos zu bleiben: dort fährt Verkehr, kein Fußgänger soll landen. + Reihenfolge von hinten nach vorn: Land, Beläge, Wege, Gehweg, Restflächen, Fahrbahn, Gebäude — + jede Ebene deckt die vorige ab. + + chunk["markings"] wird hier nie gelesen: bei einem Pixel je Meter wäre eine 10 cm breite Linie + ein Drittel Pixel und damit nur Rauschen. chunk["bridges"] ebenfalls nicht mehr: ihre Fahrbahn + steckt schon in carriageway, ihr begehbarer Streifen im sidewalk — eine eigene Brücken-Regel + braucht es hier nicht mehr. """ - stats = {"land": 0, "strassen": 0, "bruecken": 0, "gebaeude": 0, "palmen": 0} + stats = {"land": 0, "gehweg": 0, "restflaechen": 0, "strassen": 0, "gebaeude": 0, "palmen": 0} placed = [(chunk, chunk["origin"][0], -chunk["origin"][1]) for chunk in chunks] for chunk, ox, oy in placed: for rings in chunk["land"]: @@ -102,11 +111,17 @@ def _paint_layers(img, chunks, scale, *, land, surface, road, bridge, building, for item in chunk.get("paths", []): _draw(img, item["poly"], ox, oy, path, scale=scale) for chunk, ox, oy in placed: - for item in chunk["roads"]: - fill = bridge if item["bridge"] else road - if fill is not None: - _draw(img, item["poly"], ox, oy, fill, scale=scale) - stats["bruecken" if item["bridge"] else "strassen"] += 1 + for item in chunk["sidewalk"]: + _draw(img, item["poly"], ox, oy, sidewalk, scale=scale) + stats["gehweg"] += 1 + for chunk, ox, oy in placed: + for item in chunk["lots"]: + _draw(img, item["poly"], ox, oy, lots, scale=scale) + stats["restflaechen"] += 1 + for chunk, ox, oy in placed: + for item in chunk["carriageway"]: + _draw(img, item["poly"], ox, oy, road, scale=scale) + stats["strassen"] += 1 for chunk, ox, oy in placed: for item in chunk["buildings"]: _draw(img, item["poly"], ox, oy, building(item["height"]), outline=outline, scale=scale) @@ -124,7 +139,8 @@ def render(out_dir: Path, dst: Path) -> dict: size = int((SLICE["x1"] - SLICE["x0"]) * SCALE) img = Image.new("RGB", (size, size), WATER) stats = _paint_layers(img, _load_chunks(out_dir), SCALE, land=LAND, surface=SURFACE.__getitem__, - road=ROAD, bridge=BRIDGE, building=_building_shade, outline=OUTLINE, palm=PALM, path=PATH) + sidewalk=SIDEWALK, lots=ROAD, road=ROAD, building=_building_shade, + outline=OUTLINE, palm=PALM, path=PATH) dst.parent.mkdir(parents=True, exist_ok=True) img.save(dst) return stats @@ -133,17 +149,19 @@ def render(out_dir: Path, dst: Path) -> dict: def _walkable_mask(chunks: list[dict], width_m: int) -> Image.Image: """Begehbarkeitsmaske mit WALKABLE_SCALE, vorsichtig aus einem 1-m-Raster verdichtet. - Begehbar heißt: die Figur steht dort auf Kollision. Die tragen nur die Landplatte und die - Brücken — Straßen und Beläge sind reine Auflagen (DECALS in src/world/streaming.js) —, und - Gebäude sind geschlossene Kästen. Ein Maskenpixel gilt nur als begehbar, wenn jeder + Begehbar heißt: die Figur steht dort auf Kollision. Das tragen nur die Landplatte und der + Gehweg. Fahrbahn und Restflächen sperren aktiv, auch über der Landplatte — dort fährt Verkehr, + kein Fußgänger soll dort landen —, und Gebäude sind geschlossene Kästen. Für eine Brücke gibt es + keine eigene Regel mehr: ihr begehbarer Streifen liegt im Gehweg, der dort die Landplatte + ersetzt, ihre Fahrbahn sperrt wie jede andere. Ein Maskenpixel gilt nur als begehbar, wenn jeder Quadratmeter darunter frei ist. Ein nur teilweise freier Pixel setzte die Figur sonst in eine Wand oder vor die Kaimauer ins Wasser; an den ausgelieferten Daten traf das 4,3 % der Klicks auf Gebäude. Die Landung erfolgt in der Pixelmitte (nearestWalkable in src/ui/mapMath.js), also mindestens 2 m von jedem gesperrten Quadratmeter entfernt. """ fine = Image.new("L", (width_m, width_m), BLOCKED) - _paint_layers(fine, chunks, 1.0, land=WALKABLE, surface=lambda _kind: None, road=None, - bridge=WALKABLE, building=lambda _height: BLOCKED) + _paint_layers(fine, chunks, 1.0, land=WALKABLE, surface=lambda _kind: None, + sidewalk=WALKABLE, lots=BLOCKED, road=BLOCKED, building=lambda _height: BLOCKED) step = round(1 / WALKABLE_SCALE) size = width_m // step cells = np.asarray(fine).reshape(size, step, size, step) @@ -157,8 +175,9 @@ def render_map(out_dir: Path, dst_dir: Path) -> dict: width_m = SLICE["x1"] - SLICE["x0"] picture = Image.new("RGB", (int(width_m * MAP_SCALE),) * 2, WATER) - _paint_layers(picture, chunks, MAP_SCALE, land=LAND, surface=SURFACE.__getitem__, road=ROAD, - bridge=BRIDGE, building=_building_shade, outline=OUTLINE, path=PATH) + _paint_layers(picture, chunks, MAP_SCALE, land=LAND, surface=SURFACE.__getitem__, + sidewalk=SIDEWALK, lots=ROAD, road=ROAD, building=_building_shade, + outline=OUTLINE, path=PATH) walkable = _walkable_mask(chunks, width_m) diff --git a/tools/extract/tests/test_overview.py b/tools/extract/tests/test_overview.py index c1d0edd..413bdf7 100644 --- a/tools/extract/tests/test_overview.py +++ b/tools/extract/tests/test_overview.py @@ -2,6 +2,7 @@ import hashlib import json from pathlib import Path +import pytest from PIL import Image import overview @@ -23,8 +24,8 @@ def _fixture(tmp_path: Path) -> Path: "origin": [SLICE["x0"], -SLICE["y1"]], "land": [[_ring(0, 0, 250, 250)]], "surfaces": [], - "roads": [{"poly": [_ring(100, 0, 120, 250)], "bridge": False}], - "centerlines": [], + "carriageway": [{"poly": [_ring(100, 0, 120, 250)], "klasse": "hauptstrasse"}], + "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]], } @@ -57,7 +58,7 @@ def test_walkable_mask_blocks_buildings_and_water(tmp_path): 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 # Straße bei 110 m / 125 m + assert mask.getpixel((27, 31)) == overview.BLOCKED # Fahrbahn bei 110 m / 125 m: sperrt jetzt aktiv, auch auf Land assert mask.getpixel((50, 50)) == overview.WALKABLE # Land bei 200 m / 200 m assert mask.getpixel((375, 375)) == overview.BLOCKED # Wasser @@ -86,7 +87,11 @@ def test_debug_overview_still_draws_palms(tmp_path): def test_walkable_mask_counts_only_ground_with_collision(tmp_path): - """Straßen und Beläge sind reine Auflagen: ohne Landplatte darunter trägt nur eine Brücke.""" + """Ein Belag traegt weiterhin nicht, ganz ohne Landplatte im Chunk. Fahrbahn und Restflaeche + sperren aktiv (siehe test_walkable_mask_blocks_buildings_and_water fuer denselben Befund MIT + Landplatte); eine Bruecke fuer sich traegt jetzt nicht mehr - ihr begehbarer Streifen liegt im + Gehweg, der die Landplatte an der Stelle ersetzt und hier zeigt, dass er das auch ohne sie tut. + """ src = tmp_path / "chunks" src.mkdir() chunk = { @@ -94,9 +99,11 @@ def test_walkable_mask_counts_only_ground_with_collision(tmp_path): "origin": [SLICE["x0"], -SLICE["y1"]], "land": [], "surfaces": [{"poly": [_ring(0, 0, 40, 40)], "kind": "sand"}], - "roads": [{"poly": [_ring(100, 0, 120, 40)], "bridge": False}, - {"poly": [_ring(200, 0, 220, 40)], "bridge": True}], - "centerlines": [], + "curb": [], "edges": [], "markings": [], + "carriageway": [{"poly": [_ring(100, 0, 120, 40)], "klasse": "wohnstrasse"}], + "lots": [{"poly": [_ring(140, 0, 160, 40)]}], + "bridges": [{"poly": [_ring(180, 0, 200, 40)]}], + "sidewalk": [{"poly": [_ring(220, 0, 240, 40)]}], "buildings": [], "palms": [], } @@ -105,10 +112,12 @@ def test_walkable_mask_counts_only_ground_with_collision(tmp_path): 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: keine Kollision - assert mask.getpixel((27, 5)) == overview.BLOCKED # Straße ohne Land bei 110 m / 20 m: keine Kollision - assert mask.getpixel((52, 5)) == overview.WALKABLE # Brücke bei 210 m / 20 m - assert mask.getpixel((40, 5)) == overview.BLOCKED # Wasser bei 160 m / 20 m + assert mask.getpixel((5, 5)) == overview.BLOCKED # Sand ohne Land bei 20 m / 20 m: traegt nicht + assert mask.getpixel((27, 5)) == overview.BLOCKED # Fahrbahn ohne Land bei 108 m / 20 m: sperrt aktiv + assert mask.getpixel((37, 5)) == overview.BLOCKED # Restflaeche ohne Land bei 148 m / 20 m: sperrt aktiv + assert mask.getpixel((42, 5)) == overview.BLOCKED # Wasser bei 168 m / 20 m + assert mask.getpixel((47, 5)) == overview.BLOCKED # Bruecke ohne Land bei 188 m / 20 m: traegt nicht mehr + assert mask.getpixel((57, 5)) == overview.WALKABLE # Gehweg bei 228 m / 20 m: traegt jetzt auch ohne Land def test_walkable_mask_blocks_pixels_that_are_only_partly_free(tmp_path): @@ -120,8 +129,7 @@ def test_walkable_mask_blocks_pixels_that_are_only_partly_free(tmp_path): "origin": [SLICE["x0"], -SLICE["y1"]], "land": [[_ring(0, 0, 102, 250)]], # Küste bei x = 102 m, mitten in Pixel 25 "surfaces": [], - "roads": [], - "centerlines": [], + "carriageway": [], "curb": [], "sidewalk": [], "markings": [], "lots": [], "bridges": [], "edges": [], "buildings": [{"poly": [_ring(38, 40, 50, 50)], "height": 8.0, "source": "district", "speculative": False}], "palms": [], } @@ -136,12 +144,21 @@ def test_walkable_mask_blocks_pixels_that_are_only_partly_free(tmp_path): assert mask.getpixel((8, 11)) == overview.WALKABLE # 32..36 m: frei +@pytest.mark.skip(reason="public/data/vice-a ist bis Task 16 im alten Chunk-Format; siehe Docstring") 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. + + Übersprungen bis Task 16: render_map() liest seit dieser Aufgabe (15) nur noch carriageway, + sidewalk und lots statt roads und centerlines, aber public/data/vice-a bleibt laut Plan (Ruling + zu Task 12 in docs/superpowers/sdd/2026-09-16-strassen/progress.md) bis Task 16 im alten Format — + der Pipeline-Lauf in Task 12 schreibt die Daten nur zur Messung und stellt sie danach per + git checkout wieder her. Bis Task 16 die Welt neu erzeugt und committet, wirft dieser Test einen + KeyError statt eines aussagekräftigen Fehlschlags; Task 16 muss dieses Skip mit den neuen Daten + zusammen entfernen. """ shipped = Path(overview.__file__).resolve().parents[2] / "public" / "data" / "vice-a" overview.render_map(shipped, tmp_path) @@ -163,8 +180,7 @@ def test_map_picture_draws_paths_and_the_new_surface_kinds(tmp_path): {"poly": [_ring(50, 0, 90, 40)], "kind": "court"}, {"poly": [_ring(0, 50, 40, 90)], "kind": "pool"}, {"poly": [_ring(50, 50, 90, 90)], "kind": "parking"}], - "roads": [], - "centerlines": [], + "carriageway": [], "curb": [], "sidewalk": [], "markings": [], "lots": [], "bridges": [], "edges": [], "buildings": [], "palms": [], "paths": [{"poly": [_ring(100, 0, 104, 250)]}], @@ -180,3 +196,71 @@ def test_map_picture_draws_paths_and_the_new_surface_kinds(tmp_path): 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)], "klasse": "hauptstrasse"}], + "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_ist_begehbar_die_fahrbahn_nicht(tmp_path): + 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 jetzt 12 m breiten Gehwegs, genau das Muster + # der Fahrbahnprobe unten (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.BLOCKED, "Fahrbahn"