feat(world): Geometrie-Baustein für Land, Straßen, Brücken und Gebäude

Normalisiert Ringe vor der Triangulierung: ShapeUtils.triangulateShape
verlangt echte Vector2 (nicht {x,y}-Objekte) wegen des internen
Vector2#equals-Aufrufs, und jeder extrahierte Ring schließt sich selbst
(letzter Punkt == erster). Ohne vorheriges Entfernen dieses Schlusspunkts
verschieben sich ab dem ersten Loch alle Dreiecksindizes um eins gegenüber
der von ShapeUtils intern neu vergebenen Nummerierung — Häuser und Straßen
mit Innenhof bekämen an genau der Stelle falsch verdrahtete Dreiecke, ohne
dass die Dreieckszahl sich ändert. Beide Fehler traten mit den Testquadraten
aus dem Task-Brief nicht auf, weil deren Ringe offen sind; die echten
Chunk-Dateien schließen aber ausnahmslos jeden Ring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-12 19:23:30 +02:00
1 parent 70c35faf86
commit dce90a2263
3 files changed
+265

No files matched your search

+138
View File
@@ -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);
}
+111
View File
@@ -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);
});
});
+16
View File
@@ -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");