fix(extract): Fahrbahn, Restflächen und Brücken sind begehbar, nicht nur der Gehweg

Korrektur eines Fehlers im Plantext, nicht in der vorigen Umsetzung: "begehbar" ist in
src/ui/mapMath.js (nearestWalkable()/landingFor()) als "Kollisionsboden ohne Gebäude"
definiert, nicht als Fußgängerzone. Fahrbahn ist die kollidierende Mulde, Restflächen
liegen auf der Bodenplatte, Brücken haben ihr eigenes Deck - alle drei tragen wie die
Landplatte und der Gehweg. Nur Gebäude sperren; Wasser bleibt gesperrt, weil nichts
darüber gemalt wird. Ein Klick auf eine Straße oder einen Damm über Wasser landet damit
wieder auf ihr, statt ins Nichts zu greifen.

chunk["bridges"] wird dafür wieder gelesen und zusätzlich im Kartenbild in BRIDGE
gezeichnet, nach der Fahrbahn, damit eine Brücke als Brücke zu sehen ist und nicht als
Straße im Wasser.

test_walkable_mask_counts_only_ground_with_collision und
test_walkable_mask_blocks_buildings_and_water zurückgestellt; ein neuer Test sichert die
Brückenfarbe im Kartenbild. test_gehweg_ist_begehbar_die_fahrbahn_nicht sagte nach der
Korrektur nicht mehr die Wahrheit über sich selbst - Fahrbahn ist jetzt auch begehbar -
und ist umbenannt in test_gehweg_und_fahrbahn_sind_beide_begehbar. Der Skip-Grund von
test_committed_map_files_match_the_committed_chunks nennt jetzt die Bedingung fürs
Entfernen statt einer Aufgabennummer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-17 18:52:56 +02:00
1 parent 4e21367e73
commit 6c73b7f816
2 files changed
+81 -46

No files matched your search

