#!/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()