test(world): ersetzte Grundrisse und feste Materialien je Ort strenger prüfen

Behebt Befunde 2-6 aus dem Abschluss-Review: der replaces-Test prüft
jetzt zusätzlich jede Ecke des getroffenen Grundrisses gegen die
Außenmaße plus REPLACED_OVERHANG (5 m), der Verteilungstest vergleicht
als Mengen und bekommt eine Gegenrichtung je Datei, BUMP_BY_PLACE
erzwingt eine Zeile je Ort statt sie mit "?? []" zu verdecken, ein
Gebäude an einer Chunkgrenze bekommt einen Test über zwei Chunks samt
JSDoc-Hinweis, und der Rückwand-Messwert in der Spec nennt Kapselmitte,
Wandfläche und Radius einzeln.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-15 03:57:31 +02:00
1 parent 14fd9cefde
commit 237a4ed389
5 files changed
+59 -12

No files matched your search

+24 -5
View File
@@ -5,7 +5,7 @@ import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds } from "@gltf-transform/functions";
import { MeshoptDecoder } from "meshoptimizer";
import { PLACES, SOLID_MATERIALS } from "../src/world/places.js";
import { isSolidMaterial, PLACES, SOLID_MATERIALS } from "../src/world/places.js";
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs";
// Feste Materialien je Ort. SOLID_MATERIALS gilt für alle Orte zusammen, aber keine Datei enthält alle
@@ -26,16 +26,20 @@ const SOLID_BY_PLACE = {
// Materialien, deren Relief nur aus EXT_materials_bump kommt. Die Tribüne hat keine Bump-Maps.
const BUMP_BY_PLACE = {
"hood-yard": ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"],
tribuene: [],
};
// Bruch: ein festes Material steht in keiner Zeile — die Datei seines Orts würde nie darauf geprüft.
// Bruch: ein festes Material steht in keiner Zeile — die Datei seines Orts würde nie darauf geprüft. Als
// Mengen verglichen, damit ein Name, der in zwei Orten fest ist, keinen Fehlalarm auslöst.
it("verteilt SOLID_MATERIALS vollständig auf die Orte", () => {
expect(Object.values(SOLID_BY_PLACE).flat().sort()).toEqual([...SOLID_MATERIALS].sort());
expect(new Set(Object.values(SOLID_BY_PLACE).flat())).toEqual(new Set(SOLID_MATERIALS));
});
describe.each(PLACES.map((place) => [place.id, place]))("%s.glb", (id, place) => {
const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url));
const bump = BUMP_BY_PLACE[id] ?? [];
// Kein "?? []": eine fehlende Zeile soll die Prüfung darunter rot machen (Bruch), nicht still
// durchlaufen. it.runIf gleich unten sichert nur seine eigene Bedingung gegen "undefined" ab.
const bump = BUMP_BY_PLACE[id];
let document;
beforeAll(async () => {
await MeshoptDecoder.ready;
@@ -65,9 +69,15 @@ describe.each(PLACES.map((place) => [place.id, place]))("%s.glb", (id, place) =>
}
});
// Bruch: der Ort hat keine Zeile in BUMP_BY_PLACE — ohne diesen Test liefe die Bump-Prüfung darunter
// per it.runIf(bump?.length > 0) einfach übersprungen durch, statt rot zu werden.
it("hat eine Zeile in BUMP_BY_PLACE", () => {
expect(bump).toBeDefined();
});
// Bruch: die Aufbereitung verwirft EXT_materials_bump (wie die gltf-transform-CLI) — das Wellblech der
// Container wäre flach.
it.runIf(bump.length > 0)("gibt den Bump-Materialien ihre Bump-Textur", () => {
it.runIf((bump ?? []).length > 0)("gibt den Bump-Materialien ihre Bump-Textur", () => {
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
const materials = new Map(document.getRoot().listMaterials().map((material) => [material.getName(), material]));
@@ -86,6 +96,15 @@ describe.each(PLACES.map((place) => [place.id, place]))("%s.glb", (id, place) =>
expect(SOLID_BY_PLACE[id].filter((name) => !names.includes(name))).toEqual([]);
});
// Bruch: ein Material der Datei ist laut isSolidMaterial fest (weil sein Name anderswo fest ist), ohne
// in der Zeile seines Orts zu stehen — es würde hier still mitkollidieren, ungeprüft von SOLID_BY_PLACE.
it("hat kein fremdes festes Material ohne Zeile in SOLID_BY_PLACE", () => {
const names = document.getRoot().listMaterials().map((material) => material.getName());
const ownSolid = new Set(SOLID_BY_PLACE[id]);
expect(names.filter((name) => isSolidMaterial(name) && !ownSolid.has(name))).toEqual([]);
});
// Bruch: bounds in PLACES passt nicht mehr zur Datei — der Datentest prüfte dann die falsche Fläche.
it("passt auf 5 cm zu den Außenmaßen in PLACES", () => {
const { min, max } = getBounds(document.getRoot().listScenes()[0]);
+12 -5
View File
@@ -9,6 +9,9 @@ import { withoutReplacedBuildings } from "../src/world/streaming.js";
const CHUNK_LOAD_RADIUS = 400;
const CHUNK_UNLOAD_RADIUS = 600;
const STEP = 0.5; // Meter zwischen zwei Prüfpunkten auf der Ortsfläche
// Wie weit darf ein ersetzter Grundriss über die Außenmaße seines Orts hinausragen? Die Tribüne liegt
// heute 3,78 m vor der Rückseite ihres alten Kastens, Modell-z −18,60 gegen −14,82.
const REPLACED_OVERHANG = 5; // Meter
const cache = new Map();
function chunk(i, j) {
@@ -44,10 +47,10 @@ function worldToModel(place, X, Y) {
return { x: dx * cos - dz * sin, z: dx * sin + dz * cos };
}
function insideBounds(place, X, Y) {
function insideBounds(place, X, Y, margin = 0) {
const { x, z } = worldToModel(place, X, Y);
const { minX, maxX, minZ, maxZ } = place.bounds;
return x >= minX && x <= maxX && z >= minZ && z <= maxZ;
return x >= minX - margin && x <= maxX + margin && z >= minZ - margin && z <= maxZ + margin;
}
describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte", (_id, place) => {
@@ -76,14 +79,18 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte",
expect(bad.slice(0, 5)).toEqual([]);
});
// Bruch: Nach einem neuen Lauf der Extraktion liegt das ersetzte Gebäude woanders oder ist geteilt. Der
// Punkt träfe dann nichts (der alte Kasten stünde wieder im Ort), mehrere Grundrisse oder einen außerhalb.
// Bruch: Nach einem neuen Lauf der Extraktion liegt das ersetzte Gebäude woanders oder ist geteilt, oder
// der Punkt trifft ein fremdes oder mit einem Nachbarn zusammengelegtes Gebäude. Der Punkt träfe dann
// nichts (der alte Kasten stünde wieder im Ort), mehrere Grundrisse, einen außerhalb, oder einen mit
// Ecken weit jenseits der Außenmaße.
it.runIf((place.replaces ?? []).length > 0)("ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich", () => {
const bad = place.replaces.filter(([X, Y]) => {
const { i, j } = chunkForWorld(X, Y);
const c = chunk(i, j);
const hits = c.buildings.filter((building) => pointInPolygon(X - c.origin[0], -Y - c.origin[1], building.poly));
return hits.length !== 1 || !insideBounds(place, X, Y);
if (hits.length !== 1 || !insideBounds(place, X, Y)) return true;
const corners = hits[0].poly.flat(1);
return corners.some(([u, v]) => !insideBounds(place, c.origin[0] + u, -(c.origin[1] + v), REPLACED_OVERHANG));
});
expect(bad).toEqual([]);
});
+17
View File
@@ -54,6 +54,23 @@ describe("withoutReplacedBuildings", () => {
expect(withoutReplacedBuildings(chunk, [])).toBe(chunk);
expect(withoutReplacedBuildings(chunk, [[1405, 1255]])).toBe(chunk);
});
// Bruch: eine Zuordnung des Punkts per chunkForWorld ordnete ihn nur chunk_9_5 zu (i = 9) und ließe die
// ungeschnittene Kopie desselben Grundrisses in chunk_10_5 stehen — genau das Gebäude, das der Ort ersetzt.
it("lässt denselben, ungeschnittenen Grundriss in beiden berührten Chunks weg", () => {
const chunkA = {
chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [],
buildings: [{ poly: square(245, 190), height: 18.6 }], // Welt x 1495–1505, y 1050–1060
};
const chunkB = {
chunk: [10, 5], origin: [1500, -1250], land: [], surfaces: [], roads: [],
buildings: [{ poly: square(-5, 190), height: 18.6 }], // dieselbe Welt-Fläche, andere u
};
const points = [[1498, 1055]];
expect(withoutReplacedBuildings(chunkA, points).buildings).toEqual([]);
expect(withoutReplacedBuildings(chunkB, points).buildings).toEqual([]);
});
});
// Gemeinsame Vorrichtung für beide ChunkStreamer-describe-Blöcke unten (mit und ohne PropLayer).