import { isAbsolute } from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { Document } from "@gltf-transform/core"; import sharp from "sharp"; import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs"; import { FAR_TEXTURE_SIZE, NEAR_MATERIALS, NEAR_TEXTURE_SIZE, SIMPLIFY_RATIOS, optimizePlace, placeTarget } from "../tools/assets/places.mjs"; /** 1100 px große Streifen. seed macht die Bilder verschieden, sonst legt dedup() sie zusammen. */ function stripes(seed) { const size = 1100; const data = Buffer.alloc(size * size * 3); for (let i = 0; i < data.length; i++) data[i] = (Math.floor(i / 3) % size) * seed & 0xff; return sharp(data, { raw: { width: size, height: size, channels: 3 } }).png().toBuffer(); } /** n × n Quadrate in der Ebene (2·n² Dreiecke), indiziert. */ function grid(document, n) { const positions = []; const indices = []; for (let z = 0; z <= n; z++) for (let x = 0; x <= n; x++) positions.push(x, 0, z); for (let z = 0; z < n; z++) { for (let x = 0; x < n; x++) { const a = z * (n + 1) + x, b = a + 1, c = a + n + 1, d = c + 1; indices.push(a, c, b, b, c, d); } } return document.createPrimitive() .setAttribute("POSITION", document.createAccessor().setType("VEC3").setArray(new Float32Array(positions))) .setIndices(document.createAccessor().setType("SCALAR").setArray(new Uint32Array(indices))); } /** Ein Container (Nahfläche mit Bump) und ein Grasstück (Pflanze mit Normal-Map), je ein Knoten. */ async function sampleDocument() { const document = new Document(); document.createBuffer(); const bumpExtension = document.createExtension(EXTMaterialsBump); const [near, nearBump, far, farNormal] = await Promise.all([1, 3, 5, 7].map(stripes)); const texture = (name, image) => document.createTexture(name).setImage(image).setMimeType("image/png"); const container = document.createMaterial("cont_a_side").setBaseColorTexture(texture("near", near)); container.setExtension(EXT_MATERIALS_BUMP, bumpExtension.createBump().setBumpFactor(2.2).setBumpTexture(texture("nearBump", nearBump))); const grass = document.createMaterial("grass_medium_01") .setBaseColorTexture(texture("far", far)) .setNormalTexture(texture("farNormal", farNormal)); const scene = document.createScene(); scene.addChild(document.createNode("container").setMesh(document.createMesh().addPrimitive(grid(document, 1).setMaterial(container)))); scene.addChild(document.createNode("grass").setMesh(document.createMesh().addPrimitive(grid(document, 40).setMaterial(grass)))); return { document, container, grass }; } const trianglesOf = (document, name) => document.getRoot().listMeshes() .flatMap((mesh) => mesh.listPrimitives()) .filter((primitive) => primitive.getMaterial().getName() === name) .reduce((sum, primitive) => sum + primitive.getIndices().getCount() / 3, 0); describe("optimizePlace", () => { let sample; beforeAll(async () => { sample = await sampleDocument(); await optimizePlace(sample.document); }, 60000); // Bruch: Nahflächen so unscharf wie Pflanzen, Größen vertauscht, oder die Bump-Map zählt nicht // zu ihrem Material (sie hängt über die Erweiterung dran, nicht direkt am Material). it("verkleinert Texturen je nach Material auf 1024 oder 512 px und wandelt sie in WebP", () => { const { document, container, grass } = sample; const bumpTexture = container.getExtension(EXT_MATERIALS_BUMP).getBumpTexture(); expect(container.getBaseColorTexture().getSize()).toEqual([NEAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE]); expect(bumpTexture.getSize()).toEqual([NEAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE]); expect(grass.getBaseColorTexture().getSize()).toEqual([FAR_TEXTURE_SIZE, FAR_TEXTURE_SIZE]); expect(grass.getNormalTexture().getSize()).toEqual([FAR_TEXTURE_SIZE, FAR_TEXTURE_SIZE]); for (const texture of document.getRoot().listTextures()) expect(texture.getMimeType()).toBe("image/webp"); }); // Bruch: die Vereinfachung trifft Container und Schuppen (Löcher in Wänden) oder lässt Pflanzen // unberührt (die Köcherbäume blieben bei 2,2 Mio. Dreiecken). it("vereinfacht nur Pflanzen und Kleinteile und behält die Materialnamen", () => { const { document } = sample; expect(trianglesOf(document, "cont_a_side")).toBe(2); expect(trianglesOf(document, "grass_medium_01")).toBeGreaterThan(0); expect(trianglesOf(document, "grass_medium_01")).toBeLessThanOrEqual(3200 * 0.5); }); // Bruch: das Wellblech geht verloren, die Geometrie bleibt unkomprimiert, oder es entstehen // GPU-Instanzen, deren Matrizen die Kollision nicht liest. it("erhält die Bump-Map, komprimiert mit meshopt und legt keine Instanzen an", () => { const { document, container } = sample; const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName); expect(container.getExtension(EXT_MATERIALS_BUMP).getBumpFactor()).toBe(2.2); expect(used).toEqual(expect.arrayContaining([EXT_MATERIALS_BUMP, "EXT_meshopt_compression", "EXT_texture_webp"])); expect(used).not.toContain("EXT_mesh_gpu_instancing"); }); }); describe("placeTarget", () => { // Bruch: ein Pfad relativ zum Arbeitsverzeichnis statt zum Projekt — bei einem Aufruf aus einem // anderen Ordner (z. B. aus einem Skript) landete die Datei am falschen Ort oder mkdir schlug fehl. it("liefert den absoluten Zielpfad unter public/models/places/", () => { const target = placeTarget("hood-yard"); expect(isAbsolute(target)).toBe(true); expect(target.endsWith("public/models/places/hood-yard.glb")).toBe(true); }); // Bruch: eine ID mit Pfadtrennzeichen oder Großbuchstaben schreibt außerhalb von // public/models/places/ oder unter einem Namen, den PLACES so nie referenziert. it("lehnt IDs mit Pfadtrennzeichen oder Großbuchstaben ab", () => { expect(() => placeTarget("../x")).toThrow(); expect(() => placeTarget("Hood Yard")).toThrow(); }); }); describe("Materiallisten der Tribüne", () => { // Bruch: Beton, Estrich, Verkleidung, Stahl oder Sitze fielen auf 512 px, obwohl der Spieler direkt // davorsteht — oder Dach und Kabinenboden, die man nur von weitem sieht, behielten 768 px. it("führt Beton, Estrich, Verkleidung, Stahl und Sitze als Nahflächen, Dach und Kabinenboden nicht", () => { for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale"]) expect(NEAR_MATERIALS.has(name)).toBe(true); for (const name of ["Trapezblech", "Riffelblech"]) expect(NEAR_MATERIALS.has(name)).toBe(false); }); // Bruch: Kabel, Kästen und Leuchten blieben bei 64 000 Dreiecken, oder die Vereinfachung träfe Körper, // Dach, Geländer oder Sitze (Löcher in Wänden, schiefe Stufenkanten). it("vereinfacht nur die Kleinteile der Tribüne, auf 0,25", () => { for (const name of ["modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp", "industrial_wall_lamp_glass", "power_box_01", "utility_box_01"]) { expect(SIMPLIFY_RATIOS.get(name)).toBe(0.25); } for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "Sitzschale"]) { expect(SIMPLIFY_RATIOS.has(name)).toBe(false); } }); });