Source code for parq_blockmodel.solid

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, ClassVar, Literal, Protocol

import numpy as np
import pyvista as pv

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


def _resolve_geometry_like(grid: Any) -> RegularGeometry:
    if isinstance(grid, RegularGeometry):
        return grid

    if hasattr(grid, "geometry") and isinstance(grid.geometry, RegularGeometry):
        return grid.geometry

    raise TypeError("evaluate() expects a RegularGeometry or an object exposing .geometry as RegularGeometry.")


def _centroid_points(geometry: RegularGeometry) -> np.ndarray:
    # Compute centroids once and return (N, 3) world-space points.
    return np.asarray(geometry._centroids.T, dtype=float)


[docs] class Solid(Protocol): """Protocol for volumetric geometry classifiers."""
[docs] def evaluate(self, grid: Any) -> np.ndarray: """Return a boolean mask for grid centroids inside the solid."""
def _triangles_from_3dface(entity: Any) -> list[np.ndarray]: points = [ np.asarray(entity.dxf.vtx0, dtype=float), np.asarray(entity.dxf.vtx1, dtype=float), np.asarray(entity.dxf.vtx2, dtype=float), np.asarray(entity.dxf.vtx3, dtype=float), ] # DXF triangles repeat the last point for vtx3. unique: list[np.ndarray] = [points[0]] for point in points[1:]: if not np.allclose(point, unique[-1]): unique.append(point) if len(unique) >= 3 and np.allclose(unique[-1], unique[0]): unique.pop() if len(unique) == 3: return [np.vstack(unique)] if len(unique) == 4: return [np.vstack([unique[0], unique[1], unique[2]]), np.vstack([unique[0], unique[2], unique[3]])] return [] def _collect_entity_triangles(entity: Any) -> list[np.ndarray]: entity_type = entity.dxftype() if entity_type == "3DFACE": return _triangles_from_3dface(entity) if entity_type in {"INSERT", "POLYLINE", "POLYFACE", "MESH"} and hasattr(entity, "virtual_entities"): triangles: list[np.ndarray] = [] for child in entity.virtual_entities(): triangles.extend(_collect_entity_triangles(child)) return triangles return [] def _triangle_mesh_from_triangles(triangles: list[np.ndarray]) -> TriangleMesh: vertices: list[tuple[float, float, float]] = [] vertex_index: dict[tuple[float, float, float], int] = {} faces: list[tuple[int, int, int]] = [] for tri in triangles: indices: list[int] = [] for point in tri: key = (float(point[0]), float(point[1]), float(point[2])) if key not in vertex_index: vertex_index[key] = len(vertices) vertices.append(key) indices.append(vertex_index[key]) faces.append((indices[0], indices[1], indices[2])) return TriangleMesh( vertices=np.asarray(vertices, dtype=float), faces=np.asarray(faces, dtype=np.intp), ) def _resolve_dxf_triangles(path: str | Path, by: Literal["layer", "block"]) -> dict[str, list[np.ndarray]]: try: import ezdxf except ImportError as exc: raise ImportError("MeshSolid.from_dxf() requires optional dependency 'ezdxf'.") from exc doc = ezdxf.readfile(str(path)) msp = doc.modelspace() groups: dict[str, list[np.ndarray]] = {} if by == "layer": for entity in msp: triangles = _collect_entity_triangles(entity) if triangles: groups.setdefault(str(entity.dxf.layer), []).extend(triangles) return groups for insert in msp.query("INSERT"): triangles = _collect_entity_triangles(insert) if triangles: groups.setdefault(str(insert.dxf.name), []).extend(triangles) return groups def _to_polydata(mesh: TriangleMesh) -> pv.PolyData: mesh.validate() faces = np.hstack( [ np.full((mesh.n_faces, 1), 3, dtype=np.int64), np.asarray(mesh.faces, dtype=np.int64), ] ).ravel() return pv.PolyData(np.asarray(mesh.vertices, dtype=float), faces) def _validate_solid_surface(surface: pv.PolyData) -> None: if not surface.is_all_triangles: raise ValueError("MeshSolid requires a triangulated mesh.") if surface.n_open_edges != 0: raise ValueError("MeshSolid requires a watertight mesh (n_open_edges must be zero).") if not surface.is_manifold: raise ValueError("MeshSolid requires a manifold mesh.") def _triangle_mesh_from_polydata(surface: pv.PolyData) -> TriangleMesh: if not isinstance(surface, pv.PolyData): raise TypeError("from_pyvista() expects a pyvista.PolyData object.") faces_raw = np.asarray(surface.faces, dtype=np.int64) if faces_raw.size == 0: raise ValueError("MeshSolid requires a non-empty mesh.") if faces_raw.size % 4 != 0: raise ValueError("MeshSolid requires triangle-only PolyData faces.") faces = faces_raw.reshape(-1, 4) if not np.all(faces[:, 0] == 3): raise ValueError("MeshSolid requires triangle-only PolyData faces.") return TriangleMesh( vertices=np.asarray(surface.points, dtype=float), faces=faces[:, 1:].astype(np.intp), )
[docs] @dataclass(frozen=True) class MeshSolid: """Closed 3D solid represented by a triangulated mesh.""" EVALUATE_CHUNK_SIZE: ClassVar[int] = 1_000_000 mesh: TriangleMesh name: str | None = None _surface: pv.PolyData = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: surface = _to_polydata(self.mesh) _validate_solid_surface(surface) object.__setattr__(self, "_surface", surface)
[docs] @classmethod def from_mesh(cls, mesh: TriangleMesh, name: str | None = None) -> "MeshSolid": """Create a MeshSolid from an in-memory triangle mesh.""" return cls(mesh=mesh, name=name)
[docs] @classmethod def from_ply(cls, path: str | Path, name: str | None = None) -> "MeshSolid": """Create a MeshSolid from a PLY mesh file.""" mesh = read_ply(path) return cls(mesh=mesh, name=name)
[docs] @classmethod def from_pyvista(cls, surface: pv.PolyData, name: str | None = None) -> "MeshSolid": """Create a MeshSolid directly from a PyVista PolyData surface.""" _validate_solid_surface(surface) mesh = _triangle_mesh_from_polydata(surface) mesh.validate() solid = cls.__new__(cls) object.__setattr__(solid, "mesh", mesh) object.__setattr__(solid, "name", name) object.__setattr__(solid, "_surface", surface) return solid
[docs] @classmethod def from_dxf( cls, path: str | Path, *, name: str | None = None, by: Literal["layer", "block"] = "layer", ) -> "MeshSolid": """Create a MeshSolid from DXF 3D-face geometry grouped by layer or block.""" if by not in {"layer", "block"}: raise ValueError("from_dxf() argument 'by' must be 'layer' or 'block'.") grouped = _resolve_dxf_triangles(path, by=by) available = sorted(grouped.keys()) if not available: raise ValueError("No supported DXF solid geometry found. Supported entities include 3DFACE.") selected_name = name if selected_name is None: if len(available) > 1: raise ValueError( f"Multiple DXF solid candidates found for by='{by}': {available}. " f"Provide name='<candidate>' to select one." ) selected_name = available[0] if selected_name not in grouped: raise ValueError(f"DXF solid candidate '{selected_name}' not found for by='{by}'. Available: {available}") mesh = _triangle_mesh_from_triangles(grouped[selected_name]) return cls(mesh=mesh, name=selected_name)
[docs] def evaluate(self, grid: Any) -> np.ndarray: """Return boolean mask of centroid points inside the solid. Boundary points are classified as inside. """ geometry = _resolve_geometry_like(grid) points = _centroid_points(geometry) n_points = points.shape[0] mask = np.zeros(n_points, dtype=bool) xmin, xmax, ymin, ymax, zmin, zmax = self._surface.bounds prefiltered = ( (points[:, 0] >= xmin) & (points[:, 0] <= xmax) & (points[:, 1] >= ymin) & (points[:, 1] <= ymax) & (points[:, 2] >= zmin) & (points[:, 2] <= zmax) ) candidate_idx = np.flatnonzero(prefiltered) if candidate_idx.size == 0: return mask chunk_size = max(1, int(self.EVALUATE_CHUNK_SIZE)) for start in range(0, candidate_idx.size, chunk_size): idx = candidate_idx[start : start + chunk_size] cloud = pv.PolyData(points[idx]) if hasattr(cloud, "select_interior_points"): selected = cloud.select_interior_points( self._surface, method="cell_locator", check_surface=False, ) mask[idx] = np.asarray(selected.point_data["selected_points"], dtype=bool) else: selected = cloud.select_enclosed_points(self._surface, check_surface=False) mask[idx] = np.asarray(selected.point_data["SelectedPoints"], dtype=bool) return mask