moved to full client
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
77
app/page.tsx
77
app/page.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user