diff --git a/docs/superpowers/specs/2026-09-14-hood-yard-design.md b/docs/superpowers/specs/2026-09-14-hood-yard-design.md new file mode 100644 index 0000000..95d3656 --- /dev/null +++ b/docs/superpowers/specs/2026-09-14-hood-yard-design.md @@ -0,0 +1,168 @@ +# Hood Yard im Park J6/K6 — Entwurf + +**Datum:** 2026-09-14 +**Zusammenhang:** Der Nutzer hat im eigenen Editor „three-d-stage“ einen Hof mit Containern, Schuppen, Zaun, Bäumen und Strommasten gebaut (`~/Downloads/hood-yard.glb`, dazu eine `.mtl` nur mit Materialnamen). Er soll 1:1 an die Stelle, an der im echten Flamingo Park (Miami Beach) die kleinen Gebäude zwischen Baseball- und Fußballfeld stehen. Das Spiel zeigt diesen Park in J6/K6, oben an der Grenze der beiden Planquadrate. Vorlage ist ein Satellitenbild des Nutzers (`~/Desktop/Bildschirmfoto 2026-09-14 um 00.15.53.png`, 1552 × 474 px). +**Baut auf:** `src/world/coords.js` (Welt ↔ three, Planquadrate), `src/world/props.js` (GLTFLoader mit Meshopt-Decoder), `src/world/streaming.js` (Lade- und Entladeradius, Collider je Chunk), `src/world/shadows.js` (`prepareLitMaterials`), `tools/assets/props.mjs` (bisherige Modell-Aufbereitung über `npx @gltf-transform/cli`). + +## Ziel + +Der Hof steht im Spiel an der richtigen Stelle, in Originalgröße, bündig auf dem Gelände, mit Schatten und Kollision, und das Spiel bleibt flüssig. Weitere Modelle aus dem Editor lassen sich später mit einem Listeneintrag und einem Skriptaufruf dazustellen. + +## Befund (gemessen) + +### Modell + +| Punkt | Wert | +|---|---| +| Datei | 653,6 MB, `THREE.GLTFExporter r184`, 1103 Knoten, 294 Meshes, 44 Materialien | +| Texturen | 105 PNG in 2048 × 2048, davon 69 verschieden (409 MB) | +| Dreiecke | 750 826, davon rund 560 000 in Bäumen und Büschen, 92 000 in Mast-Kleinteilen | +| Maße | x −17,23 bis 17,00 · z −13,40 bis 8,60 · Höhe bis 8,94 m (Masten), also 34,2 × 22,0 m | +| Einheit | Meter: ein Container ist 2,9 × 6,3 m und 2,6 m hoch | +| Ausrichtung | Rückseite mit Zaun und Hintergrundbäumen bei −z, Front mit Straße und Toren bei +z. Im Spiel ist −z Norden, das passt ohne Drehung zum Satellitenbild. | +| Ursprung | Mitte der Gebäudereihe: Container x −10,17 bis −0,72, Schuppen x −0,55 bis 10,35 | +| Boden | `lot_gravel` ist eine 34 cm dicke Kiesplatte von y 0 bis 0,34. Die Wurzel ist um 0,34 angehoben, alles andere steht auf der Plattenoberfläche. | +| Erweiterungen | `KHR_texture_transform`, `EXT_materials_bump`, `KHR_materials_emissive_strength` | +| Wellblech | Die sechs Container-Materialien (`cont_*_side`, `cont_*_end`) sind flache Kästen, ihr Relief kommt nur aus `EXT_materials_bump`. three r184 liest die Erweiterung, `@gltf-transform` 4.5.0 kennt sie nicht und verwirft sie beim Einlesen. | + +### Stelle im Spiel (Chunk-Daten `chunk_9_5`, `chunk_10_5`) + +- Zwischen der Straße am Nordrand des Parks und dem Baseballfeld (`surfaces`, `kind: "park"`) liegt ein Streifen Land: bei x 1472 von y 1057,25 bis 1082,2 (25 m), bei x 1506 von 1058,0 bis 1086,1 (28 m). Die Straße steigt nach Osten um etwa 5° an. +- Das Fußballfeld beginnt bei x 1550,5, seine Oberkante liegt bei y 1073,5. +- Im Bereich x 1465–1520, y 1055–1095 stehen keine Palmen und keine Gebäude. Zwei Laternen (`vergeLampPoints`, eine je Chunk) stehen bei (1478,1 | 1081,6) und (1478,0 | 1083,6). +- Vom Startpunkt (x 1600, y 690) sind es 393 m bis zur Hofmitte. + +### Satellitenbild + +- Maßstab etwa 0,15 m je Pixel, geschätzt aus Autos und Feldmarkierungen (±15 %). +- Anker: Nordost-Ecke des Baseballfelds, im Bild (1000 | 213), im Spiel (1525 | 1058). +- Die echte Reihe ist etwa 37 m lang: drei Container bei x 1470,6–1487,9, danach eine Reihe kleiner Schuppen bis x 1507,8. Die Schuppenfronten liegen bei y ≈ 1066,7. +- Die Reihe im Modell ist 20,5 m lang. Beide Enden können deshalb nicht zugleich passen. + +## Entscheidungen des Nutzers + +| Frage | Entscheidung | +|---|---| +| Größe | 1:1, nichts skalieren (Modell ist in Metern gebaut) | +| Lage | Übergang von Containern zu Schuppen an derselben Stelle wie in echt: Hofmitte x 1489, y 1067,5, keine Drehung (Vergleichsbild im Gespräch bestätigt) | +| Weg ins Spiel | Eigene Schicht „Orte“ mit Laden und Entfernen je nach Abstand, getrennt vom Chunk-Streaming | +| Aufbereitung | Eigenes Node-Skript mit `@gltf-transform`, das `EXT_materials_bump` erhält | +| Abschnitte Aufbereitung, Laufzeit, Prüfung | bestätigt | +| Arbeitsweise | Eigener Arbeitsbaum, weil im Hauptordner nicht committete Änderungen einer anderen Sitzung liegen | + +## Lage + +`at = [1489, 1067.5]`, `rotation = 0`, `lift = −0.28`. + +| Nachbar | Abstand zur Hofkante (Außenmaße 34,2 × 22 m: x 1471,8–1506,0, y 1058,9–1080,9) | +|---|---| +| Straßenrand im Norden | 1,3 m (Westende) bis 5,2 m (Ostende) | +| Baseballfeld im Süden | 1,65 m (Westende) bis 0,9 m (Ostende) | +| Laterne (1478,1 \| 1081,6) | 0,7 m nördlich der Kante, die zweite Laterne 2,7 m | +| Fußballfeld | 44 m östlich | +| J6/K6-Grenze (x 1500) | 0,65 m östlich des letzten Schuppens | + +**Höhe:** Mit `lift = −0.28` liegt die Kiesoberfläche 6 cm über dem Gelände (Oberkante Land y = 0). Man läuft ohne Stufe hinein. Bei 600 m Abstand löst der Tiefenpuffer etwa 4,3 cm auf (Nahwert 0,5 m, 24 Bit), 6 cm flackern also nicht. Alle Teile sinken mit, die Maße im Modell bleiben gleich. + +## Aufbau + +| Einheit | Aufgabe | +|---|---| +| `tools/assets/places.mjs` | Neu. `node tools/assets/places.mjs ` schreibt `public/models/places/.glb` und gibt einen Vorher-Nachher-Bericht aus. | +| `tools/assets/bumpExtension.mjs` | Neu. Durchreich-Erweiterung `EXT_materials_bump` für `@gltf-transform`: `bumpFactor` und `bumpTexture` als echte Texturreferenz, damit Dedup, Prune und Texturkompression sie sehen. | +| `src/world/places.js` | Neu. `PLACES` (Liste), reine Helfer `placeTransform(place)`, `wantedPlaces(position, places, { load, unload, active })`, `isSolidMaterial(name)` und die Klasse `PlaceLayer`. | +| `src/main.js` | Legt `PlaceLayer` neben dem `ChunkStreamer` an und ruft in `tick()` direkt nach `streamer.update` `places.update(playerPosition(player))`. | +| `package.json` | Neue Entwicklungsabhängigkeiten `@gltf-transform/core`, `@gltf-transform/extensions`, `@gltf-transform/functions` (4.5.0), `sharp`, `meshoptimizer`. | +| `public/models/places/hood-yard.glb` | Neu, versioniert. Die Quelldatei bleibt außerhalb des Repos. | + +## Aufbereitung (`tools/assets/places.mjs`) + +Reihenfolge: + +1. **Einlesen** mit `NodeIO`, allen Erweiterungen aus `@gltf-transform/extensions` und `bumpExtension.mjs`. +2. **`dedup()` und `prune()`:** doppelte Texturen, Materialien, Meshes und Accessoren zusammenlegen, Ungenutztes entfernen (105 → 69 Texturen). +3. **`flatten()` und `join()`:** alle Mesh-Knoten direkt unter die Szene, danach Primitive je Material zu einem zusammenfügen. Aus rund 300 Draw Calls werden etwa 45. Materialnamen bleiben erhalten. +4. **`weld()` und `simplifyPrimitive()` mit `MeshoptSimplifier`**, nur für Primitive dieser Materialien: `quiver_tree_01_leaf`, `quiver_tree_01_trunk`, `quiver_tree_02`, `searsia_lucida`, `searsia_lucida_leaves`, `searsia_lucida_twigs`, `grass_medium_01`, `grass_medium_01_dry`, `grass_medium_02`, `modular_electricity_poles_pieces`, `security_camera_01`. Die Stärke (`ratio`, `error`) wird am Bild im Spiel eingestellt und im Skript als Konstante festgehalten. +5. **`textureCompress()` mit `sharp` nach WebP.** + - 1024 px für Texturen der Nahflächen: `gravel`, `asphalt`, `curbAsphalt`, `padAsphalt`, `puddleAsphalt`, `cobble`, `grass`, `cont_*`, `contFrame`, `contHardware`, `ironPlain`, `ironTeal`, `ironBlue`, `osb`, `osbB`, `rollershutter_window_01`, `rollershutter_window_01_graffiti`, `chainlink_post`, `chainlink_wire`. + - 512 px für alle übrigen. + - Hängt eine Textur nach dem Dedup an Materialien beider Gruppen, gilt die größere Kante. + - Normal-Maps werden mit höherer Qualität kodiert als Farb- und Rauheitskarten, damit keine Blockmuster im Licht entstehen. +6. **`meshopt()` mit `MeshoptEncoder`:** Quantisierung und `EXT_meshopt_compression`, wie bei der Straßenlaterne. + +Bewusst nicht: `instance()` (die Kollision des Controllers liest nur `mesh.geometry` mit `matrixWorld`, Instanzmatrizen gingen verloren), `palette()` (fasst texturlose Materialien unter neuem Namen zusammen), KTX2 (bräuchte `toktx` und einen Transcoder im Spiel). + +**Bericht:** Megabyte, Texturen (Anzahl, Kantenlängen), Dreiecke, Primitive, geschätzter Grafikspeicher (RGBA mit Mipmaps), Außenmaße. Ziel: unter 30 MB. + +## Orte-Schicht (`src/world/places.js`) + +### Liste + +```js +export const PLACES = [ + { id: "hood-yard", url: "./models/places/hood-yard.glb", at: [1489, 1067.5], rotation: 0, lift: -0.28 }, +]; +``` + +- `at`: Welt-x und Welt-y in Metern, wie auf der Minimap. Nie three-z. +- `rotation`: Grad gegen den Uhrzeigersinn, von oben mit Norden oben gesehen. Das ist dieselbe Festlegung wie bei `add_building` in `corrections.toml` (`docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md`). +- `lift`: Meter, auf die Modellwurzel. + +### Umrechnung + +`placeTransform(place)` liefert `{ position: { x: at[0], y: lift, z: -at[1] }, rotationY: rotation · π / 180 }`. Positives `rotationY` dreht in three +x nach −z, von oben also gegen den Uhrzeigersinn. + +### Laden und Entfernen + +- `wantedPlaces` misst vom Punkt `at`: Laden bei höchstens 400 m, Entfernen erst über 600 m. Das sind dieselben Radien wie `ChunkStreamer.update`. +- `PlaceLayer.update(position)` stößt das Laden an (ein zwischengespeichertes Promise je Ort, `GLTFLoader` mit `MeshoptDecoder`) und setzt einen fertig geladenen Ort ein, sobald er gewünscht ist. Ist der Spieler beim Ende des Ladens schon weiter als 600 m entfernt, bleibt der Ort draußen, bis er wieder näher als 400 m kommt. +- **Einsetzen:** Wurzel an `placeTransform`, jedes Mesh `castShadow` und `receiveShadow`, nicht feste Meshes `userData.excludeFromCollider = true`, `scene.add`, danach `player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: wurzel } })`. +- **Entfernen:** `scene.remove` und `player.removeCollider(handle)`. Die geladenen Daten bleiben erhalten, eine Rückkehr setzt sofort wieder ein. Kosten: rund 200 MB Grafikspeicher, sobald der Hof einmal geladen war (der Bericht der Aufbereitung nennt den genauen Wert). +- Die Kaskaden-Schatten richten die neuen Materialien selbst ein (`prepareLitMaterials` in `sun.update()`). + +### Kollision + +`isSolidMaterial(name)` gibt für diese Materialien `true` zurück, alle anderen laufen nicht in den Collider: + +| Gruppe | Materialien | +|---|---| +| Boden | `gravel`, `asphalt`, `curbAsphalt`, `padAsphalt`, `puddleAsphalt`, `cobble`, `grass`, `concrete` | +| Container | `cont_a_side`, `cont_a_end`, `cont_b_side`, `cont_b_end`, `cont_c_side`, `cont_c_end`, `contFrame` | +| Schuppen und Tore | `ironPlain`, `ironTeal`, `ironBlue`, `galv`, `rust`, `darkTrim`, `osb`, `osbB`, `rollershutter_window_01`, `rollershutter_window_01_graffiti` | +| Zaun | `chainlink_post`, `chainlink_wire` | +| Masten, Fass, Kiste | `modular_electricity_poles`, `barrel_03`, `plastic_container` | +| Bäume | `quiver_tree_01_trunk`, `quiver_tree_02` | + +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`. + +## 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. +- **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. + +## Tests + +- **`tests/places.test.js`:** + - `placeTransform`: Welt-y wird zu −z, `lift` landet in y, 90° drehen +x nach −z. + - `wantedPlaces`: lädt bei 390 m, entfernt bei 500 m nicht, entfernt bei 650 m, meldet nichts doppelt an. + - `isSolidMaterial`: je ein Material jeder Gruppe fest, `grass_medium_01` und `quiver_tree_01_leaf` nicht. + - `PlaceLayer` mit Attrappen für Szene, Spieler (`addCollider`, `removeCollider`) und Loader: Einsetzen meldet genau einen Collider an und markiert nur nicht feste Meshes; Entfernen meldet ihn ab; Rückkehr setzt ohne neues Laden ein; ein Ladefehler gibt eine Warnung und wirft nicht. +- **Datentest gegen `public/data/vice-a/chunk_9_5.json` und `chunk_10_5.json`:** Das Rechteck der Außenmaße an seiner Lage schneidet kein Straßen- oder Gebäudepolygon, liegt ganz auf `land` und enthält keine Palme und keinen Punkt aus `vergeLampPoints(lampPoints(...))`. +- **Asset-Test auf `public/models/places/hood-yard.glb`** (liest nur den JSON-Teil der Datei): kleiner als 30 MB; `extensionsUsed` enthält `EXT_meshopt_compression`, `EXT_texture_webp` und `EXT_materials_bump`, aber nicht `EXT_mesh_gpu_instancing`; alle Bilder `image/webp`; jedes Material aus `isSolidMaterial` existiert. +- **Browser:** + - Über die Minimap zum Hof springen. + - Screenshots in Augenhöhe von der Straße und vom Baseballfeld aus, dazu eine Draufsicht zum Vergleich mit dem Satellitenbild. + - Gegen Container und Zaun laufen, durch Gras laufen. + - Konsole ohne Fehler. `renderer.info` (Draw Calls, Texturen, Geometrien) und Ladezeit festhalten. + +## Nicht Teil dieser Arbeit + +- Die Minimap zeigt den Hof nicht, ihr Kartenbild kommt aus der Extraktion. +- Keine Detailstufen und kein Ausblenden von Palmen oder Laternen. An dieser Stelle überschneidet sich nichts, der Datentest wacht darüber. +- Das Modell des Nutzers wird nur verkleinert, nicht umgebaut. +- Kein Sprungziel im Debug-Panel. Die Minimap springt schon an jede Stelle. +- Die Feinkalibrierung (`corrections.toml`) bleibt eine eigene Aufgabe.