Files
gta6/tools/extract/overview.py
T
knsoandClaude Sonnet 5 6c73b7f816 fix(extract): Fahrbahn, Restflächen und Brücken sind begehbar, nicht nur der Gehweg
Korrektur eines Fehlers im Plantext, nicht in der vorigen Umsetzung: "begehbar" ist in
src/ui/mapMath.js (nearestWalkable()/landingFor()) als "Kollisionsboden ohne Gebäude"
definiert, nicht als Fußgängerzone. Fahrbahn ist die kollidierende Mulde, Restflächen
liegen auf der Bodenplatte, Brücken haben ihr eigenes Deck - alle drei tragen wie die
Landplatte und der Gehweg. Nur Gebäude sperren; Wasser bleibt gesperrt, weil nichts
darüber gemalt wird. Ein Klick auf eine Straße oder einen Damm über Wasser landet damit
wieder auf ihr, statt ins Nichts zu greifen.

chunk["bridges"] wird dafür wieder gelesen und zusätzlich im Kartenbild in BRIDGE
gezeichnet, nach der Fahrbahn, damit eine Brücke als Brücke zu sehen ist und nicht als
Straße im Wasser.

test_walkable_mask_counts_only_ground_with_collision und
test_walkable_mask_blocks_buildings_and_water zurückgestellt; ein neuer Test sichert die
Brückenfarbe im Kartenbild. test_gehweg_ist_begehbar_die_fahrbahn_nicht sagte nach der
Korrektur nicht mehr die Wahrheit über sich selbst - Fahrbahn ist jetzt auch begehbar -
und ist umbenannt in test_gehweg_und_fahrbahn_sind_beide_begehbar. Der Skip-Grund von
test_committed_map_files_match_the_committed_chunks nennt jetzt die Bedingung fürs
Entfernen statt einer Aufgabennummer.

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

206 lines
9.6 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)
BRIDGE = (196, 92, 92)
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, bridge, 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, road und bridge sind dagegen an jedem Aufrufort eine feste Füllung: in der
Begehbarkeitsmaske für alle fünf WALKABLE, denn nach der Bedeutung in nearestWalkable()
(src/ui/mapMath.js) ist ein Pixel begehbar, wenn seine Fläche Kollisionsboden ohne Gebäude ist —
und Fahrbahn (die kollidierende Mulde), Restflächen (auf der Bodenplatte) und Brücken (eigenes
Deck) haben Kollision, Beläge und Wege dagegen nicht.
Reihenfolge von hinten nach vorn: Land, Beläge, Wege, Gehweg, Restflächen, Fahrbahn, Brücken,
Gebäude — jede Ebene deckt die vorige ab. Brücken zuletzt, damit eine Brücke als Brücke zu sehen
ist und nicht als Straße im Wasser: ihre Fahrbahn steckt schon in carriageway an derselben Stelle.
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.
"""
stats = {"land": 0, "gehweg": 0, "restflaechen": 0, "strassen": 0, "bruecken": 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["bridges"]:
_draw(img, item["poly"], ox, oy, bridge, scale=scale)
stats["bruecken"] += 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, bridge=BRIDGE,
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 hier wörtlich, was nearestWalkable() in src/ui/mapMath.js daraus liest: die ganze
Fläche eines Pixels ist Kollisionsboden ohne Gebäude. Das sind Landplatte, Gehweg, Fahrbahn,
Restflächen und Brückendecks — alle fünf tragen für sich, auch eine Brücke über Wasser ganz ohne
Landplatte darunter. Beläge und Wege bleiben reine Auflagen ohne eigene Kollision, Gebäude sind
geschlossene Kästen, und Wasser bleibt gesperrt, weil dort nichts gemalt wird. 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=WALKABLE, road=WALKABLE, bridge=WALKABLE, 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, bridge=BRIDGE, 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)