import { describe, expect, it } from "vitest"; import { Box3, BufferGeometry } from "three"; import { buildChunkGeometry, CARRIAGEWAY_DEPTH, extrudePolygons, flatPolygons, KERB_HEIGHT, KERB_INSET, KERB_TOP, kerbGeometry, MARKING_COLORS, MARKING_OFFSET, markingGeometry } from "../src/world/geometry.js"; const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10]]; const PLOT = [SQUARE]; // Fläche ohne Loch: nur der Außenring const COURTYARD = [[[0, 0], [30, 0], [30, 30], [0, 30]], [[10, 10], [20, 10], [20, 20], [10, 20]]]; // Block mit Innenhof // Die echten Chunk-Dateien (public/data/vice-a/*.json) schließen jeden Ring selbst: der // letzte Punkt wiederholt den ersten. PLOT/COURTYARD oben tun das nicht. Beide Schreibweisen // beschreiben dasselbe Polygon und müssen dieselbe Geometrie ergeben. const PLOT_CLOSED = [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]]; const COURTYARD_CLOSED = [[[0, 0], [30, 0], [30, 30], [0, 30], [0, 0]], [[10, 10], [20, 10], [20, 20], [10, 20], [10, 10]]]; /** Fläche der (x,z)-Projektion aller Dreiecke einer Geometrie. Deckt Fehler auf, die die * reine Dreieckszahl nicht zeigt: ein Dreieck kann auf den falschen Punkt zeigen und die * Zahl der Dreiecke bleibt trotzdem gleich. */ function footprintArea(geo) { const pos = geo.getAttribute("position"); const index = geo.getIndex().array; let area = 0; for (let i = 0; i < index.length; i += 3) { const a = index[i], b = index[i + 1], c = index[i + 2]; const x0 = pos.getX(a), z0 = pos.getZ(a); const x1 = pos.getX(b), z1 = pos.getZ(b); const x2 = pos.getX(c), z2 = pos.getZ(c); area += Math.abs((x1 - x0) * (z2 - z0) - (x2 - x0) * (z1 - z0)) / 2; } return area; } describe("geometry", () => { it("extrudiert ein Quadrat zu Wänden und Dach", () => { const geo = extrudePolygons([PLOT], { base: 0, top: 20, uvScale: 4 }); // 4 Wände à 2 Dreiecke + Dach aus 2 Dreiecken expect(geo.getIndex().count / 3).toBe(10); const box = new Box3().setFromBufferAttribute(geo.getAttribute("position")); expect(box.max.y).toBeCloseTo(20); expect(box.min.y).toBeCloseTo(0); }); it("richtet Dachflächen nach oben aus", () => { const geo = extrudePolygons([PLOT], { base: 0, top: 5, uvScale: 4 }); geo.computeVertexNormals(); const n = geo.getAttribute("normal"); const up = [...Array(n.count).keys()].filter((i) => n.getY(i) > 0.9); expect(up.length).toBeGreaterThan(0); }); it("lässt den Innenhof eines Blocks offen", () => { // Außenring 30x30, Loch 10x10: 8 Wände à 2 Dreiecke, das Dach ist ein Ring aus // 8 Dreiecken. Ohne Lochbehandlung wäre der Hof zugedeckt und das Dach hätte 2. const geo = extrudePolygons([COURTYARD], { base: 0, top: 10, uvScale: 4 }); expect(geo.getIndex().count / 3).toBe(24); }); it("legt flache Flächen auf die gewünschte Höhe", () => { const geo = flatPolygons([PLOT], { y: 0.03, uvScale: 8 }); const box = new Box3().setFromBufferAttribute(geo.getAttribute("position")); expect(box.min.y).toBeCloseTo(0.03); expect(box.max.y).toBeCloseTo(0.03); }); // Flackernder Boden (Diagnose-Report, Punkt 4): Brückendeck und Boden lagen beide exakt bei // y=0 — identische Höhe, garantiertes Z-Fighting an jeder Stelle, an der eine Brücke an Land // endet, unabhängig von Kameraabstand oder Tiefenpuffer-Versatz. Die Brückenoberseite muss // jetzt oberhalb von y=0 liegen, ohne eine für den Spieler wahrnehmbare Stufe zu erzeugen. it("hebt die Brückenoberseite über den Boden (kein gemeinsames y=0 mehr)", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], bridges: [{ poly: PLOT }], palms: [], buildings: [], }; const out = buildChunkGeometry(chunk); const groundTop = new Box3().setFromBufferAttribute(out.ground.getAttribute("position")).max.y; const bridgeTop = new Box3().setFromBufferAttribute(out.bridges.getAttribute("position")).max.y; expect(groundTop).toBeCloseTo(0); expect(bridgeTop).toBeGreaterThan(groundTop); // Bewusst klein (Größenordnung der bestehenden SURFACE_OFFSET/ROAD_OFFSET-Abstände, // s. Kommentar in geometry.js): groß genug, um im Tiefenpuffer aufzulösen, klein genug, // um beim Gehen/Fahren nicht als Stufe aufzufallen — "flach und begehbar wie bisher". expect(bridgeTop - groundTop).toBeLessThan(0.1); }); it("baut alle Teile eines Chunks", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [{ poly: PLOT, kind: "sand" }, { poly: PLOT, kind: "park" }], carriageway: [{ poly: PLOT, klasse: "wohnstrasse" }], bridges: [{ poly: PLOT }], palms: [], buildings: [{ poly: PLOT, height: 12, source: "district", speculative: false }], }; const out = buildChunkGeometry(chunk); for (const key of ["ground", "sand", "park", "carriageway", "bridges", "buildings"]) { expect(out[key]).toBeInstanceOf(BufferGeometry); } expect(out.urban).toBeNull(); }); it("baut Parkwege flach auf Straßenhöhe und die neuen Flächenarten wie Beläge", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: [], palms: [], buildings: [], surfaces: ["field", "court", "pool", "parking"].map((kind) => ({ poly: PLOT, kind })), paths: [{ poly: COURTYARD }], }; const out = buildChunkGeometry(chunk); for (const kind of ["field", "court", "pool", "parking"]) { const box = new Box3().setFromBufferAttribute(out[kind].getAttribute("position")); expect(box.max.y, kind).toBeCloseTo(0.02); } const paths = new Box3().setFromBufferAttribute(out.paths.getAttribute("position")); expect(paths.min.y).toBeCloseTo(0.03); expect(footprintArea(out.paths)).toBeCloseTo(800, 6); // Weg um einen Hof: das Loch bleibt offen }); it("liefert ohne Parkwege null statt einer leeren Geometrie", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], buildings: [], palms: [] }; expect(buildChunkGeometry(chunk).paths).toBeNull(); }); it("gibt Gebäuden eine Farbe je Haus", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], palms: [], buildings: [{ poly: PLOT, height: 9, source: "district", speculative: false }] }; expect(buildChunkGeometry(chunk).buildings.getAttribute("color")).toBeTruthy(); }); it("hält alle Koordinaten im Chunk", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], buildings: [], palms: [] }; const box = new Box3().setFromBufferAttribute(buildChunkGeometry(chunk).ground.getAttribute("position")); expect(box.min.x).toBeGreaterThanOrEqual(-0.01); expect(box.max.x).toBeLessThanOrEqual(250.01); }); // Die folgenden zwei Tests stehen nicht im Brief. Die echten Chunk-Dateien schließen // jeden Ring selbst (erster Punkt == letzter Punkt); PLOT/COURTYARD oben nicht. Beide // Formen beschreiben dasselbe Polygon und müssen dieselbe Geometrie ergeben — sonst // weichen alle echten Flächen, Straßen und Gebäude von den hier geprüften Werten ab. it("behandelt einen sich selbst schließenden Ring wie seinen offenen Zwilling", () => { const geo = extrudePolygons([PLOT_CLOSED], { base: 0, top: 20, uvScale: 4 }); expect(geo.getIndex().count / 3).toBe(10); }); it("verschiebt bei geschlossenen Ringen keine Lochpunkte", () => { // Dachfläche eines Blocks mit Innenhof: 30x30 Außenring minus 10x10 Loch = 800. // Ohne Entdopplung des Schlusspunkts landet der erste Lochpunkt auf dem Schlusspunkt // des Außenrings, und mindestens ein Dreieck bekommt einen falschen Eckpunkt. const geo = flatPolygons([COURTYARD_CLOSED], { y: 0, uvScale: 4 }); expect(footprintArea(geo)).toBeCloseTo(800, 6); }); // Fix-Runde 1, Befund A / Ruling 47: ein Polygon mit leerem Außenring oder ganz ohne // Ringe muss wie eine leere Fläche zu null degradieren, nicht werfen. it("gibt bei einem Polygon mit leerem Außenring oder ganz ohne Ringe null zurück, statt zu werfen", () => { expect(extrudePolygons([[[]]], { base: 0, top: 10, uvScale: 4 })).toBeNull(); expect(extrudePolygons([[]], { base: 0, top: 10, uvScale: 4 })).toBeNull(); expect(flatPolygons([[[]]], { y: 0, uvScale: 4 })).toBeNull(); expect(flatPolygons([[]], { y: 0, uvScale: 4 })).toBeNull(); }); // Fix-Runde 1, Befund B / Ruling 49. it("wendet Außenwände nach außen, Lochwände nach innen und Deckflächen nach oben", () => { const geo = extrudePolygons([COURTYARD], { base: 0, top: 10, uvScale: 4 }); const pos = geo.getAttribute("position"), idx = geo.getIndex(); const MITTE = 15; // Außenring 30x30 und Loch 10x10 teilen den Mittelpunkt let deckel = 0, aussen = 0, innen = 0; for (let t = 0; t < idx.count; t += 3) { const p = [0, 1, 2].map((k) => { const i = idx.getX(t + k); return [pos.getX(i), pos.getY(i), pos.getZ(i)]; }); const u = [0, 1, 2].map((a) => p[1][a] - p[0][a]); const v = [0, 1, 2].map((a) => p[2][a] - p[0][a]); const n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]]; const mid = [0, 1, 2].map((a) => (p[0][a] + p[1][a] + p[2][a]) / 3); if (Math.abs(n[0]) < 1e-6 && Math.abs(n[2]) < 1e-6) { expect(n[1]).toBeGreaterThan(0); // Deckfläche nach oben deckel++; continue; } const radial = [mid[0] - MITTE, mid[2] - MITTE]; const dot = n[0] * radial[0] + n[2] * radial[1]; if (Math.max(Math.abs(radial[0]), Math.abs(radial[1])) > 10) { expect(dot).toBeGreaterThan(0); // Außenwand zeigt vom Mittelpunkt weg aussen++; } else { expect(dot).toBeLessThan(0); // Lochwand zeigt in den Hof hinein innen++; } } expect([deckel, aussen, innen]).toEqual([8, 8, 8]); }); describe("Bodenplatte mit vielen Löchern (Risiko aus dem Entwurf, Teil 2)", () => { // 250-m-Chunk mit einem Raster aus 100 rechteckigen Löchern: die Größenordnung, die die // Fahrbahnlöcher im dichtesten Stadtchunk erzeugen. Gemessen wird die Zeit, die // triangulateShape dafür braucht — sie läuft beim Nachladen im Hauptthread. const plateWithHoles = (count) => { const outer = [[0, 0], [250, 0], [250, -250], [0, -250]]; const holes = []; const side = Math.ceil(Math.sqrt(count)); for (let k = 0; k < count; k++) { const x = 5 + (k % side) * (240 / side), z = -5 - Math.floor(k / side) * (240 / side); const w = 240 / side - 6; holes.push([[x, z], [x + w, z], [x + w, z - w], [x, z - w]]); } return [[outer, ...holes]]; }; it("baut eine Platte mit 100 Löchern in unter 50 ms", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: plateWithHoles(100), buildings: [], palms: [] }; const start = performance.now(); const geo = buildChunkGeometry(chunk).ground; const ms = performance.now() - start; expect(geo).not.toBeNull(); // Jedes Loch bringt vier Wandvierecke; die Deckfläche kommt dazu. expect(geo.getAttribute("position").count).toBeGreaterThan(100 * 16); expect(ms).toBeLessThan(50); }); it("lässt kein Loch verschwinden", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: plateWithHoles(25), buildings: [], palms: [] }; const geo = buildChunkGeometry(chunk).ground; // Ohne Löcher hätte die Platte 4 Ringpunkte für die Wände plus 4 für die Decke. expect(geo.getAttribute("position").count).toBeGreaterThan(25 * 16); // 25 Löcher mit Seitenzahl 5: 250² − 25 · 42² = 18400 m². // footprintArea prüft die tatsächliche Dreiecksfläche; wäre triangulateShape // fehlgeschlagen, würde die Deckfläche zugedeckt und der Wert sinken. expect(footprintArea(geo)).toBeCloseTo(18400, 0); }); }); }); describe("Straßen aus Teil 2", () => { const chunk = (extra) => ({ chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], buildings: [], palms: [], carriageway: [], curb: [], sidewalk: [], markings: [], lots: [], bridges: [], edges: [], ...extra, }); // Eine Fahrbahn nördlich der Bordsteinlinie z = 10, die Linie in beiden Laufrichtungen: emit.py // legt die Richtung jeder Linie fest, sie sagt also nichts darüber, wo die Fahrbahn liegt. const FAHRBAHN = [[[10, 10], [20, 10], [20, 20], [10, 20]]]; const kante = (umgedreht) => (umgedreht ? [[20, 10], [10, 10]] : [[10, 10], [20, 10]]); const bounds = (geo) => new Box3().setFromBufferAttribute(geo.getAttribute("position")); it("legt die Fahrbahn 15 cm unter den Boden", () => { const geo = buildChunkGeometry(chunk({ carriageway: [{ poly: FAHRBAHN, klasse: "wohnstrasse" }], })).carriageway; // toBeCloseTo, nicht toEqual: Die Positionen liegen als Float32 vor, und -0,15 ist dort // -0,15000000596. expect(bounds(geo).min.y).toBeCloseTo(-CARRIAGEWAY_DEPTH); expect(bounds(geo).max.y).toBeCloseTo(-CARRIAGEWAY_DEPTH); }); it("baut aus einer Bordsteinlinie eine Wand aus der Mulde und eine Oberkante knapp über den Belägen", () => { const geo = kerbGeometry([kante(false)], [FAHRBAHN]); expect(bounds(geo).min.y).toBeCloseTo(-CARRIAGEWAY_DEPTH); expect(bounds(geo).max.y).toBeCloseTo(KERB_HEIGHT); // Wand (4 Punkte) und Oberkante (4 Punkte) je Abschnitt. expect(geo.getAttribute("position").count).toBe(8); }); it("legt die Oberkante auf die Gehwegseite und die Wand 1 cm in die Fahrbahn, in beiden Laufrichtungen", () => { for (const umgedreht of [false, true]) { const geo = kerbGeometry([kante(umgedreht)], [FAHRBAHN]); const pos = geo.getAttribute("position"); expect(bounds(geo).min.z, `umgedreht=${umgedreht}`).toBeCloseTo(10 - KERB_TOP); expect(bounds(geo).max.z, `umgedreht=${umgedreht}`).toBeCloseTo(10 + KERB_INSET); for (let i = 0; i < pos.count; i++) { if (pos.getY(i) < 0) expect(pos.getZ(i), `Wandfuß, umgedreht=${umgedreht}`).toBeCloseTo(10 + KERB_INSET); } } }); it("zeigt die Oberkante nach oben, in beiden Laufrichtungen", () => { for (const umgedreht of [false, true]) { const geo = kerbGeometry([kante(umgedreht)], [FAHRBAHN]); const pos = geo.getAttribute("position"); const normal = geo.getAttribute("normal"); const aussen = []; for (let i = 0; i < pos.count; i++) { if (pos.getZ(i) < 10 - KERB_TOP / 2) aussen.push(normal.getY(i)); // Außenkante der Oberkante } expect(aussen, `umgedreht=${umgedreht}`).toHaveLength(2); for (const ny of aussen) expect(ny, `umgedreht=${umgedreht}`).toBeGreaterThan(0.99); } }); it("baut nur die Wand, wenn keine Fahrbahn neben der Linie liegt", () => { // Ohne Fahrbahn lässt sich die Seite nicht bestimmen, und eine Oberkante auf der falschen // Seite hinge als Sims über der Mulde. const geo = kerbGeometry([kante(false)], []); expect(geo.getAttribute("position").count).toBe(4); expect(bounds(geo).min.z).toBeCloseTo(10); expect(bounds(geo).max.z).toBeCloseTo(10); }); it("gibt Markierungen ihre Farbe als Vertexfarbe und legt sie 1 cm über die Fahrbahn", () => { const geo = markingGeometry([ { poly: [[[0, 0], [3, 0], [3, -0.1], [0, -0.1]]], art: "gelb_doppelt" }, { poly: [[[5, 0], [8, 0], [8, -0.6], [5, -0.6]]], art: "zebra" }, ]); const color = geo.getAttribute("color").array; expect(color.slice(0, 3)).toEqual(new Float32Array(MARKING_COLORS.gelb_doppelt)); expect(color.slice(12, 15)).toEqual(new Float32Array(MARKING_COLORS.zebra)); expect(bounds(geo).min.y).toBeCloseTo(-CARRIAGEWAY_DEPTH + MARKING_OFFSET); expect(bounds(geo).max.y).toBeCloseTo(-CARRIAGEWAY_DEPTH + MARKING_OFFSET); }); it("legt Markierungen, die ganz auf einer Brücke liegen, auf das Deck und alle anderen in die Mulde", () => { const strich = (x) => ({ poly: [[[x, 5], [x + 3, 5], [x + 3, 5.1], [x, 5.1]]], art: "gelb_gestrichelt" }); const out = buildChunkGeometry(chunk({ bridges: [{ poly: [[[0, 0], [20, 0], [20, 20], [0, 20]]] }], markings: [strich(5), strich(18), strich(30)], // ganz auf der Brücke, halb, daneben })); const deck = bounds(out.bridges).max.y; const pos = out.markings.getAttribute("position"); expect(pos.count).toBe(12); for (let i = 0; i < 4; i++) expect(pos.getY(i), "ganz auf der Brücke").toBeCloseTo(deck + MARKING_OFFSET); for (let i = 4; i < 12; i++) expect(pos.getY(i), "halb oder daneben").toBeCloseTo(-CARRIAGEWAY_DEPTH + MARKING_OFFSET); }); it("lädt alte Chunks ohne die neuen Schlüssel weiter", () => { const alt = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], buildings: [], palms: [] }; expect(() => buildChunkGeometry(alt)).not.toThrow(); expect(buildChunkGeometry(alt).carriageway).toBeNull(); }); });