added collision conversion

This commit is contained in:
Andreas Wilms
2026-03-09 15:18:50 +01:00
parent c8bac0f9eb
commit 24fa2fe3f5
4 changed files with 471 additions and 57 deletions

100
app/api/convert/route.ts Normal file
View File

@@ -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<NextResponse>((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 });
}
}

View File

@@ -1,4 +1,5 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
export default function XgridsWizard() { export default function XgridsWizard() {
@@ -19,21 +20,15 @@ export default function XgridsWizard() {
if (type === "DONE") { if (type === "DONE") {
setIsProcessing(false); setIsProcessing(false);
setStatus( setStatus("Pipeline Complete!");
`Conversion Complete! Downloading ${data.files.length} files...`,
);
// Loop through all generated files and trigger downloads
data.files.forEach((file: { name: string; blob: Blob }) => { data.files.forEach((file: { name: string; blob: Blob }) => {
downloadFile(file.blob, file.name); downloadFile(file.blob, file.name);
}); });
} }
}; };
return () => worker.terminate(); return () => worker.terminate();
}, []); }, []);
// Helper to trigger browser downloads
const downloadFile = (blob: Blob, name: string) => { const downloadFile = (blob: Blob, name: string) => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); 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; if (!files.length || !workerRef.current) return;
setIsProcessing(true); // FIND SPECIFIC ENTRIES
setStatus("Reading files into memory (this might take a moment)..."); const lciFile = files.find((f) => f.name.toLowerCase() === "collision.lci");
const lccFile = files.find((f) => f.name.toLowerCase().endsWith(".lcc"));
// Find the main .lcc file to act as our entry point
const lccFile = files.find((f) => f.name.endsWith(".lcc"));
if (!lciFile) {
setStatus("Error: 'collision.lci' not found in folder.");
return;
}
if (!lccFile) { if (!lccFile) {
setStatus("Error: Missing .lcc file in folder."); setStatus("Error: Main '.lcc' scene file not found.");
setIsProcessing(false);
return; return;
} }
setIsProcessing(true);
try { 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( const filesData = await Promise.all(
files.map(async (f) => ({ files.map(async (f) => ({
name: f.name, 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); const buffersToTransfer = filesData.map((f) => f.buffer);
workerRef.current.postMessage( workerRef.current.postMessage(
@@ -84,13 +100,13 @@ export default function XgridsWizard() {
type: "START_CONVERSION", type: "START_CONVERSION",
filesData, filesData,
mainLccName: lccFile.name, 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); console.error(error);
setStatus("Error reading files. Check console."); setStatus(`Error: ${error.message}`);
setIsProcessing(false); setIsProcessing(false);
} }
}; };
@@ -98,11 +114,9 @@ export default function XgridsWizard() {
return ( return (
<main className="min-h-screen bg-slate-900 text-white p-8 flex flex-col items-center"> <main className="min-h-screen bg-slate-900 text-white p-8 flex flex-col items-center">
<div className="max-w-2xl w-full bg-slate-800 rounded-xl p-8 shadow-2xl border border-slate-700"> <div className="max-w-2xl w-full bg-slate-800 rounded-xl p-8 shadow-2xl border border-slate-700">
<header className="mb-8"> <header className="mb-8 text-center">
<h1 className="text-3xl font-bold mb-2">Xgrids Scene Wizard</h1> <h1 className="text-3xl font-bold mb-2">Xgrids Scene Wizard</h1>
<p className="text-slate-400"> <p className="text-slate-400">Targeting collision.lci + scene.lcc</p>
Convert .lcc/.lci to SOG, LODs, and PLY meshes locally.
</p>
</header> </header>
<div className="space-y-6"> <div className="space-y-6">
@@ -117,13 +131,13 @@ export default function XgridsWizard() {
onChange={handleFolderUpload} onChange={handleFolderUpload}
/> />
<label htmlFor="folder-upload" className="cursor-pointer"> <label htmlFor="folder-upload" className="cursor-pointer">
<span className="bg-blue-600 px-6 py-3 rounded-md font-medium hover:bg-blue-500 transition-colors inline-block"> <span className="bg-blue-600 px-6 py-3 rounded-md font-medium hover:bg-blue-500 transition-all inline-block shadow-lg">
Select Xgrids Folder Select Xgrids Folder
</span> </span>
<p className="mt-4 text-sm text-slate-500 italic"> <p className="mt-4 text-sm text-slate-500 italic">
{files.length > 0 {files.length > 0
? `${files.length} files selected` ? `${files.length} files selected`
: "Drag and drop or click to browse"} : "Upload folder"}
</p> </p>
</label> </label>
</div> </div>
@@ -137,36 +151,11 @@ export default function XgridsWizard() {
</div> </div>
<button <button
onClick={startConversion} onClick={startPipeline}
disabled={files.length === 0 || isProcessing} disabled={files.length === 0 || isProcessing}
className="w-full py-4 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-700 disabled:text-slate-500 disabled:cursor-not-allowed rounded-lg font-bold transition-all shadow-lg" className="w-full py-4 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-700 disabled:text-slate-500 rounded-lg font-bold transition-all shadow-lg"
> >
{isProcessing ? ( {isProcessing ? "Processing..." : "Generate Scene Files"}
<span className="flex items-center justify-center gap-2">
<svg
className="animate-spin h-5 w-5 text-white"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Processing...
</span>
) : (
"Generate Scene Files"
)}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -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("<I", lci_data[0:4])[0]
if magic != LCI_MAGIC:
raise ValueError(
f"Invalid file format. Expected magic 0x{LCI_MAGIC:08X}, got 0x{magic:08X}"
)
version = struct.unpack("<I", lci_data[4:8])[0]
header_len = struct.unpack("<I", lci_data[8:12])[0]
min_x, min_y, min_z = struct.unpack("<fff", lci_data[12:24])
max_x, max_y, max_z = struct.unpack("<fff", lci_data[24:36])
cell_length_x = struct.unpack("<f", lci_data[36:40])[0]
cell_length_y = struct.unpack("<f", lci_data[40:44])[0]
mesh_num = struct.unpack("<I", lci_data[44:48])[0]
# Validate mesh count
if mesh_num == 0:
raise ValueError("LCI file contains no mesh data")
# Validate header length
expected_header = 48 + mesh_num * MESH_HEADER_SIZE
if header_len != expected_header:
raise ValueError(
f"Header length mismatch: expected {expected_header}, got {header_len}"
)
if verbose:
print("LCI File Information:")
print(f" Version: {version}")
print(f" Header length: {header_len} bytes")
print(
f" Bounding box: min({min_x:.2f}, {min_y:.2f}, {min_z:.2f}) "
f"max({max_x:.2f}, {max_y:.2f}, {max_z:.2f})"
)
print(f" Cell length: X={cell_length_x:.2f}, Y={cell_length_y:.2f}")
print(f" Mesh count: {mesh_num}\n")
# Read mesh headers (40 bytes each, starting at offset 48)
meshes = []
mesh_header_offset = 48
for i in range(mesh_num):
offset = mesh_header_offset + i * MESH_HEADER_SIZE
# Validate buffer bounds for mesh header
if offset + MESH_HEADER_SIZE > len(lci_data):
raise ValueError(
f"Mesh header {i} exceeds file size (offset {offset}, file size {len(lci_data)})"
)
index_x = struct.unpack("<I", lci_data[offset : offset + 4])[0]
index_y = struct.unpack("<I", lci_data[offset + 4 : offset + 8])[0]
data_offset = struct.unpack("<Q", lci_data[offset + 8 : offset + 16])[0]
bytes_size = struct.unpack("<Q", lci_data[offset + 16 : offset + 24])[0]
vertex_num = struct.unpack("<I", lci_data[offset + 24 : offset + 28])[0]
face_num = struct.unpack("<I", lci_data[offset + 28 : offset + 32])[0]
bvh_size = struct.unpack("<I", lci_data[offset + 32 : offset + 36])[0]
meshes.append(
{
"index_x": index_x,
"index_y": index_y,
"offset": data_offset,
"bytes_size": bytes_size,
"vertex_num": vertex_num,
"face_num": face_num,
"bvh_size": bvh_size,
}
)
if verbose:
print(
f"Mesh {i}: grid({index_x},{index_y}), "
f"{vertex_num:,} verts, {face_num:,} faces"
)
if verbose:
print()
# Read all mesh data
all_vertices = []
all_faces = []
global_vertex_offset = 0
for i, mesh in enumerate(meshes):
mesh_offset = mesh["offset"]
vertex_num = mesh["vertex_num"]
face_num = mesh["face_num"]
bytes_size = mesh["bytes_size"]
bvh_size = mesh["bvh_size"]
# Validate mesh data bounds
expected_data_size = (vertex_num * 12) + (face_num * 12) + bvh_size
if expected_data_size != bytes_size:
raise ValueError(
f"Mesh {i} data size mismatch: expected {expected_data_size} bytes, "
f"header specifies {bytes_size} bytes"
)
if mesh_offset + expected_data_size > 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("<fff", lci_data[pos : pos + 12])
vertices.append((x, y, z))
pos += 12
# Read faces
faces = []
for j in range(face_num):
v0, v1, v2 = struct.unpack("<III", lci_data[pos : pos + 12])
# Validate face indices
if v0 >= 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()