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

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()