Files
gta6/tools/extract/leonida/emit.py
T
knsoandClaude Sonnet 5 bdc6a0ffe5 feat(extract): Chunk-Format für Fahrbahn, Bordstein, Gehweg und Markierung
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 <noreply@anthropic.com>
2026-09-17 09:41:36 +02:00

174 lines
8.5 KiB
Python

import json
from itertools import groupby
from pathlib import Path
import shapely
from shapely.geometry import Polygon, box
from .coords import CHUNK_SIZE, SLICE
N = (SLICE["x1"] - SLICE["x0"]) // CHUNK_SIZE # 12
def chunk_box(i: int, j: int) -> Polygon:
x0 = SLICE["x0"] + i * CHUNK_SIZE
y1 = SLICE["y1"] - j * CHUNK_SIZE
return box(x0, y1 - CHUNK_SIZE, x0 + CHUNK_SIZE, y1)
def split_by_chunk(poly: Polygon) -> dict[tuple[int, int], Polygon]:
out = {}
for j in range(N):
for i in range(N):
part = poly.intersection(chunk_box(i, j))
if not part.is_empty and part.area > 0.5:
out[(i, j)] = part
return out
def _rings(poly: Polygon, i: int, j: int) -> list[list[list[float]]]:
"""Aussenring zuerst, danach jedes Loch, in derselben Transformation.
Ruling 34: ein Loch (z.B. ein von Strassen umschlossener Block, ein Innenhof, ein Teich
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 _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.
"""
ox = SLICE["x0"] + i * CHUNK_SIZE
oy = SLICE["y1"] - j * CHUNK_SIZE
return {
"chunk": [i, j],
"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]),
"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),
"paths": sorted(({"poly": _rings(p, i, j)} for p in paths), key=lambda p: p["poly"]),
}
def chunk_box_of(i: int, j: int) -> Polygon:
return chunk_box(i, j)
def split_by_chunk_all(polys, i: int, j: int):
"""Alle Anteile der übergebenen Polygone, die in Chunk (i, j) liegen."""
cell = chunk_box(i, j)
for poly in polys:
part = poly.intersection(cell)
if not part.is_empty and part.area > 0.5:
if part.geom_type == "Polygon":
yield part
else:
yield from (g for g in part.geoms if g.geom_type == "Polygon" and g.area > 0.5)
def in_chunk(point, i: int, j: int) -> bool:
x0 = SLICE["x0"] + i * CHUNK_SIZE
y1 = SLICE["y1"] - j * CHUNK_SIZE
return x0 <= point[0] < x0 + CHUNK_SIZE and y1 - CHUNK_SIZE <= point[1] < y1
def write_debug_png(out_dir: Path, mosaic, payloads: list[dict]) -> None:
"""Ein Bild je Chunk: die extrahierten Vektoren über der Karte, zur Sichtprüfung."""
from PIL import Image, ImageDraw
out_dir.mkdir(parents=True, exist_ok=True)
for payload in payloads:
i, j = payload["chunk"]
x0, y0 = i * CHUNK_SIZE, j * CHUNK_SIZE
crop = Image.fromarray(mosaic[y0:y0 + CHUNK_SIZE, x0:x0 + CHUNK_SIZE]).convert("RGB")
draw = ImageDraw.Draw(crop)
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["carriageway"]:
ring = road["poly"][0]
draw.line([tuple(pt) for pt in ring], fill=(255, 0, 255), width=1)
for b in payload["buildings"]:
ring = b["poly"][0]
if len(ring) >= 3:
draw.polygon([tuple(pt) for pt in ring], outline=(255, 220, 0))
for x, z in payload["palms"]:
draw.ellipse([x - 2, z - 2, x + 2, z + 2], outline=(0, 200, 0))
crop.save(out_dir / f"chunk_{i}_{j}.png")
def write_chunks(out_dir: Path, payloads: list[dict], meta: dict) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
for payload in payloads:
i, j = payload["chunk"]
(out_dir / f"chunk_{i}_{j}.json").write_text(json.dumps(payload, separators=(",", ":")))
(out_dir / "manifest.json").write_text(json.dumps(meta, indent=1))