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:
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]})`);
|
||||
Reference in new issue
Block a user