diff --git a/src/world/geometry.js b/src/world/geometry.js new file mode 100644 index 0000000..af69df5 --- /dev/null +++ b/src/world/geometry.js @@ -0,0 +1,138 @@ +import { BufferGeometry, Float32BufferAttribute, ShapeUtils, Vector2 } from "three"; + +const GROUND_THICKNESS = 1.0; +const SURFACE_OFFSET = 0.02; // Sand, Park und Stadtfläche liegen knapp über dem Boden +const ROAD_OFFSET = 0.03; // Straßen wiederum knapp über den Flächen +const BRIDGE_THICKNESS = 0.6; +export const SURFACE_KINDS = ["sand", "park", "urban"]; + +/** Pastellton je Gebäude, reproduzierbar aus seiner Lage. */ +function buildingTint(poly) { + const [x, z] = poly[0][0]; + const h = Math.abs(Math.sin(x * 12.9898 + z * 78.233) * 43758.5453) % 1; + return [0.82 + 0.18 * h, 0.80 + 0.20 * ((h * 3) % 1), 0.78 + 0.22 * ((h * 7) % 1)]; +} + +// Die extrahierten Ringe schließen sich selbst: der letzte Punkt wiederholt den ersten +// (geprüft über alle 144 Chunks — ausnahmslos). ShapeUtils.triangulateShape entfernt diesen +// Schlusspunkt intern über Vector2#equals und zählt seine Indizes danach neu durch. Bauen wir +// die Positionsliste dagegen aus dem rohen, geschlossenen Ring auf, verschiebt sich jeder Index +// ab dem ersten Loch um eins gegenüber dieser internen Nummerierung, und Dreiecke zeigen auf +// falsche Punkte. Deshalb hier einmal vorab entfernen, bevor irgendetwas anderes mit dem Ring +// passiert — Ringe ohne Schlusspunkt (wie in den Tests) bleiben dabei unverändert. +const openRing = (ring) => { + const [x0, z0] = ring[0]; + const [xn, zn] = ring[ring.length - 1]; + return ring.length > 1 && x0 === xn && z0 === zn ? ring.slice(0, -1) : ring; +}; + +const signedArea = (ring) => ring.reduce((sum, [x0, z0], i) => { + const [x1, z1] = ring[(i + 1) % ring.length]; + return sum + x0 * z1 - x1 * z0; +}, 0) / 2; + +/** Ringe im Uhrzeigersinn, damit Wände nach außen zeigen. */ +const clockwise = (ring) => (signedArea(ring) > 0 ? [...ring].reverse() : ring); + +function toGeometry(pos, idx, uv, color) { + const geo = new BufferGeometry(); + geo.setAttribute("position", new Float32BufferAttribute(pos, 3)); + geo.setAttribute("uv", new Float32BufferAttribute(uv, 2)); + if (color) geo.setAttribute("color", new Float32BufferAttribute(color, 3)); + geo.setIndex(idx); + geo.computeVertexNormals(); + return geo; +} + +export function flatPolygons(polygons, { y = 0, uvScale = 8 } = {}) { + const pos = [], idx = [], uv = []; + for (const raw of polygons) { + const outer = clockwise(openRing(raw[0])); + // clockwise() gibt denselben Array zurueck, wenn der Ring schon passt — deshalb + // vor dem Umdrehen kopieren, sonst wird die Eingabe des Aufrufers veraendert. + const holes = raw.slice(1).map((hole) => [...clockwise(openRing(hole))].reverse()); + const base = pos.length / 3; + for (const ring of [outer, ...holes]) { + for (const [x, z] of ring) { + pos.push(x, y, z); + uv.push(x / uvScale, z / uvScale); + } + } + const toPoints = (ring) => ring.map(([x, z]) => new Vector2(x, z)); + for (const tri of ShapeUtils.triangulateShape(toPoints(outer), holes.map(toPoints))) { + idx.push(base + tri[0], base + tri[2], base + tri[1]); // nach oben gedreht + } + } + return pos.length ? toGeometry(pos, idx, uv) : null; +} + +export function extrudePolygons(polygons, { base = 0, top = 10, uvScale = 4, tint = null } = {}) { + const pos = [], idx = [], uv = [], color = []; + for (const raw of polygons) { + // Jede Fläche ist eine Liste von Ringen: Außenring zuerst, danach die Löcher. + // triangulateShape erwartet die Löcher gegenläufig zum Außenring. + const outer = clockwise(openRing(raw[0])); + // clockwise() gibt denselben Array zurueck, wenn der Ring schon passt — deshalb + // vor dem Umdrehen kopieren, sonst wird die Eingabe des Aufrufers veraendert. + const holes = raw.slice(1).map((hole) => [...clockwise(openRing(hole))].reverse()); + for (const ring of [outer, ...holes]) { + const n = ring.length; + for (let i = 0; i < n; i++) { + const [x0, z0] = ring[i]; + const [x1, z1] = ring[(i + 1) % n]; + const len = Math.hypot(x1 - x0, z1 - z0); + const b = pos.length / 3; + pos.push(x0, base, z0, x1, base, z1, x1, top, z1, x0, top, z0); + uv.push(0, 0, len / uvScale, 0, len / uvScale, (top - base) / uvScale, 0, (top - base) / uvScale); + if (tint) for (let k = 0; k < 4; k++) color.push(...tint); + idx.push(b, b + 1, b + 2, b, b + 2, b + 3); + } + } + // Deckfläche: erst alle Punkte in derselben Reihenfolge, die triangulateShape indiziert. + const b = pos.length / 3; + for (const ring of [outer, ...holes]) { + for (const [x, z] of ring) { + pos.push(x, top, z); + uv.push(x / uvScale, z / uvScale); + if (tint) color.push(...tint); + } + } + const toPoints = (ring) => ring.map(([x, z]) => new Vector2(x, z)); + for (const tri of ShapeUtils.triangulateShape(toPoints(outer), holes.map(toPoints))) { + idx.push(b + tri[0], b + tri[2], b + tri[1]); + } + } + return pos.length ? toGeometry(pos, idx, uv, tint ? color : null) : null; +} + +export function buildChunkGeometry(chunk) { + const roads = chunk.roads.filter((r) => !r.bridge).map((r) => r.poly); + const bridges = chunk.roads.filter((r) => r.bridge).map((r) => r.poly); + const out = { + ground: extrudePolygons(chunk.land, { base: -GROUND_THICKNESS, top: 0, uvScale: 8 }), + roads: flatPolygons(roads, { y: ROAD_OFFSET, uvScale: 8 }), + bridges: extrudePolygons(bridges, { base: -BRIDGE_THICKNESS, top: 0, uvScale: 8 }), + buildings: buildBuildings(chunk.buildings), + }; + for (const kind of SURFACE_KINDS) { + const rings = (chunk.surfaces ?? []).filter((s) => s.kind === kind).map((s) => s.poly); + out[kind] = flatPolygons(rings, { y: SURFACE_OFFSET, uvScale: 8 }); + } + return out; +} + +function buildBuildings(buildings) { + const parts = buildings.map((b) => + extrudePolygons([b.poly], { base: 0, top: b.height, uvScale: 4, tint: buildingTint(b.poly) })); + return parts.filter(Boolean).reduce((acc, geo) => (acc ? mergeInto(acc, geo) : geo), null); +} + +function mergeInto(a, b) { + const offset = a.getAttribute("position").count; + const pos = [...a.getAttribute("position").array, ...b.getAttribute("position").array]; + const uv = [...a.getAttribute("uv").array, ...b.getAttribute("uv").array]; + const ca = a.getAttribute("color"), cb = b.getAttribute("color"); + const color = ca && cb ? [...ca.array, ...cb.array] : null; + const idx = [...a.getIndex().array, ...[...b.getIndex().array].map((i) => i + offset)]; + return toGeometry(pos, idx, uv, color); +} diff --git a/tests/geometry.test.js b/tests/geometry.test.js new file mode 100644 index 0000000..d560342 --- /dev/null +++ b/tests/geometry.test.js @@ -0,0 +1,111 @@ +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); + }); +}); diff --git a/tools/checkChunks.mjs b/tools/checkChunks.mjs new file mode 100644 index 0000000..b7691b2 --- /dev/null +++ b/tools/checkChunks.mjs @@ -0,0 +1,16 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { buildChunkGeometry } from "../src/world/geometry.js"; + +const dir = "public/data/vice-a"; +let tris = 0; +for (const file of readdirSync(dir).filter((f) => f.startsWith("chunk_"))) { + const chunk = JSON.parse(readFileSync(`${dir}/${file}`, "utf8")); + for (const geo of Object.values(buildChunkGeometry(chunk))) { + if (!geo) continue; + const pos = geo.getAttribute("position").array; + if (pos.some((v) => !Number.isFinite(v))) throw new Error(`NaN in ${file}`); + tris += geo.getIndex().count / 3; + } +} +console.log("Dreiecke gesamt:", tris); +if (tris > 1_500_000) throw new Error("Budget überschritten");