340 lines
18 KiB
JavaScript
340 lines
18 KiB
JavaScript
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();
|
||
});
|
||
});
|