326 lines
9.9 KiB
Python
326 lines
9.9 KiB
Python
#!/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()
|