diff --git a/tests/weapons.test.js b/tests/weapons.test.js new file mode 100644 index 0000000..67caff1 --- /dev/null +++ b/tests/weapons.test.js @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { BufferAttribute, BufferGeometry, Quaternion, Vector3 } from "three"; +import { checkLength, longestExtent, maskToMetallicRoughness, meshArrays, readTga, + TEXTURE_SIZE, weaponTarget, WEAPON_SOURCES } from "../tools/assets/weapons.mjs"; + +/** Baut eine TGA-Datei wie die Masken im Pack: 18 Byte Kopf, danach BGRA je Pixel in Dateireihenfolge. */ +function tgaFile({ width, height, pixels, type = 2, depth = 32, originTop = false }) { + const bytes = new Uint8Array(18 + pixels.length * 4); + bytes[2] = type; + bytes[12] = width; + bytes[14] = height; + bytes[16] = depth; + bytes[17] = 8 | (originTop ? 0x20 : 0); + pixels.forEach(([r, g, b, a], i) => bytes.set([b, g, r, a], 18 + i * 4)); + return bytes; +} + +describe("readTga", () => { + // Dateireihenfolge: rot, grün, blau, weiß + const pixels = [[255, 0, 0, 10], [0, 255, 0, 20], [0, 0, 255, 30], [255, 255, 255, 40]]; + + // Bruch: Ursprung unten links übersehen — die Maske läge kopfüber auf der Waffe. + it("dreht die Zeilen um, wenn der Ursprung unten links liegt", () => { + const { width, height, data } = readTga(tgaFile({ width: 2, height: 2, pixels })); + expect([width, height]).toEqual([2, 2]); + expect([...data]).toEqual([0, 0, 255, 30, 255, 255, 255, 40, 255, 0, 0, 10, 0, 255, 0, 20]); + }); + + it("übernimmt die Reihenfolge, wenn der Ursprung oben liegt", () => { + const { data } = readTga(tgaFile({ width: 2, height: 2, pixels, originTop: true })); + expect([...data]).toEqual([255, 0, 0, 10, 0, 255, 0, 20, 0, 0, 255, 30, 255, 255, 255, 40]); + }); + + // Bruch: stillschweigend falsch gelesene Datei statt einer verständlichen Meldung. + it("lehnt RLE, 24 Bit und Farbtabellen ab", () => { + expect(() => readTga(tgaFile({ width: 2, height: 2, pixels, type: 10 }))).toThrow(/Typ 10/); + expect(() => readTga(tgaFile({ width: 2, height: 2, pixels, depth: 24 }))).toThrow(/24 Bit/); + const withColorMap = tgaFile({ width: 2, height: 2, pixels }); + withColorMap[1] = 1; + expect(() => readTga(withColorMap)).toThrow(/Farbtabelle 1/); + }); + + // Bruch: Dateigröße nicht geprüft — die Maske würde stillschweigend unvollständig gelesen. + it("meldet eine zu kurze Datei", () => { + expect(() => readTga(tgaFile({ width: 4, height: 4, pixels }))).toThrow(/kürzer/); + }); +}); + +describe("maskToMetallicRoughness", () => { + // Bruch: Kanäle vertauscht — Holz glänzte metallisch, Metall wirkte stumpf. + it("legt den Grauwert nach G (Rauheit) und Alpha nach B (Metall)", () => { + const mask = { width: 2, height: 1, data: Uint8Array.from([217, 217, 217, 56, 242, 242, 242, 179]) }; + expect([...maskToMetallicRoughness(mask)]).toEqual([255, 217, 56, 255, 242, 179]); + }); +}); + +describe("meshArrays", () => { + function geometry() { + const result = new BufferGeometry(); + result.setAttribute("position", new BufferAttribute(Float32Array.from([1, 0, 0, 0, 1, 0, 0, 0, 1]), 3)); + result.setAttribute("normal", new BufferAttribute(Float32Array.from([0, 0, 1, 0, 0, 1, 0, 0, 1]), 3)); + result.setAttribute("uv", new BufferAttribute(Float32Array.from([0, 0, 1, 0.25, 0.5, 1]), 2)); + return result; + } + + // Bruch: v nicht gespiegelt — jede Waffentextur läge senkrecht verkehrt. + it("spiegelt v, weil FBX den UV-Ursprung unten und glTF ihn oben hat", () => { + expect([...meshArrays(geometry(), new Quaternion()).uvs]).toEqual([0, 1, 1, 0.75, 0.5, 0]); + }); + + // Bruch: Knotendrehung übergangen — die L96 zeigte mit dem Lauf nach hinten. + it("dreht Eckpunkte und Normalen mit der Knotendrehung", () => { + const halfTurn = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI); + const { positions, normals } = meshArrays(geometry(), halfTurn); + expect(positions[0]).toBeCloseTo(-1, 6); + expect(positions[8]).toBeCloseTo(-1, 6); + expect(normals[2]).toBeCloseTo(-1, 6); + }); +}); + +describe("checkLength", () => { + const pistol = WEAPON_SOURCES.find((source) => source.id === "pistol"); + const along = (length) => Float32Array.from([0, 0, 0, length, 0.02, 0.01]); + + it("misst die längste Ausdehnung", () => { + expect(longestExtent(along(0.189))).toBeCloseTo(0.189, 6); + }); + + it("lässt eine 0,19 m lange Pistole durch", () => { + expect(checkLength("pistol", along(0.189), pistol.length)).toBeCloseTo(0.189, 6); + }); + + // Bruch: Knotenmaßstab 100 der Pistole mitgerechnet — eine 19 m lange Waffe in der Hand. + it("bricht ab, wenn die Einheit nicht stimmt", () => { + expect(() => checkLength("pistol", along(18.9), pistol.length)).toThrow(/falsche Einheit/); + }); +}); + +describe("WEAPON_SOURCES", () => { + it("nennt die fünf Waffen und die AK47 nur als Option 2", () => { + expect(WEAPON_SOURCES.map((source) => source.id)).toEqual(["pistol", "ak47", "m4", "shotgun870", "l96"]); + expect(WEAPON_SOURCES.find((source) => source.id === "ak47").dir).toBe("AK47_Option2(Same Socketing)"); + expect(TEXTURE_SIZE).toBe(1024); + }); + + // Bruch: ID-Validierung übergangen — eine manipulierte ID könnte aus public/models/weapons/ herausführen. + it("legt jede Waffe unter public/models/weapons ab", () => { + expect(weaponTarget("pistol").replace(/\\/g, "/")).toMatch(/public\/models\/weapons\/pistol\.glb$/); + expect(() => weaponTarget("../ual")).toThrow(/Ungültige Waffen-ID/); + }); +}); diff --git a/tools/assets/weapons.mjs b/tools/assets/weapons.mjs new file mode 100644 index 0000000..fb399e6 --- /dev/null +++ b/tools/assets/weapons.mjs @@ -0,0 +1,118 @@ +// Baut die Waffen aus dem ASIS Weapons Pack zu GLB um (docs/superpowers/specs/2026-09-17-animationen-design.md, +// "Umbau der Waffen"): +// +// node tools/assets/weapons.mjs -> public/models/weapons/.glb +// +// Das Pack bleibt außerhalb des Repos. Die Geometrie kommt über three.js' FBXLoader, die Materialien werden neu +// gebaut: T_ ist die Grundfarbe, N_ die Normalmap (schon im OpenGL-Format von glTF), M_ eine TGA-Maske mit +// Grau = Rauheit und Alpha = Metall (am AK47-Holz gemessen, s. Entwurf). +import { fileURLToPath } from "node:url"; +import { Vector3 } from "three"; + +export const TEXTURE_SIZE = 1024; + +/** Die fünf Waffen des Packs. length ist die erwartete Länge in Metern und fängt eine falsche Einheit ab. */ +export const WEAPON_SOURCES = [ + { id: "pistol", dir: "Pistol", mesh: "SM_Pistol.fbx", base: "T_Pistol.png", normal: "N_Pistol.png", mask: "M_Pistol.tga", length: [0.15, 0.25] }, + { id: "ak47", dir: "AK47_Option2(Same Socketing)", mesh: "SM_AK47.fbx", base: "T_AK47.png", normal: "N_AK47.png", mask: "M_AK47.tga", length: [0.7, 1.3] }, + { id: "m4", dir: "M4 Carbine", mesh: "SM_M4_Carbine.fbx", base: "T_M4_Carbine.png", normal: "N_M4_Carbine.png", mask: "M_M4_Carbine.tga", length: [0.7, 1.3] }, + { id: "shotgun870", dir: "870 Shotgun", mesh: "SM_870_Shotgun.fbx", base: "T_870_Shotgun.png", normal: "N_870_Shotgun.png", mask: "M_870_Shotgun.tga", length: [0.7, 1.3] }, + { id: "l96", dir: "L96 Sniper Rifle", mesh: "SM_L96_Sniper_Rifle.fbx", base: "T_L96_Sniper_Rifle.png", normal: "N_L96_Sniper_Rifle.png", mask: "M_L96_Sniper_Rifle.tga", length: [0.7, 1.3] }, +]; + +/** + * Liest ein unkomprimiertes 32-Bit-TGA (Typ 2) und gibt RGBA mit der obersten Bildzeile zuerst zurück. Mehr kann + * der Leser nicht, und mehr steckt auch nicht im Pack: alle fünf Masken sind Typ 2, 32 Bit, Ursprung unten links. + */ +export function readTga(bytes) { + const idLength = bytes[0]; + const colorMapType = bytes[1]; + const imageType = bytes[2]; + const width = bytes[12] | (bytes[13] << 8); + const height = bytes[14] | (bytes[15] << 8); + const depth = bytes[16]; + const flags = bytes[17]; + if (colorMapType !== 0 || imageType !== 2 || depth !== 32) { + throw new Error(`TGA: nur unkomprimiert mit 32 Bit lesbar (Typ ${imageType}, ${depth} Bit, Farbtabelle ${colorMapType})`); + } + if (flags & 0x10) throw new Error("TGA: Zeilen von rechts nach links werden nicht unterstützt"); + const start = 18 + idLength; + if (bytes.length < start + width * height * 4) throw new Error("TGA: Datei kürzer als ihre Bildgröße"); + const originTop = (flags & 0x20) !== 0; + const data = new Uint8Array(width * height * 4); + for (let row = 0; row < height; row++) { + const sourceRow = originTop ? row : height - 1 - row; + for (let x = 0; x < width; x++) { + const from = start + (sourceRow * width + x) * 4; + const to = (row * width + x) * 4; + data[to] = bytes[from + 2]; // TGA speichert BGRA + data[to + 1] = bytes[from + 1]; + data[to + 2] = bytes[from]; + data[to + 3] = bytes[from + 3]; + } + } + return { width, height, data }; +} + +/** Maske -> metallicRoughness von glTF: Rauheit nach G, Metall nach B. R bleibt ungenutzt. */ +export function maskToMetallicRoughness({ width, height, data }) { + const rgb = new Uint8Array(width * height * 3); + for (let i = 0; i < width * height; i++) { + rgb[i * 3] = 255; + rgb[i * 3 + 1] = data[i * 4]; + rgb[i * 3 + 2] = data[i * 4 + 3]; + } + return rgb; +} + +/** + * Eckpunkte, Normalen und UVs eines FBX-Meshes für glTF. Die Knotendrehung wird eingerechnet (die L96 liegt im + * Pack um 180° gedreht), der Knotenmaßstab nicht: Die Eckpunkte liegen schon in Metern, die Pistole trägt + * zusätzlich den Maßstab 100. Die v-Achse wird gespiegelt, weil FBX den UV-Ursprung unten links hat, glTF oben links. + */ +export function meshArrays(geometry, quaternion) { + const source = geometry.index ? geometry.toNonIndexed() : geometry; + const position = source.getAttribute("position"); + const normal = source.getAttribute("normal"); + const uv = source.getAttribute("uv"); + if (!position || !normal || !uv) throw new Error("Mesh ohne position, normal oder uv"); + const positions = new Float32Array(position.count * 3); + const normals = new Float32Array(position.count * 3); + const uvs = new Float32Array(position.count * 2); + const point = new Vector3(); + for (let i = 0; i < position.count; i++) { + point.fromBufferAttribute(position, i).applyQuaternion(quaternion).toArray(positions, i * 3); + point.fromBufferAttribute(normal, i).applyQuaternion(quaternion).normalize().toArray(normals, i * 3); + uvs[i * 2] = uv.getX(i); + uvs[i * 2 + 1] = 1 - uv.getY(i); + } + return { positions, normals, uvs }; +} + +/** Längste Ausdehnung in Metern. */ +export function longestExtent(positions) { + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + for (let i = 0; i < positions.length; i += 3) { + for (let axis = 0; axis < 3; axis++) { + min[axis] = Math.min(min[axis], positions[i + axis]); + max[axis] = Math.max(max[axis], positions[i + axis]); + } + } + return Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]); +} + +/** Bricht ab, wenn die Waffe nicht die erwartete Länge hat — dann stimmt die Einheit nicht. */ +export function checkLength(id, positions, [min, max]) { + const length = longestExtent(positions); + if (!(length >= min && length <= max)) { + throw new Error(`${id}: ${length.toFixed(3)} m lang, erwartet ${min}–${max} m — falsche Einheit?`); + } + return length; +} + +/** Zielpfad einer Waffe, unabhängig vom Arbeitsverzeichnis (wie placeTarget in tools/assets/places.mjs). */ +export function weaponTarget(id) { + if (!/^[a-z0-9]+$/.test(id)) throw new Error(`Ungültige Waffen-ID "${id}": nur Kleinbuchstaben und Ziffern.`); + return fileURLToPath(new URL(`../../public/models/weapons/${id}.glb`, import.meta.url)); +}