Source code for parq_blockmodel.mesh.ply

"""PLY (Polygon File Format) support for triangulated meshes.

PLY is the canonical internal format for mesh storage, supporting
lossless round-tripping of all vertex/face attributes and geometry metadata.

Format details:
    - ASCII or binary (we use ASCII for readability, binary for efficiency)
    - Per-vertex properties: x, y, z, and optional attributes
    - Per-face connectivity with optional face attributes
    - Custom properties stored as additional scalar fields
    - Metadata in comments and extra properties

References:
    https://en.wikipedia.org/wiki/PLY_(file_format)
"""

import json
from pathlib import Path
from typing import Optional, Union

import numpy as np
import pandas as pd

from parq_blockmodel.mesh.types import TriangleMesh


[docs] def write_ply( mesh: TriangleMesh, output_path: Union[str, Path], binary: bool = False, ) -> None: """Write a TriangleMesh to a PLY file. The PLY file will contain: - Vertex coordinates (x, y, z) - Optional per-vertex attributes (grade, density, rock_type, etc.) - Vertex ijk indices (if available) - Face connectivity (as vertex_indices) - Optional per-face attributes - Metadata in comments and custom properties Parameters ---------- mesh : TriangleMesh The mesh to write. output_path : str or Path Path to the output PLY file. binary : bool, default False If True, write in binary format. If False, write ASCII. Raises ------ ValueError If the mesh is invalid or contains unsupported attribute types. """ output_path = Path(output_path) mesh.validate() # Prepare vertex and face data vertex_data = _prepare_vertex_data(mesh) face_data = _prepare_face_data(mesh) if binary: _write_ply_binary(output_path, mesh, vertex_data, face_data) else: _write_ply_ascii(output_path, mesh, vertex_data, face_data)
[docs] def read_ply( input_path: Union[str, Path], ) -> TriangleMesh: """Read a TriangleMesh from a PLY file. Reconstructs the full mesh including vertex/face attributes and metadata. Parameters ---------- input_path : str or Path Path to the input PLY file. Returns ------- TriangleMesh The reconstructed mesh. Raises ------ ValueError If the PLY file is malformed or incompatible. """ input_path = Path(input_path) # Parse PLY header and data with open(input_path, 'r') as f: lines = f.readlines() # Parse header header_lines = [] data_start = 0 for i, line in enumerate(lines): header_lines.append(line.strip()) if line.strip() == "end_header": data_start = i + 1 break if "end_header" not in header_lines: raise ValueError("PLY file missing end_header marker.") # Extract metadata from comments metadata = {} vertex_count = 0 face_count = 0 vertex_properties = [] face_properties = [] i = 0 while i < len(header_lines): line = header_lines[i] if line.startswith("comment "): comment_data = line[8:].strip() if "=" in comment_data: key, value = comment_data.split("=", 1) try: metadata[key.strip()] = json.loads(value.strip()) except json.JSONDecodeError: metadata[key.strip()] = value.strip() elif line.startswith("element vertex"): vertex_count = int(line.split()[-1]) elif line.startswith("element face"): face_count = int(line.split()[-1]) elif line.startswith("property "): # Parse property (type name, or list count type name for faces) if header_lines[i-1].startswith("element vertex"): parts = line.split() if parts[1] == "list": raise ValueError("Vertex properties cannot be lists in this implementation.") vertex_properties.append(parts[-1]) elif header_lines[i-1].startswith("element face"): pass # Face properties are handled separately i += 1 # Read data (ASCII for simplicity; binary support can be added) vertices_list = [] faces_list = [] for i in range(data_start, data_start + vertex_count): parts = lines[i].strip().split() vertices_list.append([float(p) for p in parts[:3]]) for i in range(data_start + vertex_count, data_start + vertex_count + face_count): parts = lines[i].strip().split() n = int(parts[0]) face = [int(parts[j]) for j in range(1, 1 + n)] if n != 3: raise ValueError(f"Non-triangular face encountered: {n} vertices.") faces_list.append(face) vertices = np.array(vertices_list, dtype=float) faces = np.array(faces_list, dtype=np.intp) # TODO: Parse vertex and face attributes from data vertex_attributes = {} face_attributes = {} mesh = TriangleMesh( vertices=vertices, faces=faces, vertex_attributes=vertex_attributes, face_attributes=face_attributes, metadata=metadata, ) return mesh
def _prepare_vertex_data(mesh: TriangleMesh) -> dict: """Prepare vertex data for PLY export. Returns dict with "columns" (list of names) and "data" (dict of arrays). """ columns = ["x", "y", "z"] data = { "x": mesh.vertices[:, 0], "y": mesh.vertices[:, 1], "z": mesh.vertices[:, 2], } # Add ijk if available if mesh.vertex_ijk is not None: columns.extend(["i", "j", "k"]) data["i"] = mesh.vertex_ijk[:, 0] data["j"] = mesh.vertex_ijk[:, 1] data["k"] = mesh.vertex_ijk[:, 2] # Add other vertex attributes for attr_name in sorted(mesh.vertex_attributes.keys()): columns.append(attr_name) data[attr_name] = mesh.vertex_attributes[attr_name] return {"columns": columns, "data": data} def _prepare_face_data(mesh: TriangleMesh) -> dict: """Prepare face data for PLY export.""" columns = ["vertex_indices"] data = {"vertex_indices": mesh.faces} # Add ijk if available if mesh.face_ijk is not None: columns.extend(["i", "j", "k"]) data["i"] = mesh.face_ijk[:, 0] data["j"] = mesh.face_ijk[:, 1] data["k"] = mesh.face_ijk[:, 2] # Add face attributes for attr_name in sorted(mesh.face_attributes.keys()): columns.append(attr_name) data[attr_name] = mesh.face_attributes[attr_name] return {"columns": columns, "data": data} def _write_ply_ascii( output_path: Path, mesh: TriangleMesh, vertex_data: dict, face_data: dict, ) -> None: """Write mesh to ASCII PLY file.""" with open(output_path, 'w') as f: # Header f.write("ply\n") f.write("format ascii 1.0\n") # Metadata comments for key, value in mesh.metadata.items(): if isinstance(value, (dict, list)): value_str = json.dumps(value) else: value_str = str(value) f.write(f"comment {key}={value_str}\n") # Vertex element _int_props = {"i", "j", "k"} f.write(f"element vertex {mesh.n_vertices}\n") for col in vertex_data["columns"]: ply_type = "int" if col in _int_props else "float" f.write(f"property {ply_type} {col}\n") # Face element f.write(f"element face {mesh.n_faces}\n") f.write("property list uchar int vertex_indices\n") for col in face_data["columns"]: if col != "vertex_indices": ply_type = "int" if col in _int_props else "float" f.write(f"property {ply_type} {col}\n") f.write("end_header\n") # Vertex data — build each row as a space-separated string. # Using numpy directly is much faster than iterrows(). col_arrays = [vertex_data["data"][col] for col in vertex_data["columns"]] for row_vals in zip(*col_arrays): f.write(" ".join(str(v) for v in row_vals) + "\n") # Face data for face_idx, face in enumerate(mesh.faces): f.write(f"3 {face[0]} {face[1]} {face[2]}") if mesh.face_ijk is not None: f.write(f" {mesh.face_ijk[face_idx, 0]} {mesh.face_ijk[face_idx, 1]} {mesh.face_ijk[face_idx, 2]}") for attr_name in sorted(mesh.face_attributes.keys()): f.write(f" {mesh.face_attributes[attr_name][face_idx]}") f.write("\n") def _write_ply_binary( output_path: Path, mesh: TriangleMesh, vertex_data: dict, face_data: dict, ) -> None: """Write mesh to binary PLY file (little-endian). Not yet implemented; falls back to ASCII for now. """ # TODO: Implement binary PLY writing _write_ply_ascii(output_path, mesh, vertex_data, face_data)