Erstes Risiko des Entwurfs: die Fahrbahn wird als Loch aus der Bodenplatte geschnitten, und ShapeUtils.triangulateShape läuft beim Nachladen im Hauptthread. Der Test hält fest, dass 100 Löcher je Chunk unter 50 ms bleiben und keines verschwindet. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
237 lines
13 KiB
JavaScript
237 lines
13 KiB
JavaScript
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);
|
|
});
|
|
|
|
// 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: [],
|
|
roads: [{ poly: PLOT, bridge: true }],
|
|
centerlines: [], 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" }],
|
|
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("baut Parkwege flach auf Straßenhöhe und die neuen Flächenarten wie Beläge", () => {
|
|
const chunk = {
|
|
chunk: [0, 0], origin: [0, 0], land: [], roads: [], centerlines: [], 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: [], roads: [], centerlines: [], 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: [], 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]);
|
|
});
|
|
|
|
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),
|
|
roads: [], centerlines: [], buildings: [], palms: [] };
|
|
const start = performance.now();
|
|
const geo = buildChunkGeometry(chunk).ground;
|
|
const ms = performance.now() - start;
|
|
console.log(`ZEITMESSUNG: ${ms.toFixed(2)} ms`);
|
|
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),
|
|
roads: [], centerlines: [], 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);
|
|
});
|
|
});
|
|
});
|