+30 -24
View File
@@ -33,6 +33,7 @@ SIDEWALK = (206, 202, 194)
SURFACE = {"sand": (226, 210, 150), "park": (120, 160, 90), "urban": (176, 176, 172), 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} "field": (96, 176, 78), "court": (92, 138, 118), "pool": (70, 150, 220), "parking": ROAD}
PATH = (226, 216, 190) PATH = (226, 216, 190)
BRIDGE = (196, 92, 92)
PALM = (40, 190, 70) PALM = (40, 190, 70)
OUTLINE = (30, 30, 30) OUTLINE = (30, 30, 30)
TALL = 120.0 # Meter, ab der ein Gebäude die hellste Färbung bekommt TALL = 120.0 # Meter, ab der ein Gebäude die hellste Färbung bekommt
@@ -78,24 +79,25 @@ def _load_chunks(out_dir: Path) -> list[dict]:
return [json.loads(f.read_text()) for f in sorted(out_dir.glob("chunk_*.json"))] return [json.loads(f.read_text()) for f in sorted(out_dir.glob("chunk_*.json"))]
def _paint_layers(img, chunks, scale, *, land, surface, sidewalk, lots, road, building, def _paint_layers(img, chunks, scale, *, land, surface, sidewalk, lots, road, bridge, building,
outline=None, palm=None, path=None) -> dict: outline=None, palm=None, path=None) -> dict:
"""Ebene für Ebene über alle Chunks, damit nichts von einem Nachbarchunk überdeckt wird. """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; beide können 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, 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 sidewalk, lots, road und bridge sind dagegen an jedem Aufrufort eine feste Füllung: in der
Begehbarkeitsmaske ausdrücklich WALKABLE oder BLOCKED, denn Fahrbahn und Restflächen sollen dort Begehbarkeitsmaske für alle fünf WALKABLE, denn nach der Bedeutung in nearestWalkable()
aktiv sperren statt nur wirkungslos zu bleiben: dort fährt Verkehr, kein Fußgänger soll landen. (src/ui/mapMath.js) ist ein Pixel begehbar, wenn seine Fläche Kollisionsboden ohne Gebäude ist —
Reihenfolge von hinten nach vorn: Land, Beläge, Wege, Gehweg, Restflächen, Fahrbahn, Gebäude — und Fahrbahn (die kollidierende Mulde), Restflächen (auf der Bodenplatte) und Brücken (eigenes
jede Ebene deckt die vorige ab. Deck) haben Kollision, Beläge und Wege dagegen nicht.
Reihenfolge von hinten nach vorn: Land, Beläge, Wege, Gehweg, Restflächen, Fahrbahn, Brücken,
Gebäude — jede Ebene deckt die vorige ab. Brücken zuletzt, damit eine Brücke als Brücke zu sehen
ist und nicht als Straße im Wasser: ihre Fahrbahn steckt schon in carriageway an derselben Stelle.
chunk["markings"] wird hier nie gelesen: bei einem Pixel je Meter wäre eine 10 cm breite Linie 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 ein Drittel Pixel und damit nur Rauschen.
steckt schon in carriageway, ihr begehbarer Streifen im sidewalk — eine eigene Brücken-Regel
braucht es hier nicht mehr.
""" """
stats = {"land": 0, "gehweg": 0, "restflaechen": 0, "strassen": 0, "gebaeude": 0, "palmen": 0} stats = {"land": 0, "gehweg": 0, "restflaechen": 0, "strassen": 0, "bruecken": 0, "gebaeude": 0, "palmen": 0}
placed = [(chunk, chunk["origin"][0], -chunk["origin"][1]) for chunk in chunks] placed = [(chunk, chunk["origin"][0], -chunk["origin"][1]) for chunk in chunks]
for chunk, ox, oy in placed: for chunk, ox, oy in placed:
for rings in chunk["land"]: for rings in chunk["land"]:
@@ -122,6 +124,10 @@ def _paint_layers(img, chunks, scale, *, land, surface, sidewalk, lots, road, bu
for item in chunk["carriageway"]: for item in chunk["carriageway"]:
_draw(img, item["poly"], ox, oy, road, scale=scale) _draw(img, item["poly"], ox, oy, road, scale=scale)
stats["strassen"] += 1 stats["strassen"] += 1
for chunk, ox, oy in placed:
for item in chunk["bridges"]:
_draw(img, item["poly"], ox, oy, bridge, scale=scale)
stats["bruecken"] += 1
for chunk, ox, oy in placed: for chunk, ox, oy in placed:
for item in chunk["buildings"]: for item in chunk["buildings"]:
_draw(img, item["poly"], ox, oy, building(item["height"]), outline=outline, scale=scale) _draw(img, item["poly"], ox, oy, building(item["height"]), outline=outline, scale=scale)
@@ -139,8 +145,8 @@ def render(out_dir: Path, dst: Path) -> dict:
size = int((SLICE["x1"] - SLICE["x0"]) * SCALE) size = int((SLICE["x1"] - SLICE["x0"]) * SCALE)
img = Image.new("RGB", (size, size), WATER) img = Image.new("RGB", (size, size), WATER)
stats = _paint_layers(img, _load_chunks(out_dir), SCALE, land=LAND, surface=SURFACE.__getitem__, stats = _paint_layers(img, _load_chunks(out_dir), SCALE, land=LAND, surface=SURFACE.__getitem__,
sidewalk=SIDEWALK, lots=ROAD, road=ROAD, building=_building_shade, sidewalk=SIDEWALK, lots=ROAD, road=ROAD, bridge=BRIDGE,
outline=OUTLINE, palm=PALM, path=PATH) building=_building_shade, outline=OUTLINE, palm=PALM, path=PATH)
dst.parent.mkdir(parents=True, exist_ok=True) dst.parent.mkdir(parents=True, exist_ok=True)
img.save(dst) img.save(dst)
return stats return stats
@@ -149,19 +155,19 @@ def render(out_dir: Path, dst: Path) -> dict:
def _walkable_mask(chunks: list[dict], width_m: int) -> Image.Image: def _walkable_mask(chunks: list[dict], width_m: int) -> Image.Image:
"""Begehbarkeitsmaske mit WALKABLE_SCALE, vorsichtig aus einem 1-m-Raster verdichtet. """Begehbarkeitsmaske mit WALKABLE_SCALE, vorsichtig aus einem 1-m-Raster verdichtet.
Begehbar heißt: die Figur steht dort auf Kollision. Das tragen nur die Landplatte und der Begehbar heißt hier wörtlich, was nearestWalkable() in src/ui/mapMath.js daraus liest: die ganze
Gehweg. Fahrbahn und Restflächen sperren aktiv, auch über der Landplatte — dort fährt Verkehr, Fläche eines Pixels ist Kollisionsboden ohne Gebäude. Das sind Landplatte, Gehweg, Fahrbahn,
kein Fußgänger soll dort landen —, und Gebäude sind geschlossene Kästen. Für eine Brücke gibt es Restflächen und Brückendecks — alle fünf tragen für sich, auch eine Brücke über Wasser ganz ohne
keine eigene Regel mehr: ihr begehbarer Streifen liegt im Gehweg, der dort die Landplatte Landplatte darunter. Beläge und Wege bleiben reine Auflagen ohne eigene Kollision, Gebäude sind
ersetzt, ihre Fahrbahn sperrt wie jede andere. Ein Maskenpixel gilt nur als begehbar, wenn jeder geschlossene Kästen, und Wasser bleibt gesperrt, weil dort nichts gemalt wird. Ein Maskenpixel
Quadratmeter darunter frei ist. Ein nur teilweise freier Pixel setzte die Figur sonst in eine gilt nur als begehbar, wenn jeder Quadratmeter darunter frei ist. Ein nur teilweise freier Pixel
Wand oder vor die Kaimauer ins Wasser; an den ausgelieferten Daten traf das 4,3 % der Klicks setzte die Figur sonst in eine Wand oder vor die Kaimauer ins Wasser; an den ausgelieferten Daten
auf Gebäude. Die Landung erfolgt in der Pixelmitte (nearestWalkable in src/ui/mapMath.js), traf das 4,3 % der Klicks auf Gebäude. Die Landung erfolgt in der Pixelmitte (nearestWalkable in
also mindestens 2 m von jedem gesperrten Quadratmeter entfernt. src/ui/mapMath.js), also mindestens 2 m von jedem gesperrten Quadratmeter entfernt.
""" """
fine = Image.new("L", (width_m, width_m), BLOCKED) fine = Image.new("L", (width_m, width_m), BLOCKED)
_paint_layers(fine, chunks, 1.0, land=WALKABLE, surface=lambda _kind: None, _paint_layers(fine, chunks, 1.0, land=WALKABLE, surface=lambda _kind: None, sidewalk=WALKABLE,
sidewalk=WALKABLE, lots=BLOCKED, road=BLOCKED, building=lambda _height: BLOCKED) lots=WALKABLE, road=WALKABLE, bridge=WALKABLE, building=lambda _height: BLOCKED)
step = round(1 / WALKABLE_SCALE) step = round(1 / WALKABLE_SCALE)
size = width_m // step size = width_m // step
cells = np.asarray(fine).reshape(size, step, size, step) cells = np.asarray(fine).reshape(size, step, size, step)
@@ -176,7 +182,7 @@ def render_map(out_dir: Path, dst_dir: Path) -> dict:
picture = Image.new("RGB", (int(width_m * MAP_SCALE),) * 2, WATER) picture = Image.new("RGB", (int(width_m * MAP_SCALE),) * 2, WATER)
_paint_layers(picture, chunks, MAP_SCALE, land=LAND, surface=SURFACE.__getitem__, _paint_layers(picture, chunks, MAP_SCALE, land=LAND, surface=SURFACE.__getitem__,
sidewalk=SIDEWALK, lots=ROAD, road=ROAD, building=_building_shade, sidewalk=SIDEWALK, lots=ROAD, road=ROAD, bridge=BRIDGE, building=_building_shade,
outline=OUTLINE, path=PATH) outline=OUTLINE, path=PATH)
walkable = _walkable_mask(chunks, width_m) walkable = _walkable_mask(chunks, width_m)
+51 -22
View File
@@ -58,7 +58,7 @@ def test_walkable_mask_blocks_buildings_and_water(tmp_path):
assert mask.mode == "L" assert mask.mode == "L"
assert mask.size == (750, 750) assert mask.size == (750, 750)
assert mask.getpixel((10, 10)) == overview.BLOCKED # Gebäude bei 40 m / 40 m assert mask.getpixel((10, 10)) == overview.BLOCKED # Gebäude bei 40 m / 40 m
assert mask.getpixel((27, 31)) == overview.BLOCKED # Fahrbahn bei 110 m / 125 m: sperrt jetzt aktiv, auch auf Land 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((50, 50)) == overview.WALKABLE # Land bei 200 m / 200 m
assert mask.getpixel((375, 375)) == overview.BLOCKED # Wasser assert mask.getpixel((375, 375)) == overview.BLOCKED # Wasser
@@ -87,10 +87,10 @@ def test_debug_overview_still_draws_palms(tmp_path):
def test_walkable_mask_counts_only_ground_with_collision(tmp_path): def test_walkable_mask_counts_only_ground_with_collision(tmp_path):
"""Ein Belag traegt weiterhin nicht, ganz ohne Landplatte im Chunk. Fahrbahn und Restflaeche """Begehbar ist aller Boden mit Kollision, ganz ohne Landplatte im Chunk: der Gehweg, die
sperren aktiv (siehe test_walkable_mask_blocks_buildings_and_water fuer denselben Befund MIT Fahrbahn als kollidierende Mulde, Restflaechen auf der Bodenplatte und eine Bruecke mit eigenem
Landplatte); eine Bruecke fuer sich traegt jetzt nicht mehr - ihr begehbarer Streifen liegt im Deck tragen alle fuer sich - eine Bruecke ueber Wasser ist deshalb ausdruecklich begehbar. Nur
Gehweg, der die Landplatte an der Stelle ersetzt und hier zeigt, dass er das auch ohne sie tut. ein Belag ist reine Auflage ohne eigene Kollision, und natuerlich sperren Gebaeude und Wasser.
""" """
src = tmp_path / "chunks" src = tmp_path / "chunks"
src.mkdir() src.mkdir()
@@ -112,12 +112,12 @@ def test_walkable_mask_counts_only_ground_with_collision(tmp_path):
dst = tmp_path / "out" dst = tmp_path / "out"
overview.render_map(src, dst) overview.render_map(src, dst)
mask = Image.open(dst / "map-walkable.png") mask = Image.open(dst / "map-walkable.png")
assert mask.getpixel((5, 5)) == overview.BLOCKED # Sand ohne Land bei 20 m / 20 m: traegt nicht 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.BLOCKED # Fahrbahn ohne Land bei 108 m / 20 m: sperrt aktiv assert mask.getpixel((27, 5)) == overview.WALKABLE # Fahrbahn ohne Land bei 108 m / 20 m: kollidierende Mulde
assert mask.getpixel((37, 5)) == overview.BLOCKED # Restflaeche ohne Land bei 148 m / 20 m: sperrt aktiv 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((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((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 jetzt auch ohne Land 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): def test_walkable_mask_blocks_pixels_that_are_only_partly_free(tmp_path):
@@ -144,7 +144,9 @@ def test_walkable_mask_blocks_pixels_that_are_only_partly_free(tmp_path):
assert mask.getpixel((8, 11)) == overview.WALKABLE # 32..36 m: frei 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") @pytest.mark.skip(reason="public/data/vice-a ist noch im alten Chunk-Format (roads/centerlines); "
"Skip erst entfernen, wenn es im neuen Format (carriageway/sidewalk/lots/"
"bridges/...) neu erzeugt und committet ist")
def test_committed_map_files_match_the_committed_chunks(tmp_path): def test_committed_map_files_match_the_committed_chunks(tmp_path):
"""Kartenbild, Maske und map.json müssen aus genau den ausgelieferten Chunks stammen. """Kartenbild, Maske und map.json müssen aus genau den ausgelieferten Chunks stammen.
@@ -152,13 +154,12 @@ def test_committed_map_files_match_the_committed_chunks(tmp_path):
Welt und landete nach einer alten Maske. Die Laufzeitprüfung über manifest_sha256 bemerkt das Welt und landete nach einer alten Maske. Die Laufzeitprüfung über manifest_sha256 bemerkt das
erst im Browser, dieser Test schon beim Testlauf. erst im Browser, dieser Test schon beim Testlauf.
Übersprungen bis Task 16: render_map() liest seit dieser Aufgabe (15) nur noch carriageway, Übersprungen: render_map() kennt nur noch das neue Chunk-Format (carriageway/sidewalk/lots/
sidewalk und lots statt roads und centerlines, aber public/data/vice-a bleibt laut Plan (Ruling bridges/...), aber die committeten Chunks unter public/data/vice-a liegen noch im alten Format
zu Task 12 in docs/superpowers/sdd/2026-09-16-strassen/progress.md) bis Task 16 im alten Format — (roads/centerlines) — der Pipeline-Lauf, der sie im neuen Format neu erzeugt, ist noch nicht
der Pipeline-Lauf in Task 12 schreibt die Daten nur zur Messung und stellt sie danach per committet. Bis dahin wirft dieser Test einen KeyError statt eines aussagekräftigen Fehlschlags.
git checkout wieder her. Bis Task 16 die Welt neu erzeugt und committet, wirft dieser Test einen Sobald public/data/vice-a im neuen Chunk-Format neu erzeugt und committet ist, muss dieses Skip
KeyError statt eines aussagekräftigen Fehlschlags; Task 16 muss dieses Skip mit den neuen Daten mit entfernt werden.
zusammen entfernen.
""" """
shipped = Path(overview.__file__).resolve().parents[2] / "public" / "data" / "vice-a" shipped = Path(overview.__file__).resolve().parents[2] / "public" / "data" / "vice-a"
overview.render_map(shipped, tmp_path) overview.render_map(shipped, tmp_path)
@@ -251,7 +252,11 @@ def test_markierung_wird_nicht_gezeichnet(tmp_path):
assert bild.getpixel((105, 120)) == overview.ROAD assert bild.getpixel((105, 120)) == overview.ROAD
def test_gehweg_ist_begehbar_die_fahrbahn_nicht(tmp_path): 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 = tmp_path / "chunks"
src.mkdir() src.mkdir()
_strassen_fixture(src) _strassen_fixture(src)
@@ -260,7 +265,31 @@ def test_gehweg_ist_begehbar_die_fahrbahn_nicht(tmp_path):
overview.render_map(src, dst) overview.render_map(src, dst)
maske = Image.open(dst / "map-walkable.png").convert("L") maske = Image.open(dst / "map-walkable.png").convert("L")
step = round(1 / overview.WALKABLE_SCALE) 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 # x=117 statt 113: 4 m Abstand zu jeder Kante des 12 m breiten Gehwegs, genau das Muster der
# der Fahrbahnprobe unten (x=105, 4 m Abstand zu deren Kanten 100/112) - keine Grenzzelle. # 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((117 // step, 10 // step)) == overview.WALKABLE, "Gehweg"
assert maske.getpixel((105 // step, 10 // step)) == overview.BLOCKED, "Fahrbahn" 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)], "klasse": "hauptstrasse"}],
"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"