"""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) SURFACE = {"sand": (226, 210, 150), "park": (120, 160, 90), "urban": (176, 176, 172)} ROAD = (74, 74, 78) 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, road, bridge, building, outline=None, palm=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. palm=None zeichnet keine Palmen; eine Füllung None lässt Beläge oder Straßen aus. Reihenfolge und Füllung entsprechen exakt dem bisherigen Prüfbild, damit dessen Ausgabe bytegleich bleibt. """ stats = {"land": 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) for chunk, ox, oy in placed: for item in chunk["roads"]: fill = bridge if item["bridge"] else road if fill is not None: _draw(img, item["poly"], ox, oy, fill, scale=scale) stats["bruecken" if item["bridge"] else "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__, road=ROAD, bridge=BRIDGE, building=_building_shade, outline=OUTLINE, palm=PALM) 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. Die tragen nur die Landplatte und die Brücken — Straßen und Beläge sind reine Auflagen (DECALS in src/world/streaming.js) —, und Gebäude sind geschlossene Kästen. 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, road=None, 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__, road=ROAD, bridge=BRIDGE, building=_building_shade, outline=OUTLINE) 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)