"""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 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. 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"]: _draw(img, item["poly"], ox, oy, surface(item["kind"]), scale=scale) for chunk, ox, oy in placed: for item in chunk["roads"]: _draw(img, item["poly"], ox, oy, bridge if item["bridge"] else road, 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 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) # Begehbar ist, was Boden hat: Land, Beläge, Straßen und Brücken. Gebäude werden danach wieder # gesperrt; Wasser ist der Hintergrund. Innenhöfe bleiben über die Löcher begehbar. walkable = Image.new("L", (int(width_m * WALKABLE_SCALE),) * 2, BLOCKED) _paint_layers(walkable, chunks, WALKABLE_SCALE, land=WALKABLE, surface=lambda _kind: WALKABLE, road=WALKABLE, bridge=WALKABLE, building=lambda _height: BLOCKED) 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)