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"]),
}
+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:
+52 -1
View File
@@ -1,7 +1,8 @@
from shapely.geometry import Polygon
from shapely.geometry import LineString, Polygon
from leonida.emit import chunk_payload, split_by_chunk, split_by_chunk_all
BIG = Polygon([(-1000, 2500), (-700, 2500), (-700, 2200), (-1000, 2200)]) # Chunk (0,0) und Nachbarn
HEIGHT = {"height": 9.0, "source": "district"}
def test_polygon_is_clipped_into_its_chunks():
parts = split_by_chunk(BIG)
@@ -48,6 +49,56 @@ def test_polygon_holes_survive_into_the_payload():
assert max(x for x, _ in exterior) > max(x for x, _ in hole)
assert all(0 <= x <= 250 and 0 <= z <= 250 for x, z in hole)
def test_the_same_geometry_in_another_order_gives_the_same_output():
"""Die Ausgabe darf nicht am Rechenweg haengen, nur an der Geometrie.
unary_union() in park.py fasst das kartenweite Strassenpolygon neu zusammen und gibt dieselbe
Flaeche danach an anderer Listenstelle und mit anderem Ringstartpunkt zurueck. Das aenderte
88 Chunkdateien, in denen sich geometrisch nichts bewegt hatte (symmetrische Differenz der
Strassenvereinigung 0,00 m2 in allen 88). Hier steht dieselbe Welt zweimal: einmal in der
einen, einmal in der anderen Reihenfolge, und `zweite` beschreibt `a` ausserdem mit anderem
Startpunkt und anderer Wicklung."""
a = Polygon([(-990, 2490), (-980, 2490), (-980, 2480), (-990, 2480)])
b = Polygon([(-960, 2460), (-950, 2460), (-950, 2450), (-960, 2450)])
a_gedreht = Polygon([(-980, 2480), (-980, 2490), (-990, 2490), (-990, 2480)])
assert a.equals(a_gedreht) and list(a.exterior.coords) != list(a_gedreht.exterior.coords)
erste = chunk_payload(0, 0, land=[a, b], surfaces=[(a, "urban"), (b, "urban")],
roads=[(a, False), (b, True)], centerlines=[],
buildings=[(a, HEIGHT, False), (b, HEIGHT, True)],
palms=[(-990.0, 2490.0), (-960.0, 2460.0)], paths=[a, b])
zweite = chunk_payload(0, 0, land=[b, a_gedreht], surfaces=[(b, "urban"), (a_gedreht, "urban")],
roads=[(b, True), (a_gedreht, False)], centerlines=[],
buildings=[(b, HEIGHT, True), (a_gedreht, HEIGHT, False)],
palms=[(-960.0, 2460.0), (-990.0, 2490.0)], paths=[b, a_gedreht])
assert erste == zweite
def test_surfaces_keep_the_order_of_their_kinds():
"""Gegenprobe zum Sortieren: overview.py malt die Belaege in Listenreihenfolge uebereinander.
Der Parkrasen liegt unter den Feldern; sortierte man ueber die Arten hinweg, deckte der grosse
Rasen sie auf map.png wieder zu (19.573 m2 Feld allein im Flamingo Park)."""
rasen = Polygon([(-1000, 2500), (-900, 2500), (-900, 2400), (-1000, 2400)])
feld = Polygon([(-980, 2480), (-940, 2480), (-940, 2440), (-980, 2440)])
payload = chunk_payload(0, 0, land=[], surfaces=[(rasen, "park"), (feld, "field")], roads=[],
centerlines=[], buildings=[], palms=[])
assert [s["kind"] for s in payload["surfaces"]] == ["park", "field"]
def test_centerlines_keep_their_order_and_direction():
"""Die zweite Gegenprobe: Mittellinien werden bewusst nicht sortiert und nicht normalisiert.
vergeLampPoints() in src/world/props.js setzt jede Laterne abwechselnd links und rechts der
Fahrbahn (gerader Index links, ungerader rechts) und dreht sie in Laufrichtung der Linie.
Sortieren verschob an den ausgelieferten Chunks 1.848 von 3.921 Laternen auf die andere
Strassenseite; normalize() wuerde zusaetzlich jede zweite Linie umdrehen."""
spaet = LineString([(-900, 2400), (-950, 2400)])
frueh = LineString([(-990, 2490), (-960, 2490)])
payload = chunk_payload(0, 0, land=[], surfaces=[], roads=[], centerlines=[spaet, frueh],
buildings=[], palms=[])
assert payload["centerlines"] == [[[100, 100], [50, 100]], [[10, 10], [40, 10]]]
def test_split_by_chunk_all_yields_polygons_for_a_multi_part_intersection():
"""split_by_chunk_all() ist die Variante, die run.py tatsaechlich benutzt (nicht
split_by_chunk), und hat einen MultiPolygon-Zweig, den kein anderer Test trifft: ein
+32 -5
View File
@@ -72,12 +72,33 @@ def test_same_seed_gives_the_same_palms():
assert apply_park(world(), layout(), seed=7).palms == apply_park(world(), layout(), seed=7).palms
def test_corners_must_lie_at_a_road():
def corners(**moved):
game = {"sw": (0.0, 0.0), "se": (100.0, 0.0), "ne": (100.0, 200.0), "nw": (0.0, 200.0)}
return SimpleNamespace(game={**game, **moved})
def test_corners_must_lie_at_a_road_when_it_pulls_back():
"""Die eine Richtung: die Straße bleibt, wo sie ist, die Ecke steht daneben."""
roads = [(box(-20, -20, 0, 220), False), (box(100, -20, 120, 220), False)]
check_corners(SimpleNamespace(game={"sw": (0.0, 0.0), "se": (100.0, 0.0), "ne": (100.0, 200.0), "nw": (0.0, 200.0)}), roads)
far = SimpleNamespace(game={"sw": (0.0, 0.0), "se": (100.0, 0.0), "ne": (100.0, 200.0), "nw": (30.0, 200.0)})
check_corners(corners(), roads)
with pytest.raises(ParkError, match="Spielecke nw"):
check_corners(far, roads)
check_corners(corners(nw=(30.0, 200.0)), roads)
def test_a_road_that_grew_inward_over_the_corner_is_caught():
"""Die andere Richtung, und der Grund für die Messung gegen den Rand statt gegen die Fläche.
Die Westkante der linken Straße ist von x = 0 auf x = 6 nach innen gewandert; die SW- und die
NW-Ecke liegen jetzt 6 m tief in der Fahrbahn. apply_park() schnitte die verbreiterte Straße
wortlos wieder auf das alte Viereck zurück — deshalb muss die Prüfung hier abbrechen. Mit dem
Flächenabstand tut sie das nicht: er ist für eine Ecke mitten in der Straße 0 (die Zusicherung
unten misst genau das), und die alte Bedingung poly.distance(corner) <= 2.0 ließ den Fall
durchlaufen.
"""
grown = [(box(-20, -20, 6, 220), False), (box(100, -20, 120, 220), False)]
assert min(poly.distance(Point(0, 0)) for poly, _ in grown) == 0
with pytest.raises(ParkError, match="Spielecke sw"):
check_corners(corners(), grown)
def test_flamingo_park_goes_into_a_world_with_roads_around_the_park():
@@ -93,4 +114,10 @@ def test_flamingo_park_goes_into_a_world_with_roads_around_the_park():
# Bekannter Zustand: die Zufahrt zum Parkplatz quert die Sperrfläche der Tribüne und wird dort
# ausgeschnitten (Task 5). Jede weitere oder andere Warnung ist ein echter Befund.
assert [m for m in messages if m.startswith("Warnung")] == ["Warnung: Zufahrt schneidet die Sperrfläche tribuene"]
assert any(message.startswith("Flamingo Park:") for message in messages)
# Die Flächenzahl gehört mit in den Vergleich: _surface_kind() kennt nur die Sportarten aus
# FIELD_SPORTS und COURT_SPORTS und lässt jede andere wortlos weg. Taggt OpenStreetMap beim
# nächsten Abruf ein Feld um — aus "tennis" wird "pickleball", aus "soccer" ein "multi" —,
# verschwindet es aus dem Park, ohne dass eine Warnung oder ein anderer Test das bemerkt;
# hier sinkt dann die 14, und der Abzug wird geprüft statt der Park stillschweigend kleiner.
assert [m for m in messages if m.startswith("Flamingo Park:")] == [
"Flamingo Park: 2 Wegflächen, 14 Flächen, 11 Gebäude, Jefferson Avenue 12 m breit"]
+25 -1
View File
@@ -2,7 +2,7 @@ from pathlib import Path
import pytest
from leonida.park import Anchor, Keepout, ParkError, load_park, parse_park
from leonida.park import Anchor, Keepout, ParkError, load_elements, load_park, parse_park
DATA = Path(__file__).resolve().parents[1] / "data"
@@ -92,3 +92,27 @@ def test_committed_park_file_loads():
assert [k.name for k in config.keepouts] == ["hood-yard", "tribuene"]
assert (config.scale_u, config.offset_u) == (1.0, 6.25)
assert len(config.street.alley_cut) == 12
assert len(load_elements(config.osm_path)) > 0
@pytest.mark.parametrize("text, message", [
("{ abgeschnitten", "kein gültiges JSON"),
('{"license": "ODbL 1.0"}', "Feld 'elements' fehlt oder ist keine Liste"),
('{"elements": {"1": {}}}', "Feld 'elements' fehlt oder ist keine Liste"),
('{"elements": [42]}', "elements[1] braucht eine Tabelle 'tags'"),
('{"elements": [{"id": 7, "geometry": [[25.78, -80.14], [25.79, -80.14]]}]}',
"elements[1] (id 7) braucht eine Tabelle 'tags'"),
('{"elements": [{"id": 7, "tags": {"highway": "footway"}}]}',
"elements[1] (id 7) braucht 'geometry' als Liste von Punkten"),
('{"elements": [{"id": 7, "tags": {}, "geometry": [[25.78]]}]}',
"elements[1] (id 7) braucht 'geometry' als Liste von Punkten"),
])
def test_a_broken_osm_dump_aborts_with_the_file_name(tmp_path, text, message):
"""osm.py wird von Hand aufgerufen; ein alter oder halb geschriebener Abzug ist der Normalfall,
nicht die Ausnahme. Er soll denselben ParkError mit Dateinamen geben wie die Parkdatei, statt
als KeyError irgendwo in build_layout() zu landen."""
path = tmp_path / "flamingo-park.json"
path.write_text(text, encoding="utf-8")
with pytest.raises(ParkError, match="flamingo-park.json") as err:
load_elements(path)
assert message in str(err.value)
+14
View File
@@ -53,6 +53,20 @@ def test_path_is_cut_at_the_real_park_edge():
assert path.bounds[3] <= 1400 + 1.5 + 1e-6
def test_paths_stop_at_the_park_edge_instead_of_bulging_over_it():
"""LineString.buffer() setzt an jedes Wegende eine runde Kappe von einer halben Wegbreite.
Der letzte Stützpunkt liegt schon auf der Parkkante (_extend zielt dorthin), die Kappe ragte
also bis zu 1,5 m (Fußweg) bzw. 3 m (Hauptweg) darüber hinaus und lag als Betonzunge auf dem
Belag daneben — an den echten Daten 95,96 m² außerhalb des Vierecks.
"""
cfg = config()
quad = ParkFit(cfg).game_quad
(path,) = build_layout([way({"highway": "footway"}, (50, 150), (50, 260))], cfg, ParkFit(cfg)).paths
assert path.difference(quad).area == 0
assert path.bounds[3] == pytest.approx(1400.0)
def test_path_ending_near_the_west_edge_runs_on_to_the_road():
(path,) = layout([way({"highway": "footway"}, (40, 100), (3, 100))]).paths
assert path.contains(Point(1001, 1200))