import { afterEach, describe, expect, it, vi } from "vitest"; import { applyPolygonOffsets, loadSet, MATERIAL_KEYS, POLYGON_OFFSETS, POLYGON_OFFSET_ORDER, SURFACE_COLORS, TEXTURES, waterNormalData } from "../src/world/materials.js"; import { buildChunkGeometry } from "../src/world/geometry.js"; describe("materials", () => { it("deckt genau die Geometrie-Teile ab", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: [], buildings: [], palms: [] }; expect(Object.keys(buildChunkGeometry(chunk)).sort()).toEqual([...MATERIAL_KEYS].sort()); }); it("nennt für jedes Material genau eine Textur oder eine Farbe", () => { for (const key of MATERIAL_KEYS) expect(Boolean(TEXTURES[key]) !== Boolean(SURFACE_COLORS[key]), key).toBe(true); }); it("gibt Feldern das Gras des Parks, dem Parkplatz den Asphalt der Straßen und Wegen Betonplatten", () => { expect(TEXTURES.field).toBe(TEXTURES.park); expect(TEXTURES.parking).toBe(TEXTURES.carriageway); expect(TEXTURES.paths).toBe("concrete_pavement"); expect(Object.keys(SURFACE_COLORS).sort()).toEqual(["court", "markings", "pool"]); }); it("deckt Strand und Park mit eigenen Materialien ab", () => { expect(MATERIAL_KEYS).toContain("sand"); expect(MATERIAL_KEYS).toContain("park"); }); it("gibt Brücken dieselbe Textur wie Straßen, damit an der Übergangsnaht kein Farbsprung entsteht (Ruling 56)", () => { // Brücken sind in den Daten dasselbe Straßenstück, nur über Wasser. expect(TEXTURES.bridges).toBe(TEXTURES.carriageway); }); it("erzeugt eine Wellen-Normalmap mit neutraler Grundfarbe", () => { const data = waterNormalData(32); expect(data.length).toBe(32 * 32 * 4); // Normalen zeigen im Mittel nach oben: Blaukanal nahe 255. const blue = [...data].filter((_, i) => i % 4 === 2); expect(blue.reduce((a, b) => a + b, 0) / blue.length).toBeGreaterThan(200); }); }); // Flackernder Boden (Diagnose-Report): geometry.js legt Boden bei y=0, Strand/Park/Stadtfläche // bei SURFACE_OFFSET=0.02 und Straßen bei ROAD_OFFSET=0.03 an — 1-2 cm Abstand, der bei // 150-300 m Kameraentfernung unter der Auflösung eines 24-Bit-Tiefenpuffers liegt. Rein und // ganz ohne Browser testbar (wie im Auftrag gefordert): POLYGON_OFFSETS ist eine reine // Datenstruktur, applyPolygonOffsets() eine reine Funktion auf Platzhalterobjekten — beide // brauchen weder createMaterials() noch echtes Texturladen. describe("Tiefenpuffer-Versatz je Schicht (gegen Flackern zwischen Boden/Stadt/Park/Strand/Straße)", () => { it("ordnet die Schichten Boden < Stadtfläche < Park < Strand < Feld < Platz < Pool < Parkplatz < Restfläche < Gehweg < Weg", () => { expect(POLYGON_OFFSET_ORDER).toEqual(["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "lots", "sidewalk", "paths"]); }); it("aktiviert polygonOffset für jede der fünf Schichten", () => { for (const key of POLYGON_OFFSET_ORDER) { expect(POLYGON_OFFSETS[key]?.polygonOffset).toBe(true); } }); it("kein Paar unter den fünf Schichten teilt sich Faktor und Units — Strand, Park und " + "Stadtfläche bekommen je eine eigene Stufe statt sich eine zu teilen", () => { const sigs = POLYGON_OFFSET_ORDER.map((key) => { const o = POLYGON_OFFSETS[key]; return `${o.polygonOffsetFactor}/${o.polygonOffsetUnits}`; }); expect(new Set(sigs).size).toBe(sigs.length); }); // Im Browser gemessen (Bordstein-Befund): Der Faktor wird mit der Tiefensteigung multipliziert, // und die geht beim streifenden Blick über eine Bodenfläche gegen unendlich. Mit einem Faktor je // Rangstufe zog der Gehwegbelag (-9) den 18 cm hohen Bordstein daneben aus 18 m vollständig zu; // von oben und aus der Nähe war er da. Deshalb trägt die Rangfolge jetzt allein die Units. it("trägt die Rangfolge in den Units, nicht im Faktor", () => { const units = POLYGON_OFFSET_ORDER.map((key) => POLYGON_OFFSETS[key].polygonOffsetUnits); for (let i = 1; i < units.length; i++) { // Zunehmend negativ = zunehmend Richtung Kamera gezogen, s. Kommentar in materials.js. expect(units[i], POLYGON_OFFSET_ORDER[i]).toBeLessThan(units[i - 1]); } }); // Zweiter Teil des Bordstein-Befunds: Mit Faktor 0 blieb er zwar sichtbar, aber nur etwa 40 m // weit - danach zog ihn der Gehwegbelag beim flachen Blick doch zu. Der Bordstein grenzt an die // Beläge und braucht deshalb denselben Faktor; über die Units steht er eine Stufe über dem // obersten Belag, damit auch seine 20 cm breite Oberkante auf dem Gehweg sichtbar bleibt. it("nimmt den Bordstein in den Versatz auf, eine Stufe über dem obersten Belag", () => { const oberster = POLYGON_OFFSET_ORDER[POLYGON_OFFSET_ORDER.length - 1]; expect(POLYGON_OFFSETS.curb.polygonOffsetFactor).toBe(POLYGON_OFFSETS[oberster].polygonOffsetFactor); expect(POLYGON_OFFSETS.curb.polygonOffsetUnits) .toBeLessThan(POLYGON_OFFSETS[oberster].polygonOffsetUnits); expect(POLYGON_OFFSET_ORDER).not.toContain("curb"); }); it("gibt allen Belägen denselben kleinen Faktor, nur die Bodenplatte bleibt bei null", () => { const [boden, ...belaege] = POLYGON_OFFSET_ORDER; expect(POLYGON_OFFSETS[boden].polygonOffsetFactor).toBe(0); const faktoren = new Set(belaege.map((key) => POLYGON_OFFSETS[key].polygonOffsetFactor)); expect([...faktoren]).toEqual([-1]); // Die Markierung liegt außerhalb der Rangfolge, folgt aber derselben Regel. expect(POLYGON_OFFSETS.markings.polygonOffsetFactor).toBe(-1); }); it("trägt den Versatz nur in Boden/Stadtfläche/Park/Strand/Straße ein — Brücken, Gebäude, " + "Wasser und Meeresboden bleiben unberührt (eigene Materialobjekte, s. Auftrag)", () => { const fake = Object.fromEntries( ["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "lots", "sidewalk", "paths", "markings", "carriageway", "curb", "bridges", "buildings", "water", "seafloor"] .map((k) => [k, {}]), ); applyPolygonOffsets(fake); for (const key of POLYGON_OFFSET_ORDER) { expect(fake[key]).toMatchObject(POLYGON_OFFSETS[key]); } // markings steht nicht in POLYGON_OFFSET_ORDER (eigener Versatz außerhalb der Rangfolge, // s. Kommentar dort), bekommt seinen Versatz aber trotzdem über den zweiten Eintrag im // POLYGON_OFFSETS-Objekt — einziger Beleg, dass der bis in ein Materialobjekt durchreicht. expect(fake.markings).toEqual(POLYGON_OFFSETS.markings); expect(fake.curb).toEqual(POLYGON_OFFSETS.curb); for (const key of ["carriageway", "bridges", "buildings", "water", "seafloor"]) { expect(fake[key]).toEqual({}); } }); }); describe("Schichten aus Teil 2", () => { it("hat für jede neue Schicht ein Material", () => { for (const key of ["carriageway", "curb", "sidewalk", "markings", "lots"]) { expect(MATERIAL_KEYS).toContain(key); } expect(MATERIAL_KEYS).not.toContain("roads"); }); it("nimmt Bordstein und Gehweg von Poly Haven, nicht mehr aus dem Kit", () => { // Korrektur: sidewalk_Diffuse.jpg aus dem Kit ist ein Platzhalterbild (roter Schriftzug), // keine Betontextur (im Browser geprüft) - der Gehweg benutzt deshalb denselben Belag wie // die Parkwege, nicht den Kit-Slug. expect(TEXTURES.curb).toBe("brushed_concrete_04"); expect(TEXTURES.sidewalk).toBe("concrete_pavement"); expect(TEXTURES.sidewalk).toBe(TEXTURES.paths); expect(TEXTURES.carriageway).toBe("asphalt_02"); expect(TEXTURES.lots).toBe("asphalt_02"); }); it("gibt der Markierung eine Farbe statt einer Textur", () => { expect(SURFACE_COLORS.markings).toBeDefined(); expect(TEXTURES.markings).toBeUndefined(); }); it("gibt der Markierung einen eigenen kleinen Versatz statt der obersten Stufe der Beläge", () => { // Ihr einziger Nachbar ist die Fahrbahn 1 cm darunter, und die hat keinen Versatz. Die oberste // Stufe zöge sie beim streifenden Blick am weitesten nach vorn, ohne dass es etwas nützte. expect(POLYGON_OFFSET_ORDER).not.toContain("markings"); expect(POLYGON_OFFSETS.markings).toEqual({ polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -2 }); // Eine Stufe wie der unterste Belag, nicht die oberste: Ihr Nachbar ist die Fahrbahn ohne Versatz. expect(POLYGON_OFFSETS.markings.polygonOffsetUnits) .toBe(POLYGON_OFFSETS[POLYGON_OFFSET_ORDER[1]].polygonOffsetUnits); // Die Fahrbahn liegt 15 cm tiefer und berührt keinen Belag — sie braucht keinen Versatz. // Der Bordstein dagegen schon: Er grenzt an den Gehweg und verlor ohne Faktor beim flachen // Blick gegen ihn (s. Test "nimmt den Bordstein in den Versatz auf"). expect(POLYGON_OFFSETS.carriageway).toBeUndefined(); expect(POLYGON_OFFSETS.curb).toBeDefined(); }); }); // Befund der Gesamtprüfung: loadSet() lud Diffuse-, Normal- und ARM-Karte in einem einzigen // Promise.all und fiel auf den einfarbigen Ersatz zurück, sobald eine davon fehlte. Für den Gehweg // liefert modular_sidewalk_curb_kit nur `sidewalk_Diffuse.jpg` (der Entwurf sagt das selbst, // Abschnitt "Die beiden Kits") — 0,298 km² rendern deshalb einfarbig, und die committete Kachel // lag ungenutzt im Repository. Die Tests laufen ohne echte Dateien: loadSet() bekommt seinen Loader // von außen, hier ein Attrappenloader, der je nach Dateiname auflöst oder abweist. describe("Texturen laden: Diffuse ist Pflicht, Normal und ARM sind freiwillig", () => { /** Loader-Attrappe: löst für jede Kachel auf, deren Kürzel in `vorhanden` steht. */ const fakeLoader = (vorhanden) => ({ loadAsync: (url) => { // Über die Endung vergleichen, nicht über den letzten Unterstrich: "nor_gl" trägt selbst einen. const teil = vorhanden.find((t) => url.endsWith(`_${t}.jpg`)); return teil ? Promise.resolve({ name: teil }) : Promise.reject(new Error(`404 ${url}`)); }, }); afterEach(() => { vi.restoreAllMocks(); }); it("nimmt eine Diffuse-Karte ohne Normal- und ARM-Karte an, statt einfarbig zu werden", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const material = await loadSet(fakeLoader(["Diffuse"]), "sidewalk", 0xd8d2c4); expect(material.map).toMatchObject({ name: "Diffuse" }); expect(material.normalMap).toBeNull(); expect(material.aoMap).toBeNull(); expect(material.roughnessMap).toBeNull(); // Ohne ARM-Karte kommt die Rauheit aus dem Material selbst, nicht aus der Textur. expect(material.roughness).toBe(0.9); expect(warn).not.toHaveBeenCalled(); }); it("nimmt auch eine fehlende ARM-Karte allein hin, ohne die Normal-Map mitzureißen", async () => { const material = await loadSet(fakeLoader(["Diffuse", "nor_gl"]), "sidewalk", 0xd8d2c4); expect(material.normalMap).toMatchObject({ name: "nor_gl" }); expect(material.roughnessMap).toBeNull(); }); it("hängt den vollen Satz weiterhin vollständig ein", async () => { const material = await loadSet(fakeLoader(["Diffuse", "nor_gl", "arm"]), "asphalt_02", 0x505050, { maxAnisotropy: 8 }); expect(material.map).toMatchObject({ name: "Diffuse", anisotropy: 8 }); expect(material.normalMap).toMatchObject({ name: "nor_gl", anisotropy: 8 }); expect(material.aoMap).toMatchObject({ name: "arm" }); expect(material.roughnessMap).toBe(material.aoMap); }); it("fällt nur dann auf den einfarbigen Ersatz zurück, wenn die Diffuse-Karte fehlt", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const material = await loadSet(fakeLoader(["nor_gl", "arm"]), "fehlt", 0x123456); expect(material.map).toBeNull(); expect(material.color.getHex()).toBe(0x123456); expect(warn).toHaveBeenCalledTimes(1); }); });