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