// 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 { mkdir, readFile, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Document, NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS, EXTTextureWebP } from "@gltf-transform/extensions"; import { getBounds, meshopt, weld } from "@gltf-transform/functions"; import { MeshoptEncoder } from "meshoptimizer"; import sharp from "sharp"; import { Vector3 } from "three"; export const TEXTURE_SIZE = 1024; const QUALITY = 80; // Verlustbehaftetes WebP erzeugt in Normal-Maps sonst Blockmuster (wie in tools/assets/places.mjs). const NORMAL_QUALITY = 92; /** 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)); } async function encodeWebp(image, quality) { const buffer = await image .resize({ width: TEXTURE_SIZE, height: TEXTURE_SIZE, fit: "inside", withoutEnlargement: true }) .webp({ quality }) .toBuffer(); return new Uint8Array(buffer); } /** Baut das Dokument einer Waffe. loadFbx kommt von außen, damit nur main() die Browser-Platzhalter zieht. */ export async function buildWeapon(source, packDir, loadFbx) { const dir = join(packDir, source.dir); const fbx = await loadFbx(join(dir, source.mesh)); const meshes = []; fbx.traverse((object) => { if (object.isMesh) meshes.push(object); }); if (meshes.length === 0) throw new Error(`${source.id}: kein Mesh in ${source.mesh}`); // Der Entwurf misst "genau ein Mesh mit einem Material" je Datei; hier wird das geprüft statt nur vorausgesetzt. if (meshes.length > 1) { throw new Error(`${source.id}: ${meshes.length} Meshes in ${source.mesh}, erwartet genau eins`); } const mesh = meshes[0]; const { positions, normals, uvs } = meshArrays(mesh.geometry, mesh.quaternion); const length = checkLength(source.id, positions, source.length); const document = new Document(); const buffer = document.createBuffer(); const accessor = (array, type) => document.createAccessor().setArray(array).setType(type).setBuffer(buffer); const mask = readTga(await readFile(join(dir, source.mask))); const [baseColor, normalMap, metallicRoughness] = await Promise.all([ encodeWebp(sharp(join(dir, source.base)).removeAlpha(), QUALITY), encodeWebp(sharp(join(dir, source.normal)), NORMAL_QUALITY), encodeWebp(sharp(Buffer.from(maskToMetallicRoughness(mask)), { raw: { width: mask.width, height: mask.height, channels: 3 } }), QUALITY), ]); const texture = (name, image) => document.createTexture(name).setImage(image).setMimeType("image/webp"); const material = document.createMaterial(source.id) .setBaseColorTexture(texture(`${source.id}_baseColor`, baseColor)) .setNormalTexture(texture(`${source.id}_normal`, normalMap)) .setMetallicRoughnessTexture(texture(`${source.id}_metallicRoughness`, metallicRoughness)) .setMetallicFactor(1) .setRoughnessFactor(1); const primitive = document.createPrimitive() .setAttribute("POSITION", accessor(positions, "VEC3")) .setAttribute("NORMAL", accessor(normals, "VEC3")) .setAttribute("TEXCOORD_0", accessor(uvs, "VEC2")) .setMaterial(material); const node = document.createNode(source.id).setMesh(document.createMesh(source.id).addPrimitive(primitive)); document.getRoot().setDefaultScene(document.createScene(source.id).addChild(node)); document.createExtension(EXTTextureWebP).setRequired(true); await MeshoptEncoder.ready; await document.transform(weld(), meshopt({ encoder: MeshoptEncoder, level: "medium" })); return { document, length, vertices: positions.length / 3 }; } async function main() { const [packDir] = process.argv.slice(2); if (!packDir) { console.error("Aufruf: node tools/assets/weapons.mjs "); process.exit(1); } const { loadFbx } = await import("./fbx.mjs"); const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.encoder": MeshoptEncoder }); for (const source of WEAPON_SOURCES) { const { document, length, vertices } = await buildWeapon(source, packDir, loadFbx); const target = weaponTarget(source.id); await mkdir(dirname(target), { recursive: true }); await io.write(target, document); const { min, max } = getBounds(document.getRoot().listScenes()[0]); console.log(`${source.id}.glb ${((await stat(target)).size / 1e6).toFixed(2)} MB, ${length.toFixed(2)} m lang,`, `${vertices} Eckpunkte, Maße ${min.map((value, axis) => (max[axis] - value).toFixed(2)).join(" × ")} m`); } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();