From f7506ac556fe065fa3537e4e67c313cce59e552d Mon Sep 17 00:00:00 2001 From: KNSO Date: Mon, 14 Sep 2026 20:08:18 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Umsetzungsplan=20f=C3=BCr=20die=20Trib?= =?UTF-8?q?=C3=BCne=20im=20Park=20J6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Tasks: ersetzte Gebäude beim Chunk-Aufbau weglassen, Materialrollen und Asset-Test je Ort, Tribüne aufbereiten und eintragen (am Prototyp gemessen: 1,7 MB, 69 557 Dreiecke, 14 Primitive, 49 MB), Prüfung im Browser mit vorberechneten Blick- und Kapselpunkten. Co-Authored-By: Claude Opus 5 --- docs/superpowers/plans/2026-09-14-tribuene.md | 718 ++++++++++++++++++ 1 file changed, 718 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-14-tribuene.md diff --git a/docs/superpowers/plans/2026-09-14-tribuene.md b/docs/superpowers/plans/2026-09-14-tribuene.md new file mode 100644 index 0000000..3ce4144 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-tribuene.md @@ -0,0 +1,718 @@ +# Tribüne im Park J6 — Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die Tribüne aus `tribuene.glb` ersetzt 1:1 den Gebäudekasten in J6 (Flamingo Park Baseball Stadium): an der Stelle des alten Gebäudes, mit Schatten und Kollision, und den alten Kasten gibt es im Spiel nicht mehr. + +**Architecture:** Die Orte-Schicht aus dem Hood Yard bleibt, wie sie ist. Neu ist das Feld `replaces` im `PLACES`-Eintrag, eine Liste von Weltpunkten. `ChunkStreamer` bekommt diese Punkte aus `main.js` und lässt über die reine Funktion `withoutReplacedBuildings` jeden Grundriss weg, in dem ein Punkt liegt. Die Tribüne selbst wird mit `tools/assets/places.mjs` aufbereitet und über die globalen Materiallisten eingeordnet. Der Asset-Test prüft danach jeden Ort einzeln. + +**Tech Stack:** three.js 0.184, Vite 7, vitest 3.2, `@gltf-transform/core|extensions|functions` 4.5.0, `sharp` 0.35.4, `meshoptimizer` 1.2.0. + +**Spec:** `docs/superpowers/specs/2026-09-14-tribuene-design.md` + +## Global Constraints + +- **Lage:** `at: [1409.9, 1051.1]`, `rotation: 45`, `lift: 0`, `bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 }`, `replaces: [[1403.25, 1057.75]]`. `at` und `replaces` sind Welt-x und Welt-y, nie three-z. +- **Radien** bleiben: `PLACE_PRELOAD_RADIUS = 600`, `PLACE_SHOW_RADIUS = 250`, `PLACE_HIDE_RADIUS = 450`. +- **Materialrollen:** + - fest: `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Trapezblech`, `Riffelblech`, `Verglasung`, `utility_box_01` + - Nahflächen: `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Sitzschale` + - Vereinfachung 0,25: `modular_electric_cables`, `modular_electric_cables_boxes`, `industrial_wall_lamp`, `industrial_wall_lamp_glass`, `power_box_01`, `utility_box_01` +- **Quelle:** `/Users/Kenso/Downloads/tribuene.glb` (6,3 MB) kommt nie ins Repo. Die Kopie `tribuene.glb` im Hauptordner bleibt unberührt. +- **Aufbereitung** nur mit `node tools/assets/places.mjs`, nie mit der gltf-transform-CLI. +- **Nicht anfassen:** `public/data/`, `tools/extract/`, die Minimap, `src/world/geometry.js`, `src/world/props.js`, die Radien. +- **Arbeitsbaum** `/Users/Kenso/Developer/gta6/.claude/worktrees/tribuene`, Branch `leonida/tribuene`, alle Befehle von dort aus. + - Gestaget wird immer mit Pfaden, nie mit `git add -A`. + - Die Isolationswache verweigert Befehle mit Variablen als Befehl oder mit Skriptpfaden, die Leerzeichen enthalten. Deshalb Pfade ausschreiben und einfache Einzelbefehle nutzen. +- **Stil:** Kommentare und Testnamen auf Deutsch, wie in den vorhandenen Dateien. Über Tests, die eine Fehlerart abfangen, steht ein Kommentar „Bruch:“. +- **Commit-Nachrichten** enden mit einer Leerzeile und `Co-Authored-By: Claude Opus 5 `. +- **Tests:** einzeln mit `npx vitest run `, alle mit `npm test`. Ausgangsstand: `Test Files 17 passed (17)`, `Tests 170 passed (170)`. + +## Vorbereitung (erledigt) + +Der Arbeitsbaum steht auf `6a05626` plus dem Spec-Commit `4a1b337`. `node_modules` sowie `three-player-controller/package.json` und `dist/` sind hineinkopiert, der Ausgangsstand ist grün. + +- [ ] **Prüfen:** `git status --short` gibt nichts aus, `npm test` meldet 17 Dateien und 170 Tests grün. + +--- + +### Task 1: Ersetzte Gebäude beim Chunk-Aufbau weglassen + +**Files:** +- Modify: `src/world/streaming.js` (Import, neue Funktion, Konstruktor, `#load`) +- Modify: `src/main.js:11` (Import) und `src/main.js:239` (Konstruktion des `ChunkStreamer`) +- Test: `tests/streaming.test.js` + +**Interfaces:** +- Consumes: `pointInPolygon(x, z, rings)` aus `src/world/props.js`, `PLACES` aus `src/world/places.js` (bisher ohne `replaces`) +- Produces: + - `withoutReplacedBuildings(chunk, points)` in `src/world/streaming.js`. `points` ist `Array<[x, y]>` in Weltkoordinaten. Die Funktion gibt `chunk` selbst zurück, wenn nichts entfällt, sonst `{ ...chunk, buildings }` mit gefilterter Liste. + - `new ChunkStreamer({ ..., replacedBuildings = [] })` + - `main.js` übergibt `PLACES.flatMap((place) => place.replaces ?? [])`. + +- [ ] **Schritt 1: Failing Tests schreiben** + +In `tests/streaming.test.js` den Import ändern: + +```js +import { ChunkStreamer, wantedChunks, withoutReplacedBuildings } from "../src/world/streaming.js"; +``` + +`makeStreamer` bekommt einen vierten Parameter (die übrigen Aufrufe bleiben gültig, `undefined` greift auf die Vorgabe zurück): + +```js +function makeStreamer(chunkData, props, pinned, replacedBuildings) { +``` + +und in derselben Funktion die Rückgabe: + +```js + return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned, replacedBuildings }), player, scene }; +``` + +Direkt nach `describe("wantedChunks", …)` (vor dem Kommentar „Gemeinsame Vorrichtung …“) einfügen: + +```js +describe("withoutReplacedBuildings", () => { + // Chunk 9_5 (J6) hat den Ursprung [1250, -1250]: Welt (x, y) liegt im Chunk bei u = x − 1250, v = −y + 1250. + const square = (u, v) => [[[u, v], [u + 10, v], [u + 10, v + 10], [u, v + 10], [u, v]]]; + const chunk = { + chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [], + buildings: [ + { poly: square(150, 190), height: 18.6 }, // Welt x 1400–1410, y 1050–1060 + { poly: square(100, 100), height: 10 }, // Welt x 1350–1360, y 1140–1150 + ], + }; + + // Bruch: Ursprung vergessen oder Welt-y nicht gespiegelt — der Punkt träfe nichts, und der alte Kasten + // stünde mitten im Ort; oder die Eingabe wird verändert statt kopiert. + it("lässt nur den Grundriss weg, in dem ein Punkt liegt, gerechnet in Chunk-Koordinaten", () => { + const result = withoutReplacedBuildings(chunk, [[1405, 1055]]); + + expect(result.buildings).toEqual([chunk.buildings[1]]); + expect(result.land).toBe(chunk.land); + expect(chunk.buildings).toHaveLength(2); + }); + + it("gibt ohne Treffer den Chunk selbst zurück", () => { + expect(withoutReplacedBuildings(chunk, [])).toBe(chunk); + expect(withoutReplacedBuildings(chunk, [[1405, 1255]])).toBe(chunk); + }); +}); +``` + +Im `describe("ChunkStreamer", …)` als letzten Test (nach „entlädt einen angehefteten Chunk nicht …“) einfügen: + +```js + // Bruch: der Streamer baut die Geometrie aus dem ungefilterten Chunk — der alte Kasten stünde samt + // Kollision mitten in dem Ort, der ihn ersetzt. + it("baut ein Gebäude, das ein Ort ersetzt, weder als Kasten noch in die Kollision", async () => { + const chunk = { + chunk: [4, 6], origin: [0, -1000], + land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]], + surfaces: [], roads: [], + buildings: [{ poly: [[[10, 10], [20, 10], [20, 20], [10, 20]]], height: 12 }], + }; + // Gegenprobe ohne Ersatz: Land und Gebäude liegen in der Kollisionsgruppe. + const plain = makeStreamer(chunk); + plain.streamer.update(emptyChunkCenter); + await flush(); + expect(plain.player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(2); + + // Mitte des Gebäudes: u 15 -> Welt-x 15, v 15 -> Welt-y −(−1000 + 15) = 985. + const { streamer, player } = makeStreamer(chunk, undefined, undefined, [[15, 985]]); + streamer.update(emptyChunkCenter); + await flush(); + + const source = player.addCollider.mock.calls[0][0].shape.source; + expect(source.children).toHaveLength(1); + expect(source.children[0].geometry.getAttribute("color")).toBeUndefined(); // die Landplatte, kein Gebäude + }); +``` + +(Gebäudegeometrie trägt als einzige ein `color`-Attribut, siehe `buildBuildings` in `src/world/geometry.js`.) + +- [ ] **Schritt 2: Tests laufen lassen, sie müssen fehlschlagen** + +Run: `npx vitest run tests/streaming.test.js` +Erwartet: Die zwei `withoutReplacedBuildings`-Tests schlagen fehl (`withoutReplacedBuildings is not a function`). „baut ein Gebäude, das ein Ort ersetzt, …“ scheitert an `expected [ …(2) ] to have a length of 1`. + +- [ ] **Schritt 3: Umsetzen** + +In `src/world/streaming.js` die Importe ergänzen: + +```js +import { Group, Mesh } from "three"; +import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js"; +import { buildChunkGeometry } from "./geometry.js"; +import { pointInPolygon } from "./props.js"; +``` + +Nach `function dist(k, position) { … }` einfügen: + +```js +/** + * Der Chunk ohne die Gebäudegrundrisse, die ein Ort ersetzt (PLACES[].replaces in src/world/places.js): + * jeder Grundriss, in dem einer der Weltpunkte [x, y] liegt, entfällt — und mit ihm Kasten, Schatten und + * Kollision. Wirkt wie remove_building der Feinkalibrierung, nur zur Laufzeit. Trifft kein Punkt, kommt + * derselbe Chunk zurück. + */ +export function withoutReplacedBuildings(chunk, points) { + if (points.length === 0) return chunk; + const [ox, oz] = chunk.origin; + const buildings = chunk.buildings.filter((building) => + !points.some(([x, y]) => pointInPolygon(x - ox, -y - oz, building.poly))); + return buildings.length === chunk.buildings.length ? chunk : { ...chunk, buildings }; +} +``` + +Konstruktor: + +```js + constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [], replacedBuildings = [] }) { + Object.assign(this, { scene, player, materials, props, baseUrl, replacedBuildings }); +``` + +In `#load` die Zeile `const geos = buildChunkGeometry(chunk);` ersetzen durch: + +```js + const geos = buildChunkGeometry(withoutReplacedBuildings(chunk, this.replacedBuildings)); +``` + +`this.props.build(chunk)` bleibt beim ungefilterten Chunk, Palmen und Laternen kennen keine Gebäude. + +In `src/main.js` Zeile 11: + +```js +import { PLACES, PlaceLayer } from "./world/places.js"; +``` + +und Zeile 239 (`const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });`) ersetzen durch: + +```js +// Gebäudekästen aus der Extraktion, an deren Stelle ein Ort steht (PLACES[].replaces), baut der Streamer +// gar nicht erst — sonst stünde zum Beispiel die Tribüne mitten in ihrem alten Kasten. +const streamer = new ChunkStreamer({ + scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS, + replacedBuildings: PLACES.flatMap((place) => place.replaces ?? []), +}); +``` + +- [ ] **Schritt 4: Tests laufen lassen, sie müssen bestehen** + +Run: `npx vitest run tests/streaming.test.js` +Erwartet: alle Tests grün. + +Run: `npm test` +Erwartet: `Test Files 17 passed (17)`, `Tests 173 passed (173)`. + +- [ ] **Schritt 5: Commit** + +```bash +git add src/world/streaming.js src/main.js tests/streaming.test.js +git commit -m "feat(world): Gebäude, die ein Ort ersetzt, beim Chunk-Aufbau weglassen" -m "withoutReplacedBuildings filtert die Grundrisse, in denen ein Weltpunkt aus PLACES[].replaces liegt; ChunkStreamer baut die Geometrie daraus, main.js übergibt die Punkte aller Orte. Damit entfallen Kasten, Schatten und Kollision des alten Gebäudes." -m "Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 2: Materialrollen der Tribüne und Asset-Test je Ort + +**Files:** +- Modify: `src/world/places.js:37-52` (`SOLID_MATERIALS` und Kommentar darüber) +- Modify: `tools/assets/places.mjs:26-45` (`NEAR_MATERIALS`, `SIMPLIFY_RATIOS` und Kommentar) +- Test: `tests/places.test.js` (`describe("isSolidMaterial")`), `tests/placesPipeline.test.js` (neuer Block), `tests/placesAsset.test.js` (neu geschrieben) + +**Interfaces:** +- Consumes: nichts aus Task 1 +- Produces: + - `SOLID_MATERIALS`, `NEAR_MATERIALS` und `SIMPLIFY_RATIOS` enthalten die Tribünen-Namen. + - `tests/placesAsset.test.js` prüft jede Datei aus `PLACES` gegen die Tabellen `SOLID_BY_PLACE` und `BUMP_BY_PLACE`. Task 3 fügt `tribuene` zu `PLACES` hinzu, die Zeile in `SOLID_BY_PLACE` steht dann schon. + +- [ ] **Schritt 1: Failing Tests schreiben** + +In `tests/places.test.js` im `describe("isSolidMaterial", …)` nach dem ersten `it` einfügen: + +```js + // Bruch: Die Tribüne wäre an Beton, Stahl, Dach, Glas oder den Anschlusskästen durchlässig, oder man + // bliebe an Sitzschalen, Kabeln und Wandleuchten hängen (zwischen den Sitzreihen sind nur 38 cm). + it("lässt Beton, Stahl, Dach, Glas und Anschlusskästen der Tribüne kollidieren, Sitze, Kabel und Leuchten nicht", () => { + for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", + "utility_box_01"]) { + expect(isSolidMaterial(name)).toBe(true); + } + for (const name of ["Sitzschale", "modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp", + "industrial_wall_lamp_glass", "power_box_01"]) { + expect(isSolidMaterial(name)).toBe(false); + } + }); +``` + +In `tests/placesPipeline.test.js` den Import ändern: + +```js +import { FAR_TEXTURE_SIZE, NEAR_MATERIALS, NEAR_TEXTURE_SIZE, SIMPLIFY_RATIOS, optimizePlace, placeTarget } + from "../tools/assets/places.mjs"; +``` + +und am Dateiende anhängen: + +```js +describe("Materiallisten der Tribüne", () => { + // Bruch: Beton, Estrich, Verkleidung, Stahl oder Sitze fielen auf 512 px, obwohl der Spieler direkt + // davorsteht — oder Dach und Kabinenboden, die man nur von weitem sieht, behielten 768 px. + it("führt Beton, Estrich, Verkleidung, Stahl und Sitze als Nahflächen, Dach und Kabinenboden nicht", () => { + for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale"]) expect(NEAR_MATERIALS.has(name)).toBe(true); + for (const name of ["Trapezblech", "Riffelblech"]) expect(NEAR_MATERIALS.has(name)).toBe(false); + }); + + // Bruch: Kabel, Kästen und Leuchten blieben bei 64 000 Dreiecken, oder die Vereinfachung träfe Körper, + // Dach, Geländer oder Sitze (Löcher in Wänden, schiefe Stufenkanten). + it("vereinfacht nur die Kleinteile der Tribüne, auf 0,25", () => { + for (const name of ["modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp", + "industrial_wall_lamp_glass", "power_box_01", "utility_box_01"]) { + expect(SIMPLIFY_RATIOS.get(name)).toBe(0.25); + } + for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", + "Sitzschale"]) { + expect(SIMPLIFY_RATIOS.has(name)).toBe(false); + } + }); +}); +``` + +- [ ] **Schritt 2: Tests laufen lassen, sie müssen fehlschlagen** + +Run: `npx vitest run tests/places.test.js tests/placesPipeline.test.js` +Erwartet: 3 fehlgeschlagene Tests, nämlich der neue `isSolidMaterial`-Test (`Sichtbeton`: expected false to be true) und beide Tests in „Materiallisten der Tribüne“. + +- [ ] **Schritt 3: Listen ergänzen** + +In `src/world/places.js` den Kommentar über `SOLID_MATERIALS` ändern und am Listenende (nach der Zeile mit den Bäumen) eine Gruppe anhängen: + +```js +/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Sitzschalen, Kleinteile — läuft man durch. */ +export const SOLID_MATERIALS = [ +``` + +```js + // Bäume + "quiver_tree_01_trunk", "quiver_tree_02", + // Tribüne: Betonkörper und Tunnel, Verkleidung, Stahl (Geländer, Dachtragwerk, Rinne), Dach, Kabinenboden, + // Glas der Pressekabine, Anschlusskästen am Boden + "Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01", +]; +``` + +In `tools/assets/places.mjs` in `NEAR_MATERIALS` nach `"chainlink_post", "chainlink_wire",` anhängen: + +```js + // Tribüne: die Quelle hat höchstens 768 px, compressTexture vergrößert nicht + "Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale", +``` + +Den Kommentar über `SIMPLIFY_RATIOS` und die Liste so ändern: + +```js +/** + * Anteil der Dreiecke, der je Material bleibt; Gebäude, Boden und Zäune (Container, Schuppen, Tribüne mit + * Sitzen) fehlen hier und bleiben unverändert. Gemessen am Hood Yard nach flatten/join: die vier Köcherbäume + * haben zusammen 2,2 Mio. Dreiecke (quiver_tree_01_leaf 1,13 Mio., quiver_tree_02 739 000, + * quiver_tree_01_trunk 370 000), Gras je 60 000–150 000, die Mast-Kleinteile 92 000. An der Tribüne haben + * Kabel, Kabeldosen, Wandleuchten und Kästen zusammen 64 298. + */ +export const SIMPLIFY_RATIOS = new Map([ + ["quiver_tree_01_leaf", 0.15], ["quiver_tree_01_trunk", 0.15], ["quiver_tree_02", 0.15], + ["searsia_lucida", 0.5], ["searsia_lucida_leaves", 0.5], ["searsia_lucida_twigs", 0.5], + ["grass_medium_01", 0.5], ["grass_medium_01_dry", 0.5], ["grass_medium_02", 0.5], + ["modular_electricity_poles_pieces", 0.25], ["security_camera_01", 0.25], + ["modular_electric_cables", 0.25], ["modular_electric_cables_boxes", 0.25], ["industrial_wall_lamp", 0.25], + ["industrial_wall_lamp_glass", 0.25], ["power_box_01", 0.25], ["utility_box_01", 0.25], +]); +``` + +- [ ] **Schritt 4: Tests laufen lassen** + +Run: `npx vitest run tests/places.test.js tests/placesPipeline.test.js` +Erwartet: grün. + +Run: `npx vitest run tests/placesAsset.test.js` +Erwartet: 1 Fehlschlag in „enthält jedes feste Material der Kollisionsliste“ mit den acht Tribünen-Namen. Das ist der bekannte Fallstrick: Der Test gleicht die ganze globale Liste mit der Datei des Hood Yard ab. + +- [ ] **Schritt 5: Asset-Test auf alle Orte umbauen** + +`tests/placesAsset.test.js` vollständig ersetzen durch: + +```js +import { statSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; +import { NodeIO } from "@gltf-transform/core"; +import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; +import { getBounds } from "@gltf-transform/functions"; +import { MeshoptDecoder } from "meshoptimizer"; +import { PLACES, SOLID_MATERIALS } from "../src/world/places.js"; +import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs"; + +// Feste Materialien je Ort. SOLID_MATERIALS gilt für alle Orte zusammen, aber keine Datei enthält alle +// Namen — geprüft wird deshalb jede Datei gegen ihre eigene Zeile. +const SOLID_BY_PLACE = { + "hood-yard": [ + "gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete", + "cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame", + "ironPlain", "ironTeal", "ironBlue", "galv", "rust", "darkTrim", "osb", "osbB", + "rollershutter_window_01", "rollershutter_window_01_graffiti", + "chainlink_post", "chainlink_wire", + "modular_electricity_poles", "barrel_03", "plastic_container", + "quiver_tree_01_trunk", "quiver_tree_02", + ], + tribuene: ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01"], +}; + +// Materialien, deren Relief nur aus EXT_materials_bump kommt. Die Tribüne hat keine Bump-Maps. +const BUMP_BY_PLACE = { + "hood-yard": ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"], +}; + +// Bruch: ein festes Material steht in keiner Zeile — die Datei seines Orts würde nie darauf geprüft. +it("verteilt SOLID_MATERIALS vollständig auf die Orte", () => { + expect(Object.values(SOLID_BY_PLACE).flat().sort()).toEqual([...SOLID_MATERIALS].sort()); +}); + +describe.each(PLACES.map((place) => [place.id, place]))("%s.glb", (id, place) => { + const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url)); + const bump = BUMP_BY_PLACE[id] ?? []; + let document; + beforeAll(async () => { + await MeshoptDecoder.ready; + const io = new NodeIO() + .registerExtensions([...ALL_EXTENSIONS, EXTMaterialsBump]) + .registerDependencies({ "meshopt.decoder": MeshoptDecoder }); + document = await io.read(file); + }, 30000); + + // Bruch: Aufbereitung übersprungen oder falsch eingestellt — die Quelle des Hood Yard hatte 653 MB. + it("ist kleiner als 30 MB", () => { + expect(statSync(file).size).toBeLessThan(30e6); + }); + + // Bruch: Geometrie unkomprimiert oder GPU-Instanzen, deren Matrizen die Kollision des Controllers nicht liest. + it("nutzt meshopt und WebP und hat keine Instanzen", () => { + const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName); + + expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp"])); + expect(used).not.toContain("EXT_mesh_gpu_instancing"); + }); + + it("hat nur WebP-Texturen bis 1024 px", () => { + for (const texture of document.getRoot().listTextures()) { + expect(texture.getMimeType()).toBe("image/webp"); + expect(Math.max(...texture.getSize())).toBeLessThanOrEqual(1024); + } + }); + + // Bruch: die Aufbereitung verwirft EXT_materials_bump (wie die gltf-transform-CLI) — das Wellblech der + // Container wäre flach. + it.runIf(bump.length > 0)("gibt den Bump-Materialien ihre Bump-Textur", () => { + const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName); + const materials = new Map(document.getRoot().listMaterials().map((material) => [material.getName(), material])); + + expect(used).toContain(EXT_MATERIALS_BUMP); + for (const name of bump) { + expect(materials.get(name)?.getExtension(EXT_MATERIALS_BUMP)?.getBumpTexture()).toBeTruthy(); + } + }); + + // Bruch: dedup oder ein neues Werkzeug benennt ein Material um — der Ort wäre dort ohne Warnung + // durchlässig, weil die Kollision nach Namen auswählt. Oder der Ort hat keine Zeile in SOLID_BY_PLACE. + it("enthält jedes feste Material seines Orts", () => { + const names = document.getRoot().listMaterials().map((material) => material.getName()); + + expect(SOLID_BY_PLACE[id]).toBeDefined(); + expect(SOLID_BY_PLACE[id].filter((name) => !names.includes(name))).toEqual([]); + }); + + // Bruch: bounds in PLACES passt nicht mehr zur Datei — der Datentest prüfte dann die falsche Fläche. + it("passt auf 5 cm zu den Außenmaßen in PLACES", () => { + const { min, max } = getBounds(document.getRoot().listScenes()[0]); + const { minX, maxX, minZ, maxZ } = place.bounds; + + expect(Math.abs(min[0] - minX)).toBeLessThan(0.05); + expect(Math.abs(max[0] - maxX)).toBeLessThan(0.05); + expect(Math.abs(min[2] - minZ)).toBeLessThan(0.05); + expect(Math.abs(max[2] - maxZ)).toBeLessThan(0.05); + }); +}); +``` + +- [ ] **Schritt 6: Alle Tests laufen lassen** + +Run: `npm test` +Erwartet: `Test Files 17 passed (17)`, `Tests 177 passed (177)`. Gegenüber dem Ausgangsstand kommen hinzu: +- 3 Tests aus Task 1 +- 1 Test aus `places.test.js` +- 2 Tests aus `placesPipeline.test.js` +- der neue Verteilungstest im Asset-Test + +- [ ] **Schritt 7: Commit** + +```bash +git add src/world/places.js tools/assets/places.mjs tests/places.test.js tests/placesPipeline.test.js tests/placesAsset.test.js +git commit -m "feat(world): Materialrollen der Tribüne, Asset-Test je Ort" -m "Fest: Sichtbeton, Estrich, Verkleidung, Stahl, Trapezblech, Riffelblech, Verglasung, utility_box_01. Nahflächen: Sichtbeton, Estrich, Verkleidung, Stahl, Sitzschale. Kabel, Kabeldosen, Wandleuchten und Kästen auf 0,25. Der Asset-Test prüft jede Datei aus PLACES gegen ihre eigene Zeile fester Materialien statt gegen die ganze globale Liste, Bump nur beim Hood Yard." -m "Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 3: Tribüne aufbereiten und eintragen + +**Files:** +- Create: `public/models/places/tribuene.glb` (Ergebnis von `places.mjs`) +- Modify: `src/world/places.js` (Kommentar über `PLACES`, neuer Eintrag) +- Modify: `docs/superpowers/specs/2026-09-14-tribuene-design.md` (Abschnitt „Budget“) +- Test: `tests/places.test.js` (`describe("PLACES")`), `tests/placesSite.test.js` + +**Interfaces:** +- Consumes: + - `withoutReplacedBuildings(chunk, points)` aus Task 1 + - die Materiallisten und `SOLID_BY_PLACE.tribuene` aus Task 2 + - `node tools/assets/places.mjs ` +- Produces: `PLACES` mit dem Eintrag `tribuene`, den `main.js` (Task 1) und `PlaceLayer` verwenden, und `public/models/places/tribuene.glb`. + +- [ ] **Schritt 1: Aufbereiten** + +Run: `node tools/assets/places.mjs /Users/Kenso/Downloads/tribuene.glb tribuene` + +Erwartet (am Prototyp gemessen, Laufzeit unter 5 s): +- `vorher 6.3 MB`, darin `triangles: 117916`, `gpuMB: 53` +- `nachher 1.7 MB`, darin: + - `materials: 14`, `primitives: 14`, `triangles: 69557` + - `textures: { '512x512 image/webp': 23, '256x256 image/webp': 12, '768x768 image/webp': 4 }` + - `gpuMB: 49`, `bounds: { min: [ -20.8, 0, -14.82 ], max: [ 20.8, 12.57, 0.8 ] }` + +Weicht eine Zahl deutlich ab, anhalten und melden. + +- [ ] **Schritt 2: Eintrag in `PLACES`** + +In `src/world/places.js` den Kommentar über `PLACES` ergänzen: + +```js +/** + * 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 (wie add_building in corrections.toml). lift: Meter auf die + * Modellwurzel. bounds: Außenmaße in Modellkoordinaten (x, z); das Spiel braucht sie nicht, die Tests + * prüfen damit Datei (placesAsset.test.js) und Lage (placesSite.test.js) gegeneinander. replaces + * (optional): Weltpunkte [x, y]; jeder Gebäudegrundriss aus der Extraktion, in dem einer liegt, wird beim + * Chunk-Aufbau weggelassen (withoutReplacedBuildings in streaming.js). + */ +``` + +und nach dem Eintrag `hood-yard` anfügen: + +```js + { + id: "tribuene", + url: "./models/places/tribuene.glb", + // Längs mittig auf dem alten Gebäudekasten, Front 1 m vor dessen Feldseite (Vergleichsbild des Nutzers); + // längs bleiben bis zur Fahrbahn nur 1,25 m nach Nordosten und 1,8 m nach Südwesten. + at: [1409.9, 1051.1], + // Die Sitzreihen schauen wie in echt nach Südosten auf die Home Plate. + rotation: 45, + // Keine Bodenplatte: der Betonkörper steht mit seiner Unterseite auf 0, flach auf 0 liegt nichts. + lift: 0, + bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 }, + // Der 18,6 m hohe Gebäudekasten aus der Extraktion an dieser Stelle (J6). + replaces: [[1403.25, 1057.75]], + }, +``` + +- [ ] **Schritt 3: Tests laufen lassen, die vorhandenen Datentests müssen anschlagen** + +Run: `npx vitest run tests/places.test.js tests/placesSite.test.js tests/placesAsset.test.js` +Erwartet: +- `tribuene.glb` im Asset-Test: 5 Tests grün, 1 übersprungen (Bump). +- Rot wird „hat für jeden Ort id, url, at, rotation, lift und bounds“ in `places.test.js`, weil `replaces` ein unerwartetes Feld ist. +- Rot wird „Ort tribuene auf der Karte > liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“. Die Liste zeigt Punkte im alten Gebäude. +- Alles andere bleibt grün. + +- [ ] **Schritt 4: Form-Test lässt `replaces` zu** + +In `tests/places.test.js` den Test im `describe("PLACES", …)` ersetzen durch: + +```js + // Bruch: ein Eintrag ohne Pflichtfeld — placeTransform rechnete mit undefined, die Tests mit bounds + // prüften nichts; oder replaces ist keine Liste von Punkten [x, y], und der Streamer ließe nichts weg. + it("hat für jeden Ort id, url, at, rotation, lift und bounds, dazu optional replaces mit Weltpunkten", () => { + for (const { replaces = [], ...entry } of PLACES) { + expect(entry).toEqual({ + id: expect.any(String), url: expect.stringMatching(/^\.\/models\/places\/.+\.glb$/), + at: [expect.any(Number), expect.any(Number)], rotation: expect.any(Number), lift: expect.any(Number), + bounds: { minX: expect.any(Number), maxX: expect.any(Number), minZ: expect.any(Number), maxZ: expect.any(Number) }, + }); + for (const point of replaces) expect(point).toEqual([expect.any(Number), expect.any(Number)]); + } + }); +``` + +- [ ] **Schritt 5: Lage-Test kennt ersetzte Gebäude** + +In `tests/placesSite.test.js`: + +Import ergänzen (nach der Zeile mit `props.js`): + +```js +import { withoutReplacedBuildings } from "../src/world/streaming.js"; +``` + +Nach der Funktion `chunk(i, j)` einfügen: + +```js +// Grundrisse, die ein Ort ersetzt, baut der Streamer nicht (withoutReplacedBuildings, verdrahtet in +// src/main.js) — für die Prüfung auf Gebäude zählen sie deshalb nicht. +const REPLACED = PLACES.flatMap((place) => place.replaces ?? []); +const standing = new Map(); +function standingBuildings(i, j) { + const key = `${i}_${j}`; + if (!standing.has(key)) standing.set(key, withoutReplacedBuildings(chunk(i, j), REPLACED).buildings); + return standing.get(key); +} +``` + +Im Test „liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“ den Kommentar und die Gebäudezeile ändern: + +```js + // Bruch: der Ort steht auf einer Straße, in einem Gebäude oder über Wasser — nach einem Verschieben + // in PLACES oder nach einem neuen Lauf der Extraktion. Gebäude, die ein Ort ersetzt, zählen nicht. +``` + +```js + const inBuilding = standingBuildings(i, j).some((building) => pointInPolygon(u, v, building.poly)); +``` + +Direkt nach diesem Test einfügen: + +```js + // Bruch: Nach einem neuen Lauf der Extraktion liegt das ersetzte Gebäude woanders oder ist geteilt. Der + // Punkt träfe dann nichts (der alte Kasten stünde wieder im Ort), mehrere Grundrisse oder einen außerhalb. + it.runIf((place.replaces ?? []).length > 0)("ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich", () => { + const bad = place.replaces.filter(([X, Y]) => { + const { i, j } = chunkForWorld(X, Y); + const c = chunk(i, j); + const hits = c.buildings.filter((building) => pointInPolygon(X - c.origin[0], -Y - c.origin[1], building.poly)); + return hits.length !== 1 || !insideBounds(place, X, Y); + }); + expect(bad).toEqual([]); + }); +``` + +- [ ] **Schritt 6: Tests laufen lassen, sie müssen bestehen** + +Run: `npx vitest run tests/places.test.js tests/placesSite.test.js tests/placesAsset.test.js` +Erwartet: alles grün. Übersprungen sind nur „gibt den Bump-Materialien ihre Bump-Textur“ bei `tribuene.glb` und „ersetzt mit jedem replaces-Punkt …“ beim Hood Yard. + +- [ ] **Schritt 7: Gegenprobe, der neue Datentest muss anschlagen** + +In `src/world/places.js` den Punkt vorübergehend auf `replaces: [[1403.25, 1087.75]]` setzen (30 m nördlich, auf der Straße). + +Run: `npx vitest run tests/placesSite.test.js` +Erwartet für `tribuene`: rot sind „liegt ganz auf Land, … in keinem Gebäude“ (der Kasten zählt wieder) und „ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich“ (`[[1403.25, 1087.75]]`). + +Danach zurück auf `replaces: [[1403.25, 1057.75]]`. Run: `npx vitest run tests/placesSite.test.js`, erwartet grün. + +- [ ] **Schritt 8: Budget in der Spec** + +In `docs/superpowers/specs/2026-09-14-tribuene-design.md` den Abschnitt „## Budget“ vollständig ersetzen, mit den Werten aus dem Bericht von Schritt 1: + +```markdown +## Budget + +- **Gemessen an der Aufbereitung (2026-09-14):** + - 1,7 MB, 69 557 Dreiecke, 14 Primitive (Draw Calls) + - 39 WebP-Texturen: 4 × 768, 23 × 512, 12 × 256 px + - geschätzt 49 MB Grafikspeicher, Außenmaße unverändert + - Die sechs Kleinteil-Materialien haben 15 939 Dreiecke statt 64 298. +- **Zum Vergleich der Hood Yard:** 657 653 Dreiecke, 43 Primitive, 231 MB. +``` + +Weicht der Bericht vom Prototyp ab, gelten die Werte des Berichts. + +- [ ] **Schritt 9: Alle Tests laufen lassen** + +Run: `npm test` +Erwartet: `Test Files 17 passed (17)`, `Tests 186 passed | 2 skipped (188)`. Gegenüber Task 2 kommen hinzu: +- 6 Asset-Tests der Tribüne, davon 5 grün und 1 übersprungen +- 4 Lage-Tests der Tribüne, alle grün +- beim Hood Yard der neue Lage-Test, übersprungen + +- [ ] **Schritt 10: Commit** + +```bash +git add public/models/places/tribuene.glb src/world/places.js tests/places.test.js tests/placesSite.test.js docs/superpowers/specs/2026-09-14-tribuene-design.md +git commit -m "feat(world): Tribüne im Park J6 statt des alten Gebäudekastens" -m "tribuene.glb mit places.mjs aufbereitet (6,3 MB -> 1,7 MB, 69 557 Dreiecke, 14 Primitive, 49 MB Grafikspeicher). PLACES-Eintrag bei (1409,9 | 1051,1), 45° gedreht, lift 0, replaces auf den 18,6 m hohen Kasten. Der Lage-Test übergeht ersetzte Grundrisse und prüft, dass jeder replaces-Punkt genau ein Gebäude unter dem Ort trifft." -m "Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 4: Prüfung im Browser (Controller, nicht an Subagenten) + +Das übernimmt die Sitzung selbst. Sie braucht den Browser-Bereich, einen Eintrag in `.claude/launch.json` des Hauptordners (vorher den Nutzer fragen) und Rückfragen an den Nutzer. Grundlage ist `.claude/skills/placing-editor-models/browser-checks.md`, die Abschnitte werden unten mit §N zitiert. + +**Files:** +- Create, nie committen, am Ende löschen: `index-debug.html` (§2) +- Modify, am Ende zurücknehmen: `/Users/Kenso/Developer/gta6/.claude/launch.json` (§1, Eintrag `place-check`, Port 5181, `cd /Users/Kenso/Developer/gta6/.claude/worktrees/tribuene && npx vite --port 5181 --strictPort`) +- Modify: `docs/superpowers/specs/2026-09-14-tribuene-design.md` (neuer Punkt „Gemessen im Spiel“ unter „Fehlerfälle und Risiken“) + +**Werte** (Welt → three: z = −y): + +| Zweck | Welt (x \| y) | three | +|---|---|---| +| Wurzel der Tribüne | (1409,9 \| 1051,1) | `position` (1409.9, 0, −1051.1), `rotation.y` = π/4 | +| Sprungziel auf dem Spielfeld | (1420,5 \| 1040,5) | — | +| Draufsicht | Mitte (1404,94 \| 1056,06) | `position [1404.94, 60, -1056.06]`, `target [1404.94, 0, -1056.06]`, `up [0, 0, -1]` | +| Blick vom Spielfeld | (1427,58 \| 1033,42) | `position [1427.58, 1.7, -1033.42]`, `target [1404.94, 5, -1056.06]` | +| Blick von hinten | (1388,69 \| 1072,31) | `position [1388.69, 1.7, -1072.31]`, `target [1404.94, 5, -1056.06]` | +| Streifen des alten Kastens hinter der Tribüne | (1397,88 \| 1063,12) | Strahl von (1397.88, 60, −1063.12) nach unten | +| Kapsel hinter der Rückwand | (1389,39 \| 1054,64) | `capsule.position.set(1389.39, 1, -1054.64)`, Blick 135° | +| Kapsel vor dem Tunnel (x 6–8) | (1402,83 \| 1068,07) | `capsule.position.set(1402.83, 1, -1068.07)`, Blick 135° | +| Kapsel auf der Sitzstufe y 6,0 | (1394,34 \| 1049,69) | `capsule.position.set(1394.34, 7, -1049.69)`, Blick 45° (längs der Reihe) | + +- [ ] **Schritt 1: Server und Debug-Seite** + - Den Nutzer fragen, ob der Eintrag `place-check` in `.claude/launch.json` des Hauptordners gesetzt werden darf. + - Dann `ExitWorktree(keep)`, Eintrag setzen, `preview_start({ name: "place-check" })`, `EnterWorktree({ path: "/Users/Kenso/Developer/gta6/.claude/worktrees/tribuene" })`. + - `index-debug.html` nach §2 anlegen, `resize_window` auf 1280 × 800 setzen, `http://localhost:5181/index-debug.html` öffnen und den Hook nach §3 einrichten. +- [ ] **Schritt 2: Vorwärmen am Startpunkt** + - Warten, bis `renderer.info.memory.textures` 5 s lang gleich bleibt. + - Die verschiedenen Texturen zählen, die über `scene.traverse` an Materialien hängen. + - Erwartet: Die GPU-Zahl liegt um mindestens 108 höher (69 Hood Yard + 39 Tribüne), weil beide Orte vorgeladen, aber noch nicht in der Szene sind. +- [ ] **Schritt 3: Sprung** + - Minimap nach §4 öffnen und einen Screenshot machen. + - Bei (1420,5 \| 1040,5) klicken und warten, bis die Wurzel der Tribüne in `scene.children` hängt. + - Die Konsole mit `read_console_messages({ onlyErrors: true })` lesen: keine Fehler. +- [ ] **Schritt 4: Alter Kasten weg** + - Mit `THREE.Raycaster` von (1397.88, 60, −1063.12) senkrecht nach unten gegen `scene.children` schießen. + - Erwartet: Der erste Treffer mit `object.isMesh` liegt unter y 1. Die Oberkante des alten Kastens läge bei 18,6 m. + - Denselben Strahl an der alten Mitte (1403.25, 60, −1057.75) schießen: Der erste Treffer liegt höchstens bei 12,6 m, also auf dem Dach der Tribüne. +- [ ] **Schritt 5: Kollisionsmarken und Quantisierung (§5)** + - Die Meshes ohne `userData.excludeFromCollider` haben genau die Materialnamen `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Trapezblech`, `Riffelblech`, `Verglasung`, `utility_box_01`. + - Alle haben `Float32Array`-Positionen. +- [ ] **Schritt 6: Blicke (§6)** + - Draufsicht, Blick vom Spielfeld und Blick von hinten aufnehmen und mit `decode-view.py` speichern. + - Die Bilder per `SendUserFile` an den Nutzer schicken. + - Achten auf: zerrissene Kabel oder Leuchten, Schatten des Glases, Helligkeit. +- [ ] **Schritt 7: Kollision (§7)**, jede Sekunde die Lage notieren, danach `keyup`: + - **Rückwand:** Kapsel hinter die Rückwand setzen, Blick 135°, 15 s `KeyW`. Erwartet: Sie steht bei y 0,75 und bleibt wenige cm vor der Wand stehen, also Modell-z knapp unter −14,8. + - **Tunnel:** Kapsel vor den Tunnel setzen, Blick 135°, 25 s `KeyW`. Festhalten, ob sie über die 25-cm-Kante hineinkommt und die Treppe hinaufsteigt. Nur feststellen, nichts ändern. + - **Sitzstufe:** Kapsel auf die Sitzstufe setzen, Blick 45°, 15 s `KeyW`. Erwartet: Sie steht bei etwa y 6,75 und läuft durch die Sitzschalen hindurch, ohne Halt bis zum nächsten Geländer. +- [ ] **Schritt 8: Kosten (§8)** + - Den Median aus 60 Bildern mit `gl.finish` messen: Blick vom Spielfeld, einmal mit und einmal ohne die Wurzel der Tribüne in der Szene. + - Dasselbe in der Draufsicht. + - Erwartet: deutlich unter dem Hood Yard (+0,5 ms). +- [ ] **Schritt 9: Aufräumen** + - `window.__view = null`, `index-debug.html` löschen, `preview_stop`. + - `ExitWorktree(keep)`, den Eintrag `place-check` aus `.claude/launch.json` entfernen, `EnterWorktree(path)`. + - `git status --short` zeigt nur die Spec. +- [ ] **Schritt 10: Messwerte in die Spec und Commit** + - Unter „Fehlerfälle und Risiken“ einen Punkt **Gemessen im Spiel (2026-09-14)** anlegen: + - Kosten mit und ohne Tribüne + - Vorwärmen (Differenz des Texturzählers) + - Ergebnis der drei Kollisionsläufe + - Auffälligkeiten aus den Blicken + - Commit mit `git add docs/superpowers/specs/2026-09-14-tribuene-design.md`, Nachricht `docs: Tribünen-Spec mit Messwerten aus dem Spiel` plus Co-Authored-By-Zeile.