docs: Task 12 gegen das echte Netz berichtigen

Vor dem Auftrag die Kette am echten Netz nachgemessen. Vier Fehler im Plan:

- Der Zuschnitt auf Chunks verwirft Stücke bis 0,5 m², ein Strich ist
  0,3 m² - keiner der 4.363 Striche hätte überlebt. Markierungen bekommen
  eine eigene Splittergrenze.
- _classified() stand in der Chunk-Schleife: 144 x 5,8 s, rund 14 Minuten.
- Der Entwurf setzt Palmen nach dem Straßenbau neu, der Plan nicht:
  24 Palmen stünden auf der Fahrbahn.
- Der erste Test verglich einen Aufruf mit sich selbst.

Dazu Messskript und Erwartungen auf den Stand nach dem Flamingo Park.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-17 19:22:48 +02:00
1 parent 6c73b7f816
commit 892558fc50
1 file changed
+175 -49
+175 -49
View File
@@ -1985,11 +1985,21 @@ git commit -m "feat(extract): Flamingo Park hängt seine Straße an den Graphen"
**Dateien:** **Dateien:**
- Ändern: `tools/extract/run.py` - Ändern: `tools/extract/run.py`
- Ändern: `tools/extract/leonida/emit.py` (nur ein Parameter)
- Test: `tools/extract/tests/test_pipeline_roads.py` (neu) - Test: `tools/extract/tests/test_pipeline_roads.py` (neu)
**Schnittstellen:** **Schnittstellen:**
- Verbraucht: alles aus den Tasks 3–11. - 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** - [ ] **Schritt 1: Den fehlschlagenden Test schreiben**
@@ -2002,32 +2012,79 @@ from __future__ import annotations
from shapely.geometry import box from shapely.geometry import box
from shapely.ops import unary_union 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.roads import Edge, Graph, Node
from leonida.street import build_street, punch 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(): def test_boden_und_belag_bekommen_dasselbe_loch():
graph = Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0))], """Vergisst run.py eine der beiden Schichten, liegt dort ein Belag über der Mulde oder die
edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse")]) Bodenplatte schließt sie."""
street = build_street(graph, box(0, -6, 100, 6), bridges=[]) graph, street = _gerade_wohnstrasse()
land = punch([box(-20, -50, 120, 50)], street.carriageway) platte = box(-20, -50, 120, 50)
belag = punch([box(-20, -50, 120, 50)], street.carriageway) welt = _welt(graph, surfaces=[(box(-20, -50, 50, 50), "urban"), (box(50, -50, 120, 50), "park")])
assert abs(unary_union(land).area - unary_union(belag).area) < 0.01 out = fit_to_carriageway(welt, street.carriageway)
assert unary_union(land).intersection(street.carriageway).area < 0.01 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(): def test_gehweg_liegt_nicht_im_loch():
graph = Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0))], graph, street = _gerade_wohnstrasse()
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 = unary_union(punch([box(-20, -50, 120, 50)], street.carriageway)) 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" 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** - [ ] **Schritt 2: Test laufen lassen**
Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_pipeline_roads.py -q` 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** - [ ] **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] bridge_polys = [p for p, bridge in world.roads if bridge]
street = build_street(world.graph, corridor, bridge_polys) street = build_street(world.graph, corridor, bridge_polys)
marks = markings(world.graph) marks = markings(world.graph)
world = replace(world, palmen = len(world.palms)
land=punch(world.land, street.carriageway), world = fit_to_carriageway(world, street.carriageway)
surfaces=[(part, kind) for poly, kind in world.surfaces arten = dict(sorted(Counter(art for _, art in marks).items()))
for part in punch([poly], street.carriageway)])
print(f"Straßen: {len(street.kerb)} Bordsteinstücke, {len(street.lots)} Restflächen, " 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: 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)), land=list(split_by_chunk_all(world.land, i, j)),
surfaces=[(part, kind) for (p, kind) in world.surfaces surfaces=[(part, kind) for (p, kind) in world.surfaces
for part in split_by_chunk_all([p], i, j)], 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)], for part in split_by_chunk_all([poly], i, j)],
curb=[part for line in street.kerb curb=[part for line in street.kerb
for part in _clip_lines(line, chunk_box_of(i, j))], for part in _clip_lines(line, chunk_box_of(i, j))],
sidewalk=list(split_by_chunk_all(_pieces(street.sidewalk), i, j)), sidewalk=list(split_by_chunk_all(_pieces(street.sidewalk), i, j)),
markings=[(part, art) for poly, art in marks markings=chunk_markings(marks, i, j),
for part in split_by_chunk_all([poly], i, j)],
lots=list(split_by_chunk_all(street.lots, i, j)), lots=list(split_by_chunk_all(street.lots, i, j)),
bridges=list(split_by_chunk_all(bridge_polys, i, j)), bridges=list(split_by_chunk_all(bridge_polys, i, j)),
edges=[(part, e.klass, e.width, SECTION[e.klass].carriageway) 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 ```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: def _pieces(geometry) -> list:
return [g for g in getattr(geometry, "geoms", [geometry]) if not g.is_empty] 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] return [g for g in _pieces(part) if g.geom_type == "LineString" and g.length > 0.5]
def _classified(street, graph) -> list: def fit_to_carriageway(world: World, carriage) -> World:
"""Jede Fahrbahnfläche mit der Klasse der Kante, die in ihr liegt. """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. Bodenplatte und Beläge bekommen dasselbe Loch — fehlt es in einer Schicht, liegt dort ein Belag
Zugeordnet wird über die Kante, deren Mittelpunkt in der Fläche liegt — bei mehreren gewinnt über der Mulde oder die Platte schließt sie. Palmen näher als PALM_KERB an der Fahrbahn fallen
die breiteste, weil an einer Kreuzung die größere Straße das Bild bestimmt. 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 = [] out = []
for poly in _pieces(street.carriageway): for poly in _pieces(street.carriageway):
treffer = [e.klass for e in graph.edges treffer = [klass for klass, mitte in mitten if poly.contains(mitte)]
if poly.contains(LineString(e.points).interpolate(0.5, normalized=True))]
klass = max(treffer, key=lambda k: SECTION[k].carriageway) if treffer else "wohnstrasse" klass = max(treffer, key=lambda k: SECTION[k].carriageway) if treffer else "wohnstrasse"
out.append((poly, klass)) out.append((poly, klass))
return out 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: 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** - [ ] **Schritt 4: Pipeline laufen lassen**
Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run python run.py` 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. 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 import json
from collections import Counter from collections import Counter
from pathlib import Path from pathlib import Path
from shapely.geometry import Polygon from shapely.geometry import LineString, Point, Polygon
from shapely.ops import unary_union
ROOT = Path("../../public/data/vice-a") ROOT = Path("../../public/data/vice-a")
zahl = Counter() stuecke, flaeche, arten, klassen = Counter(), Counter(), Counter(), Counter()
flaeche = Counter() bordstein, palmen_auf_fahrbahn = 0.0, 0
for path in sorted(ROOT.glob("chunk_*.json")): for path in sorted(ROOT.glob("chunk_*.json")):
c = json.loads(path.read_text(encoding="utf-8")) c = json.loads(path.read_text(encoding="utf-8"))
for key in ("carriageway", "sidewalk", "lots", "markings", "curb", "edges"): 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 key in ("carriageway", "sidewalk", "lots"):
for item in c.get(key, []): for item in c.get(key, []):
flaeche[key] += Polygon(item["poly"][0], item["poly"][1:]).area flaeche[key] += Polygon(item["poly"][0], item["poly"][1:]).area
klassen = Counter() bordstein += sum(LineString(line).length for line in c.get("curb", []))
for path in sorted(ROOT.glob("chunk_*.json")): arten.update(m["art"] for m in c.get("markings", []))
c = json.loads(path.read_text(encoding="utf-8"))
klassen.update(e["klasse"] for e in c.get("edges", [])) klassen.update(e["klasse"] for e in c.get("edges", []))
print("Stücke:", dict(zahl)) fahrbahn = [Polygon(item["poly"][0], item["poly"][1:]) for item in c.get("carriageway", [])]
print("Flächen km²:", {k: round(v / 1e6, 3) for k, v in flaeche.items()}) palmen_auf_fahrbahn += sum(1 for x, y in c.get("palms", [])
print("Kanten je Klasse:", dict(klassen)) 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 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 ```bash
cd ../.. && git status --short cd ../.. && git checkout -- public/data/vice-a && git status --short
git add tools/extract/run.py tools/extract/tests/test_pipeline_roads.py ```
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" 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.
--- ---