From 3321a029fad928e3da96152341e8c2dcd867ca3f Mon Sep 17 00:00:00 2001 From: KNSO Date: Mon, 14 Sep 2026 10:24:46 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Umsetzungsplan=20f=C3=BCr=20den=20Hood?= =?UTF-8?q?=20Yard,=20Spec=20am=20Prototyp=20gesch=C3=A4rft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prototyp am echten Modell: 653,6 MB -> 16,2 MB, 2,9 Mio. -> 658 000 Dreiecke, Bump-Maps erhalten. Radien geändert: Der Hof erscheint erst ab 250 m und verschwindet ab 450 m, sonst stünde er vom Startpunkt aus über ungeladenem Boden. Vereinfachung je Material statt eines gemeinsamen Anteils. Co-Authored-By: Claude Opus 5 --- .../superpowers/plans/2026-09-14-hood-yard.md | 1548 +++++++++++++++++ .../specs/2026-09-14-hood-yard-design.md | 89 +- 2 files changed, 1616 insertions(+), 21 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-14-hood-yard.md diff --git a/docs/superpowers/plans/2026-09-14-hood-yard.md b/docs/superpowers/plans/2026-09-14-hood-yard.md new file mode 100644 index 0000000..f32b6fc --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-hood-yard.md @@ -0,0 +1,1548 @@ +# Hood Yard — 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:** Den im Editor three-d-stage gebauten Hood Yard 1:1 an die Stelle der kleinen Gebäude zwischen Baseball- und Fußballfeld im Park J6/K6 setzen, verkleinert von 653 MB auf rund 16 MB, mit Schatten und Kollision. + +**Architecture:** Ein Node-Skript (`tools/assets/places.mjs`) verkleinert die Quelldatei mit `@gltf-transform` und erhält über eine eigene Erweiterung (`tools/assets/bumpExtension.mjs`) das Wellblech der Container. Eine neue Orte-Schicht (`src/world/places.js`) lädt das Ergebnis ab 600 m vor, setzt es ab 250 m in die Szene und meldet einen statischen Collider an, ab 450 m nimmt sie es wieder heraus. `main.js` ruft sie in `tick()` neben dem Chunk-Streaming auf. + +**Tech Stack:** three.js 0.184, Vite 7, vitest 3, `@gltf-transform/core|extensions|functions` 4.5.0, `sharp` 0.35.4, `meshoptimizer` 1.2.0. + +**Spec:** `docs/superpowers/specs/2026-09-14-hood-yard-design.md` + +## Global Constraints + +- Lage: `at: [1489, 1067.5]`, `rotation: 0`, `lift: -0.28`, `bounds: { minX: -17.15, maxX: 17, minZ: -13.4, maxZ: 8.6 }`. `at` ist Welt-x und Welt-y, nie three-z. +- Radien: `PLACE_PRELOAD_RADIUS = 600`, `PLACE_SHOW_RADIUS = 250`, `PLACE_HIDE_RADIUS = 450`. +- Texturen: WebP, 1024 px für die Nahflächen aus der Spec, 512 px für alle anderen, Qualität 80, Normal-Maps 92. +- Vereinfachung: 0,15 für `quiver_tree_01_leaf`, `quiver_tree_01_trunk`, `quiver_tree_02`; 0,5 für `searsia_lucida*` und `grass_medium_*`; 0,25 für `modular_electricity_poles_pieces` und `security_camera_01`; `error` 0,01. Kein `instance()`, kein `palette()`. +- Ergebnis `public/models/places/hood-yard.glb` kleiner als 30 MB. Die Quelle `~/Downloads/hood-yard.glb` (653 MB) kommt nie ins Repo. +- Versionen exakt gepinnt: `@gltf-transform/core`, `@gltf-transform/extensions`, `@gltf-transform/functions` 4.5.0, `sharp` 0.35.4, `meshoptimizer` 1.2.0. +- Nicht anfassen: `src/world/streaming.js`, `src/world/props.js`, `public/data/`, die Minimap. +- Gearbeitet wird im Arbeitsbaum `.claude/worktrees/hood-yard` auf Branch `leonida/hood-yard`. Gestaget wird immer mit Pfaden, nie mit `git add -A`. +- Kommentare und Testnamen auf Deutsch, im Stil der vorhandenen Dateien (Kommentar „Bruch:“ über Tests, die eine Fehlerart abfangen). +- Tests: `npx vitest run `, alle mit `npm test`. Ausgangsstand: 12 Testdateien, 132 Tests. + +## Vorbereitung: Arbeitsbaum (einmal vor Task 1) + +Im Hauptordner liegen nicht committete Änderungen einer anderen Sitzung (`tools/extract`, drei Specs, Controller) und unversionierte Kopien aus dem Controller-Repo. Darunter ist ein `vite.config.ts`, das vitest dem `vite.config.js` vorzieht und das dort „No test files found“ auslöst. Der Arbeitsbaum hat beides nicht. + +- [ ] **Schritt 1: Arbeitsbaum anlegen** (mit superpowers:using-git-worktrees, aus dem Hauptordner) + +```bash +git worktree add .claude/worktrees/hood-yard -b leonida/hood-yard +cp -Rc node_modules .claude/worktrees/hood-yard/node_modules +cp three-player-controller/package.json .claude/worktrees/hood-yard/three-player-controller/ +cp -R three-player-controller/dist .claude/worktrees/hood-yard/three-player-controller/dist +``` + +`cp -c` klont auf APFS ohne Kopierzeit. Das Submodul `three-player-controller` ist im Arbeitsbaum nicht ausgecheckt. `dist/` und `package.json` reichen dem Spiel, denn `node_modules/three-player-controller` zeigt relativ auf `../three-player-controller`. Git ignoriert Dateien in einem nicht ausgecheckten Submodul, das ist geprüft. + +- [ ] **Schritt 2: Ausgangsstand prüfen** (alle weiteren Befehle im Arbeitsbaum) + +```bash +cd .claude/worktrees/hood-yard +git status --short +npm test +``` + +Erwartet: `git status` gibt nichts aus. `npm test` meldet `Test Files 12 passed (12)` und `Tests 132 passed (132)`. + +--- + +### Task 1: `EXT_materials_bump` für gltf-transform + +**Files:** +- Modify: `package.json`, `package-lock.json` (Entwicklungsabhängigkeiten) +- Create: `tools/assets/bumpExtension.mjs` +- Test: `tests/bumpExtension.test.js` + +**Interfaces:** +- Produces: `export const EXT_MATERIALS_BUMP = "EXT_materials_bump"`; `export class Bump extends ExtensionProperty` mit `getBumpFactor(): number`, `setBumpFactor(factor: number): this`, `getBumpTexture(): Texture | null`, `getBumpTextureInfo(): TextureInfo | null`, `setBumpTexture(texture: Texture | null): this`; `export class EXTMaterialsBump extends Extension` mit `createBump(): Bump`. + +- [ ] **Step 1: Abhängigkeiten installieren** + +```bash +npm install --save-dev --save-exact @gltf-transform/core@4.5.0 @gltf-transform/extensions@4.5.0 @gltf-transform/functions@4.5.0 sharp@0.35.4 meshoptimizer@1.2.0 +``` + +Erwartet: In `package.json` stehen unter `devDependencies` fünf neue Einträge ohne `^`. `npm test` meldet weiter 12 Dateien und 132 Tests. + +- [ ] **Step 2: Write the failing test** + +`tests/bumpExtension.test.js`: + +```js +import { describe, expect, it } from "vitest"; +import { Document, NodeIO } from "@gltf-transform/core"; +import { KHRTextureTransform } from "@gltf-transform/extensions"; +import { dedup, prune } from "@gltf-transform/functions"; +import sharp from "sharp"; +import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs"; + +/** 8 × 8 px mit wechselnden Werten — prune() ersetzt einfarbige Texturen sonst durch Faktoren. */ +function pattern() { + const data = Buffer.alloc(8 * 8 * 3); + for (let i = 0; i < data.length; i++) data[i] = (i * 37) & 0xff; + return sharp(data, { raw: { width: 8, height: 8, channels: 3 } }).png().toBuffer(); +} + +/** Ein Dreieck mit dem Material cont_a_side, Bump-Stärke 2,2 und Wellenteilung [3.2, 1]. */ +async function bumpDocument() { + const document = new Document(); + document.createBuffer(); + const bumpExtension = document.createExtension(EXTMaterialsBump); + const transformExtension = document.createExtension(KHRTextureTransform); + const texture = document.createTexture("wellblech").setImage(await pattern()).setMimeType("image/png"); + const bump = bumpExtension.createBump().setBumpFactor(2.2).setBumpTexture(texture); + bump.getBumpTextureInfo().setExtension("KHR_texture_transform", transformExtension.createTransform().setScale([3.2, 1])); + const material = document.createMaterial("cont_a_side").setExtension(EXT_MATERIALS_BUMP, bump); + const positions = document.createAccessor().setType("VEC3").setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0])); + const primitive = document.createPrimitive().setMaterial(material).setAttribute("POSITION", positions); + document.createScene().addChild(document.createNode().setMesh(document.createMesh().addPrimitive(primitive))); + return document; +} + +/** JSON-Teil einer .glb: 12 Byte Kopf, dann Länge und Typ des ersten Blocks, dann das JSON. */ +function glbJson(glb) { + const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength); + return JSON.parse(new TextDecoder().decode(glb.subarray(20, 20 + view.getUint32(12, true)))); +} + +describe("EXT_materials_bump", () => { + const io = () => new NodeIO().registerExtensions([EXTMaterialsBump, KHRTextureTransform]); + + // Bruch: @gltf-transform verwirft die Erweiterung (Wellblech flach), prune entfernt die Textur als + // unbenutzt, oder die Wellenteilung an der bumpTexture geht verloren (Wellen 3,2-mal zu breit). + it("übersteht dedup, prune, Schreiben und Einlesen mit Stärke, Textur und Teilung", async () => { + const document = await bumpDocument(); + await document.transform(dedup(), prune()); + + const read = await io().readBinary(await io().writeBinary(document)); + + const bump = read.getRoot().listMaterials()[0].getExtension(EXT_MATERIALS_BUMP); + expect(bump.getBumpFactor()).toBe(2.2); + expect(bump.getBumpTexture().getSize()).toEqual([8, 8]); + expect(bump.getBumpTextureInfo().getExtension("KHR_texture_transform").getScale()).toEqual([3.2, 1]); + }); + + // Bruch: Feldnamen oder Aufbau weichen von der Spezifikation ab — three.js (GLTFLoader) liest + // genau materials[i].extensions.EXT_materials_bump.bumpFactor und .bumpTexture.index. + it("schreibt das JSON so, wie three.js es liest", async () => { + const json = glbJson(await io().writeBinary(await bumpDocument())); + + expect(json.extensionsUsed).toContain(EXT_MATERIALS_BUMP); + const def = json.materials[0].extensions[EXT_MATERIALS_BUMP]; + expect(def.bumpFactor).toBe(2.2); + expect(json.textures[def.bumpTexture.index]).toBeDefined(); + expect(def.bumpTexture.extensions.KHR_texture_transform.scale).toEqual([3.2, 1]); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run tests/bumpExtension.test.js` +Expected: FAIL, `Failed to load url ../tools/assets/bumpExtension.mjs` (Datei fehlt) + +- [ ] **Step 4: Write minimal implementation** + +`tools/assets/bumpExtension.mjs`: + +```js +// EXT_materials_bump für @gltf-transform (docs/superpowers/specs/2026-09-14-hood-yard-design.md). +// @gltf-transform 4.5.0 kennt die Erweiterung nicht und verwirft sie beim Einlesen. Das Wellblech der +// Container aus three-d-stage steckt aber allein darin, die Container selbst sind flache Kästen. +// Aufgebaut wie KHR_materials_transmission in @gltf-transform/extensions: bumpTexture ist eine echte +// Texturreferenz, damit dedup, prune und compressTexture sie sehen. Ihre TextureInfo läuft über +// setTextureInfo/createTextureInfoDef, so kommt KHR_texture_transform (die Wellenteilung, etwa +// scale [3.2, 1]) mit. three.js liest die Erweiterung selbst (GLTFLoader: bumpFactor -> bumpScale, +// bumpTexture -> bumpMap). +import { Extension, ExtensionProperty, PropertyType, TextureChannel, TextureInfo } from "@gltf-transform/core"; + +export const EXT_MATERIALS_BUMP = "EXT_materials_bump"; + +export class Bump extends ExtensionProperty { + static EXTENSION_NAME = EXT_MATERIALS_BUMP; + + init() { + this.extensionName = EXT_MATERIALS_BUMP; + this.propertyType = "Bump"; + this.parentTypes = [PropertyType.MATERIAL]; + } + + getDefaults() { + return Object.assign(super.getDefaults(), { + bumpFactor: 1, + bumpTexture: null, + bumpTextureInfo: new TextureInfo(this.graph, "bumpTextureInfo"), + }); + } + + getBumpFactor() { + return this.get("bumpFactor"); + } + + setBumpFactor(factor) { + return this.set("bumpFactor", factor); + } + + getBumpTexture() { + return this.getRef("bumpTexture"); + } + + getBumpTextureInfo() { + return this.getRef("bumpTexture") ? this.getRef("bumpTextureInfo") : null; + } + + setBumpTexture(texture) { + return this.setRef("bumpTexture", texture, { channels: TextureChannel.R }); + } +} + +export class EXTMaterialsBump extends Extension { + static EXTENSION_NAME = EXT_MATERIALS_BUMP; + extensionName = EXT_MATERIALS_BUMP; + prereadTypes = [PropertyType.MESH]; + prewriteTypes = [PropertyType.MESH]; + + createBump() { + return new Bump(this.document.getGraph()); + } + + read() { + return this; + } + + write() { + return this; + } + + preread(context) { + const materialDefs = context.jsonDoc.json.materials ?? []; + const textureDefs = context.jsonDoc.json.textures ?? []; + materialDefs.forEach((materialDef, index) => { + const def = materialDef.extensions?.[EXT_MATERIALS_BUMP]; + if (!def) return; + const bump = this.createBump(); + context.materials[index].setExtension(EXT_MATERIALS_BUMP, bump); + if (def.extras) bump.setExtras(def.extras); + if (def.bumpFactor !== undefined) bump.setBumpFactor(def.bumpFactor); + if (def.bumpTexture !== undefined) { + bump.setBumpTexture(context.textures[textureDefs[def.bumpTexture.index].source]); + context.setTextureInfo(bump.getBumpTextureInfo(), def.bumpTexture); + } + }); + return this; + } + + prewrite(context) { + for (const material of this.document.getRoot().listMaterials()) { + const bump = material.getExtension(EXT_MATERIALS_BUMP); + if (!bump) continue; + const materialDef = context.jsonDoc.json.materials[context.materialIndexMap.get(material)]; + const def = context.createPropertyDef(bump); + def.bumpFactor = bump.getBumpFactor(); + const texture = bump.getBumpTexture(); + if (texture) def.bumpTexture = context.createTextureInfoDef(texture, bump.getBumpTextureInfo()); + materialDef.extensions = materialDef.extensions ?? {}; + materialDef.extensions[EXT_MATERIALS_BUMP] = def; + } + return this; + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run tests/bumpExtension.test.js` +Expected: PASS, 2 Tests + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json tools/assets/bumpExtension.mjs tests/bumpExtension.test.js +git commit -m "$(cat <<'EOF' +feat(assets): EXT_materials_bump für gltf-transform + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 2: Aufbereitungs-Skript `tools/assets/places.mjs` + +**Files:** +- Create: `tools/assets/places.mjs` +- Test: `tests/placesPipeline.test.js` + +**Interfaces:** +- Consumes: `EXT_MATERIALS_BUMP`, `EXTMaterialsBump` aus Task 1. +- Produces: `export const NEAR_TEXTURE_SIZE = 1024`; `export const FAR_TEXTURE_SIZE = 512`; `export const NEAR_MATERIALS: Set`; `export const SIMPLIFY_RATIOS: Map`; `export async function optimizePlace(document: Document): Promise`; `export function describePlace(document: Document): { materials, primitives, triangles, textures: Record, gpuMB, bounds: { min: number[], max: number[] } }`. Aufruf: `node tools/assets/places.mjs ` schreibt `public/models/places/.glb`. + +- [ ] **Step 1: Write the failing test** + +`tests/placesPipeline.test.js`: + +```js +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"; + +/** 1100 px große Streifen. seed macht die Bilder verschieden, sonst legt dedup() sie zusammen. */ +function stripes(seed) { + const size = 1100; + const data = Buffer.alloc(size * size * 3); + for (let i = 0; i < data.length; i++) data[i] = (Math.floor(i / 3) % size) * seed & 0xff; + return sharp(data, { raw: { width: size, height: size, channels: 3 } }).png().toBuffer(); +} + +/** n × n Quadrate in der Ebene (2·n² Dreiecke), indiziert. */ +function grid(document, n) { + const positions = []; + const indices = []; + for (let z = 0; z <= n; z++) for (let x = 0; x <= n; x++) positions.push(x, 0, z); + for (let z = 0; z < n; z++) { + for (let x = 0; x < n; x++) { + const a = z * (n + 1) + x, b = a + 1, c = a + n + 1, d = c + 1; + indices.push(a, c, b, b, c, d); + } + } + return document.createPrimitive() + .setAttribute("POSITION", document.createAccessor().setType("VEC3").setArray(new Float32Array(positions))) + .setIndices(document.createAccessor().setType("SCALAR").setArray(new Uint32Array(indices))); +} + +/** Ein Container (Nahfläche mit Bump) und ein Grasstück (Pflanze mit Normal-Map), je ein Knoten. */ +async function sampleDocument() { + const document = new Document(); + document.createBuffer(); + const bumpExtension = document.createExtension(EXTMaterialsBump); + const [near, nearBump, far, farNormal] = await Promise.all([1, 3, 5, 7].map(stripes)); + const texture = (name, image) => document.createTexture(name).setImage(image).setMimeType("image/png"); + + const container = document.createMaterial("cont_a_side").setBaseColorTexture(texture("near", near)); + container.setExtension(EXT_MATERIALS_BUMP, + bumpExtension.createBump().setBumpFactor(2.2).setBumpTexture(texture("nearBump", nearBump))); + const grass = document.createMaterial("grass_medium_01") + .setBaseColorTexture(texture("far", far)) + .setNormalTexture(texture("farNormal", farNormal)); + + const scene = document.createScene(); + scene.addChild(document.createNode("container").setMesh(document.createMesh().addPrimitive(grid(document, 1).setMaterial(container)))); + scene.addChild(document.createNode("grass").setMesh(document.createMesh().addPrimitive(grid(document, 40).setMaterial(grass)))); + return { document, container, grass }; +} + +const trianglesOf = (document, name) => document.getRoot().listMeshes() + .flatMap((mesh) => mesh.listPrimitives()) + .filter((primitive) => primitive.getMaterial().getName() === name) + .reduce((sum, primitive) => sum + primitive.getIndices().getCount() / 3, 0); + +describe("optimizePlace", () => { + let sample; + beforeAll(async () => { + sample = await sampleDocument(); + await optimizePlace(sample.document); + }, 60000); + + // Bruch: Nahflächen so unscharf wie Pflanzen, Größen vertauscht, oder die Bump-Map zählt nicht + // zu ihrem Material (sie hängt über die Erweiterung dran, nicht direkt am Material). + it("verkleinert Texturen je nach Material auf 1024 oder 512 px und wandelt sie in WebP", () => { + const { document, container, grass } = sample; + const bumpTexture = container.getExtension(EXT_MATERIALS_BUMP).getBumpTexture(); + + expect(container.getBaseColorTexture().getSize()).toEqual([NEAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE]); + expect(bumpTexture.getSize()).toEqual([NEAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE]); + expect(grass.getBaseColorTexture().getSize()).toEqual([FAR_TEXTURE_SIZE, FAR_TEXTURE_SIZE]); + expect(grass.getNormalTexture().getSize()).toEqual([FAR_TEXTURE_SIZE, FAR_TEXTURE_SIZE]); + for (const texture of document.getRoot().listTextures()) expect(texture.getMimeType()).toBe("image/webp"); + }); + + // Bruch: die Vereinfachung trifft Container und Schuppen (Löcher in Wänden) oder lässt Pflanzen + // unberührt (die Köcherbäume blieben bei 2,2 Mio. Dreiecken). + it("vereinfacht nur Pflanzen und Kleinteile und behält die Materialnamen", () => { + const { document } = sample; + + expect(trianglesOf(document, "cont_a_side")).toBe(2); + expect(trianglesOf(document, "grass_medium_01")).toBeGreaterThan(0); + expect(trianglesOf(document, "grass_medium_01")).toBeLessThanOrEqual(3200 * 0.5); + }); + + // Bruch: das Wellblech geht verloren, die Geometrie bleibt unkomprimiert, oder es entstehen + // GPU-Instanzen, deren Matrizen die Kollision nicht liest. + it("erhält die Bump-Map, komprimiert mit meshopt und legt keine Instanzen an", () => { + const { document, container } = sample; + const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName); + + expect(container.getExtension(EXT_MATERIALS_BUMP).getBumpFactor()).toBe(2.2); + expect(used).toEqual(expect.arrayContaining([EXT_MATERIALS_BUMP, "EXT_meshopt_compression", "EXT_texture_webp"])); + expect(used).not.toContain("EXT_mesh_gpu_instancing"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/placesPipeline.test.js` +Expected: FAIL, `Failed to load url ../tools/assets/places.mjs` + +- [ ] **Step 3: Write minimal implementation** + +`tools/assets/places.mjs`: + +```js +// Bereitet ein Modell aus dem Editor three-d-stage für die Orte-Schicht auf (src/world/places.js, +// docs/superpowers/specs/2026-09-14-hood-yard-design.md): +// +// node tools/assets/places.mjs -> public/models/places/.glb +// +// Die Quelle bleibt außerhalb des Repos (hood-yard.glb: 653 MB, 105 PNG in 2048 px, rund 2,9 Mio. +// gezeichnete Dreiecke). Anders als tools/assets/props.mjs nicht über die gltf-transform-CLI: die +// 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 { ExtensionProperty, NodeIO, PropertyType } from "@gltf-transform/core"; +import { ALL_EXTENSIONS, EXTTextureWebP } from "@gltf-transform/extensions"; +import { compressTexture, dedup, flatten, getBounds, join, listTextureSlots, meshopt, prune, + simplifyPrimitive, weld } from "@gltf-transform/functions"; +import { MeshoptEncoder, MeshoptSimplifier } from "meshoptimizer"; +import sharp from "sharp"; +import { EXTMaterialsBump } from "./bumpExtension.mjs"; + +export const NEAR_TEXTURE_SIZE = 1024; +export const FAR_TEXTURE_SIZE = 512; +const QUALITY = 80; +// Verlustbehaftetes WebP erzeugt in Normal-Maps sonst Blockmuster, die im Streiflicht auffallen. +const NORMAL_QUALITY = 92; + +/** Flächen, an denen der Spieler direkt vorbeiläuft: ihre Texturen in 1024 px, alle anderen in 512 px. */ +export const NEAR_MATERIALS = new Set([ + "gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", + "cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame", "contHardware", + "ironPlain", "ironTeal", "ironBlue", "osb", "osbB", "rollershutter_window_01", "rollershutter_window_01_graffiti", + "chainlink_post", "chainlink_wire", +]); + +/** + * Anteil der Dreiecke, der je Material bleibt; Container, Schuppen, Zaun und Boden 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. + */ +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], +]); +// Anteil am Mesh-Radius. Nach join umspannt ein Pflanzenmaterial den ganzen Hof (~17 m Radius), die +// Grenze liegt dann bei ~17 cm und greift kaum — die Anteile oben steuern (am Modell gemessen: 0,002 +// und 0,01 ergeben dieselben Dreieckszahlen). +const SIMPLIFY_ERROR = 0.01; + +/** Materialien, die eine Textur benutzen: direkt oder über eine Erweiterung wie EXT_materials_bump. */ +function materialsUsing(texture) { + const materials = new Set(); + for (const parent of texture.listParents()) { + if (parent.propertyType === PropertyType.MATERIAL) materials.add(parent); + if (!(parent instanceof ExtensionProperty)) continue; + for (const owner of parent.listParents()) { + if (owner.propertyType === PropertyType.MATERIAL) materials.add(owner); + } + } + return [...materials]; +} + +/** Verkleinert ein eingelesenes Dokument an Ort und Stelle, in der Reihenfolge der Spec. */ +export async function optimizePlace(document) { + await Promise.all([MeshoptEncoder.ready, MeshoptSimplifier.ready]); + // Kein instance(): die Kollision des Controllers liest nur mesh.geometry mit matrixWorld. + // Kein palette(): es fasst texturlose Materialien unter neuem Namen zusammen, die Kollision + // wählt aber nach Materialnamen (src/world/places.js, SOLID_MATERIALS). + await document.transform(dedup(), flatten(), join(), weld()); + for (const mesh of document.getRoot().listMeshes()) { + for (const primitive of mesh.listPrimitives()) { + const ratio = SIMPLIFY_RATIOS.get(primitive.getMaterial()?.getName()); + if (ratio === undefined) continue; + simplifyPrimitive(primitive, { simplifier: MeshoptSimplifier, ratio, error: SIMPLIFY_ERROR }); + } + } + await document.transform(prune()); + + // createExtension meldet das Bildformat nicht an, das tut sonst erst NodeIO.registerExtensions. + // Ohne register() liefert texture.getSize() für WebP null (Bericht, Tests). + EXTTextureWebP.register(); + document.createExtension(EXTTextureWebP).setRequired(true); + for (const texture of document.getRoot().listTextures()) { + const near = materialsUsing(texture).some((material) => NEAR_MATERIALS.has(material.getName())); + const size = near ? NEAR_TEXTURE_SIZE : FAR_TEXTURE_SIZE; + const quality = listTextureSlots(texture).includes("normalTexture") ? NORMAL_QUALITY : QUALITY; + await compressTexture(texture, { encoder: sharp, targetFormat: "webp", resize: [size, size], quality }); + } + + await document.transform(meshopt({ encoder: MeshoptEncoder, level: "medium" })); + return document; +} + +/** Kennzahlen für den Bericht. Dreiecke und Primitive je Knoten gezählt, also wie gezeichnet. */ +export function describePlace(document) { + const root = document.getRoot(); + let triangles = 0; + let primitives = 0; + for (const node of root.listNodes()) { + for (const primitive of node.getMesh()?.listPrimitives() ?? []) { + primitives++; + triangles += (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION").getCount()) / 3; + } + } + let gpuBytes = 0; + const sizes = {}; + for (const texture of root.listTextures()) { + const [width, height] = texture.getSize() ?? [0, 0]; + gpuBytes += (width * height * 4 * 4) / 3; // RGBA mit Mipmaps + const key = `${width}x${height} ${texture.getMimeType()}`; + sizes[key] = (sizes[key] ?? 0) + 1; + } + const { min, max } = getBounds(root.listScenes()[0]); + const round = (values) => values.map((value) => Number(value.toFixed(2))); + return { + materials: root.listMaterials().length, primitives, triangles, textures: sizes, + gpuMB: Math.round(gpuBytes / 1e6), bounds: { min: round(min), max: round(max) }, + }; +} + +async function main() { + const [source, id] = process.argv.slice(2); + if (!source || !id) { + console.error("Aufruf: node tools/assets/places.mjs "); + process.exit(1); + } + const target = `public/models/places/${id}.glb`; + const io = new NodeIO() + .registerExtensions([...ALL_EXTENSIONS, EXTMaterialsBump]) + .registerDependencies({ "meshopt.encoder": MeshoptEncoder }); + const megabytes = async (path) => `${((await stat(path)).size / 1e6).toFixed(1)} MB`; + + const document = await io.read(source); + console.log("vorher", await megabytes(source), describePlace(document)); + await optimizePlace(document); + await mkdir(dirname(target), { recursive: true }); + await io.write(target, document); + console.log("nachher", await megabytes(target), describePlace(document)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main(); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/placesPipeline.test.js` +Expected: PASS, 3 Tests. `prune: Removed types...`-Zeilen im stdout sind normal. + +- [ ] **Step 5: Commit** + +```bash +git add tools/assets/places.mjs tests/placesPipeline.test.js +git commit -m "$(cat <<'EOF' +feat(assets): Aufbereitung für Orte mit Texturgrößen je Material und Vereinfachung + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 3: Orte-Liste und reine Helfer + +**Files:** +- Create: `src/world/places.js` +- Test: `tests/places.test.js` + +**Interfaces:** +- Consumes: `worldToThree(X, Y) -> { x, z }` aus `src/world/coords.js`. +- Produces: `PLACE_PRELOAD_RADIUS = 600`, `PLACE_SHOW_RADIUS = 250`, `PLACE_HIDE_RADIUS = 450`; `PLACES: Array<{ id, url, at: [X, Y], rotation, lift, bounds: { minX, maxX, minZ, maxZ } }>`; `SOLID_MATERIALS: string[]`; `isSolidMaterial(name): boolean`; `placeTransform(place) -> { position: { x, y, z }, rotationY }`; `placeDistance(position: { x, z }, place): number`; `wantedPlaces(position, places, { show, hide, active: Set }) -> { add: string[], remove: string[] }`; `prepareModel(root: Object3D, place): boolean`. + +- [ ] **Step 1: Write the failing test** + +`tests/places.test.js`: + +```js +import { describe, expect, it } from "vitest"; +import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from "three"; +import { PLACES, SOLID_MATERIALS, isSolidMaterial, placeTransform, prepareModel, + wantedPlaces } from "../src/world/places.js"; + +describe("placeTransform", () => { + // Bruch: Welt-y nicht negiert (der Hof landete 2 km weiter südlich im Meer) oder lift nicht in y. + it("rechnet Welt-x/-y in three um und hebt um lift", () => { + const transform = placeTransform({ at: [1489, 1067.5], rotation: 0, lift: -0.28 }); + + expect(transform.position).toEqual({ x: 1489, y: -0.28, z: -1067.5 }); + expect(transform.rotationY).toBe(0); + }); + + // Bruch: Grad nicht ins Bogenmaß umgerechnet oder falsche Drehrichtung. "Gegen den Uhrzeigersinn, + // Norden oben" heißt in three: +x (Osten) wird zu −z (Norden). + it("dreht mit positivem Winkel Osten nach Norden", () => { + const root = new Group(); + root.rotation.y = placeTransform({ at: [0, 0], rotation: 90, lift: 0 }).rotationY; + root.updateMatrixWorld(); + + const east = new Vector3(1, 0, 0).applyMatrix4(root.matrixWorld); + expect(east.x).toBeCloseTo(0); + expect(east.z).toBeCloseTo(-1); + }); +}); + +describe("wantedPlaces", () => { + const place = { id: "hof", at: [1000, 1000], rotation: 0, lift: 0 }; + const south = (distance) => ({ x: 1000, z: -1000 + distance }); + const radii = { show: 250, hide: 450 }; + + it("setzt innerhalb des show-Radius ein", () => { + expect(wantedPlaces(south(240), [place], { ...radii, active: new Set() }).add).toEqual(["hof"]); + expect(wantedPlaces(south(260), [place], { ...radii, active: new Set() }).add).toEqual([]); + }); + + // Bruch: ein Radius für Ein- und Ausblenden — an der Grenze flackerte der Hof mit jedem Schritt. + it("nimmt erst jenseits des hide-Radius heraus", () => { + const active = new Set(["hof"]); + + expect(wantedPlaces(south(400), [place], { ...radii, active }).remove).toEqual([]); + expect(wantedPlaces(south(460), [place], { ...radii, active }).remove).toEqual(["hof"]); + }); + + it("meldet einen aktiven Ort nicht doppelt an", () => { + expect(wantedPlaces(south(10), [place], { ...radii, active: new Set(["hof"]) }).add).toEqual([]); + }); +}); + +describe("isSolidMaterial", () => { + // Bruch: Gras oder Blätter im Collider (man bliebe im Gras hängen), oder ein fester Teil fehlt + // (man liefe durch Container, Schuppen oder Zaun). + it("lässt feste Teile kollidieren, Gras, Blätter und Kleinteile nicht", () => { + for (const name of ["gravel", "cont_a_side", "ironTeal", "chainlink_wire", "barrel_03", "quiver_tree_01_trunk"]) { + expect(isSolidMaterial(name)).toBe(true); + } + for (const name of ["grass_medium_01", "quiver_tree_01_leaf", "searsia_lucida_leaves", "trashbag", undefined]) { + expect(isSolidMaterial(name)).toBe(false); + } + }); + + it("kennt jedes Material der Liste", () => { + expect(SOLID_MATERIALS.every(isSolidMaterial)).toBe(true); + }); +}); + +/** Ein Mesh je Materialname unter einer Wurzel, wie GLTFLoader es nach join() liefert. */ +function fakeModel(names = ["cont_a_side", "grass_medium_01"]) { + const root = new Group(); + for (const name of names) root.add(new Mesh(new BoxGeometry(), new MeshStandardMaterial({ name }))); + return root; +} + +describe("prepareModel", () => { + // Bruch: Lage nicht gesetzt, Schatten aus, oder feste Teile fälschlich aus der Kollision genommen. + it("setzt Lage und Schatten und nimmt nur nicht feste Teile aus der Kollision", () => { + const root = fakeModel(); + const [container, grass] = root.children; + + expect(prepareModel(root, { at: [1489, 1067.5], rotation: 0, lift: -0.28 })).toBe(true); + expect(root.position.toArray()).toEqual([1489, -0.28, -1067.5]); + expect(container.userData.excludeFromCollider).toBeUndefined(); + expect(grass.userData.excludeFromCollider).toBe(true); + for (const mesh of [container, grass]) { + expect(mesh.castShadow).toBe(true); + expect(mesh.receiveShadow).toBe(true); + } + }); + + it("meldet, wenn kein Teil fest ist", () => { + expect(prepareModel(fakeModel(["grass_medium_01"]), { at: [0, 0], rotation: 0, lift: 0 })).toBe(false); + }); +}); + +describe("PLACES", () => { + // Bruch: ein Eintrag ohne Pflichtfeld — placeTransform rechnete mit undefined, die Tests mit bounds + // prüften nichts. + it("hat für jeden Ort id, url, at, rotation, lift und bounds", () => { + for (const 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) }, + }); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/places.test.js` +Expected: FAIL, `Failed to load url ../src/world/places.js` + +- [ ] **Step 3: Write minimal implementation** + +`src/world/places.js`: + +```js +// 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 { 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). +// Heruntergeladen wird früher, damit das Einsetzen nicht auf die Datei wartet. +export const PLACE_PRELOAD_RADIUS = 600; +export const PLACE_SHOW_RADIUS = 250; +export const PLACE_HIDE_RADIUS = 450; + +/** + * 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. + */ +export const PLACES = [ + { + id: "hood-yard", + url: "./models/places/hood-yard.glb", + // Übergang Container -> Schuppen an derselben Stelle wie im Satellitenbild des Nutzers. + at: [1489, 1067.5], + rotation: 0, + // Die 34 cm dicke Kiesplatte liegt mit ihrer Unterseite auf 0; so ragt sie noch 6 cm heraus. + lift: -0.28, + bounds: { minX: -17.15, maxX: 17, minZ: -13.4, maxZ: 8.6 }, + }, +]; + +/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Kleinteile — läuft man durch. */ +export const SOLID_MATERIALS = [ + // 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", +]; +const SOLID = new Set(SOLID_MATERIALS); + +export function isSolidMaterial(name) { + return SOLID.has(name); +} + +/** Lage eines Orts in three-Koordinaten. Positives rotationY dreht +x nach −z, also nach Norden. */ +export function placeTransform(place) { + const { x, z } = worldToThree(place.at[0], place.at[1]); + return { position: { x, y: place.lift, z }, rotationY: (place.rotation * Math.PI) / 180 }; +} + +/** Abstand in der Ebene zwischen einer three-Position und der Mitte eines Orts. */ +export function placeDistance(position, place) { + const { x, z } = worldToThree(place.at[0], place.at[1]); + return Math.hypot(x - position.x, z - position.z); +} + +/** Wie wantedChunks: add innerhalb von show, remove erst jenseits von hide. */ +export function wantedPlaces(position, places, { show, hide, active }) { + const add = []; + const remove = []; + for (const place of places) { + const distance = placeDistance(position, place); + if (distance <= show && !active.has(place.id)) add.push(place.id); + if (distance > hide && active.has(place.id)) remove.push(place.id); + } + return { add, remove }; +} + +/** + * 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. + */ +export function prepareModel(root, place) { + const { position, rotationY } = placeTransform(place); + root.position.set(position.x, position.y, position.z); + root.rotation.set(0, rotationY, 0); + let solid = false; + root.traverse((object) => { + if (!object.isMesh) return; + 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; + }); + root.updateMatrixWorld(true); + return solid; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/places.test.js` +Expected: PASS, 10 Tests + +- [ ] **Step 5: Commit** + +```bash +git add src/world/places.js tests/places.test.js +git commit -m "$(cat <<'EOF' +feat(world): Orte-Liste mit Lage, Radien und Kollisionsmaterialien + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 4: Hood Yard aufbereiten und die Datei prüfen + +**Files:** +- Create: `public/models/places/hood-yard.glb` (erzeugt, versioniert) +- Test: `tests/placesAsset.test.js` + +**Interfaces:** +- Consumes: `PLACES`, `SOLID_MATERIALS` (Task 3); `EXT_MATERIALS_BUMP`, `EXTMaterialsBump` (Task 1); CLI aus Task 2. + +- [ ] **Step 1: Write the failing test** + +`tests/placesAsset.test.js`: + +```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"; + +const place = PLACES.find((entry) => entry.id === "hood-yard"); +const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url)); +const CONTAINERS = ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"]; + +describe("hood-yard.glb", () => { + 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 hatte 653 MB. + it("ist kleiner als 30 MB", () => { + expect(statSync(file).size).toBeLessThan(30e6); + }); + + // Bruch: das Wellblech ist weg, die Geometrie unkomprimiert, oder GPU-Instanzen, deren Matrizen die + // Kollision des Controllers nicht liest. + it("nutzt meshopt und WebP, behält EXT_materials_bump und hat keine Instanzen", () => { + const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName); + + expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp", EXT_MATERIALS_BUMP])); + 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); + } + }); + + it("gibt allen sechs Container-Materialien ihre Bump-Textur", () => { + const materials = new Map(document.getRoot().listMaterials().map((material) => [material.getName(), material])); + for (const name of CONTAINERS) { + expect(materials.get(name)?.getExtension(EXT_MATERIALS_BUMP)?.getBumpTexture()).toBeTruthy(); + } + }); + + // Bruch: dedup oder ein neues Werkzeug benennt ein Material um — der Hof wäre dort ohne Warnung + // durchlässig, weil die Kollision nach Namen auswählt. + it("enthält jedes feste Material der Kollisionsliste", () => { + const names = document.getRoot().listMaterials().map((material) => material.getName()); + expect(SOLID_MATERIALS.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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/placesAsset.test.js` +Expected: FAIL im `beforeAll` mit `ENOENT` (die Datei gibt es noch nicht) + +- [ ] **Step 3: Datei erzeugen** + +```bash +node tools/assets/places.mjs ~/Downloads/hood-yard.glb hood-yard 2>&1 | grep -v "^prune\|^quantize" +``` + +Erwartet, etwa 10 s Laufzeit (so am Prototyp gemessen): + +``` +vorher 653.6 MB { materials: 44, primitives: 542, triangles: 2899070, textures: { '2048x2048 image/png': 105 }, gpuMB: 2349, bounds: { min: [ -17.15, -0, -13.4 ], max: [ 17, 8.94, 8.6 ] } } +nachher 16.2 MB { materials: 43, primitives: 43, triangles: 657653, textures: { '1024x1024 image/webp': 32, '512x512 image/webp': 37 }, gpuMB: 231, bounds: { min: [ -17.15, -0, -13.4 ], max: [ 17, 8.94, 8.6 ] } } +``` + +Weichen `bounds` um mehr als 5 cm von `PLACES` ab, gilt `PLACES.bounds` als falsch. Dann den Grund klären, bevor die Werte in `PLACES` angepasst werden: Vereinfachung an den Hintergrundbäumen? `lot_gravel` bestimmt x ±17 und z −13,4/8,6. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/placesAsset.test.js` +Expected: PASS, 6 Tests + +- [ ] **Step 5: Commit** + +```bash +git add public/models/places/hood-yard.glb tests/placesAsset.test.js +git commit -m "$(cat <<'EOF' +feat(assets): Hood Yard aufbereitet (653,6 MB -> 16,2 MB) + +2,9 Mio. -> 658 000 Dreiecke, 542 -> 43 Primitive, 105 PNG in 2048 px -> +32 WebP in 1024 px und 37 in 512 px. EXT_materials_bump der Container erhalten. + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 5: Lage der Orte gegen die Karte prüfen + +**Files:** +- Test: `tests/placesSite.test.js` + +**Interfaces:** +- Consumes: `PLACES`, `PLACE_SHOW_RADIUS`, `PLACE_HIDE_RADIUS`, `placeTransform` (Task 3); `CHUNKS_PER_SIDE`, `CHUNK_SIZE`, `SLICE`, `chunkForWorld` aus `src/world/coords.js`; `lampPoints`, `vergeLampPoints`, `pointInPolygon` aus `src/world/props.js`. + +Ein Wächtertest über vorhandene Daten: Er muss mit der richtigen Lage sofort bestehen. Rot wird er über eine absichtlich falsche Lage gezeigt (Schritt 3). + +- [ ] **Step 1: Test schreiben** + +`tests/placesSite.test.js`: + +```js +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE, chunkForWorld } from "../src/world/coords.js"; +import { PLACES, PLACE_HIDE_RADIUS, PLACE_SHOW_RADIUS, placeTransform } from "../src/world/places.js"; +import { lampPoints, pointInPolygon, vergeLampPoints } from "../src/world/props.js"; + +// Radien aus ChunkStreamer.update (src/world/streaming.js), gemessen zum Chunk-Mittelpunkt. +const CHUNK_LOAD_RADIUS = 400; +const CHUNK_UNLOAD_RADIUS = 600; +const STEP = 0.5; // Meter zwischen zwei Prüfpunkten auf der Ortsfläche + +const cache = new Map(); +function chunk(i, j) { + const key = `${i}_${j}`; + if (!cache.has(key)) { + cache.set(key, JSON.parse(readFileSync(new URL(`../public/data/vice-a/chunk_${key}.json`, import.meta.url), "utf8"))); + } + return cache.get(key); +} + +/** Modellpunkt (x, z) -> Welt (X, Y), mit derselben Drehung wie three (rotation.y). */ +function modelToWorld(place, x, z) { + const { position, rotationY } = placeTransform(place); + const cos = Math.cos(rotationY), sin = Math.sin(rotationY); + return { X: position.x + x * cos + z * sin, Y: -(position.z - x * sin + z * cos) }; +} + +/** Welt (X, Y) -> Modellpunkt (x, z), die Umkehrung von modelToWorld. */ +function worldToModel(place, X, Y) { + const { position, rotationY } = placeTransform(place); + const cos = Math.cos(rotationY), sin = Math.sin(rotationY); + const dx = X - position.x, dz = -Y - position.z; + return { x: dx * cos - dz * sin, z: dx * sin + dz * cos }; +} + +function insideBounds(place, X, Y) { + const { x, z } = worldToModel(place, X, Y); + const { minX, maxX, minZ, maxZ } = place.bounds; + return x >= minX && x <= maxX && z >= minZ && z <= maxZ; +} + +describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte", (_id, place) => { + const points = []; + for (let x = place.bounds.minX; x <= place.bounds.maxX + 1e-9; x += STEP) { + for (let z = place.bounds.minZ; z <= place.bounds.maxZ + 1e-9; z += STEP) points.push(modelToWorld(place, x, z)); + } + const keys = [...new Set(points.map(({ X, Y }) => { + const { i, j } = chunkForWorld(X, Y); + return `${i}_${j}`; + }))]; + const under = keys.map((key) => key.split("_").map(Number)); + + // 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. + it("liegt ganz auf Land, auf keiner Straße und in keinem Gebäude", () => { + const bad = points.filter(({ X, Y }) => { + const { i, j } = chunkForWorld(X, Y); + const c = chunk(i, j); + const u = X - c.origin[0], v = -Y - c.origin[1]; + const onLand = c.land.some((rings) => pointInPolygon(u, v, rings)); + const onRoad = c.roads.some((road) => pointInPolygon(u, v, road.poly)); + const inBuilding = c.buildings.some((building) => pointInPolygon(u, v, building.poly)); + return !onLand || onRoad || inBuilding; + }); + expect(bad.slice(0, 5)).toEqual([]); + }); + + // Bruch: eine Palme oder Laterne mitten im Ort. Beide entstehen je Chunk und wissen nichts von Orten; + // Laternen stehen bis 16 m neben ihrer Mittellinie, deshalb zählen auch die Nachbarchunks. + it("enthält keine Palme und keine Laterne", () => { + const nearby = new Set(); + for (const [i, j] of under) { + for (let di = -1; di <= 1; di++) { + for (let dj = -1; dj <= 1; dj++) { + if (i + di >= 0 && i + di < CHUNKS_PER_SIDE && j + dj >= 0 && j + dj < CHUNKS_PER_SIDE) nearby.add(`${i + di}_${j + dj}`); + } + } + } + const props = [...nearby].flatMap((key) => { + const c = chunk(...key.split("_").map(Number)); + const toWorld = (u, v) => ({ X: c.origin[0] + u, Y: -(c.origin[1] + v) }); + const palms = (c.palms ?? []).map(([u, v]) => toWorld(u, v)); + const lamps = vergeLampPoints(lampPoints(c.centerlines ?? []), c.roads ?? []).map((p) => toWorld(p.x, p.z)); + return [...palms, ...lamps]; + }); + 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", () => { + const { position } = placeTransform(place); + const farthest = Math.max(...under.map(([i, j]) => Math.hypot( + SLICE.x0 + (i + 0.5) * CHUNK_SIZE - position.x, + -(SLICE.y1 - (j + 0.5) * CHUNK_SIZE) - position.z, + ))); + expect(farthest + PLACE_SHOW_RADIUS).toBeLessThanOrEqual(CHUNK_LOAD_RADIUS); + expect(farthest + PLACE_HIDE_RADIUS).toBeLessThanOrEqual(CHUNK_UNLOAD_RADIUS); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `npx vitest run tests/placesSite.test.js` +Expected: PASS, 3 Tests + +- [ ] **Step 3: Rot zeigen und zurücksetzen** + +In `src/world/places.js` vorübergehend `at: [1489, 1067.5]` durch `at: [1489, 1072.5]` ersetzen (5 m nach Norden) und `npx vitest run tests/placesSite.test.js` starten. Erwartet: FAIL in „liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“ (Rand auf der Straße) und in „enthält keine Palme und keine Laterne“ (Laterne bei 1478,1 | 1081,6). + +Danach `at` auf `[1489, 1067.5]` zurücksetzen und `PLACE_SHOW_RADIUS = 250` vorübergehend auf `400` stellen. Erwartet: FAIL nur in „ist nur in der Szene, solange die Chunks unter ihm geladen sind“. + +Zurücksetzen, dann `git diff src/world/places.js`. Erwartet: keine Ausgabe. Der Test ist wieder grün. + +- [ ] **Step 4: Commit** + +```bash +git add tests/placesSite.test.js +git commit -m "$(cat <<'EOF' +test(world): Lage der Orte gegen Straßen, Gebäude, Palmen, Laternen und Chunk-Radien + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 6: `PlaceLayer` lädt, setzt ein und nimmt heraus + +**Files:** +- Modify: `src/world/places.js` (Importe oben, `loadModel` und `PlaceLayer` am Ende) +- Test: `tests/places.test.js` (Importe erweitern, `describe("PlaceLayer")` anhängen) + +**Interfaces:** +- Consumes: alles aus Task 3. +- Produces: `export class PlaceLayer` mit `constructor({ scene: Scene, player: { addCollider(desc), removeCollider(handle) }, places = PLACES, load = (url) => Promise })` und `update(position: { x, y, z }): void`. Öffentliche Felder für Tests: `models: Map`, `wanted: Set`, `active: Map`. + +- [ ] **Step 1: Write the failing test** + +In `tests/places.test.js` die ersten beiden Importzeilen ersetzen: + +```js +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Scene, Vector3 } from "three"; +import { PLACES, PlaceLayer, SOLID_MATERIALS, isSolidMaterial, placeTransform, prepareModel, + wantedPlaces } from "../src/world/places.js"; +``` + +Und am Ende der Datei anhängen: + +```js +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) { + const scene = new Scene(); + const player = { addCollider: vi.fn(() => ({ id: 7 })), removeCollider: vi.fn() }; + const layer = new PlaceLayer({ scene, player, places: [place], load }); + 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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/places.test.js` +Expected: FAIL in den sechs `PlaceLayer`-Tests mit `PlaceLayer is not a constructor`, die zehn aus Task 3 bestehen weiter + +- [ ] **Step 3: Write minimal implementation** + +In `src/world/places.js` die Importzeile `import { worldToThree } from "./coords.js";` ersetzen durch: + +```js +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"; +``` + +Und am Ende der Datei anhängen: + +```js +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 }) { + Object.assign(this, { scene, player, places, load }); + 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((root) => { + const solid = prepareModel(root, place); + if (!solid) console.warn(`Ort ${id} hat keine festen Teile und bekommt keine Kollision.`); + 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) { + const model = this.models.get(id); + if (!model || this.active.has(id)) return; + this.scene.add(model.root); + model.root.updateMatrixWorld(true); + // Ohne feste Teile keinen Collider: eine leere Quelle liefert im Controller nur ein Handle, + // das nie fertig wird (vgl. Ruling 51 in streaming.js). + const handle = model.solid + ? this.player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: model.root } }) + : null; + 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); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/places.test.js` +Expected: PASS, 16 Tests. Danach `npm test`, erwartet 17 Testdateien und 162 Tests, alle grün. + +- [ ] **Step 5: Commit** + +```bash +git add src/world/places.js tests/places.test.js +git commit -m "$(cat <<'EOF' +feat(world): PlaceLayer lädt vor, setzt ein und nimmt heraus + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +--- + +### Task 7: Einbau ins Spiel und Prüfung im Browser + +**Files:** +- Modify: `src/main.js` (Import, Anlegen nach dem `ChunkStreamer`, Aufruf in `tick()`) +- Vorübergehend, nie committen: ein Eintrag in der `.claude/launch.json`, die das Vorschau-Werkzeug liest, und bei verborgenem Browser-Bereich eine Kopie `index-debug.html` im Arbeitsbaum + +**Interfaces:** +- Consumes: `PlaceLayer` (Task 6), `playerPosition(player)` aus `src/player.js`. + +- [ ] **Step 1: Einbauen** + +In `src/main.js` unter `import { PropLayer } from "./world/props.js";`: + +```js +import { PlaceLayer } from "./world/places.js"; +``` + +Direkt nach der Zeile `const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });`: + +```js +// 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 geladen sind. +const places = new PlaceLayer({ scene, player }); +``` + +In `tick()` direkt nach `streamer.update(playerPosition(player));`: + +```js + places.update(playerPosition(player)); +``` + +- [ ] **Step 2: Alle Tests** + +Run: `npm test` +Expected: 17 Testdateien, 162 Tests, alle grün. + +- [ ] **Step 3: Dev-Server des Arbeitsbaums starten** + +In der `.claude/launch.json`, die das Vorschau-Werkzeug liest, vorübergehend eintragen. Auf 5173 läuft oft schon der Server des Hauptordners. + +```json +{ + "name": "hood-yard", + "runtimeExecutable": "bash", + "runtimeArgs": ["-lc", "cd .claude/worktrees/hood-yard && npx vite --port 5181 --strictPort"], + "port": 5181, + "url": "http://localhost:5181" +} +``` + +Dann `preview_start` mit `{ name: "hood-yard" }`. Die Kniffe für einen verborgenen Browser-Bereich stehen in `/Users/Kenso/.claude/projects/-Users-Kenso-Developer-gta6/memory/browser-verification-hidden-pane.md`: Worker-Takt statt `requestAnimationFrame`, `Scene.prototype.onBeforeRender`, vorgetäuschter Pointer-Lock, zwei Screenshots nacheinander. + +- [ ] **Step 4: Laden prüfen** + +- `read_network_requests` mit `urlPattern: "hood-yard.glb"`: Status 200, rund 16 MB. +- `read_console_messages` mit `onlyErrors: true`: höchstens die bekannte Pointer-Lock-Meldung des Browser-Bereichs. Kein `Ort hood-yard konnte nicht geladen werden`, keine GLTFLoader-Fehler. + +- [ ] **Step 5: Zum Hof springen** + +`M` drücken (`computer` mit `action: "key"`, `text: "m"`). Mit `javascript_tool` die Klickstelle für Welt (1489 | 1040) berechnen, das ist das Baseballfeld 27,5 m südlich der Hofmitte. Die Karte zeigt nach dem Öffnen das ganze Kartenbild (`fitView` in `src/ui/map.js`): + +```js +const dpr = Math.min(devicePixelRatio, 2); +const w = Math.round(innerWidth * dpr), h = Math.round(innerHeight * dpr); +const scale = Math.min(w / 3000, h / 3000); +const offsetX = (w - 3000 * scale) / 2, offsetY = (h - 3000 * scale) / 2; +const px = 1489 + 1000, py = 2500 - 1040; +[(px * scale + offsetX) / dpr, (py * scale + offsetY) / dpr] +``` + +Dorthin klicken. Danach Szene, Kamera und Renderer greifbar machen: + +```js +const url = performance.getEntriesByType("resource").map((entry) => entry.name).find((name) => name.includes("/node_modules/.vite/deps/three.js")); +const THREE = await import(url); +window.__probe = {}; +THREE.Scene.prototype.onBeforeRender = function (renderer, scene, camera) { Object.assign(window.__probe, { renderer, scene, camera }); }; +``` + +Warten, bis `window.__probe.scene.getObjectByName("capsule").position` bei x ≈ 1489, z ≈ −1040 steht. Warten, bis `window.__probe.scene.children` eine Gruppe mit Position (1489, −0.28, −1067.5) enthält. + +- [ ] **Step 6: Ansehen und Bilder festhalten** + +Kamera übersteuern, ohne den Spieler zu bewegen. Die Schattenstufen müssen denselben Blick bekommen, sonst fehlen die Schatten im Bild. Einmal ausführen: + +```js +const deps = performance.getEntriesByType("resource").map((entry) => entry.name); +const THREE = await import(deps.find((name) => name.includes("/node_modules/.vite/deps/three.js"))); +const { CSM } = await import(deps.find((name) => name.includes("three_examples_jsm_csm_CSM"))); +const eye = new THREE.Vector3(), target = new THREE.Vector3(), up = new THREE.Vector3(), matrix = new THREE.Matrix4(); +window.__view = null; // { position: [x, y, z], target: [x, y, z], up?: [x, y, z] } in three-Koordinaten +function applyView(camera) { + if (!window.__view) return; + eye.fromArray(window.__view.position); + target.fromArray(window.__view.target); + up.fromArray(window.__view.up ?? [0, 1, 0]); + matrix.lookAt(eye, target, up).setPosition(eye); + camera.matrixWorld.copy(matrix); + camera.matrixWorldInverse.copy(matrix).invert(); +} +const csmUpdate = CSM.prototype.update; +CSM.prototype.update = function (...args) { applyView(this.camera); return csmUpdate.apply(this, args); }; +THREE.Scene.prototype.onBeforeRender = function (renderer, scene, camera) { + Object.assign(window.__probe, { renderer, scene, camera }); + applyView(camera); +}; +``` + +Drei Blicke: + +| Blick | `position` | `target` | `up` | +|---|---|---|---| +| Vom Baseballfeld | [1489, 1.7, -1040] | [1489, 2, -1067.5] | – | +| Von der Straße | [1489, 1.7, -1085] | [1489, 1.5, -1067.5] | – | +| Draufsicht | [1489, 60, -1067.5] | [1489, 0, -1067.5] | [0, 0, -1] | + +Je Blick `window.__view` setzen, ein Bild lang warten (verborgener Bereich: Worker-Takt), dann im selben Aufruf: + +```js +const { renderer, scene, camera } = window.__probe; +renderer.render(scene, camera); +renderer.domElement.toDataURL("image/jpeg", 0.9); +``` + +Die Datenadresse mit Python aus der gespeicherten Werkzeugausgabe dekodieren und als `hof-baseballfeld.jpg`, `hof-strasse.jpg` und `hof-draufsicht.jpg` in den Scratchpad legen. Am Ende `window.__view = null`. + +Prüfen: +- Container haben sichtbares Wellblech. +- Kiesfläche ohne Kante und ohne Flackern. +- Schatten von Containern und Bäumen sind da. +- Die Köcherbäume sind nicht löchrig. +- Die Draufsicht passt zum Vergleichsbild `hof-lage.png` aus dem Gespräch: Container im Westen, Schuppen östlich daneben, J6/K6-Grenze 0,65 m östlich des letzten Schuppens. + +- [ ] **Step 7: Kollision prüfen** + +Der Controller liest Tasten über `keydown`/`keyup` auf `window` (`e.code`). Mausbewegungen liest er nur, wenn `document.pointerLockElement` sein Canvas ist (`InputSystem.ts`, `onMouseMove`): + +Jeder `javascript_tool`-Aufruf hat eigene Variablen, deshalb holt der Ausschnitt `THREE` neu: + +```js +const deps = performance.getEntriesByType("resource").map((entry) => entry.name); +const THREE = await import(deps.find((name) => name.includes("/node_modules/.vite/deps/three.js"))); +const { renderer, scene, camera } = window.__probe; +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, 90° = Osten +const turn = (degrees) => window.dispatchEvent(new MouseEvent("mousemove", { movementX: degrees / 8.6 * 300, movementY: 0 })); +const walk = (ms) => new Promise((resolve) => { + window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyW" })); + setTimeout(() => { window.dispatchEvent(new KeyboardEvent("keyup", { code: "KeyW" })); resolve(capsule.position.toArray()); }, ms); +}); +Object.assign(window, { capsule, heading, turn, walk }); // für die folgenden Aufrufe +``` + +- **Container von Westen:** `capsule.position.set(1473, 1, -1067.5)` steht auf dem Kies westlich von Container A, auf dem Weg liegt kein Bordstein. Die Westwand von Container A liegt bei x 1478,83. Mit `turn(90 - heading())` nach Osten drehen, `heading()` prüfen (±5°, sonst noch einmal). `await walk(3000)`: Die Kapsel bleibt vor x ≈ 1478,8 stehen und kommt nicht bis 1480. +- **Zaun von Norden, durch Gras und Büsche:** `capsule.position.set(1485, 1, -1079)`, mit `turn(180 - heading())` nach Süden drehen, `await walk(3000)`. Auf dem Weg liegen `grass_back` und Hintergrundbüsche, die durchlässig sind. Das Zaunbrett liegt bei z −1071,4. Erwartet: Die Kapsel kommt bis kurz vor z −1071,4, bleibt dort stehen und bleibt vorher nirgends hängen. +- **Kollisionsmarken am echten Modell:** + +```js +const hof = window.__probe.scene.children.find((object) => object.position.x === 1489 && object.position.z === -1067.5); +const solid = new Set(); +hof.traverse((object) => { if (object.isMesh && !object.userData.excludeFromCollider) solid.add(object.material.name); }); +[...solid].sort() +``` + +Erwartet: nur Namen aus `SOLID_MATERIALS`, keiner mit `grass_medium`, `leaf`, `searsia` oder `trashbag`. + +Die Drehung in `turn` ist nur ungefähr, deshalb nach jedem Drehen `heading()` prüfen. Ohne vorgetäuschten Pointer-Lock geht es nicht, der Browser-Bereich verweigert ihn. + +- [ ] **Step 8: Kennzahlen festhalten** + +In der Baseballfeld-Ansicht `renderer.info.render.calls`, `renderer.info.render.triangles` und `renderer.info.memory` notieren, einmal mit und einmal ohne Hof. Für „ohne Hof“ vorübergehend `scene.remove` der Hof-Gruppe und danach wieder `scene.add`. Ladezeit aus `performance.getEntriesByName(...)` für `hood-yard.glb` notieren. Ist der Bereich sichtbar, die Bildrate über 2 s mit `requestAnimationFrame`-Zeitstempeln messen, sonst als nicht messbar vermerken. + +- [ ] **Step 9: Nachstellen, falls nötig** + +Wirken Köcherbäume oder Gras zu grob, `SIMPLIFY_RATIOS` in `tools/assets/places.mjs` anheben (zum Beispiel 0,15 → 0,25). Danach: +1. Task 4, Step 3 wiederholen. +2. `npx vitest run tests/placesAsset.test.js tests/placesPipeline.test.js` ausführen. +3. Die Blicke aus Step 6 neu aufnehmen. + +Jede Änderung zusammen mit der neuen `.glb` committen: + +```bash +git add tools/assets/places.mjs public/models/places/hood-yard.glb +git commit -m "$(cat <<'EOF' +fix(assets): Vereinfachung der Köcherbäume nach Augenschein + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +Wirkt der Hof unter dem Spiellicht deutlich heller oder dunkler als im Editor, nichts ändern. Stattdessen die Bilder dem Nutzer zeigen und fragen (Spec, Fehlerfälle). + +- [ ] **Step 10: Aufräumen und committen** + +Den Eintrag `hood-yard` aus der `.claude/launch.json` entfernen und `index-debug.html` löschen, falls angelegt. Erwartet: `git status --short` zeigt nur `M src/main.js`. + +```bash +git add src/main.js +git commit -m "$(cat <<'EOF' +feat(world): Hood Yard im Spiel + +Co-Authored-By: Claude Opus 5 +EOF +)" +``` + +- [ ] **Step 11: Dem Nutzer zeigen** + +Die drei Bilder mit `SendUserFile` schicken, zusammen mit den Kennzahlen aus Step 8 und dem Ergebnis der Kollisionsprüfung. Um Abnahme bitten. Danach geht es mit superpowers:finishing-a-development-branch weiter, der Branch soll zurück nach `leonida/vice-slice`. diff --git a/docs/superpowers/specs/2026-09-14-hood-yard-design.md b/docs/superpowers/specs/2026-09-14-hood-yard-design.md index 95d3656..4834851 100644 --- a/docs/superpowers/specs/2026-09-14-hood-yard-design.md +++ b/docs/superpowers/specs/2026-09-14-hood-yard-design.md @@ -16,8 +16,8 @@ Der Hof steht im Spiel an der richtigen Stelle, in Originalgröße, bündig auf |---|---| | 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 | +| Dreiecke | 750 826 in den Meshes. Weil Knoten Meshes mehrfach benutzen, zeichnet die Szene rund 2,9 Mio. Die vier Köcherbäume allein haben 2,2 Mio.: `quiver_tree_01_leaf` 1,13 Mio., `quiver_tree_02` 739 000, `quiver_tree_01_trunk` 370 000. | +| Maße | x −17,15 bis 17,00 · z −13,40 bis 8,60 · Höhe bis 8,94 m (Masten), also 34,15 × 22,0 m (`getBounds`, Eckpunkte) | | 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 | @@ -31,6 +31,7 @@ Der Hof steht im Spiel an der richtigen Stelle, in Originalgröße, bündig auf - 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. +- Der Hof liegt auf den Chunks 9_5 und 10_5. Ihre Mittelpunkte sind 128 m und 148 m von der Hofmitte entfernt. Chunks laden erst, wenn ihr Mittelpunkt 400 m nah ist. Vom Startpunkt aus sind beide weiter weg, der Boden unter dem Hof fehlt dort also noch. ### Satellitenbild @@ -50,11 +51,17 @@ Der Hof steht im Spiel an der richtigen Stelle, in Originalgröße, bündig auf | Abschnitte Aufbereitung, Laufzeit, Prüfung | bestätigt | | Arbeitsweise | Eigener Arbeitsbaum, weil im Hauptordner nicht committete Änderungen einer anderen Sitzung liegen | +### Nachtrag beim Planen (Prototyp am echten Modell) + +- **Radien:** Mit Laden bei 400 m und Entfernen bei 600 m wäre der Hof vom Startpunkt aus zu sehen, bevor der Boden unter ihm geladen ist, und stünde über dem Meer. Er erscheint deshalb erst ab 250 m und verschwindet ab 450 m. Heruntergeladen wird er schon ab 600 m, damit das Einsetzen nicht auf die Datei wartet. Folge: Er lädt beim Start im Hintergrund, sichtbar wird er, wenn der Spieler rund 150 m nach Norden gelaufen ist. +- **Vereinfachung:** Es gibt einen Anteil je Material statt eines gemeinsamen Werts, weil die vier Köcherbäume 2,2 Mio. Dreiecke haben. +- **Messung:** Das Ergebnis ist 16,2 MB groß, siehe Abschnitt Aufbereitung. + ## 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) | +| Nachbar | Abstand zur Hofkante (Außenmaße 34,15 × 22 m: x 1471,85–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) | @@ -70,9 +77,9 @@ Der Hof steht im Spiel an der richtigen Stelle, in Originalgröße, bündig auf |---|---| | `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/world/places.js` | Neu. `PLACES` (Liste), `SOLID_MATERIALS`, reine Helfer `placeTransform(place)`, `placeDistance(position, place)`, `wantedPlaces(position, places, { show, hide, active })`, `isSolidMaterial(name)`, `prepareModel(root, place)` 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`. | +| `package.json` | Neue Entwicklungsabhängigkeiten, exakt gepinnt: `@gltf-transform/core`, `@gltf-transform/extensions`, `@gltf-transform/functions` 4.5.0, `sharp` 0.35.4, `meshoptimizer` 1.2.0. Das sind die Versionen, die `@gltf-transform/cli@4.5.0` selbst benutzt. | | `public/models/places/hood-yard.glb` | Neu, versioniert. Die Quelldatei bleibt außerhalb des Repos. | ## Aufbereitung (`tools/assets/places.mjs`) @@ -80,19 +87,29 @@ Der Hof steht im Spiel an der richtigen Stelle, in Originalgröße, bündig auf 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.** +2. **`dedup()`:** doppelte Texturen, Materialien, Meshes und Accessoren zusammenlegen (105 → 69 Texturen). Dabei verschmilzt `searsia_lucida_twigs` mit `searsia_lucida_leaves`, die Einstellungen sind gleich und beide sind durchlässig. +3. **`flatten()` und `join()`:** alle Mesh-Knoten direkt unter die Szene, danach Primitive je Material zu einem zusammenfügen. Das ergibt 43 Primitive, also 43 Draw Calls. Materialnamen bleiben erhalten. +4. **`weld()` und `simplifyPrimitive()` mit `MeshoptSimplifier`**, Anteil der Dreiecke, der bleibt, je Material (`error` 0,01): + + | Anteil | Materialien | + |---|---| + | 0,15 | `quiver_tree_01_leaf`, `quiver_tree_01_trunk`, `quiver_tree_02` | + | 0,5 | `searsia_lucida`, `searsia_lucida_leaves`, `searsia_lucida_twigs`, `grass_medium_01`, `grass_medium_01_dry`, `grass_medium_02` | + | 0,25 | `modular_electricity_poles_pieces`, `security_camera_01` | + + Die Fehlergrenze greift kaum: Nach `join` umspannt ein Pflanzenmaterial den ganzen Hof, der Anteil steuert. Stimmt das Bild im Spiel nicht, werden die Anteile nachgestellt. +5. **`prune()`**, danach **`compressTexture()` je Textur 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. + - Welche Materialien eine Textur benutzen, ergibt sich aus ihren Eltern: direkt ein Material oder eine Erweiterung wie `EXT_materials_bump` an einem Material. Hängt sie an beiden Gruppen, gilt die größere Kante. + - Qualität 80, Normal-Maps 92, damit dort 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. +**Bericht:** Megabyte, Texturen (Anzahl, Kantenlängen), gezeichnete Dreiecke und Primitive (je Knoten gezählt), geschätzter Grafikspeicher (RGBA mit Mipmaps), Außenmaße. Ziel: unter 30 MB. + +**Gemessen am Prototyp (2026-09-14):** 16,2 MB, 657 653 Dreiecke, 43 Primitive, 32 Texturen in 1024 px und 37 in 512 px WebP, geschätzt 231 MB Grafikspeicher, Außenmaße unverändert, 11 s Laufzeit. Die Bump-Map aller sechs Container-Materialien und ihre Wellenteilung (`KHR_texture_transform`) sind erhalten. ## Orte-Schicht (`src/world/places.js`) @@ -100,13 +117,17 @@ Bewusst nicht: `instance()` (die Kollision des Controllers liest nur `mesh.geome ```js export const PLACES = [ - { id: "hood-yard", url: "./models/places/hood-yard.glb", at: [1489, 1067.5], rotation: 0, lift: -0.28 }, + { + id: "hood-yard", url: "./models/places/hood-yard.glb", at: [1489, 1067.5], rotation: 0, lift: -0.28, + bounds: { minX: -17.15, maxX: 17, minZ: -13.4, maxZ: 8.6 }, + }, ]; ``` - `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. +- `bounds`: Außenmaße in Modellkoordinaten (x, z). Das Spiel braucht sie nicht, die Tests prüfen damit Datei (Asset-Test) und Lage (Datentest) gegeneinander. ### Umrechnung @@ -114,10 +135,20 @@ export const PLACES = [ ### 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). +- Gemessen wird vom Punkt `at` aus, mit drei Radien: + + | Konstante | Wert | Wirkung | + |---|---|---| + | `PLACE_PRELOAD_RADIUS` | 600 m | Datei laden und vorbereiten, noch nicht einsetzen | + | `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. +- `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 } })`. +- **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()`). ### Kollision @@ -148,11 +179,27 @@ Durchlässig bleiben Gras (`grass_medium_*`), Blätter und Zweige, die Büsche ( - **`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. + - `wantedPlaces`: setzt bei 240 m ein, bei 260 m nicht; nimmt bei 400 m nicht heraus, bei 460 m schon; 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. + - `prepareModel`: Lage, Schatten, Kollisionsmarke nur an nicht festen Meshes, Rückgabe `false` ohne feste Teile. + - `PlaceLayer` mit Attrappen für Szene, Spieler (`addCollider`, `removeCollider`) und Loader: + - lädt bei 500 m vor, setzt aber erst bei 240 m ein + - nimmt erst jenseits von 450 m heraus und meldet den Collider ab + - setzt nach der Rückkehr ohne neues Laden wieder ein + - lässt einen Ort draußen, wenn der Spieler beim Ende des Ladens schon fort ist + - ohne feste Teile kein Collider + - ein Ladefehler gibt eine Warnung, wirft nicht und wird nicht wiederholt +- **Datentest `tests/placesSite.test.js`** gegen die Chunks unter jedem Ort: + - Prüfpunkte alle 0,5 m auf dem Rechteck der Außenmaße an seiner Lage liegen alle auf `land` und auf keiner Straße und in keinem Gebäude. + - Keine Palme und kein Punkt aus `vergeLampPoints(lampPoints(...))` dieser Chunks und ihrer Nachbarn liegt im Rechteck. + - Fernster Chunk-Mittelpunkt + 250 ≤ 400 und + 450 ≤ 600. +- **Asset-Test `tests/placesAsset.test.js`** auf `public/models/places/hood-yard.glb`, eingelesen mit `NodeIO`, `MeshoptDecoder` und `bumpExtension.mjs`: + - kleiner als 30 MB + - Erweiterungen `EXT_meshopt_compression`, `EXT_texture_webp` und `EXT_materials_bump`, aber nicht `EXT_mesh_gpu_instancing` + - alle Texturen `image/webp` und höchstens 1024 px + - alle sechs Container-Materialien mit Bump-Textur + - jedes Material aus `SOLID_MATERIALS` existiert + - `getBounds` stimmt auf 5 cm mit `bounds` aus `PLACES` überein - **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.