From bdc6a0ffe503c9cb80f4b2fa056e5b95c00e9994 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 09:41:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(extract):=20Chunk-Format=20f=C3=BCr=20Fahr?= =?UTF-8?q?bahn,=20Bordstein,=20Gehweg=20und=20Markierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chunk_payload() liefert jetzt carriageway, curb, sidewalk, markings, lots, bridges und edges statt roads und centerlines - die Ausgabe fuer Task 12 (Pipeline) und Task 13/14 (Zeichnen). curb ist ein Linienzug: Bordstein hat keine eigene Flaeche, Loch und Fahrbahn teilen sich den Umriss. _line() normalisiert die Laufrichtung wie _rings() die Wicklung - anders als bei den alten centerlines duerfen curb und edges das, weil die Laternen ihre Seite seit Teil 2 aus Kantenrichtung und Gehwegpruefung waehlen, nicht mehr aus der Listenposition. Selbstpruefung zur Lauf-Stabilitaet ging ueber die zwei mitgelieferten Tests hinaus, die bridges und edges nur leer durchreichen. Ad-hoc nachgerechnet und dabei eine Luecke gefunden: Der Sortierschluessel von edges deckte nur (line, klasse) ab, nicht auch breite und fahrbahn wie bei buildings ueblich (dort alle vier Felder). Bei gleichem line+klasse, aber unterschiedlicher breite/fahrbahn, waere die Ausgabe wieder an der Listenposition haengengeblieben - in der echten Pipeline legt SECTION[klasse] beide Werte fest und kann so nicht divergieren, der Schluessel deckt jetzt trotzdem alle vier Felder ab, wie bei buildings. test_centerlines_keep_their_order_and_direction geht weg: Sie bewies die jetzt bewusst umgekehrte Zusicherung (Reihenfolge und Richtung tragen Bedeutung); centerlines selbst entfaellt. Die zwei neuen Tests aus dem Auftrag belegen das Gegenteil fuer die Nachfolgefelder. Bricht wie angekuendigt run.py (ruft chunk_payload noch mit roads=/centerlines= auf) - das repariert Task 11/12. Anders als im Auftrag angenommen bleibt die Testsuite komplett gruen (248/248): test_park_apply.py und test_park_street.py haengen an park.py's eigenem World, nicht an chunk_payload, und kein anderer Test importiert emit.py. Co-Authored-By: Claude Sonnet 5 --- tools/extract/leonida/emit.py | 45 ++++++++++++------- tools/extract/tests/test_emit.py | 77 +++++++++++++++++++++----------- 2 files changed, 81 insertions(+), 41 deletions(-) diff --git a/tools/extract/leonida/emit.py b/tools/extract/leonida/emit.py index 4c347d6..6bc4b1a 100644 --- a/tools/extract/leonida/emit.py +++ b/tools/extract/leonida/emit.py @@ -71,21 +71,27 @@ def _sorted_surfaces(entries: list[dict]) -> list[dict]: for entry in sorted(run, key=lambda e: e["poly"])] -def chunk_payload(i, j, *, land, surfaces, roads, centerlines, buildings, palms, paths=()) -> dict: +def _line(line, i: int, j: int) -> list[list[float]]: + """Ein Linienzug in Chunk-Koordinaten, richtungsunabhängig festgelegt. + + normalize() dreht eine Linie in eine feste Laufrichtung. Das ist hier anders als bei den + früheren centerlines erwünscht: Die Laternen wählen ihre Seite seit Teil 2 aus der + Kantenrichtung *und* prüfen gegen die Gehwegfläche, hängen also nicht mehr an der + Listenposition (siehe props.js). Damit darf die Ausgabe sortiert und normalisiert werden, + und ein Lauf erzeugt wieder Datei für Datei dasselbe. + """ + ox = SLICE["x0"] + i * CHUNK_SIZE + oy = SLICE["y1"] - j * CHUNK_SIZE + return [[round(x - ox, 2), round(-y + oy, 2)] for x, y in shapely.normalize(line).coords] + + +def chunk_payload(i, j, *, land, surfaces, carriageway, curb, sidewalk, markings, lots, + bridges, edges, 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(). + Schluessel sind dann bytegleich und ihre Reihenfolge wieder egal. """ ox = SLICE["x0"] + i * CHUNK_SIZE oy = SLICE["y1"] - j * CHUNK_SIZE @@ -94,14 +100,21 @@ def chunk_payload(i, j, *, land, surfaces, roads, centerlines, buildings, palms, "origin": [ox, -oy], "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], + "carriageway": sorted(({"poly": _rings(p, i, j), "klasse": k} for p, k in carriageway), + key=lambda c: (c["poly"], c["klasse"])), + "curb": sorted(_line(l, i, j) for l in curb), + "sidewalk": sorted(({"poly": _rings(p, i, j)} for p in sidewalk), key=lambda s: s["poly"]), + "markings": sorted(({"poly": _rings(p, i, j), "art": a} for p, a in markings), + key=lambda m: (m["poly"], m["art"])), + "lots": sorted(({"poly": _rings(p, i, j)} for p in lots), key=lambda l: l["poly"]), + "bridges": sorted(({"poly": _rings(p, i, j)} for p in bridges), key=lambda b: b["poly"]), + "edges": sorted(({"line": _line(l, i, j), "klasse": k, "breite": w, "fahrbahn": f} + for l, k, w, f in edges), + key=lambda e: (e["line"], e["klasse"], e["breite"], e["fahrbahn"])), "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": sorted(({"poly": _rings(p, i, j)} for p in paths), key=lambda p: p["poly"]), } @@ -140,7 +153,7 @@ def write_debug_png(out_dir: Path, mosaic, payloads: list[dict]) -> None: for rings in payload["land"]: ring = rings[0] # nur der Aussenring; Loecher werden nicht gezeichnet draw.line([tuple(pt) for pt in ring] + [tuple(ring[0])], fill=(0, 255, 255), width=1) - for road in payload["roads"]: + for road in payload["carriageway"]: ring = road["poly"][0] draw.line([tuple(pt) for pt in ring], fill=(255, 0, 255), width=1) for b in payload["buildings"]: diff --git a/tools/extract/tests/test_emit.py b/tools/extract/tests/test_emit.py index c4359b2..209a4e0 100644 --- a/tools/extract/tests/test_emit.py +++ b/tools/extract/tests/test_emit.py @@ -1,4 +1,4 @@ -from shapely.geometry import LineString, Polygon +from shapely.geometry import 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 @@ -13,20 +13,24 @@ def test_coordinates_are_chunk_local_three_js(): # chunk_payload() nimmt (wie in run.py) bereits chunk-geschnittene Geometrie entgegen; # BIG selbst überspannt vier Chunks (siehe Test oben), darum hier zuerst zuschneiden. clipped = split_by_chunk(BIG)[(0, 0)] - payload = chunk_payload(0, 0, land=[clipped], surfaces=[], roads=[], centerlines=[], buildings=[], palms=[]) + payload = chunk_payload(0, 0, land=[clipped], surfaces=[], carriageway=[], curb=[], sidewalk=[], + markings=[], lots=[], bridges=[], edges=[], buildings=[], palms=[]) ring = payload["land"][0][0] # Ruling 34: [0] ist jetzt der Aussenring assert payload["origin"] == [-1000, -2500] # three-z = -Y assert min(x for x, _ in ring) >= -0.01 assert max(x for x, _ in ring) <= 250.01 def test_payload_matches_schema(): - payload = chunk_payload(2, 3, land=[], surfaces=[], roads=[], centerlines=[], buildings=[], palms=[]) - assert set(payload) == {"chunk", "origin", "land", "surfaces", "roads", "centerlines", "buildings", "palms", "paths"} + payload = chunk_payload(2, 3, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[], markings=[], + lots=[], bridges=[], edges=[], buildings=[], palms=[]) + assert set(payload) == {"chunk", "origin", "land", "surfaces", "carriageway", "curb", "sidewalk", + "markings", "lots", "bridges", "edges", "buildings", "palms", "paths"} assert payload["chunk"] == [2, 3] def test_paths_are_chunk_local_rings_like_roads(): path = Polygon([(-990, 2490), (-980, 2490), (-980, 2480), (-990, 2480)]) - payload = chunk_payload(0, 0, land=[], surfaces=[], roads=[], centerlines=[], buildings=[], palms=[], paths=[path]) + payload = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[], curb=[], sidewalk=[], markings=[], + lots=[], bridges=[], edges=[], buildings=[], palms=[], paths=[path]) (entry,) = payload["paths"] assert set(entry) == {"poly"} ring = entry["poly"][0] @@ -38,8 +42,8 @@ def test_polygon_holes_survive_into_the_payload(): gehen sonst 449.876 m2 Strassenloch und 208.657 m2 Stadtloch verloren.""" outer = Polygon([(-1000, 2250), (-800, 2250), (-800, 2450), (-1000, 2450)], [[(-950, 2300), (-870, 2300), (-870, 2400), (-950, 2400)]]) - payload = chunk_payload(0, 0, land=[outer], surfaces=[], roads=[], centerlines=[], - buildings=[], palms=[]) + payload = chunk_payload(0, 0, land=[outer], surfaces=[], carriageway=[], curb=[], sidewalk=[], + markings=[], lots=[], bridges=[], edges=[], buildings=[], palms=[]) assert len(payload["land"]) == 1 rings = payload["land"][0] assert len(rings) == 2 # Aussenring und Loch @@ -63,11 +67,13 @@ def test_the_same_geometry_in_another_order_gives_the_same_output(): 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=[], + carriageway=[(a, "wohnstrasse"), (b, "wohnstrasse")], curb=[], sidewalk=[], + markings=[], lots=[], bridges=[b], edges=[], 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=[], + carriageway=[(b, "wohnstrasse"), (a_gedreht, "wohnstrasse")], curb=[], sidewalk=[], + markings=[], lots=[], bridges=[b], edges=[], buildings=[(b, HEIGHT, True), (a_gedreht, HEIGHT, False)], palms=[(-960.0, 2460.0), (-990.0, 2490.0)], paths=[b, a_gedreht]) assert erste == zweite @@ -80,25 +86,12 @@ def test_surfaces_keep_the_order_of_their_kinds(): 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=[]) + payload = chunk_payload(0, 0, land=[], surfaces=[(rasen, "park"), (feld, "field")], + carriageway=[], curb=[], sidewalk=[], markings=[], lots=[], bridges=[], + edges=[], 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 @@ -112,3 +105,37 @@ def test_split_by_chunk_all_yields_polygons_for_a_multi_part_intersection(): parts = list(split_by_chunk_all([staple], 0, 0)) assert len(parts) == 2 assert all(p.geom_type == "Polygon" for p in parts) + + +def test_neue_schichten_stehen_im_chunk(): + from shapely.geometry import LineString, box + payload = chunk_payload( + 0, 0, + land=[box(0, 2400, 100, 2500)], surfaces=[], + carriageway=[(box(10, 2400, 20, 2500), "wohnstrasse")], + curb=[LineString([(10, 2400), (10, 2500)])], + sidewalk=[box(20, 2400, 23, 2500)], + markings=[(box(14, 2440, 16, 2443), "gelb_gestrichelt")], + lots=[box(40, 2400, 60, 2420)], + bridges=[], edges=[(LineString([(15, 2400), (15, 2500)]), "wohnstrasse", 12.0, 7.0)], + buildings=[], palms=[]) + assert payload["carriageway"][0]["klasse"] == "wohnstrasse" + assert payload["markings"][0]["art"] == "gelb_gestrichelt" + assert payload["edges"][0]["klasse"] == "wohnstrasse" + assert payload["edges"][0]["fahrbahn"] == 7.0 + assert len(payload["curb"][0]) == 2, "der Bordstein ist ein Linienzug, keine Fläche" + assert "roads" not in payload and "centerlines" not in payload + + +def test_neue_schichten_sind_lauf_stabil(): + """Zwei Aufrufe mit vertauschter Eingabe liefern dieselbe Ausgabe (Fixrunde 3 aus Teil 1).""" + from shapely.geometry import LineString, box + a, b = box(10, 2400, 20, 2450), box(30, 2400, 40, 2450) + l1, l2 = LineString([(0, 2400), (0, 2450)]), LineString([(5, 2400), (5, 2450)]) + eins = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[(a, "gasse"), (b, "gasse")], + curb=[l1, l2], sidewalk=[a], markings=[(a, "zebra"), (b, "zebra")], + lots=[a, b], bridges=[], edges=[], buildings=[], palms=[]) + zwei = chunk_payload(0, 0, land=[], surfaces=[], carriageway=[(b, "gasse"), (a, "gasse")], + curb=[l2, l1], sidewalk=[a], markings=[(b, "zebra"), (a, "zebra")], + lots=[b, a], bridges=[], edges=[], buildings=[], palms=[]) + assert eins == zwei