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);