added collision conversion
This commit is contained in:
100
app/api/convert/route.ts
Normal file
100
app/api/convert/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/page.tsx
103
app/page.tsx
@@ -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,39 +151,14 @@ 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>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
325
scripts/preprocess/convert_lci_to_ply.py
Normal file
325
scripts/preprocess/convert_lci_to_ply.py
Normal 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()
|
||||||
Reference in New Issue
Block a user