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

74 KiB
Raw Blame History

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:

from leonida.coords import SLICE, chunk_for_world, chunk_name, parse_chunk_name, pixel_to_world, slice_tiles, tile_for_world

und anfügen:

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:

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
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
"""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:

# 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
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:

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:

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
@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
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

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:

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:

MIN_PIECE_M = 1.0   # Reststücke von Mittellinien (m) und Flächen (m²) darunter entfallen

Direkt vor # action -> Funktion(world, correction) … einfügen:

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:

_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
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:

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:

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:

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:

                points.extend(verge_point(line, p, side, roads, buildings, land))
  • Step 4: tools/extract/leonida/lamps.py anlegen
"""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:

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:

        "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
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:

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:

from leonida.coords import CHUNK_SIZE, SLICE, parse_chunk_name

Unter WALKABLE_SCALE = … einfügen:

CHUNK_SCALE = 4.0                # Pixel je Meter, Ausschnitt eines einzelnen Chunks
LAMP = (255, 214, 90)

In _paint_layers die Signatur um lamp=None erweitern:

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:

        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:

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:

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.

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:

from leonida.lamps import lamp_points

Dann anfügen:

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:

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:

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:

_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:

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):

    # 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:

                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.

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:

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.

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:
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:
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:

    /**
     * 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
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