feat(extract): Kartenbild, Begehbarkeitsmaske und map.json für die Minimap

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-13 10:03:29 +02:00
1 parent 9f8f93acc8
commit 0b0376e564
5 files changed
+189 -38

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"scale": 1.0,
"walkable_scale": 0.25,
"slice": {
"x0": -1000,
"x1": 2000,
"y0": -500,
"y1": 2500
},
"manifest_sha256": "a14048879d3a3663e93ed488d52a065d8b0644de5fd1a510ac746249f25036f6"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

+93 -38
View File
@@ -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. Zeichnet ausschließlich das, was wirklich ausgeliefert wird — kein Kartenbild darunter.
Damit fallen Fehler auf, die kein Unit-Test sieht: gespiegelte Achsen, versetzte Chunks, Damit fallen Fehler auf, die kein Unit-Test sieht: gespiegelte Achsen, versetzte Chunks,
Beschriftungen der Originalkarte, die als Geometrie durchgerutscht sind. 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 cd tools/extract && uv run python overview.py
""" """
import hashlib
import json import json
from pathlib import Path from pathlib import Path
@@ -15,28 +21,34 @@ from leonida.coords import SLICE
OUT = Path("../../public/data/vice-a") OUT = Path("../../public/data/vice-a")
DST = Path("debug/overview.png") 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) WATER = (36, 72, 104)
LAND = (150, 162, 120) LAND = (150, 162, 120)
SURFACE = {"sand": (226, 210, 150), "park": (120, 160, 90), "urban": (176, 176, 172)} SURFACE = {"sand": (226, 210, 150), "park": (120, 160, 90), "urban": (176, 176, 172)}
ROAD = (74, 74, 78) ROAD = (74, 74, 78)
BRIDGE = (196, 92, 92) BRIDGE = (196, 92, 92)
PALM = (40, 190, 70) PALM = (40, 190, 70)
OUTLINE = (30, 30, 30)
TALL = 120.0 # Meter, ab der ein Gebäude die hellste Färbung bekommt 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.""" """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. """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 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] xs = [p[0] for p in outer]
ys = [p[1] for p in outer] ys = [p[1] for p in outer]
x0, y0 = int(min(xs)) - 1, int(min(ys)) - 1 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 = ImageDraw.Draw(mask)
md.polygon([(x - x0, y - y0) for x, y in outer], fill=255, outline=255) md.polygon([(x - x0, y - y0) for x, y in outer], fill=255, outline=255)
for hole in rings[1:]: 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) img.paste(fill, (x0, y0), mask)
if outline: if outline:
ImageDraw.Draw(img).polygon(outer, fill=None, outline=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: def render(out_dir: Path, dst: Path) -> dict:
"""Debug-Gesamtansicht mit Palmen."""
size = int((SLICE["x1"] - SLICE["x0"]) * SCALE) size = int((SLICE["x1"] - SLICE["x0"]) * SCALE)
img = Image.new("RGB", (size, size), WATER) img = Image.new("RGB", (size, size), WATER)
chunks = [json.loads(f.read_text()) for f in sorted(out_dir.glob("chunk_*.json"))] stats = _paint_layers(img, _load_chunks(out_dir), SCALE, land=LAND, surface=SURFACE.__getitem__,
stats = {"land": 0, "strassen": 0, "bruecken": 0, "gebaeude": 0, "palmen": 0} road=ROAD, bridge=BRIDGE, building=_building_shade, outline=OUTLINE, palm=PALM)
# 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
dst.parent.mkdir(parents=True, exist_ok=True) dst.parent.mkdir(parents=True, exist_ok=True)
img.save(dst) img.save(dst)
return stats 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__": if __name__ == "__main__":
print(render(OUT, DST), "->", DST) print(render(OUT, DST), "->", DST)
print(render_map(OUT, OUT), "->", OUT)
+85
View File
@@ -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