Compare commits

..
10 Commits
Author SHA1 Message Date
knso a55dca5fb6 start 2026-09-15 21:05:18 +02:00
knsoandClaude Opus 5 6a056262a8 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>
2026-09-14 19:22:20 +02:00
knsoandClaude Sonnet 5 7e4af1d0bc docs: Hood-Yard-Spec mit Messwerten und Vorwärmen
Wortlaut: die Radien garantieren nur, dass die Chunks unter einem Ort
angefordert sind, solange er in der Szene ist — nicht, dass ihr Boden
schon gebaut ist (ChunkStreamer baut höchstens einen Chunk je Bild).
"geladen" -> "angefordert" im Kommentar über den Radien
(src/world/places.js), über der PlaceLayer-Konstruktion (src/main.js),
im Testnamen (tests/placesSite.test.js) und in der Spec, dort mit
einem Satz zu den paar Bildern nach einem Kartensprung.

Spec zusätzlich: warmUp in den Bullets "Vorbereiten"/"Einsetzen",
robustes Einsetzen (Warnung, kein erneuter Versuch) im Bullet zum
Ladefehler, und ein neuer Messwerte-Bullet in "Fehlerfälle und
Risiken" (+0,4–0,6 ms je Bild, ~130 ms einmaliger Texturupload ohne
warmUp).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:43:23 +02:00
knsoandClaude Sonnet 5 fc9e818c79 test(world): entquantisierte Positionen auch für interleaved Attribute
GLTFLoader liefert quantisierte Positionen in echt als
InterleavedBufferAttribute (gemeinsamer Puffer mit Normale/UV), nicht
als eigene BufferAttribute wie im bisherigen Test. dequantizePosition
braucht dafür keine Änderung: es liest nur über das gemeinsame
Attribut-Interface (normalized, getX/getY/getZ, count), das three.js
für beide Klassen implementiert. Der Test läuft grün und schreibt das
fest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:41:46 +02:00
knsoandClaude Sonnet 5 23a0623898 fix(assets): Zielpfad der Aufbereitung unabhängig vom Arbeitsverzeichnis
places.mjs schrieb bisher nach public/models/places/<id>.glb relativ
zu process.cwd() und nahm jede id ungeprüft an. Die neue, exportierte
placeTarget(id) löst den Pfad stattdessen von import.meta.url ab (egal
von wo das Skript aufgerufen wird) und lehnt IDs außerhalb von
[a-z0-9-]+ ab, bevor daraus ein Pfad wird, der aus public/models/
places/ herausführen könnte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:41:18 +02:00
knsoandClaude Sonnet 5 ed5254f88e fix(world): Einsetzen eines Orts gegen Collider-Fehler absichern
#show baute bisher scene.add, bevor player.addCollider lief: wirft
addCollider (z. B. toter Worker), blieb die Wurzel ohne active-Eintrag
in der Szene stehen — sichtbar, aber nie wieder entfernbar — und die
Ausnahme entkam synchron aus update() in die Spielschleife.

