diff --git a/.claude/skills/placing-editor-models/SKILL.md b/.claude/skills/placing-editor-models/SKILL.md new file mode 100644 index 0000000..9296794 --- /dev/null +++ b/.claude/skills/placing-editor-models/SKILL.md @@ -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/.glb /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 /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 --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 `. 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`. diff --git a/.claude/skills/placing-editor-models/browser-checks.md b/.claude/skills/placing-editor-models/browser-checks.md new file mode 100644 index 0000000..c7ffb71 --- /dev/null +++ b/.claude/skills/placing-editor-models/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/ && npx vite --port 5181 --strictPort"], + "port": 5181, "url": "http://localhost:5181" } +``` + +- Arbeitsbaum vorbereiten: `cp -Rc node_modules /node_modules`, dazu `three-player-controller/package.json` und `dist/` in `/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 `` vor dem Modul-Skript: + +```html + +``` + +## 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 ` 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. diff --git a/.claude/skills/placing-editor-models/scripts/decode-view.py b/.claude/skills/placing-editor-models/scripts/decode-view.py new file mode 100644 index 0000000..c1e60a1 --- /dev/null +++ b/.claude/skills/placing-editor-models/scripts/decode-view.py @@ -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 +""" +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) diff --git a/.claude/skills/placing-editor-models/scripts/measure-model.mjs b/.claude/skills/placing-editor-models/scripts/measure-model.mjs new file mode 100644 index 0000000..e6ff5d2 --- /dev/null +++ b/.claude/skills/placing-editor-models/scripts/measure-model.mjs @@ -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 [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 [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}`); +} diff --git a/.claude/skills/placing-editor-models/scripts/render-site.py b/.claude/skills/placing-editor-models/scripts/render-site.py new file mode 100644 index 0000000..e7db5c1 --- /dev/null +++ b/.claude/skills/placing-editor-models/scripts/render-site.py @@ -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) diff --git a/.claude/skills/placing-editor-models/scripts/site-data.mjs b/.claude/skills/placing-editor-models/scripts/site-data.mjs new file mode 100644 index 0000000..966877c --- /dev/null +++ b/.claude/skills/placing-editor-models/scripts/site-data.mjs @@ -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 +// +// 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 "); + 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]})`);