From 3f4fa7f1ed88bda8067ca912b43590fa77abffdf Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Fri, 18 Sep 2026 06:16:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(world):=20Gehweg=20l=C3=A4dt=20seine=20Diff?= =?UTF-8?q?use-Textur=20auch=20ohne=20Normal-=20und=20ARM-Karte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadSet() lud Diffuse-, Normal- und ARM-Karte in einem gemeinsamen Promise.all und fiel schon beim Fehlen einer einzigen Datei auf den einfarbigen Ersatz zurück. modular_sidewalk_curb_kit liefert für den Gehweg nur sidewalk_Diffuse.jpg (keine Normal-Map, und die metallicRoughness-Karte ist laut Entwurf eine 605-Byte-Einfarbfläche, die bewusst nicht übernommen wird) — Folge: 0,298 km² rendern einfarbig #d8d2c4, bei jedem Laden "Textur sidewalk fehlt", die committete Kachel blieb ungenutzt. Nur die Diffuse-Karte ist jetzt Pflicht; Normal- und ARM-Karte werden einzeln geladen und fallen bei einem Fehler je für sich auf null zurück, ohne die anderen beiden mitzureißen. Ohne ARM-Karte bleibt roughness auf 0.9 (derselbe Wert wie der bisherige einfarbige Ersatz). loadSet() ist jetzt exportiert, damit tests/materials.test.js sie mit einem Attrappenloader ohne echte Dateien prüfen kann: Diffuse allein, Diffuse+Normal ohne ARM, der volle Satz, und weiterhin der einfarbige Ersatz, wenn die Diffuse-Karte selbst fehlt. --- src/world/materials.js | 34 ++++++++++++++++++----- tests/materials.test.js | 60 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/world/materials.js b/src/world/materials.js index aa10bfd..19d2c1a 100644 --- a/src/world/materials.js +++ b/src/world/materials.js @@ -67,17 +67,36 @@ export function waterNormalData(size = 128) { return data; } -async function loadSet(loader, slug, fallback, { tint, maxAnisotropy } = {}) { - let map, normalMap, arm; +/** + * Ein Materialsatz aus den Kacheln von `./textures/_*.jpg`. + * + * Nur die Diffuse-Karte ist Pflicht; Normal- und ARM-Karte sind freiwillig. Grund: Nicht jede + * Quelle liefert alle drei. Der Gehweg aus modular_sidewalk_curb_kit hat nur `sidewalk_Diffuse.jpg` + * — das Kit bringt gar keine Normal-Map dafür, und `sidewalk_metallicRoughness.png` ist eine 605 + * Byte große Einfarbfläche, die der Entwurf bewusst nicht übernimmt (Abschnitt "Die beiden Kits"). + * Solange alle drei Pflicht waren, fiel der Gehweg deshalb auf den einfarbigen Ersatz zurück: 0,298 + * km² in `#d8d2c4`, bei jedem Laden die Warnung "Textur sidewalk fehlt", und die committete + * Diffuse-Kachel lag ungenutzt im Repository. + * + * Ohne ARM-Karte bleibt die Rauheit auf demselben Wert wie beim einfarbigen Ersatz (0,9): matt, + * wie eine Betonfläche. Ohne Normal-Map ist die Fläche flach — das ist der ehrliche Zustand der + * Quelle und immer noch näher an ihr als ein Einfarbton. + */ +export async function loadSet(loader, slug, fallback, { tint, maxAnisotropy } = {}) { + let map; try { - [map, normalMap, arm] = await Promise.all( - ["Diffuse", "nor_gl", "arm"].map((m) => loader.loadAsync(`./textures/${slug}_${m}.jpg`)), - ); + map = await loader.loadAsync(`./textures/${slug}_Diffuse.jpg`); } catch (err) { console.warn(`Textur ${slug} fehlt, einfarbiger Ersatz:`, err); return new MeshStandardMaterial({ color: fallback, roughness: 0.9 }); } + // Die beiden Beiwerke einzeln und je für sich: eine fehlende Datei nimmt nur ihre eigene + // Wirkung weg, nicht die der anderen und schon gar nicht die der Diffuse-Karte. + const [normalMap, arm] = await Promise.all( + ["nor_gl", "arm"].map((m) => loader.loadAsync(`./textures/${slug}_${m}.jpg`).then((t) => t, () => null)), + ); for (const t of [map, normalMap, arm]) { + if (!t) continue; t.wrapS = t.wrapT = RepeatWrapping; // Ruling 58: Straßen laufen flach in die Tiefe — dort flimmert Standard- // filterung zuerst. createMaterials() kennt den Renderer nicht selbst, daher @@ -89,7 +108,10 @@ async function loadSet(loader, slug, fallback, { tint, maxAnisotropy } = {}) { // (Ruling 57): metalness steht fest auf 0, three multipliziert texelMetalness nur // noch dazu (metalnessmap_fragment.glsl.js) und könnte das Ergebnis nie von 0 // wegbewegen — reine Kosten (Texturbindung, Shader-Permutation) ohne Wirkung. - const material = new MeshStandardMaterial({ map, normalMap, aoMap: arm, roughnessMap: arm, metalness: 0 }); + const material = new MeshStandardMaterial({ + map, normalMap, aoMap: arm, roughnessMap: arm, metalness: 0, + ...(arm ? {} : { roughness: 0.9 }), + }); // Ruling 55/56: nur der geladene Erfolgsfall bekommt die Tönung — der einfarbige // Ersatz oben hat schon seinen eigenen, passenden Grundton. if (tint) material.color.set(tint); diff --git a/tests/materials.test.js b/tests/materials.test.js index 632fe85..f5ea8e3 100644 --- a/tests/materials.test.js +++ b/tests/materials.test.js @@ -1,6 +1,6 @@ -import { describe, expect, it } from "vitest"; -import { applyPolygonOffsets, MATERIAL_KEYS, POLYGON_OFFSETS, POLYGON_OFFSET_ORDER, SURFACE_COLORS, TEXTURES, - waterNormalData } from "../src/world/materials.js"; +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", () => { @@ -127,3 +127,57 @@ describe("Schichten aus Teil 2", () => { expect(POLYGON_OFFSETS.curb).toBeUndefined(); }); }); + +// 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); + }); +});