moved to full client

This commit is contained in:
Andreas Wilms
2026-03-09 16:12:01 +01:00
parent 8a1f133e50
commit 7293839a19
5 changed files with 149 additions and 676 deletions

View File

@@ -1,92 +0,0 @@
import { spawn } from "child_process";
import { writeFile, readFile, mkdir, unlink } from "fs/promises";
import { NextRequest, NextResponse } from "next/server";
import path from "path";
import os from "os";
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const tempDir = path.join(os.tmpdir(), "xgrids-pipeline");
await mkdir(tempDir, { recursive: true });
const safeName = file.name.replace(/[^a-z0-9.]/gi, "_").toLowerCase();
const timestamp = Date.now();
const inputPath = path.join(tempDir, `${timestamp}_${safeName}`);
const outputPath = inputPath.replace(/\.(lcc|lci|bin)$/i, ".ply");
// DETERMINE WHICH SCRIPT TO RUN
let scriptName = "convert_lci_to_ply.py";
if (file.name.toLowerCase().includes("environment.bin")) {
scriptName = "convert_env_to_ply.py";
}
const scriptPath = path.join(
process.cwd(),
"scripts",
"preprocess",
scriptName,
);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(inputPath, buffer);
return new Promise<NextResponse>((resolve) => {
const pythonProcess = spawn("python3", [
scriptPath,
inputPath,
outputPath,
]);
let errorOutput = "";
pythonProcess.stderr.on("data", (data) => {
errorOutput += data.toString();
});
pythonProcess.on("close", async (code) => {
if (code !== 0) {
await unlink(inputPath).catch(() => {});
return resolve(
NextResponse.json(
{ error: `Python failed (${scriptName}): ${errorOutput}` },
{ status: 500 },
),
);
}
try {
const plyBuffer = await readFile(outputPath);
await Promise.all([
unlink(inputPath).catch(() => {}),
unlink(outputPath).catch(() => {}),
]);
resolve(
new NextResponse(plyBuffer, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${file.name.replace(/\.[^/.]+$/, "")}.ply"`,
},
}),
);
} catch (e) {
resolve(
NextResponse.json(
{ error: "Failed to read output PLY" },
{ status: 500 },
),
);
}
});
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -49,66 +49,29 @@ export default function XgridsWizard() {
const startPipeline = async () => {
if (!files.length || !workerRef.current) return;
// FIND SPECIFIC ENTRIES
const lciFile = files.find((f) => f.name.toLowerCase() === "collision.lci");
const lccFile = files.find((f) => f.name.toLowerCase().endsWith(".lcc"));
if (!lciFile) {
setStatus("Error: 'collision.lci' not found in folder.");
return;
}
if (!lccFile) {
setStatus("Error: Main '.lcc' scene file not found.");
return;
}
setIsProcessing(true);
setStatus("Preparing files for Worker...");
try {
// --- PHASE 1: Python (Only collision.lci) ---
setStatus("Step 1/2: Converting collision.lci to PLY...");
const formData = new FormData();
formData.append("file", lciFile);
const mainLcc = files.find((f) => f.name.toLowerCase().endsWith(".lcc"));
const pyResponse = await fetch("/api/convert", {
method: "POST",
body: formData,
});
const filesData = await Promise.all(
files.map(async (f) => ({
name: f.name,
buffer: await f.arrayBuffer(),
})),
);
if (!pyResponse.ok) {
const err = await pyResponse.json();
throw new Error(err.error || "Python script failed");
}
const plyBlob = await pyResponse.blob();
downloadFile(plyBlob, "collision_mesh.ply");
// --- PHASE 2: Worker (The whole folder context) ---
setStatus("Step 2/2: Generating Splats and LODs from .lcc...");
const filesData = await Promise.all(
files.map(async (f) => ({
name: f.name,
buffer: await f.arrayBuffer(),
})),
);
const buffersToTransfer = filesData.map((f) => f.buffer);
workerRef.current.postMessage(
{
type: "START_CONVERSION",
filesData,
mainLccName: lccFile.name,
fileName: lccFile.name.replace(".lcc", ""),
},
buffersToTransfer,
);
} catch (error: any) {
console.error(error);
setStatus(`Error: ${error.message}`);
setIsProcessing(false);
}
// Send EVERYTHING to the worker.
// The worker will now handle Collision, Environment, SOG, and LODs.
workerRef.current.postMessage(
{
type: "START_CONVERSION",
filesData,
mainLccName: mainLcc?.name,
fileName: mainLcc?.name.replace(".lcc", ""),
},
filesData.map((f) => f.buffer),
);
};
return (
@@ -161,4 +124,4 @@ export default function XgridsWizard() {
</div>
</main>
);
}
}

View File

