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

+56 -7
View File
@@ -1,6 +1,8 @@
import json
from itertools import groupby
from pathlib import Path
import shapely
from shapely.geometry import Polygon, box
from .coords import CHUNK_SIZE, SLICE
@@ -31,29 +33,76 @@ def _rings(poly: Polygon, i: int, j: int) -> list[list[list[float]]]:
im Park) ist genauso echte Flaeche wie der Aussenring und darf beim Zuschneiden nicht
verschwinden - sonst wird der Block zu Asphalt, der Innenhof zu Gebaeudemasse, der Teich
zu Wiese.
normalize() zuerst, weil dieselbe Flaeche sonst je nach Rechenweg anders in der Datei
steht: GEOS waehlt Ringstartpunkt und Wicklung nach Belieben. unary_union() in
apply_street() fasst dafuer das kartenweite Strassenpolygon neu zusammen und aenderte so
88 Chunkdateien, in denen sich geometrisch nichts bewegt hatte - symmetrische Differenz
der Strassenvereinigung exakt 0,00 m2 in allen 88. normalize() legt beides fest
(lexikographisch kleinster Punkt zuerst, Aussenring im Uhrzeigersinn, Loecher dagegen)
und macht die Ausgabe damit vom Rechenweg unabhaengig.
Die vereinheitlichte Wicklung ist unbedenklich, nachgeprueft an allen drei Verbrauchern:
geometry.js stellt sie beim Laden ohnehin selbst her (clockwise(), Zeile 50), das
Halbstrahl-Verfahren von pointInPolygon() in props.js kommt ohne sie aus, und PIL fuellt
in overview.py unabhaengig von Wicklung und Startpunkt (nachgemessen, Bild bytegleich).
"""
ox = SLICE["x0"] + i * CHUNK_SIZE
oy = SLICE["y1"] - j * CHUNK_SIZE
poly = shapely.normalize(poly)
# Welt (X, Y) -> three (x, z) -> relativ zur Chunk-Ecke
return [[[round(x - ox, 2), round(-y - (-oy), 2)] for x, y in ring.coords]
for ring in (poly.exterior, *poly.interiors)]
def _sorted_surfaces(entries: list[dict]) -> list[dict]:
"""Belaege nur innerhalb einer Art sortieren - zwischen den Arten traegt die Reihenfolge Bedeutung.
overview.py malt die Belaege in Listenreihenfolge uebereinander, der spaetere gewinnt. An den
ausgelieferten Chunks ueberlappen sich verschiedene Arten auf 36.873 m2: der Parkrasen liegt
unter Feldern (19.573 m2), Plaetzen (11.872 m2), Parkplatz (2.878 m2), Pool (539 m2) und
Spielplatzsand (270 m2), die Stadtflaeche ueber dem Strandsand (1.729 m2). Ein Sortieren ueber
die Arten hinweg kehrte das um und liesse die Felder unter dem Rasen verschwinden - und eine
feste Rangfolge je Art gaebe es dafuer nicht, weil "sand" als Strand vor und als Spielplatz
hinter "park" steht. Die Abfolge der Arten selbst steht fest im Code (run.py, SURFACE_ORDER in
park.py) und ist damit schon eindeutig; GEOS mischt nur innerhalb einer Art.
"""
return [entry for _, run in groupby(entries, key=lambda e: e["kind"])
for entry in sorted(run, key=lambda e: e["poly"])]
def chunk_payload(i, j, *, land, surfaces, roads, centerlines, buildings, palms, paths=()) -> dict:
"""Ein Chunk als Ausgabestruktur, deterministisch sortiert.
Sortiert wird nach der gerundeten Ausgabestruktur selbst, nicht nach Flaeche, Schwerpunkt oder
Bounding-Box: nur sie ist total geordnet und ohne Sonderfall, denn zwei Eintraege mit gleichem
Schluessel sind dann bytegleich und ihre Reihenfolge wieder egal. Die abgeleiteten Zahlen
koennen sich dagegen zwei verschiedene Flaechen teilen, und die Ausgabe hinge fuer diese
beiden weiterhin an der Listenposition, die GEOS vergibt.
centerlines bleiben bewusst unsortiert und unnormalisiert: dort traegt sowohl die Reihenfolge
als auch die Laufrichtung Bedeutung. vergeLampPoints() in props.js setzt jede Laterne
abwechselnd links und rechts der Fahrbahn (gerader Index links, ungerader rechts) und verwirft
sie, wenn auf ihrer Seite kein freier Platz ist - an den ausgelieferten Chunks wechselten beim
Sortieren 1.848 von 3.921 Laternen die Strassenseite. Die Mittellinien sind auch ohne
Sortieren stabil: sie entstehen aus centerlines() und difference(), nie aus unary_union().
"""
ox = SLICE["x0"] + i * CHUNK_SIZE
oy = SLICE["y1"] - j * CHUNK_SIZE
return {
"chunk": [i, j],
"origin": [ox, -oy],
"land": [_rings(p, i, j) for p in land],
"surfaces": [{"poly": _rings(p, i, j), "kind": k} for p, k in surfaces],
"roads": [{"poly": _rings(p, i, j), "bridge": b} for p, b in roads],
"land": sorted(_rings(p, i, j) for p in land),
"surfaces": _sorted_surfaces([{"poly": _rings(p, i, j), "kind": k} for p, k in surfaces]),
"roads": sorted(({"poly": _rings(p, i, j), "bridge": b} for p, b in roads),
key=lambda r: (r["poly"], r["bridge"])),
"centerlines": [[[round(x - ox, 2), round(-y + oy, 2)] for x, y in ln.coords] for ln in centerlines],
"buildings": [{"poly": _rings(p, i, j), "height": h["height"],
"source": h["source"], "speculative": s} for p, h, s in buildings],
"palms": [[round(x - ox, 2), round(-y + oy, 2)] for x, y in palms],
"buildings": sorted(({"poly": _rings(p, i, j), "height": h["height"],
"source": h["source"], "speculative": s} for p, h, s in buildings),
key=lambda b: (b["poly"], b["height"], b["source"], b["speculative"])),
"palms": sorted([round(x - ox, 2), round(-y + oy, 2)] for x, y in palms),
# Parkwege (Flamingo Park, docs/superpowers/specs/2026-09-15-flamingo-park-design.md), aufgebaut wie roads.
"paths": [{"poly": _rings(p, i, j)} for p in paths],
"paths": sorted(({"poly": _rings(p, i, j)} for p in paths), key=lambda p: p["poly"]),
}