Die drei Browser- und Abnahmeaufgaben in einem Wegwerf-Arbeitsverzeichnis durchgespielt, mit Gegenproben aus absichtlich falschem Code. Gefunden: - Task 13: Höhen wurden exakt verglichen, die Positionen liegen aber als Float32 vor - die Tests wären auch mit richtigem Code rot gewesen. - Task 13: Die Oberkante des Bordsteins lag auf y = 0 und verschwand damit unter dem Gehwegbelag; auf welcher Seite sie liegt, war offen. Sie liegt jetzt 1 cm über den Belägen, und die Seite kommt aus zwei Proben an der Fahrbahn. Die Wand steht 1 cm in der Fahrbahn, sonst fällt sie deckungsgleich auf die Lochwand der Bodenplatte. - Task 13: Die Brückenmarkierung hatte weder Code noch Test; die Regel ist jetzt "alle Eckpunkte auf der Brücke" statt "Schwerpunkt", sonst schwebt der Rest einer halb aufliegenden Markierung über der Mulde. - Task 14: Ein Laternentest wäre mit richtigem Code rot gewesen, ein zweiter konnte nicht fehlschlagen, und die geforderte Laterne aus einem Chunk im neuen Format hatte keinen Test. - Task 14: Die Tönung ließ die grüne Parkfarbe aus Teil 1 fallen, die Ersatzfarben fehlten, DECALS ist nicht exportiert, und die Markierung sollte die oberste Versatzstufe bekommen, obwohl der Kommentar dort vor genau diesem Wachstum warnt. - Task 14: tests/placesSite.test.js stand nicht in der Dateiliste und wäre gebrochen. - Task 16: Die Erwartungszahlen stammten von vor dem Park, der vom Entwurf verlangte Integrationstest fehlte, und das Entfernen des Skips hatte keinen eigenen Schritt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 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.
// Über CURB_KIT umstellbar, damit der Fall "Kit fehlt" geprüft werden kann, ohne den Ordner
// des Nutzers zu verschieben.
const KIT = `${process.env.CURB_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
CURB_KIT=gibt-es-nicht node tools/assets/textures.mjs
Erwartet: die Meldung, dass das Kit fehlt, Exit 0, keine Datei gelöscht.
Der Ordner des Nutzers wird dafür nicht verschoben. Ein mv nach /tmp und zurück wäre die naheliegende Prüfung, aber wenn der Lauf dazwischen abbricht, liegt ein Ordner, der nicht uns gehört, im Temp-Verzeichnis. Die Umgebungsvariable erreicht dasselbe, ohne etwas anzufassen.
- 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])
# labelled traegt die Knotennummer auf jedem *gewachsenen* Pixel. Genau das braucht _attach():
# Das Ende einer Kette liegt neben einem gewachsenen Pixel, nicht neben einem Kreuzungspixel.
return labelled, points
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. ndi.label auf der gewachsenen Maske leistet genau das; außerhalb der gewachsenen Bereiche steht 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, distance: np.ndarray) -> Graph— der Aufruf für die Pipeline: Skelett, Graph, säubern, Breite und Klasse. Die Distanztransformation kommt von außen, weilrun.pysie auch für die Palmen braucht.
-
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
# clean() vereinfacht mit 1,0 m, und der Zwischenpunkt liegt genau auf der Verbindungslinie -
# Douglas-Peucker wirft ihn weg. Geprüft wird das Verketten, nicht die Zahl der Stützpunkte.
assert out.edges[0].points == [punkt(0, 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_saeubern_bleibt_bei_vielen_fransen_schnell():
"""Wache gegen quadratische Laufzeit.
Das echte Skelett hat rund 12.000 Rohäste, die allermeisten davon Fransen. Eine Fassung, die
je Kante den Grad über alle Kanten sucht und nach jedem Fund von vorn beginnt, rechnet daran
Stunden — hier fällt sie sofort auf.
"""
import time
nodes = [Node(punkt(x, 0)) for x in range(0, 2001, 2)]
edges = [Edge(i, i + 1, gerade(nodes[i].point, nodes[i + 1].point))
for i in range(len(nodes) - 1)]
for i in range(1, len(nodes) - 1, 2): # an jeden zweiten Knoten eine Franse
nodes.append(Node(punkt(nodes[i].point[0], 3)))
edges.append(Edge(i, len(nodes) - 1, gerade(nodes[i].point, nodes[-1].point)))
graph = Graph(nodes=nodes, edges=edges)
assert len(graph.edges) > 1500
start = time.perf_counter()
out = clean(graph)
assert time.perf_counter() - start < 5.0
assert len(out.edges) == 1, "nach den Fransen bleibt eine durchgehende Kante"
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 collections import Counter
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 _degrees(graph: Graph) -> Counter:
grade = Counter()
for edge in graph.edges:
grade[edge.a] += 1
grade[edge.b] += 1
return grade
def _drop_spurs(graph: Graph) -> None:
"""Fransen weg, in Durchgängen statt Fund für Fund.
Der Grad wird einmal gezählt und beim Entfernen fortgeschrieben, statt ihn je Kante neu über
alle Kanten zu suchen, und ein Durchgang räumt alle Fransen auf einmal weg, statt nach jedem
Fund von vorn zu beginnen. Beides zusammen macht aus einer quadratischen eine lineare
Rechnung je Durchgang — das echte Skelett hat rund 12.000 Rohäste, und die naive Fassung
rechnete daran Stunden. Die Zahl der Durchgänge ist die Tiefe der Fransenketten, also klein.
"""
grade = _degrees(graph)
while True:
bleibt, entfernt = [], 0
for edge in graph.edges:
if (grade[edge.a] == 1 or grade[edge.b] == 1) and _length(edge) < SPUR_MIN:
grade[edge.a] -= 1
grade[edge.b] -= 1
entfernt += 1
else:
bleibt.append(edge)
graph.edges = bleibt
if not entfernt:
return
def _incident_index(graph: Graph) -> dict[int, list[int]]:
index: dict[int, list[int]] = {}
for k, edge in enumerate(graph.edges):
index.setdefault(edge.a, []).append(k)
if edge.b != edge.a:
index.setdefault(edge.b, []).append(k)
return index
def _merge_chains(graph: Graph) -> None:
"""Knoten mit genau zwei Kanten auflösen. Der Index entsteht je Verschmelzung einmal neu —
das ist vertretbar, weil es nach dem Fransenschnitt nur noch wenige solcher Knoten gibt."""
changed = True
while changed:
changed = False
for node, incident in _incident_index(graph).items():
if len(incident) != 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
grade = _degrees(graph)
for k, edge in enumerate(graph.edges):
if edge.a == edge.b or _length(edge) >= CONTRACT_MAX:
continue
if grade[edge.a] < 3 or grade[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 = [band.buffer(0.2) for k, edge in enumerate(graph.edges)
if not SECTION[edge.klass].kerb and (band := 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, Polygon
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[Polygon, 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: Die Achsen liegen bei ±WIDTH, also 2 * WIDTH = 20 cm
# auseinander. Jede Linie ist WIDTH breit und ragt von ihrer Achse WIDTH/2 nach
# innen; es bleiben 2*WIDTH − 2*(WIDTH/2) = WIDTH = 10 cm Lücke zwischen den
# gemalten Linien — der Wert aus dem Entwurf.
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[Polygon, 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[Polygon, 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, und sortiert wird nach der Ausgabe
selbst. Beides ist nur zulässig, solange niemand Reihenfolge oder Laufrichtung eine Bedeutung
gibt — bei den früheren centerlines tat das vergeLampPoints() in props.js, das die Straßenseite
einer Laterne aus dem Listenindex ablas. sidewalkLampPoints() wählt die Seite aus der Geometrie
und prüft gegen die Gehwegfläche; erst damit darf die Ausgabe sortiert 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), key=lambda s: s["poly"]),
"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"], e["breite"], e["fahrbahn"])),
"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"]),
}
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 - Ändern:
tools/extract/leonida/emit.py(nur ein Parameter) - Test:
tools/extract/tests/test_pipeline_roads.py(neu)
Schnittstellen:
- Verbraucht: alles aus den Tasks 3–11.
- Liefert: einen Lauf, der die neuen Schichten schreibt. In
run.pyzwei testbare Helfer,fit_to_carriageway(world, carriage) -> Worldundchunk_markings(marks, i, j) -> list[tuple[Polygon, str]]; inemit.pysplit_by_chunk_all(polys, i, j, min_area=0.5).
Vor dem Auftrag am echten Netz nachgemessen, vier Fehler dieser Aufgabe sind hier schon berichtigt: (1)
split_by_chunk_all()verwirft Stücke bis 0,5 m². Ein Strich der gestrichelten Linien ist 3 m × 10 cm = 0,3 m² — alle 4.363 Striche der Stadt wären beim Zuschnitt verschwunden, nachgezählt: 0 übrig. Markierungen laufen deshalb überchunk_markings()mit eigener Grenze. (2)_classified()stand in der Chunk-Schleife und hätte 144-mal je 5,8 s gerechnet, rund 14 Minuten. Es läuft jetzt einmal davor. (3) Der Entwurf setzt die Palmen nach dem Straßenbau neu (Abschnitt Ablauf, Schritt 7), der Plan hatte das nicht.palm_points()hält nur Abstand zum gemessenen Korridor, und die Fahrbahn ragt an Kurven und Kreuzungen darüber hinaus: 24 Palmen stünden auf der Fahrbahn, 32 weitere näher alsPALM_KERB. (4) Der erste Test verglich denselben Aufruf mit sich selbst und konnte nicht fehlschlagen. Er prüft jetzt den Helfer, denrun.pywirklich benutzt. Die Erwartungszahlen in Schritt 4 und 5 sind nach dem Flamingo Park gemessen, so wie der Lauf sie erzeugt.
- Schritt 1: Den fehlschlagenden Test schreiben
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.emit import chunk_box_of
from leonida.park import World
from leonida.roads import Edge, Graph, Node
from leonida.street import build_street, punch
from run import chunk_markings, fit_to_carriageway
def _gerade_wohnstrasse():
"""100 m Wohnstraße entlang y = 0: Fahrbahn 7 m breit, also von y = -3,5 bis 3,5."""
graph = Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse")])
return graph, build_street(graph, box(0, -6, 100, 6), bridges=[])
def _welt(graph, *, surfaces=(), palms=()):
return World(land=[box(-20, -50, 120, 50)], surfaces=list(surfaces), roads=[], graph=graph,
buildings=[], palms=list(palms))
def test_boden_und_belag_bekommen_dasselbe_loch():
"""Vergisst run.py eine der beiden Schichten, liegt dort ein Belag über der Mulde oder die
Bodenplatte schließt sie."""
graph, street = _gerade_wohnstrasse()
platte = box(-20, -50, 120, 50)
welt = _welt(graph, surfaces=[(box(-20, -50, 50, 50), "urban"), (box(50, -50, 120, 50), "park")])
out = fit_to_carriageway(welt, street.carriageway)
boden = unary_union(out.land)
assert boden.intersection(street.carriageway).area < 0.01, "die Fahrbahn liegt im Loch"
loch = street.carriageway.intersection(platte).area
assert abs(platte.area - boden.area - loch) < 0.01, "das Loch ist nicht größer als die Fahrbahn"
for kind in ("urban", "park"):
belag = unary_union([poly for poly, k in out.surfaces if k == kind])
assert belag.intersection(street.carriageway).area < 0.01, f"kein Belag {kind} über der Mulde"
assert abs(unary_union([poly for poly, _ in out.surfaces]).area - boden.area) < 0.01, "dasselbe Loch"
def test_gehweg_liegt_nicht_im_loch():
graph, street = _gerade_wohnstrasse()
land = unary_union(punch([box(-20, -50, 120, 50)], street.carriageway))
assert street.sidewalk.difference(land).area < 0.5, "der Gehweg liegt auf der Bodenplatte"
def test_keine_palme_auf_oder_dicht_an_der_fahrbahn():
"""Entwurf, Ablauf Schritt 7: Palmen werden nach dem Straßenbau neu gesetzt. Am echten Netz
standen ohne diesen Schritt 24 Palmen auf der Fahrbahn und 32 weitere näher als PALM_KERB."""
graph, street = _gerade_wohnstrasse()
welt = _welt(graph, palms=[(50.0, 0.0), (50.0, 4.5), (50.0, 5.5)])
out = fit_to_carriageway(welt, street.carriageway)
assert out.palms == [(50.0, 5.5)], "auf der Fahrbahn und 1 m daneben weg, 2 m daneben bleibt"
def test_striche_ueberleben_den_zuschnitt():
"""Ein Strich der gestrichelten Mitte ist 3 m × 10 cm = 0,3 m². Mit der Splittergrenze der
Flächen (0,5 m²) fiel am echten Netz jeder einzelne der 4.363 Striche weg."""
zelle = chunk_box_of(0, 0)
x, y = zelle.centroid.x, zelle.centroid.y
stuecke = chunk_markings([(box(x, y, x + 3.0, y + 0.1), "gelb_gestrichelt")], 0, 0)
assert [art for _, art in stuecke] == ["gelb_gestrichelt"]
assert abs(stuecke[0][0].area - 0.3) < 1e-6
def test_strich_ueber_der_chunkgrenze_wird_geteilt_nicht_verworfen():
zelle = chunk_box_of(0, 0)
grenze, y = zelle.bounds[2], zelle.centroid.y # rechte Kante von Chunk (0, 0)
strich = [(box(grenze - 1.0, y, grenze + 2.0, y + 0.1), "weiss_gestrichelt")]
assert [round(poly.area, 6) for poly, _ in chunk_markings(strich, 0, 0)] == [0.1]
assert [round(poly.area, 6) for poly, _ in chunk_markings(strich, 1, 0)] == [0.2]
- Schritt 2: Test laufen lassen
Ausführen: cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_pipeline_roads.py -q
Erwartet: FAIL schon beim Sammeln, ImportError: cannot import name 'chunk_markings' from 'run' — die Helfer entstehen erst in Schritt 3. (pythonpath = ["."] in pyproject.toml macht run.py für die Tests importierbar; main() läuft beim Import nicht.)
- Schritt 3:
run.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)
palmen = len(world.palms)
world = fit_to_carriageway(world, street.carriageway)
arten = dict(sorted(Counter(art for _, art in marks).items()))
print(f"Straßen: {len(street.kerb)} Bordsteinstücke, {len(street.lots)} Restflächen, "
f"{len(marks)} Markierungen {arten}, {palmen - len(world.palms)} Palmen an der Fahrbahn entfernt")
classified = _classified(street, world.graph) # einmal, nicht je Chunk: 5,8 s am echten Netz
replace aus dataclasses, Counter aus collections, unary_union aus shapely.ops und PALM_KERB aus leonida.heights importieren (dort steht der Import von assign_heights, palm_points schon).
Die Chunk-Schleife auf die neuen Schlüssel umstellen:
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
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=chunk_markings(marks, i, j),
lots=list(split_by_chunk_all(street.lots, i, j)),
bridges=list(split_by_chunk_all(bridge_polys, i, j)),
edges=[(part, e.klass, e.width, SECTION[e.klass].carriageway)
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 die Helfer oben in run.py:
MARKING_MIN_AREA = 0.001 # m² — ein Strich ist 3 m × 10 cm = 0,3 m², die Flächengrenze 0,5 m² verwürfe ihn
def _pieces(geometry) -> list:
return [g for g in getattr(geometry, "geoms", [geometry]) if not g.is_empty]
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 fit_to_carriageway(world: World, carriage) -> World:
"""Die Welt an die gebaute Fahrbahn anpassen (Entwurf, Ablauf Schritte 3 und 7).
Bodenplatte und Beläge bekommen dasselbe Loch — fehlt es in einer Schicht, liegt dort ein Belag
über der Mulde oder die Platte schließt sie. Palmen näher als PALM_KERB an der Fahrbahn fallen
weg, nach derselben Regel, mit der apply_street() sie von der Jefferson Avenue fernhält:
palm_points() hält nur Abstand zum gemessenen Korridor, und die Fahrbahn ragt an Kurven und
Kreuzungen darüber hinaus.
"""
return replace(world,
land=punch(world.land, carriage),
surfaces=[(part, kind) for poly, kind in world.surfaces
for part in punch([poly], carriage)],
palms=[p for p in world.palms if carriage.distance(Point(p)) >= PALM_KERB])
def chunk_markings(marks, i: int, j: int) -> list:
"""Die Markierungen in Chunk (i, j) — an der Chunkgrenze geteilt, nicht verworfen."""
return [(part, art) for poly, art in marks
for part in split_by_chunk_all([poly], i, j, min_area=MARKING_MIN_AREA)]
def _classified(street, graph) -> list:
"""Jede Fahrbahnfläche mit der Klasse der breitesten Kante, deren Mittelpunkt in ihr liegt.
Vorsicht beim Lesen des Felds: carriageway_area() verschmilzt das Netz zu wenigen
zusammenhängenden Teilen, und ein Teil trägt dann die breiteste Klasse aller Straßen darin. Am
echten Netz sind das 5 von 213 Teilen, aber 91 % der Fahrbahnfläche tragen "boulevard". Über
die einzelne Straße sagt das Feld also wenig — die Klasse je Straße steht in edges. Heute liest
es kein Verbraucher; Browser und Minimap färben die Fahrbahn einheitlich.
"""
mitten = [(e.klass, LineString(e.points).interpolate(0.5, normalized=True)) for e in graph.edges]
out = []
for poly in _pieces(street.carriageway):
treffer = [klass for klass, mitte in mitten if poly.contains(mitte)]
klass = max(treffer, key=lambda k: SECTION[k].carriageway) if treffer else "wohnstrasse"
out.append((poly, klass))
return out
SECTION aus leonida.section und Point aus shapely.geometry importieren.
In tools/extract/leonida/emit.py bekommt split_by_chunk_all die Splittergrenze als Parameter, mit dem bisherigen Wert als Vorgabe — alle anderen Aufrufer bleiben unverändert:
def split_by_chunk_all(polys, i: int, j: int, min_area: float = 0.5):
"""Alle Anteile der übergebenen Polygone, die in Chunk (i, j) liegen.
min_area verwirft die Splitter, die beim Zuschneiden an der Chunkgrenze entstehen. 0,5 m² passt
für Flächen; Markierungen sind kleiner und brauchen eine eigene Grenze (run.py, chunk_markings).
"""
cell = chunk_box(i, j)
for poly in polys:
part = poly.intersection(cell)
if not part.is_empty and part.area > min_area:
if part.geom_type == "Polygon":
yield part
else:
yield from (g for g in part.geoms if g.geom_type == "Polygon" and g.area > min_area)
Die Namensnennung im Manifest um den Bordstein erweitern:
"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 nach rund vier Minuten (davon etwa 70 s für das Zuschneiden der Markierungen in der Chunk-Schleife), die Zeile „Straßennetz: 1537 Knoten, 1790 Kanten", die Zeile „Straßen: 830 Bordsteinstücke, 171 Restflächen, 22394 Markierungen {…}, 56 Palmen an der Fahrbahn entfernt", die bekannte Parkzeile „Flamingo Park: 2 Wegflächen, 14 Flächen, 11 Gebäude, Jefferson Avenue 12 m breit" und genau eine Warnung (Zufahrt schneidet die Sperrfläche tribuene). Die Zahlen hat der Controller mit derselben Kette gemessen; weichen sie ab, gehört das in den Bericht.
Bricht der Lauf in check_corners ab, ist das der im Entwurf vorhergesagte Stolperstein: Dann misst die Prüfung gegen die Fahrbahn statt gegen den Korridor. Nachsehen, ob world.roads beim Aufruf noch der Korridor ist — nicht die Toleranz hochsetzen.
- 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 LineString, Point, Polygon
ROOT = Path("../../public/data/vice-a")
stuecke, flaeche, arten, klassen = Counter(), Counter(), Counter(), Counter()
bordstein, palmen_auf_fahrbahn = 0.0, 0
for path in sorted(ROOT.glob("chunk_*.json")):
c = json.loads(path.read_text(encoding="utf-8"))
for key in ("carriageway", "sidewalk", "lots", "markings", "curb", "edges"):
stuecke[key] += len(c.get(key, []))
for key in ("carriageway", "sidewalk", "lots"):
for item in c.get(key, []):
flaeche[key] += Polygon(item["poly"][0], item["poly"][1:]).area
bordstein += sum(LineString(line).length for line in c.get("curb", []))
arten.update(m["art"] for m in c.get("markings", []))
klassen.update(e["klasse"] for e in c.get("edges", []))
fahrbahn = [Polygon(item["poly"][0], item["poly"][1:]) for item in c.get("carriageway", [])]
palmen_auf_fahrbahn += sum(1 for x, y in c.get("palms", [])
if any(f.contains(Point(x, y)) for f in fahrbahn))
print("Stücke, je Chunk gezählt:", dict(stuecke))
print("Flächen km²:", {k: round(v / 1e6, 4) for k, v in flaeche.items()})
print("Bordstein km:", round(bordstein / 1e3, 2))
print("Markierungsstücke je Art:", dict(sorted(arten.items())))
print("Kantenstücke je Klasse:", dict(klassen))
print("Palmen auf der Fahrbahn:", palmen_auf_fahrbahn)
EOF
Erwartet, vom Controller mit derselben Kette nach dem Flamingo Park gemessen:
| Größe | Erwartet |
|---|---|
| Fahrbahn | 0,9084 km² |
| Gehweg | 0,2983 km² |
| Restflächen | 0,0973 km² |
| Bordstein | 120,61 km |
| Markierungsstücke je Art | je Art mindestens so viele wie in der Zeile „Straßen:" aus Schritt 4 — Zuschneiden teilt nur. gelb_gestrichelt und weiss_gestrichelt dürfen auf keinen Fall 0 sein. |
| Kantenstücke je Klasse | alle vier Klassen besetzt |
| Palmen auf der Fahrbahn | 0 |
Die Stückzahlen im Skript sind je Chunk gezählt: Was über eine Chunkgrenze läuft, zählt doppelt. Mit den ungeteilten Zahlen (213 Fahrbahnteile, 171 Restflächen, 830 Bordsteinstücke, 1.777 Kanten nach dem Park) sind sie deshalb nicht vergleichbar — die ungeteilten stehen in der Zeile „Straßen:". Flächen und Bordsteinlänge sind dagegen vergleichbar; der Zuschnitt verliert nur Splitter unter 0,5 m² beziehungsweise 0,5 m. Weicht eine Fläche oder die Bordsteinlänge um mehr als 1 % ab, ist etwas falsch verdrahtet — dann nicht die Erwartung anpassen, sondern im Bericht melden.
Zur Einordnung: Der Korridor ohne Brücken ist nach dem Park 1,3140 km². Fahrbahn, Gehweg und Restflächen liegen darunter, weil Splitter unter 200 m² absichtlich wegfallen, und ein Teil der Fahrbahn liegt außerhalb des Korridors — die Bänder ragen an Kurven und Kreuzungen über die alte Rasterkontur hinaus, und das ist gewollt. Ohne den Park waren es 0,9136 / 0,2976 / 0,0944 km² und 120,27 km; der Unterschied ist die herausgeschnittene Parkfläche und die eingefügte Jefferson Avenue.
- Schritt 6: Erzeugte Daten zurückstellen
Der Lauf hat public/data/vice-a neu geschrieben. Die Daten kommen erst in Task 16 in einen eigenen Commit, und bis dahin sollen die Arbeitsbäume von Task 13 und 14 sauber bleiben:
cd ../.. && git checkout -- public/data/vice-a && git status --short
Erwartet: unversioniert nur die beiden Kit-Ordner des Nutzers (modular_city_road_pack__game_ready/, modular_sidewalk_curb_kit/), geändert nur run.py und emit.py, neu nur der Test. Liegen unter public/data/vice-a neue, unversionierte Dateien, stammen sie aus diesem Lauf: im Bericht nennen und nur diese entfernen. Die Kit-Ordner werden nie angefasst.
- Schritt 7: Commit
git add tools/extract/run.py tools/extract/leonida/emit.py tools/extract/tests/test_pipeline_roads.py
git commit -m "feat(extract): Pipeline baut Straßen aus dem Graphen"
Hier nicht public/data mitcommitten.
Task 13: Browser — Geometrie der neuen Schichten
Brücken, im Plan bisher übersehen: Die Fahrbahn liegt überall bei y = −0,15 — über einer Brücke also im Inneren des Brückendecks, das bis
BRIDGE_OFFSET(+0,02) reicht. Fahrbahn und Markierungen wären auf allen Brücken unsichtbar, obwohl der Entwurf sie dort „obenauf" verspricht. So wird es gelöst: Markierungen, deren Eckpunkte alle in einer Fläche auschunk.bridgesdesselben Chunks liegen, bekommen y =BRIDGE_OFFSET + MARKING_OFFSETstatt-CARRIAGEWAY_DEPTH + MARKING_OFFSET. Eine Markierung, die nur teilweise auf der Brücke liegt, bleibt in der Mulde und verschwindet auf dem Brückenstück im Deck — auf Deckhöhe schwebte ihr übriger Teil 17 cm über der Fahrbahn. Die Fahrbahnfläche selbst bleibt über Brücken im Deck verborgen; das Deck trägt ohnehin dieselbe Asphalttextur. Die 17-cm-Stufe am Brückenanfang deckt die Stirnwand des Decks ab, dieextrudePolygonsschon baut, und sie ist begehbar (gemessen: 15 und 25 cm werden ohne Halt erklommen).
Vor dem Auftrag trocken erprobt, drei Fehler dieser Aufgabe sind hier schon berichtigt: (1) Die Tests verglichen Höhen mit
toEqual. Die Positionen liegen aber als Float32 vor, und −0,15 ist dort −0,15000000596 — die Tests wären auch mit richtigem Code rot gewesen. JetzttoBeCloseTo, wie in den übrigen Tests dieser Datei. (2) Die Oberkante des Bordsteins lag auf y = 0. Der Gehweg beginnt genau an der Fahrbahnkante und liegt aufSURFACE_OFFSET(+0,02); er hätte die Oberkante überall zugedeckt, obwohl sie laut Entwurf dazu da ist, von oben gesehen zu werden. Sie liegt jetzt aufKERB_HEIGHT, 1 cm über den Belägen, und die Wand reicht bis dorthin. (3) Auf welche Seite die Oberkante gehört, war offen.emit.pylegt die Laufrichtung jeder Linie fest, sie sagt also nichts über die Fahrbahnseite, und auf der falschen Seite hinge die Oberkante als Sims über der Mulde. Die Seite bestimmt jetztcarriagewaySide()mit je einer Probe links und rechts an den Fahrbahnflächen desselben Chunks; lässt sie sich nicht bestimmen, entsteht nur die Wand. Dazu steht die Wand 1 cm in der Fahrbahn (KERB_INSET): Auf der Linie selbst fiele sie genau auf die Lochwand der Bodenplatte, und zwei deckungsgleiche Flächen flimmern. Gegenprobe mit absichtlich falschem Code: Richtet sich die Oberkante nach der Laufrichtung statt nach den Proben, werden drei Tests rot; hebt man Markierungen nach ihrem Schwerpunkt statt nach allen Eckpunkten auf das Deck, wird der Brückentest rot.
Seit Task 10 gebrochen, hier zu reparieren:
buildChunkGeometry()liestchunk.roads.filter(...)ohne Rückfall und wirft mit Chunks im neuen Format einenTypeError. Die Fixtures intests/geometry.test.jssind von Hand im alten Format gebaut und merken das nicht — stell sie mit um, sonst prüfen sie weiter eine Schnittstelle, die es nicht mehr gibt.
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,KERB_HEIGHT = SURFACE_OFFSET + 0.01,KERB_INSET = 0.01,MARKING_OFFSET = 0.01MARKING_COLORS = { gelb_gestrichelt, gelb_doppelt, weiss_gestrichelt, weiss_rand, haltelinie, zebra }kerbGeometry(lines, carriageway)undmarkingGeometry(markings, bridges)— das zweite Argument sind jeweils die Flächen (Ringlisten) der Fahrbahn bzw. der Brücken desselben ChunksbuildChunkGeometry(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,
});
// Eine Fahrbahn nördlich der Bordsteinlinie z = 10, die Linie in beiden Laufrichtungen: emit.py
// legt die Richtung jeder Linie fest, sie sagt also nichts darüber, wo die Fahrbahn liegt.
const FAHRBAHN = [[[10, 10], [20, 10], [20, 20], [10, 20]]];
const kante = (umgedreht) => (umgedreht ? [[20, 10], [10, 10]] : [[10, 10], [20, 10]]);
const bounds = (geo) => new Box3().setFromBufferAttribute(geo.getAttribute("position"));
it("legt die Fahrbahn 15 cm unter den Boden", () => {
const geo = buildChunkGeometry(chunk({
carriageway: [{ poly: FAHRBAHN, klasse: "wohnstrasse" }],
})).carriageway;
// toBeCloseTo, nicht toEqual: Die Positionen liegen als Float32 vor, und -0,15 ist dort
// -0,15000000596.
expect(bounds(geo).min.y).toBeCloseTo(-CARRIAGEWAY_DEPTH);
expect(bounds(geo).max.y).toBeCloseTo(-CARRIAGEWAY_DEPTH);
});
it("baut aus einer Bordsteinlinie eine Wand aus der Mulde und eine Oberkante knapp über den Belägen", () => {
const geo = kerbGeometry([kante(false)], [FAHRBAHN]);
expect(bounds(geo).min.y).toBeCloseTo(-CARRIAGEWAY_DEPTH);
expect(bounds(geo).max.y).toBeCloseTo(KERB_HEIGHT);
// Wand (4 Punkte) und Oberkante (4 Punkte) je Abschnitt.
expect(geo.getAttribute("position").count).toBe(8);
});
it("legt die Oberkante auf die Gehwegseite und die Wand 1 cm in die Fahrbahn, in beiden Laufrichtungen", () => {
for (const umgedreht of [false, true]) {
const geo = kerbGeometry([kante(umgedreht)], [FAHRBAHN]);
const pos = geo.getAttribute("position");
expect(bounds(geo).min.z, `umgedreht=${umgedreht}`).toBeCloseTo(10 - KERB_TOP);
expect(bounds(geo).max.z, `umgedreht=${umgedreht}`).toBeCloseTo(10 + KERB_INSET);
for (let i = 0; i < pos.count; i++) {
if (pos.getY(i) < 0) expect(pos.getZ(i), `Wandfuß, umgedreht=${umgedreht}`).toBeCloseTo(10 + KERB_INSET);
}
}
});
it("zeigt die Oberkante nach oben, in beiden Laufrichtungen", () => {
for (const umgedreht of [false, true]) {
const geo = kerbGeometry([kante(umgedreht)], [FAHRBAHN]);
const pos = geo.getAttribute("position");
const normal = geo.getAttribute("normal");
const aussen = [];
for (let i = 0; i < pos.count; i++) {
if (pos.getZ(i) < 10 - KERB_TOP / 2) aussen.push(normal.getY(i)); // Außenkante der Oberkante
}
expect(aussen, `umgedreht=${umgedreht}`).toHaveLength(2);
for (const ny of aussen) expect(ny, `umgedreht=${umgedreht}`).toBeGreaterThan(0.99);
}
});
it("baut nur die Wand, wenn keine Fahrbahn neben der Linie liegt", () => {
// Ohne Fahrbahn lässt sich die Seite nicht bestimmen, und eine Oberkante auf der falschen
// Seite hinge als Sims über der Mulde.
const geo = kerbGeometry([kante(false)], []);
expect(geo.getAttribute("position").count).toBe(4);
expect(bounds(geo).min.z).toBeCloseTo(10);
expect(bounds(geo).max.z).toBeCloseTo(10);
});
it("gibt Markierungen ihre Farbe als Vertexfarbe und legt sie 1 cm über die Fahrbahn", () => {
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));
expect(color.slice(12, 15)).toEqual(new Float32Array(MARKING_COLORS.zebra));
expect(bounds(geo).min.y).toBeCloseTo(-CARRIAGEWAY_DEPTH + MARKING_OFFSET);
expect(bounds(geo).max.y).toBeCloseTo(-CARRIAGEWAY_DEPTH + MARKING_OFFSET);
});
it("legt Markierungen, die ganz auf einer Brücke liegen, auf das Deck und alle anderen in die Mulde", () => {
const strich = (x) => ({ poly: [[[x, 5], [x + 3, 5], [x + 3, 5.1], [x, 5.1]]], art: "gelb_gestrichelt" });
const out = buildChunkGeometry(chunk({
bridges: [{ poly: [[[0, 0], [20, 0], [20, 20], [0, 20]]] }],
markings: [strich(5), strich(18), strich(30)], // ganz auf der Brücke, halb, daneben
}));
const deck = bounds(out.bridges).max.y;
const pos = out.markings.getAttribute("position");
expect(pos.count).toBe(12);
for (let i = 0; i < 4; i++) expect(pos.getY(i), "ganz auf der Brücke").toBeCloseTo(deck + MARKING_OFFSET);
for (let i = 4; i < 12; i++) expect(pos.getY(i), "halb oder daneben").toBeCloseTo(-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_HEIGHT, KERB_INSET, KERB_TOP, MARKING_COLORS, MARKING_OFFSET, kerbGeometry und markingGeometry dazu; Box3 ist schon importiert.
- Schritt 2: Test laufen lassen, Fehlschlag sehen
Ausführen: npm test -- tests/geometry.test.js
Erwartet: rot, weil die neuen Exporte fehlen (etwa kerbGeometry is not a function).
- Schritt 3:
geometry.jserweitern
Oben bei den Konstanten, direkt nach SURFACE_KINDS (sie brauchen SURFACE_OFFSET):
// 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
// Die Oberkante liegt 1 cm über den Belägen statt auf y = 0: Der Gehweg beginnt genau an der
// Fahrbahnkante und liegt auf SURFACE_OFFSET — eine Oberkante auf y = 0 deckte er vollständig zu.
export const KERB_HEIGHT = SURFACE_OFFSET + 0.01;
// Die Wand steht 1 cm in der Fahrbahn: Auf der Linie selbst fiele sie genau auf die Lochwand der
// Bodenplatte (extrudePolygons baut auch für Löcher Wände), und zwei deckungsgleiche Flächen
// flimmern im Tiefenpuffer.
export const KERB_INSET = 0.01;
const KERB_PROBE = 0.3; // Abstand der Proben links und rechts einer Bordsteinlinie
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 neuen Bauer, vor buildChunkGeometry:
/** Liegt (x, z) in einer der Flächen? Gerade-ungerade über alle Ringe, ein Loch zählt also als
* außen. Die Flächen eines Chunks überlappen sich nicht, sie sind Stücke einer Vereinigung. */
function insideAny(x, z, polys) {
let inside = false;
for (const poly of polys) {
for (const ring of poly) {
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, zi] = ring[i];
const [xj, zj] = ring[j];
if ((zi > z) !== (zj > z) && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside;
}
}
}
return inside;
}
/** Auf welcher Seite einer Bordsteinlinie liegt die Fahrbahn: +1 in Richtung der Normale
* (-dz, dx), -1 gegenüber, 0, wenn es sich nicht entscheiden lässt.
*
* Die Laufrichtung der Linie sagt darüber nichts: emit.py legt jede Linie auf eine feste Richtung
* fest, damit die Ausgabe nicht vom Rechenweg abhängt. Ein Bordsteinstück ist aber ein
* zusammenhängendes Stück Fahrbahnrand, die Fahrbahn liegt also auf seiner ganzen Länge auf
* derselben Seite. Geprüft wird Abschnitt für Abschnitt, bis genau eine der beiden Proben in der
* Fahrbahn liegt — an der Chunkgrenze kann eine Probe jenseits des Chunks landen, wo es keine
* Fahrbahnfläche dieses Chunks gibt. */
function carriagewaySide(line, carriageway) {
for (let i = 0; i + 1 < line.length; i++) {
const [x0, z0] = line[i];
const [x1, z1] = line[i + 1];
const len = Math.hypot(x1 - x0, z1 - z0);
if (len < 1e-6) continue;
const mx = (x0 + x1) / 2, mz = (z0 + z1) / 2;
const px = (-(z1 - z0) / len) * KERB_PROBE, pz = ((x1 - x0) / len) * KERB_PROBE;
const links = insideAny(mx + px, mz + pz, carriageway);
const rechts = insideAny(mx - px, mz - pz, carriageway);
if (links !== rechts) return links ? 1 : -1;
}
return 0;
}
/** Bordstein: senkrechte Wand aus der Mulde bis knapp über die Beläge, dazu eine schmale
* Oberkante auf der Gehwegseite.
*
* 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. Auf
* welche Seite sie gehört, bestimmt carriagewaySide() an den Fahrbahnflächen desselben Chunks —
* auf der Fahrbahnseite hinge sie als Sims über der Mulde. Lässt sich die Seite nicht bestimmen,
* entsteht nur die Wand, auf der Linie selbst. Die Wand bekommt in materials.js DoubleSide. */
export function kerbGeometry(lines, carriageway = [],
{ depth = CARRIAGEWAY_DEPTH, height = KERB_HEIGHT, top = KERB_TOP,
inset = KERB_INSET } = {}) {
const pos = [], idx = [], uv = [];
for (const line of lines) {
const side = carriagewaySide(line, carriageway);
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;
// Einheitsvektor zur Fahrbahn hin; (0, 0), wenn die Seite unbekannt ist.
const cx = (-dz / len) * side, cz = (dx / len) * side;
const ax = x0 + cx * inset, az = z0 + cz * inset;
const bx = x1 + cx * inset, bz = z1 + cz * inset;
const w = pos.length / 3;
pos.push(ax, -depth, az, bx, -depth, bz, bx, height, bz, ax, height, az);
uv.push(0, 0, len, 0, len, depth + height, 0, depth + height);
idx.push(w, w + 1, w + 2, w, w + 2, w + 3);
if (side === 0) continue;
const t = pos.length / 3;
pos.push(ax, height, az, bx, height, bz,
x1 - cx * top, height, z1 - cz * top, x0 - cx * top, height, z0 - cz * top);
uv.push(0, 0, len, 0, len, top + inset, 0, top + inset);
// Nach oben zeigen: Der Umlaufsinn des Vierecks hängt an der Seite.
if (side > 0) idx.push(t, t + 1, t + 2, t, t + 2, t + 3);
else 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.
*
* Über einer Brücke liegt die Fahrbahn im Deck verborgen: Sie liegt überall auf
* -CARRIAGEWAY_DEPTH, das Deck reicht bis BRIDGE_OFFSET. Eine Markierung, deren Eckpunkte alle in
* einer Brückenfläche desselben Chunks liegen, kommt deshalb auf das Deck. Liegt sie nur
* teilweise darauf, bleibt sie in der Mulde und verschwindet auf der Brücke im Deck — auf
* Deckhöhe schwebte ihr übriger Teil 17 cm über der Fahrbahn. */
export function markingGeometry(markings, bridges = []) {
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 onDeck = bridges.length > 0 && ring.every(([x, z]) => insideAny(x, z, bridges));
const y = (onDeck ? BRIDGE_OFFSET : -CARRIAGEWAY_DEPTH) + MARKING_OFFSET;
const base = pos.length / 3;
for (const [x, z] of ring) {
pos.push(x, y, 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 ?? [], (chunk.carriageway ?? []).map((c) => c.poly)),
sidewalk: flatPolygons((chunk.sidewalk ?? []).map((s) => s.poly), { y: SURFACE_OFFSET, uvScale: 4 }),
markings: markingGeometry(chunk.markings ?? [], (chunk.bridges ?? []).map((b) => b.poly)),
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),
// Parkwege (docs/superpowers/specs/2026-09-15-flamingo-park-design.md): flach wie Straßen, Textur in
// echter Größe — Poly Haven misst concrete_pavement mit 1,8 × 1,8 m.
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 die neuen Schlüssel umstellen: Ein Eintrag { poly, bridge: false } wird zu carriageway: [{ poly, klasse: "wohnstrasse" }], ein Eintrag { poly, bridge: true } zu bridges: [{ poly }], und roads: [], centerlines: [] in den übrigen Fixtures entfällt. In „baut alle Teile eines Chunks" wird der Schlüssel roads in der Liste zu carriageway. Nichts an der Aussage der Tests ä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
Seit Task 10 still gebrochen, hier zu reparieren:
props.jsliestchunk.centerlines ?? []undchunk.roads ?? []. Beide Schlüssel gibt es nicht mehr — es entsteht kein Fehler, sondern einfach keine einzige Laterne auf der ganzen Karte. Die Fixtures intests/props.test.jssind im alten Format gebaut und merken das nicht. Nach dieser Aufgabe muss eine Laterne aus einem Chunk im neuen Format entstehen; das prüft der Test zuchunkLampPointsin Schritt 1.Danach nachziehen: Der Docstring von
_line()intools/extract/leonida/emit.pybeschreibt den Stand vor dieser Aufgabe — er zitiertprops.jsmit einer festen Zeilennummer und sagt, heute lese noch niemandcurboderedges. Schritt 7 gibt den neuen Wortlaut vor.
Vor dem Auftrag trocken erprobt (Scratch-Worktree mit Task 13 und dieser Aufgabe, ganze JS-Suite grün). Acht Fehler dieser Aufgabe sind hier schon berichtigt: (1) Der erste Laternentest legte den Gehweg auf die Mittellinie selbst (x 4 bis 12, z −1 bis 1), gesucht wird aber senkrecht zu ihr. Der Test wäre mit richtigem Code rot gewesen. (2) „hängt nicht an der Listenposition" konnte nicht fehlschlagen: Mit diesem Gehweg fanden beide Reihenfolgen gar keine Laterne, und zwei leere Listen sind gleich. Jetzt mit Längenzusicherung und mit einem Gehweg nur auf einer Seite, an dem die alte Index-Logik scheitert. (3) Die Forderung aus dem Kasten oben — eine Laterne aus einem Chunk im neuen Format — hatte keinen Test.
chunkLampPoints(chunk)zieht die Verdrahtung aus#buildLampsheraus, damit sie prüfbar ist. (4)DECALSist instreaming.jsnicht exportiert. Der Test prüft das Verhalten jetzt wie der bestehende Test daneben: über den Kollisionskörper und die Szene. (5) Die Tönung ließpark: PARK_TINTfallen — die grüne Tönung des Parks, für die sich der Nutzer in Teil 1 entschieden hat. (6)FALLBACK_COLORShatte keine Farben für die neuen Schichten; fehlt eine Textur, bekäme threecolor: undefined. (7) Die Markierung sollte die oberste Versatzstufe bekommen (−11). Der Kommentar überPOLYGON_OFFSET_ORDERwarnt aber genau davor: Der Faktor wird beim streifenden Blick mit der Tiefensteigung multipliziert und zieht eine Fläche vor Geometrie, die in Wahrheit davor steht, und wächst die Liste, ist die Stufenhöhe zu entkoppeln. Die Markierung berührt keinen Belag, ihr einziger Nachbar ist die Fahrbahn ohne Versatz — sie bekommt deshalb einen eigenen kleinen Versatz außerhalb der Rangfolge. Die Rangfolge selbst wächst nur um eine Stufe (die Wege stehen auf −10); den streifenden Blick sieht die Browserprüfung nach Task 16 nach. (8)tests/placesSite.test.jsbenutztvergeLampPointsundchunk.roadsund stand nicht in der Dateiliste. Ohne Umstellung bricht er hier mitvergeLampPoints is not a function. Dazu fehlteemit.pyim Commit, obwohl der Kasten oben dort eine Änderung verlangt.
Dateien:
- Ändern:
src/world/materials.js,src/world/streaming.js,src/world/props.js,tools/extract/leonida/emit.py(nur der Docstring von_line()) - Test:
tests/materials.test.js,tests/streaming.test.js,tests/props.test.js,tests/placesSite.test.js
Schnittstellen:
-
Verbraucht: die Teile aus Task 13.
-
Liefert:
MATERIAL_KEYSmitcarriageway,curb,sidewalk,markings,lots,bridgesstattroads; inprops.jsLAMP_VERGE,sidewalkLampPoints(points, sidewalk)undchunkLampPoints(chunk). -
Schritt 1: Die fehlschlagenden Tests schreiben
An tests/materials.test.js anhängen:
describe("Schichten aus Teil 2", () => {
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("gibt der Markierung einen eigenen kleinen Versatz statt der obersten Stufe der Beläge", () => {
// Ihr einziger Nachbar ist die Fahrbahn 1 cm darunter, und die hat keinen Versatz. Die oberste
// Stufe zöge sie beim streifenden Blick am weitesten nach vorn, ohne dass es etwas nützte.
expect(POLYGON_OFFSET_ORDER).not.toContain("markings");
expect(POLYGON_OFFSETS.markings).toEqual({ polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 });
// Fahrbahn und Bordstein liegen 15 cm tiefer und sind echte Geometrie — kein Versatz nötig.
expect(POLYGON_OFFSETS.carriageway).toBeUndefined();
expect(POLYGON_OFFSETS.curb).toBeUndefined();
});
});
In tests/streaming.test.js, im selben describe direkt nach dem Test „legt Parkwege und die neuen Flächenarten als Auflagen ohne Kollision in die Szene":
// Teil 2: Fahrbahn und Bordstein sind Kollision — man fährt in der Mulde, und der Bordstein ist die
// Stufe zum Gehweg. Gehweg, Markierung und Restflächen bleiben Auflagen.
it("legt Fahrbahn und Bordstein in die Kollision, Gehweg, Markierung und Restfläche als Auflagen in die Szene", async () => {
const square = [[[0, 0], [20, 0], [20, 20], [0, 20]]];
const chunk = {
chunk: [4, 6], origin: [0, -1000], land: [square], surfaces: [], buildings: [],
carriageway: [{ poly: [[[0, 20], [20, 20], [20, 30], [0, 30]]], klasse: "wohnstrasse" }],
curb: [[[0, 20], [20, 20]]],
sidewalk: [{ poly: [[[0, 17], [20, 17], [20, 20], [0, 20]]] }],
markings: [{ poly: [[[2, 25], [5, 25], [5, 25.1], [2, 25.1]]], art: "gelb_gestrichelt" }],
lots: [{ poly: [[[0, 0], [5, 0], [5, 5], [0, 5]]] }],
};
const { streamer, player, scene } = makeStreamer(chunk);
streamer.update(emptyChunkCenter);
await flush();
expect(player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(3); // Landplatte, Fahrbahn, Bordstein
const group = scene.add.mock.calls[0][0];
expect(group.children.filter((child) => child.isMesh)).toHaveLength(3); // Gehweg, Markierung, Restfläche
});
In tests/props.test.js den ganzen Block describe("vergeLampPoints", …) durch diesen ersetzen. vergeLampPoints entfällt in Schritt 5; was seine Tests zusicherten — die Laterne steht neben der Fahrbahn, ohne Platz entfällt sie, geschlossene Ringe gehen — trägt der neue Block in der neuen Form weiter:
describe("Laternen auf dem Gehweg", () => {
// Straße entlang der x-Achse, angle 0: Die Normale ist (0, 1). Der Gehweg liegt nur auf der Seite
// z > 0, 3,5 bis 6 m neben der Mittellinie — wie bei einer Wohnstraße mit 7 m Fahrbahn.
const gehweg = [{ poly: [[[-20, 3.5], [20, 3.5], [20, 6], [-20, 6]]] }];
it("stellt die Laterne auf den Gehweg, so dicht an die Bordsteinkante wie möglich", () => {
const out = sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], gehweg);
expect(out).toHaveLength(1);
expect(out[0].x).toBeCloseTo(0);
expect(out[0].z).toBeCloseTo(4); // der erste Abstand aus LAMP_VERGE, der auf dem Gehweg liegt
});
it("sucht auch auf der anderen Straßenseite", () => {
const gegenueber = [{ poly: [[[-20, -6], [20, -6], [20, -3.5], [-20, -3.5]]] }];
const out = sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], gegenueber);
expect(out).toHaveLength(1);
expect(out[0].z).toBeCloseTo(-4);
});
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", () => {
// Die alte Fassung wählte die Seite nach dem Index: Der zweite Punkt hätte auf der Seite ohne
// Gehweg gesucht und wäre entfallen.
const punkte = [{ x: -10, z: 0, angle: 0 }, { x: 10, z: 0, angle: 0 }];
const vorwaerts = sidewalkLampPoints(punkte, gehweg);
const rueckwaerts = sidewalkLampPoints([...punkte].reverse(), gehweg);
expect(vorwaerts).toHaveLength(2);
const xs = (out) => out.map((p) => p.x).sort((a, b) => a - b);
expect(xs(vorwaerts)).toEqual(xs(rueckwaerts));
});
it("findet den Gehweg auch, wenn sein Ring geschlossen ist (so liegen die Chunk-Dateien vor)", () => {
const ring = [[-20, 3.5], [20, 3.5], [20, 6], [-20, 6]];
expect(sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], [{ poly: [[...ring, ring[0]]] }])).toHaveLength(1);
});
it("setzt aus einem Chunk im Format von Teil 2 Laternen auf den Gehweg", () => {
// Wohnstraße entlang z = 50, Gehweg auf beiden Seiten; lampPoints() setzt auf 100 m drei Punkte.
const chunk = {
chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], buildings: [], palms: [],
carriageway: [], curb: [], markings: [], lots: [], bridges: [],
edges: [{ line: [[0, 50], [100, 50]], klasse: "wohnstrasse", breite: 12, fahrbahn: 7 }],
sidewalk: [{ poly: [[[0, 53.5], [100, 53.5], [100, 56], [0, 56]]] },
{ poly: [[[0, 44], [100, 44], [100, 46.5], [0, 46.5]]] }],
};
const out = chunkLampPoints(chunk);
expect(out).toHaveLength(3);
for (const p of out) expect(chunk.sidewalk.some((s) => pointInPolygon(p.x, p.z, s.poly))).toBe(true);
});
});
Im Import oben in tests/props.test.js entfällt vergeLampPoints; chunkLampPoints und sidewalkLampPoints kommen dazu.
- Schritt 2: Tests laufen lassen, Fehlschlag sehen
Ausführen: npm test
Erwartet: die neuen Tests rot (etwa sidewalkLampPoints is not a function). Rot ist außerdem seit Task 13 „materials > deckt genau die Geometrie-Teile ab", weil MATERIAL_KEYS noch roads enthält — der wird mit Schritt 3 grün.
- Schritt 3:
materials.jsumstellen
DoubleSide zum Import aus three hinzufügen.
export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "field", "court", "pool", "parking",
"paths", "carriageway", "curb", "sidewalk", "markings", "lots",
"bridges", "buildings"];
In TEXTURES die Zeile 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",
Die beiden Kommentare in TEXTURES, die noch roads nennen, nachziehen: „klont park und roads" wird „klont park und carriageway"; der Kommentar zu Ruling 56 über bridges sagt nur noch, dass eine Brücke dasselbe Straßenstück über Wasser ist und deshalb dieselbe Textur wie die Fahrbahn trägt — roads.filter(r => r.bridge) gibt es nicht mehr.
// Die Markierung trägt ihre Farbe je Art als Vertexfarbe in der Geometrie; das Material ist nur der Träger.
export const SURFACE_COLORS = { court: 0x6f8c79, pool: 0x3f9fd8, markings: 0xffffff };
In FALLBACK_COLORS die Zeile mit roads ersetzen durch:
carriageway: 0x505050, lots: 0x505050, curb: 0xbdbdbd, sidewalk: 0xd8d2c4,
bridges: 0xb0b0b0, buildings: 0xe8e4dc,
const CLONES = { field: { from: "park", tint: FIELD_TINT }, parking: { from: "carriageway" } };
POLYGON_OFFSET_ORDER und POLYGON_OFFSETS neu:
export const POLYGON_OFFSET_ORDER = ["ground", "urban", "park", "sand", "field", "court", "pool",
"parking", "lots", "sidewalk", "paths"];
export const POLYGON_OFFSETS = {
...Object.fromEntries(
POLYGON_OFFSET_ORDER.map((key, i) => [
key,
{ polygonOffset: true, polygonOffsetFactor: -i, polygonOffsetUnits: -i },
]),
),
markings: { polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 },
};
Den Kommentarblock über POLYGON_OFFSET_ORDER um diesen Absatz ergänzen:
// Teil 2: Gehweg und Restflächen liegen als Beläge auf SURFACE_OFFSET und reihen sich unter den
// Wegen ein. Fahrbahn und Bordstein brauchen keinen Versatz, weil sie 15 cm tiefer als echte
// Geometrie liegen und mit nichts koplanar sind. Die Markierung steht nicht in der Rangfolge: Sie
// liegt 1 cm über der Fahrbahn und berührt keinen Belag, ihr einziger Nachbar hat Versatz 0. Die
// oberste Stufe zöge sie beim streifenden Blick am weitesten nach vorn, ohne dass es etwas nützte,
// deshalb ein eigener Versatz von -1. Die Rangfolge wächst damit nur um eine Stufe; die Wege
// stehen jetzt auf -10.
In createMaterials:
const tints = { carriageway: ROAD_TINT, lots: ROAD_TINT, bridges: ROAD_TINT, park: PARK_TINT };
und direkt nach materials.buildings.vertexColors = true;:
materials.markings.vertexColors = true; // Farbe je Markierungsart, s. geometry.js MARKING_COLORS
materials.curb.side = DoubleSide; // welche Seite der Wand außen liegt, weiß die Linie nicht
- Schritt 4:
streaming.jsumstellen
const DECALS = new Set(["sand", "park", "urban", "field", "court", "pool", "parking",
"paths", "sidewalk", "markings", "lots"]);
Darüber ein Kommentar: 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 und 25 cm ohne Halt erklimmt und erst bei 40 cm blockiert. Den Kommentar in #load („Straßen und Bodenflächen sind reine Auflagen; Kollision trägt die Landplatte.") entsprechend nachziehen: Auflagen sind Beläge, Wege, Gehweg, Markierung und Restflächen; Kollision tragen Landplatte, Fahrbahn, Bordstein, Brücken und Gebäude.
- Schritt 5:
props.jsumstellen
LAMP_VERGE 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];
vergeLampPoints samt Docstring durch diese beiden Funktionen ersetzen:
/**
* 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:
* Senkrecht zur Blickrichtung (Normale zu (cos angle, sin angle) ist (-sin angle, cos angle)) wird
* erst links, dann rechts von innen nach außen durch LAMP_VERGE probiert, 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;
}
/**
* Die Laternen eines Chunks: im Abstand von lampPoints() entlang der Straßenkanten, jede auf den
* Gehweg daneben. Eine eigene Funktion, damit ein Test prüfen kann, dass aus einem Chunk im Format
* von Teil 2 wirklich Laternen entstehen — nach dem Formatwechsel las #buildLamps Schlüssel, die es
* nicht mehr gab, und setzte ohne jeden Fehler keine einzige Laterne.
*/
export function chunkLampPoints(chunk) {
return sidewalkLampPoints(lampPoints((chunk.edges ?? []).map((e) => e.line)), chunk.sidewalk ?? []);
}
In #buildLamps die beiden ersten Zeilen durch const points = chunkLampPoints(chunk); ersetzen und im Docstring „Laternen entlang der Mittellinien (lampPoints), seitlich zur Fahrbahnkante versetzt (vergeLampPoints, …)" durch „Laternen auf dem Gehweg entlang der Straßenkanten (chunkLampPoints)" ersetzen. Im Docstring von pointInPolygon wird aus „wie chunk.roads[i].poly, Task 6" „wie chunk.sidewalk[i].poly".
- Schritt 6: Bestehende Tests umstellen
tests/materials.test.js:
expect(TEXTURES.parking).toBe(TEXTURES.roads)→TEXTURES.carriagewayexpect(Object.keys(SURFACE_COLORS).sort()).toEqual(["court", "pool"])→["court", "markings", "pool"]expect(TEXTURES.bridges).toBe(TEXTURES.roads)→TEXTURES.carriageway, der Kommentar darüber ohneroads.filter(r => r.bridge)- die erwartete
POLYGON_OFFSET_ORDER→["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "lots", "sidewalk", "paths"], der Testname entsprechend („… < Parkplatz < Restfläche < Gehweg < Weg") - im Test zu
applyPolygonOffsetsdie Platzhalterschlüssel auf["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "lots", "sidewalk", "paths", "markings", "carriageway", "curb", "bridges", "buildings", "water", "seafloor"]und die unberührten auf["carriageway", "curb", "bridges", "buildings", "water", "seafloor"]
tests/placesSite.test.js:
import { chunkLampPoints, pointInPolygon } from "../src/world/props.js";
// Straße ist seit Teil 2 alles, was früher der Korridor war: Fahrbahn, Gehweg und Restflächen.
const onRoad = [...(c.carriageway ?? []), ...(c.sidewalk ?? []), ...(c.lots ?? [])]
.some((road) => pointInPolygon(u, v, road.poly));
const lamps = chunkLampPoints(c).map((p) => toWorld(p.x, p.z));
und im Kommentar über dem Laternentest „bis 16 m neben ihrer Mittellinie" → „bis 12 m neben ihrer Straßenkante (LAMP_VERGE)". Dieser Test liest die committeten Chunks, und die liegen bis Task 16 im alten Format vor: Straße und Laternen prüft er deshalb erst mit den neuen Daten wieder wirklich. Task 16 muss ihn mit den neuen Daten grün sehen.
- Schritt 7: Docstring von
_line()nachziehen
In tools/extract/leonida/emit.py:
"""Ein Linienzug in Chunk-Koordinaten, richtungsunabhängig festgelegt.
normalize() dreht eine Linie in eine feste Laufrichtung, damit dieselbe Linie unabhängig vom
Rechenweg gleich in der Datei steht. Zulässig ist das, weil kein Verbraucher aus Richtung oder
Listenposition etwas ableitet: Die Laternen (sidewalkLampPoints() in props.js) suchen ihre
Straßenseite an der Gehwegfläche, und die Oberkante des Bordsteins (kerbGeometry() in
geometry.js) findet ihre Seite an der Fahrbahnfläche. Bei den früheren centerlines war das
nicht so, deshalb blieben sie unsortiert und unnormalisiert.
"""
- Schritt 8: Alle Tests
npm test
cd tools/extract && PYTHONUTF8=1 uv run pytest -q
Erwartet: beide grün, pytest mit derselben Zahl wie vorher (nur ein Docstring geändert).
- Schritt 9: Commit
cd ../.. && git status --short
git add src/world/materials.js src/world/streaming.js src/world/props.js tools/extract/leonida/emit.py \
tests/materials.test.js tests/streaming.test.js tests/props.test.js tests/placesSite.test.js
git commit -m "feat(world): Materialien, Kollision und Laternen für die neuen Straßen"
Task 15: Minimap und Begehbarkeit
Seit Task 10 gebrochen, hier zu reparieren:
overview.pyliestchunk["roads"]unditem["bridge"]ohne Rückfall und wirft mit Chunks im neuen Format einenKeyError. Die Fixtures intools/extract/tests/test_overview.pysind im alten Format gebaut; stell sie mit um.
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
Skip entfernen:
test_committed_map_files_match_the_committed_chunksintools/extract/tests/test_overview.pyist seit der Minimap-Umstellung übersprungen, weil die committeten Chunks bis zu dieser Aufgabe noch im alten Format vorlagen. Sobaldpublic/data/vice-ahier neu erzeugt ist, muss der Skip fallen und der Test grün sein (Schritt 4). Das gehört zur Abnahme, nicht zu einer späteren Aufräumrunde.
Vor dem Auftrag nachgesehen, drei Punkte sind hier schon berichtigt: (1) Die Erwartungen der Geometrie-Abnahme stammten von vor dem Flamingo Park und vor drei Fixrunden. Jetzt stehen dort die Zahlen, die der Controller mit der Kette aus Task 12 gemessen hat, und dasselbe Messskript wie in Task 12. (2) Der Entwurf verlangt unter „Tests" einen Integrationslauf auf den echten Daten, der Kantenzahl je Klasse, Markierungszahl und Restflächenzahl festhält. Keine Aufgabe hatte ihn; er entsteht in Schritt 6, sobald die Daten feststehen. Die Kreuzungszahl steht in keinem Chunk, weil keine Knoten ausgeliefert werden — sie kommt aus der Zeile „Straßennetz" in die Messwerte des Entwurfs, nicht in den Test. (3) Zwei Tests lesen die committeten Chunks und prüfen die neuen Schichten erst mit den neuen Daten wirklich: der übersprungene Minimap-Abgleich und
tests/placesSite.test.js(seit Task 14 gegen Fahrbahn, Gehweg, Restflächen undchunkLampPoints). Beide müssen nach dem Neuerzeugen grün sein.
Dateien:
- Erzeugt:
public/data/vice-a/* - Neu:
tools/extract/tests/test_committed_roads.py - Ändern:
tools/extract/tests/test_overview.py(Skip entfernen),docs/superpowers/specs/2026-09-16-strassen-design.md(Messwerte)
Schnittstellen:
-
Verbraucht: alles.
-
Liefert: die ausgelieferte Welt, einen Test, der sie festhält, 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 (pytest mit einem übersprungenen Test). Ist etwas rot: nicht weitermachen.
- Schritt 2: Welt erzeugen
cd tools/extract && PYTHONUTF8=1 uv run python run.py
Erwartet: Exit 0 nach rund vier Minuten, die Zeilen „Straßennetz: 1537 Knoten, 1790 Kanten", „Straßen: 830 Bordsteinstücke, 171 Restflächen, 22394 Markierungen {…}, 56 Palmen an der Fahrbahn entfernt", „Flamingo Park: …" und genau eine Warnung (Zufahrt schneidet die Sperrfläche tribuene). Die ganze Ausgabe in den Bericht.
- Schritt 3: Abnahme der Lauf-Stabilität
cd /a/developer/GitHub-desktop/gta6 && git status --short
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: vor dem Commit nur Dateien unter public/data/vice-a und die beiden Kit-Ordner; nach dem zweiten Lauf 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: Skip entfernen, beide Suiten mit den neuen Daten
In tools/extract/tests/test_overview.py den @pytest.mark.skip(...) vor test_committed_map_files_match_the_committed_chunks entfernen, dazu den Satz im Docstring, der das Überspringen erklärt.
cd tools/extract && PYTHONUTF8=1 uv run pytest -q
cd ../.. && npm test
Erwartet: beide grün, pytest ohne übersprungenen Test, tests/placesSite.test.js grün. Wird etwas rot, ist das ein Befund über die neuen Daten: melden, nicht den Test anpassen.
git add tools/extract/tests/test_overview.py
git commit -m "test(extract): Minimap-Abgleich mit den neuen Daten wieder scharf"
- Schritt 5: Abnahme der Geometrie
cd tools/extract && PYTHONUTF8=1 uv run python - <<'EOF'
import json
from collections import Counter
from pathlib import Path
from shapely.geometry import LineString, Point, Polygon
ROOT = Path("../../public/data/vice-a")
stuecke, flaeche, arten, klassen = Counter(), Counter(), Counter(), Counter()
bordstein, palmen_auf_fahrbahn = 0.0, 0
for path in sorted(ROOT.glob("chunk_*.json")):
c = json.loads(path.read_text(encoding="utf-8"))
for key in ("carriageway", "sidewalk", "lots", "markings", "curb", "edges"):
stuecke[key] += len(c.get(key, []))
for key in ("carriageway", "sidewalk", "lots"):
for item in c.get(key, []):
flaeche[key] += Polygon(item["poly"][0], item["poly"][1:]).area
bordstein += sum(LineString(line).length for line in c.get("curb", []))
arten.update(m["art"] for m in c.get("markings", []))
klassen.update(e["klasse"] for e in c.get("edges", []))
fahrbahn = [Polygon(item["poly"][0], item["poly"][1:]) for item in c.get("carriageway", [])]
palmen_auf_fahrbahn += sum(1 for x, y in c.get("palms", [])
if any(f.contains(Point(x, y)) for f in fahrbahn))
print("Stücke, je Chunk gezählt:", dict(stuecke))
print("Flächen km²:", {k: round(v / 1e6, 4) for k, v in flaeche.items()})
print("Bordstein km:", round(bordstein / 1e3, 2))
print("Markierungsstücke je Art:", dict(sorted(arten.items())))
print("Kantenstücke je Klasse:", dict(klassen))
print("Palmen auf der Fahrbahn:", palmen_auf_fahrbahn)
EOF
Erwartet, vom Controller mit derselben Kette gemessen (dieselben Zahlen wie in Task 12, Schritt 5):
| Größe | Erwartet |
|---|---|
| Fahrbahn | 0,9084 km² |
| Gehweg | 0,2983 km² |
| Restflächen | 0,0973 km² |
| Bordstein | 120,61 km |
| Markierungsstücke je Art | je Art mindestens so viele wie in der Zeile „Straßen:" aus Schritt 2; gelb_gestrichelt und weiss_gestrichelt nicht 0 |
| Kantenstücke je Klasse | alle vier Klassen besetzt |
| Palmen auf der Fahrbahn | 0 |
Zur Einordnung: Heute gilt die ganze Korridorfläche von 1,316 km² als Straße. Weicht eine Fläche oder die Bordsteinlänge um mehr als 1 % ab, ist das ein Befund — melden, nicht die Erwartung anpassen. Die ganze Ausgabe in den Bericht; die Stückzahlen braucht Schritt 6.
- Schritt 6: Die ausgelieferten Straßen festhalten
tools/extract/tests/test_committed_roads.py:
"""Die ausgelieferten Straßen, festgehalten (Entwurf, Abschnitt Tests: Integrationslauf).
Der Test liest die committeten Chunks, nicht den Lauf — die Kacheln liegen nicht im Repository.
Die Zahlen sind beim Neuerzeugen gemessen. Ändert sich die Extraktion absichtlich, gehören die neuen
Zahlen hierher, mit einem Satz im Commit, warum. Die Kreuzungszahl fehlt, weil die Chunks keine
Knoten tragen; sie steht in den Messwerten des Entwurfs.
"""
from __future__ import annotations
import json
from collections import Counter
from pathlib import Path
from shapely.geometry import LineString, Point, Polygon
ROOT = Path(__file__).resolve().parents[3] / "public" / "data" / "vice-a"
def _chunks() -> list[dict]:
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(ROOT.glob("chunk_*.json"))]
def _flaeche(chunks, key) -> float:
return sum(Polygon(i["poly"][0], i["poly"][1:]).area for c in chunks for i in c.get(key, []))
def test_kantenstuecke_je_klasse():
assert dict(Counter(e["klasse"] for c in _chunks() for e in c["edges"])) == {
# die vier Zahlen aus „Kantenstücke je Klasse", Schritt 5
}
def test_markierungsstuecke_je_art():
assert dict(Counter(m["art"] for c in _chunks() for m in c["markings"])) == {
# die sechs Zahlen aus „Markierungsstücke je Art", Schritt 5
}
def test_restflaechen_und_bordsteinstuecke():
chunks = _chunks()
assert sum(len(c["lots"]) for c in chunks) == ... # „lots" aus „Stücke, je Chunk gezählt"
assert sum(len(c["curb"]) for c in chunks) == ... # „curb" aus „Stücke, je Chunk gezählt"
def test_flaechen_und_bordsteinlaenge():
chunks = _chunks()
assert round(_flaeche(chunks, "carriageway") / 1e6, 4) == ... # aus „Flächen km²"
assert round(_flaeche(chunks, "sidewalk") / 1e6, 4) == ...
assert round(_flaeche(chunks, "lots") / 1e6, 4) == ...
laenge = sum(LineString(line).length for c in chunks for line in c["curb"])
assert round(laenge / 1e3, 2) == ... # aus „Bordstein km"
def test_keine_palme_auf_der_fahrbahn():
for c in _chunks():
fahrbahn = [Polygon(i["poly"][0], i["poly"][1:]) for i in c["carriageway"]]
auf = [p for p in c["palms"] if any(f.contains(Point(p)) for f in fahrbahn)]
assert auf == [], f"Chunk {c['chunk']}"
Die Platzhalter ({…}-Kommentare und ...) mit den genauen Zahlen aus Schritt 5 füllen — sie werden hier festgehalten, nicht geschätzt. Dann:
cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_committed_roads.py -q
Erwartet: 5 bestanden. Gegenprobe, damit der Test nicht nur sich selbst bestätigt: eine der Zahlen vorübergehend um 1 ändern, rot sehen, zurückändern — Ausgabe in den Bericht.
- Schritt 7: 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 5, die Zeilen des Laufs aus Schritt 2 (mit Knoten- und Kantenzahl), das Ergebnis der Abnahmen aus Schritt 3 und 4 und die Zeit aus Task 1.
- Schritt 8: Commit
cd ../.. && git status --short
git add docs/superpowers/specs/2026-09-16-strassen-design.md tools/extract/tests/test_committed_roads.py
git commit -m "test(extract): ausgelieferte Straßen festhalten, Messwerte in den Entwurf"
- Schritt 9: 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 Ausgaben aus Schritt 2 und 5, alle 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) |
| Tests: Integrationslauf auf den echten Daten | 16 (Schritt 6) |
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.