Ablauf wie beim Hood Yard mit den gefundenen Fallstricken (Dreiecke je Knoten, Bodenplatte +6 cm, globale Materiallisten, Asset-Test je Ort, launch.json nur im Hauptordner, gl.finish-Messung, Vorwärmen prüfen) und Werkzeugen: Modell vermessen, Kartendaten eines Weltfensters, Vergleichsbild Satellit/Spiel, Screenshot-Dekodierung, erprobte Browser-Ausschnitte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
8.0 KiB
Python
166 lines
8.0 KiB
Python
"""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)
|