fix(extract): Chunk-Ausgabe lauf-stabil, Eckenprüfung in beide Richtungen

emit.py normalisiert jede ausgegebene Fläche und sortiert die Listen nach der
Ausgabestruktur selbst. unary_union() in apply_street() gab das kartenweite
Straßenpolygon mit anderem Ringstartpunkt und an anderer Listenstelle zurück und
änderte damit 88 Chunkdateien, in denen sich geometrisch nichts bewegt hatte
(symmetrische Differenz 0,00 m²). Belagsarten und Mittellinien behalten ihre
Reihenfolge: overview.py malt die Beläge in Listenreihenfolge übereinander, und
vergeLampPoints() hängt an Reihenfolge und Laufrichtung der Mittellinien —
Sortieren verschöbe dort 1.848 von 3.921 Laternen auf die andere Straßenseite.

check_corners() misst gegen den Straßenrand statt gegen die Straßenfläche. Alle
vier Ecken liegen in der Fahrbahn, ihr Flächenabstand ist 0,000 m; die alte
Prüfung schlug deshalb nur an, wenn eine Straße sich zurückzieht. Eine nach
innen verbreiterte Straße hätte apply_park() still auf das veraltete Viereck
zurückgeschnitten.

Die Parkwege werden am Parkviereck abgeschnitten — die runden Kappen von
LineString.buffer() lagen mit 95,96 m² darüber hinaus, rund 41 m² davon sichtbar
als Betonzungen auf den umgebenden Belägen. load_elements() prüft den OSM-Abzug
so streng wie die Parkdatei, mit Dateiname statt nacktem KeyError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-16 13:43:01 +02:00
1 parent a3504df43d
commit 3c74202b5e
10 files changed
+333 -102

No files matched your search

+49 -7
View File
@@ -279,7 +279,31 @@ class ParkLayout:
def load_elements(path: Path) -> list[dict]:
return json.loads(path.read_text(encoding="utf-8"))["elements"]
"""Die Elemente des OSM-Abzugs, so streng geprüft wie die Parkdatei.
osm.py wird von Hand aufgerufen, der Abzug liegt also beliebig lange herum und kann
abgeschnitten oder aus einer älteren Fassung des Skripts sein. Ohne diese Prüfung fällt das
erst als nackter KeyError mitten in build_layout() auf — ohne Dateinamen und ohne den Hinweis,
wie man den Abzug erneuert.
"""
def fail(message: str):
raise ParkError(f"{path.name}: {message}")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as err:
fail(f"kein gültiges JSON: {err}")
elements = data.get("elements") if isinstance(data, dict) else None
if not isinstance(elements, list):
fail("Feld 'elements' fehlt oder ist keine Liste: cd tools/extract && uv run python osm.py")
for n, element in enumerate(elements, start=1):
where = f"elements[{n}]" if not isinstance(element, dict) else f"elements[{n}] (id {element.get('id')})"
if not isinstance(element, dict) or not isinstance(element.get("tags"), dict):
fail(f"{where} braucht eine Tabelle 'tags'")
geometry = element.get("geometry")
if not (isinstance(geometry, list) and all(_is_point(point) for point in geometry)):
fail(f"{where} braucht 'geometry' als Liste von Punkten [Breite, Länge]")
return elements
def keepout_polygon(keepout: Keepout) -> Polygon:
@@ -344,7 +368,12 @@ def build_layout(elements: list[dict], config, fit: ParkFit) -> ParkLayout:
if footways:
union = unary_union(footways)
warnings += _keepout_warnings(union, config.keepouts, keepouts, "Parkweg")
paths = _polygons(union.difference(blocked))
# Am Parkviereck abschneiden: _extend() legt den letzten Stützpunkt genau auf die Kante,
# die runde Kappe von LineString.buffer() setzt danach bis zu eine halbe Wegbreite (3 m)
# darüber hinaus. Gemessen lagen so 95,96 m² Weg außerhalb des Vierecks, davon rund 41 m²
# als Betonzungen sichtbar auf den umgebenden Belägen. Die gewollte Reichweite bis zur
# Straßenkante geht dabei nicht verloren, weil _extend() ohnehin auf diese Kante zielt.
paths = _polygons(union.difference(blocked).intersection(fit.game_quad))
if service:
service_union = unary_union(service)
warnings += _keepout_warnings(service_union, config.keepouts, keepouts, "Zufahrt")
@@ -487,13 +516,26 @@ def apply_street(world: World, street: Street) -> World:
surfaces=surfaces, palms=palms)
def check_corners(config, roads: list[tuple[Polygon, bool]], tolerance: float = 2.0) -> None:
"""Jede Spielecke muss an einer Straße liegen, sonst hat sich die Extraktion geändert."""
def check_corners(config, roads: list[tuple[Polygon, bool]], tolerance: float = 2.5) -> None:
"""Jede Spielecke muss am Rand einer Straße liegen, sonst hat sich die Extraktion geändert.
Gemessen wird gegen den Rand, nicht gegen die Fläche: alle vier Ecken liegen heute innerhalb
der Straßenfläche, ihr Flächenabstand ist 0,000 m. Eine Prüfung auf die Fläche schlägt deshalb
nur an, wenn eine Straße sich zurückzieht. Verbreitert ein künftiger Extraktionslauf die
Straßen nach innen, bliebe der Flächenabstand 0, und apply_park() schnitte die verbreiterte
Straße still auf das veraltete Viereck zurück — genau der Fehlerfall, den diese Prüfung
abfangen soll.
Am Referenzstand f32ea27 liegen die Ecken 0,45 m (sw), 0,14 m (se), 1,46 m (ne) und 1,14 m (nw)
vom Straßenrand entfernt. 2,5 m Toleranz lässt der weitesten Ecke damit 1,04 m Luft, bevor die
Prüfung grundlos abbricht, und schlägt umgekehrt an, sobald eine Straßenkante sich um mehr als
ihren heutigen Abstand plus rund einen Meter verschiebt.
"""
for name, (x, y) in config.game.items():
corner = Point(x, y)
if not any(poly.distance(corner) <= tolerance for poly, bridge in roads if not bridge):
raise ParkError(f"Spielecke {name} ({x:g} | {y:g}) liegt weiter als {tolerance:g} m von jeder Straße: "
"Ecken in flamingo-park.toml neu messen")
if not any(poly.boundary.distance(corner) <= tolerance for poly, bridge in roads if not bridge):
raise ParkError(f"Spielecke {name} ({x:g} | {y:g}) liegt weiter als {tolerance:g} m vom Rand jeder "
"Straße: Ecken in flamingo-park.toml neu messen")
def apply_park(world: World, layout: ParkLayout, seed: int) -> World: