diff --git a/public/data/vice-a/map-walkable.png b/public/data/vice-a/map-walkable.png new file mode 100644 index 0000000..47a1243 Binary files /dev/null and b/public/data/vice-a/map-walkable.png differ diff --git a/public/data/vice-a/map.json b/public/data/vice-a/map.json new file mode 100644 index 0000000..185eb5d --- /dev/null +++ b/public/data/vice-a/map.json @@ -0,0 +1,11 @@ +{ + "scale": 1.0, + "walkable_scale": 0.25, + "slice": { + "x0": -1000, + "x1": 2000, + "y0": -500, + "y1": 2500 + }, + "manifest_sha256": "a14048879d3a3663e93ed488d52a065d8b0644de5fd1a510ac746249f25036f6" +} diff --git a/public/data/vice-a/map.png b/public/data/vice-a/map.png new file mode 100644 index 0000000..f52f08c Binary files /dev/null and b/public/data/vice-a/map.png differ diff --git a/tools/extract/overview.py b/tools/extract/overview.py index dc2e8cf..2610b7f 100644 --- a/tools/extract/overview.py +++ b/tools/extract/overview.py @@ -1,11 +1,17 @@ -"""Gesamtansicht des Ausschnitts aus den fertigen Chunk-Dateien. +"""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 @@ -15,28 +21,34 @@ from leonida.coords import SLICE OUT = Path("../../public/data/vice-a") DST = Path("debug/overview.png") -SCALE = 0.5 # Pixel je Meter +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): +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] + 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): +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. + 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) + 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 @@ -47,49 +59,92 @@ def _draw(img, rings, ox, oy, fill, outline=None): 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)], fill=0) + 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) - chunks = [json.loads(f.read_text()) for f in sorted(out_dir.glob("chunk_*.json"))] - stats = {"land": 0, "strassen": 0, "bruecken": 0, "gebaeude": 0, "palmen": 0} - - # Ebene für Ebene über alle Chunks, damit nichts von einem Nachbarchunk überdeckt wird. - for chunk in chunks: - ox, oy = chunk["origin"][0], -chunk["origin"][1] - for rings in chunk["land"]: - _draw(img, rings, ox, oy, LAND) - stats["land"] += 1 - for chunk in chunks: - ox, oy = chunk["origin"][0], -chunk["origin"][1] - for surface in chunk["surfaces"]: - _draw(img, surface["poly"], ox, oy, SURFACE[surface["kind"]]) - for chunk in chunks: - ox, oy = chunk["origin"][0], -chunk["origin"][1] - for road in chunk["roads"]: - _draw(img, road["poly"], ox, oy, BRIDGE if road["bridge"] else ROAD) - stats["bruecken" if road["bridge"] else "strassen"] += 1 - for chunk in chunks: - ox, oy = chunk["origin"][0], -chunk["origin"][1] - for building in chunk["buildings"]: - t = min(1.0, building["height"] / TALL) - shade = (int(70 + 170 * t), int(70 + 120 * t), int(80 + 60 * t)) - _draw(img, building["poly"], ox, oy, shade, outline=(30, 30, 30)) - stats["gebaeude"] += 1 - for x, z in chunk["palms"]: - px, py = _to_px([(x, z)], ox, oy)[0] - ImageDraw.Draw(img).ellipse([px - 1.6, py - 1.6, px + 1.6, py + 1.6], fill=PALM) - stats["palmen"] += 1 - + 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) diff --git a/tools/extract/tests/test_overview.py b/tools/extract/tests/test_overview.py new file mode 100644 index 0000000..8b41e05 --- /dev/null +++ b/tools/extract/tests/test_overview.py @@ -0,0 +1,85 @@ +import hashlib +import json +from pathlib import Path + +from PIL import Image + +import overview +from leonida.coords import SLICE + + +def _ring(x0, z0, x1, z1): + return [[x0, z0], [x1, z0], [x1, z1], [x0, z1], [x0, z0]] + + +def _fixture(tmp_path: Path) -> Path: + """Ein einziger Chunk A1 (Nordwest): Land, eine Straße, ein Gebäude, eine Palme. + + Chunk-lokale Koordinaten von chunk_0_0 fallen bei scale 1 genau auf die Kartenpixel, + weil der Chunk-Ursprung die Nordwest-Ecke des Ausschnitts ist. + """ + chunk = { + "chunk": [0, 0], + "origin": [SLICE["x0"], -SLICE["y1"]], + "land": [[_ring(0, 0, 250, 250)]], + "surfaces": [], + "roads": [{"poly": [_ring(100, 0, 120, 250)], "bridge": False}], + "centerlines": [], + "buildings": [{"poly": [_ring(20, 20, 60, 60)], "height": 12.0, "source": "district", "speculative": False}], + "palms": [[200.0, 200.0]], + } + (tmp_path / "chunk_0_0.json").write_text(json.dumps(chunk)) + (tmp_path / "manifest.json").write_text('{"slice": "vice-a"}\n') + return tmp_path + + +def test_map_picture_has_one_pixel_per_metre_and_no_palms(tmp_path): + src = tmp_path / "chunks" + src.mkdir() + _fixture(src) + dst = tmp_path / "out" + overview.render_map(src, dst) + picture = Image.open(dst / "map.png") + assert picture.size == (3000, 3000) + assert picture.getpixel((110, 125)) == overview.ROAD + assert picture.getpixel((40, 40)) == overview._building_shade(12.0) + assert picture.getpixel((200, 200)) == overview.LAND # Palme wird nicht gezeichnet + assert picture.getpixel((1500, 1500)) == overview.WATER + + +def test_walkable_mask_blocks_buildings_and_water(tmp_path): + src = tmp_path / "chunks" + src.mkdir() + _fixture(src) + dst = tmp_path / "out" + overview.render_map(src, dst) + mask = Image.open(dst / "map-walkable.png") + assert mask.mode == "L" + assert mask.size == (750, 750) + assert mask.getpixel((10, 10)) == overview.BLOCKED # Gebäude bei 40 m / 40 m + assert mask.getpixel((27, 31)) == overview.WALKABLE # Straße bei 110 m / 125 m + assert mask.getpixel((50, 50)) == overview.WALKABLE # Land bei 200 m / 200 m + assert mask.getpixel((375, 375)) == overview.BLOCKED # Wasser + + +def test_map_json_carries_scale_slice_and_manifest_fingerprint(tmp_path): + src = tmp_path / "chunks" + src.mkdir() + _fixture(src) + dst = tmp_path / "out" + overview.render_map(src, dst) + info = json.loads((dst / "map.json").read_text()) + assert info["scale"] == 1.0 + assert info["walkable_scale"] == 0.25 + assert info["slice"] == SLICE + assert info["manifest_sha256"] == hashlib.sha256((src / "manifest.json").read_bytes()).hexdigest() + + +def test_debug_overview_still_draws_palms(tmp_path): + src = tmp_path / "chunks" + src.mkdir() + _fixture(src) + dst = tmp_path / "debug.png" + stats = overview.render(src, dst) + assert stats["palmen"] == 1 + assert Image.open(dst).getpixel((100, 100)) == overview.PALM # 200 m bei 0,5 px/m