473 lines
22 KiB
JavaScript
473 lines
22 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
copyFile,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rm,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import sharp from "sharp";
|
|
import { descriptorSourceMatrix, normalizeWdbResourcePath } from "../lib/environment-assembly.mjs";
|
|
import { loadEnvironmentRecipe, projectRoot } from "../lib/recipe.mjs";
|
|
import { convertNativeTerrainChunks, sourceTerrainToThreeMatrix } from "./terrain-geometry.mjs";
|
|
import { writeNativeTerrainGlbs } from "./native-terrain-glb.mjs";
|
|
|
|
let workDirectory;
|
|
const texconvDefault = path.resolve(process.env.TEXCONV_BIN ?? path.join(
|
|
process.env.LOCALAPPDATA ?? "",
|
|
"Microsoft",
|
|
"WinGet",
|
|
"Packages",
|
|
"Microsoft.DirectXTex.Texconv_Microsoft.Winget.Source_8wekyb3d8bbwe",
|
|
"texconv.exe",
|
|
));
|
|
const argv = process.argv.slice(2);
|
|
|
|
function option(name, fallback) {
|
|
const equals = argv.find((value) => value.startsWith(name + "="));
|
|
if (equals) return equals.slice(name.length + 1);
|
|
const index = argv.indexOf(name);
|
|
if (index >= 0 && argv[index + 1] && !argv[index + 1].startsWith("--")) return argv[index + 1];
|
|
return fallback;
|
|
}
|
|
|
|
function projectOption(name, fallback) {
|
|
const value = option(name, fallback);
|
|
return path.resolve(projectRoot, value);
|
|
}
|
|
|
|
function hashBytes(data) {
|
|
return createHash("sha256").update(data).digest("hex");
|
|
}
|
|
|
|
async function hashFile(file) {
|
|
return hashBytes(await readFile(file));
|
|
}
|
|
|
|
function ensureInside(parent, candidate, label) {
|
|
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
|
|
if (!relative || (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) {
|
|
return path.resolve(candidate);
|
|
}
|
|
throw new Error(`${label} resolves outside the allowed directory: ${candidate}`);
|
|
}
|
|
|
|
function sourceRelativeFile(sourceDirectory, relative, label) {
|
|
if (typeof relative !== "string" || !relative.trim()) throw new Error(`${label} is required.`);
|
|
return ensureInside(sourceDirectory, path.resolve(sourceDirectory, relative.replaceAll("/", path.sep)), label);
|
|
}
|
|
|
|
function typedArrayFromBuffer(buffer, Type, bytesPerElement, label) {
|
|
if (buffer.byteLength % bytesPerElement !== 0) {
|
|
throw new Error(`${label} has a byte length that is not aligned to ${bytesPerElement}.`);
|
|
}
|
|
const copy = new Uint8Array(buffer.byteLength);
|
|
copy.set(buffer);
|
|
return new Type(copy.buffer);
|
|
}
|
|
|
|
async function readJson(file, label) {
|
|
try {
|
|
return JSON.parse(await readFile(file, "utf8"));
|
|
} catch (error) {
|
|
throw new Error(`Could not read ${label} ${file}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function loadTerrainChunks(manifest, sourceDirectory) {
|
|
if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0) {
|
|
throw new Error("Native terrain manifest must contain chunks.");
|
|
}
|
|
const chunks = [];
|
|
for (const row of manifest.chunks) {
|
|
const files = row.geometryFiles;
|
|
if (!files) throw new Error(`${row.id} has no geometryFiles record.`);
|
|
const positionsFile = sourceRelativeFile(sourceDirectory, files.heightfieldVerts ?? files.positions, `${row.id} positions`);
|
|
const uvsFile = sourceRelativeFile(sourceDirectory, files.normalizedXZUvs ?? files.uvs, `${row.id} normalized XZ UVs`);
|
|
const indicesFile = sourceRelativeFile(sourceDirectory, files.exactIndices ?? files.indices, `${row.id} exact indices`);
|
|
const normalsFile = sourceRelativeFile(sourceDirectory, files.nativeNormals ?? files.normals, `${row.id} native normals`);
|
|
const triangleFlagsFile = sourceRelativeFile(sourceDirectory, files.triangleVisibilityFlags, `${row.id} triangle flags`);
|
|
const vertexFlagsFile = files.vertexFlags
|
|
? sourceRelativeFile(sourceDirectory, files.vertexFlags, `${row.id} vertex flags`)
|
|
: null;
|
|
const cellFlagsFile = files.cellVisibilityFlags
|
|
? sourceRelativeFile(sourceDirectory, files.cellVisibilityFlags, `${row.id} cell flags`)
|
|
: null;
|
|
const [positionBytes, uvBytes, indexBytes, normalBytes, triangleFlagsBytes] = await Promise.all([
|
|
readFile(positionsFile), readFile(uvsFile), readFile(indicesFile), readFile(normalsFile), readFile(triangleFlagsFile),
|
|
]);
|
|
const vertexFlagsBytes = vertexFlagsFile ? await readFile(vertexFlagsFile) : null;
|
|
const cellFlagsBytes = cellFlagsFile ? await readFile(cellFlagsFile) : null;
|
|
const triangleVisibilityFlags = new Uint8Array(triangleFlagsBytes);
|
|
chunks.push({
|
|
id: row.id,
|
|
coordinate: row.coordinate,
|
|
dimension: Number(row.dimension),
|
|
gridSize: Number(row.gridSize),
|
|
serializedOrigin: row.serializedOrigin,
|
|
positions: typedArrayFromBuffer(positionBytes, Float32Array, 4, `${row.id} positions`),
|
|
uvs: typedArrayFromBuffer(uvBytes, Float32Array, 4, `${row.id} UVs`),
|
|
indices: typedArrayFromBuffer(indexBytes, Uint16Array, 2, `${row.id} indices`),
|
|
normals: typedArrayFromBuffer(normalBytes, Float32Array, 4, `${row.id} native normals`),
|
|
triangleVisibilityFlags,
|
|
triangleVisibilitySha256: hashBytes(triangleFlagsBytes),
|
|
vertexFlagsSha256: vertexFlagsBytes ? hashBytes(vertexFlagsBytes) : null,
|
|
cellVisibilityFlagsSha256: cellFlagsBytes ? hashBytes(cellFlagsBytes) : null,
|
|
visibleSourceTriangles: Number(row.visibleSourceTriangleCount),
|
|
maskedSourceTriangles: Number(row.maskedSourceTriangleCount),
|
|
layers: row.layers,
|
|
splatMaps: row.splatMaps,
|
|
material: row.material,
|
|
});
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
function selectPrimaryDescriptor(wdbReport, recipe) {
|
|
const wanted = normalizeWdbResourcePath(recipe.source.model);
|
|
const matches = (wdbReport.descriptors ?? []).filter((row) => normalizeWdbResourcePath(row.resource) === wanted);
|
|
if (matches.length === 0) throw new Error(`WDB report has no descriptor for ${recipe.source.model}.`);
|
|
if (matches.length === 1) return matches[0];
|
|
const expected = recipe.source.primaryPlacementTranslation;
|
|
if (!Array.isArray(expected) || expected.length !== 3) {
|
|
throw new Error("Multiple primary-resource placements require recipe.source.primaryPlacementTranslation.");
|
|
}
|
|
const match = matches.find((row) => row.translation?.every((value, index) => (
|
|
Math.abs(Number(value) - Number(expected[index])) <= 0.0001
|
|
)));
|
|
if (!match) throw new Error("Configured primary WDB descriptor was not found in the WDB report.");
|
|
return match;
|
|
}
|
|
|
|
function run(executable, argumentsList, label) {
|
|
return new Promise((resolve, reject) => {
|
|
console.log(`[runewaker-terrain] ${label}`);
|
|
const child = spawn(executable, argumentsList, {
|
|
cwd: projectRoot,
|
|
stdio: "inherit",
|
|
windowsHide: true,
|
|
});
|
|
child.once("error", reject);
|
|
child.once("exit", (code) => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(`${label} exited with code ${code}.`));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function verifyExists(file, label) {
|
|
try {
|
|
await stat(file);
|
|
} catch {
|
|
throw new Error(`${label} does not exist: ${file}`);
|
|
}
|
|
}
|
|
|
|
async function copyIfIdenticalOrMissing(source, target) {
|
|
try {
|
|
const [sourceHash, targetHash] = await Promise.all([hashFile(source), hashFile(target)]);
|
|
if (sourceHash !== targetHash) {
|
|
throw new Error(`Refusing to overwrite different texture-cache content at ${target}.`);
|
|
}
|
|
return targetHash;
|
|
} catch (error) {
|
|
if (error.code !== "ENOENT") throw error;
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
await copyFile(source, target);
|
|
return hashFile(target);
|
|
}
|
|
}
|
|
|
|
function basenameForPng(sourceDDS) {
|
|
return path.basename(sourceDDS).replace(/\.dds$/i, ".png");
|
|
}
|
|
|
|
async function decodeTerrainImages(chunks, sourceDirectory, tempDirectory, texconv, rawTextureDirectory, preparedTextureDirectory) {
|
|
await mkdir(tempDirectory, { recursive: true });
|
|
await mkdir(rawTextureDirectory, { recursive: true });
|
|
await mkdir(preparedTextureDirectory, { recursive: true });
|
|
const uniqueTiles = new Map();
|
|
const uniqueWeights = new Map();
|
|
for (const chunk of chunks) {
|
|
for (const layer of chunk.layers ?? []) {
|
|
if (!uniqueTiles.has(layer.sourceDDS)) uniqueTiles.set(layer.sourceDDS, layer);
|
|
}
|
|
for (const weight of chunk.splatMaps ?? []) {
|
|
if (!uniqueWeights.has(weight.linearPngFile)) uniqueWeights.set(weight.linearPngFile, weight);
|
|
}
|
|
}
|
|
const textureBySource = new Map();
|
|
const textureReport = [];
|
|
let textureIndex = 0;
|
|
|
|
for (const [sourceDDS, layer] of uniqueTiles) {
|
|
const ddsFile = sourceRelativeFile(sourceDirectory, sourceDDS, `terrain tile ${sourceDDS}`);
|
|
await verifyExists(ddsFile, `Terrain source tile ${sourceDDS}`);
|
|
const rawTarget = path.join(rawTextureDirectory, path.basename(sourceDDS));
|
|
const ddsHash = await copyIfIdenticalOrMissing(ddsFile, rawTarget);
|
|
const destination = path.join(tempDirectory, `tile-${String(textureIndex).padStart(2, "0")}`);
|
|
await mkdir(destination, { recursive: true });
|
|
await run(texconv, ["-ft", "PNG", "-o", destination, "-y", "--permissive", ddsFile], `decode source terrain tile ${sourceDDS}`);
|
|
const decodedPng = path.join(destination, basenameForPng(sourceDDS));
|
|
await verifyExists(decodedPng, `Converted terrain tile ${sourceDDS}`);
|
|
const opaquePng = await sharp(decodedPng)
|
|
.removeAlpha()
|
|
.ensureAlpha(1)
|
|
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
|
.toBuffer();
|
|
const image = await sharp(opaquePng).raw().toBuffer({ resolveWithObject: true });
|
|
if (image.info.channels !== 4) throw new Error(`${sourceDDS} did not convert to RGBA PNG.`);
|
|
for (let alphaOffset = 3; alphaOffset < image.data.length; alphaOffset += 4) {
|
|
if (image.data[alphaOffset] !== 255) throw new Error(`${sourceDDS} terrain alpha was not forced opaque.`);
|
|
}
|
|
if (Number(layer.width) > 0 && image.info.width !== Number(layer.width)) {
|
|
throw new Error(`${sourceDDS} converted width ${image.info.width} does not match source ${layer.width}.`);
|
|
}
|
|
if (Number(layer.height) > 0 && image.info.height !== Number(layer.height)) {
|
|
throw new Error(`${sourceDDS} converted height ${image.info.height} does not match source ${layer.height}.`);
|
|
}
|
|
const imageName = basenameForPng(sourceDDS);
|
|
const preparedTarget = path.join(preparedTextureDirectory, imageName);
|
|
const opaqueTemp = path.join(destination, "opaque-" + imageName);
|
|
await writeFile(opaqueTemp, opaquePng);
|
|
const pngHash = await copyIfIdenticalOrMissing(opaqueTemp, preparedTarget);
|
|
textureBySource.set(sourceDDS, {
|
|
kind: "tile",
|
|
image: opaquePng,
|
|
imageName,
|
|
sourceDDS,
|
|
ddsSha256: ddsHash,
|
|
pngSha256: pngHash,
|
|
width: image.info.width,
|
|
height: image.info.height,
|
|
alphaPolicy: "source tile alpha ignored; export PNG alpha forced to 255; material OPAQUE",
|
|
});
|
|
textureReport.push({
|
|
kind: "tile",
|
|
sourceDDS,
|
|
sourceClientPath: layer.sourceClientPath ?? null,
|
|
imageName,
|
|
width: image.info.width,
|
|
height: image.info.height,
|
|
sourceDdsSha256: ddsHash,
|
|
preparedPngSha256: pngHash,
|
|
alphaPolicy: "source tile alpha ignored; export PNG alpha forced to 255",
|
|
});
|
|
textureIndex += 1;
|
|
}
|
|
|
|
for (const [linearPngFile, weight] of uniqueWeights) {
|
|
const sourceDDS = sourceRelativeFile(sourceDirectory, weight.splatDDS, `splat DDS ${weight.splatDDS}`);
|
|
const sourcePng = sourceRelativeFile(sourceDirectory, linearPngFile, `linear splat PNG ${linearPngFile}`);
|
|
await verifyExists(sourceDDS, `Weight map DDS ${weight.splatDDS}`);
|
|
await verifyExists(sourcePng, `Linear weight PNG ${linearPngFile}`);
|
|
const weightImageName = path.basename(linearPngFile);
|
|
const rawTarget = path.join(rawTextureDirectory, weightImageName.replace(/\.png$/i, ".dds"));
|
|
const ddsHash = await copyIfIdenticalOrMissing(sourceDDS, rawTarget);
|
|
const preparedTarget = path.join(preparedTextureDirectory, path.basename(linearPngFile));
|
|
const pngHash = await copyIfIdenticalOrMissing(sourcePng, preparedTarget);
|
|
const bytes = await readFile(sourcePng);
|
|
const image = await sharp(bytes).metadata();
|
|
if (Number(weight.width) > 0 && image.width !== Number(weight.width)) {
|
|
throw new Error(`${linearPngFile} width ${image.width} does not match source ${weight.width}.`);
|
|
}
|
|
if (Number(weight.height) > 0 && image.height !== Number(weight.height)) {
|
|
throw new Error(`${linearPngFile} height ${image.height} does not match source ${weight.height}.`);
|
|
}
|
|
textureBySource.set(linearPngFile, {
|
|
kind: "weight",
|
|
image: bytes,
|
|
imageName: path.basename(linearPngFile),
|
|
sourceDDS: weight.splatDDS,
|
|
sourceCompressedFile: weight.sourceCompressedFile,
|
|
ddsSha256: ddsHash,
|
|
pngSha256: pngHash,
|
|
width: image.width,
|
|
height: image.height,
|
|
channelLayerIds: weight.channelLayerIds,
|
|
colorSpace: "linear RGB; referenced only by metallicRoughnessTexture and occlusionTexture",
|
|
});
|
|
textureReport.push({
|
|
kind: "weight",
|
|
sourceDDS: weight.splatDDS,
|
|
sourceCompressedFile: weight.sourceCompressedFile,
|
|
preparedLinearPng: linearPngFile,
|
|
imageName: path.basename(linearPngFile),
|
|
width: image.width,
|
|
height: image.height,
|
|
channelLayerIds: weight.channelLayerIds,
|
|
sourceDdsSha256: ddsHash,
|
|
preparedPngSha256: pngHash,
|
|
colorSpace: "linear RGB; source DXT1 alpha ignored",
|
|
});
|
|
}
|
|
|
|
return { textureBySource, textureReport };
|
|
}
|
|
|
|
async function main() {
|
|
const recipeFallback = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes", "sand-vortex.json");
|
|
const { file: recipeFile, recipe } = await loadEnvironmentRecipe(argv, recipeFallback);
|
|
const slug = recipe.slug;
|
|
const title = recipe.title ?? slug;
|
|
if (recipe.source?.nativeTerrain?.kind !== "runewaker-wdb-terrain") {
|
|
throw new Error(`${title} recipe source.nativeTerrain.kind must be 'runewaker-wdb-terrain'.`);
|
|
}
|
|
workDirectory = path.join(projectRoot, "runewaker-export-work", slug);
|
|
const defaultManifest = path.join(workDirectory, "native-terrain-source", "terrain-native-source.json");
|
|
const defaultWdbReport = path.join(workDirectory, "raw", "wdb-report.json");
|
|
const defaultOutputDirectory = path.join(workDirectory, "converted", "terrain");
|
|
const manifestFile = projectOption("--manifest", defaultManifest);
|
|
const wdbReportFile = projectOption("--wdb-report", defaultWdbReport);
|
|
const outputDirectory = projectOption("--output-directory", defaultOutputDirectory);
|
|
const texconv = path.resolve(option("--texconv", texconvDefault));
|
|
const sourceDirectory = path.dirname(manifestFile);
|
|
const stageDirectory = await mkdtemp(path.join(workDirectory, ".native-terrain-stage-"));
|
|
const stageVisual = path.join(stageDirectory, `${slug}-terrain-visual.glb`);
|
|
const stageCollision = path.join(stageDirectory, `${slug}-terrain-collision.glb`);
|
|
const stageTextures = path.join(stageDirectory, "texconv");
|
|
const rawTextureDirectory = path.join(workDirectory, "raw", "textures");
|
|
const preparedTextureDirectory = path.join(workDirectory, "prepared", "textures");
|
|
|
|
try {
|
|
await Promise.all([
|
|
verifyExists(manifestFile, "Native terrain manifest"),
|
|
verifyExists(wdbReportFile, `${title} WDB report`),
|
|
verifyExists(texconv, "DirectXTex texconv"),
|
|
]);
|
|
const [manifest, wdbReport] = await Promise.all([
|
|
readJson(manifestFile, "native terrain manifest"),
|
|
readJson(wdbReportFile, `${title} WDB report`),
|
|
]);
|
|
if (manifest.schemaVersion !== 1 || !manifest.source?.terrainHeader) {
|
|
throw new Error("Unsupported native terrain manifest schema or missing source terrain header.");
|
|
}
|
|
const descriptor = selectPrimaryDescriptor(wdbReport, recipe);
|
|
// Validate the primary descriptor before deriving its inverse transform.
|
|
descriptorSourceMatrix(descriptor);
|
|
const metersPerSourceUnit = Number(recipe.coordinateSystem.metersPerSourceUnit);
|
|
const sourceToThree = sourceTerrainToThreeMatrix(descriptor, metersPerSourceUnit);
|
|
const sourceChunks = await loadTerrainChunks(manifest, sourceDirectory);
|
|
const converted = convertNativeTerrainChunks(sourceChunks, sourceToThree);
|
|
const textures = await decodeTerrainImages(
|
|
sourceChunks,
|
|
sourceDirectory,
|
|
stageTextures,
|
|
texconv,
|
|
rawTextureDirectory,
|
|
preparedTextureDirectory,
|
|
);
|
|
|
|
const outputDirectoryResolved = ensureInside(projectRoot, outputDirectory, "Terrain output directory");
|
|
await mkdir(outputDirectoryResolved, { recursive: true });
|
|
const visualFile = path.join(outputDirectoryResolved, `${slug}-terrain-visual.glb`);
|
|
const collisionFile = path.join(outputDirectoryResolved, `${slug}-terrain-collision.glb`);
|
|
await writeNativeTerrainGlbs({
|
|
converted,
|
|
textureSources: textures,
|
|
provenance: {
|
|
dungeonId: slug,
|
|
dungeonTitle: title,
|
|
sourceManifestSha256: await hashFile(manifestFile),
|
|
sourceWdbSha256: manifest.source.wdbSha256,
|
|
primaryPlacementGuid: descriptor.guid,
|
|
metersPerSourceUnit,
|
|
partitionVisualChunks: recipe.source.nativeTerrain.partitionVisualChunks === true,
|
|
...(recipe.source.nativeTerrain?.cliffProjection?.kind === "source-scaled-side-projection" ? { cliffProjection: {
|
|
sourceFromAsset: sourceToThree.clone().invert().toArray(),
|
|
chunkSizeSourceUnits: manifest.source.terrainHeader.chunkDimension * manifest.source.terrainHeader.gridSize,
|
|
} } : {}),
|
|
textureSourceDDS: textures.textureReport.map((texture) => texture.sourceDDS),
|
|
},
|
|
visualFile: stageVisual,
|
|
collisionFile: stageCollision,
|
|
});
|
|
|
|
await mkdir(path.dirname(visualFile), { recursive: true });
|
|
await Promise.all([
|
|
copyFile(stageVisual, visualFile),
|
|
copyFile(stageCollision, collisionFile),
|
|
]);
|
|
|
|
const visualBytes = await readFile(visualFile);
|
|
const collisionBytes = await readFile(collisionFile);
|
|
const report = {
|
|
schemaVersion: 1,
|
|
dungeonId: recipe.slug,
|
|
generatedAt: new Date().toISOString(),
|
|
source: {
|
|
manifest: path.relative(projectRoot, manifestFile).split(path.sep).join("/"),
|
|
manifestSha256: await hashFile(manifestFile),
|
|
wdbReport: path.relative(projectRoot, wdbReportFile).split(path.sep).join("/"),
|
|
wdbSha256: manifest.source.wdbSha256,
|
|
terrainChunkCount: sourceChunks.length,
|
|
primaryPlacementGuid: descriptor.guid,
|
|
primaryPlacement: descriptor,
|
|
geometry: "native WDB heightfield; exact source grid topology; no resampling",
|
|
waterPayloadPolicy: manifest.source.waterPayloadPolicy,
|
|
normals: "source terrain shader vertexData RGB decoded, inverse-transpose transformed, and preserved per chunk",
|
|
sourceTriangleVisibility: "retained as per-primitive base64 Uint8Array in visual GLB extras and summarized below",
|
|
materialProjection: recipe.source.nativeTerrain?.cliffProjection ?? { kind: "native-xz" },
|
|
textures: textures.textureReport,
|
|
},
|
|
coordinateSystem: {
|
|
source: "Runewaker left-handed Y-up world coordinates",
|
|
transform: "inverse primary WDB descriptor TRS, then reflected-Z meter bridge",
|
|
metersPerSourceUnit,
|
|
},
|
|
geometry: converted.report,
|
|
visual: {
|
|
file: path.basename(visualFile),
|
|
fileRelativeToReport: "same directory",
|
|
sha256: hashBytes(visualBytes),
|
|
bytes: visualBytes.byteLength,
|
|
triangles: converted.report.sourceTriangleSlots,
|
|
meshObjects: recipe.source.nativeTerrain.partitionVisualChunks === true ? converted.chunks.length : 1,
|
|
partition: recipe.source.nativeTerrain.partitionVisualChunks === true ? "native-chunk-meshes" : "shared-grid-mesh",
|
|
vertices: converted.vertexCount,
|
|
primitives: converted.chunks.length,
|
|
sourceVisibleTriangles: converted.report.visibleSourceTriangles,
|
|
sourceMaskedTriangleSlotsRetained: converted.report.maskedSourceTriangles,
|
|
},
|
|
collision: {
|
|
file: path.basename(collisionFile),
|
|
fileRelativeToReport: "same directory",
|
|
sha256: hashBytes(collisionBytes),
|
|
bytes: collisionBytes.byteLength,
|
|
triangles: converted.report.collisionTriangles,
|
|
meshObjects: 1,
|
|
excludedSourceMaskedOrDegenerateTriangles: converted.report.excludedCollisionTriangles,
|
|
},
|
|
standardTextureCache: {
|
|
rawDirectory: path.relative(projectRoot, rawTextureDirectory).split(path.sep).join("/"),
|
|
preparedDirectory: path.relative(projectRoot, preparedTextureDirectory).split(path.sep).join("/"),
|
|
imageNamesMatchGlbTextures: true,
|
|
},
|
|
};
|
|
const reportFile = path.join(outputDirectoryResolved, "conversion-report.json");
|
|
await writeFile(reportFile, JSON.stringify(report, null, 2) + "\n", "utf8");
|
|
console.log(`[${slug}-terrain] visual: ${path.relative(projectRoot, visualFile).split(path.sep).join("/")} (${report.visual.triangles} source slots)`);
|
|
console.log(`[${slug}-terrain] collision: ${path.relative(projectRoot, collisionFile).split(path.sep).join("/")} (${report.collision.triangles} visible triangles)`);
|
|
console.log(`[${slug}-terrain] report: ${path.relative(projectRoot, reportFile).split(path.sep).join("/")}`);
|
|
} finally {
|
|
const absoluteStage = path.resolve(stageDirectory);
|
|
const absoluteWork = path.resolve(workDirectory);
|
|
const relativeStage = path.relative(absoluteWork, absoluteStage);
|
|
if (relativeStage && !relativeStage.startsWith("..") && !path.isAbsolute(relativeStage)) {
|
|
await rm(absoluteStage, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
main().catch((error) => {
|
|
console.error(`[sand-vortex-terrain] ${error.stack ?? error.message}`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|