diff --git a/docs/superpowers/plans/2026-09-16-strassen.md b/docs/superpowers/plans/2026-09-16-strassen.md index cbd7873..7968b86 100644 --- a/docs/superpowers/plans/2026-09-16-strassen.md +++ b/docs/superpowers/plans/2026-09-16-strassen.md @@ -1985,11 +1985,21 @@ git commit -m "feat(extract): Flamingo Park hängt seine Straße an den Graphen" **Dateien:** - Ändern: `tools/extract/run.py` +- Ändern: `tools/extract/leonida/emit.py` (nur ein Parameter) - Test: `tools/extract/tests/test_pipeline_roads.py` (neu) **Schnittstellen:** - Verbraucht: alles aus den Tasks 3–11. -- Liefert: einen Lauf, der die neuen Schichten schreibt. +- Liefert: einen Lauf, der die neuen Schichten schreibt. In `run.py` zwei testbare Helfer, + `fit_to_carriageway(world, carriage) -> World` und `chunk_markings(marks, i, j) -> list[tuple[Polygon, str]]`; + in `emit.py` `split_by_chunk_all(polys, i, j, min_area=0.5)`. + +> **Vor dem Auftrag am echten Netz nachgemessen, vier Fehler dieser Aufgabe sind hier schon berichtigt:** +> (1) `split_by_chunk_all()` verwirft Stücke bis 0,5 m². Ein Strich der gestrichelten Linien ist 3 m × 10 cm = 0,3 m² — alle 4.363 Striche der Stadt wären beim Zuschnitt verschwunden, nachgezählt: 0 übrig. Markierungen laufen deshalb über `chunk_markings()` mit eigener Grenze. +> (2) `_classified()` stand in der Chunk-Schleife und hätte 144-mal je 5,8 s gerechnet, rund 14 Minuten. Es läuft jetzt einmal davor. +> (3) Der Entwurf setzt die Palmen nach dem Straßenbau neu (Abschnitt Ablauf, Schritt 7), der Plan hatte das nicht. `palm_points()` hält nur Abstand zum gemessenen Korridor, und die Fahrbahn ragt an Kurven und Kreuzungen darüber hinaus: 24 Palmen stünden auf der Fahrbahn, 32 weitere näher als `PALM_KERB`. +> (4) Der erste Test verglich denselben Aufruf mit sich selbst und konnte nicht fehlschlagen. Er prüft jetzt den Helfer, den `run.py` wirklich benutzt. +> Die Erwartungszahlen in Schritt 4 und 5 sind **nach** dem Flamingo Park gemessen, so wie der Lauf sie erzeugt. - [ ] **Schritt 1: Den fehlschlagenden Test schreiben** @@ -2002,32 +2012,79 @@ from __future__ import annotations from shapely.geometry import box from shapely.ops import unary_union +from leonida.emit import chunk_box_of +from leonida.park import World from leonida.roads import Edge, Graph, Node from leonida.street import build_street, punch +from run import chunk_markings, fit_to_carriageway + + +def _gerade_wohnstrasse(): + """100 m Wohnstraße entlang y = 0: Fahrbahn 7 m breit, also von y = -3,5 bis 3,5.""" + graph = Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse")]) + return graph, build_street(graph, box(0, -6, 100, 6), bridges=[]) + + +def _welt(graph, *, surfaces=(), palms=()): + return World(land=[box(-20, -50, 120, 50)], surfaces=list(surfaces), roads=[], graph=graph, + buildings=[], palms=list(palms)) def test_boden_und_belag_bekommen_dasselbe_loch(): - graph = Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0))], - edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse")]) - street = build_street(graph, box(0, -6, 100, 6), bridges=[]) - land = punch([box(-20, -50, 120, 50)], street.carriageway) - belag = punch([box(-20, -50, 120, 50)], street.carriageway) - assert abs(unary_union(land).area - unary_union(belag).area) < 0.01 - assert unary_union(land).intersection(street.carriageway).area < 0.01 + """Vergisst run.py eine der beiden Schichten, liegt dort ein Belag über der Mulde oder die + Bodenplatte schließt sie.""" + graph, street = _gerade_wohnstrasse() + platte = box(-20, -50, 120, 50) + welt = _welt(graph, surfaces=[(box(-20, -50, 50, 50), "urban"), (box(50, -50, 120, 50), "park")]) + out = fit_to_carriageway(welt, street.carriageway) + boden = unary_union(out.land) + assert boden.intersection(street.carriageway).area < 0.01, "die Fahrbahn liegt im Loch" + loch = street.carriageway.intersection(platte).area + assert abs(platte.area - boden.area - loch) < 0.01, "das Loch ist nicht größer als die Fahrbahn" + for kind in ("urban", "park"): + belag = unary_union([poly for poly, k in out.surfaces if k == kind]) + assert belag.intersection(street.carriageway).area < 0.01, f"kein Belag {kind} über der Mulde" + assert abs(unary_union([poly for poly, _ in out.surfaces]).area - boden.area) < 0.01, "dasselbe Loch" def test_gehweg_liegt_nicht_im_loch(): - graph = Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0))], - edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse")]) - street = build_street(graph, box(0, -6, 100, 6), bridges=[]) + graph, street = _gerade_wohnstrasse() land = unary_union(punch([box(-20, -50, 120, 50)], street.carriageway)) assert street.sidewalk.difference(land).area < 0.5, "der Gehweg liegt auf der Bodenplatte" + + +def test_keine_palme_auf_oder_dicht_an_der_fahrbahn(): + """Entwurf, Ablauf Schritt 7: Palmen werden nach dem Straßenbau neu gesetzt. Am echten Netz + standen ohne diesen Schritt 24 Palmen auf der Fahrbahn und 32 weitere näher als PALM_KERB.""" + graph, street = _gerade_wohnstrasse() + welt = _welt(graph, palms=[(50.0, 0.0), (50.0, 4.5), (50.0, 5.5)]) + out = fit_to_carriageway(welt, street.carriageway) + assert out.palms == [(50.0, 5.5)], "auf der Fahrbahn und 1 m daneben weg, 2 m daneben bleibt" + + +def test_striche_ueberleben_den_zuschnitt(): + """Ein Strich der gestrichelten Mitte ist 3 m × 10 cm = 0,3 m². Mit der Splittergrenze der + Flächen (0,5 m²) fiel am echten Netz jeder einzelne der 4.363 Striche weg.""" + zelle = chunk_box_of(0, 0) + x, y = zelle.centroid.x, zelle.centroid.y + stuecke = chunk_markings([(box(x, y, x + 3.0, y + 0.1), "gelb_gestrichelt")], 0, 0) + assert [art for _, art in stuecke] == ["gelb_gestrichelt"] + assert abs(stuecke[0][0].area - 0.3) < 1e-6 + + +def test_strich_ueber_der_chunkgrenze_wird_geteilt_nicht_verworfen(): + zelle = chunk_box_of(0, 0) + grenze, y = zelle.bounds[2], zelle.centroid.y # rechte Kante von Chunk (0, 0) + strich = [(box(grenze - 1.0, y, grenze + 2.0, y + 0.1), "weiss_gestrichelt")] + assert [round(poly.area, 6) for poly, _ in chunk_markings(strich, 0, 0)] == [0.1] + assert [round(poly.area, 6) for poly, _ in chunk_markings(strich, 1, 0)] == [0.2] ``` - [ ] **Schritt 2: Test laufen lassen** Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_pipeline_roads.py -q` -Erwartet: 2 bestanden — der Test prüft schon gebauten Code aus Task 7 und darf grün sein. +Erwartet: FAIL schon beim Sammeln, `ImportError: cannot import name 'chunk_markings' from 'run'` — die Helfer entstehen erst in Schritt 3. (`pythonpath = ["."]` in `pyproject.toml` macht `run.py` für die Tests importierbar; `main()` läuft beim Import nicht.) - [ ] **Schritt 3: `run.py` umbauen** @@ -2070,15 +2127,15 @@ Nach `world, messages = apply_flamingo_park(...)` die Straßen bauen: bridge_polys = [p for p, bridge in world.roads if bridge] street = build_street(world.graph, corridor, bridge_polys) marks = markings(world.graph) - world = replace(world, - land=punch(world.land, street.carriageway), - surfaces=[(part, kind) for poly, kind in world.surfaces - for part in punch([poly], street.carriageway)]) + palmen = len(world.palms) + world = fit_to_carriageway(world, street.carriageway) + arten = dict(sorted(Counter(art for _, art in marks).items())) print(f"Straßen: {len(street.kerb)} Bordsteinstücke, {len(street.lots)} Restflächen, " - f"{len(marks)} Markierungen") + f"{len(marks)} Markierungen {arten}, {palmen - len(world.palms)} Palmen an der Fahrbahn entfernt") + classified = _classified(street, world.graph) # einmal, nicht je Chunk: 5,8 s am echten Netz ``` -`replace` aus `dataclasses` und `unary_union` aus `shapely.ops` importieren. +`replace` aus `dataclasses`, `Counter` aus `collections`, `unary_union` aus `shapely.ops` und `PALM_KERB` aus `leonida.heights` importieren (dort steht der Import von `assign_heights, palm_points` schon). Die Chunk-Schleife auf die neuen Schlüssel umstellen: @@ -2088,13 +2145,12 @@ Die Chunk-Schleife auf die neuen Schlüssel umstellen: land=list(split_by_chunk_all(world.land, i, j)), surfaces=[(part, kind) for (p, kind) in world.surfaces for part in split_by_chunk_all([p], i, j)], - carriageway=[(part, klass) for poly, klass in _classified(street, world.graph) + carriageway=[(part, klass) for poly, klass in classified for part in split_by_chunk_all([poly], i, j)], curb=[part for line in street.kerb for part in _clip_lines(line, chunk_box_of(i, j))], sidewalk=list(split_by_chunk_all(_pieces(street.sidewalk), i, j)), - markings=[(part, art) for poly, art in marks - for part in split_by_chunk_all([poly], i, j)], + markings=chunk_markings(marks, i, j), lots=list(split_by_chunk_all(street.lots, i, j)), bridges=list(split_by_chunk_all(bridge_polys, i, j)), edges=[(part, e.klass, e.width, SECTION[e.klass].carriageway) @@ -2107,9 +2163,12 @@ Die Chunk-Schleife auf die neuen Schlüssel umstellen: )) ``` -Dazu drei kleine Helfer oben in `run.py`: +Dazu die Helfer oben in `run.py`: ```python +MARKING_MIN_AREA = 0.001 # m² — ein Strich ist 3 m × 10 cm = 0,3 m², die Flächengrenze 0,5 m² verwürfe ihn + + def _pieces(geometry) -> list: return [g for g in getattr(geometry, "geoms", [geometry]) if not g.is_empty] @@ -2120,24 +2179,66 @@ def _clip_lines(line, cell) -> list: return [g for g in _pieces(part) if g.geom_type == "LineString" and g.length > 0.5] -def _classified(street, graph) -> list: - """Jede Fahrbahnfläche mit der Klasse der Kante, die in ihr liegt. +def fit_to_carriageway(world: World, carriage) -> World: + """Die Welt an die gebaute Fahrbahn anpassen (Entwurf, Ablauf Schritte 3 und 7). - Die Fahrbahn ist eine einzige verschmolzene Geometrie; die Klasse hängt aber an der Kante. - Zugeordnet wird über die Kante, deren Mittelpunkt in der Fläche liegt — bei mehreren gewinnt - die breiteste, weil an einer Kreuzung die größere Straße das Bild bestimmt. + Bodenplatte und Beläge bekommen dasselbe Loch — fehlt es in einer Schicht, liegt dort ein Belag + über der Mulde oder die Platte schließt sie. Palmen näher als PALM_KERB an der Fahrbahn fallen + weg, nach derselben Regel, mit der apply_street() sie von der Jefferson Avenue fernhält: + palm_points() hält nur Abstand zum gemessenen Korridor, und die Fahrbahn ragt an Kurven und + Kreuzungen darüber hinaus. """ - from leonida.section import SECTION + return replace(world, + land=punch(world.land, carriage), + surfaces=[(part, kind) for poly, kind in world.surfaces + for part in punch([poly], carriage)], + palms=[p for p in world.palms if carriage.distance(Point(p)) >= PALM_KERB]) + + +def chunk_markings(marks, i: int, j: int) -> list: + """Die Markierungen in Chunk (i, j) — an der Chunkgrenze geteilt, nicht verworfen.""" + return [(part, art) for poly, art in marks + for part in split_by_chunk_all([poly], i, j, min_area=MARKING_MIN_AREA)] + + +def _classified(street, graph) -> list: + """Jede Fahrbahnfläche mit der Klasse der breitesten Kante, deren Mittelpunkt in ihr liegt. + + Vorsicht beim Lesen des Felds: carriageway_area() verschmilzt das Netz zu wenigen + zusammenhängenden Teilen, und ein Teil trägt dann die breiteste Klasse aller Straßen darin. Am + echten Netz sind das 5 von 213 Teilen, aber 91 % der Fahrbahnfläche tragen "boulevard". Über + die einzelne Straße sagt das Feld also wenig — die Klasse je Straße steht in edges. Heute liest + es kein Verbraucher; Browser und Minimap färben die Fahrbahn einheitlich. + """ + mitten = [(e.klass, LineString(e.points).interpolate(0.5, normalized=True)) for e in graph.edges] out = [] for poly in _pieces(street.carriageway): - treffer = [e.klass for e in graph.edges - if poly.contains(LineString(e.points).interpolate(0.5, normalized=True))] + treffer = [klass for klass, mitte in mitten if poly.contains(mitte)] klass = max(treffer, key=lambda k: SECTION[k].carriageway) if treffer else "wohnstrasse" out.append((poly, klass)) return out ``` -`SECTION` aus `leonida.section` importieren. +`SECTION` aus `leonida.section` und `Point` aus `shapely.geometry` importieren. + +In `tools/extract/leonida/emit.py` bekommt `split_by_chunk_all` die Splittergrenze als Parameter, mit dem bisherigen Wert als Vorgabe — alle anderen Aufrufer bleiben unverändert: + +```python +def split_by_chunk_all(polys, i: int, j: int, min_area: float = 0.5): + """Alle Anteile der übergebenen Polygone, die in Chunk (i, j) liegen. + + min_area verwirft die Splitter, die beim Zuschneiden an der Chunkgrenze entstehen. 0,5 m² passt + für Flächen; Markierungen sind kleiner und brauchen eine eigene Grenze (run.py, chunk_markings). + """ + cell = chunk_box(i, j) + for poly in polys: + part = poly.intersection(cell) + if not part.is_empty and part.area > min_area: + if part.geom_type == "Polygon": + yield part + else: + yield from (g for g in part.geoms if g.geom_type == "Polygon" and g.area > min_area) +``` Die Namensnennung im Manifest um den Bordstein erweitern: @@ -2151,7 +2252,7 @@ Die Namensnennung im Manifest um den Bordstein erweitern: - [ ] **Schritt 4: Pipeline laufen lassen** Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run python run.py` -Erwartet: Exit 0, die Zeile „Straßennetz: … Knoten, … Kanten", die Zeile „Straßen: …", die bekannte Parkzeile und **genau eine** Warnung (`Zufahrt schneidet die Sperrfläche tribuene`). +Erwartet: Exit 0 nach rund vier Minuten (davon etwa 70 s für das Zuschneiden der Markierungen in der Chunk-Schleife), die Zeile „Straßennetz: 1537 Knoten, 1790 Kanten", die Zeile „Straßen: 830 Bordsteinstücke, 171 Restflächen, 22394 Markierungen {…}, 56 Palmen an der Fahrbahn entfernt", die bekannte Parkzeile „Flamingo Park: 2 Wegflächen, 14 Flächen, 11 Gebäude, Jefferson Avenue 12 m breit" und **genau eine** Warnung (`Zufahrt schneidet die Sperrfläche tribuene`). Die Zahlen hat der Controller mit derselben Kette gemessen; weichen sie ab, gehört das in den Bericht. Bricht der Lauf in `check_corners` ab, ist das der im Entwurf vorhergesagte Stolperstein: Dann misst die Prüfung gegen die Fahrbahn statt gegen den Korridor. Nachsehen, ob `world.roads` beim Aufruf noch der Korridor ist — nicht die Toleranz hochsetzen. @@ -2162,42 +2263,67 @@ cd tools/extract && PYTHONUTF8=1 uv run python - <<'EOF' import json from collections import Counter from pathlib import Path -from shapely.geometry import Polygon -from shapely.ops import unary_union +from shapely.geometry import LineString, Point, Polygon ROOT = Path("../../public/data/vice-a") -zahl = Counter() -flaeche = Counter() +stuecke, flaeche, arten, klassen = Counter(), Counter(), Counter(), Counter() +bordstein, palmen_auf_fahrbahn = 0.0, 0 for path in sorted(ROOT.glob("chunk_*.json")): c = json.loads(path.read_text(encoding="utf-8")) for key in ("carriageway", "sidewalk", "lots", "markings", "curb", "edges"): - zahl[key] += len(c.get(key, [])) + stuecke[key] += len(c.get(key, [])) for key in ("carriageway", "sidewalk", "lots"): for item in c.get(key, []): flaeche[key] += Polygon(item["poly"][0], item["poly"][1:]).area -klassen = Counter() -for path in sorted(ROOT.glob("chunk_*.json")): - c = json.loads(path.read_text(encoding="utf-8")) + bordstein += sum(LineString(line).length for line in c.get("curb", [])) + arten.update(m["art"] for m in c.get("markings", [])) klassen.update(e["klasse"] for e in c.get("edges", [])) -print("Stücke:", dict(zahl)) -print("Flächen km²:", {k: round(v / 1e6, 3) for k, v in flaeche.items()}) -print("Kanten je Klasse:", dict(klassen)) + fahrbahn = [Polygon(item["poly"][0], item["poly"][1:]) for item in c.get("carriageway", [])] + palmen_auf_fahrbahn += sum(1 for x, y in c.get("palms", []) + if any(f.contains(Point(x, y)) for f in fahrbahn)) +print("Stücke, je Chunk gezählt:", dict(stuecke)) +print("Flächen km²:", {k: round(v / 1e6, 4) for k, v in flaeche.items()}) +print("Bordstein km:", round(bordstein / 1e3, 2)) +print("Markierungsstücke je Art:", dict(sorted(arten.items()))) +print("Kantenstücke je Klasse:", dict(klassen)) +print("Palmen auf der Fahrbahn:", palmen_auf_fahrbahn) EOF ``` -Erwartet, am echten Netz vom Controller nach Task 7 gemessen: Fahrbahn **0,914 km²** in 213 Teilen, Gehweg **0,295 km²**, Restflächen **0,093 km²** in 163 Stücken, Bordstein **120,3 km** in 832 Stücken, alle vier Klassen besetzt. Abweichungen von mehr als etwa 5 % gehören in den Bericht. +Erwartet, vom Controller mit derselben Kette nach dem Flamingo Park gemessen: -Zur Einordnung: Der gemessene Korridor ist 1,3156 km². Fahrbahn, Gehweg und Restflächen ergeben zusammen 1,3018 km²; die fehlenden 0,0138 km² sind die Splitter unter 200 m², die absichtlich wegfallen. 6,8 % der Fahrbahn liegen außerhalb des gemessenen Korridors — das sind die Bänder, die an Kurven und Kreuzungen über die alte Rasterkontur hinausragen, und das ist gewollt. +| Größe | Erwartet | +|---|---| +| Fahrbahn | **0,9084 km²** | +| Gehweg | **0,2983 km²** | +| Restflächen | **0,0973 km²** | +| Bordstein | **120,61 km** | +| Markierungsstücke je Art | je Art mindestens so viele wie in der Zeile „Straßen:" aus Schritt 4 — Zuschneiden teilt nur. `gelb_gestrichelt` und `weiss_gestrichelt` dürfen auf keinen Fall 0 sein. | +| Kantenstücke je Klasse | alle vier Klassen besetzt | +| Palmen auf der Fahrbahn | **0** | -- [ ] **Schritt 6: Commit** +Die Stückzahlen im Skript sind je Chunk gezählt: Was über eine Chunkgrenze läuft, zählt doppelt. Mit den ungeteilten Zahlen (213 Fahrbahnteile, 171 Restflächen, 830 Bordsteinstücke, 1.777 Kanten nach dem Park) sind sie deshalb nicht vergleichbar — die ungeteilten stehen in der Zeile „Straßen:". Flächen und Bordsteinlänge sind dagegen vergleichbar; der Zuschnitt verliert nur Splitter unter 0,5 m² beziehungsweise 0,5 m. **Weicht eine Fläche oder die Bordsteinlänge um mehr als 1 % ab, ist etwas falsch verdrahtet** — dann nicht die Erwartung anpassen, sondern im Bericht melden. + +Zur Einordnung: Der Korridor ohne Brücken ist nach dem Park 1,3140 km². Fahrbahn, Gehweg und Restflächen liegen darunter, weil Splitter unter 200 m² absichtlich wegfallen, und ein Teil der Fahrbahn liegt außerhalb des Korridors — die Bänder ragen an Kurven und Kreuzungen über die alte Rasterkontur hinaus, und das ist gewollt. Ohne den Park waren es 0,9136 / 0,2976 / 0,0944 km² und 120,27 km; der Unterschied ist die herausgeschnittene Parkfläche und die eingefügte Jefferson Avenue. + +- [ ] **Schritt 6: Erzeugte Daten zurückstellen** + +Der Lauf hat `public/data/vice-a` neu geschrieben. Die Daten kommen erst in Task 16 in einen eigenen Commit, und bis dahin sollen die Arbeitsbäume von Task 13 und 14 sauber bleiben: ```bash -cd ../.. && git status --short -git add tools/extract/run.py tools/extract/tests/test_pipeline_roads.py +cd ../.. && git checkout -- public/data/vice-a && git status --short +``` + +Erwartet: unversioniert nur die beiden Kit-Ordner des Nutzers (`modular_city_road_pack__game_ready/`, `modular_sidewalk_curb_kit/`), geändert nur `run.py` und `emit.py`, neu nur der Test. Liegen unter `public/data/vice-a` neue, unversionierte Dateien, stammen sie aus diesem Lauf: im Bericht nennen und nur diese entfernen. Die Kit-Ordner werden nie angefasst. + +- [ ] **Schritt 7: Commit** + +```bash +git add tools/extract/run.py tools/extract/leonida/emit.py tools/extract/tests/test_pipeline_roads.py git commit -m "feat(extract): Pipeline baut Straßen aus dem Graphen" ``` -Die erzeugten Daten kommen erst in Task 16 in einen eigenen Commit — hier **nicht** `public/data` mitcommitten. +Hier **nicht** `public/data` mitcommitten. ---