16 Aufgaben mit Tests zuerst. Aufgabe 1 misst das größte Risiko des Entwurfs, bevor irgendetwas gebaut wird: ob der Browser eine Bodenplatte mit 100 Löchern je Chunk in unter 50 ms trianguliert. Danach Graph, Querschnitte, Kreuzungen, Mulde, Markierungen, Chunk-Format, Flamingo Park, Pipeline, Browser, Minimap und die Abnahme. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
114 KiB
Straßen mit Bordstein, Gehweg und US-Markierung — Umsetzungsplan
Für agentische Bearbeiter: ERFORDERLICHE UNTER-SKILL:
superpowers:subagent-driven-development(empfohlen) odersuperpowers: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, Branchleonida/strassen, Git Bash. Python immer austools/extractmitPYTHONUTF8=1 uv run .... -
Die Ordner
modular_city_road_pack__game_ready/undmodular_sidewalk_curb_kit/im Wurzelverzeichnis gehören dem Nutzer: nie ändern, nie committen. Lesen ist erlaubt. Vor jedem Commitgit status --shortprüfen. -
index-debug.htmldarf 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 annotationsbleibt in jeder Datei stehen, die es heute hat. -
Die Chunk-Ausgabe bleibt lauf-stabil:
shapely.normalizeje 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 gasse6 m 6,0 m 0 m nein wohnstrasse12 m 7,0 m 2,5 m ja hauptstrasse18 m 12,0 m 3,0 m ja boulevard24 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:
buildChunkGeometryaussrc/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:
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
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.
<Attribution-Zeile nach der eigenen Systemweisung>"
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:
// 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
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
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.
<Attribution-Zeile nach der eigenen Systemweisung>"
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)ausvectors.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])mitGraph.degree(node_id) -> intundGraph.incident(node_id) -> list[int](Kantenindizes)graph_from_skeleton(skel: np.ndarray) -> GraphJUNCTION_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:
"""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.pyanlegen
"""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
cd tools/extract && PYTHONUTF8=1 uv run pytest -q
Erwartet: 191 bestanden (186 + 5).
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.
<Attribution-Zeile nach der eigenen Systemweisung>"
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,Edgeaus Task 3. -
Liefert:
SPUR_MIN = 10.0,CONTRACT_MAX = 5.0,SIMPLIFY = 1.0,WIDTH_MARGIN = 8.0,WIDTH_STEP = 5.0CLASS_WIDTH = {"gasse": 6.0, "wohnstrasse": 12.0, "hauptstrasse": 18.0, "boulevard": 24.0}clean(graph: Graph) -> Graphmeasure_widths(graph: Graph, distance: np.ndarray) -> Graph(setztwidthundklassje 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:
"""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:
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
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
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.
<Attribution-Zeile nach der eigenen Systemweisung>"
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_WIDTHausroads.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) -> floattrimmed_line(graph, k) -> LineString | Noneribbon(graph, k) -> Polygon | None
-
Schritt 1: Den fehlschlagenden Test schreiben
tools/extract/tests/test_section.py:
"""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.pyschreiben
"""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
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,ribbonaus 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:
"""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:
# 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
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,ribbonaussection.py;Graphausroads.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)mitcarriageway: MultiPolygon,kerb: list[LineString],sidewalk: MultiPolygon,lots: list[Polygon]build_street(graph, corridor, bridges) -> Streetpunch(polygons, carriageway) -> list[Polygon]
-
Schritt 1: Den fehlschlagenden Test schreiben
tools/extract/tests/test_street.py:
"""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.pyschreiben
"""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
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_lineaussection.py;Graphausroads.py. -
Liefert:
WIDTH = 0.10,DASH = 3.0,GAP = 9.0KINDS = ("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:
"""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.pyschreiben
"""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
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,SECTIONaussection.py. -
Liefert:
CROSSWALK_DEPTH = 3.0,BAR = 0.6,STOP_DEPTH = 0.6,STOP_SETBACK = 4.2junction_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:
"""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):
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
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:
Streetausstreet.py,markings()ausmarking.py,Graphausroads.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).roadsundcenterlinesentfallen. -
Schritt 1: Den fehlschlagenden Test schreiben
An tools/extract/tests/test_emit.py anhängen:
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_payloadumbauen
In tools/extract/leonida/emit.py die Signatur und den Rumpf ersetzen. _rings und _sorted_surfaces bleiben unverändert; neu kommt _line dazu:
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
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.
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 zugraph: Graph.roadsbehält seine Bedeutung: der gemessene Korridor. -
Schritt 1: Den fehlschlagenden Test für das Schneiden schreiben
tools/extract/tests/test_roads_cut.py:
"""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:
# 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.pyumstellen
Drei Stellen, jede einzeln:
-
World(Zeile 467):centerlines: list[LineString]durchgraph: Graphersetzen, Import ergänzen. -
apply_street(Zeile 482): Die Zeilen 504–505, diecenterlinesbilden, ersetzen durch:
# 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.
-
apply_park(Zeile 546): Dort wirdworld.centerlinesan den Sperrflächen geschnitten. Denselben Schnitt jetzt auf dem Graphen:graph = cut_edges(world.graph, sperrflaeche)für jede Sperrfläche, undgraph=graphinreplace(...). Die Warnung, die dabei entsteht, bleibt wörtlich, wie sie ist —test_park_apply.pyhält sie fest. -
check_corners(Zeile 519) bleibt unverändert und misst weiter gegenworld.roads. Über den Docstring einen Absatz setzen:
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:
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
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:
"""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.pyumbauen
Importe ergänzen:
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:
# 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:
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:
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:
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:
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:
"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
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
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.01MARKING_COLORS = { gelb_gestrichelt, gelb_doppelt, weiss_gestrichelt, weiss_rand, haltelinie, zebra }kerbGeometry(lines)undmarkingGeometry(markings)buildChunkGeometry(chunk)mit den Teilencarriageway,curb,sidewalk,markings,lots,bridgesstattroads
-
Schritt 1: Den fehlschlagenden Test schreiben
An tests/geometry.test.js anhängen:
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.jserweitern
Oben bei den Konstanten:
// 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:
/** 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:
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
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_KEYSmitcarriageway,curb,sidewalk,markings,lots,bridgesstattroads;sidewalkLampPoints(points, sidewalk)inprops.js. -
Schritt 1: Den fehlschlagenden Test schreiben
In tests/materials.test.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:
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:
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.jsumstellen
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:
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:
tintsauf{ carriageway: ROAD_TINT, lots: ROAD_TINT, bridges: ROAD_TINT }umstellen.CLONESauf{ field: { from: "park", tint: FIELD_TINT }, parking: { from: "carriageway" } }.- Nach dem Erzeugen der einfarbigen Materialien:
materials.markings.vertexColors = true;undmaterials.curb.side = DoubleSide;(DoubleSideaus three importieren).
POLYGON_OFFSET_ORDER neu:
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.jsumstellen
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.jsumstellen
vergeLampPoints durch sidewalkLampPoints ersetzen:
// 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:
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
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_mapzeichnet 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:
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.pyumstellen -
Neue Farbe
SIDEWALK = (206, 202, 194)nebenROAD; Restflächen und Brücken behaltenROAD. -
_paint_layers(...)liest stattchunk["roads"]jetztchunk["carriageway"](FarbeROAD), davorchunk["lots"](FarbeROAD) undchunk["sidewalk"](FarbeSIDEWALK). 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
sidewalkkünftig als begehbar;carriageway,lotsundbridgesnicht. -
Schritt 4: Test laufen lassen und committen
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
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
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
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
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
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/centerlinesals Schlüssel ganz verschwinden dürfen: Der Plan entfernt sie; alte Chunks laden weiter, weil der Browser jeden neuen Schlüssel mit?? []liest.