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,97 @@
---
name: placing-editor-models
description: Use when a model built in the three-d-stage editor (GLB export, often sent with a .mtl) should be placed 1:1 at a real-world spot in the Leonida world — "setz das an die Stelle aus dem Satellitenbild", a yard, kiosk or building next to a park or street in a Planquadrat — or when an entry in src/world/places.js has to move, look different or collide differently.
---
# Editor-Modelle in Leonida platzieren
## Überblick
Der Ablauf bleibt der des Hood Yard: Brainstorming mit der Lagefrage am Vergleichsbild → kurze Spec → Plan → Umsetzung mit Subagenten und Reviews → Browserprüfung → Gesamt-Review → Abschluss. Vorlage: `docs/superpowers/specs/2026-09-14-hood-yard-design.md` und `docs/superpowers/plans/2026-09-14-hood-yard.md`.
Neu gebaut werden muss nichts. Orte-Schicht (`src/world/places.js`), Aufbereitung (`tools/assets/places.mjs`), Bump-Erweiterung, Tests und das Vorwärmen in `main.js` gibt es schon. Ein neuer Ort besteht aus:
- einem `PLACES`-Eintrag,
- einer aufbereiteten `.glb`,
- Ergänzungen in den Materiallisten und Tests.
Die eigentliche Arbeit sind die Lage, die Materialrollen und die Prüfung im Spiel.
**Pflicht-Skills in dieser Reihenfolge:** superpowers:brainstorming, superpowers:writing-plans, superpowers:subagent-driven-development, superpowers:finishing-a-development-branch.
## Schon entschieden, nicht neu fragen
| Punkt | Festlegung |
|---|---|
| Maßstab | 1:1, three-d-stage baut in Metern; `PLACES` hat kein `scale` |
| Lage | `at` = Welt-x/-y (Minimap), `rotation` in Grad gegen den Uhrzeigersinn, Norden oben |
| Höhe | Mit Bodenplatte (flaches Teil über die ganze Grundfläche): `lift = 0,06 − t`, t = Höhe, auf der die übrigen Gruppen stehen (Hood Yard: 0,34, obwohl `ground` wegen Bordsteinen bis 0,5 reicht → −0,28). Bei 0 cm flackert der Boden, ein Podest wird zur Stolperkante. Ohne Bodenplatte: `lift = 0` |
| Radien | Vorladen 600 m, einsetzen 250 m, herausnehmen 450 m; `tests/placesSite.test.js` prüft den Chunk-Abstand |
| Aufbereitung | nur `tools/assets/places.mjs`: die gltf-transform-CLI verwirft `EXT_materials_bump`. Ergebnis < 30 MB, die Quelle kommt nie ins Repo |
| Kollision | über Materialnamen (`SOLID_MATERIALS`); Pflanzen und Kleinteile durchlässig, Stämme fest |
| Arbeitsweise | eigener Arbeitsbaum, wenn im Hauptordner fremde Änderungen liegen |
## Ablauf
1. **Modell vermessen**, die `.mtl` ignorieren:
```bash
node .claude/skills/placing-editor-models/scripts/measure-model.mjs ~/Downloads/<name>.glb <scratch>/model.json
```
- Dreiecke zählt das Skript je Knoten; die Summe über die Meshes unterschätzt bei three-d-stage stark.
- Außerdem zeigt es Bump-Materialien, Außenmaße und Baugruppen.
- **Bodenplatte suchen:** eine flache Gruppe, die x und z der Außenmaße abdeckt. Gibt es keine, ist `lift = 0`.
- **t bestimmen:** t ist das kleinste y-Minimum der übrigen Gruppen, also die Höhe, auf der sie stehen. Nicht das y-Maximum der Plattengruppe, darin stecken oft Bordsteine und Wege. Dann gilt `lift = 0,06 − t`.
- Die Wurzel-Verschiebung ist nur ein Hinweis, nicht die Plattendicke.
2. **Stelle holen:** Planquadrat → Weltbox (`parseChunkName`, `chunkOrigin` in `src/world/coords.js`), dann ein Fenster von etwa 150 × 50 m:
```bash
node .claude/skills/placing-editor-models/scripts/site-data.mjs <x0> <x1> <y0> <y1> <scratch>/site.json
```
Straßenränder, Flächen und Laternen daraus ablesen.
3. **Satellitenbild einhängen:**
- **Anker:** ein Punkt, der im Bild und in den Chunk-Daten eindeutig ist (Feldecke, Kreuzung), möglichst nah am Ziel. Echte Stadt und Spielkarte weichen voneinander ab.
- **Maßstab:** Meter pro Pixel aus Autos (~4,5 m) oder Feldmarkierungen schätzen, mit ±15 % Unsicherheit rechnen.
4. **Vergleichsbild** rendern und per `SendUserFile` schicken:
```bash
tools/extract/.venv/bin/python .claude/skills/placing-editor-models/scripts/render-site.py --site site.json --model model.json --at X Y --satellite <bild> --anchor-px PX PY --anchor-world AX AY --mpp 0.15 --out lage.png
```
- Dann genau **eine** Frage mit `AskUserQuestion`: „wie im Bild“ oder die zwei naheliegenden Verschiebungen.
- Ist die echte Anlage länger oder kürzer als das Modell, die Ausrichtung (Mitte, Anfang, Übergang zwischen Bauteilen) ausdrücklich zur Wahl stellen.
5. **Materialrollen festlegen.** `NEAR_MATERIALS` und `SIMPLIFY_RATIOS` (`places.mjs`) sowie `SOLID_MATERIALS` (`places.js`) sind global und nach Namen geordnet.
- Neue Namen gegen die Listen abgleichen. Hat derselbe Name eine andere Rolle (etwa `grass` als begehbare Fläche statt als Halme), dem Nutzer vorlegen und nicht still teilen.
- Anteile beim Vereinfachen, nach gezeichneten Dreiecken: Bäume mit mehr als 300 000 → 0,15; Gras und Büsche → 0,5; Kleinteile → 0,25; Gebäude, Boden und Zäune nie.
6. **Spec und Plan** kurz halten, nur was neu ist:
- **Spec:** Befund (Tabelle aus Schritt 1), Lage-Tabelle mit Abständen zu Straße, Feldern und Laternen, Materialrollen, Budget.
- **Plan-Tasks:**
- Materiallisten
- Aufbereitung und Asset-Test
- `PLACES`-Eintrag und Lage-Test
- Browserprüfung
7. **Aufbereiten:** `node tools/assets/places.mjs <quelle.glb> <id>`. Aus dem Bericht die `bounds` in `PLACES` übernehmen; `gpuMB` und die Dreieckszahl mit dem Nutzer abgleichen, wenn sie deutlich über dem Hood Yard liegen (231 MB, 658 000).
8. **Tests:**
- `tests/placesSite.test.js` prüft jeden `PLACES`-Eintrag schon von selbst.
- `tests/placesAsset.test.js` ist auf `hood-yard` zugeschnitten. Beim zweiten Ort auf `describe.each(PLACES)` umbauen: feste Materialien **je Ort** prüfen statt ganz `SOLID_MATERIALS`, die Bump-Prüfung nur dort, wo das Modell Bump hat.
9. **Browserprüfung** nach `browser-checks.md`:
- Lage prüfen.
- Kollision: Kapsel steht auf der Platte, eine Wand stoppt sie, Pflanzen sind durchlässig.
- Drei Blicke als Bilder an den Nutzer.
- Kosten mit `gl.finish` messen, mit und ohne Ort.
- Vorwärmen: Texturzähler am Startpunkt ablesen.
10. **Gesamt-Review, Abnahme durch den Nutzer, Branch abschließen.**
## Fallstricke
| Symptom | Ursache | Gegenmittel |
|---|---|---|
| Dreieckszahl viel kleiner als gezeichnet | three-d-stage benutzt Meshes mehrfach | je Knoten zählen (`measure-model.mjs`) |
| Wellblech, Relief oder Rillen flach | gltf-transform-CLI verwirft `EXT_materials_bump` | nur `places.mjs` |
| Figur läuft durch Wände, steht bei y 0,75 statt auf der Platte | meshopt-Positionen sind quantisiert, der Collider-Bau klemmt sie auf ±1 | Orte immer über `prepareModel`/`PlaceLayer` einsetzen; im Browser prüfen, dass feste Meshes Float32 haben |
| Boden flackert im Ort | Plattenoberkante auf y 0 | `lift` für +6 cm |
| Laterne oder Palme steht im Ort | Requisiten entstehen je Chunk, Laternen bis 16 m neben der Mittellinie, auch aus Nachbarchunks | Site-Test, Lage verschieben |
| Ort schwebt nach einem Minimap-Sprung kurz über dem Meer | der Streamer baut einen Chunk pro Frame | hingenommen (Ruling Hood Yard) |
| Asset-Test wird mit dem zweiten Ort rot | globaler `SOLID_MATERIALS`-Check | je Ort prüfen (Schritt 8) |
| `preview_start` findet den Server nicht | liest nur `.claude/launch.json` des Hauptordners | Nutzer fragen, Arbeitsbaum kurz verlassen, Eintrag setzen, zurück, am Ende entfernen |
| Befehle im Arbeitsbaum verweigert | Isolationswache mag keine Variablen als Befehl oder Skriptpfade mit Leerzeichen | Pfade ausschreiben; statt `review-package` drei Befehle: `git log --oneline A..B >`, `git diff --stat A..B >>`, `git diff -U10 A..B >>` in eine Datei |
| Draw-Call- und Dreieckszahlen zu optimistisch | `renderer.info` wird nach den Schattenstufen zurückgesetzt | `gl.finish`-Messung (`browser-checks.md` §8) |
| Ruckler beim ersten Erscheinen | Vorwärmen lief nicht | am Startpunkt muss `renderer.info.memory.textures` die Texturen des Orts schon enthalten |
| Seite friert nach einem Browser-Hook ein | Ausnahme im `onBeforeRender`-Hook beendet die Zeichenschleife | `window.__probe ??= {}`, nach Vite-Reload neu einrichten |
Werkzeuge: `scripts/` (Vermessung, Kartendaten, Vergleichsbild, Screenshot-Dekodierung); Browser-Ausschnitte: `browser-checks.md`.
@@ -0,0 +1,123 @@
# Browserprüfung eines Orts (verborgener Browser-Bereich)
Alle Ausschnitte sind am Hood Yard erprobt (2026-09-14). `javascript_tool` bricht nach 45 s ab: lange Abläufe auf mehrere Aufrufe verteilen. Jeder Aufruf hat eigene Variablen — was später gebraucht wird, an `window` hängen.
## 1. Dev-Server
- Das Vorschau-Werkzeug liest Server nur aus `.claude/launch.json` des **Hauptordners**. Auf 5173 läuft oft schon der Hauptordner.
- Arbeitet die Sitzung in einem Arbeitsbaum, darf sie diese Datei nicht ändern: Nutzer fragen, dann `ExitWorktree(keep)` → Eintrag setzen → `preview_start` → `EnterWorktree(path)`. Am Ende wieder raus und den Eintrag entfernen.
```json
{ "name": "place-check", "runtimeExecutable": "bash",
"runtimeArgs": ["-lc", "cd /Users/Kenso/Developer/gta6/.claude/worktrees/<name> && npx vite --port 5181 --strictPort"],
"port": 5181, "url": "http://localhost:5181" }
```
- Arbeitsbaum vorbereiten: `cp -Rc node_modules <baum>/node_modules`, dazu `three-player-controller/package.json` und `dist/` in `<baum>/three-player-controller/` kopieren. Git ignoriert Dateien im nicht ausgecheckten Submodul.
## 2. Debug-Seite (nie committen, danach löschen)
Der verborgene Bereich meldet oft 0 × 0 px, `requestAnimationFrame` steht, und three's `Timer` liefert delta 0, solange `document.hidden` gilt. Vor jedem Zug `resize_window` auf 1280 × 800 setzen (die App setzt es am Zugende zurück). `index-debug.html` im Root: Kopie von `index.html` mit diesem Skript im `<head>` vor dem Modul-Skript:
```html
<script>
(() => {
Object.defineProperty(document, "hidden", { configurable: true, get: () => false });
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" });
const queue = new Map(); let nextId = 1;
window.requestAnimationFrame = (cb) => { const id = nextId++; queue.set(id, cb); return id; };
window.cancelAnimationFrame = (id) => { queue.delete(id); };
const worker = new Worker(URL.createObjectURL(new Blob(["setInterval(() => postMessage(0), 33);"], { type: "text/javascript" })));
worker.onmessage = () => { const cbs = [...queue.values()]; queue.clear(); const now = performance.now(); for (const cb of cbs) cb(now); };
})();
</script>
```
## 3. Szene, Kamera, Renderer greifbar machen
Wirft ein Hook, stirbt die Zeichenschleife endgültig. Vite lädt die Seite bei Abhängigkeits-Rebuilds neu (`performance.now()` klein), dann ist `window.__probe` weg — deshalb `??=`.
```js
const deps = performance.getEntriesByType("resource").map((e) => e.name);
const THREE = await import(deps.find((n) => n.includes("/node_modules/.vite/deps/three.js")));
window.__frames = [];
THREE.Scene.prototype.onBeforeRender = function (renderer, scene, camera) {
Object.assign((window.__probe ??= {}), { renderer, scene, camera });
window.__frames.push(performance.now());
if (window.__frames.length > 4000) window.__frames.splice(0, 2000);
if (window.__applyView) window.__applyView(camera);
};
```
## 4. Hinspringen über die Minimap
`M` per `keydown`/`keyup` (`code: "KeyM"`) auf `window`. Das Kartenbild bleibt im verborgenen Bereich bei „Karte lädt …“, bis ein **Screenshot** gemacht wurde. Danach klicken, mit `X`, `Y` = Welt-Ziel neben dem Ort (nicht auf ein Gebäude):
```js
const map = [...document.querySelectorAll("canvas")].find((c) => c !== window.__probe.renderer.domElement);
const rect = map.getBoundingClientRect(), dpr = map.width / rect.width;
const scale = Math.min(map.width / 3000, map.height / 3000);
const ox = (map.width - 3000 * scale) / 2, oy = (map.height - 3000 * scale) / 2;
const opts = { clientX: rect.left + ((X + 1000) * scale + ox) / dpr, clientY: rect.top + ((2500 - Y) * scale + oy) / dpr, button: 0, bubbles: true };
map.dispatchEvent(new MouseEvent("mousedown", opts)); map.dispatchEvent(new MouseEvent("mouseup", opts));
```
Die Wurzel des Orts findet man über ihre Lage: `scene.children.find((o) => Math.abs(o.position.x - at[0]) < 0.01 && Math.abs(o.position.z + at[1]) < 0.01)`.
## 5. Zustand prüfen
- **Kollisionsmarken:** Mit `root.traverse` die Meshes ohne `userData.excludeFromCollider` sammeln. Ihre Materialnamen müssen genau die festen Teile sein.
- **Quantisierung:** Alle festen Meshes haben `geometry.attributes.position.array instanceof Float32Array`, dafür sorgt `prepareModel`. Fehlt das, landet der Collider bei ±1 m um den Weltursprung.
- **Vorwärmen:** Vor dem Sprung ist der Ort noch nicht in der Szene. `renderer.info.memory.textures` liegt am Startpunkt trotzdem schon um die Texturzahl des Orts höher.
## 6. Blicke aufnehmen
Kamera übersteuern, auch in einem Wrapper um `CSM.prototype.update`, sonst fehlen die Schatten im Bild:
```js
const { CSM } = await import(deps.find((n) => n.includes("three_examples_jsm_csm_CSM")));
const eye = new THREE.Vector3(), target = new THREE.Vector3(), up = new THREE.Vector3(), m = new THREE.Matrix4();
window.__applyView = (camera) => {
if (!window.__view) return;
eye.fromArray(window.__view.position); target.fromArray(window.__view.target); up.fromArray(window.__view.up ?? [0, 1, 0]);
m.lookAt(eye, target, up).setPosition(eye);
camera.matrixWorld.copy(m); camera.matrixWorldInverse.copy(m).invert();
};
if (!CSM.prototype.__wrapped) {
const update = CSM.prototype.update;
CSM.prototype.update = function (...a) { window.__applyView(this.camera); return update.apply(this, a); };
CSM.prototype.__wrapped = true;
}
```
- **Blicke:** Augenhöhe von vorn und von hinten (y 1,7), dazu eine Draufsicht (`position [x, 60, z]`, `up [0, 0, -1]`, Norden oben). Pro Blick `window.__view` setzen und ~1 s warten.
- **Aufnehmen:** `renderer.render(scene, camera)`, auf ein 2D-Canvas mit 1280 × 800 zeichnen, `toDataURL("image/jpeg", 0.85)` zurückgeben.
- **Speichern:** Das Ergebnis ist zu groß und landet unter `tool-results/`. `scripts/decode-view.py <tool-result.txt> <bild.jpg>` holt das Bild heraus.
- **Aufräumen:** `window.__view = null`.
## 7. Kollision ablaufen
```js
Object.defineProperty(document, "pointerLockElement", { configurable: true, get: () => renderer.domElement });
const capsule = scene.getObjectByName("capsule");
const heading = () => { const d = camera.getWorldDirection(new THREE.Vector3()); return Math.atan2(d.x, -d.z) * 180 / Math.PI; }; // 0 = Norden
const turn = (deg) => window.dispatchEvent(new MouseEvent("mousemove", { movementX: deg / 8.6 * 300, movementY: 0 }));
Object.assign(window, { capsule, heading, turn });
```
- **Start:** Kapsel mit `capsule.position.set(x, 1, z)` auf freien Boden des Orts setzen, mit Blick auf eine Wand; der Weg darf nicht über Bordsteine führen. Drehen, `heading()` prüfen, dann `keydown` `KeyW`. Jede Sekunde die Lage notieren, danach `keyup`.
- **Stand:** Auf blankem Chunk-Boden steht die Kapsel bei y 0,75, auf der Bodenplatte des Orts um deren Oberkante höher (Hood Yard: 0,81).
- **Wand:** Die Kapsel muss mit wenigen cm Abstand vor der Wand stehen bleiben. Gras und Blätter muss sie ohne Halt durchlaufen.
- **Tempo:** etwa 0,38 m/s bei 33-ms-Takt, also genug Zeit geben (15–25 s).
## 8. Kosten messen
`renderer.info.render` zählt die Schattenstufen nicht mit, es wird nach `shadowMap.render` zurückgesetzt. Stattdessen takten, einmal mit und einmal ohne die Wurzel des Orts in der Szene:
```js
const gl = renderer.getContext();
const measure = (n) => { const t = []; for (let i = 0; i < n; i++) { gl.finish(); const s = performance.now(); renderer.render(scene, camera); gl.finish(); t.push(performance.now() - s); } t.sort((a, b) => a - b); return t[n >> 1]; };
```
- **Median:** Beim Hood Yard kosteten Blick vom Rand und Blick im Ort je +0,5 ms.
- **Ruckler beim Einsetzen:** Nach einem Sprung die größte Lücke in `window.__frames` rund um den Moment suchen, in dem der Ort in der Szene erscheint. Mit Vorwärmen waren das 86 ms bei 33-ms-Takt, ohne kamen allein ~130 ms für den Texturupload dazu.
@@ -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]})`);