Der Collider-Handle wird jetzt vor scene.add in einem try/catch
gebaut; schlägt er fehl, bleibt die Szene unverändert, der Ort landet
in this.failed (kein erneuter Versuch) und #show überspringt ihn beim
nächsten Aufruf.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:40:28 +02:00
knsoandClaude Sonnet 5 1c24fbf71e feat(world): Orte beim Vorladen vorwärmen
PlaceLayer nimmt jetzt ein optionales warmUp(root) entgegen und ruft es
nach prepareModel, aber vor dem Einsetzen auf. main.js nutzt das, um
Texturen hochzuladen und CSM-Shader zu übersetzen, solange der Ort noch
nicht in der Szene ist — gemessen kostete das sonst rund 130 ms auf
einen Schlag im ersten Bild, in dem der Hof erscheint. Schlägt das
Vorwärmen fehl, wird nur gewarnt; der Ort wird trotzdem eingesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:39:50 +02:00
knsoandClaude Sonnet 5 522101c026 fix(world): quantisierte Positionen vor dem Kollisionsaufbau in Float32 wandeln
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:42:25 +02:00
knsoandClaude Haiku 4.5 9f6570b11d feat(world): Hood Yard im Spiel
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-14 11:35:17 +02:00
knsoandClaude Sonnet 5 ef2001542a feat(world): PlaceLayer lädt vor, setzt ein und nimmt heraus
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 11:26:34 +02:00
24 changed files with 1133 additions and 43 deletions

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]})`);
@@ -173,8 +173,8 @@ Spieler.
- **Autos:** vier SUVs an Straßenpunkten nahe dem Start, Konfiguration aus der Showcase
übernommen. Ihre Chunks bleiben geladen, weil jedes Fahrzeug in jedem Frame Physik rechnet.
- **Figuren:** Modul aus `showcase.js` nach `src/characters/`, inklusive Delta-Retargeting
und ans Mesh angepasster Hitbox. Start ist der Rasen am Südrand des Parks von Vice Beach bei X 1600, Y 690 (three.js: x = 1600, z = -690) — ursprünglich X 1699, Y 743 auf einer Buchstabenkontur von „(MIAMI BEACH)“, versetzt mit dem Beschriftungs-Plan —,
abgeleitet aus dem Schwerpunkt der 259 Ocean-Beach-Landmarks im Ausschnitt.
und ans Mesh angepasster Hitbox. Start ist der Rasen am Südrand des Parks von Vice Beach bei X 1600, Y 690 (three.js: x = 1600, z = -690) — ursprünglich X 1699, Y 743, abgeleitet aus dem Schwerpunkt der 259 Ocean-Beach-Landmarks im Ausschnitt,
auf einer Buchstabenkontur von „(MIAMI BEACH)“, versetzt mit dem Beschriftungs-Plan.
- **Debug-Panel:** Figurenauswahl, Chunk-Grenzen, Hitbox, Kartenbild auf dem Boden,
Sichtweite, Höhenfaktor, Sprung zu einem der 54 Wahrzeichen.
- **Namensnennung im HUD:** Kartendaten und Landmarks von map.gtadb.org (CC BY 4.0,
@@ -41,10 +41,10 @@ Gemessen an der Vorlage (`tools/extract/cache`, YANIS v15) und den ausgelieferte
## Einbau in die Pipeline
```
Kacheln → classify → drop_map_labels → remove_annotations → Farbanteil-Prüfung → Land, Straßen, Gebäude, Höhen, Palmen → Markierung ergänzter Gebäude → Chunks → Minimap
Kacheln → classify → drop_map_labels → remove_annotations → drop_map_labels → Farbanteil-Prüfung → Land, Straßen, Gebäude, Höhen, Palmen → Markierung ergänzter Gebäude → Chunks → Minimap
```
`remove_annotations` ändert ausschließlich das Klassenbild — welches Pixel Wasser, Land, Park, Sand, Stadtfläche, Gebäude, Straße, Weg oder spekulativ ist. Alles danach bleibt Code von heute. `drop_map_labels` bleibt als Absicherung für Schrift im offenen Wasser.
`remove_annotations` ändert ausschließlich das Klassenbild — welches Pixel Wasser, Land, Park, Sand, Stadtfläche, Gebäude, Straße, Weg oder spekulativ ist. Alles danach bleibt Code von heute. `drop_map_labels` bleibt als Absicherung für Schrift im offenen Wasser und läuft nach `remove_annotations` ein zweites Mal, für Inselreste, die erst das Entfernen im Wasser hinterlässt.
| Einheit | Aufgabe |
|---|---|
@@ -95,6 +95,8 @@ reason = "knallrotes Gebäude in A10, laut Nutzer echt"
Der Rahmen soll die Beschriftung mit mindestens 8 m Luft umschließen; `--propose` schlägt Rahmen mit 10 m Luft vor. Wo keine Farbregel sicher greift — blasse Schrift, winzige Straßennamen in Gebäudegrau, Piktogramme aus dünnen dunklen Strichen —, bekommt die Stelle einen engen `region`-Rahmen: dann wird der ganze Rahmen neu aufgebaut.
Interstate-Schilder mit rotem Banner stehen als `region` in der Liste, weil keine Farbregel das dunkelrote Banner sicher trifft (ΔE 20,5 zu `spekulativ`) und der Rest sonst als spekulatives Gebäude auf der Fahrbahn stünde.
## Erkennung je Art
Je Eintrag entsteht eine Maske in fünf Schritten:
@@ -121,9 +123,11 @@ Die **Gitternetz-Maske** sind die Streifen aus `[grid]`, auf den Ausschnitt besc
Reihenfolge in `inpaint.py`:
1. **Gitternetz:** Jedes Pixel der Gitternetz-Maske, das in keiner Eintragsmaske liegt, übernimmt die Klasse des nächsten Pixels außerhalb aller Masken, gleich welcher Klasse. Gefüllt wird in Streifen von 10 m beiderseits jeder Linie.
2. **Eintragsmasken:** Alle Eintragsmasken werden vereinigt und in zusammenhängende Masken zerlegt; überlappende Einträge landen so in derselben Maske. Je Maske:
2. **Eintragsmasken:** Alle Eintragsmasken werden vereinigt und in zusammenhängende Masken zerlegt; überlappende Einträge landen so in derselben Maske. Zuerst wird jede Maske aufgebaut:
- **dünn** (größter Abstand eines Maskenpixels zum Maskenrand ≤ 2 m — Straßennamen, Zeigerlinien): wie das Gitternetz.
- **sonst:** Straßen und Wege → Flächen → Gebäude schließen in einem Fenster aus dem Hüllrechteck der Maske plus 60 m, danach Gebäude ergänzen im Nachbarschaftsfenster (plus 150 m).
- **sonst:** Straßen und Wege → Flächen → Gebäude schließen in einem Fenster aus dem Hüllrechteck der Maske plus 60 m.
Erst danach werden für alle nicht dünnen Masken, in derselben Reihenfolge, Gebäude ergänzt im Nachbarschaftsfenster (plus 150 m). Sonst zählten die Buchstabenkonturen noch nicht aufgebauter Masken als Straße und teilten Blöcke, und wo Gebäude entstehen, hinge von der Reihenfolge der Masken ab.
### Straßen und Wege
@@ -156,7 +156,7 @@ Die Namensnennung steht unten links und reicht auf schmalen Fenstern bis in die
- Ein Test, der die Namen mit der tatsächlichen Lage verbindet statt nur mit den Indizes: der Downtown-Bezugspunkt aus `tools/extract/data/districts.json` bei Welt `(−511, 254)` ergibt über `chunkForWorld` `(i = 1, j = 8)` und damit `B9`; der höchste Turm, das Southeast Financial Center bei `(−461, −460)`, ergibt `C12`. An der bestehenden Funktion nachgerechnet. (Korrigiert nach Task 4: `(−511, 254)` ist der Mittelpunkt der westlichen Hochhäuser, nicht der Hochhauskern selbst.)
**Ohne Browser (vitest), für `mapMath.js`:**
- `worldToMap` und `mapToWorld` an bekannten Punkten: dem früheren Spawn `(1699, 3, −743)` und alle vier Ecken des Ausschnitts; dazu Hin- und Rückweg ergibt wieder den Ausgangspunkt. Ein Test, der eine gespiegelte Achse sicher fängt.
- `worldToMap` und `mapToWorld` an bekannten Punkten: dem früheren Spawn `(1699, 3, −743)` und allen vier Ecken des Ausschnitts; dazu Hin- und Rückweg ergibt wieder den Ausgangspunkt. Ein Test, der eine gespiegelte Achse sicher fängt.
- Zoomgrenzen: nie kleiner als „ganzer Ausschnitt", nie größer als vierfach; Verschiebung bleibt innerhalb des Kartenrands.
- Klick gegen Ziehen an der 4-px-Schwelle, knapp darunter und knapp darüber, und ein Ziehen, das zum Startpunkt zurückkehrt.
- `nearestWalkable` an einer künstlichen Maske: begehbarer Treffer landet in der Mitte seines Pixels; ein Gebäudepixel neben einer Straße findet die Straße; nur Wasser im Umkreis ergibt kein Ergebnis; ein Treffer knapp außerhalb von 20 m zählt nicht.
@@ -143,11 +143,11 @@ export const PLACES = [
| `PLACE_SHOW_RADIUS` | 250 m | einsetzen |
| `PLACE_HIDE_RADIUS` | 450 m | herausnehmen |
Chunks laden bei 400 m und bauen bei 600 m ab, beides gemessen zu ihrem Mittelpunkt. Der fernste Chunk-Mittelpunkt unter dem Hof liegt 148 m von dessen Mitte entfernt. 250 + 148 ≤ 400 und 450 + 148 ≤ 600 heißt: Solange der Hof in der Szene ist, ist sein Boden geladen. Der Datentest prüft das für jeden Ort.
Chunks laden bei 400 m und bauen bei 600 m ab, beides gemessen zu ihrem Mittelpunkt. Der fernste Chunk-Mittelpunkt unter dem Hof liegt 148 m von dessen Mitte entfernt. 250 + 148 ≤ 400 und 450 + 148 ≤ 600 heißt: Solange der Hof in der Szene ist, ist sein Boden angefordert. `ChunkStreamer` baut aber höchstens einen Chunk je Bild; direkt nach einem Kartensprung neben den Hof kann er deshalb ein paar Bilder lang vor seinem Boden erscheinen. Der Datentest prüft das für jeden Ort.
- `wantedPlaces(position, places, { show, hide, active })` arbeitet wie `wantedChunks`: `add` ab `show`, `remove` erst jenseits von `hide`.
- `PlaceLayer.update(position)` stößt innerhalb von 600 m das Laden an. Dafür gibt es je Ort ein einziges Laden mit `GLTFLoader` und `MeshoptDecoder`. Danach setzt es einen fertig geladenen Ort ein oder nimmt ihn heraus. Ist der Spieler beim Ende des Ladens schon jenseits von 450 m, bleibt der Ort draußen, bis er wieder näher als 250 m kommt.
- **Vorbereiten** (`prepareModel`, einmal nach dem Laden): Wurzel an `placeTransform`, jedes Mesh `castShadow` und `receiveShadow`, nicht feste Meshes `userData.excludeFromCollider = true`. Hat kein Mesh ein festes Material, gibt es eine Warnung und später keinen Collider (eine leere Quelle liefert im Controller nur ein Handle, das nie fertig wird, vgl. Ruling 51).
- **Einsetzen:** `scene.add`, danach `player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: wurzel } })`.
- **Vorbereiten** (`prepareModel`, einmal nach dem Laden): Wurzel an `placeTransform`, jedes Mesh `castShadow` und `receiveShadow`, nicht feste Meshes `userData.excludeFromCollider = true`. Hat kein Mesh ein festes Material, gibt es eine Warnung und später keinen Collider (eine leere Quelle liefert im Controller nur ein Handle, das nie fertig wird, vgl. Ruling 51). Ist ein `warmUp` gesetzt, läuft es danach einmalig (siehe „Gemessen im Spiel" unten).
- **Einsetzen:** Collider-Handle bauen (`player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: wurzel } })`), erst danach `scene.add` — wirft `addCollider`, bleibt die Szene unverändert (siehe Fehlerfälle).
- **Entfernen:** `scene.remove` und `player.removeCollider(handle)`. Die geladenen Daten bleiben erhalten, eine Rückkehr setzt sofort wieder ein. Kosten: geschätzt 231 MB Grafikspeicher, sobald der Hof einmal gezeichnet war.
- Die Kaskaden-Schatten richten die neuen Materialien selbst ein (`prepareLitMaterials` in `sun.update()`).
@@ -166,14 +166,17 @@ export const PLACES = [
Durchlässig bleiben Gras (`grass_medium_*`), Blätter und Zweige, die Büsche (`searsia_lucida*`), `contHardware`, `modular_electricity_poles_pieces`, `security_camera_01*` und `trashbag`.
Positionen fester Meshes, die quantisiert vorliegen (KHR_mesh_quantization aus `meshopt()`), wandelt `prepareModel` in Float32 um: `colliderBuild.ts` rechnet `matrixWorld` per `applyMatrix4` in eine Kopie der Geometrie ein, und normalisierte Int16-Werte würden dabei auf ±1 geklemmt.
## Fehlerfälle und Risiken
- **Datei lädt nicht:** `console.warn` mit Ort und Fehler, der Ort gilt als fehlgeschlagen und wird nicht erneut versucht. Das Spiel läuft ohne ihn weiter.
- **Datei lädt nicht oder lässt sich nicht einsetzen:** `console.warn` mit Ort und Fehler, der Ort gilt als fehlgeschlagen und wird nicht erneut versucht — auch wenn `addCollider` beim Einsetzen wirft (die Szene bleibt dann unverändert statt einer Wurzel ohne Collider). Das Spiel läuft ohne ihn weiter.
- **Entfernen, bevor der Collider fertig ist:** `removeCollider` räumt auch unfertige Worker-Collider ab, genau wie beim Chunk-Abbau.
- **Beleuchtung weicht vom Editor ab:** Das Spiel leuchtet mit HDRI ×5 und Sonne 2,5. Wirkt der Hof zu hell oder zu dunkel, wird das im Browser verglichen und im Gespräch entschieden, nicht still angepasst.
- **Vereinfachung dünnt Blätter aus:** Die Stärke wird nur an Pflanzen und Kleinteilen eingestellt, mit Vorher-Nachher-Screenshot.
- **Collider zu schwer:** Wird der BVH-Aufbau im Worker spürbar langsam, fallen die Baumkronen (`quiver_tree_02`) aus der Kollisionsliste.
- **Neue Karte:** Erzeugt die Pipeline Straßen, Palmen oder Laternen neu, prüft der Datentest die Hoffläche erneut.
- **Gemessen im Spiel (2026-09-14, 2560 × 1600, mit Schattenstufen):** +0,4–0,6 ms je Bild (Blick vom Baseballfeld 2,7–2,9 ms statt 2,3 ms, im Hof 2,1 statt 1,6 ms). Das erste Zeichnen kostete einmalig rund 130 ms Texturupload; `warmUp` legt das und die Shader-Übersetzung ins Vorladen.
## Tests
+25 -1
View File
@@ -6,8 +6,9 @@ import { playerController } from "three-player-controller";
import { ChunkStreamer } from "./world/streaming.js";
import { createMaterials } from "./world/materials.js";
import { createSky } from "./world/sky.js";
import { createSunShadows } from "./world/shadows.js";
import { createSunShadows, prepareLitMaterials } from "./world/shadows.js";
import { PropLayer } from "./world/props.js";
import { PlaceLayer } from "./world/places.js";
import { chunkForWorld, threeToWorld } from "./world/coords.js";
import { createCharacterModule } from "./characters/index.js";
import { activeVehicleOrNull, playerIsGrounded, playerPosition } from "./player.js";
@@ -236,6 +237,28 @@ await props.load();
// endlose Fall-Auffang-Schleife laufen, sobald der Chunk dauerhaft ausbleibt. Nach 15s gibt
// es nur noch eine einmalige Warnung, kein Verhaltenswechsel.
const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });
// warmUp fürs Vorladen eines Orts (PlaceLayer unten): lädt seine Texturen schon hier auf die GPU und
// übersetzt seine CSM-Shader, statt das im ersten Bild nach dem Einsetzen zu tun — dort kostete genau
// das gemessen rund 130 ms auf einmal (docs/superpowers/specs/2026-09-14-hood-yard-design.md,
// "Fehlerfälle und Risiken").
async function warmUpPlace(root) {
prepareLitMaterials(root, sun.csm);
const textures = new Set();
root.traverse((object) => {
if (!object.isMesh) return;
for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
if (!material) continue;
for (const value of Object.values(material)) {
if (value?.isTexture) textures.add(value);
}
}
});
for (const texture of textures) renderer.initTexture(texture);
await renderer.compileAsync(root, camera, scene);
}
// Fest platzierte Modelle aus three-d-stage (src/world/places.js). Eigene Radien, damit ein Ort nur in
// der Szene ist, solange die Chunks unter ihm angefordert sind.
const places = new PlaceLayer({ scene, player, warmUp: warmUpPlace });
player.setSkipCapsuleCollision(true);
let settled = false;
let warnedSlowSpawn = false;
@@ -250,6 +273,7 @@ function tick(delta) {
// Sitzplatz-Offset, nicht die Weltposition (Begründung in src/player.js). Ohne diese
// Umstellung hört das Streaming beim Fahren auf nachzuladen.
streamer.update(playerPosition(player));
places.update(playerPosition(player));
if (!settled) {
// Ein Sprung vor dem ersten Boden — Karte und Panel sind schon vor props.load() bedienbar —
// ersetzt den Spawn als Startpunkt, statt beim Einrasten stillschweigend verloren zu gehen.
+124 -3
View File
@@ -1,12 +1,15 @@
// Orte: fest platzierte Modelle aus dem Editor three-d-stage, getrennt vom Chunk-Streaming
// (docs/superpowers/specs/2026-09-14-hood-yard-design.md). Aufbereitet werden sie mit
// tools/assets/places.mjs; hier stehen Lage, Laden und Kollision.
import { BufferAttribute } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import { worldToThree } from "./coords.js";
// Chunks laden bei 400 m und bauen bei 600 m ab, gemessen zu ihrem Mittelpunkt (ChunkStreamer.update).
// Der fernste Chunk-Mittelpunkt unter dem Hood Yard liegt 148 m von dessen Mitte entfernt: erschiene
// der Hof schon bei 400 m, stünde er vom Startpunkt aus über dem Meer. Mit 250 m und 450 m ist sein
// Boden geladen, solange er in der Szene ist (tests/placesSite.test.js prüft das für jeden Ort).
// Boden angefordert, solange er in der Szene ist (tests/placesSite.test.js prüft das für jeden Ort).
// Heruntergeladen wird früher, damit das Einsetzen nicht auf die Datei wartet.
export const PLACE_PRELOAD_RADIUS = 600;
export const PLACE_SHOW_RADIUS = 250;
@@ -77,6 +80,23 @@ export function wantedPlaces(position, places, { show, hide, active }) {
return { add, remove };
}
// tools/assets/places.mjs komprimiert mit meshopt() (KHR_mesh_quantization): Positionen fester Meshes
// liegen als normalisierte Ganzzahlen vor, erst über die Mesh-Transform auf echte Maße skaliert.
// colliderBuild.ts im three-player-controller klont die Geometrie aber und rechnet matrixWorld per
// applyMatrix4 in die Kopie hinein — dabei würden normalisierte Werte beim Zurückschreiben auf ±1
// geklemmt. Deshalb hier vorher in echte, nicht normalisierte Float32-Koordinaten entquantisieren.
function dequantizePosition(geometry) {
const position = geometry.attributes.position;
if (!position.normalized && position.array instanceof Float32Array) return;
const values = new Float32Array(position.count * 3);
for (let i = 0; i < position.count; i++) {
values[i * 3] = position.getX(i);
values[i * 3 + 1] = position.getY(i);
values[i * 3 + 2] = position.getZ(i);
}
geometry.setAttribute("position", new BufferAttribute(values, 3));
}
/**
* Setzt Lage und Schatten und nimmt nicht feste Meshes aus der Kollision (colliderBuild.ts im
* three-player-controller überspringt userData.excludeFromCollider). Gibt zurück, ob ein Teil fest ist.
@@ -91,9 +111,110 @@ export function prepareModel(root, place) {
object.castShadow = true;
object.receiveShadow = true;
const materials = Array.isArray(object.material) ? object.material : [object.material];
if (materials.some((material) => isSolidMaterial(material?.name))) solid = true;
else object.userData.excludeFromCollider = true;
if (materials.some((material) => isSolidMaterial(material?.name))) {
solid = true;
dequantizePosition(object.geometry);
} else {
object.userData.excludeFromCollider = true;
}
});
root.updateMatrixWorld(true);
return solid;
}
function loadModel(url) {
const loader = new GLTFLoader();
// tools/assets/places.mjs komprimiert mit EXT_meshopt_compression, wie die Straßenlaterne (props.js).
loader.setMeshoptDecoder(MeshoptDecoder);
return loader.loadAsync(url).then((gltf) => gltf.scene);
}
export class PlaceLayer {
constructor({ scene, player, places = PLACES, load = loadModel, warmUp = null }) {
Object.assign(this, { scene, player, places, load, warmUp });
this.models = new Map(); // id -> { root, solid }; bleibt nach dem ersten Laden erhalten
this.pending = new Set();
this.failed = new Set();
this.wanted = new Set(); // innerhalb von show gewesen, noch nicht jenseits von hide
this.active = new Map(); // id -> { root, handle }
}
update(position) {
for (const place of this.places) {
if (placeDistance(position, place) <= PLACE_PRELOAD_RADIUS) this.#preload(place);
}
const { add, remove } = wantedPlaces(position, this.places, {
show: PLACE_SHOW_RADIUS, hide: PLACE_HIDE_RADIUS, active: this.wanted,
});
for (const id of add) {
this.wanted.add(id);
this.#show(id);
}
for (const id of remove) {
this.wanted.delete(id);
this.#hide(id);
}
}
#preload(place) {
const { id } = place;
if (this.models.has(id) || this.pending.has(id) || this.failed.has(id)) return;
this.pending.add(id);
this.load(place.url)
.then(async (root) => {
const solid = prepareModel(root, place);
if (!solid) console.warn(`Ort ${id} hat keine festen Teile und bekommt keine Kollision.`);
// Vorwärmen (Texturupload, Shader-Übersetzung) noch hier im Vorladen: schlägt es fehl,
// wird der Ort trotzdem eingesetzt, nur ohne den Vorteil (siehe main.js, warmUpPlace).
if (this.warmUp) {
try {
await this.warmUp(root);
} catch (err) {
console.warn(`Ort ${id} konnte nicht vorgewärmt werden:`, err);
}
}
this.models.set(id, { root, solid });
// Nur einsetzen, wenn der Spieler noch da ist; sonst beim nächsten Herankommen.
if (this.wanted.has(id)) this.#show(id);
})
.catch((err) => {
this.failed.add(id);
console.warn(`Ort ${id} konnte nicht geladen werden:`, err);
})
.finally(() => this.pending.delete(id));
}
#show(id) {
if (this.failed.has(id)) return;
const model = this.models.get(id);
if (!model || this.active.has(id)) return;
// Collider vor der Szene bauen: prepareModel hat schon updateMatrixWorld(true) auf der Wurzel
// aufgerufen, die Quelle hat also eine gültige Weltmatrix, obwohl sie noch nicht in der Szene
// hängt. Wirft addCollider (z. B. toter Worker), bleibt die Szene unverändert statt eine
// Wurzel ohne active-Eintrag (und damit nie wieder entfernbar) stehen zu lassen — und aus
// update(), das #show synchron aufruft, entkäme sonst eine Ausnahme in die Spielschleife.
let handle;
try {
// Ohne feste Teile keinen Collider: eine leere Quelle liefert im Controller nur ein Handle,
// das nie fertig wird (vgl. Ruling 51 in streaming.js).
handle = model.solid
? this.player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: model.root } })
: null;
} catch (err) {
this.failed.add(id);
console.warn(`Ort ${id} konnte nicht eingesetzt werden:`, err);
return;
}
this.scene.add(model.root);
model.root.updateMatrixWorld(true);
this.active.set(id, { root: model.root, handle });
}
#hide(id) {
const entry = this.active.get(id);
if (!entry) return;
this.scene.remove(entry.root);
if (entry.handle) this.player.removeCollider(entry.handle);
this.active.delete(id);
}
}
+245 -3
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from "three";
import { PLACES, SOLID_MATERIALS, isSolidMaterial, placeTransform, prepareModel,
import { afterEach, describe, expect, it, vi } from "vitest";
import { BoxGeometry, BufferAttribute, BufferGeometry, Group, InterleavedBuffer,
InterleavedBufferAttribute, Mesh, MeshStandardMaterial, Scene, Vector3 } from "three";
import { PLACES, PlaceLayer, SOLID_MATERIALS, isSolidMaterial, placeTransform, prepareModel,
wantedPlaces } from "../src/world/places.js";
describe("placeTransform", () => {
@@ -91,6 +92,78 @@ describe("prepareModel", () => {
it("meldet, wenn kein Teil fest ist", () => {
expect(prepareModel(fakeModel(["grass_medium_01"]), { at: [0, 0], rotation: 0, lift: 0 })).toBe(false);
});
// Bruch: colliderBuild.ts im three-player-controller klont die Geometrie und rechnet matrixWorld
// per applyMatrix4 hinein. Bei einer normalisierten Int16-Position (KHR_mesh_quantization aus
// meshopt() in tools/assets/places.mjs) klemmt das Zurückschreiben die Weltkoordinaten auf ±1 —
// der Hof kollidierte dann nahe dem Weltursprung statt an seiner Stelle im Hood Yard.
it("entquantisiert feste Positionen, bevor der Collider die Weltmatrix hineinrechnet", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position",
new BufferAttribute(new Int16Array([-32767, 0, -32767, 32767, 32767, 32767]), 3, true));
const mesh = new Mesh(geometry, new MeshStandardMaterial({ name: "cont_a_side" }));
mesh.scale.setScalar(17);
const root = new Group();
root.add(mesh);
prepareModel(root, { at: [1489, 1067.5], rotation: 0, lift: -0.28 });
expect(mesh.geometry.attributes.position.array).toBeInstanceOf(Float32Array);
expect(mesh.geometry.attributes.position.normalized).toBe(false);
// Wie colliderBuild.ts: Geometrie klonen und die Weltmatrix hineinrechnen.
const worldGeometry = mesh.geometry.clone();
worldGeometry.applyMatrix4(mesh.matrixWorld);
worldGeometry.computeBoundingBox();
expect(worldGeometry.boundingBox.min.x).toBeCloseTo(1472);
expect(worldGeometry.boundingBox.max.x).toBeCloseTo(1506);
expect(worldGeometry.boundingBox.min.z).toBeCloseTo(-1084.5);
expect(worldGeometry.boundingBox.max.z).toBeCloseTo(-1050.5);
});
// Bruch: GLTFLoader liefert quantisierte Positionen in echt als InterleavedBufferAttribute
// (gemeinsamer Puffer mit weiteren Attributen wie Normale und UV), nicht als eigene
// BufferAttribute — eine Entquantisierung, die nur den einfachen Fall abdeckt, ließe die echten
// Modelldaten quantisiert und der Hof kollidierte wie oben nahe dem Weltursprung.
it("entquantisiert feste Positionen auch als interleaved Attribut", () => {
const buffer = new InterleavedBuffer(
new Int16Array([-32767, 0, -32767, 0, 0, 0, 0, 0, 32767, 32767, 32767, 0, 0, 0, 0, 0]), 8);
const geometry = new BufferGeometry();
geometry.setAttribute("position", new InterleavedBufferAttribute(buffer, 3, 0, true));
const mesh = new Mesh(geometry, new MeshStandardMaterial({ name: "cont_a_side" }));
mesh.scale.setScalar(17);
const root = new Group();
root.add(mesh);
prepareModel(root, { at: [1489, 1067.5], rotation: 0, lift: -0.28 });
expect(mesh.geometry.attributes.position.array).toBeInstanceOf(Float32Array);
expect(mesh.geometry.attributes.position.normalized).toBe(false);
// Wie colliderBuild.ts: Geometrie klonen und die Weltmatrix hineinrechnen.
const worldGeometry = mesh.geometry.clone();
worldGeometry.applyMatrix4(mesh.matrixWorld);
worldGeometry.computeBoundingBox();
expect(worldGeometry.boundingBox.min.x).toBeCloseTo(1472);
expect(worldGeometry.boundingBox.max.x).toBeCloseTo(1506);
expect(worldGeometry.boundingBox.min.z).toBeCloseTo(-1084.5);
expect(worldGeometry.boundingBox.max.z).toBeCloseTo(-1050.5);
});
it("lässt eine durchlässige Position quantisiert", () => {
const geometry = new BufferGeometry();
const position = new BufferAttribute(new Int16Array([0, 0, 0, 32767, 32767, 32767]), 3, true);
geometry.setAttribute("position", position);
const mesh = new Mesh(geometry, new MeshStandardMaterial({ name: "grass_medium_01" }));
const root = new Group();
root.add(mesh);
prepareModel(root, { at: [0, 0], rotation: 0, lift: 0 });
expect(mesh.geometry.attributes.position).toBe(position);
});
});
describe("PLACES", () => {
@@ -106,3 +179,172 @@ describe("PLACES", () => {
}
});
});
describe("PlaceLayer", () => {
afterEach(() => vi.restoreAllMocks());
const place = { id: "hof", url: "./hof.glb", at: [1489, 1067.5], rotation: 0, lift: -0.28 };
// Spieler distance Meter südlich der Ortsmitte (three: +z ist Süden).
const south = (distance) => ({ x: 1489, y: 0, z: -1067.5 + distance });
function makeLayer(load, { warmUp } = {}) {
const scene = new Scene();
const player = { addCollider: vi.fn(() => ({ id: 7 })), removeCollider: vi.fn() };
const layer = new PlaceLayer({ scene, player, places: [place], load, warmUp });
return { scene, player, layer };
}
// Bruch: Einsetzen schon beim Vorladen (der Hof stünde über ungeladenem Boden) oder erst nach
// einem zweiten Laden.
it("lädt ab 600 m vor und setzt erst ab 250 m ein", async () => {
const model = fakeModel();
const load = vi.fn(async () => model);
const { scene, player, layer } = makeLayer(load);
layer.update(south(500));
await vi.waitFor(() => expect(layer.models.has("hof")).toBe(true));
expect(load).toHaveBeenCalledWith("./hof.glb");
expect(scene.children).not.toContain(model);
expect(player.addCollider).not.toHaveBeenCalled();
layer.update(south(240));
expect(scene.children).toContain(model);
expect(player.addCollider).toHaveBeenCalledWith({
motion: "static", useWorker: true, shape: { kind: "mesh", source: model },
});
});
// Bruch: Collider bleibt nach dem Herausnehmen stehen (unsichtbare Wand) oder Flackern an der Grenze.
it("nimmt den Ort erst jenseits von 450 m heraus und meldet den Collider ab", async () => {
const model = fakeModel();
const { scene, player, layer } = makeLayer(vi.fn(async () => model));
layer.update(south(100));
await vi.waitFor(() => expect(scene.children).toContain(model));
layer.update(south(400));
expect(scene.children).toContain(model);
expect(player.removeCollider).not.toHaveBeenCalled();
layer.update(south(460));
expect(scene.children).not.toContain(model);
expect(player.removeCollider).toHaveBeenCalledWith({ id: 7 });
});
it("setzt nach der Rückkehr ohne neues Laden wieder ein", async () => {
const model = fakeModel();
const load = vi.fn(async () => model);
const { scene, player, layer } = makeLayer(load);
layer.update(south(100));
await vi.waitFor(() => expect(scene.children).toContain(model));
layer.update(south(700));
layer.update(south(100));
expect(scene.children).toContain(model);
expect(load).toHaveBeenCalledTimes(1);
expect(player.addCollider).toHaveBeenCalledTimes(2);
});
// Bruch: ein spät fertiges Laden setzt den Ort ein, obwohl der Spieler längst weitergefahren ist.
it("lässt den Ort draußen, wenn der Spieler beim Ende des Ladens schon fort ist", async () => {
const model = fakeModel();
let finish;
const { scene, player, layer } = makeLayer(vi.fn(() => new Promise((resolve) => { finish = resolve; })));
layer.update(south(100));
layer.update(south(500));
finish(model);
await vi.waitFor(() => expect(layer.models.has("hof")).toBe(true));
expect(scene.children).not.toContain(model);
expect(player.addCollider).not.toHaveBeenCalled();
layer.update(south(100));
expect(scene.children).toContain(model);
});
it("meldet ohne feste Teile keinen Collider an", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const model = fakeModel(["grass_medium_01"]);
const { scene, player, layer } = makeLayer(vi.fn(async () => model));
layer.update(south(100));
await vi.waitFor(() => expect(scene.children).toContain(model));
layer.update(south(460));
expect(player.addCollider).not.toHaveBeenCalled();
expect(player.removeCollider).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"));
});
// Bruch: ein fehlender Ort bricht das Spiel ab oder wird in jedem Frame neu angefragt.
it("warnt bei einem Ladefehler, wirft nicht und versucht es nicht erneut", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const load = vi.fn(async () => { throw new Error("404"); });
const { scene, layer } = makeLayer(load);
expect(() => layer.update(south(100))).not.toThrow();
await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"), expect.any(Error)));
layer.update(south(100));
expect(load).toHaveBeenCalledTimes(1);
expect(scene.children).toHaveLength(0);
});
// Bruch: warmUp läuft erst nach dem Einsetzen oder gar nicht — der einmalige Stall durch
// Texturupload und Shader-Übersetzung (~130 ms, gemessen im Spiel) bliebe dann im ersten Bild,
// statt im Vorladen zu verschwinden.
it("wärmt ein Modell vor dem Einsetzen einmalig vor", async () => {
const model = fakeModel();
const load = vi.fn(async () => model);
const warmUp = vi.fn(async (root) => {
expect(scene.children).not.toContain(root);
});
const { scene, player, layer } = makeLayer(load, { warmUp });
layer.update(south(100));
await vi.waitFor(() => expect(scene.children).toContain(model));
expect(warmUp).toHaveBeenCalledTimes(1);
expect(warmUp).toHaveBeenCalledWith(model);
});
// Bruch: eine fehlschlagende Vorwärmung lässt den Ort für immer draußen, obwohl das Modell
// längst geladen und vorbereitet ist.
it("setzt einen Ort trotz fehlgeschlagener Vorwärmung ein und warnt", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const model = fakeModel();
const load = vi.fn(async () => model);
const warmUp = vi.fn(async () => { throw new Error("Kontextverlust"); });
const { scene, layer } = makeLayer(load, { warmUp });
layer.update(south(100));
await vi.waitFor(() => expect(scene.children).toContain(model));
expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"), expect.any(Error));
});
// Bruch: addCollider wirft, scene.add lief aber schon davor — die Wurzel bliebe unsichtbar und
// für immer in der Szene, ohne active-Eintrag also nie mehr entfernbar; oder die Ausnahme entkäme
// synchron aus update() in die Spielschleife (#show läuft hier direkt, nicht über die Ladekette).
it("hält die Szene unverändert und warnt, wenn addCollider beim Einsetzen wirft", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const model = fakeModel();
const load = vi.fn(async () => model);
const { scene, player, layer } = makeLayer(load);
player.addCollider.mockImplementation(() => { throw new Error("Worker tot"); });
layer.update(south(500));
await vi.waitFor(() => expect(layer.models.has("hof")).toBe(true));
expect(() => layer.update(south(100))).not.toThrow();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"), expect.any(Error));
expect(scene.children).not.toContain(model);
const callsBefore = player.addCollider.mock.calls.length;
layer.update(south(500));
layer.update(south(100));
expect(scene.children).not.toContain(model);
expect(player.addCollider.mock.calls.length).toBe(callsBefore);
});
});
+20 -1
View File
@@ -1,8 +1,9 @@
import { isAbsolute } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { Document } from "@gltf-transform/core";
import sharp from "sharp";
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs";
import { FAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE, optimizePlace } from "../tools/assets/places.mjs";
import { FAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE, optimizePlace, placeTarget } from "../tools/assets/places.mjs";
/** 1100 px große Streifen. seed macht die Bilder verschieden, sonst legt dedup() sie zusammen. */
function stripes(seed) {
@@ -95,3 +96,21 @@ describe("optimizePlace", () => {
expect(used).not.toContain("EXT_mesh_gpu_instancing");
});
});
describe("placeTarget", () => {
// Bruch: ein Pfad relativ zum Arbeitsverzeichnis statt zum Projekt — bei einem Aufruf aus einem
// anderen Ordner (z. B. aus einem Skript) landete die Datei am falschen Ort oder mkdir schlug fehl.
it("liefert den absoluten Zielpfad unter public/models/places/", () => {
const target = placeTarget("hood-yard");
expect(isAbsolute(target)).toBe(true);
expect(target.endsWith("public/models/places/hood-yard.glb")).toBe(true);
});
// Bruch: eine ID mit Pfadtrennzeichen oder Großbuchstaben schreibt außerhalb von
// public/models/places/ oder unter einem Namen, den PLACES so nie referenziert.
it("lehnt IDs mit Pfadtrennzeichen oder Großbuchstaben ab", () => {
expect(() => placeTarget("../x")).toThrow();
expect(() => placeTarget("Hood Yard")).toThrow();
});
});
+3 -3
View File
@@ -86,9 +86,9 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte",
expect(props.filter(({ X, Y }) => insideBounds(place, X, Y))).toEqual([]);
});
// Bruch: der Ort erscheint, bevor sein Boden geladen ist, und steht dann über dem Meer — oder der
// Boden wird abgebaut, während der Ort noch in der Szene ist.
it("ist nur in der Szene, solange die Chunks unter ihm geladen sind", () => {
// Bruch: der Ort erscheint, bevor der Boden unter ihm angefordert ist, und steht dann über dem
// Meer — oder der Boden wird abgebaut, während der Ort noch in der Szene ist.
it("ist nur in der Szene, solange die Chunks unter ihm angefordert sind", () => {
const { position } = placeTransform(place);
const farthest = Math.max(...under.map(([i, j]) => Math.hypot(
SLICE.x0 + (i + 0.5) * CHUNK_SIZE - position.x,
+17 -3
View File
@@ -8,7 +8,7 @@
// verwirft EXT_materials_bump, und darin steckt das Wellblech der Container (bumpExtension.mjs).
import { mkdir, stat } from "node:fs/promises";
import { dirname } from "node:path";
import { pathToFileURL } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { ExtensionProperty, NodeIO, PropertyType } from "@gltf-transform/core";
import { ALL_EXTENSIONS, EXTTextureWebP } from "@gltf-transform/extensions";
import { compressTexture, dedup, flatten, getBounds, join, listTextureSlots, meshopt, prune,
@@ -119,13 +119,27 @@ export function describePlace(document) {
};
}
/**
* Zielpfad für ein aufbereitetes Modell, absolut und unabhängig vom Arbeitsverzeichnis (public/
* models/places/<id>.glb, relativ zu dieser Datei aufgelöst statt zu process.cwd()). id landet auch
* in src/world/places.js (PLACES) und im Dateinamen — nur Kleinbuchstaben, Ziffern und Bindestrich,
* damit sie auf jedem Dateisystem gleich aussieht und nicht aus public/models/places/ herausführt.
*/
export function placeTarget(id) {
if (!/^[a-z0-9-]+$/.test(id)) {
throw new Error(`Ungültige Orts-ID "${id}": erlaubt sind nur Kleinbuchstaben, Ziffern und Bindestrich.`);
}
const dir = new URL("../../public/models/places/", import.meta.url);
return fileURLToPath(new URL(`${id}.glb`, dir));
}
async function main() {
const [source, id] = process.argv.slice(2);
if (!source || !id) {
console.error("Aufruf: node tools/assets/places.mjs <quelle.glb> <id>");
console.error("Aufruf: node tools/assets/places.mjs <quelle.glb> <id (Kleinbuchstaben, Ziffern, Bindestrich)>");
process.exit(1);
}
const target = `public/models/places/${id}.glb`;
const target = placeTarget(id);
const io = new NodeIO()
.registerExtensions([...ALL_EXTENSIONS, EXTMaterialsBump])
.registerDependencies({ "meshopt.encoder": MeshoptEncoder });
+7 -7
View File
@@ -38,7 +38,7 @@ box = [[-885, 2045], [-833, 1995]]
[[annotation]]
name = "Schild 97 in B2"
kind = "symbol"
kind = "region"
box = [[-551, 2199], [-497, 2147]]
[[annotation]]
@@ -153,7 +153,7 @@ box = [[-861, 1572], [-797, 1521]]
[[annotation]]
name = "Schild 97 in B4"
kind = "symbol"
kind = "region"
box = [[-652, 1601], [-592, 1539]]
[[annotation]]
@@ -238,7 +238,7 @@ box = [[1440, 1336], [1515, 1289]]
[[annotation]]
name = "Schild 97 in A6"
kind = "symbol"
kind = "region"
box = [[-869, 1230], [-817, 1184]]
[[annotation]]
@@ -278,7 +278,7 @@ box = [[-1000, 830], [-945, 774]]
[[annotation]]
name = "Schild 97 in A7"
kind = "symbol"
kind = "region"
box = [[-945, 933], [-893, 883]]
[[annotation]]
@@ -338,7 +338,7 @@ box = [[-670, 530], [-613, 490]]
[[annotation]]
name = "Schild 404 in B8"
kind = "symbol"
kind = "region"
box = [[-626, 618], [-567, 575]]
[[annotation]]
@@ -478,7 +478,7 @@ box = [[1905, -60], [1934, -131]]
[[annotation]]
name = "Schild 397 in B12"
kind = "symbol"
kind = "region"
box = [[-685, -346], [-625, -397]]
[[annotation]]
@@ -493,7 +493,7 @@ box = [[-313, -398], [-169, -433]]
[[annotation]]
name = "Schild 397 in D12"
kind = "symbol"
kind = "region"
box = [[-146, -410], [-87, -460]]
[[annotation]]
+11 -3
View File
@@ -24,7 +24,7 @@ from skimage.morphology import disk
from .classify import PALETTE
from .coords import SLICE, chunk_for_world, chunk_name
from .inpaint import RebuildStats, fill_nearest, is_thin, rebuild
from .inpaint import RebuildStats, add_buildings, fill_nearest, is_thin, rebuild
KINDS = ("text_white", "text_red", "marker", "symbol", "street_name", "region")
MARGIN = {"text_white": 7, "text_red": 7, "marker": 3, "symbol": 4, "street_name": 1, "region": 0}
@@ -406,7 +406,7 @@ def remove_annotations(mosaic: np.ndarray, labels: np.ndarray, annotations: Anno
owners.setdefault(int(cid), entry)
touched.append(ids)
generated = []
generated, rebuilt = [], []
h, w = labels.shape
for cid, sl in enumerate(ndi.find_objects(components), start=1):
if sl is None:
@@ -419,9 +419,17 @@ def remove_annotations(mosaic: np.ndarray, labels: np.ndarray, annotations: Anno
continue
mask = np.zeros(labels.shape, dtype=bool)
mask[sl] = piece
result = rebuild(labels, mask, union)
result = rebuild(labels, mask, union, add=False)
owner = owners[cid]
owner.stats = result.stats if owner.stats is None else _merged(owner.stats, result.stats)
rebuilt.append((cid, sl))
# Ergänzt wird erst, wenn alle Masken aufgebaut sind: sonst zählten Buchstabenkonturen noch nicht
# aufgebauter Masken als Straße und teilten Blöcke, und das Ergebnis hinge von der Reihenfolge ab.
for cid, sl in rebuilt:
mask = np.zeros(labels.shape, dtype=bool)
mask[sl] = components[sl] == cid
result = add_buildings(labels, mask, union)
owners[cid].stats = _merged(owners[cid].stats, result.stats)
generated += [Polygon([(SLICE["x0"] + c, SLICE["y1"] - r) for r, c in corners])
for corners in result.buildings]
for entry, ids in zip(entries, touched):
+18 -4
View File
@@ -90,12 +90,14 @@ def is_thin(mask: np.ndarray) -> bool:
return float(ndi.distance_transform_edt(np.pad(mask, 1)).max()) <= THIN_DEPTH
def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None) -> RebuildResult:
def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None,
add: bool = True) -> RebuildResult:
"""Baut das Klassenbild unter einer zusammenhängenden Maske wieder auf.
blocked sind alle Pixel, die nicht als Quelle taugen — alle Beschriftungsmasken; ohne
Angabe nur diese Maske. Reihenfolge wie in der Spec: Straßen und Wege, Flächen,
Gebäude schließen, Gebäude ergänzen.
Gebäude schließen, Gebäude ergänzen. Mit add=False endet der Aufbau nach dem Schließen;
ergänzt wird dann über add_buildings, sobald alle Masken aufgebaut sind.
"""
blocked = mask if blocked is None else blocked
stats = RebuildStats()
@@ -107,6 +109,14 @@ def rebuild(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = N
_stamp(L, M, painted, road, points, halves)
_fill_areas(L, M & ~painted, B)
_close_buildings(L, M, B, painted, stats)
corners = _add_buildings(labels, mask, blocked, stats) if add else []
return RebuildResult(stats, corners)
def add_buildings(labels: np.ndarray, mask: np.ndarray, blocked: np.ndarray | None = None) -> RebuildResult:
"""Ergänzt Gebäude unter einer schon aufgebauten Maske; die Statistik trägt nur added und note."""
blocked = mask if blocked is None else blocked
stats = RebuildStats()
corners = _add_buildings(labels, mask, blocked, stats)
return RebuildResult(stats, corners)
@@ -535,13 +545,17 @@ def _window(mask, margin):
def _rectangle(rows, cols) -> Polygon:
"""Gedrehtes Hüllrechteck um Pixel (Zeilen, Spalten) als Polygon in (Spalte, Zeile), mit Pixelkante."""
"""Gedrehtes Hüllrechteck um Pixel (Zeilen, Spalten) als Polygon in (Spalte, Zeile), mit Pixelkante.
Liegen die Pixel auf einer Linie, ist das Hüllrechteck eine Linie; eckige Enden machen daraus
wieder ein Rechteck mit vier Ecken statt einer Kapsel.
"""
points = MultiPoint(np.column_stack([cols, rows]).astype(float))
with warnings.catch_warnings():
# Siehe buildings.regularize(): harmlose GEOS-Warnung bei achsenparallelen Formen.
warnings.filterwarnings("ignore", message=r".*oriented_envelope.*", category=RuntimeWarning)
envelope = points.minimum_rotated_rectangle
return envelope.buffer(0.5, join_style="mitre")
return envelope.buffer(0.5, cap_style="square", join_style="mitre")
def _sides(rect: Polygon) -> tuple[float, float]:
+1
View File
@@ -25,6 +25,7 @@ def main() -> None:
labels = drop_map_labels(labels)
removal = remove_annotations(mosaic, labels, load_annotations(Path("data/annotations.toml")))
labels = removal.labels
labels = drop_map_labels(labels) # Inselreste, die erst das Entfernen im Wasser hinterlässt
print(removal.report())
if unknown_share > 0.05:
raise SystemExit(f"{unknown_share:.1%} der Pixel unbekannt. Palette kalibrieren, nicht die Grenze anheben.")
+51 -1
View File
@@ -1,4 +1,5 @@
import numpy as np
from scipy import ndimage as ndi
from shapely.geometry import box
from leonida.annotations import (Annotation, AnnotationList, Grid, annotation_mask, candidates, grid_mask,
@@ -6,7 +7,7 @@ from leonida.annotations import (Annotation, AnnotationList, Grid, annotation_ma
from leonida.classify import CLASSES, classify
PARK, ROAD, URBAN = (192, 216, 128), (80, 80, 80), (216, 216, 216)
WHITE, OUTLINE = (248, 248, 248), (80, 88, 96)
WHITE, OUTLINE, BUILDING = (248, 248, 248), (80, 88, 96), (176, 176, 176)
def picture(h=200, w=300, colour=URBAN):
@@ -154,6 +155,24 @@ def test_the_grid_is_closed_before_anything_is_rebuilt():
assert removal.grid_lines == 7 # Spalten 0, 100, 200, 300 und Zeilen 0, 100, 200
def test_a_grid_line_through_an_entry_leaves_no_strip_in_it():
img = picture(colour=PARK)
img[:, 98:102] = ROAD # Gitterlinie, als Straße gelesen, quer durch den Schriftzug
letter(img, (80, 120), (60, 140))
annotations = AnnotationList([entry("text_white", (60, 141), (40, 161))], grid=Grid(100, 6))
removal = remove_annotations(img, classify(img), annotations)
assert (removal.labels == CLASSES["land"]).all() # erst danach aufgebaut, liefe dort ein Straßenstreifen
def test_a_thin_mask_takes_the_nearest_class_and_leaves_no_slit():
img = picture()
img[60:140, 100:200] = BUILDING
line = entry("region", (99, 102), (90, 211), name="Zeigerlinie") # 3 m breit, quer über das Gebäude
removal = remove_annotations(img, classify(img), AnnotationList([line]))
assert (removal.labels[60:140, 100:200] == CLASSES["gebaeude"]).all()
assert (removal.labels[99:102, 90:100] == CLASSES["stadt"]).all()
def test_entries_sharing_one_mask_are_reported_together():
img = picture()
img[:, 140:160] = ROAD
@@ -165,6 +184,37 @@ def test_entries_sharing_one_mask_are_reported_together():
assert "#2 Rechts · A1 · Maske" in report and "gemeinsam mit #1" in report
class ReversedComponents:
"""scipy.ndimage für annotations.py, nur nummeriert label() die Flächen rückwärts."""
def __getattr__(self, name):
return getattr(ndi, name)
@staticmethod
def label(image, structure=None):
numbers, count = ndi.label(image, structure=structure)
return np.where(numbers > 0, count + 1 - numbers, 0), count
def test_added_buildings_do_not_depend_on_the_order_of_the_masks(monkeypatch):
img = picture(300, 400)
img[20:40, :] = ROAD
img[260:280, :] = ROAD
img[:, 20:40] = ROAD
img[:, 360:380] = ROAD
for top in (60, 100, 140, 180):
for left in (60, 120):
img[top:top + 20, left:left + 30] = BUILDING # links sichtbar bebaut
letter(img, (43, 257), (180, 190)) # Kontur in Straßenfarbe teilt den Block bis zum Aufbau
annotations = AnnotationList([entry("text_white", (30, 271), (165, 206), index=1, name="Balken"),
entry("region", (60, 241), (220, 341), index=2, name="Rest")])
forward = remove_annotations(img, classify(img), annotations)
monkeypatch.setattr("leonida.annotations.ndi", ReversedComponents())
backward = remove_annotations(img, classify(img), annotations)
assert forward.generated # im verdeckten Teil rechts entstehen Gebäude
assert (forward.labels == backward.labels).all()
def test_buildings_mostly_inside_added_rectangles_are_marked():
heights = [{"height": 9.0, "source": "district"}, {"height": 12.0, "source": "district"}]
marked = mark_generated([box(1, 1, 9, 9), box(6, 0, 16, 10)], heights, [box(0, 0, 10, 10)])
+32 -2
View File
@@ -1,8 +1,9 @@
import numpy as np
import pytest
from scipy import ndimage as ndi
from leonida.classify import CLASSES
from leonida.inpaint import fill_nearest, is_thin, rebuild
from leonida.inpaint import _rectangle, _sides, fill_nearest, is_thin, rebuild
STADT, PARK, WATER = CLASSES["stadt"], CLASSES["gruen"], CLASSES["wasser"]
ROAD, PATH, BUILDING = CLASSES["strasse"], CLASSES["weg"], CLASSES["gebaeude"]
@@ -133,6 +134,18 @@ def test_a_path_stays_a_path_and_the_street_wins_the_crossing():
assert (labels[120:138, 146:149] == PATH).all()
def test_a_path_runs_through_to_a_stub_too_short_for_a_direction():
labels = city()
labels[:80, 145:150] = PATH
labels[120:134, 155:160] = PATH # kurzes, versetztes Stück: kein Straßenende
mask = box_mask(labels.shape, slice(80, 120), slice(100, 200))
scribble(labels, mask)
result = rebuild(labels, mask)
assert (result.stats.connected, result.stats.extended, result.stats.dead_ends) == (1, 0, 0)
paths, count = ndi.label(labels == PATH)
assert count == 1 # oberer Weg und Stück hängen zusammen
def test_park_and_water_under_text_stay_what_they_are():
labels = city()
labels[:, :150] = PARK
@@ -203,9 +216,12 @@ def test_a_hidden_block_part_gets_the_density_of_the_visible_part():
def test_added_buildings_keep_setback_gaps_and_stay_in_the_hidden_part():
labels, mask = block_city()
scribble(labels, mask)
before = labels.copy()
result = rebuild(labels, mask)
assert (labels[~mask] == before[~mask]).all()
for corners in result.buildings: # Maske: Pixel 60–240 × 220–340 samt Pixelkante
assert all(59.5 <= r <= 240.5 and 219.5 <= c <= 340.5 for r, c in corners)
new = (labels == BUILDING) & mask
assert not (new & ~mask).any()
road_distance = ndi.distance_transform_edt(~np.isin(labels, [ROAD, PATH]))
assert road_distance[new].min() >= 15 # Straßenabstand der Nachbarn, auf 15 m begrenzt
pieces, count = ndi.label(new)
@@ -223,6 +239,14 @@ def test_no_buildings_are_added_in_a_park():
assert result.stats.added == 0
def test_no_buildings_are_added_in_water():
labels, mask = block_city()
labels[40:260, 200:360] = WATER
scribble(labels, mask)
result = rebuild(labels, mask)
assert result.stats.added == 0
def test_without_three_neighbours_nothing_is_added():
labels, mask = block_city()
labels[labels == BUILDING] = STADT
@@ -242,3 +266,9 @@ def test_rebuild_is_deterministic():
a, b = rebuild(first, mask), rebuild(second, mask)
assert (first == second).all()
assert a.buildings == b.buildings
def test_pixels_on_one_line_give_a_rectangle_with_four_corners():
rect = _rectangle(np.full(10, 5), np.arange(10, 20)) # 10 Pixel in einer Zeile
assert len(rect.exterior.coords) == 5 # vier Ecken und der Schlusspunkt
assert _sides(rect) == pytest.approx((1.0, 10.0))
BIN
View File
Binary file not shown.