diff --git a/app/api/convert/route.ts b/app/api/convert/route.ts new file mode 100644 index 0000000..1b9eeec --- /dev/null +++ b/app/api/convert/route.ts @@ -0,0 +1,100 @@ +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 }); + } + + // 1. Setup workspace + const tempDir = path.join(os.tmpdir(), "xgrids-pipeline"); + await mkdir(tempDir, { recursive: true }); + + // FIX: Sanitize filename to avoid shell/path issues with spaces + const safeName = file.name.replace(/[^a-z0-8.]/gi, "_").toLowerCase(); + const timestamp = Date.now(); + const inputPath = path.join(tempDir, `${timestamp}_${safeName}`); + + // Ensure we replace the extension correctly for the output + const outputPath = inputPath.replace(/\.(lcc|lci)$/i, ".ply"); + + const scriptPath = path.join( + process.cwd(), + "scripts", + "preprocess", + "convert_lci_to_ply.py", + ); + + // 2. Write the file + const buffer = Buffer.from(await file.arrayBuffer()); + await writeFile(inputPath, buffer); + + // 3. Execute Python + return new Promise((resolve) => { + // spawn handles arguments as an array, which is safer than exec for spaces + 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) { + console.error("Python Error:", errorOutput); + // Cleanup input even on failure + await unlink(inputPath).catch(() => {}); + return resolve( + NextResponse.json( + { + error: `Python script failed with code ${code}. ${errorOutput}`, + }, + { status: 500 }, + ), + ); + } + + try { + const plyBuffer = await readFile(outputPath); + + // Cleanup + 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 generated PLY file" }, + { status: 500 }, + ), + ); + } + }); + }); + } catch (error: any) { + console.error("API Route Error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/app/components/FileStatus.tsx b/app/components/FileStatus.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/app/page.tsx b/app/page.tsx index 52ba79b..fb2d5fc 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,5 @@ "use client"; + import { useState, useRef, useEffect } from "react"; export default function XgridsWizard() { @@ -19,21 +20,15 @@ export default function XgridsWizard() { if (type === "DONE") { setIsProcessing(false); - setStatus( - `Conversion Complete! Downloading ${data.files.length} files...`, - ); - - // Loop through all generated files and trigger downloads + setStatus("Pipeline Complete!"); data.files.forEach((file: { name: string; blob: Blob }) => { downloadFile(file.blob, file.name); }); } }; - return () => worker.terminate(); }, []); - // Helper to trigger browser downloads const downloadFile = (blob: Blob, name: string) => { const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -51,23 +46,46 @@ export default function XgridsWizard() { } }; - const startConversion = async () => { + const startPipeline = async () => { if (!files.length || !workerRef.current) return; - setIsProcessing(true); - setStatus("Reading files into memory (this might take a moment)..."); - - // Find the main .lcc file to act as our entry point - const lccFile = files.find((f) => f.name.endsWith(".lcc")); + // 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: Missing .lcc file in folder."); - setIsProcessing(false); + setStatus("Error: Main '.lcc' scene file not found."); return; } + setIsProcessing(true); + try { - // 1. Convert all File objects into an array of { name, buffer } + // --- PHASE 1: Python (Only collision.lci) --- + setStatus("Step 1/2: Converting collision.lci to PLY..."); + const formData = new FormData(); + formData.append("file", lciFile); + + const pyResponse = await fetch("/api/convert", { + method: "POST", + body: formData, + }); + + 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, @@ -75,8 +93,6 @@ export default function XgridsWizard() { })), ); - // 2. Extract just the ArrayBuffers so we can "transfer" them to the worker efficiently - // This prevents duplicating the 272MB data in RAM const buffersToTransfer = filesData.map((f) => f.buffer); workerRef.current.postMessage( @@ -84,13 +100,13 @@ export default function XgridsWizard() { type: "START_CONVERSION", filesData, mainLccName: lccFile.name, - fileName: lccFile.name.replace(".lcc", ""), // e.g., "Wilhelm Studios" + fileName: lccFile.name.replace(".lcc", ""), }, - buffersToTransfer, // Passes ownership to the worker to save memory + buffersToTransfer, ); - } catch (error) { + } catch (error: any) { console.error(error); - setStatus("Error reading files. Check console."); + setStatus(`Error: ${error.message}`); setIsProcessing(false); } }; @@ -98,11 +114,9 @@ export default function XgridsWizard() { return (
-
+

Xgrids Scene Wizard

-

- Convert .lcc/.lci to SOG, LODs, and PLY meshes locally. -

+

Targeting collision.lci + scene.lcc

@@ -117,13 +131,13 @@ export default function XgridsWizard() { onChange={handleFolderUpload} />
@@ -137,39 +151,14 @@ export default function XgridsWizard() {
); -} +} \ No newline at end of file diff --git a/scripts/preprocess/convert_lci_to_ply.py b/scripts/preprocess/convert_lci_to_ply.py new file mode 100644 index 0000000..28b16ab --- /dev/null +++ b/scripts/preprocess/convert_lci_to_ply.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +""" +XGrids LCI to PLY Converter +Converts XGrids .lci collision files to standard .ply format + +Usage: + # Convert with default output name (collision_mesh.ply) + python convert_lci_to_ply.py path/to/collision.lci + + # Specify custom output file + python convert_lci_to_ply.py path/to/collision.lci output/mesh.ply + + # Verbose output + python convert_lci_to_ply.py path/to/collision.lci -v +""" + +import argparse +import struct +import sys +from pathlib import Path + +# Constants +LCI_MAGIC = 0x6C6C6F63 # 'coll' in little-endian +MESH_HEADER_SIZE = 40 # bytes per mesh header + + +def read_lci_file(filepath, verbose=False): + """ + Parse XGrids .lci collision file according to official LCI specification + + Args: + filepath: Path to input .lci file + verbose: Enable verbose output + + Returns: + Tuple of (vertices, faces) where: + - vertices: List of (x, y, z) tuples + - faces: List of (v0, v1, v2) tuples with vertex indices + """ + filepath = Path(filepath) + + if not filepath.exists(): + raise FileNotFoundError(f"Input file not found: {filepath}") + + with open(filepath, "rb") as f: + lci_data = f.read() + + # Validate minimum file size + if len(lci_data) < 48: + raise ValueError("File too small to be valid LCI format (minimum 48 bytes required)") + + # Warn about large files that may cause memory issues + file_size_mb = len(lci_data) / (1024 * 1024) + if file_size_mb > 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()