Files
gta6/docs/superpowers/plans/2026-09-13-feinkalibrierung.md
T
knsoandClaude Opus 5 2f38e8f445 docs: Umsetzungsplan für die Feinkalibrierung
Sieben Aufgaben: Korrekturdatei lesen und prüfen, Korrekturen je Ebene
anwenden, Laternen in die Pipeline, einzelner Chunk vergrößert, Einhängen
in run.py mit Vorher/Nachher-Probe, Browser liest chunk.lamps.
Vor dem Commit in einer Kopie komplett durchgespielt, inklusive echtem
Pipeline-Lauf (22 s, 2.591 Laternen) und Mutationsproben der neuen Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:42:11 +02:00

1727 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Feinkalibrierung Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eine dauerhafte Korrekturschicht in der Extraktionspipeline, über die der Nutzer im Chat Gebäude, Straßen, Flächen und Küste sowie Palmen und Laternen Chunk für Chunk korrigieren kann.
**Architecture:** `tools/extract/leonida/corrections.py` liest `data/corrections.toml`, prüft jede Korrektur auf Formfehler und wendet sie in fester Ebenen-Reihenfolge auf die Weltgeometrie an, bevor die Chunks entstehen. Die Laternen ziehen dafür aus dem Browser nach `tools/extract/leonida/lamps.py` und kommen als `lamps` in die Chunk-Nutzlast. `overview.py --chunk` rendert einzelne Chunks vergrößert für den Vorher/Nachher-Vergleich.
**Tech Stack:** Python 3.12 über uv mit shapely 2.1.2, Pillow, pytest; `tomllib` aus der Standardbibliothek · three 0.184.0, vitest
**Spec:** `docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md`
## Global Constraints
- **Ausführungsschranke:** Dieser Plan beginnt erst, wenn die Beschriftungs-Aufgabe task_999fed4a gelandet ist. Sie ändert in einer anderen Sitzung die Pipeline. Aufgabe 6 ändert `tools/extract/run.py` und muss die Datei vorher neu lesen.
- Python-Bibliotheken: numpy, scipy, scikit-image, shapely, networkx, pillow, pytest. TOML über `tomllib` (Standardbibliothek). **Keine neue Abhängigkeit**, auch nicht in JavaScript.
- Koordinaten in Korrekturen sind **Welt-x und Welt-y in Metern**, wie die Minimap sie anzeigt. Nie three.js-z.
- Chunk-Namen A1–L12: Spalte aus `i` (West → Ost), Zeile aus `j` (Nord → Süd). A1 = `chunk_0_0`, B9 = `chunk_1_8` = Welt x −750…−500, y 250…500.
- Formfehler brechen mit `CorrectionError("Korrektur N: …")` ab. Inhaltliche Nichttreffer (verwaist, falscher Chunk, nicht auf Land) werden übersprungen und gemeldet.
- Ebenen-Reihenfolge: `area` → `surface` → `road` → `building` → Laternen setzen → `props`. Innerhalb einer Ebene Dateireihenfolge.
- Laternen-Drehwinkel: `heading = atan2(-(y1 - y0), x1 - x0)` entlang der Mittellinie in Weltkoordinaten. Das ist genau der Wert, den der Browser heute als `rotation.y = atan2(dz, dx)` in three-Koordinaten setzt.
- Laternenregeln: Abstand 35 m, erste bei `min(17,5 m, Linienlänge / 2)`, Versätze 6/8/10/12/14/16 m, Seite je Linie abwechselnd beginnend links, mindestens 0,5 m von der Fahrbahn, mindestens 0,5 m von Gebäuden, auf Land.
- **Niemals** `git add three-player-controller`, **niemals** `git add -A`. Commits nur mit ausdrücklichen Pfaden: `git commit -m "…" -- <pfade>`.
- Testausgabe muss sauber sein. Python: `cd tools/extract && uv run pytest -q`. JavaScript: `npm test`.
---
### Task 1: Chunk-Zuordnung in Python und Korrekturdatei lesen und prüfen
**Files:**
- Modify: `tools/extract/leonida/coords.py`
- Create: `tools/extract/leonida/corrections.py`
- Create: `tools/extract/data/corrections.toml`
- Test: `tools/extract/tests/test_coords.py`, `tools/extract/tests/test_corrections_load.py`
**Interfaces:**
- Consumes: `SLICE`, `CHUNK_SIZE`, `chunk_name`, `parse_chunk_name` aus `leonida/coords.py`
- Produces:
- `chunk_for_world(x: float, y: float) -> tuple[int, int]` in `leonida/coords.py`
- in `leonida/corrections.py`: `class CorrectionError(ValueError)`, `@dataclass(frozen=True) class Correction(index: int, chunk: str, action: str, fields: dict, reason: str)` mit Eigenschaft `layer -> str`, `ACTIONS: dict[str, tuple[layer, required, optional]]`, `LAYER_ORDER`, `SURFACE_KINDS`, `PROP_KINDS`, `parse_corrections(text: str) -> list[Correction]`, `load_corrections(path: Path) -> list[Correction]`
- [ ] **Step 1: Die fehlschlagenden Tests schreiben**
In `tools/extract/tests/test_coords.py` die Importzeile ersetzen durch:
```python
from leonida.coords import SLICE, chunk_for_world, chunk_name, parse_chunk_name, pixel_to_world, slice_tiles, tile_for_world
```
und anfügen:
```python
def test_chunk_for_world_matches_javascript():
# Dieselben Beispiele wie tests/coords.test.js: Ecken des Ausschnitts und der Downtown-Kern in B9.
assert chunk_for_world(-1000, 2500) == (0, 0)
assert chunk_for_world(1999, -499) == (11, 11)
assert chunk_for_world(-511, 254) == (1, 8)
assert chunk_name(*chunk_for_world(-511, 254)) == "B9"
```
`tools/extract/tests/test_corrections_load.py`:
```python
import pytest
from leonida.corrections import ACTIONS, CorrectionError, load_corrections, parse_corrections
def test_missing_file_and_comment_only_file_mean_no_corrections(tmp_path):
assert load_corrections(tmp_path / "fehlt.toml") == []
(tmp_path / "leer.toml").write_text("# nur Kommentare\n")
assert load_corrections(tmp_path / "leer.toml") == []
def test_all_spec_actions_are_known():
assert set(ACTIONS) == {"remove_building", "set_building_height", "add_building", "remove_road",
"set_bridge", "add_road", "set_surface", "set_area", "remove_props",
"add_palm", "add_lamp"}
def test_entries_parse_with_their_fields_and_layers():
text = """
[[correction]]
chunk = "c10"
action = "remove_building"
at = [412, 1830]
reason = "Beschriftung"
[[correction]]
chunk = "B9"
action = "add_building"
at = [-490.5, 270]
size = [20, 30]
height = 15
rotation = 45
[[correction]]
chunk = "B9"
action = "add_road"
from = [-700, 300]
to = [-600, 300]
width = 10
[[correction]]
chunk = "B9"
action = "set_area"
polygon = [[-700, 300], [-650, 300], [-650, 350]]
to = "water"
[[correction]]
chunk = "B9"
action = "remove_props"
at = [-700, 300]
radius = 5
kinds = ["lamp"]
"""
parsed = parse_corrections(text)
assert [c.index for c in parsed] == [1, 2, 3, 4, 5]
assert parsed[0].chunk == "C10"
assert parsed[0].reason == "Beschriftung"
assert parsed[1].fields["rotation"] == 45
assert parsed[2].fields["from"] == [-700, 300]
assert [c.layer for c in parsed] == ["building", "building", "road", "area", "props"]
@pytest.mark.parametrize("body, message", [
('action = "fly"\nat = [1, 2]', "unbekannte action 'fly'"),
('action = "remove_building"', "Feld 'at' fehlt"),
('action = "set_building_height"\nat = [1, 2]\nhight = 20', "Feld 'hight' gibt es bei set_building_height nicht"),
('action = "remove_building"\nat = "412, 1830"', "Feld 'at' muss"),
('action = "remove_building"\nat = [true, 2]', "Feld 'at' muss"),
('action = "set_building_height"\nat = [1, 2]\nheight = 0', "Feld 'height' muss"),
('action = "set_area"\npolygon = [[0, 0], [1, 1]]\nto = "water"', "Feld 'polygon' muss"),
('action = "set_area"\npolygon = [[0, 0], [10, 10], [10, 0], [0, 10]]\nto = "water"', "Feld 'polygon' muss"),
('action = "set_area"\npolygon = [[0, 0], [10, 0], [10, 10]]\nto = "lava"', "Feld 'to' muss"),
('action = "remove_props"\nat = [1, 2]\nradius = 3\nkinds = ["palm", "tree"]', "Feld 'kinds' muss"),
('action = "add_road"\nfrom = [1, 2]\nto = [1, 2]\nwidth = 8', "from und to sind derselbe Punkt"),
('action = "remove_building"\nat = [1, 2]\nreason = 7', "reason muss Text sein"),
])
def test_form_errors_stop_with_entry_number_and_field(body, message):
text = ('[[correction]]\nchunk = "B9"\naction = "remove_building"\nat = [1, 2]\n\n'
f'[[correction]]\nchunk = "B9"\n{body}\n')
with pytest.raises(CorrectionError, match="Korrektur 2") as err:
parse_corrections(text)
assert message in str(err.value)
def test_invalid_chunk_name_is_a_form_error():
with pytest.raises(CorrectionError, match="Korrektur 1: chunk 'M1'"):
parse_corrections('[[correction]]\nchunk = "M1"\naction = "remove_building"\nat = [1, 2]\n')
def test_broken_toml_and_unknown_sections_are_form_errors():
with pytest.raises(CorrectionError, match="kein gültiges TOML"):
parse_corrections("[[correction]\n")
with pytest.raises(CorrectionError, match="unbekannter Abschnitt"):
parse_corrections('[[korrektur]]\nchunk = "B9"\n')
```
- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern**
Run: `cd tools/extract && uv run pytest tests/test_coords.py tests/test_corrections_load.py -q`
Expected: FAIL — `ImportError: cannot import name 'chunk_for_world'` und `ModuleNotFoundError: No module named 'leonida.corrections'`.
- [ ] **Step 3: `chunk_for_world` an `tools/extract/leonida/coords.py` anfügen**
```python
def chunk_for_world(x: float, y: float) -> tuple[int, int]:
"""Spiegel von chunkForWorld in src/world/coords.js: i nach Osten, j von der Nordkante nach Süden."""
return int((x - SLICE["x0"]) // CHUNK_SIZE), int((SLICE["y1"] - y) // CHUNK_SIZE)
```
- [ ] **Step 4: `tools/extract/leonida/corrections.py` anlegen**
```python
"""Feinkalibrierung: dauerhafte Korrekturen an der extrahierten Welt.
Spec: docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md
Koordinaten sind Welt-x und Welt-y in Metern, genau wie die Minimap sie anzeigt.
Zwei Arten von Problemen werden unterschiedlich behandelt: ein Formfehler in der Datei
(Tippfehler) bricht mit CorrectionError ab, damit keine gewollte Korrektur still verschwindet;
eine formal richtige Korrektur, die inhaltlich nichts trifft, wird gemeldet und übersprungen.
"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
from shapely.geometry import Polygon
from .coords import chunk_name, parse_chunk_name
SURFACE_KINDS = ("sand", "park", "urban")
PROP_KINDS = ("palm", "lamp")
LAYER_ORDER = ("area", "surface", "road", "building", "props")
class CorrectionError(ValueError):
"""Formfehler in corrections.toml: der Lauf bricht ab."""
def _number(value) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _positive(value) -> bool:
return _number(value) and value > 0
def _point(value) -> bool:
return isinstance(value, list) and len(value) == 2 and all(_number(c) for c in value)
def _polygon(value) -> bool:
if not (isinstance(value, list) and len(value) >= 3 and all(_point(p) for p in value)):
return False
shape = Polygon(value)
return shape.is_valid and shape.area > 0
def _kinds(value) -> bool:
return (isinstance(value, list) and len(value) > 0 and len(set(value)) == len(value)
and all(kind in PROP_KINDS for kind in value))
# Feld -> (erwartete Form für die Meldung, Prüffunktion)
POINT = ("ein Punkt [x, y]", _point)
POSITIVE = ("eine Zahl größer als 0", _positive)
NUMBER = ("eine Zahl", _number)
BOOL = ("true oder false", lambda v: isinstance(v, bool))
SIZE = ("[breite, tiefe] mit Zahlen größer als 0", lambda v: isinstance(v, list) and len(v) == 2 and all(_positive(c) for c in v))
KIND = ("sand, park oder urban", lambda v: v in SURFACE_KINDS)
AREA_TARGET = ("land oder water", lambda v: v in ("land", "water"))
POLYGON = ("ein Vieleck aus mindestens 3 Punkten ohne sich schneidende Kanten", _polygon)
KINDS = ("eine Liste aus palm und lamp", _kinds)
# action -> (Ebene, Pflichtfelder, optionale Felder)
ACTIONS = {
"set_area": ("area", {"polygon": POLYGON, "to": AREA_TARGET}, {}),
"set_surface": ("surface", {"at": POINT, "kind": KIND}, {}),
"remove_road": ("road", {"at": POINT}, {}),
"set_bridge": ("road", {"at": POINT, "bridge": BOOL}, {}),
"add_road": ("road", {"from": POINT, "to": POINT, "width": POSITIVE}, {}),
"remove_building": ("building", {"at": POINT}, {}),
"set_building_height": ("building", {"at": POINT, "height": POSITIVE}, {}),
"add_building": ("building", {"at": POINT, "size": SIZE, "height": POSITIVE}, {"rotation": NUMBER}),
"remove_props": ("props", {"at": POINT, "radius": POSITIVE}, {"kinds": KINDS}),
"add_palm": ("props", {"at": POINT}, {}),
"add_lamp": ("props", {"at": POINT}, {}),
}
@dataclass(frozen=True)
class Correction:
index: int # 1-basiert, wie in allen Meldungen
chunk: str # normalisiert, etwa "C10"
action: str
fields: dict
reason: str = ""
@property
def layer(self) -> str:
return ACTIONS[self.action][0]
def load_corrections(path: Path) -> list[Correction]:
"""Korrekturen aus der Datei; fehlt sie, gibt es keine."""
if not path.exists():
return []
return parse_corrections(path.read_text())
def parse_corrections(text: str) -> list[Correction]:
try:
data = tomllib.loads(text)
except tomllib.TOMLDecodeError as err:
raise CorrectionError(f"corrections.toml ist kein gültiges TOML: {err}") from err
unknown = sorted(set(data) - {"correction"})
if unknown:
raise CorrectionError(f"corrections.toml: unbekannter Abschnitt {unknown[0]!r}, erwartet [[correction]]")
entries = data.get("correction", [])
if not isinstance(entries, list):
raise CorrectionError("corrections.toml: Korrekturen müssen als [[correction]] geschrieben sein")
return [_parse_entry(index, entry) for index, entry in enumerate(entries, start=1)]
def _parse_entry(index: int, entry) -> Correction:
def fail(message: str):
raise CorrectionError(f"Korrektur {index}: {message}")
if not isinstance(entry, dict):
fail("ist keine Tabelle")
action = entry.get("action")
if action not in ACTIONS:
fail(f"unbekannte action {action!r}")
chunk = entry.get("chunk")
parsed = parse_chunk_name(chunk) if isinstance(chunk, str) else None
if parsed is None:
fail(f"chunk {chunk!r} ist kein Name zwischen A1 und L12")
reason = entry.get("reason", "")
if not isinstance(reason, str):
fail("reason muss Text sein")
_, required, optional = ACTIONS[action]
allowed = {"chunk", "action", "reason", *required, *optional}
for key in entry:
if key not in allowed:
fail(f"Feld {key!r} gibt es bei {action} nicht")
fields = {}
for key, (expected, check) in {**required, **optional}.items():
if key not in entry:
if key in required:
fail(f"Feld {key!r} fehlt ({expected})")
continue
if not check(entry[key]):
fail(f"Feld {key!r} muss {expected} sein, ist {entry[key]!r}")
fields[key] = entry[key]
if action == "add_road" and fields["from"] == fields["to"]:
fail("from und to sind derselbe Punkt")
return Correction(index, chunk_name(*parsed), action, fields, reason)
```
- [ ] **Step 5: Die leere Korrekturdatei anlegen**
`tools/extract/data/corrections.toml`:
```toml
# Feinkalibrierung — dauerhafte Korrekturen an der extrahierten Welt.
# Format und alle Aktionen: docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md
# Koordinaten sind Welt-x und Welt-y in Metern, genau wie die Minimap sie anzeigt.
#
# Beispiel:
# [[correction]]
# chunk = "C10"
# action = "remove_building"
# at = [412, 1830]
# reason = "Beschriftung STARFISH ISLAND, kein Gebäude"
```
- [ ] **Step 6: Tests laufen lassen, sie müssen bestehen; dann die Suite**
Run: `cd tools/extract && uv run pytest tests/test_coords.py tests/test_corrections_load.py -q && uv run pytest -q`
Expected: PASS, Ausgabe sauber.
- [ ] **Step 7: Commit**
```bash
git add tools/extract/leonida/coords.py tools/extract/leonida/corrections.py tools/extract/data/corrections.toml tools/extract/tests/test_coords.py tools/extract/tests/test_corrections_load.py
git commit -m "feat(extract): Korrekturdatei lesen und auf Formfehler prüfen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" -- tools/extract/leonida/coords.py tools/extract/leonida/corrections.py tools/extract/data/corrections.toml tools/extract/tests/test_coords.py tools/extract/tests/test_corrections_load.py
```
---
### Task 2: Korrekturen anwenden — Prüfregeln und Gebäude
**Files:**
- Modify: `tools/extract/leonida/corrections.py`
- Test: `tools/extract/tests/test_corrections_apply.py`
**Interfaces:**
- Consumes: alles aus Task 1; `chunk_for_world` aus `leonida/coords.py`
- Produces in `leonida/corrections.py`:
- `@dataclass class World(land, surfaces, roads, lines, buildings, heights, palms, lamps=[])` mit `surfaces: [(Polygon, kind)]`, `roads: [(Polygon, bridge)]`, `lines: [LineString]`, `buildings: [(Polygon, speculative)]`, `heights: [{"height": float, "source": str}]` parallel zu `buildings`, `palms: [(x, y)]`, `lamps: [(x, y, heading)]`
- `@dataclass class Skip(correction: Correction, why: str)` · `@dataclass class Report(applied: int, skipped: list[Skip])` mit `format() -> str`
- `apply_corrections(world: World, corrections: list[Correction], place_lamps=None) -> Report` — `place_lamps(world) -> list[(x, y, heading)]` wird zwischen der Gebäude- und der Requisitenebene aufgerufen
- [ ] **Step 1: Die fehlschlagenden Tests schreiben**
`tools/extract/tests/test_corrections_apply.py`:
```python
import pytest
from shapely.geometry import LineString, Polygon, box
from leonida.corrections import World, apply_corrections, parse_corrections
def _world() -> World:
"""Testwelt in Chunk B9 (Welt x −750…−500, y 250…500). Chunk B8 darüber ist Wasser."""
courtyard = Polygon([(-600, 300), (-560, 300), (-560, 340), (-600, 340)],
[[(-590, 310), (-570, 310), (-570, 330), (-590, 330)]])
return World(
land=[box(-750, 250, -500, 500)],
surfaces=[(box(-740, 460, -700, 490), "park")],
roads=[(box(-750, 380, -500, 390), False)],
lines=[LineString([(-750, 385), (-500, 385)])],
buildings=[(box(-700, 300, -680, 320), False), (box(-650, 300, -630, 320), False), (courtyard, False)],
heights=[{"height": 10.0, "source": "district"}, {"height": 12.0, "source": "district"},
{"height": 14.0, "source": "district"}],
palms=[(-720, 420), (-520, 420)],
)
def _apply(world: World, toml: str):
return apply_corrections(world, parse_corrections(toml))
def test_remove_building_removes_only_the_building_under_the_point():
world = _world()
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "remove_building"\nat = [-690, 310]\n')
assert report.applied == 1 and report.skipped == []
assert len(world.buildings) == 2
assert world.buildings[0][0].equals(box(-650, 300, -630, 320))
assert world.heights == [{"height": 12.0, "source": "district"}, {"height": 14.0, "source": "district"}]
def test_point_in_a_courtyard_hits_no_building():
world = _world()
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "remove_building"\nat = [-580, 320]\n')
assert report.applied == 0
assert len(world.buildings) == 3
assert report.skipped[0].why == "trifft kein Gebäude"
def test_set_building_height_marks_the_source_as_correction():
world = _world()
_apply(world, '[[correction]]\nchunk = "B9"\naction = "set_building_height"\nat = [-640, 310]\nheight = 180\n')
assert world.heights[1] == {"height": 180.0, "source": "correction"}
assert world.heights[0] == {"height": 10.0, "source": "district"}
def test_add_building_builds_a_rotated_rectangle_on_land():
world = _world()
_apply(world, '[[correction]]\nchunk = "B9"\naction = "add_building"\n'
'at = [-600, 450]\nsize = [20, 10]\nheight = 25\nrotation = 90\n')
footprint, speculative = world.buildings[-1]
assert speculative is False
assert footprint.area == pytest.approx(200)
minx, miny, maxx, maxy = footprint.bounds
assert maxx - minx == pytest.approx(10) # um 90 Grad gedreht: die Breite liegt jetzt entlang y
assert maxy - miny == pytest.approx(20)
assert world.heights[-1] == {"height": 25.0, "source": "correction"}
def test_add_building_on_water_is_skipped():
world = _world()
report = _apply(world, '[[correction]]\nchunk = "B8"\naction = "add_building"\n'
'at = [-600, 600]\nsize = [10, 10]\nheight = 5\n')
assert report.applied == 0
assert report.skipped[0].why == "liegt nicht auf Land"
assert len(world.buildings) == 3
def test_a_point_outside_the_named_chunk_is_skipped_and_names_the_real_chunk():
world = _world()
report = _apply(world, '[[correction]]\nchunk = "C9"\naction = "remove_building"\nat = [-690, 310]\n')
assert report.applied == 0
assert report.skipped[0].why == "liegt nicht in C9, sondern in B9"
assert len(world.buildings) == 3
def test_a_skipped_correction_does_not_stop_the_next_one():
world = _world()
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "remove_building"\nat = [-580, 320]\n\n'
'[[correction]]\nchunk = "B9"\naction = "remove_building"\nat = [-640, 310]\n')
assert report.applied == 1 and len(report.skipped) == 1
assert len(world.buildings) == 2
def test_report_lists_every_skip_with_number_chunk_place_and_reason():
world = _world()
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "remove_building"\n'
'at = [-580, 320]\nreason = "Innenhof getroffen"\n')
text = report.format()
assert "Korrekturen: 0 angewendet, 1 übersprungen" in text
assert "#1 B9 remove_building bei [-580, 320]: trifft kein Gebäude — Innenhof getroffen" in text
```
- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern**
Run: `cd tools/extract && uv run pytest tests/test_corrections_apply.py -q`
Expected: FAIL — `ImportError: cannot import name 'World'`.
- [ ] **Step 3: Importe in `corrections.py` erweitern**
Den Importblock ersetzen durch:
```python
from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from shapely import affinity
from shapely.geometry import Point, Polygon, box
from .coords import chunk_for_world, chunk_name, parse_chunk_name
```
- [ ] **Step 4: Weltmodell, Prüfregeln, Gebäude-Aktionen und `apply_corrections` ans Ende von `corrections.py` anfügen**
```python
@dataclass
class World:
"""Die extrahierte Welt in Weltkoordinaten, so wie run.py sie vor der Chunk-Ausgabe hält."""
land: list # [Polygon]
surfaces: list # [(Polygon, kind)]
roads: list # [(Polygon, bridge)]
lines: list # [LineString]
buildings: list # [(Polygon, speculative)]
heights: list # [{"height": float, "source": str}], parallel zu buildings
palms: list # [(x, y)]
lamps: list = field(default_factory=list) # [(x, y, heading)]
@dataclass
class Skip:
correction: Correction
why: str
@dataclass
class Report:
applied: int = 0
skipped: list = field(default_factory=list)
def format(self) -> str:
lines = [f"Korrekturen: {self.applied} angewendet, {len(self.skipped)} übersprungen"]
for skip in self.skipped:
c = skip.correction
# Beim Vieleck steht der Schwerpunkt da, derselbe Punkt, den die Chunk-Prüfung nimmt.
where = c.fields.get("at") or c.fields.get("from") or [round(v, 1) for v in _anchor(c)]
reason = f" — {c.reason}" if c.reason else ""
lines.append(f" #{c.index} {c.chunk} {c.action} bei {where}: {skip.why}{reason}")
return "\n".join(lines)
def _anchor(c: Correction) -> tuple[float, float]:
"""Der Punkt, der im genannten Chunk liegen muss."""
if c.action == "set_area":
centroid = Polygon(c.fields["polygon"]).centroid
return centroid.x, centroid.y
if c.action == "add_road":
(x0, y0), (x1, y1) = c.fields["from"], c.fields["to"]
return (x0 + x1) / 2, (y0 + y1) / 2
x, y = c.fields["at"]
return x, y
def _on_land(world: World, x: float, y: float) -> bool:
point = Point(x, y)
return any(poly.contains(point) for poly in world.land)
def _containing(items: list, x: float, y: float) -> list[int]:
"""Indizes der Einträge (Polygon, …), deren Polygon den Punkt enthält — Löcher zählen nicht."""
point = Point(x, y)
return [k for k, item in enumerate(items) if item[0].contains(point)]
def _remove_building(world: World, c: Correction) -> str | None:
hits = _containing(world.buildings, *c.fields["at"])
if not hits:
return "trifft kein Gebäude"
for k in reversed(hits):
del world.buildings[k]
del world.heights[k]
return None
def _set_building_height(world: World, c: Correction) -> str | None:
hits = _containing(world.buildings, *c.fields["at"])
if not hits:
return "trifft kein Gebäude"
for k in hits:
world.heights[k] = {"height": float(c.fields["height"]), "source": "correction"}
return None
def _add_building(world: World, c: Correction) -> str | None:
x, y = c.fields["at"]
if not _on_land(world, x, y):
return "liegt nicht auf Land"
width, depth = c.fields["size"]
# Breite entlang Welt-x, Tiefe entlang Welt-y; gedreht gegen den Uhrzeigersinn, Norden oben.
footprint = affinity.rotate(box(x - width / 2, y - depth / 2, x + width / 2, y + depth / 2),
c.fields.get("rotation", 0), origin=(x, y))
world.buildings.append((footprint, False))
world.heights.append({"height": float(c.fields["height"]), "source": "correction"})
return None
# action -> Funktion(world, correction) -> None wenn angewendet, sonst Grund der Warnung
_APPLY = {
"remove_building": _remove_building,
"set_building_height": _set_building_height,
"add_building": _add_building,
}
def apply_corrections(world: World, corrections: list[Correction], place_lamps=None) -> Report:
"""Wendet die Korrekturen Ebene für Ebene an und meldet, was übersprungen wurde.
Die Reihenfolge nach Ebene macht das Ergebnis unabhängig von der Dateireihenfolge.
place_lamps(world) setzt die Laternen nach den Gebäude- und vor den Requisitenkorrekturen,
damit Laternen weder in entfernte Straßen fallen noch für remove_props unerreichbar sind.
"""
report = Report()
for layer in LAYER_ORDER:
if layer == "props" and place_lamps is not None:
world.lamps = place_lamps(world)
for c in corrections:
if c.layer != layer:
continue
actual = chunk_name(*chunk_for_world(*_anchor(c)))
if actual != c.chunk:
report.skipped.append(Skip(c, f"liegt nicht in {c.chunk}, sondern in {actual or 'keinem Chunk'}"))
continue
why = _APPLY[c.action](world, c)
if why is None:
report.applied += 1
else:
report.skipped.append(Skip(c, why))
return report
```
- [ ] **Step 5: Tests laufen lassen, sie müssen bestehen; dann die Suite**
Run: `cd tools/extract && uv run pytest tests/test_corrections_apply.py -q && uv run pytest -q`
Expected: PASS, Ausgabe sauber.
- [ ] **Step 6: Commit**
```bash
git add tools/extract/leonida/corrections.py tools/extract/tests/test_corrections_apply.py
git commit -m "feat(extract): Korrekturen anwenden, Prüfregeln und Gebäude
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" -- tools/extract/leonida/corrections.py tools/extract/tests/test_corrections_apply.py
```
---
### Task 3: Korrekturen anwenden — Straßen, Beläge und Küste
**Files:**
- Modify: `tools/extract/leonida/corrections.py`
- Test: `tools/extract/tests/test_corrections_apply.py`
**Interfaces:**
- Consumes: `World`, `apply_corrections`, `_containing`, `_on_land`, `_APPLY` aus Task 2
- Produces: Aktionen `remove_road`, `set_bridge`, `add_road`, `set_surface`, `set_area` in `_APPLY`; Konstante `MIN_PIECE_M = 1.0`
- [ ] **Step 1: Die fehlschlagenden Tests an `tests/test_corrections_apply.py` anfügen**
```python
def test_remove_road_removes_the_piece_and_cuts_its_centerline():
world = _world()
world.roads.append((box(-750, 250, -740, 380), False)) # zweites Stück, bleibt
world.lines = [LineString([(-760, 385), (-490, 385)])] # ragt beidseits über das Stück
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "remove_road"\nat = [-600, 385]\n')
assert report.applied == 1
assert [road[0].bounds for road in world.roads] == [(-750.0, 250.0, -740.0, 380.0)]
assert sorted(round(line.length, 3) for line in world.lines) == [10.0, 10.0]
def test_remove_road_that_hits_nothing_is_skipped():
world = _world()
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "remove_road"\nat = [-600, 450]\n')
assert report.skipped[0].why == "trifft keine Straße"
def test_set_bridge_changes_the_flag():
world = _world()
_apply(world, '[[correction]]\nchunk = "B9"\naction = "set_bridge"\nat = [-600, 385]\nbridge = true\n')
assert world.roads[0][1] is True
def test_add_road_builds_a_strip_with_a_centerline():
world = _world()
_apply(world, '[[correction]]\nchunk = "B9"\naction = "add_road"\nfrom = [-700, 270]\nto = [-600, 270]\nwidth = 8\n')
strip, bridge = world.roads[-1]
assert bridge is False
assert strip.area == pytest.approx(800)
assert world.lines[-1].length == pytest.approx(100)
def test_add_road_may_cross_water_when_both_ends_are_on_land():
world = _world()
world.land = [box(-750, 250, -690, 500), box(-560, 250, -500, 500)] # Wasserlücke dazwischen
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "add_road"\nfrom = [-720, 270]\nto = [-530, 270]\nwidth = 8\n')
assert report.applied == 1
def test_add_road_with_an_end_in_the_water_is_skipped():
world = _world()
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "add_road"\nfrom = [-700, 270]\nto = [-600, 600]\nwidth = 8\n')
assert report.skipped[0].why == "Anfang oder Ende liegt nicht auf Land"
def test_set_surface_changes_the_kind():
world = _world()
_apply(world, '[[correction]]\nchunk = "B9"\naction = "set_surface"\nat = [-720, 470]\nkind = "sand"\n')
assert world.surfaces[0][1] == "sand"
def test_set_surface_that_hits_nothing_is_skipped():
world = _world()
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "set_surface"\nat = [-600, 450]\nkind = "sand"\n')
assert report.skipped[0].why == "trifft keine Belagsfläche"
def test_set_area_to_water_cuts_land_and_removes_what_stands_mostly_inside():
world = _world()
# Vieleck bis x = −598: A liegt ganz darin, B zu 60 % (12 von 20 m), C zu 40 % (8 von 20 m).
world.buildings = [(box(-700, 300, -680, 320), False), (box(-610, 300, -590, 320), False),
(box(-606, 350, -586, 370), False)]
world.heights = [{"height": 10.0, "source": "district"}, {"height": 11.0, "source": "district"},
{"height": 12.0, "source": "district"}]
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "set_area"\n'
'polygon = [[-750, 250], [-598, 250], [-598, 500], [-750, 500]]\nto = "water"\n')
assert report.applied == 1
assert [round(b[0].bounds[0]) for b in world.buildings] == [-606]
assert world.heights == [{"height": 12.0, "source": "district"}]
assert world.roads == [] # 152 von 250 m im Vieleck
assert [round(line.length) for line in world.lines] == [98]
assert world.palms == [(-520, 420)]
assert world.surfaces == []
assert sum(poly.area for poly in world.land) == pytest.approx(98 * 250)
def test_set_area_to_land_is_applied_before_a_building_on_it_even_if_listed_later():
world = _world()
report = _apply(world, '[[correction]]\nchunk = "B8"\naction = "add_building"\n'
'at = [-600, 600]\nsize = [10, 10]\nheight = 5\n\n'
'[[correction]]\nchunk = "B8"\naction = "set_area"\n'
'polygon = [[-650, 550], [-550, 550], [-550, 650], [-650, 650]]\nto = "land"\n')
assert report.applied == 2
assert len(world.buildings) == 4
def test_file_order_does_not_change_the_result():
# Die neue Straße steht vor dem Wasser in der Datei. In Dateireihenfolge angewendet käme sie
# durch (beide Enden noch an Land) und bliebe stehen, weil nur 20 % von ihr im Vieleck liegen.
# Nach Ebenen angewendet kommt zuerst das Wasser, ihr westliches Ende liegt darin, sie entfällt.
entries = [
'[[correction]]\nchunk = "B9"\naction = "add_road"\nfrom = [-720, 270]\nto = [-520, 270]\nwidth = 8\n',
'[[correction]]\nchunk = "B9"\naction = "set_area"\n'
'polygon = [[-750, 250], [-680, 250], [-680, 500], [-750, 500]]\nto = "water"\n',
'[[correction]]\nchunk = "B9"\naction = "add_building"\nat = [-550, 450]\nsize = [10, 10]\nheight = 5\n',
]
forward, backward = _world(), _world()
forward_report = _apply(forward, "\n".join(entries))
_apply(backward, "\n".join(reversed(entries)))
assert len(forward.roads) == 1
assert forward_report.skipped[0].why == "Anfang oder Ende liegt nicht auf Land"
assert [r[0].wkt for r in forward.roads] == [r[0].wkt for r in backward.roads]
assert [b[0].wkt for b in forward.buildings] == [b[0].wkt for b in backward.buildings]
assert [line.wkt for line in forward.lines] == [line.wkt for line in backward.lines]
assert sum(p.area for p in forward.land) == pytest.approx(sum(p.area for p in backward.land))
```
- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern**
Run: `cd tools/extract && uv run pytest tests/test_corrections_apply.py -q`
Expected: FAIL — `KeyError: 'remove_road'` bzw. `'set_area'`.
- [ ] **Step 3: Importe in `corrections.py` erweitern**
Den Importblock ersetzen durch:
```python
from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from shapely import affinity
from shapely.geometry import LineString, Point, Polygon, box
from shapely.ops import unary_union
from .coords import chunk_for_world, chunk_name, parse_chunk_name
```
- [ ] **Step 4: Die Konstante und die Aktionen einfügen**
Unter `LAYER_ORDER = …` einfügen:
```python
MIN_PIECE_M = 1.0 # Reststücke von Mittellinien (m) und Flächen (m²) darunter entfallen
```
Direkt vor `# action -> Funktion(world, correction) …` einfügen:
```python
def _cut_lines(lines: list, area) -> list:
"""Mittellinien am Gebiet abschneiden; Reststücke unter MIN_PIECE_M entfallen."""
out = []
for line in lines:
rest = line.difference(area) if line.intersects(area) else line
for part in getattr(rest, "geoms", [rest]):
if part.geom_type == "LineString" and part.length >= MIN_PIECE_M:
out.append(part)
return out
def _pieces(geometry) -> list:
"""Die Polygone einer Geometrie, ohne Splitter unter MIN_PIECE_M."""
return [g for g in getattr(geometry, "geoms", [geometry])
if g.geom_type == "Polygon" and g.area >= MIN_PIECE_M]
def _remove_road(world: World, c: Correction) -> str | None:
hits = _containing(world.roads, *c.fields["at"])
if not hits:
return "trifft keine Straße"
for k in reversed(hits):
piece = world.roads[k][0]
del world.roads[k]
# Mittellinien laufen über mehrere Straßenstücke; bliebe dieser Abschnitt stehen,
# setzte die Laternensuche Laternen an eine Straße, die es nicht mehr gibt.
world.lines = _cut_lines(world.lines, piece)
return None
def _set_bridge(world: World, c: Correction) -> str | None:
hits = _containing(world.roads, *c.fields["at"])
if not hits:
return "trifft keine Straße"
for k in hits:
world.roads[k] = (world.roads[k][0], c.fields["bridge"])
return None
def _add_road(world: World, c: Correction) -> str | None:
(x0, y0), (x1, y1) = c.fields["from"], c.fields["to"]
# Beide Enden müssen an Land liegen, nicht der Mittelpunkt — sonst ließe sich keine Brücke
# über Wasser hinzufügen.
if not (_on_land(world, x0, y0) and _on_land(world, x1, y1)):
return "Anfang oder Ende liegt nicht auf Land"
axis = LineString([(x0, y0), (x1, y1)])
world.roads.append((axis.buffer(c.fields["width"] / 2, cap_style="flat"), False))
world.lines.append(axis)
return None
def _set_surface(world: World, c: Correction) -> str | None:
hits = _containing(world.surfaces, *c.fields["at"])
if not hits:
return "trifft keine Belagsfläche"
for k in hits:
world.surfaces[k] = (world.surfaces[k][0], c.fields["kind"])
return None
def _set_area(world: World, c: Correction) -> str | None:
area = Polygon(c.fields["polygon"])
if c.fields["to"] == "land":
world.land = _pieces(unary_union([*world.land, area]))
return None
def mostly_inside(poly) -> bool:
return poly.intersection(area).area > 0.5 * poly.area
world.land = [piece for poly in world.land for piece in _pieces(poly.difference(area))]
world.surfaces = [(piece, kind) for poly, kind in world.surfaces for piece in _pieces(poly.difference(area))]
world.roads = [(poly, bridge) for poly, bridge in world.roads if not mostly_inside(poly)]
kept = [(building, height) for building, height in zip(world.buildings, world.heights)
if not mostly_inside(building[0])]
world.buildings = [building for building, _ in kept]
world.heights = [height for _, height in kept]
world.lines = _cut_lines(world.lines, area)
world.palms = [palm for palm in world.palms if not area.contains(Point(palm))]
return None
```
Das Wörterbuch `_APPLY` ersetzen durch:
```python
_APPLY = {
"set_area": _set_area,
"set_surface": _set_surface,
"remove_road": _remove_road,
"set_bridge": _set_bridge,
"add_road": _add_road,
"remove_building": _remove_building,
"set_building_height": _set_building_height,
"add_building": _add_building,
}
```
- [ ] **Step 5: Tests laufen lassen, sie müssen bestehen; dann die Suite**
Run: `cd tools/extract && uv run pytest tests/test_corrections_apply.py -q && uv run pytest -q`
Expected: PASS, Ausgabe sauber.
- [ ] **Step 6: Commit**
```bash
git add tools/extract/leonida/corrections.py tools/extract/tests/test_corrections_apply.py
git commit -m "feat(extract): Korrekturen für Straßen, Beläge und Küste
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" -- tools/extract/leonida/corrections.py tools/extract/tests/test_corrections_apply.py
```
---
### Task 4: Laternen in der Pipeline
**Files:**
- Modify: `tools/extract/leonida/heights.py` (`_verge_point` → `verge_point` mit Parametern)
- Create: `tools/extract/leonida/lamps.py`
- Modify: `tools/extract/leonida/emit.py` (`chunk_payload` bekommt `lamps`)
- Test: `tools/extract/tests/test_lamps.py`, `tools/extract/tests/test_emit.py`
**Interfaces:**
- Consumes: `PALM_VERGE`, `PALM_KERB`, `PALM_CLEARANCE`, `palm_points` aus `leonida/heights.py`
- Produces:
- `verge_point(line, p, side, roads, buildings, land, *, offsets=PALM_VERGE, kerb=PALM_KERB, clearance=PALM_CLEARANCE) -> list[tuple[float, float]]` in `leonida/heights.py`
- in `leonida/lamps.py`: `LAMP_SPACING = 35.0`, `LAMP_OFFSETS`, `LAMP_KERB = 0.5`, `LAMP_BUILDING_CLEARANCE = 0.5`, `heading_along(line, distance) -> float`, `lamp_points(lines, road_polys, building_polys, land_polys) -> list[tuple[float, float, float]]`
- `chunk_payload(i, j, *, land, surfaces, roads, centerlines, buildings, palms, lamps=()) -> dict` mit `"lamps": [[x, z, heading], …]`
- [ ] **Step 1: Die fehlschlagenden Tests schreiben**
`tools/extract/tests/test_lamps.py`:
```python
import math
import pytest
from shapely.geometry import LineString, Point, box
from leonida.heights import PALM_CLEARANCE, PALM_KERB, PALM_VERGE, verge_point
from leonida.lamps import LAMP_KERB, LAMP_SPACING, heading_along, lamp_points
LAND = [box(-100, -100, 400, 100)]
def test_palm_defaults_are_unchanged_by_the_generalisation():
line = LineString([(0, 0), (200, 0)])
road = box(0, -9, 200, 9)
p = line.interpolate(50)
default = verge_point(line, p, "left", road, None, None)
explicit = verge_point(line, p, "left", road, None, None,
offsets=PALM_VERGE, kerb=PALM_KERB, clearance=PALM_CLEARANCE)
assert default == explicit == [(50.0, 12.0)]
def test_first_lamp_sits_at_half_spacing_and_then_every_35_m():
assert LAMP_SPACING == 35
lamps = lamp_points([LineString([(0, 0), (100, 0)])], [box(0, -5, 100, 5)], [], LAND)
assert [round(x, 1) for x, _, _ in lamps] == [17.5, 52.5, 87.5]
def test_lamps_alternate_sides_along_one_line_and_stay_off_the_carriageway():
road = box(0, -5, 100, 5)
lamps = lamp_points([LineString([(0, 0), (100, 0)])], [road], [], LAND)
assert [y > 0 for _, y, _ in lamps] == [True, False, True]
assert all(road.distance(Point(x, y)) >= LAMP_KERB for x, y, _ in lamps)
def test_no_lamp_stands_inside_a_building():
road = box(0, -5, 100, 5)
house = box(10, 5.2, 30, 40) # direkt am Nordrand: alle sechs Versätze der ersten Laterne fallen hinein
lamps = lamp_points([LineString([(0, 0), (100, 0)])], [road], [house], LAND)
assert len(lamps) == 2 # die erste Laterne entfällt, die zweite (Süden) und dritte bleiben
assert all(house.distance(Point(x, y)) >= 0.5 for x, y, _ in lamps)
def test_no_stack_where_two_opposite_arms_meet():
arms = [LineString([(0, 0), (60, 0)]), LineString([(0, 0), (-60, 0)])]
lamps = lamp_points(arms, [box(-60, -5, 60, 5)], [], LAND)
positions = [(round(x, 2), round(y, 2)) for x, y, _ in lamps]
assert len(set(positions)) == len(positions)
assert all(abs(x) > 5 for x, _ in positions)
def test_no_lamp_in_the_water():
land = [box(-100, -100, 400, 5.5)] # Ufer knapp hinter dem nördlichen Fahrbahnrand
lamps = lamp_points([LineString([(0, 0), (100, 0)])], [box(0, -5, 100, 5)], [], land)
assert lamps
assert all(land[0].contains(Point(x, y)) for x, y, _ in lamps)
def test_a_road_piece_in_the_neighbouring_chunk_also_keeps_lamps_off():
# Straße entlang der Chunk-Grenze y = 2250, beim Zuschneiden in zwei Stücke geteilt. Der Browser
# kannte je Chunk nur sein eigenes Stück und setzte die südliche Laterne auf das Nachbarstück.
line = LineString([(-1000, 2252), (-900, 2252)])
north = box(-1000, 2250, -900, 2260) # Chunk A1
south = box(-1000, 2244, -900, 2250) # Chunk A2
land = [box(-1000, 2150, -900, 2350)]
lamps = lamp_points([line], [north, south], [], land)
assert [round(y, 1) for _, y, _ in lamps] == [2262.0, 2242.0, 2262.0]
assert all(min(north.distance(Point(x, y)), south.distance(Point(x, y))) >= LAMP_KERB for x, y, _ in lamps)
def test_heading_matches_the_browser_rotation_convention():
# Der Browser drehte um rotation.y = atan2(dz, dx) in three-Koordinaten, mit z = −y.
assert heading_along(LineString([(0, 0), (10, 0)]), 5) == pytest.approx(0.0)
assert heading_along(LineString([(0, 0), (0, 10)]), 5) == pytest.approx(-math.pi / 2)
assert heading_along(LineString([(0, 10), (0, 0)]), 5) == pytest.approx(math.pi / 2)
lamps = lamp_points([LineString([(0, 0), (0, 100)])], [box(-5, 0, 5, 100)], [], [box(-100, -100, 100, 200)])
assert lamps
assert all(h == pytest.approx(-math.pi / 2, abs=1e-4) for _, _, h in lamps)
```
An `tools/extract/tests/test_emit.py`: in `test_payload_matches_schema` (die Zeile mit `assert set(payload) == {…}`) `"lamps"` in die Menge aufnehmen, und diesen Test anfügen:
```python
def test_lamps_become_chunk_local_and_keep_their_heading():
payload = chunk_payload(0, 0, land=[], surfaces=[], roads=[], centerlines=[], buildings=[], palms=[],
lamps=[(-990.0, 2490.0, -1.5708)])
assert payload["lamps"] == [[10.0, 10.0, -1.5708]]
```
- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern**
Run: `cd tools/extract && uv run pytest tests/test_lamps.py tests/test_emit.py -q`
Expected: FAIL — `ImportError: cannot import name 'verge_point'`, und im Schema-Test fehlt `lamps`.
- [ ] **Step 3: Die Randsuche in `tools/extract/leonida/heights.py` verallgemeinern**
Die Funktion `_verge_point` vollständig ersetzen durch:
```python
def verge_point(line, p, side: str, roads, buildings, land, *,
offsets=PALM_VERGE, kerb=PALM_KERB, clearance=PALM_CLEARANCE):
"""Erster Versatz von innen nach außen, der Fahrbahn und Gebäude freihält und auf Land bleibt.
Die Voreinstellungen sind die Palmenwerte. lamps.py übergibt eigene: eine Laterne darf
näher an Bordstein und Hauswand stehen als eine Palme.
"""
for dist in offsets:
off = line.parallel_offset(dist, side)
if off.is_empty:
continue
q = off.interpolate(off.project(p))
if roads is not None and q.distance(roads) < kerb:
continue
if buildings is not None and q.distance(buildings) < clearance:
continue
if land is not None and not land.contains(q):
continue
return [(round(q.x, 2), round(q.y, 2))]
return []
```
In `palm_points` die Zeile `points.extend(_verge_point(line, p, side, roads, buildings, land))` ersetzen durch:
```python
points.extend(verge_point(line, p, side, roads, buildings, land))
```
- [ ] **Step 4: `tools/extract/leonida/lamps.py` anlegen**
```python
"""Straßenlaternen entlang der Mittellinien, seitlich vom Fahrbahnrand versetzt.
Aus src/world/props.js in die Pipeline gezogen (Spec Feinkalibrierung): so erreichen
Korrekturen die Laternen, und die Randsuche kennt die Straßen des ganzen Ausschnitts statt nur
die des eigenen Chunks. Im Browser konnten dadurch an Chunk-Grenzen einzelne Laternen auf der
Fahrbahn stehen bleiben.
"""
import math
from shapely.ops import unary_union
from .heights import verge_point
LAMP_SPACING = 35.0 # Meter entlang der Mittellinie
LAMP_OFFSETS = (6.0, 8.0, 10.0, 12.0, 14.0, 16.0) # Seitenversatz, von innen nach außen probiert
LAMP_KERB = 0.5 # Meter Abstand zur Fahrbahn
LAMP_BUILDING_CLEARANCE = 0.5 # Meter Abstand zu Gebäuden; der Browser prüfte das nicht
def heading_along(line, distance: float) -> float:
"""Drehwinkel für three.js entlang der Linie an dieser Stelle.
Der Browser drehte jede Laterne um rotation.y = atan2(dz, dx) in three-Koordinaten.
Mit z = −y entsteht derselbe Winkel in Weltkoordinaten als atan2(−dy, dx).
"""
a = line.interpolate(max(0.0, distance - 0.5))
b = line.interpolate(min(line.length, distance + 0.5))
return math.atan2(-(b.y - a.y), b.x - a.x)
def lamp_points(lines, road_polys, building_polys, land_polys) -> list[tuple[float, float, float]]:
"""Laternen als (x, y, heading) in Weltkoordinaten."""
roads = unary_union(list(road_polys)) if road_polys else None
buildings = unary_union(list(building_polys)) if building_polys else None
land = unary_union(list(land_polys)) if land_polys else None
out = []
for line in lines:
# Erste Laterne bei halber Teilung: die Mittellinien sind an Kreuzungen getrennt, ein
# Start bei 0 setzte je einmündendem Ast eine Laterne in denselben Punkt.
pos = min(LAMP_SPACING / 2, line.length / 2)
index = 0
while pos <= line.length:
side = "left" if index % 2 == 0 else "right" # je Linie abwechselnd
spot = verge_point(line, line.interpolate(pos), side, roads, buildings, land,
offsets=LAMP_OFFSETS, kerb=LAMP_KERB, clearance=LAMP_BUILDING_CLEARANCE)
if spot:
x, y = spot[0]
out.append((x, y, round(heading_along(line, pos), 4)))
pos += LAMP_SPACING
index += 1
return list(dict.fromkeys(out))
```
- [ ] **Step 5: `chunk_payload` in `tools/extract/leonida/emit.py` um `lamps` erweitern**
Die Signaturzeile ersetzen durch:
```python
def chunk_payload(i, j, *, land, surfaces, roads, centerlines, buildings, palms, lamps=()) -> dict:
```
Im zurückgegebenen Wörterbuch direkt nach dem Eintrag `"palms": …` einfügen:
```python
"lamps": [[round(x - ox, 2), round(-y + oy, 2), round(h, 4)] for x, y, h in lamps],
```
- [ ] **Step 6: Tests laufen lassen, sie müssen bestehen; dann die Suite**
Run: `cd tools/extract && uv run pytest tests/test_lamps.py tests/test_emit.py tests/test_heights.py -q && uv run pytest -q`
Expected: PASS, Ausgabe sauber — die Palmentests in `test_heights.py` bleiben unverändert grün.
- [ ] **Step 7: Commit**
```bash
git add tools/extract/leonida/heights.py tools/extract/leonida/lamps.py tools/extract/leonida/emit.py tools/extract/tests/test_lamps.py tools/extract/tests/test_emit.py
git commit -m "feat(extract): Laternen in der Pipeline setzen und ausliefern
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" -- tools/extract/leonida/heights.py tools/extract/leonida/lamps.py tools/extract/leonida/emit.py tools/extract/tests/test_lamps.py tools/extract/tests/test_emit.py
```
---
### Task 5: Einzelnen Chunk vergrößert rendern
**Files:**
- Modify: `tools/extract/overview.py`
- Test: `tools/extract/tests/test_overview.py`
**Interfaces:**
- Consumes: `_paint_layers`, `_fixture` (Test), Farben aus `overview.py`; `parse_chunk_name`, `CHUNK_SIZE` aus `leonida/coords.py`
- Produces: `render_chunk(out_dir: Path, name: str, dst: Path) -> dict`; Kommandozeile `uv run python overview.py --chunk C10 [--suffix vorher]` schreibt `debug/chunk-C10.png` bzw. `debug/chunk-C10-vorher.png`; Konstanten `CHUNK_SCALE = 4.0`, `LAMP`
- [ ] **Step 1: Debug-Gesamtansicht vor der Änderung festhalten**
Run: `cd tools/extract && uv run python overview.py && cp debug/overview.png debug/overview-vor-task5.png`
Expected: die Kopie liegt in `debug/` (von Git ignoriert). Step 6 belegt damit, dass die Debug-Ansicht unverändert bleibt.
- [ ] **Step 2: Die fehlschlagenden Tests an `tools/extract/tests/test_overview.py` anfügen**
Oben `import pytest` ergänzen, dann anfügen:
```python
def test_render_chunk_draws_one_chunk_at_four_pixels_per_metre(tmp_path):
src = tmp_path / "chunks"
src.mkdir()
_fixture(src)
chunk = json.loads((src / "chunk_0_0.json").read_text())
chunk["lamps"] = [[150.0, 150.0, 0.0]]
(src / "chunk_0_0.json").write_text(json.dumps(chunk))
dst = tmp_path / "a1.png"
stats = overview.render_chunk(src, "a1", dst)
image = Image.open(dst)
assert image.size == (1000, 1000)
assert image.getpixel((440, 500)) == overview.ROAD # Straße bei 110 m / 125 m
assert image.getpixel((600, 600)) == overview.LAMP # Laterne bei 150 m / 150 m
assert stats["laternen"] == 1
def test_render_chunk_rejects_an_invalid_name(tmp_path):
with pytest.raises(ValueError, match="kein Chunk-Name"):
overview.render_chunk(tmp_path, "M1", tmp_path / "x.png")
```
- [ ] **Step 3: Tests laufen lassen, sie müssen scheitern**
Run: `cd tools/extract && uv run pytest tests/test_overview.py -q`
Expected: FAIL — `AttributeError: module 'overview' has no attribute 'render_chunk'`.
- [ ] **Step 4: `overview.py` erweitern**
Die Importzeile `from leonida.coords import SLICE` ersetzen durch:
```python
from leonida.coords import CHUNK_SIZE, SLICE, parse_chunk_name
```
Unter `WALKABLE_SCALE = …` einfügen:
```python
CHUNK_SCALE = 4.0 # Pixel je Meter, Ausschnitt eines einzelnen Chunks
LAMP = (255, 214, 90)
```
In `_paint_layers` die Signatur um `lamp=None` erweitern:
```python
def _paint_layers(img, chunks, scale, *, land, surface, road, bridge, building, outline=None, palm=None, lamp=None) -> dict:
```
im `stats`-Wörterbuch `"laternen": 0` ergänzen, und am Ende der Schleife über die Gebäude — direkt nach dem Block `if palm is not None: …` und auf derselben Einrückung — einfügen:
```python
if lamp is not None:
for x, z, _heading in chunk.get("lamps", []):
px, py = _to_px([(x, z)], ox, oy, scale)[0]
ImageDraw.Draw(img).rectangle([px - 1.5, py - 1.5, px + 1.5, py + 1.5], fill=lamp)
stats["laternen"] += 1
```
Vor `if __name__ == "__main__":` einfügen:
```python
def render_chunk(out_dir: Path, name: str, dst: Path) -> dict:
"""Ein einzelner Chunk mit 4 px/m, für den Vorher/Nachher-Vergleich bei der Feinkalibrierung."""
parsed = parse_chunk_name(name)
if parsed is None:
raise ValueError(f"{name!r} ist kein Chunk-Name zwischen A1 und L12")
i, j = parsed
chunk = json.loads((out_dir / f"chunk_{i}_{j}.json").read_text())
# Chunk-lokale Koordinaten an die Nordwest-Ecke des Ausschnitts verschoben: so fällt genau dieser
# Chunk bei CHUNK_SCALE auf das 1.000-px-Bild, ohne _to_px umzubauen.
shifted = {**chunk, "origin": [SLICE["x0"], -SLICE["y1"]]}
size = int(CHUNK_SIZE * CHUNK_SCALE)
image = Image.new("RGB", (size, size), WATER)
stats = _paint_layers(image, [shifted], CHUNK_SCALE, land=LAND, surface=SURFACE.__getitem__, road=ROAD,
bridge=BRIDGE, building=_building_shade, outline=OUTLINE, palm=PALM, lamp=LAMP)
dst.parent.mkdir(parents=True, exist_ok=True)
image.save(dst)
return stats
```
Den Block `if __name__ == "__main__":` vollständig ersetzen durch:
```python
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Gesamtansicht, Spielkarte oder einzelner Chunk.")
parser.add_argument("--chunk", help="nur diesen Chunk vergrößert rendern, etwa C10")
# Zusatz ohne Bindestrich: argparse läse "--suffix -vorher" als weitere Option und bräche ab.
parser.add_argument("--suffix", default="", help="Zusatz im Dateinamen, etwa vorher oder nachher")
args = parser.parse_args()
if args.chunk:
name = args.chunk.upper() + (f"-{args.suffix}" if args.suffix else "")
target = Path("debug") / f"chunk-{name}.png"
print(render_chunk(OUT, args.chunk, target), "->", target)
else:
print(render(OUT, DST), "->", DST)
print(render_map(OUT, OUT), "->", OUT)
```
- [ ] **Step 5: Tests laufen lassen, sie müssen bestehen**
Run: `cd tools/extract && uv run pytest tests/test_overview.py -q`
Expected: PASS.
- [ ] **Step 6: Debug-Ansicht bytegleich und Chunk-Ausschnitt an echten Daten**
Run: `cd tools/extract && uv run python overview.py && cmp debug/overview.png debug/overview-vor-task5.png && echo bytegleich`
Expected: `bytegleich`. Weichen die Bilder ab, nicht committen, sondern melden.
Run: `cd tools/extract && uv run python overview.py --chunk B9 --suffix probe`
Expected: `debug/chunk-B9-probe.png` mit 1.000 × 1.000 px. Das Bild im Bericht beschreiben (Downtown-Kern) und seinen Pfad nennen.
- [ ] **Step 7: Suite und Commit**
Run: `cd tools/extract && uv run pytest -q`
Expected: PASS, Ausgabe sauber.
```bash
git add tools/extract/overview.py tools/extract/tests/test_overview.py
git commit -m "feat(extract): einzelnen Chunk vergrößert rendern für Vorher/Nachher
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" -- tools/extract/overview.py tools/extract/tests/test_overview.py
```
---
### Task 6: Requisiten-Korrekturen und Einhängen in die Pipeline
**Files:**
- Modify: `tools/extract/leonida/corrections.py`
- Modify: `tools/extract/run.py`
- Test: `tools/extract/tests/test_corrections_apply.py`
- Erzeugt neu: `public/data/vice-a/` (Chunks mit `lamps`), `public/data/vice-a/map.png`, `map-walkable.png`, `map.json`
**Interfaces:**
- Consumes: `World`, `apply_corrections`, `_APPLY`, `_on_land` (Tasks 2–3); `lamp_points`, `heading_along` (Task 4); `render_chunk` über die Kommandozeile (Task 5)
- Produces: Aktionen `remove_props`, `add_palm`, `add_lamp`; `run.py` wendet alle Korrekturen an und schreibt `lamps` in jede Chunk-Datei
- [ ] **Step 1: Die fehlschlagenden Tests an `tests/test_corrections_apply.py` anfügen**
Oben in den Importen ergänzen:
```python
from leonida.lamps import lamp_points
```
Dann anfügen:
```python
def test_remove_props_respects_radius_and_kinds():
world = _world()
world.lamps = [(-719, 421, 0.0), (-600, 420, 0.0)]
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "remove_props"\n'
'at = [-720, 420]\nradius = 3\nkinds = ["lamp"]\n')
assert report.applied == 1
assert world.lamps == [(-600, 420, 0.0)]
assert len(world.palms) == 2 # die Palme im Umkreis bleibt, kinds nennt nur lamp
def test_remove_props_without_anything_in_reach_is_skipped():
world = _world()
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "remove_props"\nat = [-600, 300]\nradius = 2\n')
assert report.skipped[0].why == "keine Requisite im Umkreis"
def test_add_palm_and_add_lamp_need_land_and_the_lamp_faces_along_the_nearest_centerline():
world = _world()
report = _apply(world, '[[correction]]\nchunk = "B9"\naction = "add_palm"\nat = [-610, 440]\n\n'
'[[correction]]\nchunk = "B9"\naction = "add_lamp"\nat = [-610, 395]\n\n'
'[[correction]]\nchunk = "B8"\naction = "add_palm"\nat = [-610, 600]\n')
assert report.applied == 2
assert world.palms[-1] == (-610.0, 440.0)
x, y, heading = world.lamps[-1]
assert (x, y) == (-610.0, 395.0)
assert heading == pytest.approx(0.0, abs=1e-4) # die Mittellinie läuft nach Osten
assert report.skipped[0].why == "liegt nicht auf Land"
def test_lamps_are_placed_after_building_and_before_prop_corrections():
world = _world()
seen = []
def place(w):
seen.append(len(w.buildings))
return [(-700.0, 395.0, 0.0)]
report = apply_corrections(world, parse_corrections(
'[[correction]]\nchunk = "B9"\naction = "remove_props"\nat = [-700, 395]\nradius = 1\nkinds = ["lamp"]\n\n'
'[[correction]]\nchunk = "B9"\naction = "remove_building"\nat = [-690, 310]\n'), place_lamps=place)
assert seen == [2] # nach der Gebäudekorrektur gesetzt
assert world.lamps == [] # und danach von der Requisitenkorrektur erreicht
assert report.applied == 2
def test_no_lamp_stands_beside_a_centerline_cut_by_water():
# Ein Kanal quer über die Straße. Das Straßenstück bleibt (nur 40 % im Vieleck), die Mittellinie
# wird am Kanal geschnitten. Ohne den Schnitt stünden bei x −697,5, −662,5 und −627,5 Laternen
# 12 m neben der Linie am Kanalufer, an einer Straße, die dort nicht mehr fährt.
world = _world()
report = apply_corrections(world, parse_corrections(
'[[correction]]\nchunk = "B9"\naction = "set_area"\n'
'polygon = [[-700, 375], [-600, 375], [-600, 395], [-700, 395]]\nto = "water"\n'),
place_lamps=lambda w: lamp_points(w.lines, [p for p, _ in w.roads], [p for p, _ in w.buildings], w.land))
assert report.applied == 1
assert world.lamps
assert [lamp for lamp in world.lamps if -700 < lamp[0] < -600] == []
```
- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern**
Run: `cd tools/extract && uv run pytest tests/test_corrections_apply.py -q`
Expected: FAIL — vier neue Tests mit `KeyError` (`remove_props`, `add_palm`). `test_no_lamp_stands_beside_a_centerline_cut_by_water` besteht bereits: er sichert das Zusammenspiel von Task 3 und Task 4 ab, das hier zum ersten Mal zusammenkommt, und muss grün bleiben.
- [ ] **Step 3: Importe und Requisiten-Aktionen in `corrections.py`**
Den Importblock ersetzen durch:
```python
from __future__ import annotations
import math
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from shapely import affinity
from shapely.geometry import LineString, Point, Polygon, box
from shapely.ops import unary_union
from .coords import chunk_for_world, chunk_name, parse_chunk_name
from .lamps import heading_along
```
Direkt vor `# action -> Funktion(world, correction) …` einfügen:
```python
def _remove_props(world: World, c: Correction) -> str | None:
x, y = c.fields["at"]
radius = c.fields["radius"]
kinds = c.fields.get("kinds", list(PROP_KINDS))
def near(px: float, py: float) -> bool:
return math.hypot(px - x, py - y) <= radius
removed = 0
if "palm" in kinds:
before = len(world.palms)
world.palms = [palm for palm in world.palms if not near(*palm)]
removed += before - len(world.palms)
if "lamp" in kinds:
before = len(world.lamps)
world.lamps = [lamp for lamp in world.lamps if not near(lamp[0], lamp[1])]
removed += before - len(world.lamps)
return None if removed else "keine Requisite im Umkreis"
def _add_palm(world: World, c: Correction) -> str | None:
x, y = c.fields["at"]
if not _on_land(world, x, y):
return "liegt nicht auf Land"
world.palms.append((float(x), float(y)))
return None
def _add_lamp(world: World, c: Correction) -> str | None:
x, y = c.fields["at"]
if not _on_land(world, x, y):
return "liegt nicht auf Land"
heading = 0.0
if world.lines:
point = Point(x, y)
line = min(world.lines, key=point.distance)
heading = heading_along(line, line.project(point))
world.lamps.append((float(x), float(y), round(heading, 4)))
return None
```
Das Wörterbuch `_APPLY` ersetzen durch:
```python
_APPLY = {
"set_area": _set_area,
"set_surface": _set_surface,
"remove_road": _remove_road,
"set_bridge": _set_bridge,
"add_road": _add_road,
"remove_building": _remove_building,
"set_building_height": _set_building_height,
"add_building": _add_building,
"remove_props": _remove_props,
"add_palm": _add_palm,
"add_lamp": _add_lamp,
}
```
- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen**
Run: `cd tools/extract && uv run pytest tests/test_corrections_apply.py -q`
Expected: PASS.
- [ ] **Step 5: `run.py` neu lesen und einhängen**
**Zuerst `tools/extract/run.py` vollständig neu lesen.** Die Beschriftungs-Aufgabe hat die Datei womöglich verändert. Die folgenden Änderungen beziehen sich auf zwei Stellen, die es in jeder Fassung geben muss: die Zeile, in der `palms = palm_points(…)` berechnet wird, und die Schleife, die `payloads` mit `chunk_payload(…)` füllt. Findest du eine davon nicht in dieser Form, halt an und melde es, statt die Datei umzubauen.
Importe ergänzen:
```python
from leonida.corrections import World, apply_corrections, load_corrections
from leonida.lamps import lamp_points
```
Direkt nach dem Aufruf `palms = palm_points(…)` einfügen (er reicht über zwei Zeilen):
```python
# Feinkalibrierung (docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md): alle
# Korrekturen wirken hier auf die Weltgeometrie, bevor die Chunks entstehen. Die Laternen
# setzt apply_corrections zwischen den Gebäude- und den Requisitenkorrekturen.
world = World(land=land, surfaces=surfaces, roads=roads, lines=lines, buildings=buildings,
heights=heights, palms=palms)
corrections = load_corrections(Path("data/corrections.toml"))
report = apply_corrections(world, corrections, place_lamps=lambda w: lamp_points(
w.lines, [p for p, _ in w.roads], [p for p, _ in w.buildings], w.land))
print(report.format())
```
Im Aufruf von `chunk_payload(…)` die Argumente auf die korrigierte Welt umstellen und `lamps` ergänzen:
```python
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)],
roads=[(part, b) for (p, b) in world.roads for part in split_by_chunk_all([p], i, j)],
centerlines=[ln for ln in world.lines if ln.intersects(chunk_box_of(i, j))],
buildings=[(p, h, s) for (p, s), h in zip(world.buildings, world.heights)
if p.intersects(chunk_box_of(i, j))],
palms=[pt for pt in world.palms if in_chunk(pt, i, j)],
lamps=[lamp for lamp in world.lamps if in_chunk(lamp, i, j)],
```
- [ ] **Step 6: Durchlauf mit einer echten Korrektur je Bereich — Vorher/Nachher**
Zuerst ohne Korrekturen erzeugen. So zeigt schon das Vorher-Bild die Laternen, und die beiden Bilder unterscheiden sich nur durch die Korrekturen:
Run: `cd tools/extract && uv run python run.py && uv run python overview.py --chunk B9 --suffix vorher`
Expected: `Korrekturen: 0 angewendet, 0 übersprungen`, danach `debug/chunk-B9-vorher.png`.
Dann aus den frischen Daten des Downtown-Chunks B9 fünf Probekorrekturen ableiten und **vorübergehend** an `data/corrections.toml` anhängen: zwei für Gebäude, je eine für Straßen, Beläge und Requisiten.
```bash
cd tools/extract && uv run python - <<'PY'
import json
from pathlib import Path
from shapely.geometry import Point, Polygon, box
chunk = json.loads(Path("../../public/data/vice-a/chunk_1_8.json").read_text())
ox, oy = chunk["origin"][0], -chunk["origin"][1]
cell = box(ox, oy - 250, ox + 250, oy) # B9 in Weltkoordinaten
def world(rings):
ring = lambda r: [(x + ox, oy - z) for x, z in r]
return Polygon(ring(rings[0]), [ring(h) for h in rings[1:]])
def spot(poly):
"""Innerer Punkt, auf 2 Stellen gerundet; None, wenn er dabei herausfällt oder nicht in B9 liegt."""
p = poly.representative_point()
q = Point(round(p.x, 2), round(p.y, 2))
return [q.x, q.y] if poly.contains(q) and cell.contains(q) else None
def entry(action, **fields):
lines = ["", "[[correction]]", 'chunk = "B9"', f'action = "{action}"']
lines += [f"{key} = {json.dumps(value)}" for key, value in fields.items()]
return "\n".join(lines + ['reason = "Probe Task 6"'])
# Gebäude sind nicht zugeschnitten: nur solche, deren innerer Punkt in B9 liegt, und zwei, die sich nicht berühren.
buildings = [(poly, spot(poly)) for poly in (world(b["poly"]) for b in chunk["buildings"])]
buildings = [(poly, at) for poly, at in buildings if at]
first_poly, first_at = buildings[0]
second_at = next(at for poly, at in buildings[1:] if not poly.intersects(first_poly))
# Ein Straßenstück, das ganz in B9 liegt, wurde beim Zuschneiden nicht geteilt: remove_road wirkt dann nur
# hier. Gibt es keins, zeigt set_bridge die Straßenebene, ohne ein ganzes Straßennetz zu entfernen.
roads = [world(r["poly"]) for r in chunk["roads"]]
whole = sorted((p for p in roads if cell.buffer(-0.5).contains(p) and spot(p)), key=lambda p: p.area)
road = (entry("remove_road", at=spot(whole[-1])) if whole
else entry("set_bridge", at=next(spot(p) for p in roads if spot(p)), bridge=True))
# Wie bei den Straßen: eine Belagsfläche ganz in B9 ist die ganze Fläche. Gibt es keine, die größte angeschnittene:
# sie reicht über B9 hinaus, fällt aber im Bild auf, und Step 7 nimmt die Probe ohnehin zurück.
surfaces = [(s["kind"], world(s["poly"])) for s in chunk["surfaces"]]
candidates = [(k, p) for k, p in surfaces if spot(p)]
inside = [(k, p) for k, p in candidates if cell.buffer(-0.5).contains(p)]
surface_kind, surface_poly = max(inside or candidates, key=lambda kp: kp[1].area)
kind = "sand" if surface_kind == "park" else "park"
if chunk["palms"]:
x, z = chunk["palms"][0]
props = entry("remove_props", at=[round(x + ox, 2), round(oy - z, 2)], radius=2, kinds=["palm"])
else:
x, z, _ = chunk["lamps"][0]
props = entry("remove_props", at=[round(x + ox, 2), round(oy - z, 2)], radius=2, kinds=["lamp"])
probe = [entry("remove_building", at=first_at), entry("set_building_height", at=second_at, height=200),
road, entry("set_surface", at=spot(surface_poly), kind=kind), props]
with open("data/corrections.toml", "a") as f:
f.write("\n".join(probe) + "\n")
print("\n".join(probe))
PY
```
Run: `cd tools/extract && uv run python run.py && uv run python overview.py && uv run python overview.py --chunk B9 --suffix nachher`
Expected: `run.py` druckt `Korrekturen: 5 angewendet, 0 übersprungen`. Beide Bilder im Bericht nennen und beschreiben, was sich sichtbar geändert hat: das entfernte Gebäude, das hellere hohe Gebäude, die entfernte Straße samt der Laternen daran (oder die rot gefärbte bei `set_bridge`), die umgefärbte Belagsfläche, die fehlende Palme oder Laterne. Übersprungene Einträge mit ihrem Grund melden, nicht umgehen.
Außerdem vom Repo-Wurzelverzeichnis aus zählen und berichten:
```bash
node -e "
const fs=require('fs');let l=0,p=0;
for(const f of fs.readdirSync('public/data/vice-a').filter(f=>f.startsWith('chunk_'))){
const c=JSON.parse(fs.readFileSync('public/data/vice-a/'+f));l+=(c.lamps||[]).length;p+=c.palms.length;}
console.log('Laternen',l,'Palmen',p);"
```
Zur Einordnung: Der Browser meldete 4.313 Laternen, zählte aber je Chunk jede Mittellinie, die den Chunk berührt, ganz — Laternen an Linien über Chunk-Grenzen standen doppelt am selben Ort. Verschiedene Orte gab es höchstens 3.392. Davon entfallen die, bei denen alle sechs Versätze auf Fahrbahn, in Gebäude oder ins Wasser treffen. Ein Probelauf beim Schreiben dieses Plans ergab 2.591 Laternen und 2.206 Palmen.
- [ ] **Step 7: Probekorrekturen entfernen und sauber neu erzeugen**
`tools/extract/data/corrections.toml` vom Repo-Wurzelverzeichnis aus auf den Stand aus Task 1 zurücksetzen: `git checkout -- tools/extract/data/corrections.toml`.
Run: `cd tools/extract && uv run python run.py && uv run python overview.py`
Expected: `Korrekturen: 0 angewendet, 0 übersprungen`. Die ausgelieferten Daten enthalten damit keine Probekorrekturen, aber die Laternen.
- [ ] **Step 8: Suiten und Commit**
Run: `cd tools/extract && uv run pytest -q`, danach vom Repo-Wurzelverzeichnis aus `npm test`
Expected: beide PASS, Ausgabe sauber.
Vor dem Commit prüfen, dass `git diff --stat tools/extract/data/corrections.toml` leer ist.
```bash
git add tools/extract/leonida/corrections.py tools/extract/run.py tools/extract/tests/test_corrections_apply.py public/data/vice-a
git commit -m "feat(extract): Feinkalibrierung in der Pipeline, Laternen in der Nutzlast
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" -- tools/extract/leonida/corrections.py tools/extract/run.py tools/extract/tests/test_corrections_apply.py public/data/vice-a
```
---
### Task 7: Browser liest die Laternen aus der Nutzlast
**Files:**
- Modify: `src/world/props.js`
- Modify: `tests/props.test.js`
**Interfaces:**
- Consumes: `chunk.lamps = [[x, z, heading], …]` aus Task 6
- Produces: `PropLayer.build(chunk)` setzt Laternen aus `chunk.lamps`; `lampPoints`, `inRing`, `pointInPolygon`, `vergeLampPoints` und `LAMP_VERGE` gibt es nicht mehr
- [ ] **Step 1: Die fehlschlagenden Tests schreiben und die veralteten entfernen**
In `tests/props.test.js`:
- die Importzeile ersetzen durch:
```js
import { BoxGeometry, Matrix4, MeshBasicMaterial } from "three";
import { PropLayer, STREET_LAMP_HEIGHT, lampFit, palmTransforms } from "../src/world/props.js";
```
- in `describe("props", …)` die drei Tests zu `lampPoints` entfernen (der Palmentest `skaliert Palmen reproduzierbar …` bleibt),
- die Blöcke `describe("pointInPolygon", …)` und `describe("vergeLampPoints", …)` samt ihrer Kommentare darüber entfernen,
- den Block `describe("lampFit", …)` unverändert lassen,
- anfügen:
```js
describe("Laternen aus der Nutzlast", () => {
const layerWithOnePart = () => {
const layer = new PropLayer();
layer.lampParts = [{ geometry: new BoxGeometry(), material: new MeshBasicMaterial(), matrix: new Matrix4() }];
return layer;
};
it("setzt je Laterne eine Instanz an Position und Drehung aus chunk.lamps", () => {
const { visual } = layerWithOnePart().build({ palms: [], lamps: [[10, 20, Math.PI / 2], [30, 40, 0]] });
const mesh = visual.children[0];
expect(mesh.count).toBe(2);
const m = new Matrix4();
mesh.getMatrixAt(0, m);
expect(m.elements[12]).toBeCloseTo(10); // x
expect(m.elements[14]).toBeCloseTo(20); // z
expect(m.elements[0]).toBeCloseTo(0); // cos(π/2): um die y-Achse gedreht
mesh.getMatrixAt(1, m);
expect(m.elements[0]).toBeCloseTo(1); // cos(0)
});
it("baut ohne Laternen in der Nutzlast keine Laternen", () => {
expect(layerWithOnePart().build({ palms: [] }).visual.children).toHaveLength(0);
expect(layerWithOnePart().build({ palms: [], lamps: [] }).visual.children).toHaveLength(0);
});
});
```
- [ ] **Step 2: Tests laufen lassen, sie müssen scheitern**
Run: `npx vitest run tests/props.test.js`
Expected: FAIL — der erste neue Test findet keine Instanzen, weil `#buildLamps` noch aus `chunk.centerlines` rechnet.
- [ ] **Step 3: `src/world/props.js` umstellen**
Entfernen:
- die Konstante `LAMP_VERGE`,
- die Funktionen `lampPoints`, `inRing`, `pointInPolygon` und `vergeLampPoints` samt ihren Dokumentationskommentaren.
Die Methode `#buildLamps(chunk, visual) { … }` samt ihrem Dokumentationskommentar vollständig ersetzen durch:
```js
/**
* Laternen aus der Nutzlast (chunk.lamps = [[x, z, heading], …], chunk-lokal). Position und
* Drehung setzt die Pipeline (tools/extract/leonida/lamps.py): so erreichen Korrekturen die
* Laternen, und die Randsuche kennt die Straßen des ganzen Ausschnitts statt nur die dieses
* Chunks. Skalierung und Fuß-Anhebung stecken bereits in this.lampParts[*].matrix (s. load()).
*/
#buildLamps(chunk, visual) {
const lamps = chunk.lamps ?? [];
if (!lamps.length || !this.lampParts?.length) return;
const dummy = new Object3D();
const local = new Matrix4();
// Ein InstancedMesh je Teil (Mast, Glas, …), nicht je Laterne — dieselbe Platzierungsmatrix
// wird mit der lokalen Transformation jedes Teils verkettet.
for (const part of this.lampParts) {
const mesh = new InstancedMesh(part.geometry, part.material, lamps.length);
mesh.castShadow = true;
mesh.userData.excludeFromCollider = true;
mesh.userData.sharedGeometry = true; // part.geometry gehört this.lampParts (Ruling 62)
lamps.forEach(([x, z, heading], i) => {
dummy.position.set(x, 0, z);
dummy.rotation.set(0, heading, 0);
dummy.updateMatrix();
local.multiplyMatrices(dummy.matrix, part.matrix);
mesh.setMatrixAt(i, local);
});
mesh.instanceMatrix.needsUpdate = true;
visual.add(mesh);
}
}
```
- [ ] **Step 4: Tests laufen lassen, sie müssen bestehen; dann die Suite**
Run: `npx vitest run tests/props.test.js && npm test`
Expected: PASS, Ausgabe sauber. Kein Test und keine Quelldatei darf noch `lampPoints`, `pointInPolygon` oder `vergeLampPoints` erwähnen: `grep -rn "lampPoints\|pointInPolygon\|vergeLampPoints" src tests` liefert nichts.
- [ ] **Step 5: Im Browser prüfen**
Dev-Server starten, jeweils mit Screenshot im Bericht:
1. An einer Straße im Downtown-Kern (B9) stehen Laternen am Rand, nicht auf der Fahrbahn und nicht in Gebäuden, und ihr Arm zeigt wie vorher über die Fahrbahn.
2. An einer Chunk-Grenze, über die eine Straße läuft (Chunk-Grenzen im Panel einblenden), steht keine Laterne auf der Fahrbahn — das war die bekannte Lücke der Browser-Version.
3. Die Konsole ist frei von Fehlern aus diesem Code.
Wer für Screenshots einen temporären `window`-Haken setzt, entfernt ihn vor dem Commit und liest den vollständigen Diff.
- [ ] **Step 6: Commit**
```bash
git add src/world/props.js tests/props.test.js
git commit -m "feat(world): Laternen aus der Chunk-Nutzlast statt im Browser berechnen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" -- src/world/props.js tests/props.test.js
```