diff --git a/docs/superpowers/plans/2026-09-16-strassen.md b/docs/superpowers/plans/2026-09-16-strassen.md new file mode 100644 index 0000000..f2ea638 --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-strassen.md @@ -0,0 +1,2741 @@ +# Straßen mit Bordstein, Gehweg und US-Markierung — Umsetzungsplan + +> **Für agentische Bearbeiter:** ERFORDERLICHE UNTER-SKILL: `superpowers:subagent-driven-development` (empfohlen) oder `superpowers:executing-plans`, Aufgabe für Aufgabe. Die Schritte tragen Kästchen (`- [ ]`) zum Abhaken. + +**Ziel:** Jede Straße entsteht aus einem Graphen statt aus einer Rasterkontur und bekommt gerade Kanten, einen Bordstein, einen Gehweg und amerikanische Markierungen. + +**Aufbau:** Ein neues Modul `tools/extract/leonida/roads.py` baut aus dem Straßenskelett einen Graphen mit Knoten, Kanten, Breite und Klasse. Daraus entstehen Fahrbahnbänder, Kreuzungsflächen, Bordsteinlinien, Gehwegflächen und Markierungen als Geometrie. Die Fahrbahn liegt 15 cm unter dem Boden; die Bodenplatte bekommt Löcher in ihrer Form, und der Bordstein ist die Wand dazwischen. Der Browser zeichnet nur, was in den Chunks steht. + +**Werkzeuge:** Python 3.12 mit `uv` (shapely 2.1, numpy, scipy, scikit-image, networkx, Pillow), pytest; three.js 0.184, Vite 7, vitest 3. + +**Entwurf:** `docs/superpowers/specs/2026-09-16-strassen-design.md` — der Plan argumentiert aus dem Entwurf, beide gehören zusammen. + +## Globale Randbedingungen + +- Arbeitsverzeichnis `A:\developer\GitHub-desktop\gta6`, Branch `leonida/strassen`, Git Bash. Python immer aus `tools/extract` mit `PYTHONUTF8=1 uv run ...`. +- Die Ordner `modular_city_road_pack__game_ready/` und `modular_sidewalk_curb_kit/` im Wurzelverzeichnis gehören dem Nutzer: **nie ändern, nie committen**. Lesen ist erlaubt. Vor jedem Commit `git status --short` prüfen. +- `index-debug.html` darf nie committet werden. +- Kommentare, Tests und Commit-Nachrichten auf Deutsch, im Ton des bestehenden Codes: begründen, warum etwas so ist, nicht beschreiben, was dasteht. +- `from __future__ import annotations` bleibt in jeder Datei stehen, die es heute hat. +- Die Chunk-Ausgabe bleibt lauf-stabil: `shapely.normalize` je Fläche, Sortierung nach der Ausgabestruktur (Fixrunde 3 aus Teil 1). Ein zweiter Lauf der Pipeline darf keine einzige Datei ändern. +- Regelmaße aus dem Entwurf, wörtlich: Fahrspur 3,5 m, Parkspur 2,0–2,5 m, Gehweg 2,5–3,0 m, Bordsteinhöhe 0,15 m, Markierungsbreite 0,10 m, Eckradius 6,0 m, Strichmuster 3 m Strich / 9 m Lücke. +- Klassen und Querschnitte, wörtlich aus dem Entwurf: + + | Klasse | Korridor | Fahrbahn | Gehweg je Seite | Bordstein | + |---|---|---|---|---| + | `gasse` | 6 m | 6,0 m | 0 m | nein | + | `wohnstrasse` | 12 m | 7,0 m | 2,5 m | ja | + | `hauptstrasse` | 18 m | 12,0 m | 3,0 m | ja | + | `boulevard` | 24 m | 18,0 m | 3,0 m | ja | + +- Attribution in Commit-Nachrichten nach der eigenen Systemweisung des Bearbeiters. + +--- + +### Task 1: Risiko zuerst — trägt die gelöcherte Bodenplatte? + +Der Entwurf nennt das als erstes Risiko: Die Bodenplatte bekommt 88 km Lochrand, und `ShapeUtils.triangulateShape` im Browser ist damit nie belastet worden. Das wird gemessen, **bevor** irgendetwas gebaut wird. Fällt die Messung schlecht aus, ändert sich der Rest des Plans. + +**Dateien:** +- Test: `tests/geometry.test.js` (erweitern) + +**Schnittstellen:** +- Verbraucht: `buildChunkGeometry` aus `src/world/geometry.js`, unverändert. +- Liefert: eine Zahl für den Bericht und eine Entscheidung. Keine Produktionsänderung. + +- [ ] **Schritt 1: Messtest schreiben** + +In `tests/geometry.test.js` ans Ende: + +```js +describe("Bodenplatte mit vielen Löchern (Risiko aus dem Entwurf, Teil 2)", () => { + // 250-m-Chunk mit einem Raster aus 100 rechteckigen Löchern: die Größenordnung, die die + // Fahrbahnlöcher im dichtesten Stadtchunk erzeugen. Gemessen wird die Zeit, die + // triangulateShape dafür braucht — sie läuft beim Nachladen im Hauptthread. + const plateWithHoles = (count) => { + const outer = [[0, 0], [250, 0], [250, -250], [0, -250]]; + const holes = []; + const side = Math.ceil(Math.sqrt(count)); + for (let k = 0; k < count; k++) { + const x = 5 + (k % side) * (240 / side), z = -5 - Math.floor(k / side) * (240 / side); + const w = 240 / side - 6; + holes.push([[x, z], [x + w, z], [x + w, z - w], [x, z - w]]); + } + return [[outer, ...holes]]; + }; + + it("baut eine Platte mit 100 Löchern in unter 50 ms", () => { + const chunk = { chunk: [0, 0], origin: [0, 0], land: plateWithHoles(100), + roads: [], centerlines: [], buildings: [], palms: [] }; + const start = performance.now(); + const geo = buildChunkGeometry(chunk).ground; + const ms = performance.now() - start; + expect(geo).not.toBeNull(); + // Jedes Loch bringt vier Wandvierecke; die Deckfläche kommt dazu. + expect(geo.getAttribute("position").count).toBeGreaterThan(100 * 16); + expect(ms).toBeLessThan(50); + }); + + it("lässt kein Loch verschwinden", () => { + const chunk = { chunk: [0, 0], origin: [0, 0], land: plateWithHoles(25), + roads: [], centerlines: [], buildings: [], palms: [] }; + const geo = buildChunkGeometry(chunk).ground; + // Ohne Löcher hätte die Platte 4 Ringpunkte für die Wände plus 4 für die Decke. + expect(geo.getAttribute("position").count).toBeGreaterThan(25 * 16); + }); +}); +``` + +- [ ] **Schritt 2: Test laufen lassen** + +Ausführen: `npm test -- tests/geometry.test.js` +Erwartet: beide bestehen. **Sie prüfen bestehenden Code, sie dürfen nicht erst rot sein.** + +- [ ] **Schritt 3: Entscheidung festhalten** + +- Bleiben beide grün: weiter mit Task 2, im Bericht die gemessene Zeit nennen. +- Schlägt der Zeittest fehl oder wirft `triangulateShape`: **anhalten und melden**. Die gemessene Zeit und die Fehlermeldung in den Bericht. Der Ausweg wäre, die Bodenplatte je Chunk in waagerechte Streifen zu zerlegen, aber das ist eine Änderung am Entwurf und gehört dem Controller, nicht dir. + +- [ ] **Schritt 4: Commit** + +```bash +git add tests/geometry.test.js +git commit -m "test(world): Bodenplatte mit vielen Löchern messen + +Erstes Risiko des Entwurfs: die Fahrbahn wird als Loch aus der Bodenplatte +geschnitten, und ShapeUtils.triangulateShape läuft beim Nachladen im +Hauptthread. Der Test hält fest, dass 100 Löcher je Chunk unter 50 ms +bleiben und keines verschwindet. + +" +``` + +--- + +### Task 2: Texturen aus dem Bordstein-Kit holen + +**Dateien:** +- Ändern: `tools/assets/textures.mjs` +- Erzeugt (versioniert): `public/textures/curb_Diffuse.jpg`, `curb_nor_gl.jpg`, `curb_arm.jpg`, `sidewalk_Diffuse.jpg` + +**Schnittstellen:** +- Verbraucht: `modular_sidewalk_curb_kit/textures/*` (2048², nicht versioniert, gehört dem Nutzer — nur lesen). +- Liefert: vier Dateien im bestehenden Namensschema, die `createMaterials()` in Task 15 lädt. + +- [ ] **Schritt 1: Bestehendes Skript lesen** + +`tools/assets/textures.mjs` lädt Poly-Haven-Texturen und überspringt, was schon da ist. Der neue Schritt hängt sich hinten an und folgt derselben Regel. + +- [ ] **Schritt 2: Importschritt schreiben** + +Ans Ende von `tools/assets/textures.mjs`, vor einem eventuellen Abschlussaufruf: + +```js +// Bordstein und Gehweg kommen aus modular_sidewalk_curb_kit (Just8, CC BY 4.0). Der Ordner +// gehört dem Nutzer und liegt nicht im Repo — das Skript darf deshalb nicht von ihm abhängen, +// sondern überspringt den Schritt mit einer verständlichen Meldung, wenn er fehlt. Die vier +// erzeugten Dateien sind versioniert, danach braucht niemand mehr den Ordner. +const KIT = "modular_sidewalk_curb_kit/textures"; +const KIT_MAPS = [ + // glTF packt Rauheit nach G und Metall nach B — dasselbe Schema wie die arm-Karten von + // Poly Haven, deshalb ist die metallicRoughness-Karte hier direkt das arm-Ziel. + { from: "CurbMat_baseColor.jpeg", to: "curb_Diffuse.jpg" }, + { from: "CurbMat_normal.png", to: "curb_nor_gl.jpg" }, + { from: "CurbMat_metallicRoughness.png", to: "curb_arm.jpg" }, + { from: "sidewalk_baseColor.jpeg", to: "sidewalk_Diffuse.jpg" }, +]; + +async function importCurbKit() { + if (!existsSync(KIT)) { + console.log(`${KIT} fehlt — Bordstein-Texturen übersprungen. Das Kit gehört dem Nutzer ` + + "und ist nicht im Repo; die erzeugten Dateien liegen bereits unter public/textures."); + return; + } + for (const { from, to } of KIT_MAPS) { + const target = `public/textures/${to}`; + if (existsSync(target)) { + console.log(`${to} ist schon da`); + continue; + } + // 1024 wie alle übrigen Texturen: die 2048er des Kits wären für ein 15 cm hohes + // Wandband und einen Gehwegbelag Verschwendung. + await sharp(`${KIT}/${from}`).resize(1024, 1024).jpeg({ quality: 90 }).toFile(target); + console.log(`${to} erzeugt`); + } +} +``` + +`existsSync` aus `node:fs` und `sharp` werden oben im Modul schon importiert oder müssen dort ergänzt werden — nachsehen und nur ergänzen, was fehlt. `importCurbKit()` wird am Ende des Skripts aufgerufen, nach den Poly-Haven-Texturen. + +- [ ] **Schritt 3: Skript laufen lassen** + +Ausführen: `node tools/assets/textures.mjs` +Erwartet: vier Zeilen „… erzeugt", danach liegen die vier Dateien unter `public/textures/`. + +- [ ] **Schritt 4: Ohne Kit noch einmal laufen lassen** + +```bash +mv modular_sidewalk_curb_kit /tmp/kit-weg && node tools/assets/textures.mjs; mv /tmp/kit-weg modular_sidewalk_curb_kit +``` +Erwartet: die Meldung, dass das Kit fehlt, Exit 0, keine Datei gelöscht. **Danach prüfen, dass der Ordner wieder da ist** — er gehört dem Nutzer. + +- [ ] **Schritt 5: Commit** + +```bash +git status --short +git add tools/assets/textures.mjs public/textures/curb_Diffuse.jpg public/textures/curb_nor_gl.jpg public/textures/curb_arm.jpg public/textures/sidewalk_Diffuse.jpg +git commit -m "feat(assets): Bordstein- und Gehwegtexturen aus dem Kit holen + +modular_sidewalk_curb_kit (Just8, CC BY 4.0) liefert 2048er Karten für +Bordstein und Gehweg. Sie werden auf 1024 verkleinert und im Namensschema +der Poly-Haven-Texturen abgelegt, damit createMaterials() sie ohne +Sonderfall lädt. Das Kit selbst bleibt außerhalb des Repos; das Skript +überspringt den Schritt, wenn der Ordner fehlt. + +" +``` + +--- + +### Task 3: Straßengraph aus dem Skelett + +**Dateien:** +- Erstellen: `tools/extract/leonida/roads.py` +- Ändern: `tools/extract/leonida/vectors.py` (`_skeleton_graph` → `skeleton_graph`, `_to_world` → `to_world`) +- Test: `tools/extract/tests/test_roads_graph.py` + +**Schnittstellen:** +- Verbraucht: `road_mask(labels)`, `skeleton_graph(skel)`, `to_world(punkte)` aus `vectors.py`. +- Liefert: + - `@dataclass(frozen=True) Node(point: tuple[float, float])` + - `@dataclass Edge(a: int, b: int, points: list[tuple[float, float]], width: float = 0.0, klass: str = "")` + - `@dataclass Graph(nodes: list[Node], edges: list[Edge])` mit `Graph.degree(node_id) -> int` und `Graph.incident(node_id) -> list[int]` (Kantenindizes) + - `graph_from_skeleton(skel: np.ndarray) -> Graph` + - `JUNCTION_RADIUS = 8` + +- [ ] **Schritt 1: Die beiden Hilfsfunktionen öffentlich machen** + +In `tools/extract/leonida/vectors.py` `_skeleton_graph` in `skeleton_graph` und `_to_world` in `to_world` umbenennen und alle Verwendungen in derselben Datei mitziehen. Sie sind jetzt Schnittstelle für `roads.py`, und ein führender Unterstrich hieße das Gegenteil. + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest -q` +Erwartet: 186 bestanden, unverändert. + +- [ ] **Schritt 2: Den fehlschlagenden Test schreiben** + +`tools/extract/tests/test_roads_graph.py`: + +```python +"""Der Graph, aus dem die Straßen entstehen. Gegenstück zum Entwurf, Abschnitt "Das Netz".""" +from __future__ import annotations + +import numpy as np + +from leonida.coords import SLICE +from leonida.roads import JUNCTION_RADIUS, graph_from_skeleton + + +def skeleton(pixels: list[tuple[int, int]], shape=(120, 120)) -> np.ndarray: + skel = np.zeros(shape, dtype=bool) + for r, c in pixels: + skel[r, c] = True + return skel + + +def kreuz(arm: int = 40) -> np.ndarray: + """Ein Pluszeichen: vier Arme von 40 px an einem Mittelpunkt.""" + pixels = [(60, c) for c in range(60 - arm, 60 + arm + 1)] + pixels += [(r, 60) for r in range(60 - arm, 60 + arm + 1)] + return skeleton(pixels) + + +def test_ein_kreuz_gibt_einen_knoten_und_vier_kanten(): + graph = graph_from_skeleton(kreuz()) + junctions = [i for i in range(len(graph.nodes)) if graph.degree(i) >= 3] + assert len(junctions) == 1 + assert graph.degree(junctions[0]) == 4 + assert len(graph.edges) == 4 + + +def test_kanten_beginnen_und_enden_am_knotenpunkt(): + graph = graph_from_skeleton(kreuz()) + mitte = graph.nodes[next(i for i in range(len(graph.nodes)) if graph.degree(i) >= 3)].point + for edge in graph.edges: + enden = {edge.points[0], edge.points[-1]} + assert mitte in enden, "jede Kante des Kreuzes hängt am Mittelknoten" + + +def test_zwei_kreuzungen_naeher_als_der_radius_werden_ein_knoten(): + # Zwei Abzweige im Abstand von 4 m — unter JUNCTION_RADIUS, also ein Knoten. + pixels = [(60, c) for c in range(20, 100)] + pixels += [(r, 58) for r in range(40, 60)] + pixels += [(r, 62) for r in range(60, 80)] + graph = graph_from_skeleton(skeleton(pixels)) + assert 4 < JUNCTION_RADIUS + assert len([i for i in range(len(graph.nodes)) if graph.degree(i) >= 3]) == 1 + + +def test_gerade_strecke_ohne_abzweig_gibt_eine_kante_mit_zwei_enden(): + graph = graph_from_skeleton(skeleton([(60, c) for c in range(20, 100)])) + assert len(graph.edges) == 1 + assert [graph.degree(i) for i in range(len(graph.nodes))] == [1, 1] + + +def test_punkte_liegen_in_weltkoordinaten(): + graph = graph_from_skeleton(skeleton([(60, c) for c in range(20, 100)])) + xs = [x for x, _ in graph.edges[0].points] + assert min(xs) == SLICE["x0"] + 20 + assert max(xs) == SLICE["x0"] + 99 +``` + +- [ ] **Schritt 3: Test laufen lassen, Fehlschlag sehen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_roads_graph.py -q` +Erwartet: `ModuleNotFoundError: No module named 'leonida.roads'` + +- [ ] **Schritt 4: `roads.py` anlegen** + +```python +"""Straßennetz als Graph statt als nachgezeichnete Kontur. + +Der Entwurf (docs/superpowers/specs/2026-09-16-strassen-design.md) misst nach, dass die +Mittellinien im Median nur um den Faktor 1,0104 von der Luftlinie abweichen: Die Straßen sind +längst gerade, das Zackige sitzt allein in den Rändern von road_polygons(). Wer die Ränder aus +Mittellinie und Breite neu baut, statt sie zu glätten, bekommt gerade Straßen fast geschenkt. + +centerlines() in vectors.py taugt dafür nicht: Sein Filter length >= 15 wirft an jeder Kreuzung +die kurzen Stummel weg, und von 3.038 Linienenden treffen sich dadurch nur 81 zu Kreuzungen. +Hier bleibt das Skelett deshalb ungefiltert, und die Kreuzungen entstehen aus den Pixeln mit +Grad >= 3 statt aus zufällig aufeinandertreffenden Linienenden. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import networkx as nx +import numpy as np +from scipy import ndimage as ndi +from skimage.morphology import disk + +from .vectors import skeleton_graph, to_world + +# Radius, in dem Kreuzungspixel zu einem Knoten verschmelzen. Am echten Mosaik gemessen: Die +# 5.970 Pixel mit Grad >= 3 fallen damit auf 862 Knoten zusammen. Kleiner, und eine breite +# Kreuzung zerfällt in mehrere Knoten, weil das Skelett dort sternförmig ausfranst. +JUNCTION_RADIUS = 8 + + +@dataclass(frozen=True) +class Node: + point: tuple[float, float] + + +@dataclass +class Edge: + a: int + b: int + points: list[tuple[float, float]] + width: float = 0.0 + klass: str = "" + + +@dataclass +class Graph: + nodes: list[Node] = field(default_factory=list) + edges: list[Edge] = field(default_factory=list) + + def incident(self, node: int) -> list[int]: + return [k for k, e in enumerate(self.edges) if e.a == node or e.b == node] + + def degree(self, node: int) -> int: + # Eine Schleife (a == b) zählt zweimal, wie in jedem Graphen. + return sum((e.a == node) + (e.b == node) for e in self.edges) + + +def graph_from_skeleton(skel: np.ndarray) -> Graph: + """Knoten aus den Kreuzungspixeln, Kanten aus dem, was zwischen ihnen übrig bleibt.""" + pixels = skeleton_graph(skel) + clusters, points = _junction_clusters(pixels, skel.shape) + graph = Graph(nodes=[Node(p) for p in points]) + rest = pixels.copy() + rest.remove_nodes_from([p for p in pixels if clusters[p] > 0]) + for component in nx.connected_components(rest): + chain = _chain(rest.subgraph(component)) + if not chain: + continue + a = _attach(graph, pixels, clusters, chain[0]) + b = _attach(graph, pixels, clusters, chain[-1]) + line = to_world(chain) + if graph.nodes[a].point != line[0]: + line.insert(0, graph.nodes[a].point) + if graph.nodes[b].point != line[-1]: + line.append(graph.nodes[b].point) + graph.edges.append(Edge(a=a, b=b, points=line)) + return graph + + +def _junction_clusters(pixels: nx.Graph, shape) -> tuple[np.ndarray, list[tuple[float, float]]]: + """Kreuzungspixel, um JUNCTION_RADIUS gewachsen, in Zusammenhangskomponenten. + + Der Knotenpunkt ist der Schwerpunkt der *ungewachsenen* Pixel: Das Wachsen dient nur dazu, + benachbarte Kreuzungen zusammenzufassen, und würde den Punkt sonst nach außen ziehen. + """ + junction = np.zeros(shape, dtype=bool) + for pixel in pixels: + if pixels.degree(pixel) >= 3: + junction[pixel] = True + labelled, count = ndi.label(ndi.binary_dilation(junction, disk(JUNCTION_RADIUS))) + points = [] + for k in range(1, count + 1): + rows, cols = np.nonzero(junction & (labelled == k)) + points.append(to_world([(rows.mean(), cols.mean())])[0]) + return labelled * junction_reach(labelled, junction), points + + +def junction_reach(labelled: np.ndarray, junction: np.ndarray) -> np.ndarray: + """Alle gewachsenen Pixel gehören zum Knoten, nicht nur die ursprünglichen.""" + return labelled > 0 + + +def _chain(sub: nx.Graph) -> list[tuple[int, int]]: + """Die Pixel einer Kette in Laufrichtung. Ohne Kreuzungspixel ist jede Komponente eine.""" + ends = [n for n in sub if sub.degree(n) <= 1] + if not ends: + return [] # geschlossener Ring ohne Anschluss: keine Straße, die irgendwo hinführt + if len(ends) == 1: + return [ends[0]] + return nx.shortest_path(sub, ends[0], ends[-1]) + + +def _attach(graph: Graph, pixels: nx.Graph, clusters: np.ndarray, pixel) -> int: + """Knoten-ID am Ende einer Kette: der berührte Kreuzungsknoten oder ein neues Sackgassenende.""" + for neighbour in pixels.neighbors(pixel): + if clusters[neighbour] > 0: + return int(clusters[neighbour]) - 1 + graph.nodes.append(Node(to_world([pixel])[0])) + return len(graph.nodes) - 1 +``` + +- [ ] **Schritt 5: Test laufen lassen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_roads_graph.py -q` +Erwartet: 5 bestanden. + +Bleibt etwas rot, ist der wahrscheinlichste Grund `_junction_clusters`: `clusters` muss für **jedes** gewachsene Pixel die Knotennummer tragen, damit `_attach` das Ende einer Kette einem Knoten zuordnen kann. Die Rückgabe `labelled * junction_reach(...)` ist genau dafür da — `labelled` allein ist außerhalb der gewachsenen Bereiche 0, und das ist gewollt. + +- [ ] **Schritt 6: Volle Suite und Commit** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run pytest -q +``` +Erwartet: 191 bestanden (186 + 5). + +```bash +git add tools/extract/leonida/roads.py tools/extract/leonida/vectors.py tools/extract/tests/test_roads_graph.py +git commit -m "feat(extract): Straßennetz als Graph aus dem Skelett + +centerlines() verwirft alles unter 15 m und zerlegt das Netz damit an jeder +Kreuzung: von 3.038 Linienenden treffen sich nur 81. Der neue Graph lässt +das Skelett ungefiltert, macht aus den 5.970 Pixeln mit Grad >= 3 durch +Verschmelzen im Umkreis von 8 m die 862 Kreuzungen und hängt die Kanten +dort ein. + +" +``` + +--- + +### Task 4: Graph säubern, Breite messen, Klasse bestimmen + +**Dateien:** +- Ändern: `tools/extract/leonida/roads.py` +- Test: `tools/extract/tests/test_roads_clean.py` + +**Schnittstellen:** +- Verbraucht: `Graph`, `Node`, `Edge` aus Task 3. +- Liefert: + - `SPUR_MIN = 10.0`, `CONTRACT_MAX = 5.0`, `SIMPLIFY = 1.0`, `WIDTH_MARGIN = 8.0`, `WIDTH_STEP = 5.0` + - `CLASS_WIDTH = {"gasse": 6.0, "wohnstrasse": 12.0, "hauptstrasse": 18.0, "boulevard": 24.0}` + - `clean(graph: Graph) -> Graph` + - `measure_widths(graph: Graph, distance: np.ndarray) -> Graph` (setzt `width` und `klass` je Kante) + - `road_graph(labels: np.ndarray) -> Graph` — der Aufruf für die Pipeline: Skelett, Graph, säubern, messen + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +`tools/extract/tests/test_roads_clean.py`: + +```python +"""Säubern des Graphen und Messen der Breite.""" +from __future__ import annotations + +import numpy as np + +from leonida.coords import SLICE +from leonida.roads import (CLASS_WIDTH, Edge, Graph, Node, SPUR_MIN, clean, measure_widths) + + +def punkt(x: float, y: float) -> tuple[float, float]: + return (float(x), float(y)) + + +def gerade(a: tuple[float, float], b: tuple[float, float]) -> list[tuple[float, float]]: + return [a, b] + + +def test_kurze_sackgasse_faellt_weg(): + # Eine Durchgangsstraße mit einem 6-m-Stummel: unter SPUR_MIN, also eine Skelettfranse. + graph = Graph(nodes=[Node(punkt(0, 0)), Node(punkt(100, 0)), Node(punkt(50, 0)), Node(punkt(50, 6))], + edges=[Edge(0, 2, gerade(punkt(0, 0), punkt(50, 0))), + Edge(2, 1, gerade(punkt(50, 0), punkt(100, 0))), + Edge(2, 3, gerade(punkt(50, 0), punkt(50, 6)))]) + assert 6 < SPUR_MIN + out = clean(graph) + assert len(out.edges) == 1, "nach dem Stummel bleibt eine durchgehende Kante" + assert out.edges[0].points[0] == punkt(0, 0) + assert out.edges[0].points[-1] == punkt(100, 0) + + +def test_lange_sackgasse_bleibt(): + graph = Graph(nodes=[Node(punkt(0, 0)), Node(punkt(100, 0)), Node(punkt(50, 0)), Node(punkt(50, 40))], + edges=[Edge(0, 2, gerade(punkt(0, 0), punkt(50, 0))), + Edge(2, 1, gerade(punkt(50, 0), punkt(100, 0))), + Edge(2, 3, gerade(punkt(50, 0), punkt(50, 40)))]) + assert len(clean(graph).edges) == 3 + + +def test_knoten_mit_nur_zwei_kanten_verschwindet(): + graph = Graph(nodes=[Node(punkt(0, 0)), Node(punkt(50, 0)), Node(punkt(100, 0))], + edges=[Edge(0, 1, gerade(punkt(0, 0), punkt(50, 0))), + Edge(1, 2, gerade(punkt(50, 0), punkt(100, 0)))]) + out = clean(graph) + assert len(out.edges) == 1 + assert out.edges[0].points == [punkt(0, 0), punkt(50, 0), punkt(100, 0)] + + +def test_breite_ignoriert_die_kreuzungsenden(): + # 100 m lange Straße, 12 m breit, an beiden Enden eine 40 m breite Kreuzungsfläche. + # Ohne die Randregel zöge der Median die Klasse auf hauptstrasse hoch. + distance = np.full((200, 200), 0.0) + distance[:, :] = 6.0 # halbe Breite 6 m -> 12 m + distance[:, :10] = 20.0 # linkes Ende: 40 m + distance[:, -10:] = 20.0 # rechtes Ende + graph = Graph(nodes=[Node(punkt(SLICE["x0"] + 5, SLICE["y1"] - 100)), + Node(punkt(SLICE["x0"] + 195, SLICE["y1"] - 100))], + edges=[Edge(0, 1, gerade(punkt(SLICE["x0"] + 5, SLICE["y1"] - 100), + punkt(SLICE["x0"] + 195, SLICE["y1"] - 100)))]) + out = measure_widths(graph, distance) + assert out.edges[0].width == 12.0 + assert out.edges[0].klass == "wohnstrasse" + + +def test_klasse_ist_die_naechstliegende_regelbreite(): + distance = np.full((200, 200), 8.0) # 16 m + graph = Graph(nodes=[Node(punkt(SLICE["x0"] + 20, SLICE["y1"] - 100)), + Node(punkt(SLICE["x0"] + 180, SLICE["y1"] - 100))], + edges=[Edge(0, 1, gerade(punkt(SLICE["x0"] + 20, SLICE["y1"] - 100), + punkt(SLICE["x0"] + 180, SLICE["y1"] - 100)))]) + out = measure_widths(graph, distance) + assert out.edges[0].klass == "hauptstrasse" + assert CLASS_WIDTH["hauptstrasse"] == 18.0 +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag sehen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_roads_clean.py -q` +Erwartet: `ImportError: cannot import name 'clean'` + +- [ ] **Schritt 3: Säubern und Messen schreiben** + +An `tools/extract/leonida/roads.py` anhängen: + +```python +from shapely.geometry import LineString + +from .coords import SLICE + +# Sackgassen unter dieser Länge sind Skelettfransen an breiten Flächen, keine Straßen. +SPUR_MIN = 10.0 +# Zwei Kreuzungen näher als das sind eine: das Skelett franst an Parkplätzen sternförmig aus. +CONTRACT_MAX = 5.0 +SIMPLIFY = 1.0 +# Die ersten und letzten Meter einer Kante liegen im Kreuzungsbereich; dort misst die +# Distanztransformation die Breite der Kreuzung, nicht die der Straße. +WIDTH_MARGIN = 8.0 +WIDTH_STEP = 5.0 +CLASS_WIDTH = {"gasse": 6.0, "wohnstrasse": 12.0, "hauptstrasse": 18.0, "boulevard": 24.0} + + +def road_graph(labels: np.ndarray, distance: np.ndarray) -> Graph: + """Der Aufruf für die Pipeline: Skelett, Graph, säubern, Breite und Klasse.""" + from skimage.morphology import skeletonize + + from .vectors import road_mask + + return measure_widths(clean(graph_from_skeleton(skeletonize(road_mask(labels)))), distance) + + +def clean(graph: Graph) -> Graph: + """Fransen weg, Zwischenknoten auflösen, zu kurze Verbindungen zusammenziehen, glätten.""" + out = Graph(nodes=list(graph.nodes), edges=list(graph.edges)) + _drop_spurs(out) + _merge_chains(out) + _contract_short(out) + _merge_chains(out) + for edge in out.edges: + simple = LineString(edge.points).simplify(SIMPLIFY) + edge.points = [(round(x, 2), round(y, 2)) for x, y in simple.coords] + return _renumber(out) + + +def _length(edge: Edge) -> float: + return LineString(edge.points).length + + +def _drop_spurs(graph: Graph) -> None: + changed = True + while changed: + changed = False + for k, edge in enumerate(graph.edges): + frei = graph.degree(edge.a) == 1 or graph.degree(edge.b) == 1 + if frei and _length(edge) < SPUR_MIN: + del graph.edges[k] + changed = True + break + + +def _merge_chains(graph: Graph) -> None: + changed = True + while changed: + changed = False + for node in range(len(graph.nodes)): + incident = graph.incident(node) + if len(incident) != 2 or graph.degree(node) != 2: + continue + first, second = (graph.edges[i] for i in incident) + points = _join(first, second, graph.nodes[node].point) + if points is None: + continue + ends = [n for e in (first, second) for n in (e.a, e.b) if n != node] + for i in sorted(incident, reverse=True): + del graph.edges[i] + graph.edges.append(Edge(a=ends[0], b=ends[1], points=points)) + changed = True + break + + +def _join(first: Edge, second: Edge, at: tuple[float, float]) -> list[tuple[float, float]] | None: + """Zwei Kanten an einem gemeinsamen Punkt zu einer Punktfolge verketten.""" + a = first.points if first.points[-1] == at else list(reversed(first.points)) + b = second.points if second.points[0] == at else list(reversed(second.points)) + if a[-1] != at or b[0] != at: + return None # die Enden passen nicht zusammen: nichts anfassen + return a + b[1:] + + +def _contract_short(graph: Graph) -> None: + changed = True + while changed: + changed = False + for k, edge in enumerate(graph.edges): + if edge.a == edge.b or _length(edge) >= CONTRACT_MAX: + continue + if graph.degree(edge.a) < 3 or graph.degree(edge.b) < 3: + continue + ax, ay = graph.nodes[edge.a].point + bx, by = graph.nodes[edge.b].point + graph.nodes[edge.a] = Node(((ax + bx) / 2, (ay + by) / 2)) + del graph.edges[k] + for other in graph.edges: + if other.a == edge.b: + other.a = edge.a + other.points = [graph.nodes[edge.a].point, *other.points[1:]] + if other.b == edge.b: + other.b = edge.a + other.points = [*other.points[:-1], graph.nodes[edge.a].point] + changed = True + break + + +def _renumber(graph: Graph) -> Graph: + """Knoten ohne Kante fallen weg; die Kanten zeigen danach auf die neuen Nummern.""" + used = sorted({n for e in graph.edges for n in (e.a, e.b)}) + index = {old: new for new, old in enumerate(used)} + return Graph(nodes=[graph.nodes[i] for i in used], + edges=[Edge(index[e.a], index[e.b], e.points, e.width, e.klass) for e in graph.edges]) + + +def measure_widths(graph: Graph, distance: np.ndarray) -> Graph: + """Breite je Kante aus der Distanztransformation, dann die nächstliegende Regelbreite.""" + for edge in graph.edges: + line = LineString(edge.points) + edge.width = _median_width(line, distance) + edge.klass = min(CLASS_WIDTH, key=lambda name: abs(CLASS_WIDTH[name] - edge.width)) + return graph + + +def _median_width(line: LineString, distance: np.ndarray) -> float: + start, end = WIDTH_MARGIN, line.length - WIDTH_MARGIN + if end <= start: + start, end = 0.0, line.length # zu kurz für die Randregel: dann eben ganz messen + steps = max(1, int((end - start) // WIDTH_STEP)) + samples = [] + for i in range(steps + 1): + point = line.interpolate(start + (end - start) * i / steps) + row = int(round(SLICE["y1"] - point.y)) + col = int(round(point.x - SLICE["x0"])) + if 0 <= row < distance.shape[0] and 0 <= col < distance.shape[1]: + samples.append(distance[row, col] * 2) + return float(np.median(samples)) if samples else 0.0 +``` + +- [ ] **Schritt 4: Test laufen lassen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_roads_clean.py -q` +Erwartet: 5 bestanden. + +- [ ] **Schritt 5: Am echten Mosaik gegenprüfen** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run python - <<'EOF' +from collections import Counter +from pathlib import Path +import scipy.ndimage as ndi +from leonida.annotations import load_annotations, remove_annotations +from leonida.classify import classify, drop_map_labels +from leonida.roads import road_graph +from leonida.tiles import load_mosaic +from leonida.vectors import road_mask + +mosaic = load_mosaic(Path("cache")) +labels, _ = classify(mosaic, report_unknown=True) +labels = drop_map_labels(labels) +labels = remove_annotations(mosaic, labels, load_annotations(Path("data/annotations.toml"))).labels +labels = drop_map_labels(labels) +distance = ndi.distance_transform_edt(road_mask(labels)) +graph = road_graph(labels, distance) +print(f"Knoten {len(graph.nodes)}, Kanten {len(graph.edges)}") +print("Kreuzungen:", sum(1 for i in range(len(graph.nodes)) if graph.degree(i) >= 3)) +print("Klassen:", Counter(e.klass for e in graph.edges)) +EOF +``` +Erwartet, als Größenordnung aus dem Entwurf: rund 800–900 Kreuzungen und 2.000–3.500 Kanten, alle vier Klassen besetzt. Weicht es stark ab, in den Bericht schreiben und melden — nicht die Konstanten nachdrehen, bis die Zahl gefällt. + +- [ ] **Schritt 6: Commit** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run pytest -q +cd ../.. && git add tools/extract/leonida/roads.py tools/extract/tests/test_roads_clean.py +git commit -m "feat(extract): Graph säubern, Breite messen, Klasse bestimmen + +Sackgassen unter 10 m sind Skelettfransen und fallen weg, Knoten mit nur +noch zwei Kanten lösen sich auf, Kreuzungen näher als 5 m werden eine. Die +Breite kommt aus der Distanztransformation ohne die ersten und letzten 8 m: +dort misst sie die Kreuzung, nicht die Straße. + +" +``` + +--- + +### Task 5: Querschnitte und Fahrbahnbänder + +**Dateien:** +- Erstellen: `tools/extract/leonida/section.py` +- Test: `tools/extract/tests/test_section.py` + +**Schnittstellen:** +- Verbraucht: `Graph`, `Edge`, `CLASS_WIDTH` aus `roads.py`. +- Liefert: + - `LANE = 3.5`, `SETBACK_EXTRA = 2.0`, `WALK_MAX = 3.0` + - `@dataclass(frozen=True) Section(corridor, carriageway, walk, lanes, parking, kerb)` + - `SECTION: dict[str, Section]` + - `setback(graph, node) -> float` + - `trimmed_line(graph, k) -> LineString | None` + - `ribbon(graph, k) -> Polygon | None` + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +`tools/extract/tests/test_section.py`: + +```python +"""Querschnitte und Fahrbahnbänder.""" +from __future__ import annotations + +from leonida.roads import CLASS_WIDTH, Edge, Graph, Node +from leonida.section import LANE, SECTION, ribbon, setback, trimmed_line + + +def strecke(klass: str, length: float = 100.0) -> Graph: + return Graph(nodes=[Node((0.0, 0.0)), Node((length, 0.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (length, 0.0)], CLASS_WIDTH[klass], klass)]) + + +def test_fahrbahn_und_zwei_gehwege_ergeben_den_korridor(): + for name, section in SECTION.items(): + assert section.carriageway + 2 * section.walk == section.corridor, name + assert section.corridor == CLASS_WIDTH[name], name + + +def test_spuren_und_parkspuren_fuellen_die_fahrbahn(): + for name, section in SECTION.items(): + if name == "gasse": + continue # eine Fahrgasse ohne Spureinteilung, breiter als eine Regelspur + assert section.lanes * LANE + 2 * section.parking == section.carriageway, name + + +def test_nur_die_gasse_hat_keinen_bordstein(): + assert [name for name, s in SECTION.items() if not s.kerb] == ["gasse"] + + +def test_band_ist_so_breit_wie_die_fahrbahn(): + band = ribbon(strecke("hauptstrasse"), 0) + x0, y0, x1, y1 = band.bounds + assert round(y1 - y0, 6) == SECTION["hauptstrasse"].carriageway + assert round(x1 - x0, 6) == 100.0, "freie Enden werden nicht zurückgeschnitten" + + +def test_an_einer_kreuzung_wird_zurueckgeschnitten(): + graph = Graph( + nodes=[Node((0.0, 0.0)), Node((100.0, 0.0)), Node((100.0, 60.0)), Node((160.0, 0.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse"), + Edge(1, 2, [(100.0, 0.0), (100.0, 60.0)], 12.0, "wohnstrasse"), + Edge(1, 3, [(100.0, 0.0), (160.0, 0.0)], 12.0, "wohnstrasse")]) + assert graph.degree(1) == 3 + r = setback(graph, 1) + assert r == SECTION["wohnstrasse"].carriageway / 2 + 2.0 + line = trimmed_line(graph, 0) + assert round(line.length, 6) == round(100.0 - r, 6) + assert line.coords[0] == (0.0, 0.0), "das freie Ende bleibt, nur das Kreuzungsende weicht" + + +def test_kante_ganz_innerhalb_der_kreuzung_liefert_kein_band(): + graph = Graph(nodes=[Node((0.0, 0.0)), Node((3.0, 0.0)), Node((0.0, 50.0)), Node((3.0, 50.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (3.0, 0.0)], 18.0, "hauptstrasse"), + Edge(0, 2, [(0.0, 0.0), (0.0, 50.0)], 18.0, "hauptstrasse"), + Edge(1, 3, [(3.0, 0.0), (3.0, 50.0)], 18.0, "hauptstrasse")]) + assert trimmed_line(graph, 0) is None + assert ribbon(graph, 0) is None +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag sehen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_section.py -q` +Erwartet: `ModuleNotFoundError: No module named 'leonida.section'` + +- [ ] **Schritt 3: `section.py` schreiben** + +```python +"""Querschnitt je Straßenklasse und das Fahrbahnband, das daraus entsteht. + +Die Maße stehen im Entwurf (docs/superpowers/specs/2026-09-16-strassen-design.md) und stammen +aus dem amerikanischen Regelwerk: Fahrspur 3,5 m, Längsparkspur 2,0-2,5 m, Gehweg 2,5-3,0 m. +Gemessen wird auf der Karte der ganze graue Korridor - Fahrbahn und Gehweg sind dort dasselbe +Grau. Die Aufteilung nimmt dem Korridor deshalb nichts weg und gibt ihm nichts dazu: Fahrbahn +plus zweimal Gehweg ist genau die gemessene Breite. Der Stadtgrundriss bleibt damit unberührt. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from shapely.geometry import LineString, Point +from shapely.ops import substring, unary_union + +from .roads import CLASS_WIDTH, Graph + +LANE = 3.5 +# Wie weit das Band vor dem Kreuzungsmittelpunkt endet: halbe Fahrbahn der breitesten +# anliegenden Kante plus etwas Luft, damit die Kreuzungsfläche die Lücke sicher füllt. +SETBACK_EXTRA = 2.0 +# Breitester Gehweg. Weil die Gehwegfläche am Korridor beschnitten wird, ergibt sich die +# schmalere Variante von selbst: bei 12 m Korridor und 7 m Fahrbahn bleiben genau 2,5 m. +WALK_MAX = 3.0 + + +@dataclass(frozen=True) +class Section: + corridor: float + carriageway: float + walk: float + lanes: int + parking: float + kerb: bool + + +SECTION = { + # Hinterhofgasse: eine Fahrgasse, kein Bordstein, kein Gehweg, keine Markierung. + "gasse": Section(corridor=6.0, carriageway=6.0, walk=0.0, lanes=1, parking=0.0, kerb=False), + "wohnstrasse": Section(corridor=12.0, carriageway=7.0, walk=2.5, lanes=2, parking=0.0, kerb=True), + "hauptstrasse": Section(corridor=18.0, carriageway=12.0, walk=3.0, lanes=2, parking=2.5, kerb=True), + "boulevard": Section(corridor=24.0, carriageway=18.0, walk=3.0, lanes=4, parking=2.0, kerb=True), +} +assert all(s.carriageway + 2 * s.walk == s.corridor == CLASS_WIDTH[n] for n, s in SECTION.items()) + + +def setback(graph: Graph, node: int) -> float: + return max(SECTION[graph.edges[k].klass].carriageway / 2 for k in graph.incident(node)) + SETBACK_EXTRA + + +def trimmed_line(graph: Graph, k: int): + """Die Mittellinie zwischen den Kreuzungsrändern. Freie Enden bleiben, wo sie sind.""" + edge = graph.edges[k] + line = LineString(edge.points) + start = setback(graph, edge.a) if graph.degree(edge.a) >= 3 else 0.0 + end = line.length - (setback(graph, edge.b) if graph.degree(edge.b) >= 3 else 0.0) + if end - start < 1.0: + return None # die Kante liegt ganz in den Kreuzungen; die füllen sie mit + return substring(line, start, end) + + +def ribbon(graph: Graph, k: int): + """Das Fahrbahnband einer Kante: Mittellinie auf Fahrbahnbreite gepuffert. + + join_style mitre mit knappem Limit: Eine Straße mit einem Knick soll eine gerade Außenkante + behalten statt einer abgerundeten, aber bei einem sehr spitzen Knick würde eine unbegrenzte + Gehrung einen meterlangen Dorn erzeugen. + """ + line = trimmed_line(graph, k) + if line is None: + return None + return line.buffer(SECTION[graph.edges[k].klass].carriageway / 2, + cap_style="flat", join_style="mitre", mitre_limit=1.5) +``` + +- [ ] **Schritt 4: Test laufen lassen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_section.py -q` +Erwartet: 6 bestanden. + +- [ ] **Schritt 5: Commit** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run pytest -q +cd ../.. && git add tools/extract/leonida/section.py tools/extract/tests/test_section.py +git commit -m "feat(extract): Querschnitt je Klasse und Fahrbahnband" +``` + +Die vollständige Nachricht begründet, warum der Korridor aufgeteilt und nicht vergrößert wird, und trägt die Attribution nach der eigenen Systemweisung. + +--- + +### Task 6: Kreuzungsflächen mit Eckradius + +**Dateien:** +- Ändern: `tools/extract/leonida/section.py` +- Test: `tools/extract/tests/test_junction.py` + +**Schnittstellen:** +- Verbraucht: `setback`, `SECTION`, `ribbon` aus Task 5. +- Liefert: `FILLET = 6.0`, `junction_area(graph, node) -> Polygon | None`, `carriageway_area(graph) -> MultiPolygon` + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +`tools/extract/tests/test_junction.py`: + +```python +"""Kreuzungsflächen und die Fahrbahn als Ganzes.""" +from __future__ import annotations + +from shapely.geometry import Point + +from leonida.roads import Edge, Graph, Node +from leonida.section import FILLET, carriageway_area, junction_area + + +def kreuzung(klass: str = "hauptstrasse", arm: float = 60.0) -> Graph: + return Graph( + nodes=[Node((0.0, 0.0)), Node((arm, 0.0)), Node((-arm, 0.0)), Node((0.0, arm)), Node((0.0, -arm))], + edges=[Edge(0, i, [(0.0, 0.0), p], 18.0, klass) + for i, p in ((1, (arm, 0.0)), (2, (-arm, 0.0)), (3, (0.0, arm)), (4, (0.0, -arm)))]) + + +def test_kreuzung_deckt_den_knoten_ab(): + area = junction_area(kreuzung(), 0) + assert area.contains(Point(0, 0)) + + +def test_einspringende_ecke_ist_gerundet(): + area = junction_area(kreuzung(), 0) + # Ohne Rundung endete die Fläche an der Ecke (6, 6) scharf - halbe Fahrbahn in beide + # Richtungen. Mit 6 m Radius ist die einspringende Ecke ausgefüllt. + assert area.contains(Point(6.5, 6.5)) + assert FILLET == 6.0 + + +def test_zwei_parallele_strassen_verschmelzen_nicht(): + # Zwei Wohnstraßen mit 10 m Abstand der Mittellinien: Fahrbahn je 7 m, dazwischen 3 m Lücke. + # Ein global angewandtes Schließen mit 6 m würde sie zu einer Fläche verkleben. + graph = Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0)), Node((0.0, 10.0)), Node((100.0, 10.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse"), + Edge(2, 3, [(0.0, 10.0), (100.0, 10.0)], 12.0, "wohnstrasse")]) + area = carriageway_area(graph) + assert len(area.geoms) == 2, "getrennte Straßen bleiben getrennt" + + +def test_fahrbahn_enthaelt_baender_und_kreuzung(): + area = carriageway_area(kreuzung()) + assert area.contains(Point(0, 0)), "die Kreuzung selbst" + assert area.contains(Point(40, 0)), "ein Band" + assert not area.contains(Point(40, 40)), "die Ecke zwischen zwei Armen bleibt frei" +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag sehen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_junction.py -q` +Erwartet: `ImportError: cannot import name 'FILLET'` + +- [ ] **Schritt 3: Kreuzungsflächen schreiben** + +An `tools/extract/leonida/section.py` anhängen: + +```python +# Amerikanischer Eckradius, 20 ft. Er entscheidet über den Charakter der Kreuzung: größer wirkt +# großzügig und vorstädtisch, kleiner eng und städtisch. +FILLET = 6.0 + + +def junction_area(graph: Graph, node: int): + """Die Fläche einer Kreuzung: die Stummel aller Arme, mit gerundeten einspringenden Ecken. + + Gerundet wird durch Schließen (aufblasen, dann wieder schrumpfen) - und ausdrücklich nur + innerhalb einer Scheibe um den Knoten. Auf das ganze Netz angewandt verklebte dieselbe + Operation zwei parallele Straßen, deren Fahrbahnen weniger als 2 * FILLET auseinanderliegen. + """ + incident = graph.incident(node) + if not incident: + return None + reach = setback(graph, node) + arms = [] + for k in incident: + edge = graph.edges[k] + points = edge.points if edge.a == node else list(reversed(edge.points)) + line = LineString(points) + stub = substring(line, 0.0, min(reach + 1.0, line.length)) + arms.append(stub.buffer(SECTION[edge.klass].carriageway / 2, + cap_style="flat", join_style="mitre", mitre_limit=1.5)) + star = unary_union(arms) + near = Point(graph.nodes[node].point).buffer(reach + FILLET + 2.0) + rounded = star.buffer(FILLET, join_style="round").buffer(-FILLET, join_style="round") + return star.union(rounded.intersection(near)) + + +def carriageway_area(graph: Graph): + """Alle Bänder und alle Kreuzungsflächen als eine Geometrie.""" + parts = [band for k in range(len(graph.edges)) if (band := ribbon(graph, k)) is not None] + parts += [area for node in range(len(graph.nodes)) if graph.degree(node) >= 3 + and (area := junction_area(graph, node)) is not None] + return unary_union(parts) +``` + +- [ ] **Schritt 4: Test laufen lassen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_junction.py -q` +Erwartet: 4 bestanden. + +- [ ] **Schritt 5: Commit** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run pytest -q +cd ../.. && git add tools/extract/leonida/section.py tools/extract/tests/test_junction.py +git commit -m "feat(extract): Kreuzungsflächen mit amerikanischem Eckradius" +``` + +--- + +### Task 7: Die Mulde — Löcher, Bordsteinlinien, Gehweg + +**Dateien:** +- Erstellen: `tools/extract/leonida/street.py` +- Test: `tools/extract/tests/test_street.py` + +**Schnittstellen:** +- Verbraucht: `carriageway_area`, `SECTION`, `WALK_MAX`, `ribbon` aus `section.py`; `Graph` aus `roads.py`. +- Liefert: + - `DEPTH = 0.15` (nur als dokumentierter Wert; die Tiefe selbst setzt der Browser) + - `KERB_MIN = 2.0`, `LOT_MIN = 200.0` + - `@dataclass Street(carriageway, kerb, sidewalk, lots)` mit `carriageway: MultiPolygon`, `kerb: list[LineString]`, `sidewalk: MultiPolygon`, `lots: list[Polygon]` + - `build_street(graph, corridor, bridges) -> Street` + - `punch(polygons, carriageway) -> list[Polygon]` + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +`tools/extract/tests/test_street.py`: + +```python +"""Die Mulde: Löcher in der Bodenplatte, Bordsteinlinien, Gehwegflächen, Restflächen.""" +from __future__ import annotations + +from shapely.geometry import Point, box +from shapely.ops import unary_union + +from leonida.roads import Edge, Graph, Node +from leonida.section import SECTION +from leonida.street import LOT_MIN, build_street, punch + + +def strasse(klass: str = "wohnstrasse") -> Graph: + return Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], SECTION[klass].corridor, klass)]) + + +def korridor(klass: str = "wohnstrasse"): + halb = SECTION[klass].corridor / 2 + return box(0, -halb, 100, halb) + + +def test_gehweg_fuellt_genau_den_rest_des_korridors(): + street = build_street(strasse(), korridor(), bridges=[]) + section = SECTION["wohnstrasse"] + # 100 m lang, zwei Streifen von 2,5 m. Die Enden sind offen, deshalb genau die Fläche. + assert round(street.sidewalk.area) == round(100 * 2 * section.walk) + assert street.sidewalk.intersection(street.carriageway).area < 0.01 + + +def test_gasse_bekommt_weder_gehweg_noch_bordstein(): + street = build_street(strasse("gasse"), korridor("gasse"), bridges=[]) + assert street.sidewalk.area < 0.01 + assert street.kerb == [] + + +def test_bordstein_laeuft_entlang_der_fahrbahnkante(): + street = build_street(strasse(), korridor(), bridges=[]) + gesamt = sum(line.length for line in street.kerb) + # Zwei Längsseiten von 100 m; die kurzen Stirnseiten sind mit 7 m kaum der Rede wert. + assert 190 < gesamt < 220 + + +def test_bruecken_bekommen_keinen_bordstein(): + bridge = box(40, -10, 60, 10) + street = build_street(strasse(), korridor(), bridges=[bridge]) + for line in street.kerb: + assert not bridge.buffer(-0.5).intersects(line), "über der Brücke bleibt die Kante frei" + + +def test_loch_hat_genau_die_form_der_fahrbahn(): + platte = box(-50, -50, 150, 50) + street = build_street(strasse(), korridor(), bridges=[]) + rest = punch([platte], street.carriageway) + assert len(rest) == 1 + assert not rest[0].contains(Point(50, 0)), "die Fahrbahnmitte ist ausgeschnitten" + assert rest[0].contains(Point(50, 20)), "daneben bleibt die Platte" + assert abs(unary_union(rest).area + street.carriageway.area - platte.area) < 1.0 + + +def test_kleine_reststuecke_fallen_weg_grosse_bleiben(): + # Ein Korridor mit einer 30 x 30 m großen Ausbuchtung (ein Parkplatz) und einem Splitter. + weit = unary_union([korridor(), box(40, 6, 70, 36), box(10, 6, 12, 7)]) + street = build_street(strasse(), weit, bridges=[]) + assert all(lot.area >= LOT_MIN for lot in street.lots) + assert any(lot.area > 500 for lot in street.lots), "der Parkplatz bleibt" +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag sehen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_street.py -q` +Erwartet: `ModuleNotFoundError: No module named 'leonida.street'` + +- [ ] **Schritt 3: `street.py` schreiben** + +```python +"""Die Straße als Mulde: Loch in der Bodenplatte, Fahrbahn darunter, Bordstein dazwischen. + +Warum die Fahrbahn nach unten und nicht der Gehweg nach oben (Entwurf, Abschnitt "Die Mulde"): +Läge der Gehweg 15 cm hoch, hätte er überall dort eine Kante ins Nichts, wo kein Haus +anschließt - an jedem Park, jeder Baulücke, jedem Strandzugang. So bleibt die ganze Welt auf +y = 0, und der Bordstein ist genau das, was er in Wirklichkeit ist: die Wand zwischen Gehweg +und Fahrbahn. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from shapely.geometry import LineString, Polygon +from shapely.ops import unary_union + +from .roads import Graph +from .section import SECTION, WALK_MAX, carriageway_area, ribbon + +# Die Tiefe steht hier als dokumentierter Wert; gebaut wird sie im Browser (geometry.js). +DEPTH = 0.15 +# Kürzere Bordsteinstücke sind Rechenreste an Kreuzungsecken, keine Kante, die jemand sieht. +KERB_MIN = 2.0 +# Ab hier ist ein Reststück eine Fläche (Parkplatz, Vorplatz, Rampe) und keine Zacke. +LOT_MIN = 200.0 + + +@dataclass +class Street: + carriageway: object + kerb: list = field(default_factory=list) + sidewalk: object = None + lots: list = field(default_factory=list) + + +def build_street(graph: Graph, corridor, bridges) -> Street: + """Fahrbahn, Bordsteinlinien, Gehweg und Restflächen aus Graph und gemessenem Korridor.""" + carriage = carriageway_area(graph) + return Street( + carriageway=carriage, + kerb=_kerb_lines(graph, carriage, bridges), + sidewalk=_sidewalk(carriage, corridor), + lots=_lots(corridor, carriage), + ) + + +def _sidewalk(carriage, corridor): + """Der Streifen zwischen Fahrbahnkante und Korridorrand. + + Ein einziger Puffer für alle Klassen reicht: Weil am Korridor beschnitten wird, bleibt bei + 12 m Korridor und 7 m Fahrbahn genau der 2,5-m-Gehweg der Wohnstraße übrig, bei 18 m und + 12 m genau die 3,0 m der Hauptstraße. Die Gasse füllt ihren Korridor ganz aus und bekommt + dadurch von selbst keinen Gehweg. + """ + return carriage.buffer(WALK_MAX, join_style="round").intersection(corridor).difference(carriage) + + +def _kerb_lines(graph: Graph, carriage, bridges) -> list[LineString]: + """Die Fahrbahnkante, ohne die Stücke an Gassen und auf Brücken.""" + ohne = [ribbon(graph, k).buffer(0.2) for k, edge in enumerate(graph.edges) + if not SECTION[edge.klass].kerb and ribbon(graph, k) is not None] + ohne += [b.buffer(0.2) for b in bridges] + rand = carriage.boundary + if ohne: + rand = rand.difference(unary_union(ohne)) + teile = getattr(rand, "geoms", [rand]) + return [g for g in teile if g.geom_type == "LineString" and g.length >= KERB_MIN] + + +def _lots(corridor, carriage) -> list[Polygon]: + rest = corridor.difference(carriage) + return [g for g in getattr(rest, "geoms", [rest]) + if g.geom_type == "Polygon" and g.area >= LOT_MIN] + + +def punch(polygons, carriage) -> list[Polygon]: + """Die Fahrbahn aus Bodenplatte oder Belag herausschneiden.""" + out = [] + for poly in polygons: + rest = poly.difference(carriage) + out += [g for g in getattr(rest, "geoms", [rest]) + if g.geom_type == "Polygon" and g.area > 0.5] + return out +``` + +- [ ] **Schritt 4: Test laufen lassen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_street.py -q` +Erwartet: 6 bestanden. + +Bleibt `test_gehweg_fuellt_genau_den_rest_des_korridors` rot, liegt es am Puffer an den offenen Enden: `join_style="round"` rundet auch die Stirnseiten. Der Test lässt dafür Spielraum (`round()` auf ganze Quadratmeter); ist die Abweichung größer als ein Quadratmeter, stimmt etwas anderes nicht. + +- [ ] **Schritt 5: Commit** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run pytest -q +cd ../.. && git add tools/extract/leonida/street.py tools/extract/tests/test_street.py +git commit -m "feat(extract): Mulde, Bordsteinlinien, Gehweg und Restflächen" +``` + +--- + +### Task 8: Längsmarkierungen + +**Dateien:** +- Erstellen: `tools/extract/leonida/marking.py` +- Test: `tools/extract/tests/test_marking_long.py` + +**Schnittstellen:** +- Verbraucht: `SECTION`, `LANE`, `trimmed_line` aus `section.py`; `Graph` aus `roads.py`. +- Liefert: + - `WIDTH = 0.10`, `DASH = 3.0`, `GAP = 9.0` + - `KINDS = ("gelb_gestrichelt", "gelb_doppelt", "weiss_gestrichelt", "weiss_rand", "haltelinie", "zebra")` + - `longitudinal(graph) -> list[tuple[Polygon, str]]` + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +`tools/extract/tests/test_marking_long.py`: + +```python +"""Längsmarkierungen: Mittellinie, Spurtrenner, Randlinien.""" +from __future__ import annotations + +from collections import Counter + +from leonida.roads import Edge, Graph, Node +from leonida.marking import DASH, GAP, WIDTH, longitudinal +from leonida.section import SECTION + + +def strecke(klass: str, length: float = 120.0) -> Graph: + return Graph(nodes=[Node((0.0, 0.0)), Node((length, 0.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (length, 0.0)], SECTION[klass].corridor, klass)]) + + +def arten(graph: Graph) -> Counter: + return Counter(art for _, art in longitudinal(graph)) + + +def test_gasse_bekommt_keine_markierung(): + assert longitudinal(strecke("gasse")) == [] + + +def test_wohnstrasse_bekommt_eine_gestrichelte_gelbe_mitte(): + zahl = arten(strecke("wohnstrasse")) + assert set(zahl) == {"gelb_gestrichelt"} + # 120 m in 12-m-Takten: zehn Striche. + assert zahl["gelb_gestrichelt"] == 10 + assert DASH == 3.0 and GAP == 9.0 + + +def test_hauptstrasse_bekommt_doppelte_mitte_und_zwei_randlinien(): + zahl = arten(strecke("hauptstrasse")) + assert zahl["gelb_doppelt"] == 2, "zwei durchgezogene Linien nebeneinander" + assert zahl["weiss_rand"] == 2, "je eine an der Grenze zur Parkspur" + assert "weiss_gestrichelt" not in zahl, "zwei Spuren brauchen keinen Spurtrenner" + + +def test_boulevard_bekommt_zusaetzlich_spurtrenner(): + zahl = arten(strecke("boulevard")) + assert zahl["gelb_doppelt"] == 2 + assert zahl["weiss_rand"] == 2 + assert zahl["weiss_gestrichelt"] == 20, "je Richtung ein gestrichelter Trenner, 10 Striche" + + +def test_randlinie_liegt_an_der_grenze_zur_parkspur(): + section = SECTION["hauptstrasse"] + ränder = [poly for poly, art in longitudinal(strecke("hauptstrasse")) if art == "weiss_rand"] + abstände = sorted(round(abs(poly.centroid.y), 2) for poly in ränder) + erwartet = round(section.carriageway / 2 - section.parking, 2) + assert abstände == [erwartet, erwartet] + + +def test_linien_sind_zehn_zentimeter_breit(): + poly = longitudinal(strecke("wohnstrasse"))[0][0] + x0, y0, x1, y1 = poly.bounds + assert round(y1 - y0, 3) == WIDTH + assert round(x1 - x0, 3) == DASH + + +def test_markierung_endet_am_kreuzungsrand(): + graph = Graph(nodes=[Node((0.0, 0.0)), Node((120.0, 0.0)), Node((120.0, 60.0)), Node((180.0, 0.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (120.0, 0.0)], 12.0, "wohnstrasse"), + Edge(1, 2, [(120.0, 0.0), (120.0, 60.0)], 12.0, "wohnstrasse"), + Edge(1, 3, [(120.0, 0.0), (180.0, 0.0)], 12.0, "wohnstrasse")]) + ende = max(poly.bounds[2] for poly, _ in longitudinal(graph) if poly.bounds[2] < 120) + assert ende < 120 - SECTION["wohnstrasse"].carriageway / 2, "vor der Kreuzung ist Schluss" +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag sehen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_marking_long.py -q` +Erwartet: `ModuleNotFoundError: No module named 'leonida.marking'` + +- [ ] **Schritt 3: `marking.py` schreiben** + +```python +"""Amerikanische Fahrbahnmarkierung als Geometrie. + +Das Schema ist das Gegenteil des europäischen: Gelb trennt Gegenrichtungen und steht deshalb in +der Mitte, Weiß trennt gleichgerichtete Spuren und begrenzt die Fahrbahn. Die mitgelieferten +Texturen von modular_city_road_pack__game_ready zeigen das europäische Schema und sind hier +deshalb unbrauchbar (Entwurf, Abschnitt "Die beiden Kits"). + +Markierungen sind flache Vierecke, keine Textur: An Kreuzungen liegen Haltelinie und +Zebrastreifen quer zur Fahrtrichtung und ließen sich in einer längs laufenden Kachel gar nicht +unterbringen, und aus der Nähe bleibt Geometrie scharf, wo eine Textur verwischt. +""" +from __future__ import annotations + +from shapely.geometry import LineString +from shapely.ops import substring + +from .roads import Graph +from .section import LANE, SECTION, trimmed_line + +WIDTH = 0.10 # 4 in +DASH = 3.0 +GAP = 9.0 +KINDS = ("gelb_gestrichelt", "gelb_doppelt", "weiss_gestrichelt", "weiss_rand", + "haltelinie", "zebra") + + +def longitudinal(graph: Graph) -> list[tuple[object, str]]: + """Mittellinie, Spurtrenner und Randlinien je Kante, zwischen den Kreuzungsrändern.""" + out = [] + for k, edge in enumerate(graph.edges): + section = SECTION[edge.klass] + line = trimmed_line(graph, k) + if line is None or section.lanes < 2: + continue # die Gasse hat nur eine Fahrgasse und bleibt blank + if section.parking == 0.0: + # Wohnstraße: eine gestrichelte gelbe Mitte, Überholen erlaubt. + out += [(p, "gelb_gestrichelt") for p in _dashed(line, 0.0)] + else: + # Doppelt durchgezogen: zwei Linien mit 10 cm Abstand der Mitten. + out += [(_solid(line, off), "gelb_doppelt") for off in (-WIDTH, WIDTH)] + rand = section.carriageway / 2 - section.parking + out += [(_solid(line, off), "weiss_rand") for off in (-rand, rand)] + if section.lanes >= 4: + # Zwischen den gleichgerichteten Spuren, je Richtung einer. + out += [(p, "weiss_gestrichelt") for off in (-LANE, LANE) for p in _dashed(line, off)] + return out + + +def _offset(line: LineString, distance: float) -> LineString: + if distance == 0.0: + return line + # offset_curve versetzt nach links; negative Werte nach rechts. Bei geraden Straßen ist das + # exakt, bei Knicken folgt die Kurve dem Knick - genau das soll eine Markierung auch. + return line.offset_curve(distance) + + +def _solid(line: LineString, distance: float): + return _offset(line, distance).buffer(WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5) + + +def _dashed(line: LineString, distance: float) -> list: + """Striche im Takt DASH/GAP, beginnend am Anfang der Kante. + + Die Phase läuft bewusst nicht über Kreuzungen hinweg weiter: Jede Kante fängt neu an. Alles + andere hieße, eine Ordnung zu behaupten, die die Karte gar nicht hergibt. + """ + versetzt = _offset(line, distance) + out, at = [], 0.0 + while at < versetzt.length: + end = min(at + DASH, versetzt.length) + if end - at > 0.5: + out.append(substring(versetzt, at, end).buffer( + WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5)) + at = end + GAP + return out +``` + +- [ ] **Schritt 4: Test laufen lassen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_marking_long.py -q` +Erwartet: 7 bestanden. + +Häufigster Stolperstein: `offset_curve` liefert bei manchen Eingaben eine `MultiLineString`. Bei den geraden Kanten dieses Netzes kommt das nicht vor; falls doch, im Bericht melden statt still das längste Stück zu nehmen. + +- [ ] **Schritt 5: Commit** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run pytest -q +cd ../.. && git add tools/extract/leonida/marking.py tools/extract/tests/test_marking_long.py +git commit -m "feat(extract): amerikanische Längsmarkierungen als Geometrie" +``` + +--- + +### Task 9: Markierungen an Kreuzungen + +**Dateien:** +- Ändern: `tools/extract/leonida/marking.py` +- Test: `tools/extract/tests/test_marking_junction.py` + +**Schnittstellen:** +- Verbraucht: `setback`, `SECTION` aus `section.py`. +- Liefert: + - `CROSSWALK_DEPTH = 3.0`, `BAR = 0.6`, `STOP_DEPTH = 0.6`, `STOP_SETBACK = 4.2` + - `junction_marks(graph, node) -> list[tuple[Polygon, str]]` + - `markings(graph) -> list[tuple[Polygon, str]]` (längs plus alle Kreuzungen) + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +`tools/extract/tests/test_marking_junction.py`: + +```python +"""Haltelinien und Zebrastreifen.""" +from __future__ import annotations + +from collections import Counter + +from leonida.roads import Edge, Graph, Node +from leonida.marking import CROSSWALK_DEPTH, junction_marks, markings +from leonida.section import SECTION, setback + + +def kreuzung(klassen=("hauptstrasse",) * 4, arm: float = 80.0) -> Graph: + punkte = [(arm, 0.0), (-arm, 0.0), (0.0, arm), (0.0, -arm)] + return Graph(nodes=[Node((0.0, 0.0))] + [Node(p) for p in punkte], + edges=[Edge(0, i + 1, [(0.0, 0.0), p], SECTION[k].corridor, k) + for i, (p, k) in enumerate(zip(punkte, klassen))]) + + +def test_jeder_arm_bekommt_zebra_und_haltelinie(): + zahl = Counter(art for _, art in junction_marks(kreuzung(), 0)) + assert zahl["haltelinie"] == 4, "eine je Zufahrt" + assert zahl["zebra"] > 4 * 5, "je Arm mehrere Balken" + + +def test_zebra_liegt_am_kreuzungsrand(): + rand = setback(kreuzung(), 0) + zebra = [p for p, art in junction_marks(kreuzung(), 0) if art == "zebra"] + # Der Arm nach Osten: Balken zwischen Kreuzungsrand und Kreuzungsrand plus Tiefe. + ost = [p for p in zebra if p.centroid.x > 0 and abs(p.centroid.y) < 20] + assert ost, "der Ostarm hat Balken" + for poly in ost: + assert rand - 0.1 <= poly.bounds[0] <= rand + CROSSWALK_DEPTH + + +def test_haltelinie_liegt_hinter_dem_zebrastreifen(): + rand = setback(kreuzung(), 0) + halt = [p for p, art in junction_marks(kreuzung(), 0) if art == "haltelinie"] + ost = next(p for p in halt if p.centroid.x > 0 and abs(p.centroid.y) < 20) + assert ost.bounds[0] > rand + CROSSWALK_DEPTH, "erst Zebrastreifen, dann Haltelinie" + + +def test_haltelinie_deckt_nur_die_rechte_haelfte(): + halt = [p for p, art in junction_marks(kreuzung(), 0) if art == "haltelinie"] + ost = next(p for p in halt if p.centroid.x > 0 and abs(p.centroid.y) < 20) + # Zufahrt von Osten nach Westen: rechts der Fahrtrichtung liegt y < 0. + assert ost.centroid.y < 0 + assert round(ost.bounds[3] - ost.bounds[1], 1) == round(SECTION["hauptstrasse"].carriageway / 2, 1) + + +def test_arm_der_eine_gasse_ist_bekommt_keinen_zebrastreifen(): + graph = kreuzung(("hauptstrasse", "hauptstrasse", "hauptstrasse", "gasse")) + marks = junction_marks(graph, 0) + süd = [art for p, art in marks if p.centroid.y < -1 and abs(p.centroid.x) < 20] + assert süd == [], "der Gassenarm bleibt blank" + + +def test_kreuzung_aus_lauter_gassen_bekommt_nichts(): + assert junction_marks(kreuzung(("gasse",) * 4), 0) == [] + + +def test_markings_sammelt_laengs_und_kreuzung(): + arten = {art for _, art in markings(kreuzung())} + assert "gelb_doppelt" in arten and "zebra" in arten and "haltelinie" in arten +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag sehen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_marking_junction.py -q` +Erwartet: `ImportError: cannot import name 'CROSSWALK_DEPTH'` + +- [ ] **Schritt 3: Kreuzungsmarkierungen schreiben** + +An `tools/extract/leonida/marking.py` anhängen (Import oben ergänzen: `from shapely.geometry import Polygon`, `from .section import setback`): + +```python +CROSSWALK_DEPTH = 3.0 # Tiefe des Zebrastreifens in Fahrtrichtung, 10 ft +BAR = 0.6 # Balkenbreite und Lücke, 2 ft - das Miami-Balkenmuster +STOP_DEPTH = 0.6 +STOP_SETBACK = 4.2 # Kreuzungsrand + Zebrastreifen (3,0) + Sicherheitsabstand (1,2) + + +def markings(graph: Graph) -> list[tuple[object, str]]: + out = longitudinal(graph) + for node in range(len(graph.nodes)): + if graph.degree(node) >= 3: + out += junction_marks(graph, node) + return out + + +def junction_marks(graph: Graph, node: int) -> list[tuple[object, str]]: + """Zebrastreifen und Haltelinie je Zufahrt. + + Ein Zebrastreifen entsteht nur, wenn die Kreuzung mindestens zwei Arme ab Wohnstraße hat - + zwischen zwei Gassen quert niemand. Arme, die selbst Gassen sind, bleiben in jedem Fall + blank. + """ + incident = graph.incident(node) + echte = [k for k in incident if SECTION[graph.edges[k].klass].kerb] + if len(echte) < 2: + return [] + reach = setback(graph, node) + origin = graph.nodes[node].point + out = [] + for k in echte: + edge = graph.edges[k] + points = edge.points if edge.a == node else list(reversed(edge.points)) + along = _unit(origin, points[1] if len(points) > 1 else points[0]) + across = (-along[1], along[0]) + halb = SECTION[edge.klass].carriageway / 2 + # Balken längs der Fahrtrichtung, quer über die Fahrbahn verteilt. + offset = -halb + BAR / 2 + while offset <= halb - BAR / 2 + 1e-9: + mitte = (origin[0] + across[0] * offset, origin[1] + across[1] * offset) + out.append((_rect(mitte, along, across, reach, CROSSWALK_DEPTH, BAR), "zebra")) + offset += 2 * BAR + # Haltelinie quer, nur über die rechte Hälfte aus Sicht der Zufahrt. Die Zufahrt fährt + # zum Knoten hin, also gegen "along"; rechts davon liegt -across. + mitte = (origin[0] - across[0] * halb / 2, origin[1] - across[1] * halb / 2) + out.append((_rect(mitte, along, across, reach + STOP_SETBACK, STOP_DEPTH, halb), "haltelinie")) + return out + + +def _unit(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]: + dx, dy = b[0] - a[0], b[1] - a[1] + length = (dx * dx + dy * dy) ** 0.5 or 1.0 + return (dx / length, dy / length) + + +def _rect(mitte, along, across, start: float, length: float, width: float) -> Polygon: + """Rechteck im Fahrbahnsystem: start..start+length in Fahrtrichtung, width quer um mitte.""" + punkte = [] + for dl, dq in ((start, -width / 2), (start + length, -width / 2), + (start + length, width / 2), (start, width / 2)): + punkte.append((mitte[0] + along[0] * dl + across[0] * dq, + mitte[1] + along[1] * dl + across[1] * dq)) + return Polygon(punkte) +``` + +- [ ] **Schritt 4: Test laufen lassen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_marking_junction.py -q` +Erwartet: 7 bestanden. + +- [ ] **Schritt 5: Commit** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run pytest -q +cd ../.. && git add tools/extract/leonida/marking.py tools/extract/tests/test_marking_junction.py +git commit -m "feat(extract): Haltelinien und Zebrastreifen an Kreuzungen" +``` + +--- + +### Task 10: Chunk-Ausgabe + +**Dateien:** +- Ändern: `tools/extract/leonida/emit.py` +- Test: `tools/extract/tests/test_emit.py` (erweitern) + +**Schnittstellen:** +- Verbraucht: `Street` aus `street.py`, `markings()` aus `marking.py`, `Graph` aus `roads.py`. +- Liefert: `chunk_payload(i, j, *, land, surfaces, carriageway, curb, sidewalk, markings, lots, bridges, edges, buildings, palms, paths=())`. + `carriageway`: `list[tuple[Polygon, str]]` (Fläche, Klasse) · `curb`: `list[LineString]` · `sidewalk`: `list[Polygon]` · + `markings`: `list[tuple[Polygon, str]]` · `lots`: `list[Polygon]` · `bridges`: `list[Polygon]` · + `edges`: `list[tuple[LineString, str, float, float]]` (Linie, Klasse, Korridorbreite, Fahrbahnbreite). + `roads` und `centerlines` entfallen. + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +An `tools/extract/tests/test_emit.py` anhängen: + +```python +def test_neue_schichten_stehen_im_chunk(): + from shapely.geometry import LineString, box + payload = chunk_payload( + 0, 0, + land=[box(0, 2400, 100, 2500)], surfaces=[], + carriageway=[(box(10, 2400, 20, 2500), "wohnstrasse")], + curb=[LineString([(10, 2400), (10, 2500)])], + sidewalk=[box(20, 2400, 23, 2500)], + markings=[(box(14, 2440, 16, 2443), "gelb_gestrichelt")], + lots=[box(40, 2400, 60, 2420)], + bridges=[], edges=[(LineString([(15, 2400), (15, 2500)]), "wohnstrasse", 12.0, 7.0)], + buildings=[], palms=[]) + assert payload["carriageway"][0]["klasse"] == "wohnstrasse" + assert payload["markings"][0]["art"] == "gelb_gestrichelt" + assert payload["edges"][0]["klasse"] == "wohnstrasse" + assert payload["edges"][0]["fahrbahn"] == 7.0 + assert len(payload["curb"][0]) == 2, "der Bordstein ist ein Linienzug, keine Fläche" + assert "roads" not in payload and "centerlines" not in payload + + +def test_neue_schichten_sind_lauf_stabil(): + """Zwei Aufrufe mit vertauschter Eingabe liefern dieselbe Ausgabe (Fixrunde 3 aus Teil 1).""" + from shapely.geometry import LineString, box + a, b = box(10, 2400, 20, 2450), box(30, 2400, 40, 2450) + l1, l2 = LineString([(0, 2400), (0, 2450)]), LineString([(5, 2400), (5, 2450)]) + eins = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[(a, "gasse"), (b, "gasse")], + curb=[l1, l2], sidewalk=[a], markings=[(a, "zebra"), (b, "zebra")], + lots=[a, b], bridges=[], edges=[], buildings=[], palms=[]) + zwei = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[(b, "gasse"), (a, "gasse")], + curb=[l2, l1], sidewalk=[a], markings=[(b, "zebra"), (a, "zebra")], + lots=[b, a], bridges=[], edges=[], buildings=[], palms=[]) + assert eins == zwei +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag sehen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_emit.py -q` +Erwartet: `TypeError: chunk_payload() got an unexpected keyword argument 'carriageway'` + +- [ ] **Schritt 3: `chunk_payload` umbauen** + +In `tools/extract/leonida/emit.py` die Signatur und den Rumpf ersetzen. `_rings` und `_sorted_surfaces` bleiben unverändert; neu kommt `_line` dazu: + +```python +def _line(line, i: int, j: int) -> list[list[float]]: + """Ein Linienzug in Chunk-Koordinaten, richtungsunabhängig festgelegt. + + normalize() dreht eine Linie in eine feste Laufrichtung. Das ist hier anders als bei den + früheren centerlines erwünscht: Die Laternen wählen ihre Seite seit Teil 2 aus der + Kantenrichtung *und* prüfen gegen die Gehwegfläche, hängen also nicht mehr an der + Listenposition (siehe props.js). Damit darf die Ausgabe sortiert und normalisiert werden, + und ein Lauf erzeugt wieder Datei für Datei dasselbe. + """ + ox = SLICE["x0"] + i * CHUNK_SIZE + oy = SLICE["y1"] - j * CHUNK_SIZE + return [[round(x - ox, 2), round(-y + oy, 2)] for x, y in shapely.normalize(line).coords] + + +def chunk_payload(i, j, *, land, surfaces, carriageway, curb, sidewalk, markings, lots, + bridges, edges, buildings, palms, paths=()) -> dict: + """Ein Chunk als Ausgabestruktur, deterministisch sortiert. + + Sortiert wird nach der gerundeten Ausgabestruktur selbst, nicht nach Flaeche, Schwerpunkt oder + Bounding-Box: nur sie ist total geordnet und ohne Sonderfall, denn zwei Eintraege mit gleichem + Schluessel sind dann bytegleich und ihre Reihenfolge wieder egal. + """ + ox = SLICE["x0"] + i * CHUNK_SIZE + oy = SLICE["y1"] - j * CHUNK_SIZE + return { + "chunk": [i, j], + "origin": [ox, -oy], + "land": sorted(_rings(p, i, j) for p in land), + "surfaces": _sorted_surfaces([{"poly": _rings(p, i, j), "kind": k} for p, k in surfaces]), + "carriageway": sorted(({"poly": _rings(p, i, j), "klasse": k} for p, k in carriageway), + key=lambda c: (c["poly"], c["klasse"])), + "curb": sorted(_line(l, i, j) for l in curb), + "sidewalk": sorted({"poly": _rings(p, i, j)} for p in sidewalk), + "markings": sorted(({"poly": _rings(p, i, j), "art": a} for p, a in markings), + key=lambda m: (m["poly"], m["art"])), + "lots": sorted(({"poly": _rings(p, i, j)} for p in lots), key=lambda l: l["poly"]), + "bridges": sorted(({"poly": _rings(p, i, j)} for p in bridges), key=lambda b: b["poly"]), + "edges": sorted(({"line": _line(l, i, j), "klasse": k, "breite": w, "fahrbahn": f} + for l, k, w, f in edges), + key=lambda e: (e["line"], e["klasse"])), + "buildings": sorted(({"poly": _rings(p, i, j), "height": h["height"], + "source": h["source"], "speculative": s} for p, h, s in buildings), + key=lambda b: (b["poly"], b["height"], b["source"], b["speculative"])), + "palms": sorted([round(x - ox, 2), round(-y + oy, 2)] for x, y in palms), + "paths": sorted(({"poly": _rings(p, i, j)} for p in paths), key=lambda p: p["poly"]), + } +``` + +`sorted({"poly": ...} for ...)` ohne `key` schlägt bei Wörterbüchern fehl — bei `sidewalk` deshalb `key=lambda s: s["poly"]` ergänzen, wie bei `lots`. + +`write_debug_png` liest `payload["roads"]`; dort auf `payload["carriageway"]` umstellen, Farbe unverändert. + +- [ ] **Schritt 4: Test laufen lassen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_emit.py -q` +Erwartet: alle bestehen, inklusive der bisherigen Tests der Datei. + +- [ ] **Schritt 5: Commit** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run pytest -q +``` +Erwartet: rot in `test_park_apply.py` und in allem, was `chunk_payload` mit `roads=` aufruft — das repariert Task 11 und 12. **Trotzdem committen**, damit die Schnittstelle steht, und die roten Tests im Bericht nennen. + +```bash +cd ../.. && git add tools/extract/leonida/emit.py tools/extract/tests/test_emit.py +git commit -m "feat(extract): Chunk-Format für Fahrbahn, Bordstein, Gehweg und Markierung" +``` + +--- + +### Task 11: Flamingo Park an den Graphen hängen + +**Dateien:** +- Ändern: `tools/extract/leonida/roads.py` (Kanten schneiden, Knoten anhängen) +- Ändern: `tools/extract/leonida/park.py` (`World`, `apply_street`, `apply_park`, `check_corners`) +- Test: `tools/extract/tests/test_roads_cut.py` (neu), `tools/extract/tests/test_park_apply.py` (anpassen) + +**Schnittstellen:** +- Liefert in `roads.py`: `MIN_EDGE = 5.0`, `cut_edges(graph, cut) -> Graph`, `attach(graph, point, radius=JUNCTION_RADIUS) -> int` +- Ändert `World`: `centerlines: list[LineString]` wird zu `graph: Graph`. `roads` behält seine Bedeutung: **der gemessene Korridor**. + +- [ ] **Schritt 1: Den fehlschlagenden Test für das Schneiden schreiben** + +`tools/extract/tests/test_roads_cut.py`: + +```python +"""Kanten an einer Sperrfläche schneiden und neue Kanten anhängen.""" +from __future__ import annotations + +from shapely.geometry import LineString, box + +from leonida.roads import Edge, Graph, MIN_EDGE, Node, attach, cut_edges + + +def strecke() -> Graph: + return 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")]) + + +def test_schnitt_in_der_mitte_gibt_zwei_kanten(): + out = cut_edges(strecke(), box(40, -5, 60, 5)) + assert len(out.edges) == 2 + längen = sorted(round(LineString(e.points).length) for e in out.edges) + assert längen == [40, 40] + + +def test_reste_unter_der_mindestlaenge_fallen_weg(): + # Schnitt bei 96..104: übrig bleiben 96 m und nichts. + out = cut_edges(strecke(), box(96, -5, 104, 5)) + assert len(out.edges) == 1 + assert round(LineString(out.edges[0].points).length) == 96 + assert MIN_EDGE == 5.0 + + +def test_ganz_verdeckte_kante_verschwindet(): + assert cut_edges(strecke(), box(-10, -10, 110, 10)).edges == [] + + +def test_unberuehrte_kante_bleibt_wie_sie_ist(): + out = cut_edges(strecke(), box(200, -5, 220, 5)) + assert out.edges[0].points == [(0.0, 0.0), (100.0, 0.0)] + assert out.edges[0].klass == "wohnstrasse" + + +def test_attach_findet_einen_nahen_knoten(): + graph = strecke() + assert attach(graph, (100.0, 3.0)) == 1, "3 m entfernt: derselbe Knoten" + assert len(graph.nodes) == 2 + + +def test_attach_legt_sonst_einen_neuen_knoten_an(): + graph = strecke() + neu = attach(graph, (100.0, 40.0)) + assert neu == 2 + assert graph.nodes[neu].point == (100.0, 40.0) +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag sehen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_roads_cut.py -q` +Erwartet: `ImportError: cannot import name 'MIN_EDGE'` + +- [ ] **Schritt 3: Schneiden und Anhängen schreiben** + +An `tools/extract/leonida/roads.py` anhängen: + +```python +# Kürzere Reste nach einem Schnitt sind Stummel, die nur eine Kreuzung vortäuschen würden. +MIN_EDGE = 5.0 + + +def cut_edges(graph: Graph, cut) -> Graph: + """Alles, was in der Fläche liegt, aus den Kanten herausschneiden. + + Gegenstück zum Schneiden der Straßenflächen: Wo Flamingo Park die alte Gasse entfernt oder + eine Sperrfläche eine Zufahrt kappt, darf auch keine Fahrbahn mehr gebaut werden - sonst + stünde die Zufahrt weiterhin im Park, nur jetzt mit Bordstein und Markierung. + """ + out = Graph(nodes=list(graph.nodes), edges=[]) + for edge in graph.edges: + line = LineString(edge.points) + if not line.intersects(cut): + out.edges.append(edge) + continue + rest = line.difference(cut) + for piece in getattr(rest, "geoms", [rest]): + if piece.geom_type != "LineString" or piece.length < MIN_EDGE: + continue + points = [(round(x, 2), round(y, 2)) for x, y in piece.coords] + out.edges.append(Edge(a=attach(out, points[0]), b=attach(out, points[-1]), + points=points, width=edge.width, klass=edge.klass)) + return _renumber(out) + + +def attach(graph: Graph, point: tuple[float, float], radius: float = JUNCTION_RADIUS) -> int: + """Knoten-ID an dieser Stelle: der nächste innerhalb von radius, sonst ein neuer. + + Damit hängt sich eine von Hand ergänzte Straße (die Jefferson Avenue) an die Querstraße an, + statt als freies Ende danebenzuliegen und ohne Kreuzung zu enden. + """ + best, best_distance = None, radius + for k, node in enumerate(graph.nodes): + distance = ((node.point[0] - point[0]) ** 2 + (node.point[1] - point[1]) ** 2) ** 0.5 + if distance <= best_distance: + best, best_distance = k, distance + if best is not None: + return best + graph.nodes.append(Node((float(point[0]), float(point[1])))) + return len(graph.nodes) - 1 +``` + +- [ ] **Schritt 4: Test laufen lassen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_roads_cut.py -q` +Erwartet: 6 bestanden. + +- [ ] **Schritt 5: `park.py` umstellen** + +Drei Stellen, jede einzeln: + +1. **`World`** (Zeile 467): `centerlines: list[LineString]` durch `graph: Graph` ersetzen, Import ergänzen. + +2. **`apply_street`** (Zeile 482): Die Zeilen 504–505, die `centerlines` bilden, ersetzen durch: + +```python + # Die Gasse verschwindet auch aus dem Graphen, und die Jefferson Avenue kommt als Kante + # dazu - mit attach() an die Querstraße gehängt, damit dort eine echte Kreuzung entsteht + # statt eines freien Endes. Die Klasse folgt der Breite aus der Parkdatei wie bei jeder + # anderen Kante auch. + graph = cut_edges(world.graph, cut) + klass = min(CLASS_WIDTH, key=lambda name: abs(CLASS_WIDTH[name] - street.width)) + graph.edges.append(Edge(a=attach(graph, street.start), b=attach(graph, street.end), + points=[tuple(street.start), tuple(street.end)], + width=street.width, klass=klass)) +``` + +Im `replace(...)` am Ende von `apply_street` `centerlines=centerlines` durch `graph=graph` ersetzen. + +3. **`apply_park`** (Zeile 546): Dort wird `world.centerlines` an den Sperrflächen geschnitten. Denselben Schnitt jetzt auf dem Graphen: `graph = cut_edges(world.graph, sperrflaeche)` für jede Sperrfläche, und `graph=graph` in `replace(...)`. Die Warnung, die dabei entsteht, bleibt wörtlich, wie sie ist — `test_park_apply.py` hält sie fest. + +4. **`check_corners`** (Zeile 519) bleibt unverändert und misst weiter gegen `world.roads`. Über den Docstring einen Absatz setzen: + +```python + Seit Teil 2 ist world.roads ausdrücklich der gemessene *Korridor*, nicht die gebaute + Fahrbahn: Die Fahrbahn einer 12-m-Straße ist nur 7 m breit, und gegen sie gemessen lägen + die Parkecken plötzlich 2,5 m daneben. Diese Prüfung gehört an den Korridor, weil sie + fragt, ob sich die Extraktion verschoben hat — nicht, wie breit wir bauen. +``` + +- [ ] **Schritt 6: Den Park-Integrationstest anpassen** + +In `tools/extract/tests/test_park_apply.py` alle `World(...)`-Aufbauten von `centerlines=[...]` auf `graph=Graph(...)` umstellen und die Zusicherungen über `world.centerlines` auf `world.graph.edges` umschreiben. Die Meldezeile und die eine bekannte Warnung bleiben wörtlich. Zusätzlich diesen Test aufnehmen: + +```python +def test_eckenpruefung_misst_den_korridor_nicht_die_fahrbahn(): + """Regressionswache: world.roads ist der Korridor, und die Prüfung gehört an ihn. + + Eine 12-m-Straße bekommt 7 m Fahrbahn. Gegen die Fahrbahn gemessen lägen die Ecken des + Parks 2,5 m weiter draußen als erlaubt, und jeder Lauf bräche ab. + """ + from leonida.section import SECTION + assert SECTION["wohnstrasse"].corridor - SECTION["wohnstrasse"].carriageway == 5.0 + assert (SECTION["wohnstrasse"].corridor - SECTION["wohnstrasse"].carriageway) / 2 > 2.5 +``` + +- [ ] **Schritt 7: Tests laufen lassen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_park_apply.py tests/test_roads_cut.py -q` +Erwartet: alle bestehen. + +- [ ] **Schritt 8: Commit** + +```bash +git add tools/extract/leonida/roads.py tools/extract/leonida/park.py tools/extract/tests/test_roads_cut.py tools/extract/tests/test_park_apply.py +git commit -m "feat(extract): Flamingo Park hängt seine Straße an den Graphen" +``` + +--- + +### Task 12: Die Pipeline verdrahten + +**Dateien:** +- Ändern: `tools/extract/run.py` +- 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. + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +`tools/extract/tests/test_pipeline_roads.py`: + +```python +"""Die Reihenfolge, in der die Straßen entstehen — ohne den vollen Lauf.""" +from __future__ import annotations + +from shapely.geometry import box +from shapely.ops import unary_union + +from leonida.roads import Edge, Graph, Node +from leonida.street import build_street, punch + + +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 + + +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=[]) + 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" +``` + +- [ ] **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. + +- [ ] **Schritt 3: `run.py` umbauen** + +Importe ergänzen: + +```python +from scipy import ndimage as ndi + +from leonida.marking import markings +from leonida.roads import road_graph +from leonida.street import build_street, punch +``` + +Nach `lines = centerlines(labels)` (Zeile 41) einsetzen und `lines` dort stehen lassen, bis Schritt 4 es ersetzt: + +```python + # Der Graph ersetzt die nachgezeichneten Straßenränder (Entwurf Teil 2). Die + # Distanztransformation misst an jedem Straßenpixel den Abstand zum nächsten Nicht-Straßen- + # pixel; doppelt genommen ist das die Korridorbreite an dieser Stelle. + distance = ndi.distance_transform_edt(road_mask(labels)) + graph = road_graph(labels, distance) + print(f"Straßennetz: {len(graph.nodes)} Knoten, {len(graph.edges)} Kanten") +``` + +`road_mask` aus `leonida.vectors` importieren. + +Den `World`-Aufbau (Zeile 78) auf `graph=graph` statt `centerlines=lines` umstellen. `palm_points(...)` bekommt statt `lines` die Kantenlinien: + +```python + edge_lines = [LineString(e.points) for e in graph.edges] + palms = palm_points(sand, parks, edge_lines, [p for p, _ in buildings], road_shapes, land, SEED) +``` + +`LineString` aus `shapely.geometry` importieren. `lines = centerlines(labels)` und der Import von `centerlines` entfallen damit. + +Nach `world, messages = apply_flamingo_park(...)` die Straßen bauen: + +```python + corridor = unary_union([p for p, bridge in world.roads if not bridge]) + 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)]) + print(f"Straßen: {len(street.kerb)} Bordsteinstücke, {len(street.lots)} Restflächen, " + f"{len(marks)} Markierungen") +``` + +`replace` aus `dataclasses` und `unary_union` aus `shapely.ops` importieren. + +Die Chunk-Schleife auf die neuen Schlüssel umstellen: + +```python + payloads.append(chunk_payload( + i, j, + 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) + 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)], + 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) + for e in world.graph.edges + for part in _clip_lines(LineString(e.points), chunk_box_of(i, j))], + buildings=[(p, h, s) for (p, h, s) in world.buildings + if p.intersects(chunk_box_of(i, j))], + palms=[pt for pt in world.palms if in_chunk(pt, i, j)], + paths=[part for p in world.paths for part in split_by_chunk_all([p], i, j)], + )) +``` + +Dazu drei kleine Helfer oben in `run.py`: + +```python +def _pieces(geometry) -> list: + return [g for g in getattr(geometry, "geoms", [geometry]) if not g.is_empty] + + +def _clip_lines(line, cell) -> list: + """Der Anteil einer Linie in diesem Chunk, in Stücken.""" + part = line.intersection(cell) + 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. + + 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. + """ + from leonida.section import SECTION + 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))] + klass = max(treffer, key=lambda k: SECTION[k].carriageway) if treffer else "wohnstrasse" + out.append((poly, klass)) + return out +``` + +`SECTION` aus `leonida.section` importieren. + +Die Namensnennung im Manifest um den Bordstein erweitern: + +```python + "attribution": "Kartenkacheln: GTA VI Map (CC BY 4.0) — https://map.gtadb.org, YANIS v15. " + "Wahrzeichen: rolux/gtamaplib-vc (Unlicense). " + "Flamingo Park: © OpenStreetMap-Mitwirkende (ODbL). " + "Bordstein und Gehweg: Just8 (Sketchfab, CC BY 4.0).", +``` + +- [ ] **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`). + +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. + +- [ ] **Schritt 5: Ergebnis nachmessen** + +```bash +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 + +ROOT = Path("../../public/data/vice-a") +zahl = Counter() +flaeche = Counter() +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, [])) + 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")) + 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)) +EOF +``` + +Erwartet, als Größenordnung aus dem Entwurf: Fahrbahn rund 1,0–1,2 km², Gehweg 0,2–0,4 km², Restflächen rund 0,18 km², alle vier Klassen besetzt. Die Zahlen in den Bericht, auch wenn sie abweichen. + +- [ ] **Schritt 6: Commit** + +```bash +cd ../.. && git status --short +git add tools/extract/run.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. + +--- + +### Task 13: Browser — Geometrie der neuen Schichten + +**Dateien:** +- Ändern: `src/world/geometry.js` +- Test: `tests/geometry.test.js` (erweitern) + +**Schnittstellen:** +- Verbraucht: das Chunk-Format aus Task 10. +- Liefert: + - `CARRIAGEWAY_DEPTH = 0.15`, `KERB_TOP = 0.20`, `MARKING_OFFSET = 0.01` + - `MARKING_COLORS = { gelb_gestrichelt, gelb_doppelt, weiss_gestrichelt, weiss_rand, haltelinie, zebra }` + - `kerbGeometry(lines)` und `markingGeometry(markings)` + - `buildChunkGeometry(chunk)` mit den Teilen `carriageway`, `curb`, `sidewalk`, `markings`, `lots`, `bridges` statt `roads` + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +An `tests/geometry.test.js` anhängen: + +```js +describe("Straßen aus Teil 2", () => { + const chunk = (extra) => ({ + chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], buildings: [], palms: [], + carriageway: [], curb: [], sidewalk: [], markings: [], lots: [], bridges: [], edges: [], + ...extra, + }); + + it("legt die Fahrbahn 15 cm unter den Boden", () => { + const geo = buildChunkGeometry(chunk({ + carriageway: [{ poly: [[[0, 0], [10, 0], [10, -10], [0, -10]]], klasse: "wohnstrasse" }], + })).carriageway; + const y = geo.getAttribute("position").array.filter((_, i) => i % 3 === 1); + expect([...new Set(y)]).toEqual([-CARRIAGEWAY_DEPTH]); + }); + + it("baut aus einer Bordsteinlinie eine Wand und eine Oberkante", () => { + const geo = kerbGeometry([[[0, 0], [10, 0]]]); + const pos = geo.getAttribute("position").array; + const y = [...new Set(pos.filter((_, i) => i % 3 === 1))].sort((a, b) => a - b); + expect(y).toEqual([-CARRIAGEWAY_DEPTH, 0]); + // Wand (4 Punkte) und Oberkante (4 Punkte) je Abschnitt. + expect(geo.getAttribute("position").count).toBe(8); + }); + + it("legt die Oberkante des Bordsteins 20 cm zur Seite", () => { + const geo = kerbGeometry([[[0, 0], [10, 0]]]); + const pos = geo.getAttribute("position").array; + const z = [...new Set(pos.filter((_, i) => i % 3 === 2))].sort((a, b) => a - b); + expect(z.some((v) => Math.abs(Math.abs(v) - KERB_TOP) < 1e-6)).toBe(true); + }); + + it("zeigt die Oberkante nach oben", () => { + const geo = kerbGeometry([[[0, 0], [10, 0]]]); + const normals = geo.getAttribute("normal").array; + const oben = []; + for (let i = 0; i < geo.getAttribute("position").count; i++) { + if (geo.getAttribute("position").array[i * 3 + 1] === 0) oben.push(normals[i * 3 + 1]); + } + expect(Math.max(...oben)).toBeGreaterThan(0); + }); + + it("gibt Markierungen ihre Farbe als Vertexfarbe", () => { + const geo = markingGeometry([ + { poly: [[[0, 0], [3, 0], [3, -0.1], [0, -0.1]]], art: "gelb_doppelt" }, + { poly: [[[5, 0], [8, 0], [8, -0.6], [5, -0.6]]], art: "zebra" }, + ]); + const color = geo.getAttribute("color").array; + expect(color.slice(0, 3)).toEqual(new Float32Array(MARKING_COLORS.gelb_doppelt)); + const y = geo.getAttribute("position").array.filter((_, i) => i % 3 === 1); + expect([...new Set(y)]).toEqual([-CARRIAGEWAY_DEPTH + MARKING_OFFSET]); + }); + + it("lädt alte Chunks ohne die neuen Schlüssel weiter", () => { + const alt = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], + buildings: [], palms: [] }; + expect(() => buildChunkGeometry(alt)).not.toThrow(); + expect(buildChunkGeometry(alt).carriageway).toBeNull(); + }); +}); +``` + +Der Import oben in der Datei bekommt `CARRIAGEWAY_DEPTH`, `KERB_TOP`, `MARKING_OFFSET`, `MARKING_COLORS`, `kerbGeometry`, `markingGeometry` dazu. + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag sehen** + +Ausführen: `npm test -- tests/geometry.test.js` +Erwartet: `kerbGeometry is not a function` + +- [ ] **Schritt 3: `geometry.js` erweitern** + +Oben bei den Konstanten: + +```js +// Die Fahrbahn liegt in einer Mulde, statt dass der Gehweg auf einem Sockel läge (Entwurf +// Teil 2, "Die Mulde"): Ein erhöhter Gehweg hätte überall dort eine Kante ins Nichts, wo kein +// Haus anschließt — an jedem Park, jeder Baulücke, jedem Strandzugang. So bleibt die Welt auf +// y = 0, und der Bordstein ist die Wand zwischen Gehweg und Fahrbahn. +export const CARRIAGEWAY_DEPTH = 0.15; +export const KERB_TOP = 0.20; // sichtbare Breite der Bordstein-Oberkante +export const MARKING_OFFSET = 0.01; // Markierung über der Fahrbahn +export const MARKING_COLORS = { + gelb_gestrichelt: [0.94, 0.75, 0.13], + gelb_doppelt: [0.94, 0.75, 0.13], + weiss_gestrichelt: [0.94, 0.94, 0.94], + weiss_rand: [0.94, 0.94, 0.94], + haltelinie: [0.94, 0.94, 0.94], + zebra: [0.94, 0.94, 0.94], +}; +``` + +Dann die beiden neuen Bauer: + +```js +/** Bordstein: senkrechte Wand von der Fahrbahn auf Bodenhöhe, dazu eine schmale Oberkante. + * + * Die Oberkante ist kein Schmuck: Ohne sie endet der Bordstein von oben gesehen als + * Messerschneide zwischen Gehwegbelag und Wand, und aus der Ferne verschwindet er ganz. + * Die Wand bekommt in materials.js DoubleSide — welche Seite außen liegt, hängt am Umlaufsinn + * des Rings, aus dem die Linie stammt, und ein 15 cm hohes Band ist die Rechenarbeit nicht + * wert, das je Linie zu bestimmen. */ +export function kerbGeometry(lines, { depth = CARRIAGEWAY_DEPTH, top = KERB_TOP } = {}) { + const pos = [], idx = [], uv = []; + for (const line of lines) { + for (let i = 0; i + 1 < line.length; i++) { + const [x0, z0] = line[i]; + const [x1, z1] = line[i + 1]; + const dx = x1 - x0, dz = z1 - z0; + const len = Math.hypot(dx, dz); + if (len < 1e-6) continue; + const nx = (-dz / len) * top, nz = (dx / len) * top; + const w = pos.length / 3; + pos.push(x0, -depth, z0, x1, -depth, z1, x1, 0, z1, x0, 0, z0); + uv.push(0, 0, len, 0, len, depth, 0, depth); + idx.push(w, w + 1, w + 2, w, w + 2, w + 3); + const t = pos.length / 3; + pos.push(x0, 0, z0, x1, 0, z1, x1 + nx, 0, z1 + nz, x0 + nx, 0, z0 + nz); + uv.push(0, 0, len, 0, len, top, 0, top); + // Gegen den Uhrzeigersinn in der xz-Ebene, damit die Normale nach oben zeigt. + idx.push(t, t + 2, t + 1, t, t + 3, t + 2); + } + } + return pos.length ? toGeometry(pos, idx, uv) : null; +} + +/** Markierungen als flache Vierecke in einem Netz, Farbe je Art als Vertexfarbe. */ +export function markingGeometry(markings) { + const pos = [], idx = [], uv = [], color = []; + for (const mark of markings) { + const tint = MARKING_COLORS[mark.art] ?? MARKING_COLORS.weiss_rand; + const ring = clockwise(openRing(mark.poly[0] ?? [])); + if (ring.length < 3) continue; + const base = pos.length / 3; + for (const [x, z] of ring) { + pos.push(x, -CARRIAGEWAY_DEPTH + MARKING_OFFSET, z); + uv.push(x, z); + color.push(...tint); + } + const points = ring.map(([x, z]) => new Vector2(x, z)); + for (const tri of ShapeUtils.triangulateShape(points, [])) { + idx.push(base + tri[0], base + tri[2], base + tri[1]); + } + } + return pos.length ? toGeometry(pos, idx, uv, color) : null; +} +``` + +`buildChunkGeometry` umbauen: + +```js +export function buildChunkGeometry(chunk) { + const out = { + ground: extrudePolygons(chunk.land, { base: -GROUND_THICKNESS, top: 0, uvScale: 8 }), + // Fahrbahn und Bordstein sind echte Geometrie in der Mulde, keine Auflagen. + carriageway: flatPolygons((chunk.carriageway ?? []).map((c) => c.poly), + { y: -CARRIAGEWAY_DEPTH, uvScale: 8 }), + curb: kerbGeometry(chunk.curb ?? []), + sidewalk: flatPolygons((chunk.sidewalk ?? []).map((s) => s.poly), { y: SURFACE_OFFSET, uvScale: 4 }), + markings: markingGeometry(chunk.markings ?? []), + lots: flatPolygons((chunk.lots ?? []).map((l) => l.poly), { y: SURFACE_OFFSET, uvScale: 8 }), + bridges: extrudePolygons((chunk.bridges ?? []).map((b) => b.poly), + { base: -BRIDGE_THICKNESS, top: BRIDGE_OFFSET, uvScale: 8 }), + buildings: buildBuildings(chunk.buildings), + paths: flatPolygons((chunk.paths ?? []).map((path) => path.poly), { y: ROAD_OFFSET, uvScale: 1.8 }), + }; + for (const kind of SURFACE_KINDS) { + const rings = (chunk.surfaces ?? []).filter((s) => s.kind === kind).map((s) => s.poly); + out[kind] = flatPolygons(rings, { y: SURFACE_OFFSET, uvScale: 8 }); + } + return out; +} +``` + +- [ ] **Schritt 4: Test laufen lassen** + +Ausführen: `npm test -- tests/geometry.test.js` +Erwartet: alle neuen bestehen. Alte Tests, die `chunk.roads` erwarten, schlagen fehl — die stellt Schritt 5 um. + +- [ ] **Schritt 5: Die alten Geometrietests umstellen** + +Jeden Test in `tests/geometry.test.js`, der `roads:` im Chunk setzt, auf `carriageway:` bzw. `bridges:` umstellen; die Zusicherung `out.roads` wird `out.carriageway`. Nichts an ihrer Aussage ändern. + +Ausführen: `npm test -- tests/geometry.test.js` +Erwartet: alle grün. + +- [ ] **Schritt 6: Commit** + +```bash +git add src/world/geometry.js tests/geometry.test.js +git commit -m "feat(world): Fahrbahnmulde, Bordsteinwand und Markierungen bauen" +``` + +--- + +### Task 14: Browser — Materialien, Kollision, Laternen + +**Dateien:** +- Ändern: `src/world/materials.js`, `src/world/streaming.js`, `src/world/props.js` +- Test: `tests/materials.test.js`, `tests/streaming.test.js`, `tests/props.test.js` + +**Schnittstellen:** +- Verbraucht: die Teile aus Task 13. +- Liefert: `MATERIAL_KEYS` mit `carriageway`, `curb`, `sidewalk`, `markings`, `lots`, `bridges` statt `roads`; `sidewalkLampPoints(points, sidewalk)` in `props.js`. + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +In `tests/materials.test.js`: + +```js +it("hat für jede neue Schicht ein Material", () => { + for (const key of ["carriageway", "curb", "sidewalk", "markings", "lots"]) { + expect(MATERIAL_KEYS).toContain(key); + } + expect(MATERIAL_KEYS).not.toContain("roads"); +}); + +it("nimmt Bordstein und Gehweg aus dem Kit", () => { + expect(TEXTURES.curb).toBe("curb"); + expect(TEXTURES.sidewalk).toBe("sidewalk"); + expect(TEXTURES.carriageway).toBe("asphalt_02"); + expect(TEXTURES.lots).toBe("asphalt_02"); +}); + +it("gibt der Markierung eine Farbe statt einer Textur", () => { + expect(SURFACE_COLORS.markings).toBeDefined(); + expect(TEXTURES.markings).toBeUndefined(); +}); + +it("zieht die Markierung im Tiefenpuffer am weitesten nach vorn", () => { + expect(POLYGON_OFFSET_ORDER.at(-1)).toBe("markings"); + // Fahrbahn und Bordstein liegen 15 cm tiefer und sind echte Geometrie — kein Versatz nötig. + expect(POLYGON_OFFSET_ORDER).not.toContain("carriageway"); + expect(POLYGON_OFFSET_ORDER).not.toContain("curb"); +}); +``` + +In `tests/streaming.test.js`: + +```js +it("zählt Fahrbahn und Bordstein zur Kollision, den Rest zu den Auflagen", () => { + for (const key of ["sidewalk", "markings", "lots", "paths"]) expect(DECALS.has(key)).toBe(true); + for (const key of ["carriageway", "curb", "bridges", "buildings"]) expect(DECALS.has(key)).toBe(false); +}); +``` + +In `tests/props.test.js`: + +```js +describe("Laternen auf dem Gehweg", () => { + const gehweg = [{ poly: [[[4, -1], [12, -1], [12, 1], [4, 1]]] }]; + + it("stellt die Laterne auf den Gehweg statt neben die Straße", () => { + const out = sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], gehweg); + expect(out).toHaveLength(1); + expect(out[0].x).toBeGreaterThanOrEqual(4); + expect(out[0].x).toBeLessThanOrEqual(12); + }); + + it("lässt eine Laterne weg, für die es keinen Gehweg gibt", () => { + expect(sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], [])).toEqual([]); + }); + + it("hängt nicht an der Listenposition", () => { + const punkte = [{ x: 0, z: 0, angle: 0 }, { x: 0, z: 5, angle: 0 }]; + const vorwaerts = sidewalkLampPoints(punkte, gehweg); + const rueckwaerts = sidewalkLampPoints([...punkte].reverse(), gehweg); + expect(vorwaerts.map((p) => p.x).sort()).toEqual(rueckwaerts.map((p) => p.x).sort()); + }); +}); +``` + +- [ ] **Schritt 2: Tests laufen lassen, Fehlschlag sehen** + +Ausführen: `npm test` +Erwartet: die neuen Tests rot, `sidewalkLampPoints is not defined`. + +- [ ] **Schritt 3: `materials.js` umstellen** + +```js +export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "field", "court", "pool", "parking", + "paths", "carriageway", "curb", "sidewalk", "markings", "lots", + "bridges", "buildings"]; +``` + +In `TEXTURES` `roads: "asphalt_02"` ersetzen durch: + +```js + carriageway: "asphalt_02", + lots: "asphalt_02", // Parkplätze und Vorplätze: derselbe Asphalt wie die Fahrbahn + curb: "curb", // modular_sidewalk_curb_kit (Just8, CC BY 4.0), s. tools/assets/textures.mjs + sidewalk: "sidewalk", +``` + +`SURFACE_COLORS` bekommt `markings: 0xffffff` mit einem Satz Begründung: Die Farbe je Markierung steckt als Vertexfarbe in der Geometrie, das Material ist nur der Träger. + +In `createMaterials`: +- `tints` auf `{ carriageway: ROAD_TINT, lots: ROAD_TINT, bridges: ROAD_TINT }` umstellen. +- `CLONES` auf `{ field: { from: "park", tint: FIELD_TINT }, parking: { from: "carriageway" } }`. +- Nach dem Erzeugen der einfarbigen Materialien: `materials.markings.vertexColors = true;` und `materials.curb.side = DoubleSide;` (`DoubleSide` aus three importieren). + +`POLYGON_OFFSET_ORDER` neu: + +```js +export const POLYGON_OFFSET_ORDER = ["ground", "urban", "park", "sand", "field", "court", "pool", + "parking", "lots", "sidewalk", "paths", "markings"]; +``` + +Den Kommentarblock darüber um einen Absatz ergänzen: Fahrbahn und Bordstein brauchen keinen Versatz, weil sie 15 cm tiefer als echte Geometrie liegen und mit nichts koplanar sind; die Markierung steht ganz vorn, weil sie nur 1 cm über der Fahrbahn liegt. + +- [ ] **Schritt 4: `streaming.js` umstellen** + +```js +const DECALS = new Set(["sand", "park", "urban", "field", "court", "pool", "parking", + "paths", "sidewalk", "markings", "lots"]); +``` + +Den Kommentar darüber ergänzen: Fahrbahn und Bordstein sind seit Teil 2 Kollisionsgeometrie — die Fahrbahn, weil man in der Mulde fährt, der Bordstein, weil er die Stufe zum Gehweg ist. Gemessen wurde, dass die Kapsel 15 cm ohne Halt erklimmt und erst bei 40 cm blockiert. + +- [ ] **Schritt 5: `props.js` umstellen** + +`vergeLampPoints` durch `sidewalkLampPoints` ersetzen: + +```js +// Abstände zur Mittellinie, in denen ein Platz auf dem Gehweg gesucht wird. Von innen nach +// außen, damit die Laterne so dicht wie möglich an der Bordsteinkante steht. +export const LAMP_VERGE = [4, 5, 6, 7, 8, 9, 10, 11, 12]; + +/** Laternen auf den Gehweg. + * + * Vorher wählte vergeLampPoints() die Straßenseite über den Listenindex (gerade links, + * ungerade rechts) und verwarf, was auf der Fahrbahn landete. Das hing an der Reihenfolge der + * Mittellinien in der Datei — beim Sortieren der Ausgabe wechselten 1.848 von 3.921 Laternen + * die Seite, weshalb die Mittellinien als einzige Schicht unsortiert bleiben mussten. Jetzt + * entscheidet die Gehwegfläche: erst links, dann rechts, und wo kein Gehweg ist, steht keine + * Laterne. Das Ergebnis hängt an der Geometrie statt an der Listenposition. */ +export function sidewalkLampPoints(points, sidewalk) { + const out = []; + for (const p of points) { + let placed = null; + for (const side of [1, -1]) { + const nx = -Math.sin(p.angle) * side, nz = Math.cos(p.angle) * side; + for (const verge of LAMP_VERGE) { + const x = p.x + nx * verge, z = p.z + nz * verge; + if (sidewalk.some((s) => pointInPolygon(x, z, s.poly))) { + placed = { x, z, angle: p.angle }; + break; + } + } + if (placed) break; + } + if (placed) out.push(placed); + } + return out; +} +``` + +In `#buildLamps`: + +```js + const points = sidewalkLampPoints(lampPoints((chunk.edges ?? []).map((e) => e.line)), + chunk.sidewalk ?? []); +``` + +- [ ] **Schritt 6: Alle Tests** + +Ausführen: `npm test` +Erwartet: grün. Tests, die `vergeLampPoints` oder `chunk.roads` benutzen, auf die neuen Namen umstellen, ohne ihre Aussage zu ändern. + +- [ ] **Schritt 7: Commit** + +```bash +git add src/world tests +git commit -m "feat(world): Materialien, Kollision und Laternen für die neuen Straßen" +``` + +--- + +### Task 15: Minimap und Begehbarkeit + +**Dateien:** +- Ändern: `tools/extract/overview.py` +- Test: `tools/extract/tests/test_overview.py` + +**Schnittstellen:** +- Verbraucht: das Chunk-Format aus Task 10. +- Liefert: `render_map` zeichnet Fahrbahn, Gehweg und Restflächen; die Begehbarkeitsmaske zählt den Gehweg mit. + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +An `tools/extract/tests/test_overview.py` anhängen: + +```python +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. + """ + 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, 115, 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) + assert maske.getpixel((113 // step, 10 // step)) == overview.WALKABLE, "Gehweg" + assert maske.getpixel((105 // step, 10 // step)) == overview.BLOCKED, "Fahrbahn" +``` + +`SIDEWALK` muss dafuer in `overview.py` oeffentlich stehen, wie `ROAD` auch. Die Maskenpruefung liegt bewusst mitten auf den Streifen: Bei 4 m je Maskenpixel zaehlt eine Zelle nur als frei, wenn **alle** ihre Meterzellen frei sind — deshalb sind die Streifen im Fixture 12 bzw. 3 m breit und die Probe liegt nicht an ihrem Rand. + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag sehen** + +Ausführen: `cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_overview.py -q` +Erwartet: rot, weil `render_map` die neuen Schlüssel nicht kennt. + +- [ ] **Schritt 3: `overview.py` umstellen** + +- Neue Farbe `SIDEWALK = (206, 202, 194)` neben `ROAD`; Restflächen und Brücken behalten `ROAD`. +- `_paint_layers(...)` liest statt `chunk["roads"]` jetzt `chunk["carriageway"]` (Farbe `ROAD`), davor `chunk["lots"]` (Farbe `ROAD`) und `chunk["sidewalk"]` (Farbe `SIDEWALK`). Reihenfolge von hinten nach vorn: Beläge, Wege, Gehweg, Restflächen, Fahrbahn. +- Markierungen werden **nicht** gezeichnet. Ein Kommentar sagt warum: Bei 1 px je 3 m wäre eine 10-cm-Linie ein Drittel Pixel und würde nur Rauschen erzeugen. +- In der Begehbarkeitsmaske zählt `sidewalk` künftig als begehbar; `carriageway`, `lots` und `bridges` nicht. + +- [ ] **Schritt 4: Test laufen lassen und committen** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run pytest -q +cd ../.. && git add tools/extract/overview.py tools/extract/tests/test_overview.py +git commit -m "feat(extract): Minimap zeichnet Fahrbahn, Gehweg und Restflächen" +``` + +--- + +### Task 16: Welt neu erzeugen und abnehmen + +**Dateien:** +- Erzeugt: `public/data/vice-a/*` +- Ändern: `docs/superpowers/specs/2026-09-16-strassen-design.md` (Messwerte) + +**Schnittstellen:** +- Verbraucht: alles. +- Liefert: die ausgelieferte Welt und die Zahlen für den Entwurf. + +- [ ] **Schritt 1: Beide Suiten** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run pytest -q +cd ../.. && npm test +``` +Erwartet: beide grün. Ist etwas rot: nicht weitermachen. + +- [ ] **Schritt 2: Welt erzeugen** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run python run.py +``` +Erwartet: Exit 0, die Zeilen „Straßennetz: …", „Straßen: …", „Flamingo Park: …" und **genau eine** Warnung. + +- [ ] **Schritt 3: Abnahme der Lauf-Stabilität** + +```bash +cd /a/developer/GitHub-desktop/gta6 && git add public/data/vice-a && git commit -m "chore(data): Welt mit Bordstein, Gehweg und Markierung neu erzeugt" +cd tools/extract && PYTHONUTF8=1 uv run python run.py +cd .. && git status --short +``` +Erwartet: **keine einzige geänderte Datei**, nur die beiden Kit-Ordner als `??`. Ist das nicht so, ist die Ausgabe nicht lauf-stabil — melden, nicht schönreden. + +- [ ] **Schritt 4: Abnahme der Geometrie** + +```bash +cd tools/extract && PYTHONUTF8=1 uv run python - <<'EOF' +import json +from pathlib import Path +from shapely.geometry import Polygon +from shapely.ops import unary_union + +ROOT = Path("../../public/data/vice-a") +gesamt = {} +for key in ("carriageway", "sidewalk", "lots", "markings"): + flaechen = [] + for path in sorted(ROOT.glob("chunk_*.json")): + c = json.loads(path.read_text(encoding="utf-8")) + for item in c.get(key, []): + flaechen.append(Polygon(item["poly"][0], item["poly"][1:]).buffer(0)) + gesamt[key] = (len(flaechen), unary_union(flaechen).area / 1e6 if flaechen else 0.0) +for key, (n, km2) in gesamt.items(): + print(f"{key}: {n} Stücke, {km2:.3f} km²") +kerb = sum(len(json.loads(p.read_text(encoding='utf-8')).get("curb", [])) + for p in sorted(ROOT.glob("chunk_*.json"))) +print("Bordsteinstücke:", kerb) +EOF +``` + +Erwartet, gegen den Entwurf gehalten: Fahrbahn rund 1,0–1,2 km² (heute war die ganze Straßenfläche 1,318 km²), Gehweg 0,2–0,4 km², Restflächen rund 0,18 km². Alle Zahlen in den Bericht. + +- [ ] **Schritt 5: Messwerte in den Entwurf** + +Im Abschnitt „Fehlerfälle und Risiken" von `docs/superpowers/specs/2026-09-16-strassen-design.md` einen Punkt „Gemessen im Spiel" ergänzen: die Zahlen aus Schritt 4, die Zeilen des Laufs, das Ergebnis der beiden Abnahmen und die Zeit aus Task 1. + +- [ ] **Schritt 6: Commit** + +```bash +git add docs/superpowers/specs/2026-09-16-strassen-design.md +git commit -m "docs: Messwerte des Straßenumbaus im Entwurf festhalten" +``` + +- [ ] **Schritt 7: Dem Controller übergeben** + +Die Prüfung im Browser macht der Controller selbst, weil dafür der Browser, eine Fahrt über den Bordstein und eine Abstimmung mit dem Nutzer nötig sind. In den Bericht gehören: die Zahlen aus Schritt 4, beide Abnahmen, die Testzahlen und alles, was offen blieb. + +--- + +## Selbstprüfung des Plans + +**Abdeckung des Entwurfs** + +| Abschnitt des Entwurfs | Task | +|---|---| +| Das Netz (Graph, Verschmelzen, Fransen, Glätten, Breite, Klasse) | 3, 4 | +| Querschnitte | 5 | +| Geometrie: Mulde | 7, 13 | +| Geometrie: Kreuzungen | 6 | +| Geometrie: Brücken | 7 (kein Bordstein), 13 (eigener Schlüssel) | +| Geometrie: Restflächen | 7 | +| Geometrie: Kollision | 14 | +| Markierungen längs | 8 | +| Markierungen an Kreuzungen | 9 | +| Texturen aus dem Kit | 2 | +| Chunk-Format | 10 | +| Darstellung im Browser | 13, 14 | +| Laternen auf dem Gehweg | 14 | +| Minimap und Begehbarkeit | 15 | +| Zusammenspiel mit Teil 1 | 11 | +| Ablauf | 12 | +| Risiko „gelöcherte Bodenplatte" | 1 | +| Risiko „Auto in der Mulde" | Controller, Browserprüfung nach Task 16 | +| Risiko „alle Chunks ändern sich" | 16, Schritt 3 | +| Namensnennung | 12 (Manifest) | + +**Offene Punkte, die der Controller entscheidet** + +- Die Fahrt mit dem Auto über den Bordstein (Risiko 2 des Entwurfs) und die Farbabstimmung. +- Ob `roads`/`centerlines` als Schlüssel ganz verschwinden dürfen: Der Plan entfernt sie; alte Chunks laden weiter, weil der Browser jeden neuen Schlüssel mit `?? []` liest.