feat(assets): TGA-Maske, UV-Spiegelung und Längenprüfung für die Waffen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
456fbe89b9
commit
e97ef14f78
2 files changed
+229
No files matched your search
@@ -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 <Ordner WeaponsPack> -> public/models/weapons/<id>.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));
|
||||
}
|
||||
Reference in new issue
Block a user