Files
gta6/tests/geometry.test.js
T
knso 77d53fb170 fix(extract): carriageway[].klasse als irreführendes Feld entfernt
Befund 6, Controller-Entscheidung: carriageway_area() vereinigt das ganze
Straßennetz zu wenigen Zusammenhangskomponenten, und _classified() gab
jeder Komponente die Klasse ihrer breitesten enthaltenen Straße - am
echten Netz trugen dadurch 91,2 % der Fahrbahnfläche "boulevard", auch wo
tatsächlich eine Wohnstraße lag. Kein Verbraucher las das Feld (Browser
und Minimap färben die Fahrbahn einheitlich); die Klasse je Straße steht
weiterhin in edges.

_classified() samt Aufruf aus run.py entfernt, chunk_payload() nimmt
carriageway jetzt als reine Liste von Polygonen statt (poly, klasse)-
Tupeln entgegen, Sortierschlüssel entsprechend auf poly allein verkürzt.
Fixtures in test_emit.py, test_overview.py, tests/geometry.test.js und
tests/streaming.test.js sowie das Chunk-Format im Entwurf nachgezogen;
tests/props.test.js bleibt unverändert, weil dort edges betroffen sind,
nicht carriageway.
2026-09-18 06:54:01 +02:00

340 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }],
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 }],
})).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();
});
});