Files
gta6/tools/extract/overview.py
T
knsoandClaude Sonnet 5 f1c8371365 feat(extract): Minimap zeichnet Fahrbahn, Gehweg und Restflächen
overview.py las chunk["roads"] und item["bridge"] ohne Rückfall und riss mit Chunks im
Format aus Task 10 (carriageway/sidewalk/lots/bridges) einen KeyError auf. _paint_layers()
liest jetzt carriageway, sidewalk und lots getrennt; chunk["bridges"] braucht keine eigene
Regel mehr, weil seine Fahrbahn schon in carriageway steckt und sein begehbarer Streifen im
sidewalk. Markierungen werden nicht gezeichnet: bei 1 px je 3 m wäre eine 10-cm-Linie ein
Drittel Pixel und nur Rauschen.

In der Begehbarkeitsmaske sperren Fahrbahn und Restflächen jetzt aktiv, auch über der
Landplatte - dort fährt Verkehr, kein Fußgänger soll dort landen. Der Gehweg trägt dafür
auch ohne Landplatte, wie zuvor nur die Brücke; die alte Sonderregel für Brücken entfällt
darum ersatzlos.

Alle Fixtures in test_overview.py, die von Hand im alten Format standen, sind mitgezogen;
eine davon musste dabei die Zusicherung wechseln (Fahrbahn auf Land ist jetzt gesperrt statt
begehbar), zwei weitere kamen dazu (Restfläche und Brücke ohne Land). Der Gehweg-Streifen im
neuen Prüfkörper war im Planentwurf 3 statt 12 m breit: schmaler als eine 4-m-Maskenzelle,
die deshalb nie ganz hineinpasste, unabhängig von der Rundung - jetzt auf 12 m korrigiert wie
die Fahrbahn.

test_committed_map_files_match_the_committed_chunks ist bis Task 16 übersprungen: die
ausgelieferten Chunks unter public/data/vice-a bleiben laut Plan bis dahin im alten Format,
render_map() kennt das nicht mehr.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:41:19 +02:00

200 lines
9.1 KiB
Python