@@ -1,56 +1,117 @@
/* eslint-disable no-restricted-globals */
/**
* Converts XGrids .lci binary to PLY Mesh
*/
function parseLci(buffer: ArrayBuffer): Blob {
const view = new DataView(buffer);
const LCI_MAGIC = 0x6c6c6f63; // 'coll'
if (view.getUint32(0, true) !== LCI_MAGIC) {
throw new Error("Invalid LCI Magic Number");
}
const meshNum = view.getUint32(44, true);
const meshes = [];
for (let i = 0; i < meshNum; i++) {
const offset = 48 + i * 40;
meshes.push({
dataOffset: Number(view.getBigUint64(offset + 8, true)),
vertexNum: view.getUint32(offset + 24, true),
faceNum: view.getUint32(offset + 28, true),
});
}
let verticesStr = "";
let facesStr = "";
let vOffset = 0;
for (const m of meshes) {
let pos = m.dataOffset;
for (let j = 0; j < m.vertexNum; j++) {
verticesStr += `${view.getFloat32(pos, true).toFixed(6)} ${view.getFloat32(pos + 4, true).toFixed(6)} ${view.getFloat32(pos + 8, true).toFixed(6)}\n`;
pos += 12;
}
for (let j = 0; j < m.faceNum; j++) {
facesStr += `3 ${view.getUint32(pos, true) + vOffset} ${view.getUint32(pos + 4, true) + vOffset} ${view.getUint32(pos + 8, true) + vOffset}\n`;
pos += 12;
}
vOffset += m.vertexNum;
}
const header = `ply\nformat ascii 1.0\nelement vertex ${vOffset}\nproperty float x\nproperty float y\nproperty float z\nelement face ${meshes.reduce((a, b) => a + b.faceNum, 0)}\nproperty list uchar int vertex_indices\nend_header\n`;
return new Blob([header + verticesStr + facesStr], {
type: "application/octet-stream",
});
}
/**
* Converts XGrids environment.bin to PlayCanvas Gaussian Splat PLY
*/
function parseEnvironment(buffer: ArrayBuffer): Blob {
const view = new DataView(buffer);
const POINT_SIZE = 44;
const numPoints = Math.floor(buffer.byteLength / POINT_SIZE);
let ply = `ply\nformat ascii 1.0\nelement vertex ${numPoints}\nproperty float x\nproperty float y\nproperty float z\nproperty float scale_0\nproperty float scale_1\nproperty float scale_2\nproperty float rot_0\nproperty float rot_1\nproperty float rot_2\nproperty float rot_3\nproperty float opacity\nend_header\n`;
for (let i = 0; i < numPoints; i++) {
const offset = i * POINT_SIZE;
const row = [];
for (let j = 0; j < 11; j++) {
row.push(view.getFloat32(offset + j * 4, true).toFixed(6));
}
ply += row.join(" ") + "\n";
}
return new Blob([ply], { type: "application/octet-stream" });
}
// --- WORKER INFRASTRUCTURE ---
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const url = input instanceof Request ? input.url : input.toString();
self.postMessage({ type: "LOG", message: `FETCH: ${url}` });
if (url.includes("webp.wasm")) {
self.postMessage({
type: "LOG",
message: `INTERCEPTED → /workers/webp.wasm`,
});
const res = await originalFetch("/workers/webp.wasm", init);
self.postMessage({
type: "LOG",
message: `WASM response status: ${res.status}`,
});
return res;
}
if (url.includes("webp.wasm"))
return originalFetch("/workers/webp.wasm", init);
return originalFetch(input, init);
};
// Intercept XMLHttpRequest (Emscripten uses this in Workers)
if (typeof XMLHttpRequest !== "undefined") {
const originalOpen = XMLHttpRequest.prototype.open;
// @ts-ignore
XMLHttpRequest.prototype.open = function (
method: string,
url: string | URL,
...rest: any[]
) {
if (typeof url === "string" && url.includes("webp.wasm")) {
url = "/workers/webp.wasm";
}
return originalOpen.apply(this, [method, url, ...rest] as any);
};
}
self.onmessage = async (e: MessageEvent) => {
const { type, filesData, mainLccName, fileName } = e.data;
if (type === "START_CONVERSION") {
try {
self.postMessage({ type: "LOG", message: "Initialisiere..." });
const generatedFiles: { name: string; blob: Blob }[] = [];
// Emscripten's native locateFile hook
// @ts-ignore
globalThis.Module = globalThis.Module || {};
// @ts-ignore
globalThis.Module.locateFile = function (path: string) {
if (path.endsWith(".wasm")) {
return new URL("/webp.wasm", self.location.origin).href;
}
return path;
};
// 1. Process LCI (Collision)
const lciData = filesData.find((f: any) => f.name === "collision.lci");
if (lciData) {
self.postMessage({ type: "LOG", message: "Parsing Collision Mesh..." });
generatedFiles.push({
name: "collision_mesh.ply",
blob: parseLci(lciData.buffer),
});
}
// 2. Process Environment (Point Cloud)
const envData = filesData.find((f: any) => f.name === "environment.bin");
if (envData) {
self.postMessage({
type: "LOG",
message: "Parsing Environment Cloud...",
});
generatedFiles.push({
name: "environment_reference.ply",
blob: parseEnvironment(envData.buffer),
});
}
// 3. Process Splat Transformation (SOG / LODs)
self.postMessage({
type: "LOG",
message: "Initializing PlayCanvas Splat Transform...",
});
const {
readFile,
@@ -61,122 +122,78 @@ self.onmessage = async (e: MessageEvent) => {
} = await import("@playcanvas/splat-transform");
const readFs = new MemoryReadFileSystem();
self.postMessage({
type: "LOG",
message: "Lade Dateien in den virtuellen Speicher...",
});
for (const file of filesData) {
readFs.set(file.name, new Uint8Array(file.buffer));
}
const readOptions = {
iterations: 0,
lodSelect: [0, 1, 2, 3, 4], // we have captured a total level of 5
const commonOptions = {
iterations: 10,
lodSelect: [0, 1, 2, 3, 4],
unbundled: false,
lodChunkCount: 0,
lodChunkExtent: 0,
};
self.postMessage({ type: "LOG", message: "Lese LCC und Binärdaten..." });
const tables = await readFile({
filename: mainLccName,
fileSystem: readFs,
inputFormat: getInputFormat(mainLccName),
params: [],
options: readOptions,
options: { ...commonOptions, iterations: 0 },
});
const mainTable = tables[0];
if (!mainTable) throw new Error("Keine Splat-Daten gefunden.");
const generatedFiles: { name: string; blob: Blob }[] = [];
// PASS 1: Generate Single High-Quality SOG
self.postMessage({ type: "LOG", message: "Kompiliere Single SOG..." });
if (!mainTable) throw new Error("No Splat data found.");
// PASS: Single SOG
self.postMessage({ type: "LOG", message: "Compiling High-Res SOG..." });
const writeFsSingle = new MemoryFileSystem();
const singleOutputName = `${fileName}.sog`;
const singleOptions = {
...readOptions,
iterations: 10,
unbundled: false,
};
await writeFile(
{
filename: singleOutputName,
filename: `${fileName}.sog`,
outputFormat: "sog-bundle",
dataTable: mainTable,
options: singleOptions,
options: commonOptions,
},
writeFsSingle,
);
const singleSogData = writeFsSingle.results.get(singleOutputName);
if (singleSogData) {
const singleData = writeFsSingle.results.get(`${fileName}.sog`);
if (singleData) {
generatedFiles.push({
name: singleOutputName,
blob: new Blob([new Uint8Array(singleSogData).slice().buffer], {
type: "application/octet-stream",
}),
name: `${fileName}.sog`,
blob: new Blob([new Uint8Array(singleData).buffer]),
});
}
// ==========================================
// PASS 2: Generate Unbundled LOD SOGs + JSON
// ==========================================
self.postMessage({ type: "LOG", message: "Kompiliere LOD Chunks..." });
// PASS: LOD Chunks
self.postMessage({ type: "LOG", message: "Compiling LOD Chunks..." });
const writeFsLods = new MemoryFileSystem();
// MUST be exactly "meta.json" for unbundled SOG format
const lodsOutputName = "meta.json";
const lodOptions = {
...readOptions,
iterations: 10,
unbundled: true,
lodChunkCount: 512,
lodChunkExtent: 16,
};
await writeFile(
{
filename: lodsOutputName,
filename: "meta.json",
outputFormat: "sog",
dataTable: mainTable,
options: lodOptions,
options: {
...commonOptions,
unbundled: true,
lodChunkCount: 512,
lodChunkExtent: 16,
},
},
writeFsLods,
);
// Jetzt iterieren wir über alle generierten Dateien im System
for (const [generatedName, data] of writeFsLods.results.entries()) {
const mimeType = generatedName.endsWith(".json")
? "application/json"
: "application/octet-stream";
for (const [name, data] of writeFsLods.results.entries()) {
generatedFiles.push({
name: generatedName,
blob: new Blob([new Uint8Array(data).slice().buffer], {
type: mimeType,
}),
name,
blob: new Blob([new Uint8Array(data).buffer]),
});
}
// Send all Data to Frontend
self.postMessage({
type: "DONE",
data: {
files: generatedFiles,
},
});
self.postMessage({ type: "DONE", data: { files: generatedFiles } });
} catch (err: any) {
self.postMessage({ type: "LOG", message: `Fehler: ${err.message}` });
console.error(err);
self.postMessage({ type: "LOG", message: `Error: ${err.message}` });
}
}
};