206 lines
8.1 KiB
JavaScript
206 lines
8.1 KiB
JavaScript
import { createReadStream } from "node:fs";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { createInterface } from "node:readline";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { projectRoot } from "./lib/recipe.mjs";
|
|
|
|
const cases = [
|
|
{ slug: "grafu-castle", material: "runewaker_material_104" },
|
|
{ slug: "treasure-trove", material: "runewaker_material_232" },
|
|
{ slug: "demon-stronghold", material: "runewaker_material_90" },
|
|
{ slug: "kawaks-tomb", material: "runewaker_material_89" },
|
|
{ slug: "cave-water-dragon", material: "runewaker_material_100" },
|
|
];
|
|
|
|
const outputArgument = process.argv.indexOf("--output");
|
|
const outputFile = outputArgument >= 0 ? path.resolve(process.argv[outputArgument + 1]) : null;
|
|
|
|
function createStats(group) {
|
|
return {
|
|
group,
|
|
triangles: 0,
|
|
surfaceAreaM2: 0,
|
|
horizontalFacingAreaM2: 0,
|
|
wallAreaM2: 0,
|
|
otherAreaM2: 0,
|
|
nearZeroUvAreaM2: 0,
|
|
nearZeroUvAreaTriangles: 0,
|
|
boundsSourceUnits: {
|
|
min: [Infinity, Infinity, Infinity],
|
|
max: [-Infinity, -Infinity, -Infinity],
|
|
},
|
|
};
|
|
}
|
|
|
|
function includePosition(stats, position) {
|
|
for (let axis = 0; axis < 3; axis += 1) {
|
|
stats.boundsSourceUnits.min[axis] = Math.min(stats.boundsSourceUnits.min[axis], position[axis]);
|
|
stats.boundsSourceUnits.max[axis] = Math.max(stats.boundsSourceUnits.max[axis], position[axis]);
|
|
}
|
|
}
|
|
|
|
function addTriangle(stats, vertices, uvs) {
|
|
const [a, b, c] = vertices;
|
|
const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
|
const ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
|
const cross = [
|
|
ab[1] * ac[2] - ab[2] * ac[1],
|
|
ab[2] * ac[0] - ab[0] * ac[2],
|
|
ab[0] * ac[1] - ab[1] * ac[0],
|
|
];
|
|
const sourceArea = Math.hypot(...cross) * 0.5;
|
|
const areaM2 = sourceArea * 0.01;
|
|
const normalY = sourceArea > 0 ? Math.abs(cross[1]) / (sourceArea * 2) : 0;
|
|
const [uvA, uvB, uvC] = uvs;
|
|
const uvArea = Math.abs(
|
|
(uvB[0] - uvA[0]) * (uvC[1] - uvA[1])
|
|
- (uvC[0] - uvA[0]) * (uvB[1] - uvA[1]),
|
|
) * 0.5;
|
|
|
|
stats.triangles += 1;
|
|
stats.surfaceAreaM2 += areaM2;
|
|
if (normalY >= 0.7) stats.horizontalFacingAreaM2 += areaM2;
|
|
else if (normalY <= 0.3) stats.wallAreaM2 += areaM2;
|
|
else stats.otherAreaM2 += areaM2;
|
|
if (uvArea <= 1e-12) {
|
|
stats.nearZeroUvAreaM2 += areaM2;
|
|
stats.nearZeroUvAreaTriangles += 1;
|
|
}
|
|
for (const position of vertices) includePosition(stats, position);
|
|
}
|
|
|
|
function parseFaceVertex(token, positions, texcoords) {
|
|
const [vertexIndex, texcoordIndex] = token.split("/");
|
|
const position = positions[Number(vertexIndex) - 1];
|
|
const texcoord = texcoords[Number(texcoordIndex) - 1];
|
|
return position && texcoord ? { position, texcoord } : null;
|
|
}
|
|
|
|
async function materialTextures(mtlFile) {
|
|
const result = new Map();
|
|
let material = null;
|
|
for (const line of (await readFile(mtlFile, "utf8")).split(/\r?\n/)) {
|
|
const match = line.match(/^newmtl\s+(.+)$/);
|
|
if (match) {
|
|
material = match[1].trim();
|
|
continue;
|
|
}
|
|
const map = line.match(/^map_Kd\s+(.+)$/);
|
|
if (material && map) result.set(material, map[1].trim());
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function imageDimensions(ddsFile) {
|
|
try {
|
|
const bytes = await readFile(ddsFile);
|
|
if (bytes.toString("ascii", 0, 4) !== "DDS ") return null;
|
|
return { width: bytes.readUInt32LE(16), height: bytes.readUInt32LE(12) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function inspectObj({ slug, material }) {
|
|
const rawDirectory = path.join(projectRoot, "runewaker-export-work", slug, "raw");
|
|
const objFile = path.join(rawDirectory, "scene.obj");
|
|
const mtlFile = path.join(rawDirectory, "scene.mtl");
|
|
const materialName = material;
|
|
const textureMap = await materialTextures(mtlFile);
|
|
const stagedTexture = textureMap.get(materialName) ?? null;
|
|
const textureFile = stagedTexture ? path.join(rawDirectory, stagedTexture) : null;
|
|
const positions = [];
|
|
const texcoords = [];
|
|
const groups = new Map();
|
|
let currentGroup = "(default)";
|
|
let currentMaterial = null;
|
|
const lines = createInterface({ input: createReadStream(objFile), crlfDelay: Infinity });
|
|
|
|
for await (const line of lines) {
|
|
if (line.startsWith("v ")) {
|
|
const values = line.trim().split(/\s+/).slice(1, 4).map(Number);
|
|
if (values.length === 3 && values.every(Number.isFinite)) positions.push(values);
|
|
} else if (line.startsWith("vt ")) {
|
|
const values = line.trim().split(/\s+/).slice(1, 3).map(Number);
|
|
if (values.length === 2 && values.every(Number.isFinite)) texcoords.push(values);
|
|
} else if (line.startsWith("g ")) {
|
|
currentGroup = line.slice(2).trim() || "(unnamed)";
|
|
} else if (line.startsWith("usemtl ")) {
|
|
currentMaterial = line.slice(7).trim();
|
|
} else if (currentMaterial === materialName && line.startsWith("f ")) {
|
|
const face = line.trim().split(/\s+/).slice(1)
|
|
.map((token) => parseFaceVertex(token, positions, texcoords));
|
|
if (face.length < 3 || face.some((entry) => !entry)) continue;
|
|
const stats = groups.get(currentGroup) ?? createStats(currentGroup);
|
|
for (let index = 1; index < face.length - 1; index += 1) {
|
|
addTriangle(
|
|
stats,
|
|
[face[0].position, face[index].position, face[index + 1].position],
|
|
[face[0].texcoord, face[index].texcoord, face[index + 1].texcoord],
|
|
);
|
|
}
|
|
groups.set(currentGroup, stats);
|
|
}
|
|
}
|
|
|
|
const rows = [...groups.values()].sort((left, right) => right.nearZeroUvAreaM2 - left.nearZeroUvAreaM2);
|
|
const total = createStats("material-total");
|
|
for (const row of rows) {
|
|
total.triangles += row.triangles;
|
|
total.surfaceAreaM2 += row.surfaceAreaM2;
|
|
total.horizontalFacingAreaM2 += row.horizontalFacingAreaM2;
|
|
total.wallAreaM2 += row.wallAreaM2;
|
|
total.otherAreaM2 += row.otherAreaM2;
|
|
total.nearZeroUvAreaM2 += row.nearZeroUvAreaM2;
|
|
total.nearZeroUvAreaTriangles += row.nearZeroUvAreaTriangles;
|
|
}
|
|
const sourceBoundsMeters = (bounds) => ({
|
|
min: [bounds.min[0] * 0.1, bounds.min[1] * 0.1, -bounds.max[2] * 0.1],
|
|
max: [bounds.max[0] * 0.1, bounds.max[1] * 0.1, -bounds.min[2] * 0.1],
|
|
});
|
|
return {
|
|
slug,
|
|
material: materialName,
|
|
texture: stagedTexture,
|
|
textureDimensions: textureFile ? await imageDimensions(textureFile) : null,
|
|
sourceCoordinateNote: "Group bounds are native scene.obj model-local source units; meters below apply 0.1 scale and Three.js Z reflection, without WDB placement transforms. horizontalFacingAreaM2 uses absolute normal-Y, so it combines floor- and ceiling-facing faces.",
|
|
uvNearZeroThreshold: "UV triangle area <= 1e-12 (not an exact-zero test)",
|
|
totals: {
|
|
triangles: total.triangles,
|
|
surfaceAreaM2: Number(total.surfaceAreaM2.toFixed(3)),
|
|
horizontalFacingAreaM2: Number(total.horizontalFacingAreaM2.toFixed(3)),
|
|
wallAreaM2: Number(total.wallAreaM2.toFixed(3)),
|
|
otherAreaM2: Number(total.otherAreaM2.toFixed(3)),
|
|
nearZeroUvAreaM2: Number(total.nearZeroUvAreaM2.toFixed(3)),
|
|
nearZeroUvAreaTriangles: total.nearZeroUvAreaTriangles,
|
|
},
|
|
groups: rows.map((row) => ({
|
|
group: row.group,
|
|
triangles: row.triangles,
|
|
surfaceAreaM2: Number(row.surfaceAreaM2.toFixed(3)),
|
|
horizontalFacingAreaM2: Number(row.horizontalFacingAreaM2.toFixed(3)),
|
|
wallAreaM2: Number(row.wallAreaM2.toFixed(3)),
|
|
nearZeroUvAreaM2: Number(row.nearZeroUvAreaM2.toFixed(3)),
|
|
nearZeroUvAreaTriangles: row.nearZeroUvAreaTriangles,
|
|
boundsSourceUnits: {
|
|
min: row.boundsSourceUnits.min.map((value) => Number(value.toFixed(3))),
|
|
max: row.boundsSourceUnits.max.map((value) => Number(value.toFixed(3))),
|
|
},
|
|
boundsMetersWithoutWdbPlacement: sourceBoundsMeters(row.boundsSourceUnits),
|
|
})),
|
|
};
|
|
}
|
|
|
|
const report = {
|
|
schemaVersion: 1,
|
|
purpose: "Focused native OBJ/MTL provenance for residual UV hotspots; compare these groups with the converted-source and shipped cells in RUNEWAKER_ENVIRONMENT_QUALITY_REPORT.md.",
|
|
generatedAt: new Date().toISOString(),
|
|
materials: [],
|
|
};
|
|
|
|
for (const target of cases) report.materials.push(await inspectObj(target));
|
|
const output = `${JSON.stringify(report, null, 2)}\n`;
|
|
if (outputFile) await writeFile(outputFile, output, "utf8");
|
|
else process.stdout.write(output);
|