chore(skills): Projekt-Skill zum Platzieren von three-d-stage-Modellen

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>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-14 19:22:20 +02:00
1 parent 7e4af1d0bc
commit 6a056262a8
6 files changed
+560

No files matched your search

@@ -0,0 +1,16 @@
"""Holt ein JPEG aus einer gespeicherten javascript_tool-Ausgabe (tool-results/*.txt) und schreibt es als Datei.
python3 .claude/skills/placing-editor-models/scripts/decode-view.py <tool-result.txt> <bild.jpg>
"""
import base64
import json
import re
import sys
source, target = sys.argv[1], sys.argv[2]
text = "".join(item.get("text", "") for item in json.load(open(source)))
match = re.search(r"data:image/jpeg;base64,([A-Za-z0-9+/=]+)", text)
if not match:
sys.exit("keine JPEG-Datenadresse gefunden")
open(target, "wb").write(base64.b64decode(match.group(1)))
print("geschrieben", target)
@@ -0,0 +1,109 @@
// Vermisst eine .glb aus three-d-stage (Quelle oder aufbereitete Datei) für die Platzierung:
//
// node .claude/skills/placing-editor-models/scripts/measure-model.mjs <modell.glb> [bericht.json]
//
// Gibt eine kurze Übersicht aus und schreibt optional das volle JSON, das render-site.py für die
// Grundriss-Überlagerung liest. Dreiecke werden je Knoten gezählt: three-d-stage benutzt Meshes
// mehrfach, die Summe über die Meshes allein unterschätzt, was gezeichnet wird (Hood Yard: 0,75 statt
// 2,9 Mio.).
import { createHash } from "node:crypto";
import { open, stat, writeFile } from "node:fs/promises";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds } from "@gltf-transform/functions";
import { MeshoptDecoder } from "meshoptimizer";
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../../../../tools/assets/bumpExtension.mjs";
const [source, reportPath] = process.argv.slice(2);
if (!source) {
console.error("Aufruf: node measure-model.mjs <modell.glb> [bericht.json]");
process.exit(1);
}
/** asset.generator aus dem JSON-Block der .glb; NodeIO überschreibt ihn beim Einlesen. */
async function readGenerator(path) {
const file = await open(path, "r");
try {
const header = Buffer.alloc(20);
await file.read(header, 0, 20, 0);
const json = Buffer.alloc(header.readUInt32LE(12));
await file.read(json, 0, json.length, 20);
return JSON.parse(json.toString("utf8")).asset?.generator ?? "(unbekannt)";
} finally {
await file.close();
}
}
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions([...ALL_EXTENSIONS, EXTMaterialsBump])
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const document = await io.read(source);
const root = document.getRoot();
const round = (values) => values.map((value) => Number(value.toFixed(2)));
const materials = new Map();
for (const node of root.listNodes()) {
for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
const material = primitive.getMaterial();
const name = material?.getName() ?? "(ohne Material)";
const entry = materials.get(name) ?? {
name, drawnTriangles: 0, primitives: 0,
alphaMode: material?.getAlphaMode() ?? "OPAQUE",
bump: Boolean(material?.getExtension(EXT_MATERIALS_BUMP)),
};
entry.drawnTriangles += (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION").getCount()) / 3;
entry.primitives++;
materials.set(name, entry);
}
}
const textureSizes = {};
const hashes = new Set();
let gpuBytes = 0;
for (const texture of root.listTextures()) {
const [width, height] = texture.getSize() ?? [0, 0];
const key = `${width}x${height} ${texture.getMimeType()}`;
textureSizes[key] = (textureSizes[key] ?? 0) + 1;
hashes.add(createHash("md5").update(texture.getImage() ?? new Uint8Array()).digest("hex"));
gpuBytes += (width * height * 4 * 4) / 3;
}
// Baugruppen: die Kinder des obersten Knotens mit Unterbaum (bei three-d-stage z. B. ground, container_row,
// fence). Ihre Außenmaße ergeben den Grundriss für die Überlagerung auf Karte und Satellitenbild.
const scene = root.listScenes()[0];
const top = scene.listChildren();
const groupParents = top.length === 1 && top[0].listChildren().length > 0 ? top[0].listChildren() : top;
const groups = groupParents
.filter((node) => node.getMesh() || node.listChildren().length > 0)
.map((node) => {
const { min, max } = getBounds(node);
return { name: node.getName() || "(unbenannt)", min: round(min), max: round(max) };
});
const { min, max } = getBounds(scene);
const report = {
file: source,
megabytes: Number(((await stat(source)).size / 1e6).toFixed(1)),
generator: await readGenerator(source),
extensions: root.listExtensionsUsed().map((extension) => extension.extensionName),
rootTranslation: top.length === 1 ? round(top[0].getTranslation()) : null,
bounds: { min: round(min), max: round(max) },
textures: { count: root.listTextures().length, unique: hashes.size, sizes: textureSizes, gpuMB: Math.round(gpuBytes / 1e6) },
drawnTriangles: [...materials.values()].reduce((sum, entry) => sum + entry.drawnTriangles, 0),
materials: [...materials.values()].sort((a, b) => b.drawnTriangles - a.drawnTriangles),
groups,
};
console.log(`${report.file}: ${report.megabytes} MB, ${report.generator}`);
console.log(`Erweiterungen: ${report.extensions.join(", ")}`);
console.log(`Außenmaße: x ${report.bounds.min[0]} … ${report.bounds.max[0]}, y ${report.bounds.min[1]} … ${report.bounds.max[1]}, z ${report.bounds.min[2]} … ${report.bounds.max[2]}`);
console.log(`Wurzel-Verschiebung: ${JSON.stringify(report.rootTranslation)}`);
console.log(`Texturen: ${report.textures.count} (${report.textures.unique} verschieden), ${JSON.stringify(report.textures.sizes)}, ~${report.textures.gpuMB} MB Grafikspeicher`);
console.log(`Gezeichnete Dreiecke: ${report.drawnTriangles}`);
console.table(report.materials.map(({ name, drawnTriangles, primitives, alphaMode, bump }) => ({ name, drawnTriangles, primitives, alphaMode, bump })));
console.table(report.groups.map(({ name, min: lo, max: hi }) => ({ name, x: `${lo[0]} … ${hi[0]}`, y: `${lo[1]} … ${hi[1]}`, z: `${lo[2]} … ${hi[2]}` })));
if (reportPath) {
await writeFile(reportPath, JSON.stringify(report, null, 2));
console.log(`JSON: ${reportPath}`);
}
@@ -0,0 +1,165 @@
"""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)
@@ -0,0 +1,50 @@
// Holt die Kartendaten eines Weltausschnitts für die Platzierung, in Welt-x/-y (Meter, Norden = +y):
//
// node .claude/skills/placing-editor-models/scripts/site-data.mjs <x0> <x1> <y0> <y1> <ausgabe.json>
//
// Enthält Land, Flächen (park/sand/urban), Straßen, Gebäude, Palmen und die Laternen, wie PropLayer sie
// setzt (vergeLampPoints(lampPoints(...)) je Chunk — Laternen stehen bis 16 m neben ihrer Mittellinie,
// deshalb werden auch die Nachbarchunks des Ausschnitts gelesen). render-site.py zeichnet daraus das
// Vergleichsbild; die Grenzen für Straßenrand, Felder usw. lassen sich direkt aus dem JSON ablesen.
import { readFileSync, writeFileSync } from "node:fs";
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE, chunkName } from "../../../../src/world/coords.js";
import { lampPoints, vergeLampPoints } from "../../../../src/world/props.js";
const [x0, x1, y0, y1] = process.argv.slice(2, 6).map(Number);
const out = process.argv[6];
if ([x0, x1, y0, y1].some(Number.isNaN) || !out) {
console.error("Aufruf: node site-data.mjs <x0> <x1> <y0> <y1> <ausgabe.json>");
process.exit(1);
}
const iOf = (X) => Math.floor((X - SLICE.x0) / CHUNK_SIZE);
const jOf = (Y) => Math.floor((SLICE.y1 - Y) / CHUNK_SIZE);
const clamp = (k) => Math.min(CHUNKS_PER_SIDE - 1, Math.max(0, k));
const chunkUrl = (i, j) => new URL(`../../../../public/data/vice-a/chunk_${i}_${j}.json`, import.meta.url);
const chunks = [];
const palms = [];
const lamps = [];
for (let i = clamp(iOf(x0) - 1); i <= clamp(iOf(x1) + 1); i++) {
for (let j = clamp(jOf(y1) - 1); j <= clamp(jOf(y0) + 1); j++) {
const c = JSON.parse(readFileSync(chunkUrl(i, j), "utf8"));
const [ox, oz] = c.origin;
const world = (u, v) => [Number((ox + u).toFixed(2)), Number((-(oz + v)).toFixed(2))];
const rings = (poly) => poly.map((ring) => ring.map(([u, v]) => world(u, v)));
chunks.push({
key: `${i}_${j}`, name: chunkName(i, j),
land: c.land.map(rings),
surfaces: (c.surfaces ?? []).map((s) => ({ kind: s.kind, rings: rings(s.poly) })),
roads: c.roads.map((r) => ({ bridge: r.bridge, rings: rings(r.poly) })),
buildings: c.buildings.map((b) => ({ height: b.height, rings: rings(b.poly) })),
});
for (const [u, v] of c.palms ?? []) palms.push(world(u, v));
for (const p of vergeLampPoints(lampPoints(c.centerlines ?? []), c.roads ?? [])) lamps.push(world(p.x, p.z));
}
}
const inside = ([X, Y]) => X >= x0 && X <= x1 && Y >= y0 && Y <= y1;
writeFileSync(out, JSON.stringify({ box: { x0, x1, y0, y1 }, chunks, palms, lamps }));
console.log(`${chunks.length} Chunks (${chunks.map((c) => c.name).join(", ")}), ` +
`${palms.filter(inside).length} Palmen und ${lamps.filter(inside).length} Laternen im Ausschnitt → ${out}`);
for (const lamp of lamps.filter(inside)) console.log(` Laterne bei (${lamp[0]} | ${lamp[1]})`);