Files
gta6/tests/props.test.js
T
knsoandClaude Sonnet 5 ad9bf63b36 fix(props): Straßenlaternen auf glaubwürdige Höhe skaliert, Fuß auf den Boden gehoben
street_lamp_02.glb ist nur 1,657 m hoch (per gltf-transform inspect gemessen:
bboxMax.y 1.26478 - bboxMin.y -0.39258) und ihr Fuß liegt unter dem Ursprung.
#buildLamps setzte das Modell bisher unskaliert auf y=0 — der Fuß steckt im Boden,
und 1,66 m ist ohnehin zu klein neben 10-18 m hohen Palmen und 10-28 m breiten
Straßen dieser Karte. Poly Havens eigene Angabe ("1.7m tall") bestätigt, dass die
Rohgröße korrekt gemessen ist — der Fehler liegt in der Verwendung dieser Rohgröße
in diesem Maßstab, nicht in der Messung selbst.

lampFit(minY, maxY, targetHeight) leitet Skalierung und Fuß-Anhebung aus der
gemessenen bbox her; STREET_LAMP_HEIGHT = 4.0 m als benannte Zielgröße (Mitte des
für dekorative Straßenlaternen üblichen 3-6-m-Bereichs). load() wendet beides auf
die Modellwurzel an, bevor die Teil-Matrizen (Mast, Glas) eingesammelt werden, damit
#buildLamps() unverändert bleiben kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 05:14:11 +02:00

150 lines
7.4 KiB
JavaScript

