"""Kartenbeschriftungen der Vorlage entfernen, bevor die Pipeline Geometrie daraus macht. Spec: docs/superpowers/specs/2026-09-13-beschriftungen-design.md Koordinaten in annotations.toml sind Welt-x und Welt-y in Metern, genau wie die Minimap sie anzeigt. Wie in der Feinkalibrierung: ein Formfehler in der Datei bricht mit AnnotationError ab, damit kein Eintrag still verschwindet; ein formal richtiger Eintrag, der inhaltlich nichts trifft, wird gemeldet und übersprungen. """ from __future__ import annotations import tomllib from dataclasses import dataclass, field from pathlib import Path from shapely.geometry import Polygon, box from .coords import SLICE, chunk_for_world, chunk_name KINDS = ("text_white", "text_red", "marker", "symbol", "street_name", "region") MIN_BOX_SIDE = 2.0 # m class AnnotationError(ValueError): """Formfehler in annotations.toml: der Lauf bricht ab.""" @dataclass(frozen=True) class Annotation: index: int # 1-basiert, wie in allen Meldungen name: str kind: str box: tuple[float, float, float, float] | None # x_min, y_min, x_max, y_max polygon: tuple[tuple[float, float], ...] | None reason: str = "" @property def outline(self) -> Polygon: return Polygon(self.polygon) if self.polygon else box(*self.box) @property def chunk(self) -> str: centre = self.outline.centroid return chunk_name(*chunk_for_world(centre.x, centre.y)) or "?" @dataclass(frozen=True) class Keep: index: int box: tuple[float, float, float, float] reason: str @dataclass(frozen=True) class Grid: spacing: float width: float @dataclass(frozen=True) class AnnotationList: annotations: list[Annotation] = field(default_factory=list) keep: list[Keep] = field(default_factory=list) grid: Grid | None = None # --- Liste lesen und prüfen ------------------------------------------------------------------- def load_annotations(path: Path) -> AnnotationList: """Liste aus der Datei; fehlt sie, gibt es keine Einträge und kein Gitternetz.""" if not path.exists(): return AnnotationList() return parse_annotations(path.read_text()) def parse_annotations(text: str) -> AnnotationList: try: data = tomllib.loads(text) except tomllib.TOMLDecodeError as err: raise AnnotationError(f"annotations.toml ist kein gültiges TOML: {err}") from err unknown = sorted(set(data) - {"grid", "annotation", "keep"}) if unknown: raise AnnotationError(f"annotations.toml: unbekannter Abschnitt {unknown[0]!r}, " "erwartet [grid], [[annotation]] oder [[keep]]") for section in ("annotation", "keep"): if not isinstance(data.get(section, []), list): raise AnnotationError(f"annotations.toml: {section} muss als [[{section}]] geschrieben sein") return AnnotationList( [_parse_annotation(i, entry) for i, entry in enumerate(data.get("annotation", []), start=1)], [_parse_keep(i, entry) for i, entry in enumerate(data.get("keep", []), start=1)], _parse_grid(data.get("grid")), ) def _number(value) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) def _point(value) -> bool: return isinstance(value, list) and len(value) == 2 and all(_number(c) for c in value) def _inside_slice(x_min, y_min, x_max, y_max) -> bool: return x_min >= SLICE["x0"] and x_max <= SLICE["x1"] and y_min >= SLICE["y0"] and y_max <= SLICE["y1"] def _parse_box(value, fail): if not (isinstance(value, list) and len(value) == 2 and all(_point(p) for p in value)): fail(f"Feld 'box' muss zwei Ecken [[x, y], [x, y]] enthalten, ist {value!r}") (xa, ya), (xb, yb) = value x_min, x_max = sorted((float(xa), float(xb))) y_min, y_max = sorted((float(ya), float(yb))) if x_max - x_min < MIN_BOX_SIDE or y_max - y_min < MIN_BOX_SIDE: fail(f"Feld 'box' muss auf beiden Achsen mindestens {MIN_BOX_SIDE:g} m messen, ist {value!r}") if not _inside_slice(x_min, y_min, x_max, y_max): fail(f"Feld 'box' liegt außerhalb des Ausschnitts, ist {value!r}") return x_min, y_min, x_max, y_max def _parse_polygon(value, fail): if not (isinstance(value, list) and len(value) >= 3 and all(_point(p) for p in value)): fail(f"Feld 'polygon' muss mindestens 3 Punkte [x, y] enthalten, ist {value!r}") shape = Polygon(value) if not shape.is_valid or shape.area <= 0: fail(f"Feld 'polygon' muss ein Vieleck ohne sich schneidende Kanten sein, ist {value!r}") if not _inside_slice(*shape.bounds): fail(f"Feld 'polygon' liegt außerhalb des Ausschnitts, ist {value!r}") return tuple((float(x), float(y)) for x, y in value) def _parse_annotation(index: int, entry) -> Annotation: def fail(message: str): raise AnnotationError(f"Beschriftung {index}: {message}") if not isinstance(entry, dict): fail("ist keine Tabelle") for key in entry: if key not in ("name", "kind", "box", "polygon", "reason"): fail(f"Feld {key!r} gibt es nicht") name = entry.get("name") if not isinstance(name, str) or not name.strip(): fail("Feld 'name' fehlt oder ist leer") kind = entry.get("kind") if kind not in KINDS: fail(f"unbekanntes kind {kind!r}, erlaubt sind {', '.join(KINDS)}") reason = entry.get("reason", "") if not isinstance(reason, str): fail("reason muss Text sein") if kind == "region": if ("box" in entry) == ("polygon" in entry): fail("region braucht genau eines der Felder 'box' und 'polygon'") elif "polygon" in entry: fail(f"Feld 'polygon' gibt es nur bei region, nicht bei {kind}") elif "box" not in entry: fail("Feld 'box' fehlt") area = _parse_box(entry["box"], fail) if "box" in entry else None outline = _parse_polygon(entry["polygon"], fail) if "polygon" in entry else None return Annotation(index, name, kind, area, outline, reason) def _parse_keep(index: int, entry) -> Keep: def fail(message: str): raise AnnotationError(f"Ausnahme {index}: {message}") if not isinstance(entry, dict): fail("ist keine Tabelle") for key in entry: if key not in ("box", "reason"): fail(f"Feld {key!r} gibt es nicht") if "box" not in entry: fail("Feld 'box' fehlt") reason = entry.get("reason") if not isinstance(reason, str) or not reason.strip(): fail("Feld 'reason' fehlt oder ist leer") return Keep(index, _parse_box(entry["box"], fail), reason) def _parse_grid(value) -> Grid | None: if value is None: return None def fail(message: str): raise AnnotationError(f"Gitternetz: {message}") if not isinstance(value, dict): fail("muss als [grid] geschrieben sein") for key in value: if key not in ("spacing", "width"): fail(f"Feld {key!r} gibt es nicht") for key in ("spacing", "width"): if key not in value: fail(f"Feld {key!r} fehlt") if not (_number(value[key]) and value[key] > 0): fail(f"Feld {key!r} muss eine Zahl größer als 0 sein, ist {value[key]!r}") return Grid(float(value["spacing"]), float(value["width"]))