From 7293839a1903e53fa5043463759cfd7521b41b5e Mon Sep 17 00:00:00 2001 From: Andreas Wilms Date: Mon, 9 Mar 2026 16:12:01 +0100 Subject: [PATCH] moved to full client --- app/api/convert/route.ts | 92 ------- app/page.tsx | 77 ++---- app/workers/converter.worker.ts | 241 +++++++++-------- scripts/preprocess/convert_env_to_ply.py | 90 ------- scripts/preprocess/convert_lci_to_ply.py | 325 ----------------------- 5 files changed, 149 insertions(+), 676 deletions(-) delete mode 100644 app/api/convert/route.ts delete mode 100644 scripts/preprocess/convert_env_to_ply.py delete mode 100644 scripts/preprocess/convert_lci_to_ply.py diff --git a/app/api/convert/route.ts b/app/api/convert/route.ts deleted file mode 100644 index 9859fe5..0000000 --- a/app/api/convert/route.ts +++ /dev/null @@ -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((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 }); - } -} diff --git a/app/page.tsx b/app/page.tsx index fb2d5fc..21f7873 100644 --- a/app/page.tsx +++ b/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() { ); -} \ No newline at end of file +} diff --git a/app/workers/converter.worker.ts b/app/workers/converter.worker.ts index 4201d3d..9f3b5fd 100644 --- a/app/workers/converter.worker.ts +++ b/app/workers/converter.worker.ts @@ -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}` }); } } }; diff --git a/scripts/preprocess/convert_env_to_ply.py b/scripts/preprocess/convert_env_to_ply.py deleted file mode 100644 index 0e9554e..0000000 --- a/scripts/preprocess/convert_env_to_ply.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import struct -import sys -from pathlib import Path - -def convert_env_to_ply(input_path, output_path, verbose=False): - """ - Parses Xgrids environment.bin using standard library only. - Format: 44 bytes per splat (11 little-endian floats). - """ - input_path = Path(input_path) - output_path = Path(output_path) - - if not input_path.exists(): - print(f"✗ Error: File '{input_path}' not found.") - sys.exit(1) - - # 44 bytes per point (Position x,y,z | Scale x,y,z | Rotation q1,q2,q3,q4 | Opacity) - POINT_SIZE = 44 - - try: - file_size = input_path.stat().st_size - num_points = file_size // POINT_SIZE - - if verbose: - print("-" * 50) - print(f"Input: {input_path}") - print(f"Output: {output_path}") - print(f"Size: {file_size / (1024*1024):.2f} MB") - print(f"Points: {num_points:,}") - print("-" * 50) - - with open(input_path, "rb") as f_in, open(output_path, "w") as f_out: - # 1. Write PLY Header - f_out.write("ply\n") - f_out.write("format ascii 1.0\n") - f_out.write(f"element vertex {num_points}\n") - f_out.write("property float x\n") - f_out.write("property float y\n") - f_out.write("property float z\n") - f_out.write("end_header\n") - - # 2. Process Binary in Chunks (to keep RAM usage low) - # 10,000 points per chunk is a good balance for standard Python - chunk_size = 10000 - points_processed = 0 - - while points_processed < num_points: - remaining = num_points - points_processed - batch_size = min(chunk_size, remaining) - - # Read binary chunk - chunk_data = f_in.read(batch_size * POINT_SIZE) - if not chunk_data: - break - - # Unpack and write - # '<3f' grabs just the first 3 floats (XYZ) and ignores the rest of the 44 bytes - for i in range(batch_size): - offset = i * POINT_SIZE - # We only unpack the first 12 bytes (3 floats) of the 44-byte block - x, y, z = struct.unpack_from(" 100 and verbose: - print(f"Warning: Large file ({file_size_mb:.1f} MB) loaded into memory") - - # Read main header - magic = struct.unpack(" len(lci_data): - raise ValueError( - f"Mesh header {i} exceeds file size (offset {offset}, file size {len(lci_data)})" - ) - - index_x = struct.unpack(" len(lci_data): - raise ValueError( - f"Mesh {i} data exceeds file size (offset {mesh_offset}, " - f"data size {expected_data_size}, file size {len(lci_data)})" - ) - - # Read vertices - vertices = [] - pos = mesh_offset - - for j in range(vertex_num): - x, y, z = struct.unpack("= vertex_num or v1 >= vertex_num or v2 >= vertex_num: - raise ValueError( - f"Invalid face indices in mesh {i}: ({v0}, {v1}, {v2}), " - f"vertex_num={vertex_num}" - ) - - # Adjust indices to global vertex offset - faces.append( - ( - v0 + global_vertex_offset, - v1 + global_vertex_offset, - v2 + global_vertex_offset, - ) - ) - pos += 12 - - all_vertices.extend(vertices) - all_faces.extend(faces) - global_vertex_offset += len(vertices) - - if verbose: - print(f"Total: {len(all_vertices):,} vertices, {len(all_faces):,} faces") - - return all_vertices, all_faces - - -def write_ply_ascii(filepath, vertices, faces, verbose=False): - """ - Write mesh data to ASCII PLY file - - Args: - filepath: Path to output .ply file - vertices: List of (x, y, z) vertex tuples - faces: List of (v0, v1, v2) face tuples - verbose: Enable verbose output - """ - filepath = Path(filepath) - - # Create output directory if needed - filepath.parent.mkdir(parents=True, exist_ok=True) - - if verbose: - print(f"\nWriting ASCII PLY to: {filepath}") - - with open(filepath, "w") as f: - # Write header - f.write("ply\n") - f.write("format ascii 1.0\n") - f.write(f"element vertex {len(vertices)}\n") - f.write("property float x\n") - f.write("property float y\n") - f.write("property float z\n") - f.write(f"element face {len(faces)}\n") - f.write("property list uchar int vertex_indices\n") - f.write("end_header\n") - - # Write vertices with full precision - for v in vertices: - f.write(f"{v[0]:.9g} {v[1]:.9g} {v[2]:.9g}\n") - - # Write faces - for face in faces: - f.write(f"3 {face[0]} {face[1]} {face[2]}\n") - - if verbose: - file_size_kb = filepath.stat().st_size / 1024 - print(f" File size: {file_size_kb:.1f} KB") - - -def parse_args(): - """Parse command line arguments""" - parser = argparse.ArgumentParser( - description="Convert XGrids .lci collision files to .ply format", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - # Convert with default output name (collision_mesh.ply in same directory) - python convert_lci_to_ply.py collision.lci - - # Specify custom output file - python convert_lci_to_ply.py collision.lci output/mesh.ply - - # Verbose output - python convert_lci_to_ply.py collision.lci -v - """, - ) - - parser.add_argument("input", type=Path, help="Input .lci collision file") - - parser.add_argument( - "output", - type=Path, - nargs="?", - help="Output .ply file (default: collision_mesh.ply in same directory as input)", - ) - - parser.add_argument( - "-v", "--verbose", action="store_true", help="Enable verbose output" - ) - - return parser.parse_args() - - -def main(): - """Main entry point""" - args = parse_args() - - # Determine output path - output_path = ( - args.input.parent / "collision_mesh.ply" if args.output is None else args.output - ) - - if args.verbose: - print("=" * 60) - print(f"Converting: {args.input}") - print(f"Output: {output_path}") - print("=" * 60 + "\n") - - try: - # Read LCI file - vertices, faces = read_lci_file(args.input, verbose=args.verbose) - - if not vertices: - print("Error: No mesh data extracted from file", file=sys.stderr) - sys.exit(1) - - # Write PLY file - write_ply_ascii(output_path, vertices, faces, verbose=args.verbose) - - if args.verbose: - print("\n" + "=" * 60) - print("✓ Conversion successful!") - else: - print(f"✓ Converted {args.input} -> {output_path}") - print(f" {len(vertices):,} vertices, {len(faces):,} faces") - - except Exception as e: - print(f"\n✗ Conversion failed: {e}", file=sys.stderr) - if args.verbose: - import traceback - - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - main()