import { describe, expect, it } from "vitest";
import { STREET_LAMP_HEIGHT, lampFit, lampPoints, palmTransforms, pointInPolygon, vergeLampPoints } from "../src/world/props.js";
describe("props", () => {
it("setzt Laternen im gewünschten Abstand entlang der Linie", () => {
const pts = lampPoints([[[0, 0], [100, 0]]], 35);
expect(pts.length).toBe(3); // 17,5 / 52,5 / 87,5 Meter
expect(pts[0].x).toBeCloseTo(17.5); // nicht auf der Kreuzung
expect(pts[1].x).toBeCloseTo(52.5);
});
it("setzt auf einer kurzen Linie eine Laterne in die Mitte", () => {
const pts = lampPoints([[[0, 0], [10, 0]]], 35);
expect(pts).toHaveLength(1);
expect(pts[0].x).toBeCloseTo(5);
});
it("stapelt an einer Kreuzung keine Laternen", () => {
// Zwei gegenüberliegende Äste, wie centerlines() sie seit Task 4 liefert.
const pts = lampPoints([[[0, 0], [60, 0]], [[0, 0], [-60, 0]]], 35);
const keys = new Set(pts.map((p) => `${p.x.toFixed(2)},${p.z.toFixed(2)}`));
expect(keys.size).toBe(pts.length);
});
it("skaliert Palmen reproduzierbar zwischen 10 und 18 Metern", () => {
const a = palmTransforms([[5, 5], [9, 9]], 7);
const b = palmTransforms([[5, 5], [9, 9]], 7);
expect(a).toEqual(b);
for (const t of a) {
expect(t.height).toBeGreaterThanOrEqual(10);
expect(t.height).toBeLessThanOrEqual(18);
}
});
});
// Fix-Runde 1, Befund A / Ruling 60: Laternen standen exakt auf der Mittellinie.
describe("pointInPolygon", () => {
// Quadrat 0..20 mit einem 4x4-Loch in der Mitte (8..12) — offene Ringe, wie im Rest
// dieser Datei; die Laufzeitdaten schließen ihre Ringe (letzter Punkt = erster), das
// Halbstrahl-Verfahren behandelt beide Formen gleich (siehe Kommentar in props.js).
const outer = [[0, 0], [20, 0], [20, 20], [0, 20]];
const hole = [[8, 8], [12, 8], [12, 12], [8, 12]];
it("Punkt innen (im Außenring, außerhalb jedes Lochs)", () => {
expect(pointInPolygon(2, 2, [outer, hole])).toBe(true);
});
it("Punkt außen (außerhalb des Außenrings)", () => {
expect(pointInPolygon(25, 25, [outer, hole])).toBe(false);
});
it("Punkt im Loch (im Außenring, aber auch im Loch)", () => {
expect(pointInPolygon(10, 10, [outer, hole])).toBe(false);
});
// Fix-Runde 2, Befund B / Ruling 63: bis hierher benutzte jede Vorrichtung offene Ringe;
// die echten Chunk-Daten schließen ihre Ringe ausnahmslos (letzter Punkt = erster, siehe
// Kommentar oben und in props.js) — dieser Fall stand bisher in keinem Test. Ein künftiger
// Umbau, der das Schließen kaputt macht, ginge sonst durch alle Tests.
it("behandelt geschlossene Ringe genauso wie offene (Laufzeitdaten schließen ihre Ringe)", () => {
const closedOuter = [...outer, outer[0]];
const closedHole = [...hole, hole[0]];
expect(pointInPolygon(2, 2, [closedOuter, closedHole])).toBe(true);
expect(pointInPolygon(25, 25, [closedOuter, closedHole])).toBe(false);
expect(pointInPolygon(10, 10, [closedOuter, closedHole])).toBe(false);
});
});
describe("vergeLampPoints", () => {
it("versetzt eine Laterne von der Fahrbahn zum ersten freien Abstand, senkrecht zur Linie", () => {
// angle 0 -> Blickrichtung (1, 0), Normale (0, 1). Straße 10 m breit (Halbbreite 5 m):
// der erste Versatz aus LAMP_VERGE (6 m) reicht bereits über den Fahrbahnrand hinaus.
const points = [{ x: 0, z: 0, angle: 0 }];
const roads = [{ poly: [[[-50, -5], [50, -5], [50, 5], [-50, 5]]], bridge: false }];
const out = vergeLampPoints(points, roads);
expect(out).toHaveLength(1);
expect(out[0].x).toBeCloseTo(0);
expect(out[0].z).toBeCloseTo(6);
expect(pointInPolygon(out[0].x, out[0].z, roads[0].poly)).toBe(false);
});
it("lässt eine Laterne entfallen, wenn keiner der sechs Abstände die Fahrbahn verlässt", () => {
// Halbbreite 20 m > größter Versatz (16 m) -> jede Probe bleibt auf der Fahrbahn.
const points = [{ x: 0, z: 0, angle: 0 }];
const roads = [{ poly: [[[-50, -20], [50, -20], [50, 20], [-50, 20]]], bridge: false }];
expect(vergeLampPoints(points, roads)).toHaveLength(0);
});
it("wechselt die Versatzseite von Punkt zu Punkt ab", () => {
// Ohne Straßen gewinnt sofort der erste (kleinste) Versatz bei jedem Punkt.
const points = [{ x: 0, z: 0, angle: 0 }, { x: 10, z: 0, angle: 0 }];
const out = vergeLampPoints(points, []);
expect(out[0].z).toBeCloseTo(6);
expect(out[1].z).toBeCloseTo(-6);
});
// Fix-Runde 2, Befund B / Ruling 63: siehe Kommentar bei pointInPolygon — derselbe
// ungeprüfte Fall (offene statt geschlossene Ringe) steckte auch hier in jeder Vorrichtung.
it("versetzt eine Laterne auch dann, wenn der Straßenring geschlossen ist", () => {
const points = [{ x: 0, z: 0, angle: 0 }];
const ring = [[-50, -5], [50, -5], [50, 5], [-50, 5]];
const roads = [{ poly: [[...ring, ring[0]]], bridge: false }];
const out = vergeLampPoints(points, roads);
expect(out).toHaveLength(1);
expect(out[0].z).toBeCloseTo(6);
});
});
// Fix-Runde 3, Befund B: street_lamp_02.glb ist mit `npx @gltf-transform/cli@4.5.0 inspect`
// vermessen bboxMin.y=-0.39258 / bboxMax.y=1.26478 (Modellhöhe 1,65736 m) — der Fuß liegt
// unter dem Ursprung, #buildLamps setzte das Modell bisher unskaliert auf y=0, der Fuß steckt
// also im Boden, und 1,66 m ist ohnehin zu klein für eine Straßenlaterne (s. Bericht).
// lampFit() ist rein: sie bekommt die gemessene bbox und die Zielhöhe hinein, keine
// Seiteneffekte, kein GLTFLoader — PropLayer.load() ist der einzige Aufrufer.
describe("lampFit", () => {
const BBOX_MIN_Y = -0.39258;
const BBOX_MAX_Y = 1.26478;
it("skaliert die gemessene Modellhöhe exakt auf die Zielhöhe", () => {
const { scale } = lampFit(BBOX_MIN_Y, BBOX_MAX_Y, STREET_LAMP_HEIGHT);
const modelHeight = BBOX_MAX_Y - BBOX_MIN_Y;
expect(scale * modelHeight).toBeCloseTo(STREET_LAMP_HEIGHT, 5);
});
it("hebt das skalierte Modell so an, dass sein Fuß (bboxMin.y) auf y = 0 zu stehen kommt", () => {
const { scale, liftY } = lampFit(BBOX_MIN_Y, BBOX_MAX_Y, STREET_LAMP_HEIGHT);
expect(BBOX_MIN_Y * scale + liftY).toBeCloseTo(0, 5);
});
it("STREET_LAMP_HEIGHT ist eine glaubwürdige Straßenlaternenhöhe, keine 1,66 m", () => {
// 3-6 m ist der in Quellen genannte Bereich für dekorative/ornamentale Straßen- und
// Parklaternen (s. Bericht); das Modell selbst ist mit 1,66 m unstrittig zu klein.
expect(STREET_LAMP_HEIGHT).toBeGreaterThanOrEqual(3);
expect(STREET_LAMP_HEIGHT).toBeLessThanOrEqual(6);
});
it("leitet den Skalierungsfaktor aus der übergebenen Zielhöhe her, statt eine Zahl zu raten", () => {
expect(lampFit(0, 2, 4).scale).toBeCloseTo(2);
expect(lampFit(0, 4, 4).scale).toBeCloseTo(1);
expect(lampFit(0, 1, 4).scale).toBeCloseTo(4);
});
it("liefert scale 1 und hebt nur um |bboxMin.y| an, wenn keine Zielhöhe übergeben wird und die Modellhöhe 0 ist (Randfall)", () => {
// Entartete bbox (maxY === minY) darf nicht durch 0 teilen.
const { scale, liftY } = lampFit(2, 2, STREET_LAMP_HEIGHT);
expect(scale).toBe(1);
expect(liftY).toBeCloseTo(-2);
});
});