Source code for parq_blockmodel.mesh.triangulation

"""Core mesh generation logic for converting block models to triangle meshes.

This module provides the BlockMeshGenerator class that converts a regular
block model (represented by RegularGeometry + sparse/dense block data) into
a triangulated surface mesh with optional per-vertex and per-face attributes.

Key responsibilities:
    - Generate vertex coordinates in world space (with rotation applied).
    - Generate triangle connectivity with consistent winding order.
    - Map block attributes to vertex/face attributes.
    - Handle sparse block models (only vertices for present blocks).
    - Support surface-only vs. full interior mesh generation.
    - Validate right-handed coordinates.
"""

from typing import Optional, Set, Tuple

import numpy as np
import pandas as pd
from numpy.typing import NDArray

from parq_blockmodel.geometry import RegularGeometry
from parq_blockmodel.mesh.types import TriangleMesh


[docs] class BlockMeshGenerator: """Convert a block model to a triangulated surface mesh. This class generates vertex and face arrays from a RegularGeometry and optional block attribute data. Supports both rotated and non-rotated geometries, sparse and dense block layouts, and surface-only vs. full interior mesh generation. Coordinates are always in world space (right-handed), with rotation applied via the RegularGeometry's axis vectors (axis_u, axis_v, axis_w). Parameters ---------- geometry : RegularGeometry The block model geometry defining corner, block_size, shape, and axis orientation. Attributes ---------- geometry : RegularGeometry The block model geometry. Notes ----- All generated triangles use counter-clockwise (CCW) winding order when viewed from the exterior (right-hand rule). This ensures compatibility with standard 3D graphics renderers and PLY/GLB viewers. """ def __init__(self, geometry: RegularGeometry): """Initialize the mesh generator with a block model geometry. Parameters ---------- geometry : RegularGeometry The block model geometry. Must have orthonormal axis vectors. Raises ------ ValueError If the geometry's axis vectors are not properly oriented. """ self.geometry = geometry self._validate_handedness() def _validate_handedness(self) -> None: """Ensure the geometry defines a right-handed coordinate system. Checks that axis_u × axis_v ≈ axis_w (cross product). Raises ------ ValueError If the axes do not form a right-handed system. """ u = np.array(self.geometry.world.axis_u, dtype=float) v = np.array(self.geometry.world.axis_v, dtype=float) w = np.array(self.geometry.world.axis_w, dtype=float) cross = np.cross(u, v) if not np.allclose(cross, w, atol=1e-6): raise ValueError( f"Geometry axes do not form right-handed system. " f"axis_u × axis_v = {cross}, but axis_w = {w}." )
[docs] def block_corners_local(self) -> NDArray[np.floating]: """Get the 8 corner offsets for a unit cube in local (i, j, k) space. Returns a (8, 3) array where each row is a corner offset: (0,0,0), (1,0,0), (0,1,0), (1,1,0), (0,0,1), (1,0,1), (0,1,1), (1,1,1). Returns ------- np.ndarray Shape (8, 3), corner offsets in local space. """ return np.array([ [0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 1], [0, 1, 1], [1, 1, 1], ], dtype=float)
[docs] def block_vertices_xyz(self, i: int, j: int, k: int) -> NDArray[np.floating]: """Get the 8 vertices of a block in world coordinates. Parameters ---------- i, j, k : int Logical block indices. Returns ------- np.ndarray Shape (8, 3), corner coordinates in world space. """ dx, dy, dz = self.geometry.local.block_size cx, cy, cz = self.geometry.local.corner # Local coordinates of block corners corners_local = self.block_corners_local() # Scale to block size corners_local[:, 0] = corners_local[:, 0] * dx corners_local[:, 1] = corners_local[:, 1] * dy corners_local[:, 2] = corners_local[:, 2] * dz # Translate to block position (lower corner) corners_local[:, 0] += cx + i * dx corners_local[:, 1] += cy + j * dy corners_local[:, 2] += cz + k * dz # Apply rotation (axis transformation) u = np.array(self.geometry.world.axis_u, dtype=float) v = np.array(self.geometry.world.axis_v, dtype=float) w = np.array(self.geometry.world.axis_w, dtype=float) R = np.column_stack([u, v, w]) # (3, 3) rotation matrix corners_world = corners_local @ R.T return corners_world
[docs] def cube_faces_ccw(self) -> NDArray[np.intp]: """Get the 6 faces of a cube with CCW winding (right-hand rule). Each face is a pair of triangles sharing an edge. Winding order is such that the outward normal points away from the cube center. Local vertex numbering: 0: (0,0,0) 1: (1,0,0) 2: (0,1,0) 3: (1,1,0) 4: (0,0,1) 5: (1,0,1) 6: (0,1,1) 7: (1,1,1) Returns ------- np.ndarray Shape (12, 3), 12 triangles (2 per face, 6 faces). """ # Each face: [tri1, tri2] # Bottom face (k=0): vertices 0,1,2,3, CCW from below (+z is up) # Top face (k=1): vertices 4,5,6,7, CCW from above # Front face (j=0): vertices 0,1,4,5, CCW from front (+y is back) # Back face (j=1): vertices 2,3,6,7, CCW from back # Left face (i=0): vertices 0,2,4,6, CCW from left (+x is right) # Right face (i=1): vertices 1,3,5,7, CCW from right triangles = [ # Bottom face (k=0) [0, 2, 1], # CCW from below [1, 2, 3], # Top face (k=1) [4, 5, 6], # CCW from above [5, 7, 6], # Front face (j=0) [0, 1, 4], # CCW from front [1, 5, 4], # Back face (j=1) [2, 6, 3], # CCW from back [3, 6, 7], # Left face (i=0) [0, 4, 2], # CCW from left [2, 4, 6], # Right face (i=1) [1, 3, 5], # CCW from right [3, 7, 5], ] return np.array(triangles, dtype=np.intp)
[docs] def surface_faces_ccw(self) -> NDArray[np.intp]: """Get only the exterior surface faces of a cube. For a sparse block model, we typically want only faces on the boundary of the model. This method returns which faces are "exterior" in a dense grid context. When sparse blocks are present, we'll filter out internal faces via connectivity checks. Returns the same 12 triangles as cube_faces_ccw(), but this is a placeholder for future boundary detection logic. Returns ------- np.ndarray Shape (12, 3), exterior triangles. """ # For now, return all faces. In a sparse model, faces between # adjacent blocks will be filtered out by _filter_shared_faces. return self.cube_faces_ccw()
[docs] def triangulate( self, block_data: Optional[pd.DataFrame] = None, surface_only: bool = True, sparse: Optional[bool] = None, ) -> TriangleMesh: """Generate a triangle mesh from block model data. Parameters ---------- block_data : pd.DataFrame, optional Block model data with columns like "i", "j", "k", "grade", "density", etc. If provided, attributes will be mapped to the mesh. If None, only geometry is returned with no attribute data. sparse : bool, optional If True, include only blocks present in block_data. If False or None, generate the full dense mesh. If sparse=True but block_data is None, only non-empty blocks are included. surface_only : bool, default True If True, include only exterior surface faces (shared faces with adjacent blocks are removed). If False, include all faces (interior voids remain empty). Only meaningful for sparse models. Returns ------- TriangleMesh The generated mesh with vertices, faces, and optional attributes. Raises ------ ValueError If block_data is incompatible with the geometry. """ if sparse is None: sparse = block_data is None or len(block_data) < int(np.prod(self.geometry.local.shape)) if sparse and block_data is not None: blocks_to_include = self._get_block_indices_from_data(block_data) elif sparse: blocks_to_include = None # No data, return empty mesh else: ni, nj, nk = self.geometry.local.shape blocks_to_include = [ (i, j, k) for i in range(ni) for j in range(nj) for k in range(nk) ] # Generate vertex and face lists vertices, faces, vertex_ijk_list, face_ijk_list = self._generate_vertices_faces( blocks_to_include, surface_only=surface_only, sparse=sparse, ) # Map attributes if data is provided vertex_attrs = {} face_attrs = {} if block_data is not None: vertex_attrs, face_attrs = self._map_attributes( block_data, vertex_ijk_list, face_ijk_list, ) # Create metadata metadata = { "corner": tuple(self.geometry.local.corner), "block_size": tuple(self.geometry.local.block_size), "shape": tuple(self.geometry.local.shape), "axis_u": tuple(self.geometry.world.axis_u), "axis_v": tuple(self.geometry.world.axis_v), "axis_w": tuple(self.geometry.world.axis_w), "surface_only": surface_only, "sparse": sparse, } if self.geometry.world.srs: metadata["srs"] = self.geometry.world.srs mesh = TriangleMesh( vertices=vertices, faces=faces, vertex_attributes=vertex_attrs, face_attributes=face_attrs, vertex_ijk=np.array(vertex_ijk_list, dtype=np.intp) if vertex_ijk_list else None, face_ijk=np.array(face_ijk_list, dtype=np.intp) if face_ijk_list else None, metadata=metadata, ) mesh.validate() return mesh
def _get_block_indices_from_data(self, block_data: pd.DataFrame) -> list[Tuple[int, int, int]]: """Extract (i, j, k) block indices from DataFrame. Parameters ---------- block_data : pd.DataFrame Must contain either "i", "j", "k" columns or be indexable by (i,j,k). Returns ------- list of tuple List of (i, j, k) block indices. Raises ------ ValueError If block_data does not contain required positional information. """ if "i" in block_data.columns and "j" in block_data.columns and "k" in block_data.columns: return list(zip( block_data["i"].astype(int), block_data["j"].astype(int), block_data["k"].astype(int), )) elif isinstance(block_data.index, pd.MultiIndex) and block_data.index.names == ["i", "j", "k"]: return list(block_data.index) else: raise ValueError( "block_data must contain 'i', 'j', 'k' columns or be indexed by (i, j, k)." ) def _generate_vertices_faces( self, blocks_to_include: Optional[list[Tuple[int, int, int]]], surface_only: bool, sparse: bool, ) -> Tuple[NDArray, NDArray, list, list]: """Generate vertex and face arrays. Returns ------- tuple (vertices, faces, vertex_ijk_list, face_ijk_list) """ if not sparse or blocks_to_include is None: return self._generate_dense_mesh(surface_only) else: return self._generate_sparse_mesh(blocks_to_include, surface_only) def _generate_dense_mesh( self, surface_only: bool, ) -> Tuple[NDArray, NDArray, list, list]: """Generate mesh for full dense grid.""" vertices_list = [] faces_list = [] vertex_ijk_list = [] face_ijk_list = [] vertex_map = {} # (i, j, k, corner_idx) -> global_vertex_idx next_vertex_idx = 0 ni, nj, nk = self.geometry.local.shape for k in range(nk): for j in range(nj): for i in range(ni): # Get 8 corner vertices for this block block_corners = self.block_vertices_xyz(i, j, k) # Map or create vertices block_vertex_indices = [] for corner_idx in range(8): key = (i, j, k, corner_idx) if key not in vertex_map: vertex_map[key] = next_vertex_idx vertices_list.append(block_corners[corner_idx]) vertex_ijk_list.append((i, j, k)) next_vertex_idx += 1 block_vertex_indices.append(vertex_map[key]) # Add faces base_faces = self.cube_faces_ccw() for face_idx, face in enumerate(base_faces): global_face = [block_vertex_indices[v] for v in face] faces_list.append(global_face) face_ijk_list.append((i, j, k)) vertices = np.array(vertices_list, dtype=float) faces = np.array(faces_list, dtype=np.intp) return vertices, faces, vertex_ijk_list, face_ijk_list def _generate_sparse_mesh( self, blocks_to_include: list[Tuple[int, int, int]], surface_only: bool, ) -> Tuple[NDArray, NDArray, list, list]: """Generate mesh for sparse block set.""" blocks_set = set(blocks_to_include) vertices_list = [] faces_list = [] vertex_ijk_list = [] face_ijk_list = [] vertex_map = {} # (i, j, k, corner_idx) -> global_vertex_idx next_vertex_idx = 0 for i, j, k in blocks_to_include: # Get 8 corner vertices for this block block_corners = self.block_vertices_xyz(i, j, k) # Map or create vertices (shared with neighbors if they exist) block_vertex_indices = [] for corner_idx in range(8): key = (i, j, k, corner_idx) if key not in vertex_map: vertex_map[key] = next_vertex_idx vertices_list.append(block_corners[corner_idx]) vertex_ijk_list.append((i, j, k)) next_vertex_idx += 1 block_vertex_indices.append(vertex_map[key]) # Add faces, filtering if surface_only base_faces = self.cube_faces_ccw() for face_idx, face in enumerate(base_faces): # Determine which block face this is is_exterior = self._is_exterior_face(i, j, k, face_idx, blocks_set) if not surface_only or is_exterior: global_face = [block_vertex_indices[v] for v in face] faces_list.append(global_face) face_ijk_list.append((i, j, k)) vertices = np.array(vertices_list, dtype=float) if vertices_list else np.empty((0, 3), dtype=float) faces = np.array(faces_list, dtype=np.intp) if faces_list else np.empty((0, 3), dtype=np.intp) return vertices, faces, vertex_ijk_list, face_ijk_list def _is_exterior_face( self, i: int, j: int, k: int, face_idx: int, blocks_set: Set[Tuple[int, int, int]], ) -> bool: """Check if a cube face is on the exterior of the sparse model. face_idx maps to: 0,1 : bottom (k=0) 2,3 : top (k=1) 4,5 : front (j=0) 6,7 : back (j=1) 8,9 : left (i=0) 10,11 : right (i=1) """ face_to_direction = { (0, 1): (0, 0, -1), # bottom (2, 3): (0, 0, 1), # top (4, 5): (0, -1, 0), # front (6, 7): (0, 1, 0), # back (8, 9): (-1, 0, 0), # left (10, 11): (1, 0, 0), # right } for (f1, f2), direction in face_to_direction.items(): if face_idx in (f1, f2): ni, nj, nk = direction neighbor = (i + ni, j + nj, k + nk) return neighbor not in blocks_set return True # default: exterior def _map_attributes( self, block_data: pd.DataFrame, vertex_ijk_list: list[Tuple[int, int, int]], face_ijk_list: list[Tuple[int, int, int]], ) -> Tuple[dict, dict]: """Map block attributes to vertex and face attributes. For each vertex, we look up the block(s) it belongs to and assign attributes. For vertices shared between blocks, we use the first block's attributes (or could interpolate in future). Returns ------- tuple (vertex_attributes, face_attributes) as dicts of arrays. """ vertex_attrs = {} face_attrs = {} # Identify which columns are attributes (not positional) positional = {"i", "j", "k", "x", "y", "z", "block_id"} attribute_cols = [col for col in block_data.columns if col not in positional] # Create a mapping from (i, j, k) to attributes block_to_attrs = {} if "i" in block_data.columns and "j" in block_data.columns and "k" in block_data.columns: for _, row in block_data.iterrows(): key = (int(row["i"]), int(row["j"]), int(row["k"])) block_to_attrs[key] = row[attribute_cols].to_dict() elif isinstance(block_data.index, pd.MultiIndex) and block_data.index.names == ["i", "j", "k"]: for key, row in block_data.iterrows(): block_to_attrs[key] = row[attribute_cols].to_dict() # Assign vertex attributes (from the block containing the vertex) for attr_name in attribute_cols: vertex_values = [] for ijk in vertex_ijk_list: if ijk in block_to_attrs: vertex_values.append(block_to_attrs[ijk].get(attr_name, np.nan)) else: vertex_values.append(np.nan) vertex_attrs[attr_name] = np.array(vertex_values, dtype=float) # Assign face attributes (from the block containing the face) for attr_name in attribute_cols: face_values = [] for ijk in face_ijk_list: if ijk in block_to_attrs: face_values.append(block_to_attrs[ijk].get(attr_name, np.nan)) else: face_values.append(np.nan) face_attrs[attr_name] = np.array(face_values, dtype=float) return vertex_attrs, face_attrs