arx-level-generator
Version:
A tool for creating Arx Fatalis maps
243 lines • 11.3 kB
JavaScript
import fs from 'node:fs/promises';
import path from 'node:path';
import { FTL } from 'arx-convert';
import { ArxFaceType } from 'arx-convert/types';
import objectHash from 'object-hash';
import { MathUtils, MeshBasicMaterial, Vector2 } from 'three';
import { Polygons } from './Polygons.js';
import { Texture } from './Texture.js';
import { Vector3 } from './Vector3.js';
import { repeat } from './faux-ramda.js';
import { arrayPadRight, fileExists, roundToNDecimals } from './helpers.js';
import { getCacheStats, hashingAlgorithm, saveHashOf } from './services/cache.js';
import { getNonIndexedVertices } from './tools/mesh/getVertices.js';
/**
* a, b and c are normal vectors
* @see https://stackoverflow.com/a/35205710/1806628
*/
const getFaceNormal = (a, b, c) => {
return new Vector3().crossVectors(b.clone().sub(a), c.clone().sub(a)).normalize();
};
export class EntityModel {
static targetPath = 'game/graph/obj3d/interactive';
filename;
sourcePath;
originIdx;
mesh;
actionPoints;
constructor(props) {
this.filename = props.filename;
this.sourcePath = props.sourcePath ?? './';
this.actionPoints = props.actionPoints ?? [];
this.originIdx = 0;
}
/**
* props.originIdx is optional, its default value is 0
*
* use `@tools/mesh/getLowestPolygonIdx` to get the index of the lowest point of a mesh
*/
static fromThreeJsObj(threeJsObj, props) {
const model = new EntityModel(props);
model.mesh = threeJsObj;
model.originIdx = props.originIdx ?? 0;
return model;
}
/**
* props.originIdx is optional, its default value is 0
*
* use `@tools/mesh/getLowestPolygonIdx` to get the index of the lowest point of a mesh
*/
static fromPolygons(polygons, props) {
const model = new EntityModel(props);
model.mesh = polygons;
model.originIdx = props.originIdx ?? 0;
return model;
}
clone() {
const copy = new EntityModel({
filename: this.filename,
sourcePath: this.sourcePath,
});
copy.mesh = this.mesh;
return copy;
}
/**
* targetName is the folder relative to EntityModel.targetPath without the filename,
* for example `items/quest_item/mirror`
*/
async exportSourceAndTarget(settings, targetName, exportJsonFiles = false, prettify = false) {
const files = {};
const { name: entityName } = path.parse(targetName);
const binaryTarget = path.resolve(settings.outputDir, EntityModel.targetPath, targetName, `${entityName}.ftl`);
const jsonTarget = `${binaryTarget}.json`;
if (typeof this.mesh === 'undefined') {
const binarySource = path.resolve(settings.assetsDir, this.sourcePath, this.filename);
files[binaryTarget] = binarySource;
}
else {
const cachedBinary = await getCacheStats(path.join(EntityModel.targetPath, targetName, `${entityName}.ftl`), settings);
const ftlData = this.generateFtl(entityName);
const hashOfFtlData = objectHash(ftlData, { algorithm: hashingAlgorithm });
let binaryChanged = false;
if (hashOfFtlData !== cachedBinary.hash || !cachedBinary.exists) {
const ftl = FTL.save(ftlData);
await fs.writeFile(cachedBinary.filename, ftl);
await saveHashOf(cachedBinary.filename, hashOfFtlData, settings);
binaryChanged = true;
}
files[binaryTarget] = cachedBinary.filename;
if (exportJsonFiles) {
const cachedJsonTarget = `${cachedBinary.filename}.json`;
const cachedJsonExists = await fileExists(cachedJsonTarget);
if (binaryChanged || !cachedJsonExists) {
const stringifiedFtl = prettify ? JSON.stringify(ftlData, null, 2) : JSON.stringify(ftlData);
await fs.writeFile(cachedJsonTarget, stringifiedFtl);
}
files[jsonTarget] = cachedJsonTarget;
}
}
return files;
}
/**
* this method assumes that this.mesh is defined
*/
generateFtl(entityName) {
const ftlData = {
header: {
origin: this.originIdx,
name: entityName,
},
vertices: [],
faces: [],
textureContainers: [],
groups: [],
actions: this.actionPoints,
selections: [],
};
const vertexPrecision = 5;
const mesh = this.mesh;
if (mesh instanceof Polygons) {
mesh.calculateNormals();
// TODO: rotate +90 degrees on Y axis
const vertices = mesh.flatMap((polygon) => polygon.vertices.slice(0, polygon.isQuad() ? 3 : 4));
const origin = vertices[ftlData.header.origin].clone();
ftlData.vertices = mesh.flatMap((polygon) => {
const vertices = [];
const normals = polygon.normals;
vertices.push({ vector: polygon.vertices[0].clone().sub(origin).toArxVector3(), norm: normals[0] }, { vector: polygon.vertices[1].clone().sub(origin).toArxVector3(), norm: normals[1] }, { vector: polygon.vertices[2].clone().sub(origin).toArxVector3(), norm: normals[2] });
if (polygon.isQuad()) {
vertices.push({ vector: polygon.vertices[2].clone().sub(origin).toArxVector3(), norm: normals[2] }, { vector: polygon.vertices[1].clone().sub(origin).toArxVector3(), norm: normals[1] }, { vector: polygon.vertices[3].clone().sub(origin).toArxVector3(), norm: normals[3] });
}
return vertices;
});
ftlData.textureContainers = mesh.getTextureContainers();
let vertexIdxCntr = 0;
ftlData.faces = mesh.flatMap((polygon) => {
const faces = [];
const normals = polygon.normals;
const faceNormal = getFaceNormal(normals[0], normals[1], normals[2]);
const textureIdx = ftlData.textureContainers.findIndex(({ filename }) => polygon.texture?.equals(filename));
faces.push({
faceType: ArxFaceType.Flat,
vertexIdx: [vertexIdxCntr, vertexIdxCntr + 1, vertexIdxCntr + 2],
textureIdx,
u: [polygon.vertices[0].uv.x, polygon.vertices[1].uv.x, polygon.vertices[2].uv.x],
v: [polygon.vertices[0].uv.y, polygon.vertices[1].uv.y, polygon.vertices[2].uv.y],
norm: faceNormal.toArxVector3(),
});
vertexIdxCntr += 3;
if (polygon.isQuad()) {
const faceNormal = getFaceNormal(normals[2], normals[1], normals[3]);
faces.push({
faceType: ArxFaceType.Flat,
vertexIdx: [vertexIdxCntr, vertexIdxCntr + 1, vertexIdxCntr + 2],
textureIdx,
u: [polygon.vertices[2].uv.x, polygon.vertices[1].uv.x, polygon.vertices[3].uv.x],
v: [polygon.vertices[2].uv.y, polygon.vertices[1].uv.y, polygon.vertices[3].uv.y],
norm: faceNormal.toArxVector3(),
});
vertexIdxCntr += 3;
}
return faces;
});
}
else {
const { geometry, material } = mesh;
geometry.rotateY(MathUtils.degToRad(90));
const normals = geometry.getAttribute('normal');
const uvs = geometry.getAttribute('uv');
const vertices = [];
const faceIndexes = [];
getNonIndexedVertices(geometry).forEach(({ idx, vector, materialIndex }, i) => {
vertices.push({
vector: new Vector3(roundToNDecimals(vertexPrecision, vector.x), roundToNDecimals(vertexPrecision, vector.y), roundToNDecimals(vertexPrecision, vector.z)),
norm: new Vector3(normals.getX(idx), normals.getY(idx), normals.getZ(idx)),
uv: new Vector2(uvs.getX(idx), uvs.getY(idx)),
textureIdx: materialIndex ?? 0,
});
if (i % 3 === 0) {
faceIndexes.push([-1, -1, i]);
}
else {
faceIndexes[faceIndexes.length - 1][2 - (i % 3)] = i;
}
});
const origin = vertices[ftlData.header.origin].vector.clone();
ftlData.vertices = vertices.map(({ vector, norm }) => ({
vector: vector.clone().sub(origin).toArxVector3(),
norm: norm.toArxVector3(),
}));
ftlData.faces = faceIndexes.map(([aIdx, bIdx, cIdx]) => {
const a = vertices[aIdx];
const b = vertices[bIdx];
const c = vertices[cIdx];
const faceNormal = getFaceNormal(a.norm, b.norm, c.norm);
return {
faceType: ArxFaceType.Flat,
vertexIdx: [aIdx, bIdx, cIdx],
textureIdx: a.textureIdx,
u: [a.uv.x, b.uv.x, c.uv.x],
v: [a.uv.y, b.uv.y, c.uv.y],
norm: faceNormal.toArxVector3(),
};
});
const numberOfGroups = geometry.groups.length === 0 ? 1 : geometry.groups.length;
let textures = [];
if (material instanceof MeshBasicMaterial) {
if (material.map instanceof Texture) {
textures = repeat(material.map, numberOfGroups);
}
else {
console.warn('[warning] EntityModel: Unsupported texture map in material when adding threejs mesh');
}
}
else if (Array.isArray(material)) {
textures = material.map((material) => {
if (material instanceof MeshBasicMaterial) {
if (material.map instanceof Texture) {
return material.map;
}
else {
console.warn('[warning] EntityModel: Unsupported texture map in material when adding threejs mesh');
return undefined;
}
}
else {
console.warn('[warning] EntityModel: Unsupported material found when adding threejs mesh');
return undefined;
}
});
}
else if (typeof material !== 'undefined') {
console.warn('[warning] EntityModel: Unsupported material found when adding threejs mesh');
}
ftlData.textureContainers = arrayPadRight(numberOfGroups, undefined, textures).map((t) => {
return {
filename: t?.filename ?? '<missing material>',
};
});
}
return ftlData;
}
}
//# sourceMappingURL=EntityModel.js.map