Files
gta6/.claude/skills/placing-editor-models/scripts/render-site.py
T
knso 736dcbc1ad fix(skills): placing-editor-models nach dem Formatwechsel repariert
vergeLampPoints() und der Schlüssel roads existieren seit Teil 2 nicht mehr:
site-data.mjs importierte einen fehlenden Export (ESM-Link-Fehler, das
Skript starb vor der ersten Zeile) und las c.roads/c.centerlines;
render-site.py griff auf c["roads"] zu (KeyError). Beide lesen jetzt
chunkLampPoints(c) sowie die drei neuen Schichten carriageway, sidewalk
und lots (zusammen die frühere Korridorfläche); render-site.py bekommt
dafür eine eigene Vierfarb-Darstellung inklusive bridges.

Verifiziert mit einem echten Ausschnitt (1414..1564 | 1042.5..1092.5,
12 Chunks I5-L7): site-data.mjs liefert carriageway/sidewalk/lots/bridges
mit echten Werten, 4 Laternen und 3 Palmen im Fenster; render-site.py
zeichnet daraus ein plausibles Vergleichsbild ohne Absturz.
2026-09-18 06:15:55 +02:00

171 lines
8.4 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)))
# Der Straßenkorridor in seinen drei Schichten (site-data.mjs): Gehweg und Restflächen heller,
# die 15 cm tiefer liegende Fahrbahn im alten Straßengrau. Zusammen decken sie genau die Fläche
# ab, die vor dem Formatwechsel der eine Schlüssel "roads" hatte.
for key, farbe in (("sidewalk", (150, 148, 142)), ("lots", (110, 110, 112)),
("bridges", (80, 80, 85)), ("carriageway", (80, 80, 85))):
for c in site["chunks"]:
for r in c.get(key, []):
d.polygon([uv(*p) for p in r["rings"][0]], fill=farbe)
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)