"""Gesamtansicht und Spielkarte des Ausschnitts aus den fertigen Chunk-Dateien.
Zeichnet ausschließlich das, was wirklich ausgeliefert wird — kein Kartenbild darunter.
Damit fallen Fehler auf, die kein Unit-Test sieht: gespiegelte Achsen, versetzte Chunks,
Beschriftungen der Originalkarte, die als Geometrie durchgerutscht sind.
Zwei Ausgaben:
- debug/overview.png: Prüfbild mit Palmen, 0,5 px/m.
- map.png, map-walkable.png und map.json in public/data/vice-a/ für die Minimap
(docs/superpowers/specs/2026-09-13-minimap-design.md).
cd tools/extract && uv run python overview.py
"""
import hashlib
import json
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
from leonida.coords import SLICE
OUT = Path("../../public/data/vice-a")
DST = Path("debug/overview.png")
SCALE = 0.5 # Pixel je Meter, Prüfbild
MAP_SCALE = 1.0 # Pixel je Meter, map.png
WALKABLE_SCALE = 0.25 # Pixel je Meter, map-walkable.png (4 m je Pixel)
WATER = (36, 72, 104)
LAND = (150, 162, 120)
ROAD = (74, 74, 78)
SIDEWALK = (206, 202, 194)
# field, court, pool und parking: Flamingo Park nach OpenStreetMap (docs/superpowers/specs/2026-09-15-flamingo-park-design.md)
SURFACE = {"sand": (226, 210, 150), "park": (120, 160, 90), "urban": (176, 176, 172),
"field": (96, 176, 78), "court": (92, 138, 118), "pool": (70, 150, 220), "parking": ROAD}
PATH = (226, 216, 190)
PALM = (40, 190, 70)
OUTLINE = (30, 30, 30)
TALL = 120.0 # Meter, ab der ein Gebäude die hellste Färbung bekommt
WALKABLE = 255
BLOCKED = 0
def _to_px(ring, ox, oy, scale=SCALE):
"""Chunk-lokale three.js-Koordinaten (x, z) -> Bildpixel."""
return [((x + ox - SLICE["x0"]) * scale, (SLICE["y1"] - (oy - z)) * scale) for x, z in ring]
def _draw(img, rings, ox, oy, fill, outline=None, scale=SCALE):
"""Fläche über eine Maske einsetzen, damit durch die Löcher die Ebene darunter bleibt.
Ein Loch mit Wasserfarbe zu übermalen wäre falsch: der Innenhof eines Blocks liegt auf
Stadtfläche, nicht im Wasser. Funktioniert für RGB-Bilder (fill als Tupel) und
Graustufenbilder (fill als Zahl) gleichermaßen.
"""
outer = _to_px(rings[0], ox, oy, scale)
xs = [p[0] for p in outer]
ys = [p[1] for p in outer]
x0, y0 = int(min(xs)) - 1, int(min(ys)) - 1
x1, y1 = int(max(xs)) + 2, int(max(ys)) + 2
if x1 <= x0 or y1 <= y0:
return
mask = Image.new("L", (x1 - x0, y1 - y0), 0)
md = ImageDraw.Draw(mask)
md.polygon([(x - x0, y - y0) for x, y in outer], fill=255, outline=255)
for hole in rings[1:]:
md.polygon([(x - x0, y - y0) for x, y in _to_px(hole, ox, oy, scale)], fill=0)
img.paste(fill, (x0, y0), mask)
if outline:
ImageDraw.Draw(img).polygon(outer, fill=None, outline=outline)
def _building_shade(height: float) -> tuple[int, int, int]:
t = min(1.0, height / TALL)
return (int(70 + 170 * t), int(70 + 120 * t), int(80 + 60 * t))
def _load_chunks(out_dir: Path) -> list[dict]:
return [json.loads(f.read_text()) for f in sorted(out_dir.glob("chunk_*.json"))]
def _paint_layers(img, chunks, scale, *, land, surface, sidewalk, lots, road, building,
outline=None, palm=None, path=None) -> dict:
"""Ebene für Ebene über alle Chunks, damit nichts von einem Nachbarchunk überdeckt wird.
surface ist eine Funktion der Belagsart, building eine Funktion der Gebäudehöhe; beide können
None liefern und die Fläche so aussparen (path=None lässt ebenso die ganze Ebene aus). land,
sidewalk, lots und road sind dagegen an jedem Aufrufort eine feste Füllung — in der
Begehbarkeitsmaske ausdrücklich WALKABLE oder BLOCKED, denn Fahrbahn und Restflächen sollen dort
aktiv sperren statt nur wirkungslos zu bleiben: dort fährt Verkehr, kein Fußgänger soll landen.
Reihenfolge von hinten nach vorn: Land, Beläge, Wege, Gehweg, Restflächen, Fahrbahn, Gebäude —
jede Ebene deckt die vorige ab.
chunk["markings"] wird hier nie gelesen: bei einem Pixel je Meter wäre eine 10 cm breite Linie
ein Drittel Pixel und damit nur Rauschen. chunk["bridges"] ebenfalls nicht mehr: ihre Fahrbahn
steckt schon in carriageway, ihr begehbarer Streifen im sidewalk — eine eigene Brücken-Regel
braucht es hier nicht mehr.
"""
stats = {"land": 0, "gehweg": 0, "restflaechen": 0, "strassen": 0, "gebaeude": 0, "palmen": 0}
placed = [(chunk, chunk["origin"][0], -chunk["origin"][1]) for chunk in chunks]
for chunk, ox, oy in placed:
for rings in chunk["land"]:
_draw(img, rings, ox, oy, land, scale=scale)
stats["land"] += 1
for chunk, ox, oy in placed:
for item in chunk["surfaces"]:
fill = surface(item["kind"])
if fill is not None:
_draw(img, item["poly"], ox, oy, fill, scale=scale)
if path is not None:
for chunk, ox, oy in placed:
for item in chunk.get("paths", []):
_draw(img, item["poly"], ox, oy, path, scale=scale)
for chunk, ox, oy in placed:
for item in chunk["sidewalk"]:
_draw(img, item["poly"], ox, oy, sidewalk, scale=scale)
stats["gehweg"] += 1
for chunk, ox, oy in placed:
for item in chunk["lots"]:
_draw(img, item["poly"], ox, oy, lots, scale=scale)
stats["restflaechen"] += 1
for chunk, ox, oy in placed:
for item in chunk["carriageway"]:
_draw(img, item["poly"], ox, oy, road, scale=scale)
stats["strassen"] += 1
for chunk, ox, oy in placed:
for item in chunk["buildings"]:
_draw(img, item["poly"], ox, oy, building(item["height"]), outline=outline, scale=scale)
stats["gebaeude"] += 1
if palm is not None:
for x, z in chunk["palms"]:
px, py = _to_px([(x, z)], ox, oy, scale)[0]
ImageDraw.Draw(img).ellipse([px - 1.6, py - 1.6, px + 1.6, py + 1.6], fill=palm)
stats["palmen"] += 1
return stats
def render(out_dir: Path, dst: Path) -> dict:
"""Debug-Gesamtansicht mit Palmen."""
size = int((SLICE["x1"] - SLICE["x0"]) * SCALE)
img = Image.new("RGB", (size, size), WATER)
stats = _paint_layers(img, _load_chunks(out_dir), SCALE, land=LAND, surface=SURFACE.__getitem__,
sidewalk=SIDEWALK, lots=ROAD, road=ROAD, building=_building_shade,
outline=OUTLINE, palm=PALM, path=PATH)
dst.parent.mkdir(parents=True, exist_ok=True)
img.save(dst)
return stats
def _walkable_mask(chunks: list[dict], width_m: int) -> Image.Image:
"""Begehbarkeitsmaske mit WALKABLE_SCALE, vorsichtig aus einem 1-m-Raster verdichtet.
Begehbar heißt: die Figur steht dort auf Kollision. Das tragen nur die Landplatte und der
Gehweg. Fahrbahn und Restflächen sperren aktiv, auch über der Landplatte — dort fährt Verkehr,
kein Fußgänger soll dort landen —, und Gebäude sind geschlossene Kästen. Für eine Brücke gibt es
keine eigene Regel mehr: ihr begehbarer Streifen liegt im Gehweg, der dort die Landplatte
ersetzt, ihre Fahrbahn sperrt wie jede andere. Ein Maskenpixel gilt nur als begehbar, wenn jeder
Quadratmeter darunter frei ist. Ein nur teilweise freier Pixel setzte die Figur sonst in eine
Wand oder vor die Kaimauer ins Wasser; an den ausgelieferten Daten traf das 4,3 % der Klicks
auf Gebäude. Die Landung erfolgt in der Pixelmitte (nearestWalkable in src/ui/mapMath.js),
also mindestens 2 m von jedem gesperrten Quadratmeter entfernt.
"""
fine = Image.new("L", (width_m, width_m), BLOCKED)
_paint_layers(fine, chunks, 1.0, land=WALKABLE, surface=lambda _kind: None,
sidewalk=WALKABLE, lots=BLOCKED, road=BLOCKED, building=lambda _height: BLOCKED)
step = round(1 / WALKABLE_SCALE)
size = width_m // step
cells = np.asarray(fine).reshape(size, step, size, step)
free = (cells == WALKABLE).all(axis=(1, 3))
return Image.fromarray(np.where(free, WALKABLE, BLOCKED).astype(np.uint8))
def render_map(out_dir: Path, dst_dir: Path) -> dict:
"""Kartenbild, Begehbarkeitsmaske und map.json für die Minimap."""
chunks = _load_chunks(out_dir)
width_m = SLICE["x1"] - SLICE["x0"]
picture = Image.new("RGB", (int(width_m * MAP_SCALE),) * 2, WATER)
_paint_layers(picture, chunks, MAP_SCALE, land=LAND, surface=SURFACE.__getitem__,
sidewalk=SIDEWALK, lots=ROAD, road=ROAD, building=_building_shade,
outline=OUTLINE, path=PATH)
walkable = _walkable_mask(chunks, width_m)
dst_dir.mkdir(parents=True, exist_ok=True)
picture.save(dst_dir / "map.png")
walkable.save(dst_dir / "map-walkable.png")
info = {
"scale": MAP_SCALE,
"walkable_scale": WALKABLE_SCALE,
"slice": dict(SLICE),
"manifest_sha256": hashlib.sha256((out_dir / "manifest.json").read_bytes()).hexdigest(),
}
(dst_dir / "map.json").write_text(json.dumps(info, indent=2) + "\n")
return {"map": picture.size, "walkable": walkable.size, "manifest_sha256": info["manifest_sha256"]}
if __name__ == "__main__":
print(render(OUT, DST), "->", DST)
print(render_map(OUT, OUT), "->", OUT)