"""Vergleichsbild für die Platzierung: Spielkarte (und optional das Satellitenbild) mit Grundriss des Modells. tools/extract/.venv/bin/python .claude/skills/placing-editor-models/scripts/render-site.py \\ --site site.json --model modell.json --at 1489 1067.5 [--rotation 0] \\ [--satellite bild.png --anchor-px 1000 213 --anchor-world 1525 1058 --mpp 0.15] \\ [--px-per-m 7] --out lage.png site.json kommt aus site-data.mjs, modell.json aus measure-model.mjs. Das Satellitenbild wird mit Norden oben angenommen: ein Punkt, der im Bild und in der Karte eindeutig ist (Anker), plus Meter je Bildpixel legen es auf Weltmeter. Beide Felder zeigen denselben Weltausschnitt (box aus site.json), damit der Nutzer die Lage des Grundrisses in echt und im Spiel direkt vergleichen kann. """ import argparse import json import math from PIL import Image, ImageDraw, ImageFont parser = argparse.ArgumentParser() parser.add_argument("--site", required=True) parser.add_argument("--model", required=True) parser.add_argument("--at", nargs=2, type=float, required=True, metavar=("X", "Y")) parser.add_argument("--rotation", type=float, default=0.0, help="Grad gegen den Uhrzeigersinn, Norden oben") parser.add_argument("--satellite") parser.add_argument("--anchor-px", nargs=2, type=float, metavar=("PX", "PY")) parser.add_argument("--anchor-world", nargs=2, type=float, metavar=("X", "Y")) parser.add_argument("--mpp", type=float, help="Meter je Pixel im Satellitenbild") parser.add_argument("--px-per-m", type=float, default=7.0) parser.add_argument("--out", required=True) args = parser.parse_args() site = json.load(open(args.site)) model = json.load(open(args.model)) box = site["box"] PPM = args.px_per_m W, H = round((box["x1"] - box["x0"]) * PPM), round((box["y1"] - box["y0"]) * PPM) try: font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 16) except OSError: font = ImageFont.load_default() def uv(X, Y): return ((X - box["x0"]) * PPM, (box["y1"] - Y) * PPM) def model_to_world(x, z): # wie placeTransform + three rotation.y: +x wird bei positivem Winkel zu Norden r = math.radians(args.rotation) return args.at[0] + x * math.cos(r) + z * math.sin(r), args.at[1] - (-x * math.sin(r) + z * math.cos(r)) def rect_polygon(min_xyz, max_xyz): corners = [(min_xyz[0], min_xyz[2]), (max_xyz[0], min_xyz[2]), (max_xyz[0], max_xyz[2]), (min_xyz[0], max_xyz[2])] return [uv(*model_to_world(x, z)) for x, z in corners] def area(group): return (group["max"][0] - group["min"][0]) * (group["max"][2] - group["min"][2]) def draw_footprint(image, with_labels): """Große Gruppen (Boden, Gras, Büsche über den ganzen Hof) nur als Umriss, kompakte gefüllt und beschriftet — sonst verdecken Flächengruppen die Gebäude, auf die es beim Vergleich ankommt.""" overlay = Image.new("RGBA", image.size, (0, 0, 0, 0)) d = ImageDraw.Draw(overlay) palette = [(230, 140, 40), (70, 130, 220), (200, 80, 160), (220, 200, 60), (90, 200, 200), (150, 110, 230)] total = area({"min": model["bounds"]["min"], "max": model["bounds"]["max"]}) or 1 groups = sorted(model["groups"], key=area, reverse=True) compact = [g for g in groups if area(g) < 0.35 * total] for group in groups: if area(group) >= 0.35 * total: d.polygon(rect_polygon(group["min"], group["max"]), outline=(255, 255, 255, 110)) for index, group in enumerate(compact): color = palette[index % len(palette)] alpha = 60 if area(group) >= 0.15 * total else 170 # breite Pflanzengruppen blasser d.polygon(rect_polygon(group["min"], group["max"]), fill=color + (alpha,), outline=(0, 0, 0, 230)) d.polygon(rect_polygon(model["bounds"]["min"], model["bounds"]["max"]), outline=(255, 255, 255, 255), width=3) if with_labels: taken = [] for group in sorted(compact, key=area): # Gebäude vor Pflanzenflächen beschriften if area(group) < 4: # Müllsäcke, Kameras usw. nicht beschriften continue cx = (group["min"][0] + group["max"][0]) / 2 cz = (group["min"][2] + group["max"][2]) / 2 u, v = uv(*model_to_world(cx, cz)) left, top, right, bottom = d.textbbox((u, v), group["name"], font=font) if any(left < r and right > l and top < b and bottom > t for l, t, r, b in taken): continue taken.append((left, top, right, bottom)) d.rectangle([left - 2, top - 1, right + 2, bottom + 1], fill=(0, 0, 0, 150)) d.text((u, v), group["name"], font=font, fill=(255, 255, 255, 255)) return Image.alpha_composite(image.convert("RGBA"), overlay) def game_panel(): image = Image.new("RGB", (W, H), (44, 90, 140)) d = ImageDraw.Draw(image) colors = {"park": (90, 150, 70), "urban": (200, 200, 200), "sand": (230, 210, 140)} for c in site["chunks"]: for poly in c["land"]: d.polygon([uv(*p) for p in poly[0]], fill=(170, 185, 130)) for c in site["chunks"]: for s in c["surfaces"]: d.polygon([uv(*p) for p in s["rings"][0]], fill=colors.get(s["kind"], (150, 150, 150))) for c in site["chunks"]: for r in c["roads"]: d.polygon([uv(*p) for p in r["rings"][0]], fill=(80, 80, 85)) for hole in r["rings"][1:]: d.polygon([uv(*p) for p in hole], fill=(170, 185, 130)) for c in site["chunks"]: for b in c["buildings"]: d.polygon([uv(*p) for p in b["rings"][0]], fill=(150, 110, 90), outline=(0, 0, 0)) for X, Y in site["palms"]: u, v = uv(X, Y) d.ellipse([u - 5, v - 5, u + 5, v + 5], fill=(20, 90, 20)) image = draw_footprint(image, with_labels=False) d = ImageDraw.Draw(image) for X, Y in site["lamps"]: u, v = uv(X, Y) d.ellipse([u - 6, v - 6, u + 6, v + 6], fill=(255, 220, 0, 255), outline=(0, 0, 0, 255)) # Chunk-Grenzen alle 250 m; Planquadrat-Namen oben links in jedem sichtbaren Teil eines Chunks for X in range(-1000, 2001, 250): if box["x0"] <= X <= box["x1"]: u, _ = uv(X, 0) d.line([(u, 0), (u, H)], fill=(255, 40, 40), width=2) for Y in range(-500, 2501, 250): if box["y0"] <= Y <= box["y1"]: _, v = uv(0, Y) d.line([(0, v), (W, v)], fill=(255, 40, 40), width=2) names = {c["key"]: c["name"] for c in site["chunks"]} for i in range(12): for j in range(12): left, right = max(box["x0"], -1000 + i * 250), min(box["x1"], -1000 + (i + 1) * 250) north, south = min(box["y1"], 2500 - j * 250), max(box["y0"], 2500 - (j + 1) * 250) if left < right and south < north and f"{i}_{j}" in names: u, v = uv(left, north) d.text((u + 6, v + 4), names[f"{i}_{j}"], font=font, fill=(255, 40, 40)) return image def satellite_panel(): real = Image.open(args.satellite).convert("RGB") k = 1 / PPM / args.mpp cx = args.anchor_px[0] + (box["x0"] - args.anchor_world[0]) / args.mpp cy = args.anchor_px[1] - (box["y1"] - args.anchor_world[1]) / args.mpp panel = real.transform((W, H), Image.AFFINE, (k, 0, cx, 0, k, cy), resample=Image.BICUBIC, fillcolor=(30, 30, 30)) return draw_footprint(panel, with_labels=True) panels = [("Spiel: Chunk-Daten, gelb Laternen, weiß Außenmaße", game_panel())] if args.satellite: if not (args.anchor_px and args.anchor_world and args.mpp): parser.error("--satellite braucht --anchor-px, --anchor-world und --mpp") panels.insert(0, (f"Echt: Satellitenbild, 1 px ≈ {args.mpp} m, Anker {args.anchor_world}", satellite_panel())) M = 30 out = Image.new("RGB", (W, len(panels) * (H + M)), (18, 18, 18)) d = ImageDraw.Draw(out) for index, (title, panel) in enumerate(panels): top = index * (H + M) d.text((8, top + 7), f"{title} · Mitte ({args.at[0]:g} | {args.at[1]:g}), Drehung {args.rotation:g}°", font=font, fill=(240, 240, 240)) out.paste(panel.convert("RGB"), (0, top + M)) out.save(args.out) print(args.out, out.size)