diff --git a/docs/superpowers/specs/2026-09-14-tribuene-design.md b/docs/superpowers/specs/2026-09-14-tribuene-design.md index 48a6114..5a654cb 100644 --- a/docs/superpowers/specs/2026-09-14-tribuene-design.md +++ b/docs/superpowers/specs/2026-09-14-tribuene-design.md @@ -98,9 +98,12 @@ Die positive Drehung bringt +x nach Nordosten und die Front (+z) nach Südosten. ## Budget -- **Erwartet:** rund 70 000 Dreiecke, 14 Primitive (Draw Calls), unter 53 MB Grafikspeicher, Datei deutlich unter 30 MB. +- **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. -- Nach der Aufbereitung kommen die Werte aus dem Bericht von `places.mjs` hier hinein. ## Tests diff --git a/public/models/places/tribuene.glb b/public/models/places/tribuene.glb new file mode 100644 index 0000000..9358dd1 Binary files /dev/null and b/public/models/places/tribuene.glb differ diff --git a/src/world/places.js b/src/world/places.js index 93a913e..297466e 100644 --- a/src/world/places.js +++ b/src/world/places.js @@ -19,7 +19,9 @@ 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. + * 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). */ export const PLACES = [ { @@ -32,6 +34,20 @@ export const PLACES = [ lift: -0.28, bounds: { minX: -17.15, maxX: 17, minZ: -13.4, maxZ: 8.6 }, }, + { + 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]], + }, ]; /** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Sitzschalen, Kleinteile — läuft man durch. */ diff --git a/tests/places.test.js b/tests/places.test.js index 22d92da..2545ee4 100644 --- a/tests/places.test.js +++ b/tests/places.test.js @@ -181,14 +181,15 @@ describe("prepareModel", () => { 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) { + // 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)]); } }); }); diff --git a/tests/placesSite.test.js b/tests/placesSite.test.js index 3ab60ae..bc3aaa8 100644 --- a/tests/placesSite.test.js +++ b/tests/placesSite.test.js @@ -3,6 +3,7 @@ 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"; +import { withoutReplacedBuildings } from "../src/world/streaming.js"; // Radien aus ChunkStreamer.update (src/world/streaming.js), gemessen zum Chunk-Mittelpunkt. const CHUNK_LOAD_RADIUS = 400; @@ -18,6 +19,16 @@ function chunk(i, j) { return cache.get(key); } +// 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); +} + /** Modellpunkt (x, z) -> Welt (X, Y), mit derselben Drehung wie three (rotation.y). */ function modelToWorld(place, x, z) { const { position, rotationY } = placeTransform(place); @@ -51,7 +62,7 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte", 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. + // in PLACES oder nach einem neuen Lauf der Extraktion. Gebäude, die ein Ort ersetzt, zählen nicht. 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); @@ -59,12 +70,24 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte", 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)); + const inBuilding = standingBuildings(i, j).some((building) => pointInPolygon(u, v, building.poly)); return !onLand || onRoad || inBuilding; }); expect(bad.slice(0, 5)).toEqual([]); }); + // 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([]); + }); + // 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", () => {