import { describe, expect, it } from "vitest"; import { Box3, BufferGeometry } from "three"; import { buildChunkGeometry, extrudePolygons, flatPolygons } 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); }); 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" }], roads: [{ poly: PLOT, bridge: false }, { poly: PLOT, bridge: true }], centerlines: [], palms: [], buildings: [{ poly: PLOT, height: 12, source: "district", speculative: false }], }; const out = buildChunkGeometry(chunk); for (const key of ["ground", "sand", "park", "roads", "bridges", "buildings"]) { expect(out[key]).toBeInstanceOf(BufferGeometry); } expect(out.urban).toBeNull(); }); it("gibt Gebäuden eine Farbe je Haus", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], roads: [], centerlines: [], 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: [], roads: [], centerlines: [], 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]); }); });