Die drei Browser- und Abnahmeaufgaben in einem Wegwerf-Arbeitsverzeichnis durchgespielt, mit Gegenproben aus absichtlich falschem Code. Gefunden: - Task 13: Höhen wurden exakt verglichen, die Positionen liegen aber als Float32 vor - die Tests wären auch mit richtigem Code rot gewesen. - Task 13: Die Oberkante des Bordsteins lag auf y = 0 und verschwand damit unter dem Gehwegbelag; auf welcher Seite sie liegt, war offen. Sie liegt jetzt 1 cm über den Belägen, und die Seite kommt aus zwei Proben an der Fahrbahn. Die Wand steht 1 cm in der Fahrbahn, sonst fällt sie deckungsgleich auf die Lochwand der Bodenplatte. - Task 13: Die Brückenmarkierung hatte weder Code noch Test; die Regel ist jetzt "alle Eckpunkte auf der Brücke" statt "Schwerpunkt", sonst schwebt der Rest einer halb aufliegenden Markierung über der Mulde. - Task 14: Ein Laternentest wäre mit richtigem Code rot gewesen, ein zweiter konnte nicht fehlschlagen, und die geforderte Laterne aus einem Chunk im neuen Format hatte keinen Test. - Task 14: Die Tönung ließ die grüne Parkfarbe aus Teil 1 fallen, die Ersatzfarben fehlten, DECALS ist nicht exportiert, und die Markierung sollte die oberste Versatzstufe bekommen, obwohl der Kommentar dort vor genau diesem Wachstum warnt. - Task 14: tests/placesSite.test.js stand nicht in der Dateiliste und wäre gebrochen. - Task 16: Die Erwartungszahlen stammten von vor dem Park, der vom Entwurf verlangte Integrationstest fehlte, und das Entfernen des Skips hatte keinen eigenen Schritt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3347 lines
156 KiB
Markdown
3347 lines
156 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 Eckpunkte **alle** in einer Fläche aus `chunk.bridges` desselben Chunks liegen, bekommen y = `BRIDGE_OFFSET + MARKING_OFFSET` statt `-CARRIAGEWAY_DEPTH + MARKING_OFFSET`. Eine Markierung, die nur teilweise auf der Brücke liegt, bleibt in der Mulde und verschwindet auf dem Brückenstück im Deck — auf Deckhöhe schwebte ihr übriger Teil 17 cm über der Fahrbahn. Die Fahrbahnfläche selbst bleibt über Brücken im Deck verborgen; das Deck trägt ohnehin dieselbe Asphalttextur. Die 17-cm-Stufe am Brückenanfang deckt die Stirnwand des Decks ab, die `extrudePolygons` schon baut, und sie ist begehbar (gemessen: 15 und 25 cm werden ohne Halt erklommen).
|
||
|
||
> **Vor dem Auftrag trocken erprobt, drei Fehler dieser Aufgabe sind hier schon berichtigt:**
|
||
> (1) Die Tests verglichen Höhen mit `toEqual`. Die Positionen liegen aber als Float32 vor, und −0,15 ist dort −0,15000000596 — die Tests wären auch mit richtigem Code rot gewesen. Jetzt `toBeCloseTo`, wie in den übrigen Tests dieser Datei.
|
||
> (2) Die Oberkante des Bordsteins lag auf y = 0. Der Gehweg beginnt genau an der Fahrbahnkante und liegt auf `SURFACE_OFFSET` (+0,02); er hätte die Oberkante überall zugedeckt, obwohl sie laut Entwurf dazu da ist, von oben gesehen zu werden. Sie liegt jetzt auf `KERB_HEIGHT`, 1 cm über den Belägen, und die Wand reicht bis dorthin.
|
||
> (3) Auf welche Seite die Oberkante gehört, war offen. `emit.py` legt die Laufrichtung jeder Linie fest, sie sagt also nichts über die Fahrbahnseite, und auf der falschen Seite hinge die Oberkante als Sims über der Mulde. Die Seite bestimmt jetzt `carriagewaySide()` mit je einer Probe links und rechts an den Fahrbahnflächen desselben Chunks; lässt sie sich nicht bestimmen, entsteht nur die Wand.
|
||
> Dazu steht die Wand 1 cm in der Fahrbahn (`KERB_INSET`): Auf der Linie selbst fiele sie genau auf die Lochwand der Bodenplatte, und zwei deckungsgleiche Flächen flimmern.
|
||
> Gegenprobe mit absichtlich falschem Code: Richtet sich die Oberkante nach der Laufrichtung statt nach den Proben, werden drei Tests rot; hebt man Markierungen nach ihrem Schwerpunkt statt nach allen Eckpunkten auf das Deck, wird der Brückentest rot.
|
||
|
||
> **Seit Task 10 gebrochen, hier zu reparieren:** `buildChunkGeometry()` 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`, `KERB_HEIGHT = SURFACE_OFFSET + 0.01`, `KERB_INSET = 0.01`, `MARKING_OFFSET = 0.01`
|
||
- `MARKING_COLORS = { gelb_gestrichelt, gelb_doppelt, weiss_gestrichelt, weiss_rand, haltelinie, zebra }`
|
||
- `kerbGeometry(lines, carriageway)` und `markingGeometry(markings, bridges)` — das zweite Argument sind jeweils die Flächen (Ringlisten) der Fahrbahn bzw. der Brücken desselben Chunks
|
||
- `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,
|
||
});
|
||
// Eine Fahrbahn nördlich der Bordsteinlinie z = 10, die Linie in beiden Laufrichtungen: emit.py
|
||
// legt die Richtung jeder Linie fest, sie sagt also nichts darüber, wo die Fahrbahn liegt.
|
||
const FAHRBAHN = [[[10, 10], [20, 10], [20, 20], [10, 20]]];
|
||
const kante = (umgedreht) => (umgedreht ? [[20, 10], [10, 10]] : [[10, 10], [20, 10]]);
|
||
const bounds = (geo) => new Box3().setFromBufferAttribute(geo.getAttribute("position"));
|
||
|
||
it("legt die Fahrbahn 15 cm unter den Boden", () => {
|
||
const geo = buildChunkGeometry(chunk({
|
||
carriageway: [{ poly: FAHRBAHN, klasse: "wohnstrasse" }],
|
||
})).carriageway;
|
||
// toBeCloseTo, nicht toEqual: Die Positionen liegen als Float32 vor, und -0,15 ist dort
|
||
// -0,15000000596.
|
||
expect(bounds(geo).min.y).toBeCloseTo(-CARRIAGEWAY_DEPTH);
|
||
expect(bounds(geo).max.y).toBeCloseTo(-CARRIAGEWAY_DEPTH);
|
||
});
|
||
|
||
it("baut aus einer Bordsteinlinie eine Wand aus der Mulde und eine Oberkante knapp über den Belägen", () => {
|
||
const geo = kerbGeometry([kante(false)], [FAHRBAHN]);
|
||
expect(bounds(geo).min.y).toBeCloseTo(-CARRIAGEWAY_DEPTH);
|
||
expect(bounds(geo).max.y).toBeCloseTo(KERB_HEIGHT);
|
||
// Wand (4 Punkte) und Oberkante (4 Punkte) je Abschnitt.
|
||
expect(geo.getAttribute("position").count).toBe(8);
|
||
});
|
||
|
||
it("legt die Oberkante auf die Gehwegseite und die Wand 1 cm in die Fahrbahn, in beiden Laufrichtungen", () => {
|
||
for (const umgedreht of [false, true]) {
|
||
const geo = kerbGeometry([kante(umgedreht)], [FAHRBAHN]);
|
||
const pos = geo.getAttribute("position");
|
||
expect(bounds(geo).min.z, `umgedreht=${umgedreht}`).toBeCloseTo(10 - KERB_TOP);
|
||
expect(bounds(geo).max.z, `umgedreht=${umgedreht}`).toBeCloseTo(10 + KERB_INSET);
|
||
for (let i = 0; i < pos.count; i++) {
|
||
if (pos.getY(i) < 0) expect(pos.getZ(i), `Wandfuß, umgedreht=${umgedreht}`).toBeCloseTo(10 + KERB_INSET);
|
||
}
|
||
}
|
||
});
|
||
|
||
it("zeigt die Oberkante nach oben, in beiden Laufrichtungen", () => {
|
||
for (const umgedreht of [false, true]) {
|
||
const geo = kerbGeometry([kante(umgedreht)], [FAHRBAHN]);
|
||
const pos = geo.getAttribute("position");
|
||
const normal = geo.getAttribute("normal");
|
||
const aussen = [];
|
||
for (let i = 0; i < pos.count; i++) {
|
||
if (pos.getZ(i) < 10 - KERB_TOP / 2) aussen.push(normal.getY(i)); // Außenkante der Oberkante
|
||
}
|
||
expect(aussen, `umgedreht=${umgedreht}`).toHaveLength(2);
|
||
for (const ny of aussen) expect(ny, `umgedreht=${umgedreht}`).toBeGreaterThan(0.99);
|
||
}
|
||
});
|
||
|
||
it("baut nur die Wand, wenn keine Fahrbahn neben der Linie liegt", () => {
|
||
// Ohne Fahrbahn lässt sich die Seite nicht bestimmen, und eine Oberkante auf der falschen
|
||
// Seite hinge als Sims über der Mulde.
|
||
const geo = kerbGeometry([kante(false)], []);
|
||
expect(geo.getAttribute("position").count).toBe(4);
|
||
expect(bounds(geo).min.z).toBeCloseTo(10);
|
||
expect(bounds(geo).max.z).toBeCloseTo(10);
|
||
});
|
||
|
||
it("gibt Markierungen ihre Farbe als Vertexfarbe und legt sie 1 cm über die Fahrbahn", () => {
|
||
const geo = markingGeometry([
|
||
{ poly: [[[0, 0], [3, 0], [3, -0.1], [0, -0.1]]], art: "gelb_doppelt" },
|
||
{ poly: [[[5, 0], [8, 0], [8, -0.6], [5, -0.6]]], art: "zebra" },
|
||
]);
|
||
const color = geo.getAttribute("color").array;
|
||
expect(color.slice(0, 3)).toEqual(new Float32Array(MARKING_COLORS.gelb_doppelt));
|
||
expect(color.slice(12, 15)).toEqual(new Float32Array(MARKING_COLORS.zebra));
|
||
expect(bounds(geo).min.y).toBeCloseTo(-CARRIAGEWAY_DEPTH + MARKING_OFFSET);
|
||
expect(bounds(geo).max.y).toBeCloseTo(-CARRIAGEWAY_DEPTH + MARKING_OFFSET);
|
||
});
|
||
|
||
it("legt Markierungen, die ganz auf einer Brücke liegen, auf das Deck und alle anderen in die Mulde", () => {
|
||
const strich = (x) => ({ poly: [[[x, 5], [x + 3, 5], [x + 3, 5.1], [x, 5.1]]], art: "gelb_gestrichelt" });
|
||
const out = buildChunkGeometry(chunk({
|
||
bridges: [{ poly: [[[0, 0], [20, 0], [20, 20], [0, 20]]] }],
|
||
markings: [strich(5), strich(18), strich(30)], // ganz auf der Brücke, halb, daneben
|
||
}));
|
||
const deck = bounds(out.bridges).max.y;
|
||
const pos = out.markings.getAttribute("position");
|
||
expect(pos.count).toBe(12);
|
||
for (let i = 0; i < 4; i++) expect(pos.getY(i), "ganz auf der Brücke").toBeCloseTo(deck + MARKING_OFFSET);
|
||
for (let i = 4; i < 12; i++) expect(pos.getY(i), "halb oder daneben").toBeCloseTo(-CARRIAGEWAY_DEPTH + MARKING_OFFSET);
|
||
});
|
||
|
||
it("lädt alte Chunks ohne die neuen Schlüssel weiter", () => {
|
||
const alt = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [],
|
||
buildings: [], palms: [] };
|
||
expect(() => buildChunkGeometry(alt)).not.toThrow();
|
||
expect(buildChunkGeometry(alt).carriageway).toBeNull();
|
||
});
|
||
});
|
||
```
|
||
|
||
Der Import oben in der Datei bekommt `CARRIAGEWAY_DEPTH`, `KERB_HEIGHT`, `KERB_INSET`, `KERB_TOP`, `MARKING_COLORS`, `MARKING_OFFSET`, `kerbGeometry` und `markingGeometry` dazu; `Box3` ist schon importiert.
|
||
|
||
- [ ] **Schritt 2: Test laufen lassen, Fehlschlag sehen**
|
||
|
||
Ausführen: `npm test -- tests/geometry.test.js`
|
||
Erwartet: rot, weil die neuen Exporte fehlen (etwa `kerbGeometry is not a function`).
|
||
|
||
- [ ] **Schritt 3: `geometry.js` erweitern**
|
||
|
||
Oben bei den Konstanten, direkt nach `SURFACE_KINDS` (sie brauchen `SURFACE_OFFSET`):
|
||
|
||
```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
|
||
// Die Oberkante liegt 1 cm über den Belägen statt auf y = 0: Der Gehweg beginnt genau an der
|
||
// Fahrbahnkante und liegt auf SURFACE_OFFSET — eine Oberkante auf y = 0 deckte er vollständig zu.
|
||
export const KERB_HEIGHT = SURFACE_OFFSET + 0.01;
|
||
// Die Wand steht 1 cm in der Fahrbahn: Auf der Linie selbst fiele sie genau auf die Lochwand der
|
||
// Bodenplatte (extrudePolygons baut auch für Löcher Wände), und zwei deckungsgleiche Flächen
|
||
// flimmern im Tiefenpuffer.
|
||
export const KERB_INSET = 0.01;
|
||
const KERB_PROBE = 0.3; // Abstand der Proben links und rechts einer Bordsteinlinie
|
||
export const MARKING_OFFSET = 0.01; // Markierung über der Fahrbahn
|
||
export const MARKING_COLORS = {
|
||
gelb_gestrichelt: [0.94, 0.75, 0.13],
|
||
gelb_doppelt: [0.94, 0.75, 0.13],
|
||
weiss_gestrichelt: [0.94, 0.94, 0.94],
|
||
weiss_rand: [0.94, 0.94, 0.94],
|
||
haltelinie: [0.94, 0.94, 0.94],
|
||
zebra: [0.94, 0.94, 0.94],
|
||
};
|
||
```
|
||
|
||
Dann die neuen Bauer, vor `buildChunkGeometry`:
|
||
|
||
```js
|
||
/** Liegt (x, z) in einer der Flächen? Gerade-ungerade über alle Ringe, ein Loch zählt also als
|
||
* außen. Die Flächen eines Chunks überlappen sich nicht, sie sind Stücke einer Vereinigung. */
|
||
function insideAny(x, z, polys) {
|
||
let inside = false;
|
||
for (const poly of polys) {
|
||
for (const ring of poly) {
|
||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||
const [xi, zi] = ring[i];
|
||
const [xj, zj] = ring[j];
|
||
if ((zi > z) !== (zj > z) && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside;
|
||
}
|
||
}
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
/** Auf welcher Seite einer Bordsteinlinie liegt die Fahrbahn: +1 in Richtung der Normale
|
||
* (-dz, dx), -1 gegenüber, 0, wenn es sich nicht entscheiden lässt.
|
||
*
|
||
* Die Laufrichtung der Linie sagt darüber nichts: emit.py legt jede Linie auf eine feste Richtung
|
||
* fest, damit die Ausgabe nicht vom Rechenweg abhängt. Ein Bordsteinstück ist aber ein
|
||
* zusammenhängendes Stück Fahrbahnrand, die Fahrbahn liegt also auf seiner ganzen Länge auf
|
||
* derselben Seite. Geprüft wird Abschnitt für Abschnitt, bis genau eine der beiden Proben in der
|
||
* Fahrbahn liegt — an der Chunkgrenze kann eine Probe jenseits des Chunks landen, wo es keine
|
||
* Fahrbahnfläche dieses Chunks gibt. */
|
||
function carriagewaySide(line, carriageway) {
|
||
for (let i = 0; i + 1 < line.length; i++) {
|
||
const [x0, z0] = line[i];
|
||
const [x1, z1] = line[i + 1];
|
||
const len = Math.hypot(x1 - x0, z1 - z0);
|
||
if (len < 1e-6) continue;
|
||
const mx = (x0 + x1) / 2, mz = (z0 + z1) / 2;
|
||
const px = (-(z1 - z0) / len) * KERB_PROBE, pz = ((x1 - x0) / len) * KERB_PROBE;
|
||
const links = insideAny(mx + px, mz + pz, carriageway);
|
||
const rechts = insideAny(mx - px, mz - pz, carriageway);
|
||
if (links !== rechts) return links ? 1 : -1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/** Bordstein: senkrechte Wand aus der Mulde bis knapp über die Beläge, dazu eine schmale
|
||
* Oberkante auf der Gehwegseite.
|
||
*
|
||
* Die Oberkante ist kein Schmuck: Ohne sie endet der Bordstein von oben gesehen als
|
||
* Messerschneide zwischen Gehwegbelag und Wand, und aus der Ferne verschwindet er ganz. Auf
|
||
* welche Seite sie gehört, bestimmt carriagewaySide() an den Fahrbahnflächen desselben Chunks —
|
||
* auf der Fahrbahnseite hinge sie als Sims über der Mulde. Lässt sich die Seite nicht bestimmen,
|
||
* entsteht nur die Wand, auf der Linie selbst. Die Wand bekommt in materials.js DoubleSide. */
|
||
export function kerbGeometry(lines, carriageway = [],
|
||
{ depth = CARRIAGEWAY_DEPTH, height = KERB_HEIGHT, top = KERB_TOP,
|
||
inset = KERB_INSET } = {}) {
|
||
const pos = [], idx = [], uv = [];
|
||
for (const line of lines) {
|
||
const side = carriagewaySide(line, carriageway);
|
||
for (let i = 0; i + 1 < line.length; i++) {
|
||
const [x0, z0] = line[i];
|
||
const [x1, z1] = line[i + 1];
|
||
const dx = x1 - x0, dz = z1 - z0;
|
||
const len = Math.hypot(dx, dz);
|
||
if (len < 1e-6) continue;
|
||
// Einheitsvektor zur Fahrbahn hin; (0, 0), wenn die Seite unbekannt ist.
|
||
const cx = (-dz / len) * side, cz = (dx / len) * side;
|
||
const ax = x0 + cx * inset, az = z0 + cz * inset;
|
||
const bx = x1 + cx * inset, bz = z1 + cz * inset;
|
||
const w = pos.length / 3;
|
||
pos.push(ax, -depth, az, bx, -depth, bz, bx, height, bz, ax, height, az);
|
||
uv.push(0, 0, len, 0, len, depth + height, 0, depth + height);
|
||
idx.push(w, w + 1, w + 2, w, w + 2, w + 3);
|
||
if (side === 0) continue;
|
||
const t = pos.length / 3;
|
||
pos.push(ax, height, az, bx, height, bz,
|
||
x1 - cx * top, height, z1 - cz * top, x0 - cx * top, height, z0 - cz * top);
|
||
uv.push(0, 0, len, 0, len, top + inset, 0, top + inset);
|
||
// Nach oben zeigen: Der Umlaufsinn des Vierecks hängt an der Seite.
|
||
if (side > 0) idx.push(t, t + 1, t + 2, t, t + 2, t + 3);
|
||
else idx.push(t, t + 2, t + 1, t, t + 3, t + 2);
|
||
}
|
||
}
|
||
return pos.length ? toGeometry(pos, idx, uv) : null;
|
||
}
|
||
|
||
/** Markierungen als flache Vierecke in einem Netz, Farbe je Art als Vertexfarbe.
|
||
*
|
||
* Über einer Brücke liegt die Fahrbahn im Deck verborgen: Sie liegt überall auf
|
||
* -CARRIAGEWAY_DEPTH, das Deck reicht bis BRIDGE_OFFSET. Eine Markierung, deren Eckpunkte alle in
|
||
* einer Brückenfläche desselben Chunks liegen, kommt deshalb auf das Deck. Liegt sie nur
|
||
* teilweise darauf, bleibt sie in der Mulde und verschwindet auf der Brücke im Deck — auf
|
||
* Deckhöhe schwebte ihr übriger Teil 17 cm über der Fahrbahn. */
|
||
export function markingGeometry(markings, bridges = []) {
|
||
const pos = [], idx = [], uv = [], color = [];
|
||
for (const mark of markings) {
|
||
const tint = MARKING_COLORS[mark.art] ?? MARKING_COLORS.weiss_rand;
|
||
const ring = clockwise(openRing(mark.poly[0] ?? []));
|
||
if (ring.length < 3) continue;
|
||
const onDeck = bridges.length > 0 && ring.every(([x, z]) => insideAny(x, z, bridges));
|
||
const y = (onDeck ? BRIDGE_OFFSET : -CARRIAGEWAY_DEPTH) + MARKING_OFFSET;
|
||
const base = pos.length / 3;
|
||
for (const [x, z] of ring) {
|
||
pos.push(x, y, z);
|
||
uv.push(x, z);
|
||
color.push(...tint);
|
||
}
|
||
const points = ring.map(([x, z]) => new Vector2(x, z));
|
||
for (const tri of ShapeUtils.triangulateShape(points, [])) {
|
||
idx.push(base + tri[0], base + tri[2], base + tri[1]);
|
||
}
|
||
}
|
||
return pos.length ? toGeometry(pos, idx, uv, color) : null;
|
||
}
|
||
```
|
||
|
||
`buildChunkGeometry` umbauen:
|
||
|
||
```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 ?? [], (chunk.carriageway ?? []).map((c) => c.poly)),
|
||
sidewalk: flatPolygons((chunk.sidewalk ?? []).map((s) => s.poly), { y: SURFACE_OFFSET, uvScale: 4 }),
|
||
markings: markingGeometry(chunk.markings ?? [], (chunk.bridges ?? []).map((b) => b.poly)),
|
||
lots: flatPolygons((chunk.lots ?? []).map((l) => l.poly), { y: SURFACE_OFFSET, uvScale: 8 }),
|
||
bridges: extrudePolygons((chunk.bridges ?? []).map((b) => b.poly),
|
||
{ base: -BRIDGE_THICKNESS, top: BRIDGE_OFFSET, uvScale: 8 }),
|
||
buildings: buildBuildings(chunk.buildings),
|
||
// Parkwege (docs/superpowers/specs/2026-09-15-flamingo-park-design.md): flach wie Straßen, Textur in
|
||
// echter Größe — Poly Haven misst concrete_pavement mit 1,8 × 1,8 m.
|
||
paths: flatPolygons((chunk.paths ?? []).map((path) => path.poly), { y: ROAD_OFFSET, uvScale: 1.8 }),
|
||
};
|
||
for (const kind of SURFACE_KINDS) {
|
||
const rings = (chunk.surfaces ?? []).filter((s) => s.kind === kind).map((s) => s.poly);
|
||
out[kind] = flatPolygons(rings, { y: SURFACE_OFFSET, uvScale: 8 });
|
||
}
|
||
return out;
|
||
}
|
||
```
|
||
|
||
- [ ] **Schritt 4: Test laufen lassen**
|
||
|
||
Ausführen: `npm test -- tests/geometry.test.js`
|
||
Erwartet: alle neuen bestehen. Alte Tests, die `chunk.roads` erwarten, schlagen fehl — die stellt Schritt 5 um.
|
||
|
||
- [ ] **Schritt 5: Die alten Geometrietests umstellen**
|
||
|
||
Jeden Test in `tests/geometry.test.js`, der `roads:` im Chunk setzt, auf die neuen Schlüssel umstellen: Ein Eintrag `{ poly, bridge: false }` wird zu `carriageway: [{ poly, klasse: "wohnstrasse" }]`, ein Eintrag `{ poly, bridge: true }` zu `bridges: [{ poly }]`, und `roads: [], centerlines: []` in den übrigen Fixtures entfällt. In „baut alle Teile eines Chunks" wird der Schlüssel `roads` in der Liste zu `carriageway`. Nichts an der Aussage der Tests ändern.
|
||
|
||
Ausführen: `npm test -- tests/geometry.test.js`
|
||
Erwartet: alle grün.
|
||
|
||
- [ ] **Schritt 6: Commit**
|
||
|
||
```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
|
||
|
||
> **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; das prüft der Test zu `chunkLampPoints` in Schritt 1.
|
||
>
|
||
> **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`. Schritt 7 gibt den neuen Wortlaut vor.
|
||
|
||
> **Vor dem Auftrag trocken erprobt** (Scratch-Worktree mit Task 13 und dieser Aufgabe, ganze JS-Suite grün). **Acht Fehler dieser Aufgabe sind hier schon berichtigt:**
|
||
> (1) Der erste Laternentest legte den Gehweg auf die Mittellinie selbst (x 4 bis 12, z −1 bis 1), gesucht wird aber senkrecht zu ihr. Der Test wäre mit richtigem Code rot gewesen.
|
||
> (2) „hängt nicht an der Listenposition" konnte nicht fehlschlagen: Mit diesem Gehweg fanden beide Reihenfolgen gar keine Laterne, und zwei leere Listen sind gleich. Jetzt mit Längenzusicherung und mit einem Gehweg nur auf einer Seite, an dem die alte Index-Logik scheitert.
|
||
> (3) Die Forderung aus dem Kasten oben — eine Laterne aus einem Chunk im neuen Format — hatte keinen Test. `chunkLampPoints(chunk)` zieht die Verdrahtung aus `#buildLamps` heraus, damit sie prüfbar ist.
|
||
> (4) `DECALS` ist in `streaming.js` nicht exportiert. Der Test prüft das Verhalten jetzt wie der bestehende Test daneben: über den Kollisionskörper und die Szene.
|
||
> (5) Die Tönung ließ `park: PARK_TINT` fallen — die grüne Tönung des Parks, für die sich der Nutzer in Teil 1 entschieden hat.
|
||
> (6) `FALLBACK_COLORS` hatte keine Farben für die neuen Schichten; fehlt eine Textur, bekäme three `color: undefined`.
|
||
> (7) Die Markierung sollte die oberste Versatzstufe bekommen (−11). Der Kommentar über `POLYGON_OFFSET_ORDER` warnt aber genau davor: Der Faktor wird beim streifenden Blick mit der Tiefensteigung multipliziert und zieht eine Fläche vor Geometrie, die in Wahrheit davor steht, und wächst die Liste, ist die Stufenhöhe zu entkoppeln. Die Markierung berührt keinen Belag, ihr einziger Nachbar ist die Fahrbahn ohne Versatz — sie bekommt deshalb einen eigenen kleinen Versatz außerhalb der Rangfolge. Die Rangfolge selbst wächst nur um eine Stufe (die Wege stehen auf −10); den streifenden Blick sieht die Browserprüfung nach Task 16 nach.
|
||
> (8) `tests/placesSite.test.js` benutzt `vergeLampPoints` und `chunk.roads` und stand nicht in der Dateiliste. Ohne Umstellung bricht er hier mit `vergeLampPoints is not a function`.
|
||
> Dazu fehlte `emit.py` im Commit, obwohl der Kasten oben dort eine Änderung verlangt.
|
||
|
||
**Dateien:**
|
||
- Ändern: `src/world/materials.js`, `src/world/streaming.js`, `src/world/props.js`, `tools/extract/leonida/emit.py` (nur der Docstring von `_line()`)
|
||
- Test: `tests/materials.test.js`, `tests/streaming.test.js`, `tests/props.test.js`, `tests/placesSite.test.js`
|
||
|
||
**Schnittstellen:**
|
||
- Verbraucht: die Teile aus Task 13.
|
||
- Liefert: `MATERIAL_KEYS` mit `carriageway`, `curb`, `sidewalk`, `markings`, `lots`, `bridges` statt `roads`; in `props.js` `LAMP_VERGE`, `sidewalkLampPoints(points, sidewalk)` und `chunkLampPoints(chunk)`.
|
||
|
||
- [ ] **Schritt 1: Die fehlschlagenden Tests schreiben**
|
||
|
||
An `tests/materials.test.js` anhängen:
|
||
|
||
```js
|
||
describe("Schichten aus Teil 2", () => {
|
||
it("hat für jede neue Schicht ein Material", () => {
|
||
for (const key of ["carriageway", "curb", "sidewalk", "markings", "lots"]) {
|
||
expect(MATERIAL_KEYS).toContain(key);
|
||
}
|
||
expect(MATERIAL_KEYS).not.toContain("roads");
|
||
});
|
||
|
||
it("nimmt Bordstein und Gehweg aus dem Kit", () => {
|
||
expect(TEXTURES.curb).toBe("curb");
|
||
expect(TEXTURES.sidewalk).toBe("sidewalk");
|
||
expect(TEXTURES.carriageway).toBe("asphalt_02");
|
||
expect(TEXTURES.lots).toBe("asphalt_02");
|
||
});
|
||
|
||
it("gibt der Markierung eine Farbe statt einer Textur", () => {
|
||
expect(SURFACE_COLORS.markings).toBeDefined();
|
||
expect(TEXTURES.markings).toBeUndefined();
|
||
});
|
||
|
||
it("gibt der Markierung einen eigenen kleinen Versatz statt der obersten Stufe der Beläge", () => {
|
||
// Ihr einziger Nachbar ist die Fahrbahn 1 cm darunter, und die hat keinen Versatz. Die oberste
|
||
// Stufe zöge sie beim streifenden Blick am weitesten nach vorn, ohne dass es etwas nützte.
|
||
expect(POLYGON_OFFSET_ORDER).not.toContain("markings");
|
||
expect(POLYGON_OFFSETS.markings).toEqual({ polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 });
|
||
// Fahrbahn und Bordstein liegen 15 cm tiefer und sind echte Geometrie — kein Versatz nötig.
|
||
expect(POLYGON_OFFSETS.carriageway).toBeUndefined();
|
||
expect(POLYGON_OFFSETS.curb).toBeUndefined();
|
||
});
|
||
});
|
||
```
|
||
|
||
In `tests/streaming.test.js`, im selben `describe` direkt nach dem Test „legt Parkwege und die neuen Flächenarten als Auflagen ohne Kollision in die Szene":
|
||
|
||
```js
|
||
// Teil 2: Fahrbahn und Bordstein sind Kollision — man fährt in der Mulde, und der Bordstein ist die
|
||
// Stufe zum Gehweg. Gehweg, Markierung und Restflächen bleiben Auflagen.
|
||
it("legt Fahrbahn und Bordstein in die Kollision, Gehweg, Markierung und Restfläche als Auflagen in die Szene", async () => {
|
||
const square = [[[0, 0], [20, 0], [20, 20], [0, 20]]];
|
||
const chunk = {
|
||
chunk: [4, 6], origin: [0, -1000], land: [square], surfaces: [], buildings: [],
|
||
carriageway: [{ poly: [[[0, 20], [20, 20], [20, 30], [0, 30]]], klasse: "wohnstrasse" }],
|
||
curb: [[[0, 20], [20, 20]]],
|
||
sidewalk: [{ poly: [[[0, 17], [20, 17], [20, 20], [0, 20]]] }],
|
||
markings: [{ poly: [[[2, 25], [5, 25], [5, 25.1], [2, 25.1]]], art: "gelb_gestrichelt" }],
|
||
lots: [{ poly: [[[0, 0], [5, 0], [5, 5], [0, 5]]] }],
|
||
};
|
||
const { streamer, player, scene } = makeStreamer(chunk);
|
||
streamer.update(emptyChunkCenter);
|
||
await flush();
|
||
|
||
expect(player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(3); // Landplatte, Fahrbahn, Bordstein
|
||
const group = scene.add.mock.calls[0][0];
|
||
expect(group.children.filter((child) => child.isMesh)).toHaveLength(3); // Gehweg, Markierung, Restfläche
|
||
});
|
||
```
|
||
|
||
In `tests/props.test.js` den ganzen Block `describe("vergeLampPoints", …)` durch diesen ersetzen. `vergeLampPoints` entfällt in Schritt 5; was seine Tests zusicherten — die Laterne steht neben der Fahrbahn, ohne Platz entfällt sie, geschlossene Ringe gehen — trägt der neue Block in der neuen Form weiter:
|
||
|
||
```js
|
||
describe("Laternen auf dem Gehweg", () => {
|
||
// Straße entlang der x-Achse, angle 0: Die Normale ist (0, 1). Der Gehweg liegt nur auf der Seite
|
||
// z > 0, 3,5 bis 6 m neben der Mittellinie — wie bei einer Wohnstraße mit 7 m Fahrbahn.
|
||
const gehweg = [{ poly: [[[-20, 3.5], [20, 3.5], [20, 6], [-20, 6]]] }];
|
||
|
||
it("stellt die Laterne auf den Gehweg, so dicht an die Bordsteinkante wie möglich", () => {
|
||
const out = sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], gehweg);
|
||
expect(out).toHaveLength(1);
|
||
expect(out[0].x).toBeCloseTo(0);
|
||
expect(out[0].z).toBeCloseTo(4); // der erste Abstand aus LAMP_VERGE, der auf dem Gehweg liegt
|
||
});
|
||
|
||
it("sucht auch auf der anderen Straßenseite", () => {
|
||
const gegenueber = [{ poly: [[[-20, -6], [20, -6], [20, -3.5], [-20, -3.5]]] }];
|
||
const out = sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], gegenueber);
|
||
expect(out).toHaveLength(1);
|
||
expect(out[0].z).toBeCloseTo(-4);
|
||
});
|
||
|
||
it("lässt eine Laterne weg, für die es keinen Gehweg gibt", () => {
|
||
expect(sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], [])).toEqual([]);
|
||
});
|
||
|
||
it("hängt nicht an der Listenposition", () => {
|
||
// Die alte Fassung wählte die Seite nach dem Index: Der zweite Punkt hätte auf der Seite ohne
|
||
// Gehweg gesucht und wäre entfallen.
|
||
const punkte = [{ x: -10, z: 0, angle: 0 }, { x: 10, z: 0, angle: 0 }];
|
||
const vorwaerts = sidewalkLampPoints(punkte, gehweg);
|
||
const rueckwaerts = sidewalkLampPoints([...punkte].reverse(), gehweg);
|
||
expect(vorwaerts).toHaveLength(2);
|
||
const xs = (out) => out.map((p) => p.x).sort((a, b) => a - b);
|
||
expect(xs(vorwaerts)).toEqual(xs(rueckwaerts));
|
||
});
|
||
|
||
it("findet den Gehweg auch, wenn sein Ring geschlossen ist (so liegen die Chunk-Dateien vor)", () => {
|
||
const ring = [[-20, 3.5], [20, 3.5], [20, 6], [-20, 6]];
|
||
expect(sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], [{ poly: [[...ring, ring[0]]] }])).toHaveLength(1);
|
||
});
|
||
|
||
it("setzt aus einem Chunk im Format von Teil 2 Laternen auf den Gehweg", () => {
|
||
// Wohnstraße entlang z = 50, Gehweg auf beiden Seiten; lampPoints() setzt auf 100 m drei Punkte.
|
||
const chunk = {
|
||
chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], buildings: [], palms: [],
|
||
carriageway: [], curb: [], markings: [], lots: [], bridges: [],
|
||
edges: [{ line: [[0, 50], [100, 50]], klasse: "wohnstrasse", breite: 12, fahrbahn: 7 }],
|
||
sidewalk: [{ poly: [[[0, 53.5], [100, 53.5], [100, 56], [0, 56]]] },
|
||
{ poly: [[[0, 44], [100, 44], [100, 46.5], [0, 46.5]]] }],
|
||
};
|
||
const out = chunkLampPoints(chunk);
|
||
expect(out).toHaveLength(3);
|
||
for (const p of out) expect(chunk.sidewalk.some((s) => pointInPolygon(p.x, p.z, s.poly))).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
Im Import oben in `tests/props.test.js` entfällt `vergeLampPoints`; `chunkLampPoints` und `sidewalkLampPoints` kommen dazu.
|
||
|
||
- [ ] **Schritt 2: Tests laufen lassen, Fehlschlag sehen**
|
||
|
||
Ausführen: `npm test`
|
||
Erwartet: die neuen Tests rot (etwa `sidewalkLampPoints is not a function`). Rot ist außerdem seit Task 13 „materials > deckt genau die Geometrie-Teile ab", weil `MATERIAL_KEYS` noch `roads` enthält — der wird mit Schritt 3 grün.
|
||
|
||
- [ ] **Schritt 3: `materials.js` umstellen**
|
||
|
||
`DoubleSide` zum Import aus `three` hinzufügen.
|
||
|
||
```js
|
||
export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "field", "court", "pool", "parking",
|
||
"paths", "carriageway", "curb", "sidewalk", "markings", "lots",
|
||
"bridges", "buildings"];
|
||
```
|
||
|
||
In `TEXTURES` die Zeile `roads: "asphalt_02",` ersetzen durch:
|
||
|
||
```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",
|
||
```
|
||
|
||
Die beiden Kommentare in `TEXTURES`, die noch `roads` nennen, nachziehen: „klont park und roads" wird „klont park und carriageway"; der Kommentar zu Ruling 56 über `bridges` sagt nur noch, dass eine Brücke dasselbe Straßenstück über Wasser ist und deshalb dieselbe Textur wie die Fahrbahn trägt — `roads.filter(r => r.bridge)` gibt es nicht mehr.
|
||
|
||
```js
|
||
// Die Markierung trägt ihre Farbe je Art als Vertexfarbe in der Geometrie; das Material ist nur der Träger.
|
||
export const SURFACE_COLORS = { court: 0x6f8c79, pool: 0x3f9fd8, markings: 0xffffff };
|
||
```
|
||
|
||
In `FALLBACK_COLORS` die Zeile mit `roads` ersetzen durch:
|
||
|
||
```js
|
||
carriageway: 0x505050, lots: 0x505050, curb: 0xbdbdbd, sidewalk: 0xd8d2c4,
|
||
bridges: 0xb0b0b0, buildings: 0xe8e4dc,
|
||
```
|
||
|
||
```js
|
||
const CLONES = { field: { from: "park", tint: FIELD_TINT }, parking: { from: "carriageway" } };
|
||
```
|
||
|
||
`POLYGON_OFFSET_ORDER` und `POLYGON_OFFSETS` neu:
|
||
|
||
```js
|
||
export const POLYGON_OFFSET_ORDER = ["ground", "urban", "park", "sand", "field", "court", "pool",
|
||
"parking", "lots", "sidewalk", "paths"];
|
||
|
||
export const POLYGON_OFFSETS = {
|
||
...Object.fromEntries(
|
||
POLYGON_OFFSET_ORDER.map((key, i) => [
|
||
key,
|
||
{ polygonOffset: true, polygonOffsetFactor: -i, polygonOffsetUnits: -i },
|
||
]),
|
||
),
|
||
markings: { polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 },
|
||
};
|
||
```
|
||
|
||
Den Kommentarblock über `POLYGON_OFFSET_ORDER` um diesen Absatz ergänzen:
|
||
|
||
```js
|
||
// Teil 2: Gehweg und Restflächen liegen als Beläge auf SURFACE_OFFSET und reihen sich unter den
|
||
// Wegen ein. Fahrbahn und Bordstein brauchen keinen Versatz, weil sie 15 cm tiefer als echte
|
||
// Geometrie liegen und mit nichts koplanar sind. Die Markierung steht nicht in der Rangfolge: Sie
|
||
// liegt 1 cm über der Fahrbahn und berührt keinen Belag, ihr einziger Nachbar hat Versatz 0. Die
|
||
// oberste Stufe zöge sie beim streifenden Blick am weitesten nach vorn, ohne dass es etwas nützte,
|
||
// deshalb ein eigener Versatz von -1. Die Rangfolge wächst damit nur um eine Stufe; die Wege
|
||
// stehen jetzt auf -10.
|
||
```
|
||
|
||
In `createMaterials`:
|
||
|
||
```js
|
||
const tints = { carriageway: ROAD_TINT, lots: ROAD_TINT, bridges: ROAD_TINT, park: PARK_TINT };
|
||
```
|
||
|
||
und direkt nach `materials.buildings.vertexColors = true;`:
|
||
|
||
```js
|
||
materials.markings.vertexColors = true; // Farbe je Markierungsart, s. geometry.js MARKING_COLORS
|
||
materials.curb.side = DoubleSide; // welche Seite der Wand außen liegt, weiß die Linie nicht
|
||
```
|
||
|
||
- [ ] **Schritt 4: `streaming.js` umstellen**
|
||
|
||
```js
|
||
const DECALS = new Set(["sand", "park", "urban", "field", "court", "pool", "parking",
|
||
"paths", "sidewalk", "markings", "lots"]);
|
||
```
|
||
|
||
Darüber ein Kommentar: Fahrbahn und Bordstein sind seit Teil 2 Kollisionsgeometrie — die Fahrbahn, weil man in der Mulde fährt, der Bordstein, weil er die Stufe zum Gehweg ist. Gemessen wurde, dass die Kapsel 15 und 25 cm ohne Halt erklimmt und erst bei 40 cm blockiert. Den Kommentar in `#load` („Straßen und Bodenflächen sind reine Auflagen; Kollision trägt die Landplatte.") entsprechend nachziehen: Auflagen sind Beläge, Wege, Gehweg, Markierung und Restflächen; Kollision tragen Landplatte, Fahrbahn, Bordstein, Brücken und Gebäude.
|
||
|
||
- [ ] **Schritt 5: `props.js` umstellen**
|
||
|
||
`LAMP_VERGE` 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];
|
||
```
|
||
|
||
`vergeLampPoints` samt Docstring durch diese beiden Funktionen ersetzen:
|
||
|
||
```js
|
||
/**
|
||
* Laternen auf den Gehweg.
|
||
*
|
||
* Vorher wählte vergeLampPoints() die Straßenseite über den Listenindex (gerade links, ungerade
|
||
* rechts) und verwarf, was auf der Fahrbahn landete. Das hing an der Reihenfolge der Mittellinien in
|
||
* der Datei — beim Sortieren der Ausgabe wechselten 1.848 von 3.921 Laternen die Seite, weshalb die
|
||
* Mittellinien als einzige Schicht unsortiert bleiben mussten. Jetzt entscheidet die Gehwegfläche:
|
||
* Senkrecht zur Blickrichtung (Normale zu (cos angle, sin angle) ist (-sin angle, cos angle)) wird
|
||
* erst links, dann rechts von innen nach außen durch LAMP_VERGE probiert, und wo kein Gehweg ist,
|
||
* steht keine Laterne. Das Ergebnis hängt an der Geometrie statt an der Listenposition.
|
||
*/
|
||
export function sidewalkLampPoints(points, sidewalk) {
|
||
const out = [];
|
||
for (const p of points) {
|
||
let placed = null;
|
||
for (const side of [1, -1]) {
|
||
const nx = -Math.sin(p.angle) * side, nz = Math.cos(p.angle) * side;
|
||
for (const verge of LAMP_VERGE) {
|
||
const x = p.x + nx * verge, z = p.z + nz * verge;
|
||
if (sidewalk.some((s) => pointInPolygon(x, z, s.poly))) {
|
||
placed = { x, z, angle: p.angle };
|
||
break;
|
||
}
|
||
}
|
||
if (placed) break;
|
||
}
|
||
if (placed) out.push(placed);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Die Laternen eines Chunks: im Abstand von lampPoints() entlang der Straßenkanten, jede auf den
|
||
* Gehweg daneben. Eine eigene Funktion, damit ein Test prüfen kann, dass aus einem Chunk im Format
|
||
* von Teil 2 wirklich Laternen entstehen — nach dem Formatwechsel las #buildLamps Schlüssel, die es
|
||
* nicht mehr gab, und setzte ohne jeden Fehler keine einzige Laterne.
|
||
*/
|
||
export function chunkLampPoints(chunk) {
|
||
return sidewalkLampPoints(lampPoints((chunk.edges ?? []).map((e) => e.line)), chunk.sidewalk ?? []);
|
||
}
|
||
```
|
||
|
||
In `#buildLamps` die beiden ersten Zeilen durch `const points = chunkLampPoints(chunk);` ersetzen und im Docstring „Laternen entlang der Mittellinien (lampPoints), seitlich zur Fahrbahnkante versetzt (vergeLampPoints, …)" durch „Laternen auf dem Gehweg entlang der Straßenkanten (chunkLampPoints)" ersetzen. Im Docstring von `pointInPolygon` wird aus „wie chunk.roads[i].poly, Task 6" „wie chunk.sidewalk[i].poly".
|
||
|
||
- [ ] **Schritt 6: Bestehende Tests umstellen**
|
||
|
||
`tests/materials.test.js`:
|
||
- `expect(TEXTURES.parking).toBe(TEXTURES.roads)` → `TEXTURES.carriageway`
|
||
- `expect(Object.keys(SURFACE_COLORS).sort()).toEqual(["court", "pool"])` → `["court", "markings", "pool"]`
|
||
- `expect(TEXTURES.bridges).toBe(TEXTURES.roads)` → `TEXTURES.carriageway`, der Kommentar darüber ohne `roads.filter(r => r.bridge)`
|
||
- die erwartete `POLYGON_OFFSET_ORDER` → `["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "lots", "sidewalk", "paths"]`, der Testname entsprechend („… < Parkplatz < Restfläche < Gehweg < Weg")
|
||
- im Test zu `applyPolygonOffsets` die Platzhalterschlüssel auf `["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "lots", "sidewalk", "paths", "markings", "carriageway", "curb", "bridges", "buildings", "water", "seafloor"]` und die unberührten auf `["carriageway", "curb", "bridges", "buildings", "water", "seafloor"]`
|
||
|
||
`tests/placesSite.test.js`:
|
||
|
||
```js
|
||
import { chunkLampPoints, pointInPolygon } from "../src/world/props.js";
|
||
```
|
||
|
||
```js
|
||
// Straße ist seit Teil 2 alles, was früher der Korridor war: Fahrbahn, Gehweg und Restflächen.
|
||
const onRoad = [...(c.carriageway ?? []), ...(c.sidewalk ?? []), ...(c.lots ?? [])]
|
||
.some((road) => pointInPolygon(u, v, road.poly));
|
||
```
|
||
|
||
```js
|
||
const lamps = chunkLampPoints(c).map((p) => toWorld(p.x, p.z));
|
||
```
|
||
|
||
und im Kommentar über dem Laternentest „bis 16 m neben ihrer Mittellinie" → „bis 12 m neben ihrer Straßenkante (LAMP_VERGE)". Dieser Test liest die committeten Chunks, und die liegen bis Task 16 im alten Format vor: Straße und Laternen prüft er deshalb erst mit den neuen Daten wieder wirklich. Task 16 muss ihn mit den neuen Daten grün sehen.
|
||
|
||
- [ ] **Schritt 7: Docstring von `_line()` nachziehen**
|
||
|
||
In `tools/extract/leonida/emit.py`:
|
||
|
||
```python
|
||
"""Ein Linienzug in Chunk-Koordinaten, richtungsunabhängig festgelegt.
|
||
|
||
normalize() dreht eine Linie in eine feste Laufrichtung, damit dieselbe Linie unabhängig vom
|
||
Rechenweg gleich in der Datei steht. Zulässig ist das, weil kein Verbraucher aus Richtung oder
|
||
Listenposition etwas ableitet: Die Laternen (sidewalkLampPoints() in props.js) suchen ihre
|
||
Straßenseite an der Gehwegfläche, und die Oberkante des Bordsteins (kerbGeometry() in
|
||
geometry.js) findet ihre Seite an der Fahrbahnfläche. Bei den früheren centerlines war das
|
||
nicht so, deshalb blieben sie unsortiert und unnormalisiert.
|
||
"""
|
||
```
|
||
|
||
- [ ] **Schritt 8: Alle Tests**
|
||
|
||
```bash
|
||
npm test
|
||
cd tools/extract && PYTHONUTF8=1 uv run pytest -q
|
||
```
|
||
|
||
Erwartet: beide grün, pytest mit derselben Zahl wie vorher (nur ein Docstring geändert).
|
||
|
||
- [ ] **Schritt 9: Commit**
|
||
|
||
```bash
|
||
cd ../.. && git status --short
|
||
git add src/world/materials.js src/world/streaming.js src/world/props.js tools/extract/leonida/emit.py \
|
||
tests/materials.test.js tests/streaming.test.js tests/props.test.js tests/placesSite.test.js
|
||
git commit -m "feat(world): Materialien, Kollision und Laternen für die neuen Straßen"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Minimap und Begehbarkeit
|
||
|
||
> **Seit Task 10 gebrochen, hier zu reparieren:** `overview.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 (Schritt 4). Das gehört zur Abnahme, nicht zu einer späteren Aufräumrunde.
|
||
|
||
> **Vor dem Auftrag nachgesehen, drei Punkte sind hier schon berichtigt:**
|
||
> (1) Die Erwartungen der Geometrie-Abnahme stammten von vor dem Flamingo Park und vor drei Fixrunden. Jetzt stehen dort die Zahlen, die der Controller mit der Kette aus Task 12 gemessen hat, und dasselbe Messskript wie in Task 12.
|
||
> (2) Der Entwurf verlangt unter „Tests" einen Integrationslauf auf den echten Daten, der Kantenzahl je Klasse, Markierungszahl und Restflächenzahl festhält. Keine Aufgabe hatte ihn; er entsteht in Schritt 6, sobald die Daten feststehen. Die Kreuzungszahl steht in keinem Chunk, weil keine Knoten ausgeliefert werden — sie kommt aus der Zeile „Straßennetz" in die Messwerte des Entwurfs, nicht in den Test.
|
||
> (3) Zwei Tests lesen die committeten Chunks und prüfen die neuen Schichten erst mit den neuen Daten wirklich: der übersprungene Minimap-Abgleich und `tests/placesSite.test.js` (seit Task 14 gegen Fahrbahn, Gehweg, Restflächen und `chunkLampPoints`). Beide müssen nach dem Neuerzeugen grün sein.
|
||
|
||
**Dateien:**
|
||
- Erzeugt: `public/data/vice-a/*`
|
||
- Neu: `tools/extract/tests/test_committed_roads.py`
|
||
- Ändern: `tools/extract/tests/test_overview.py` (Skip entfernen), `docs/superpowers/specs/2026-09-16-strassen-design.md` (Messwerte)
|
||
|
||
**Schnittstellen:**
|
||
- Verbraucht: alles.
|
||
- Liefert: die ausgelieferte Welt, einen Test, der sie festhält, und die Zahlen für den Entwurf.
|
||
|
||
- [ ] **Schritt 1: Beide Suiten**
|
||
|
||
```bash
|
||
cd tools/extract && PYTHONUTF8=1 uv run pytest -q
|
||
cd ../.. && npm test
|
||
```
|
||
Erwartet: beide grün (pytest mit einem übersprungenen Test). Ist etwas rot: nicht weitermachen.
|
||
|
||
- [ ] **Schritt 2: Welt erzeugen**
|
||
|
||
```bash
|
||
cd tools/extract && PYTHONUTF8=1 uv run python run.py
|
||
```
|
||
Erwartet: Exit 0 nach rund vier Minuten, die Zeilen „Straßennetz: 1537 Knoten, 1790 Kanten", „Straßen: 830 Bordsteinstücke, 171 Restflächen, 22394 Markierungen {…}, 56 Palmen an der Fahrbahn entfernt", „Flamingo Park: …" und **genau eine** Warnung (`Zufahrt schneidet die Sperrfläche tribuene`). Die ganze Ausgabe in den Bericht.
|
||
|
||
- [ ] **Schritt 3: Abnahme der Lauf-Stabilität**
|
||
|
||
```bash
|
||
cd /a/developer/GitHub-desktop/gta6 && git status --short
|
||
git add public/data/vice-a && git commit -m "chore(data): Welt mit Bordstein, Gehweg und Markierung neu erzeugt"
|
||
cd tools/extract && PYTHONUTF8=1 uv run python run.py
|
||
cd ../.. && git status --short
|
||
```
|
||
Erwartet: vor dem Commit nur Dateien unter `public/data/vice-a` und die beiden Kit-Ordner; nach dem zweiten Lauf **keine einzige geänderte Datei**, nur die beiden Kit-Ordner als `??`. Ist das nicht so, ist die Ausgabe nicht lauf-stabil — melden, nicht schönreden.
|
||
|
||
- [ ] **Schritt 4: Skip entfernen, beide Suiten mit den neuen Daten**
|
||
|
||
In `tools/extract/tests/test_overview.py` den `@pytest.mark.skip(...)` vor `test_committed_map_files_match_the_committed_chunks` entfernen, dazu den Satz im Docstring, der das Überspringen erklärt.
|
||
|
||
```bash
|
||
cd tools/extract && PYTHONUTF8=1 uv run pytest -q
|
||
cd ../.. && npm test
|
||
```
|
||
Erwartet: beide grün, pytest ohne übersprungenen Test, `tests/placesSite.test.js` grün. Wird etwas rot, ist das ein Befund über die neuen Daten: melden, nicht den Test anpassen.
|
||
|
||
```bash
|
||
git add tools/extract/tests/test_overview.py
|
||
git commit -m "test(extract): Minimap-Abgleich mit den neuen Daten wieder scharf"
|
||
```
|
||
|
||
- [ ] **Schritt 5: Abnahme der Geometrie**
|
||
|
||
```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 gemessen (dieselben Zahlen wie in Task 12, Schritt 5):
|
||
|
||
| Größe | Erwartet |
|
||
|---|---|
|
||
| Fahrbahn | **0,9084 km²** |
|
||
| Gehweg | **0,2983 km²** |
|
||
| Restflächen | **0,0973 km²** |
|
||
| Bordstein | **120,61 km** |
|
||
| Markierungsstücke je Art | je Art mindestens so viele wie in der Zeile „Straßen:" aus Schritt 2; `gelb_gestrichelt` und `weiss_gestrichelt` nicht 0 |
|
||
| Kantenstücke je Klasse | alle vier Klassen besetzt |
|
||
| Palmen auf der Fahrbahn | **0** |
|
||
|
||
Zur Einordnung: Heute gilt die ganze Korridorfläche von 1,316 km² als Straße. Weicht eine Fläche oder die Bordsteinlänge um mehr als 1 % ab, ist das ein Befund — melden, nicht die Erwartung anpassen. Die ganze Ausgabe in den Bericht; die Stückzahlen braucht Schritt 6.
|
||
|
||
- [ ] **Schritt 6: Die ausgelieferten Straßen festhalten**
|
||
|
||
`tools/extract/tests/test_committed_roads.py`:
|
||
|
||
```python
|
||
"""Die ausgelieferten Straßen, festgehalten (Entwurf, Abschnitt Tests: Integrationslauf).
|
||
|
||
Der Test liest die committeten Chunks, nicht den Lauf — die Kacheln liegen nicht im Repository.
|
||
Die Zahlen sind beim Neuerzeugen gemessen. Ändert sich die Extraktion absichtlich, gehören die neuen
|
||
Zahlen hierher, mit einem Satz im Commit, warum. Die Kreuzungszahl fehlt, weil die Chunks keine
|
||
Knoten tragen; sie steht in den Messwerten des Entwurfs.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from collections import Counter
|
||
from pathlib import Path
|
||
|
||
from shapely.geometry import LineString, Point, Polygon
|
||
|
||
ROOT = Path(__file__).resolve().parents[3] / "public" / "data" / "vice-a"
|
||
|
||
|
||
def _chunks() -> list[dict]:
|
||
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(ROOT.glob("chunk_*.json"))]
|
||
|
||
|
||
def _flaeche(chunks, key) -> float:
|
||
return sum(Polygon(i["poly"][0], i["poly"][1:]).area for c in chunks for i in c.get(key, []))
|
||
|
||
|
||
def test_kantenstuecke_je_klasse():
|
||
assert dict(Counter(e["klasse"] for c in _chunks() for e in c["edges"])) == {
|
||
# die vier Zahlen aus „Kantenstücke je Klasse", Schritt 5
|
||
}
|
||
|
||
|
||
def test_markierungsstuecke_je_art():
|
||
assert dict(Counter(m["art"] for c in _chunks() for m in c["markings"])) == {
|
||
# die sechs Zahlen aus „Markierungsstücke je Art", Schritt 5
|
||
}
|
||
|
||
|
||
def test_restflaechen_und_bordsteinstuecke():
|
||
chunks = _chunks()
|
||
assert sum(len(c["lots"]) for c in chunks) == ... # „lots" aus „Stücke, je Chunk gezählt"
|
||
assert sum(len(c["curb"]) for c in chunks) == ... # „curb" aus „Stücke, je Chunk gezählt"
|
||
|
||
|
||
def test_flaechen_und_bordsteinlaenge():
|
||
chunks = _chunks()
|
||
assert round(_flaeche(chunks, "carriageway") / 1e6, 4) == ... # aus „Flächen km²"
|
||
assert round(_flaeche(chunks, "sidewalk") / 1e6, 4) == ...
|
||
assert round(_flaeche(chunks, "lots") / 1e6, 4) == ...
|
||
laenge = sum(LineString(line).length for c in chunks for line in c["curb"])
|
||
assert round(laenge / 1e3, 2) == ... # aus „Bordstein km"
|
||
|
||
|
||
def test_keine_palme_auf_der_fahrbahn():
|
||
for c in _chunks():
|
||
fahrbahn = [Polygon(i["poly"][0], i["poly"][1:]) for i in c["carriageway"]]
|
||
auf = [p for p in c["palms"] if any(f.contains(Point(p)) for f in fahrbahn)]
|
||
assert auf == [], f"Chunk {c['chunk']}"
|
||
```
|
||
|
||
Die Platzhalter (`{…}`-Kommentare und `...`) mit den **genauen** Zahlen aus Schritt 5 füllen — sie werden hier festgehalten, nicht geschätzt. Dann:
|
||
|
||
```bash
|
||
cd tools/extract && PYTHONUTF8=1 uv run pytest tests/test_committed_roads.py -q
|
||
```
|
||
Erwartet: 5 bestanden. Gegenprobe, damit der Test nicht nur sich selbst bestätigt: eine der Zahlen vorübergehend um 1 ändern, rot sehen, zurückändern — Ausgabe in den Bericht.
|
||
|
||
- [ ] **Schritt 7: Messwerte in den Entwurf**
|
||
|
||
Im Abschnitt „Fehlerfälle und Risiken" von `docs/superpowers/specs/2026-09-16-strassen-design.md` einen Punkt „Gemessen im Spiel" ergänzen: die Zahlen aus Schritt 5, die Zeilen des Laufs aus Schritt 2 (mit Knoten- und Kantenzahl), das Ergebnis der Abnahmen aus Schritt 3 und 4 und die Zeit aus Task 1.
|
||
|
||
- [ ] **Schritt 8: Commit**
|
||
|
||
```bash
|
||
cd ../.. && git status --short
|
||
git add docs/superpowers/specs/2026-09-16-strassen-design.md tools/extract/tests/test_committed_roads.py
|
||
git commit -m "test(extract): ausgelieferte Straßen festhalten, Messwerte in den Entwurf"
|
||
```
|
||
|
||
- [ ] **Schritt 9: Dem Controller übergeben**
|
||
|
||
Die Prüfung im Browser macht der Controller selbst, weil dafür der Browser, eine Fahrt über den Bordstein und eine Abstimmung mit dem Nutzer nötig sind. In den Bericht gehören: die Ausgaben aus Schritt 2 und 5, alle Abnahmen, die Testzahlen und alles, was offen blieb.
|
||
|
||
---
|
||
|
||
## Selbstprüfung des Plans
|
||
|
||
**Abdeckung des Entwurfs**
|
||
|
||
| Abschnitt des Entwurfs | Task |
|
||
|---|---|
|
||
| Das Netz (Graph, Verschmelzen, Fransen, Glätten, Breite, Klasse) | 3, 4 |
|
||
| Querschnitte | 5 |
|
||
| Geometrie: Mulde | 7, 13 |
|
||
| Geometrie: Kreuzungen | 6 |
|
||
| Geometrie: Brücken | 7 (kein Bordstein), 13 (eigener Schlüssel) |
|
||
| Geometrie: Restflächen | 7 |
|
||
| Geometrie: Kollision | 14 |
|
||
| Markierungen längs | 8 |
|
||
| Markierungen an Kreuzungen | 9 |
|
||
| Texturen aus dem Kit | 2 |
|
||
| Chunk-Format | 10 |
|
||
| Darstellung im Browser | 13, 14 |
|
||
| Laternen auf dem Gehweg | 14 |
|
||
| Minimap und Begehbarkeit | 15 |
|
||
| Zusammenspiel mit Teil 1 | 11 |
|
||
| Ablauf | 12 |
|
||
| Risiko „gelöcherte Bodenplatte" | 1 |
|
||
| Risiko „Auto in der Mulde" | Controller, Browserprüfung nach Task 16 |
|
||
| Risiko „alle Chunks ändern sich" | 16, Schritt 3 |
|
||
| Namensnennung | 12 (Manifest) |
|
||
| Tests: Integrationslauf auf den echten Daten | 16 (Schritt 6) |
|
||
|
||
**Offene Punkte, die der Controller entscheidet**
|
||
|
||
- Die Fahrt mit dem Auto über den Bordstein (Risiko 2 des Entwurfs) und die Farbabstimmung.
|
||
- Ob `roads`/`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.
|