From 9d02ee6b0dde13905e4ac2940286f15b81004455 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Thu, 21 May 2026 18:52:18 +0200 Subject: [PATCH 1/8] Allow polymorphic surface sampling; cleanup exports Make Shape.generate_surface_mesh polymorphic for Box, Sphere and Spinodoid by accepting optional bounds/resolution and falling back to the SDF/marching-cubes path when callers request explicit sampling. Add BoundsType imports and update docstrings to explain the behavior. Remove the legacy rotate_pv_euler function and its export from operations/__init__ (cleanup of Euler helper). Tidy Rve by removing unused is_matrix/matrix_number defaults. Expose new TPMS-related classes (GradedInfill, Sweep) in shape exports and update __all__ accordingly. Add a lint test (tests/test_no_top_level_ocp_imports.py) to prevent top-level OCP imports outside the CAD boundary. --- microgen/__init__.py | 6 +- microgen/operations.py | 33 ---------- microgen/rve.py | 3 - microgen/shape/__init__.py | 4 +- microgen/shape/box.py | 17 ++++- microgen/shape/sphere.py | 17 ++++- microgen/shape/spinodoid.py | 17 ++++- tests/test_no_top_level_ocp_imports.py | 88 ++++++++++++++++++++++++++ 8 files changed, 143 insertions(+), 42 deletions(-) create mode 100644 tests/test_no_top_level_ocp_imports.py diff --git a/microgen/__init__.py b/microgen/__init__.py index 1623109e..dac1514e 100644 --- a/microgen/__init__.py +++ b/microgen/__init__.py @@ -17,7 +17,6 @@ rescale, rotate, rotate_euler, - rotate_pv_euler, ) from .periodic import periodic_split_and_translate from .phase import Phase @@ -37,6 +36,7 @@ Ellipsoid, ExtrudedPolygon, FaceCenteredCubic, + GradedInfill, Infill, NormedDistance, Octahedron, @@ -48,6 +48,7 @@ Sphere, SphericalTpms, Spinodoid, + Sweep, Tpms, TruncatedCube, TruncatedCuboctahedron, @@ -76,6 +77,7 @@ "Ellipsoid", "ExtrudedPolygon", "FaceCenteredCubic", + "GradedInfill", "Infill", "NonBoxMeshError", "NormedDistance", @@ -91,6 +93,7 @@ "Shape", "Sphere", "Spinodoid", + "Sweep", "Tpms", "TruncatedCube", "TruncatedCuboctahedron", @@ -117,7 +120,6 @@ "rescale", "rotate", "rotate_euler", - "rotate_pv_euler", "Report", "SingleMesh", "surface_functions", diff --git a/microgen/operations.py b/microgen/operations.py index 1fa167d9..1a3982bc 100644 --- a/microgen/operations.py +++ b/microgen/operations.py @@ -126,39 +126,6 @@ def rotate_euler( return rotate(obj, center, rotation) -def rotate_pv_euler( - obj: pv.PolyData, - center: Sequence[float], - angles_or_rotation: Sequence[float] | Rotation, -) -> pv.PolyData: - """Rotate object according to ZXZ Euler angle convention. - - :param obj: Object to rotate - :param center: numpy array (x, y, z) - :param angles_or_rotation: list of Euler angles (psi, theta, phi) in - degrees, or a scipy ``Rotation`` object - - :return: Rotated object - """ - if isinstance(angles_or_rotation, Rotation): - rotation = angles_or_rotation - else: - rotation = Rotation.from_euler("ZXZ", angles_or_rotation, degrees=True) - - rotvec = rotation.as_rotvec(degrees=True) - angle = np.linalg.norm(rotvec) - if angle == 0: - return obj - axis = rotvec / angle - - return obj.rotate_vector( - vector=axis, - angle=angle, - point=tuple(center), - inplace=False, - ) - - def rescale(shape: CadShape, scale: float | tuple[float, float, float]) -> CadShape: """Rescale given object according to scale parameters [dim_x, dim_y, dim_z].""" from .phase import Phase # noqa: PLC0415 diff --git a/microgen/rve.py b/microgen/rve.py index 1ce5355d..116692a6 100644 --- a/microgen/rve.py +++ b/microgen/rve.py @@ -59,9 +59,6 @@ def __init__( self.min_point = self.center - 0.5 * self.dim self.max_point = self.center + 0.5 * self.dim - self.is_matrix = False - self.matrix_number = 0 - self._cached_box: CadShape | None = None @property diff --git a/microgen/shape/__init__.py b/microgen/shape/__init__.py index 2176527f..1f9f5218 100644 --- a/microgen/shape/__init__.py +++ b/microgen/shape/__init__.py @@ -48,7 +48,7 @@ TruncatedOctahedron, ) from .spinodoid import Spinodoid -from .tpms import CylindricalTpms, Infill, SphericalTpms, Tpms +from .tpms import CylindricalTpms, GradedInfill, Infill, SphericalTpms, Sweep, Tpms from .tpms_grading import NormedDistance if TYPE_CHECKING: @@ -162,6 +162,7 @@ def __init__(self: ShapeError, shape: str) -> None: "Ellipsoid", "ExtrudedPolygon", "FaceCenteredCubic", + "GradedInfill", "Infill", "NormedDistance", "Octahedron", @@ -173,6 +174,7 @@ def __init__(self: ShapeError, shape: str) -> None: "Sphere", "SphericalTpms", "Spinodoid", + "Sweep", "Tpms", "TruncatedCube", "TruncatedCuboctahedron", diff --git a/microgen/shape/box.py b/microgen/shape/box.py index 64fc46da..863447ef 100644 --- a/microgen/shape/box.py +++ b/microgen/shape/box.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType + from microgen.shape.shape import BoundsType class Box(Shape): @@ -105,10 +106,24 @@ def generate_cad(self: Box, **_: KwargsGenerateType) -> CadShape: def generate_surface_mesh( self: Box, + bounds: BoundsType | None = None, + resolution: int | None = None, level: int = 0, **_: KwargsGenerateType, ) -> pv.PolyData: - """Generate a box VTK shape using the given parameters.""" + """Generate a box VTK shape using the given parameters. + + When ``bounds`` or ``resolution`` is explicitly provided, fall back to + the implicit (marching-cubes-on-SDF) base implementation so polymorphic + callers that ask for a specific sampling get what they asked for. + Otherwise the native :class:`pyvista.Box` (fixed 6-face quad mesh + controlled by ``level``) is used. + """ + if bounds is not None or resolution is not None: + return super().generate_surface_mesh( + bounds=bounds, + resolution=resolution if resolution is not None else 50, + ) box = pv.Box( bounds=( self.center[0] - 0.5 * self.dim[0], diff --git a/microgen/shape/sphere.py b/microgen/shape/sphere.py index 32e0010d..afb1cc85 100644 --- a/microgen/shape/sphere.py +++ b/microgen/shape/sphere.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType + from microgen.shape.shape import BoundsType class Sphere(Shape): @@ -78,11 +79,25 @@ def generate_cad(self: Sphere, **_: KwargsGenerateType) -> CadShape: def generate_surface_mesh( self: Sphere, + bounds: BoundsType | None = None, + resolution: int | None = None, theta_resolution: int = 50, phi_resolution: int = 50, **_: KwargsGenerateType, ) -> pv.PolyData: - """Generate a sphere VTK shape using the given parameters.""" + """Generate a sphere VTK shape using the given parameters. + + When ``bounds`` or ``resolution`` is explicitly provided, fall back to + the implicit (marching-cubes-on-SDF) base implementation so polymorphic + callers that ask for a specific sampling get what they asked for. + Otherwise the native :class:`pyvista.Sphere` (parametric, controlled + by ``theta_resolution``/``phi_resolution``) is used. + """ + if bounds is not None or resolution is not None: + return super().generate_surface_mesh( + bounds=bounds, + resolution=resolution if resolution is not None else 50, + ) return pv.Sphere( radius=self.radius, center=tuple(self.center), diff --git a/microgen/shape/spinodoid.py b/microgen/shape/spinodoid.py index f184f075..087240bd 100644 --- a/microgen/shape/spinodoid.py +++ b/microgen/shape/spinodoid.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType + from microgen.shape.shape import BoundsType class Spinodoid(Shape): @@ -215,9 +216,23 @@ def generate_volume_mesh( def generate_surface_mesh( self: Spinodoid, + bounds: BoundsType | None = None, + resolution: int | None = None, **_: KwargsGenerateType, ) -> pv.PolyData: - """Generate the iso-surface as a PolyData with center+rotation applied.""" + """Generate the iso-surface as a PolyData with center+rotation applied. + + The native path uses the cached structured-grid surface whose resolution + is fixed at construction time (``Spinodoid(resolution=…)``). When + ``bounds`` or ``resolution`` is explicitly provided here, fall back to + the implicit (marching-cubes-on-SDF) base implementation so polymorphic + callers can re-sample the field at an arbitrary resolution / bbox. + """ + if bounds is not None or resolution is not None: + return super().generate_surface_mesh( + bounds=bounds, + resolution=resolution if resolution is not None else 50, + ) polydata = self.surface.copy() polydata = rotate(polydata, center=(0, 0, 0), rotation=self.orientation) return polydata.translate(xyz=self.center) diff --git a/tests/test_no_top_level_ocp_imports.py b/tests/test_no_top_level_ocp_imports.py new file mode 100644 index 00000000..beb2800c --- /dev/null +++ b/tests/test_no_top_level_ocp_imports.py @@ -0,0 +1,88 @@ +"""Lint-style test: no top-level OCP imports outside the CAD boundary. + +The ``[cad]`` extra (``cadquery-ocp-novtk``) is strictly optional. ``import +microgen`` must succeed without it, which means no module reachable from +``microgen.__init__`` may execute ``import OCP`` / ``from OCP …`` at +module-load time. Every OCP import must be either: + + * inside a function/method body (lazy); + * guarded by ``if TYPE_CHECKING:`` (only consumed by type checkers); or + * located inside the CAD boundary (``microgen/cad.py`` today; the + ``microgen/cad/`` subpackage after PR 4). + +This test walks every ``.py`` under ``microgen/`` and AST-checks the +top level for stray OCP imports. Failing here is what catches a future +refactor that accidentally drops an eager ``from OCP.… import …`` at +module scope and silently breaks ``pip install microgen`` (no extras). +""" +# ruff: noqa: S101 + +from __future__ import annotations + +import ast +from pathlib import Path + +MICROGEN_ROOT = Path(__file__).resolve().parent.parent / "microgen" + +# Files allowed to have top-level OCP imports (the CAD boundary). Paths are +# relative to ``microgen/``. Extend this list as the CAD subpackage grows +# (PR 4 splits ``cad.py`` into a ``cad/`` subpackage). +_CAD_BOUNDARY = { + "cad.py", +} + + +def _is_type_checking_block(node: ast.stmt) -> bool: + """True if ``node`` is ``if TYPE_CHECKING:`` (any common spelling).""" + if not isinstance(node, ast.If): + return False + test = node.test + if isinstance(test, ast.Name) and test.id == "TYPE_CHECKING": + return True + if ( + isinstance(test, ast.Attribute) + and isinstance(test.value, ast.Name) + and test.value.id == "typing" + and test.attr == "TYPE_CHECKING" + ): + return True + return False + + +def _top_level_imports(tree: ast.Module) -> list[ast.stmt]: + """Return module-level import statements, skipping ``if TYPE_CHECKING:``.""" + out: list[ast.stmt] = [] + for node in tree.body: + if isinstance(node, (ast.Import, ast.ImportFrom)): + out.append(node) + elif _is_type_checking_block(node): + # Skip — these imports never execute at runtime. + continue + return out + + +def _imports_ocp(node: ast.stmt) -> bool: + if isinstance(node, ast.ImportFrom): + return (node.module or "").split(".", 1)[0] == "OCP" + if isinstance(node, ast.Import): + return any(alias.name.split(".", 1)[0] == "OCP" for alias in node.names) + return False + + +def test_no_top_level_ocp_imports_outside_cad_boundary() -> None: + """Walk microgen/*.py and assert no eager OCP import escapes the CAD boundary.""" + offenders: list[str] = [] + for py in MICROGEN_ROOT.rglob("*.py"): + rel = py.relative_to(MICROGEN_ROOT).as_posix() + if rel in _CAD_BOUNDARY: + continue + tree = ast.parse(py.read_text(encoding="utf-8"), filename=str(py)) + for node in _top_level_imports(tree): + if _imports_ocp(node): + offenders.append(f"{rel}:{node.lineno}") + assert not offenders, ( + "Top-level OCP imports outside the CAD boundary " + f"({sorted(_CAD_BOUNDARY)}): {offenders}. " + "Move them inside a function body or under " + "``if TYPE_CHECKING:``." + ) From e1c083ed563993fda8cf64cf30e41f17a1fa7c92 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Thu, 21 May 2026 23:50:48 +0200 Subject: [PATCH 2/8] Refactor Rve to frozen dataclass and add grid Make Rve a frozen dataclass with validated inputs and explicit pbc flags. Introduce helper validators, cached min_point/max_point and a cached box property (lazy CAD box creation). Add Rve.grid(...) to produce a pyvista.StructuredGrid with flexible resolution handling. Improve __repr__ and update from_min_max to accept pbc. Expand tests to cover pbc behavior, immutability, min/max consistency, grid generation, invalid inputs, and repr content. --- microgen/rve.py | 157 +++++++++++++++++++++++++++++++++------------- tests/test_rve.py | 84 +++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 44 deletions(-) diff --git a/microgen/rve.py b/microgen/rve.py index 116692a6..b4b0454e 100644 --- a/microgen/rve.py +++ b/microgen/rve.py @@ -1,12 +1,17 @@ """Representative Volume Element (RVE). -The ``Rve.box`` attribute is a :class:`~microgen.cad.CadShape` wrapping an -OCCT box; it is built lazily on first access and requires the ``[cad]`` -extra (``cadquery-ocp-novtk``). +Frozen, immutable container for the RVE bounding box and its periodicity flags. + +``Rve.box`` is a :class:`~microgen.cad.CadShape` wrapping an OCCT box; it is +built lazily on first access and requires the ``[cad]`` extra. ``Rve.grid`` +returns a :class:`pyvista.StructuredGrid` aligned with the cell — used by +implicit shapes to sample SDF/level-set fields. """ from __future__ import annotations +from dataclasses import dataclass +from functools import cached_property from typing import TYPE_CHECKING import numpy as np @@ -16,78 +21,142 @@ if TYPE_CHECKING: from collections.abc import Sequence - from .cad import CadShape - - Vector3DType = tuple[float, float, float] | Sequence[float] + import numpy.typing as npt + import pyvista as pv + from .cad import CadShape + Vector3DType = tuple[float, float, float] | Sequence[float] | npt.NDArray[np.float64] + ResolutionType = int | tuple[int, int, int] | Sequence[int] + + +def _validate_center(center: object) -> np.ndarray: + if isinstance(center, (tuple, list)) and len(center) == _DIM: + return np.asarray(center, dtype=float) + if isinstance(center, np.ndarray) and center.shape == (_DIM,): + return center.astype(float, copy=True) + err_msg = f"center must be an array or Sequence of length {_DIM}" + raise ValueError(err_msg) + + +def _validate_dim(dim: object) -> np.ndarray: + if isinstance(dim, (int, float)) and not isinstance(dim, bool): + arr = np.array([dim] * _DIM, dtype=float) + elif isinstance(dim, (tuple, list)) and len(dim) == _DIM: + arr = np.asarray(dim, dtype=float) + elif isinstance(dim, np.ndarray) and dim.shape == (_DIM,): + arr = dim.astype(float, copy=True) + else: + err_msg = f"dim must be an array or Sequence of length {_DIM}" + raise ValueError(err_msg) + if np.any(arr <= 0): + err_msg = f"dimensions of the RVE must be greater than 0, got {arr.tolist()}" + raise ValueError(err_msg) + return arr + + +def _validate_pbc(pbc: object) -> tuple[bool, bool, bool]: + if isinstance(pbc, bool): + return (pbc, pbc, pbc) + if isinstance(pbc, (tuple, list)) and len(pbc) == _DIM: + return (bool(pbc[0]), bool(pbc[1]), bool(pbc[2])) + err_msg = f"pbc must be a bool or Sequence[bool] of length {_DIM}" + raise ValueError(err_msg) + + +@dataclass(frozen=True, init=False, eq=False, repr=False) class Rve: - """Representative Volume Element (RVE). + """Representative Volume Element (RVE) — frozen. :param center: center of the RVE - :param dim: dimensions of the RVE + :param dim: dimensions of the RVE (scalar → cube) + :param pbc: periodic-boundary-condition flags per axis ``(x, y, z)``; + defaults to fully periodic. A single ``bool`` is broadcast to all axes. """ + center: np.ndarray + dim: np.ndarray + pbc: tuple[bool, bool, bool] + def __init__( self: Rve, center: Vector3DType = (0, 0, 0), dim: float | Vector3DType = 1, + pbc: bool | tuple[bool, bool, bool] | Sequence[bool] = (True, True, True), ) -> None: """Initialize the RVE.""" - if isinstance(center, (tuple, list)) and len(center) == _DIM: - self.center = np.array(center) - elif isinstance(center, np.ndarray) and center.shape == (_DIM,): - self.center = center - else: - err_msg = f"center must be an array or Sequence of length {_DIM}" - raise ValueError(err_msg) - - if isinstance(dim, (int, float)): - self.dim = np.array([dim for _ in range(_DIM)]) - elif isinstance(dim, (tuple, list)) and len(dim) == _DIM: - self.dim = np.array(dim) - elif isinstance(dim, np.ndarray) and dim.shape == (_DIM,): - self.dim = dim - else: - err_msg = f"dim must be an array or Sequence of length {_DIM}" - raise ValueError(err_msg) + object.__setattr__(self, "center", _validate_center(center)) + object.__setattr__(self, "dim", _validate_dim(dim)) + object.__setattr__(self, "pbc", _validate_pbc(pbc)) + + @cached_property + def min_point(self: Rve) -> np.ndarray: + """Min corner ``center - 0.5 * dim``.""" + return self.center - 0.5 * self.dim + + @cached_property + def max_point(self: Rve) -> np.ndarray: + """Max corner ``center + 0.5 * dim``.""" + return self.center + 0.5 * self.dim + + @cached_property + def box(self: Rve) -> CadShape: + """Return a :class:`~microgen.cad.CadShape` box of the RVE (cached). - if np.any(self.dim <= 0): - err_msg = f"dimensions of the RVE must be greater than 0, got {self.dim}" - raise ValueError(err_msg) + Requires the ``[cad]`` extra. + """ + from .cad import make_box # noqa: PLC0415 - self.min_point = self.center - 0.5 * self.dim - self.max_point = self.center + 0.5 * self.dim + return make_box(tuple(self.dim), tuple(self.center)) - self._cached_box: CadShape | None = None + def grid(self: Rve, resolution: ResolutionType) -> pv.StructuredGrid: + """Return a structured grid aligned with the RVE. - @property - def box(self) -> CadShape: - """Return a :class:`~microgen.cad.CadShape` box of the RVE (cached). - - Requires the ``[cad]`` extra. + :param resolution: points per axis — int (broadcast to all 3 axes) or + length-3 sequence. The grid spans ``min_point`` → ``max_point`` + with endpoints included on every axis. """ - if self._cached_box is None: - from .cad import make_box # noqa: PLC0415 + import pyvista as pv # noqa: PLC0415 + + if isinstance(resolution, int): + nx, ny, nz = resolution, resolution, resolution + elif isinstance(resolution, (tuple, list)) and len(resolution) == _DIM: + nx, ny, nz = (int(r) for r in resolution) + elif isinstance(resolution, np.ndarray) and resolution.shape == (_DIM,): + nx, ny, nz = (int(r) for r in resolution.tolist()) + else: + err_msg = f"resolution must be an int or Sequence of length {_DIM}" + raise ValueError(err_msg) - self._cached_box = make_box(tuple(self.dim), tuple(self.center)) - return self._cached_box + xi = np.linspace(self.min_point[0], self.max_point[0], nx) + yi = np.linspace(self.min_point[1], self.max_point[1], ny) + zi = np.linspace(self.min_point[2], self.max_point[2], nz) + x, y, z = np.meshgrid(xi, yi, zi, indexing="ij") + return pv.StructuredGrid(x, y, z) - @box.setter - def box(self, value: CadShape) -> None: - self._cached_box = value + def __repr__(self: Rve) -> str: + return ( + f"Rve(center={tuple(self.center.tolist())}, " + f"dim={tuple(self.dim.tolist())}, pbc={self.pbc})" + ) @classmethod def from_min_max( cls: type[Rve], min_point: Vector3DType = (-0.5, -0.5, -0.5), max_point: Vector3DType = (0.5, 0.5, 0.5), + pbc: bool | tuple[bool, bool, bool] | Sequence[bool] = (True, True, True), ) -> Rve: """Generate a Rve from min and max corner points. :param min_point: ``(x_min, y_min, z_min)`` corner of the RVE :param max_point: ``(x_max, y_max, z_max)`` corner of the RVE + :param pbc: periodic-boundary-condition flags (see ``__init__``) """ lo = np.asarray(min_point, dtype=float) hi = np.asarray(max_point, dtype=float) - return cls(center=tuple(0.5 * (lo + hi)), dim=tuple(np.abs(hi - lo))) + return cls( + center=tuple(0.5 * (lo + hi)), + dim=tuple(np.abs(hi - lo)), + pbc=pbc, + ) diff --git a/tests/test_rve.py b/tests/test_rve.py index 63dbdbf6..21397342 100644 --- a/tests/test_rve.py +++ b/tests/test_rve.py @@ -1,7 +1,10 @@ """Tests for Rve class.""" +import dataclasses + import numpy as np import pytest +import pyvista as pv from microgen import Rve @@ -80,3 +83,84 @@ def test_rve_from_min_max_must_return_expected_rve() -> None: rve = Rve.from_min_max(min_point=(-1, 0, -3), max_point=(0, 2, 3)) assert np.all(rve.center == [-0.5, 1, 0]) assert np.all(rve.dim == [1, 2, 6]) + + +def test_rve_pbc_default_is_fully_periodic() -> None: + """``pbc`` defaults to ``(True, True, True)``.""" + assert Rve().pbc == (True, True, True) + + +def test_rve_pbc_scalar_broadcasts_to_all_axes() -> None: + """A single bool is broadcast to all three axes.""" + assert Rve(pbc=False).pbc == (False, False, False) + assert Rve(pbc=True).pbc == (True, True, True) + + +def test_rve_pbc_accepts_per_axis_tuple() -> None: + """A length-3 sequence sets each axis independently.""" + assert Rve(pbc=(True, False, True)).pbc == (True, False, True) + assert Rve(pbc=[False, False, True]).pbc == (False, False, True) + + +def test_rve_invalid_pbc_raises() -> None: + """``pbc`` of wrong length or type raises ``ValueError``.""" + with pytest.raises(ValueError, match="pbc must be a bool"): + Rve(pbc=(True, False)) # type: ignore[arg-type] + with pytest.raises(ValueError, match="pbc must be a bool"): + Rve(pbc=1.5) # type: ignore[arg-type] + + +def test_rve_is_frozen() -> None: + """Reassigning ``center``/``dim``/``pbc`` after construction raises FrozenInstanceError.""" + rve = Rve(dim=2) + with pytest.raises(dataclasses.FrozenInstanceError): + rve.center = np.array([1.0, 2.0, 3.0]) # type: ignore[misc] + with pytest.raises(dataclasses.FrozenInstanceError): + rve.dim = np.array([3.0, 3.0, 3.0]) # type: ignore[misc] + with pytest.raises(dataclasses.FrozenInstanceError): + rve.pbc = (False, False, False) # type: ignore[misc] + + +def test_rve_min_max_points_are_consistent() -> None: + """``min_point`` / ``max_point`` are derived from center ± 0.5·dim.""" + rve = Rve(center=(1.0, 2.0, 3.0), dim=(2.0, 4.0, 6.0)) + assert np.allclose(rve.min_point, [0.0, 0.0, 0.0]) + assert np.allclose(rve.max_point, [2.0, 4.0, 6.0]) + + +def test_rve_grid_with_scalar_resolution() -> None: + """``Rve.grid(n)`` returns a StructuredGrid spanning the cell with n^3 points.""" + rve = Rve(center=(0.0, 0.0, 0.0), dim=(2.0, 4.0, 6.0)) + grid = rve.grid(5) + assert isinstance(grid, pv.StructuredGrid) + assert grid.dimensions == (5, 5, 5) + pts = np.asarray(grid.points) + assert np.isclose(pts[:, 0].min(), -1.0) + assert np.isclose(pts[:, 0].max(), 1.0) + assert np.isclose(pts[:, 1].min(), -2.0) + assert np.isclose(pts[:, 1].max(), 2.0) + assert np.isclose(pts[:, 2].min(), -3.0) + assert np.isclose(pts[:, 2].max(), 3.0) + + +def test_rve_grid_with_per_axis_resolution() -> None: + """``Rve.grid((nx, ny, nz))`` honors per-axis resolution.""" + rve = Rve(center=(0.0, 0.0, 0.0), dim=1.0) + grid = rve.grid((3, 4, 5)) + assert grid.dimensions == (3, 4, 5) + + +def test_rve_grid_invalid_resolution_raises() -> None: + """Wrong-length resolution raises ``ValueError``.""" + rve = Rve() + with pytest.raises(ValueError, match="resolution must be an int"): + rve.grid((3, 4)) # type: ignore[arg-type] + + +def test_rve_repr_round_trips_inputs() -> None: + """``repr(rve)`` contains the canonical ``center``/``dim``/``pbc`` triple.""" + rve = Rve(center=(1.0, 2.0, 3.0), dim=(2.0, 2.0, 2.0), pbc=(True, False, True)) + text = repr(rve) + assert "center=(1.0, 2.0, 3.0)" in text + assert "dim=(2.0, 2.0, 2.0)" in text + assert "pbc=(True, False, True)" in text From 7d07d483b7e138819857625cb909f119e50faade Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Fri, 22 May 2026 00:04:00 +0200 Subject: [PATCH 3/8] Update rve.py --- microgen/rve.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/microgen/rve.py b/microgen/rve.py index b4b0454e..a9c2f02a 100644 --- a/microgen/rve.py +++ b/microgen/rve.py @@ -26,7 +26,9 @@ from .cad import CadShape - Vector3DType = tuple[float, float, float] | Sequence[float] | npt.NDArray[np.float64] + Vector3DType = ( + tuple[float, float, float] | Sequence[float] | npt.NDArray[np.float64] + ) ResolutionType = int | tuple[int, int, int] | Sequence[int] From 865aee22cf9a3459f221bb2fd787663f4b7d64ff Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Tue, 26 May 2026 09:59:15 +0200 Subject: [PATCH 4/8] Add periodic-shell sewing and Shape.period Introduce shared shape type aliases and an intrinsic period attribute, and add a generic CAD bridge and periodic-shell sewing. - Add microgen/shape/_types.py with Field, BoundsType and PeriodType aliases to centralize type defs. - Add microgen/shape/periodic_shell.py: mesh_to_periodic_shell builds sewn OCCT shells with planar cap faces for periodic meshes. - Add shape_to_cad in microgen/cad.py: generic fallback to build a tessellated CadShape from an implicit Shape. - Update microgen/shape/shape.py: import shared types, add Shape.period property, and delegate default generate_cad to shape_to_cad. - Update Tpms and Spinodoid to set their intrinsic _period and reuse mesh_to_periodic_shell; remove duplicated periodic-sewing code. - Add tests/tests_shapes/test_shape_period.py to verify period behavior and periodicity for Tpms/Spinodoid. These changes centralize type annotations, avoid duplicated sewing logic, and expose intrinsic periodicity as a data-structure invariant used by TPMS/Spinodoid shapes. The periodic shell code requires the optional CAD backend. --- microgen/cad.py | 56 ++++++++++++++ microgen/shape/_types.py | 29 +++++++ microgen/shape/periodic_shell.py | 124 ++++++++++++++++++++++++++++++ microgen/shape/shape.py | 62 ++++++--------- microgen/shape/spinodoid.py | 98 +++-------------------- microgen/shape/tpms.py | 114 ++++----------------------- tests/shapes/test_shape_period.py | 100 ++++++++++++++++++++++++ 7 files changed, 356 insertions(+), 227 deletions(-) create mode 100644 microgen/shape/_types.py create mode 100644 microgen/shape/periodic_shell.py create mode 100644 tests/shapes/test_shape_period.py diff --git a/microgen/cad.py b/microgen/cad.py index e6dd3668..da92524e 100644 --- a/microgen/cad.py +++ b/microgen/cad.py @@ -36,6 +36,9 @@ from OCP.TopoDS import TopoDS_Shape + from .shape.shape import Shape + from .shape._types import BoundsType + _INSTALL_HINT = ( "microgen's CAD backend requires the OCP (OCCT) Python bindings. " @@ -493,6 +496,59 @@ def mesh_to_shape( return CadShape(shell) +def shape_to_cad( + shape: Shape, + bounds: BoundsType | None = None, + resolution: int = 50, +) -> CadShape: + """Bridge an implicit :class:`~microgen.shape.shape.Shape` to a CAD BREP. + + Runs the shape's :meth:`generate_surface_mesh` (marching cubes on the + SDF for free Shapes; native renderer for concrete subclasses) then + wraps the resulting triangle mesh into a single tessellated BREP face + via :func:`mesh_to_shape`. + + Concrete subclasses with native primitive paths (``Box``, ``Sphere``, + ``Tpms``, ``Spinodoid`` …) usually skip this bridge and call their + own ``generate_cad``. This function is the generic fallback for + bare ``Shape`` instances built via :func:`microgen.shape.implicit_ops.from_field` + or boolean composition (``a | b``, ``a - b``, ...). + + Requires the optional ``[cad]`` install extra. + + :param shape: the implicit shape to materialise + :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)``; defaults to + ``shape.bounds`` if set, else raises ``ValueError`` + :param resolution: marching-cubes grid resolution per axis + :return: :class:`CadShape` wrapping the tessellated ``TopoDS_Shell`` + """ + require_cad() + if shape.func is None: + err_msg = "No implicit field defined — cannot build BREP from an empty Shape" + raise NotImplementedError(err_msg) + + mesh = shape.generate_surface_mesh(bounds=bounds, resolution=resolution) + if mesh.n_cells == 0: + err_msg = "Generated mesh is empty — check bounds and field function" + raise ValueError(err_msg) + + if not mesh.is_all_triangles: + mesh.triangulate(inplace=True) + triangles = mesh.faces.reshape(-1, 4)[:, 1:] + points = np.asarray(mesh.points, dtype=np.float64) + + from .shape.shape import ShellCreationError # noqa: PLC0415 + + try: + return mesh_to_shape(points, triangles) + except Exception as err: + err_msg = ( + "Failed to build the OCCT shell from the mesh; " + "try to increase the resolution or adjust bounds." + ) + raise ShellCreationError(err_msg) from err + + def _triangle_components( triangles: npt.NDArray[np.int64], ) -> npt.NDArray[np.int64]: diff --git a/microgen/shape/_types.py b/microgen/shape/_types.py new file mode 100644 index 00000000..d6959833 --- /dev/null +++ b/microgen/shape/_types.py @@ -0,0 +1,29 @@ +"""Shared type aliases for the shape package. + +Centralising these here avoids duplicate definitions across ``shape.py``, +``tpms.py`` and downstream modules. All implicit shapes use the same +``Field`` callable and ``BoundsType`` AABB representation. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import numpy as np +import numpy.typing as npt + +# Implicit scalar field: ``(x, y, z) -> array``, with negative values inside. +Field = Callable[ + [npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]], + npt.NDArray[np.float64], +] + +# Axis-aligned bounding box: ``(xmin, xmax, ymin, ymax, zmin, zmax)``. +BoundsType = tuple[float, float, float, float, float, float] + +# Period of an intrinsically-periodic shape: ``(Lx, Ly, Lz)``. +# When set, ``field(x + Lx, y, z) == field(x, y, z)`` (and analogously for y, z). +# A ``None`` period means the shape is not intrinsically periodic. +PeriodType = tuple[float, float, float] + +__all__ = ["BoundsType", "Field", "PeriodType"] diff --git a/microgen/shape/periodic_shell.py b/microgen/shape/periodic_shell.py new file mode 100644 index 00000000..3c1a4e7c --- /dev/null +++ b/microgen/shape/periodic_shell.py @@ -0,0 +1,124 @@ +"""Sew a triangulated periodic surface into a closed OCCT shell. + +Given a marching-cubes (or otherwise periodic-aligned) triangle mesh of an +implicit field whose iso-surface meets the cell boundary on cap planes, +:func:`mesh_to_periodic_shell` groups boundary triangles per cap plane, +builds **one planar BREP face per cap** (via :func:`microgen.cad.mesh_to_planar_face`) +carrying the actual cap-wire trace, and sews them together with the +interior triangles into a closed shell suitable for boolean ops, STEP +export, and gmsh ``setPeriodic`` constraints. + +Shared by :class:`microgen.shape.tpms.Tpms` and +:class:`microgen.shape.spinodoid.Spinodoid` — the two TPMS/F-rep shapes +that produce periodic meshes that need this cap-aware sewing. + +Requires the optional ``[cad]`` install extra. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import numpy.typing as npt + +if TYPE_CHECKING: + from collections.abc import Sequence + + from microgen.cad import CadShape + + from ._types import BoundsType + + +def mesh_to_periodic_shell( + points: npt.NDArray[np.float64], + triangles: npt.NDArray[np.int64], + bounds: BoundsType | Sequence[float], +) -> CadShape: + """Build a sewn OCCT shell from a triangulated surface in an AABB cell. + + Triangles whose three vertices share the mesh's exact extremum on an + AABB cap plane are grouped per face and converted to a single planar + BREP face via :func:`microgen.cad.mesh_to_planar_face` (so STEP shows + the actual gyroid/spinodoid cuts, not a bounding cube, and gmsh + ``setPeriodic`` can pair opposite cap faces by plane equation). + Remaining triangles become one planar BREP face per triangle (raw, + not pre-sewn — sewing must stitch them to the cap wires along the + seam). Everything is sewn into a closed shell via + :class:`OCP.BRepBuilderAPI.BRepBuilderAPI_Sewing`. + + :param points: ``(N, 3)`` vertex coordinates + :param triangles: ``(M, 3)`` vertex-index triplets + :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)`` of the cell + :return: :class:`microgen.cad.CadShape` wrapping the sewn ``TopoDS_Shell`` + """ + from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeWire, + BRepBuilderAPI_Sewing, + ) + from OCP.gp import gp_Pnt # noqa: PLC0415 + from OCP.TopAbs import TopAbs_FACE # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + + from microgen.cad import CadShape as _CadShape # noqa: PLC0415 + from microgen.cad import mesh_to_planar_face # noqa: PLC0415 + + pts = np.asarray(points, dtype=np.float64) + tris = np.asarray(triangles, dtype=np.int64).reshape(-1, 3) + + drift_tol = 1e-9 * float(max(abs(b) for b in bounds) or 1.0) + + consumed = np.zeros(tris.shape[0], dtype=bool) + on_plane: list[tuple[int, int, float, npt.NDArray[np.int64]]] = [] + for axis in range(3): + for sign in (-1, +1): + extremum = ( + float(pts[:, axis].max()) if sign > 0 else float(pts[:, axis].min()) + ) + expected = bounds[2 * axis + (1 if sign > 0 else 0)] + if abs(extremum - expected) > drift_tol: + continue + vert_on = pts[:, axis] == extremum + tri_on = np.all(vert_on[tris], axis=1) & ~consumed + if tri_on.any(): + on_plane.append((axis, sign, extremum, np.where(tri_on)[0])) + consumed |= tri_on + interior_idx = np.where(~consumed)[0] + + bbox_diag = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0))) + sew_tol = max(1e-9, 1e-6 * bbox_diag) + sewing = BRepBuilderAPI_Sewing(sew_tol) + + for axis, sign, extremum, tri_idx in on_plane: + origin = [0.0, 0.0, 0.0] + origin[axis] = extremum + normal = [0.0, 0.0, 0.0] + normal[axis] = float(sign) + planar = mesh_to_planar_face(pts, tris[tri_idx], origin, normal) + exp = TopExp_Explorer(planar.wrapped, TopAbs_FACE) + while exp.More(): + sewing.Add(exp.Current()) + exp.Next() + + interior_tris = tris[interior_idx] + used_vertices = np.unique(interior_tris) + pnt_cache = { + int(i): gp_Pnt(float(pts[i, 0]), float(pts[i, 1]), float(pts[i, 2])) + for i in used_vertices + } + for a, b, c in interior_tris: + ga, gb, gc = pnt_cache[int(a)], pnt_cache[int(b)], pnt_cache[int(c)] + e1 = BRepBuilderAPI_MakeEdge(ga, gb).Edge() + e2 = BRepBuilderAPI_MakeEdge(gb, gc).Edge() + e3 = BRepBuilderAPI_MakeEdge(gc, ga).Edge() + wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() + face = BRepBuilderAPI_MakeFace(wire).Face() + sewing.Add(face) + + sewing.Perform() + return _CadShape(sewing.SewedShape()) + + +__all__ = ["mesh_to_periodic_shell"] diff --git a/microgen/shape/shape.py b/microgen/shape/shape.py index 7e153e74..fa074a39 100644 --- a/microgen/shape/shape.py +++ b/microgen/shape/shape.py @@ -8,7 +8,6 @@ from __future__ import annotations import itertools -from collections.abc import Callable from typing import TYPE_CHECKING import numpy as np @@ -17,18 +16,12 @@ from scipy.spatial.transform import Rotation from . import implicit_ops as _ops +from ._types import BoundsType, Field, PeriodType if TYPE_CHECKING: from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType -Field = Callable[ - [npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]], - npt.NDArray[np.float64], -] - -BoundsType = tuple[float, float, float, float, float, float] - # Scalar-field name on the StructuredGrid used by the default mesh generators. _IMPLICIT_SCALAR = "implicit" @@ -91,6 +84,9 @@ class Shape: :param orientation: orientation of the shape :param func: implicit scalar field ``(x, y, z) -> array``, or ``None`` :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)`` or ``None`` + :param period: ``(Lx, Ly, Lz)`` if the field is intrinsically periodic + (``func(p + L) == func(p)`` along each axis), or ``None``. + Set by ``Tpms`` and ``Spinodoid`` from ``cell_size * repeat_cell``. """ def __init__( @@ -99,6 +95,7 @@ def __init__( orientation: Vector3DType | Rotation = (0, 0, 0), func: Field | None = None, bounds: BoundsType | None = None, + period: PeriodType | None = None, ) -> None: """Initialize the shape.""" self._center = center @@ -109,6 +106,7 @@ def __init__( ) self._func = func self._bounds = bounds + self._period: PeriodType | None = period # Cache of sampled structured grids keyed on (bounds, resolution). # Shared between generate_surface_mesh and generate_volume_mesh so # users calling both on the same instance only pay one N^3 field @@ -152,6 +150,17 @@ def bounds(self: Shape) -> BoundsType | None: """The bounding box ``(xmin, xmax, ymin, ymax, zmin, zmax)``, or ``None``.""" return self._bounds + @property + def period(self: Shape) -> PeriodType | None: + """The intrinsic period ``(Lx, Ly, Lz)`` if the field is periodic, else ``None``. + + When non-``None``, ``self.evaluate(x + Lx, y, z) == self.evaluate(x, y, z)`` + (and analogously for y, z) — i.e. periodicity is a data-structure + invariant of the field, not a runtime flag. ``Tpms`` and + ``Spinodoid`` set this from ``cell_size * repeat_cell``. + """ + return self._period + def require_func(self: Shape) -> Field: """Return ``_func`` or raise if not set.""" if self._func is None: @@ -297,10 +306,12 @@ def generate_cad( ) -> CadShape: """Generate a CAD shape. - The default implementation builds an OCCT tessellated BREP from the - implicit-field VTK mesh, via :func:`microgen.cad.mesh_to_shape` - (single ``TopoDS_Face`` carrying a ``Poly_Triangulation``). Subclasses - override this with native primitive construction. + The default implementation delegates to + :func:`microgen.cad.shape_to_cad`, which builds a tessellated OCCT BREP + from the implicit-field marching-cubes mesh. Concrete subclasses + with native primitive paths (``Box``, ``Sphere``, ``Cylinder``, + ``Capsule``, ``Ellipsoid``, ``Tpms``, ``Spinodoid`` …) override + this method with native OCCT construction. Requires the optional ``[cad]`` install extra (``cadquery-ocp-novtk``). @@ -308,32 +319,9 @@ def generate_cad( :param resolution: number of grid points per axis :return: :class:`microgen.cad.CadShape` wrapping an OCCT ``TopoDS_Shell`` """ - if self._func is None: - err_msg = ( - "No implicit field defined — subclasses must override generate_cad()" - ) - raise NotImplementedError(err_msg) - - from microgen.cad import mesh_to_shape # noqa: PLC0415 + from microgen.cad import shape_to_cad # noqa: PLC0415 - mesh = self.generate_surface_mesh(bounds=bounds, resolution=resolution) - if mesh.n_cells == 0: - err_msg = "Generated mesh is empty — check bounds and field function" - raise ValueError(err_msg) - - if not mesh.is_all_triangles: - mesh.triangulate(inplace=True) - triangles = mesh.faces.reshape(-1, 4)[:, 1:] - points = np.asarray(mesh.points, dtype=np.float64) - - try: - return mesh_to_shape(points, triangles) - except Exception as err: - err_msg = ( - "Failed to build the OCCT shell from the mesh; " - "try to increase the resolution or adjust bounds." - ) - raise ShellCreationError(err_msg) from err + return shape_to_cad(self, bounds=bounds, resolution=resolution) # ------------------------------------------------------------------ # Boolean operators (on implicit field) diff --git a/microgen/shape/spinodoid.py b/microgen/shape/spinodoid.py index 087240bd..685a98b3 100644 --- a/microgen/shape/spinodoid.py +++ b/microgen/shape/spinodoid.py @@ -29,12 +29,14 @@ from ..operations import rotate from ._frep_grf import _FrepGRF, _normalize_cell_size, compute_threshold_for_porosity +from .periodic_shell import mesh_to_periodic_shell from .shape import Shape if TYPE_CHECKING: from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType - from microgen.shape.shape import BoundsType + + from ._types import BoundsType class Spinodoid(Shape): @@ -181,6 +183,11 @@ def _signed_field( self._func = _signed_field lx, ly, lz = (self.cell_size * self.repeat_cell).tolist() self._bounds = (0.0, float(lx), 0.0, float(ly), 0.0, float(lz)) + # Spinodoid's field is *bit-exact* periodic on ``cell_size`` along each + # axis — every kept Fourier mode lives on the reciprocal lattice, so + # ``frep.evaluate(p + cell_size) == frep.evaluate(p)`` (see _frep_grf.py). + cs = np.asarray(self.cell_size, dtype=float) + self._period = (float(cs[0]), float(cs[1]), float(cs[2])) @cached_property def grid(self: Spinodoid) -> pv.StructuredGrid: @@ -256,7 +263,7 @@ def generate_cad( pts = np.asarray(mesh.points, dtype=np.float64) tris = mesh.faces.reshape(-1, 4)[:, 1:].astype(np.int64) - shape = _try_make_solid(_mesh_to_periodic_shell(pts, tris, self._bounds)) + shape = _try_make_solid(mesh_to_periodic_shell(pts, tris, self._bounds)) shape = rotate(obj=shape, center=(0, 0, 0), rotation=self.orientation) shape = shape.translate(self.center) # Fallback for OCCT Volume() on solids it flags invalid (rigid transforms preserve volume). @@ -265,93 +272,6 @@ def generate_cad( return shape -def _mesh_to_periodic_shell( - points: npt.NDArray[np.float64], - triangles: npt.NDArray[np.int64], - bounds: Sequence[float], -) -> CadShape: - """Build a sewn OCCT shell from a triangulated surface inside an axis-aligned box. - - Triangles whose three vertices share the mesh's exact extremum on a cube - plane are grouped per face and converted to a single planar BREP face via - :func:`microgen.cad.mesh_to_planar_face`. Remaining triangles become one - planar BREP face per triangle (raw, not pre-sewn — sewing must stitch - them to cap wires along the seam). Everything is sewn into a closed shell. - - Mirrors :meth:`microgen.Tpms._mesh_to_periodic_shell` (tpms.py:683) but - parameterised by ``bounds`` rather than an instance's - ``cell_size × repeat_cell`` (Spinodoid lives at ``[0, L]^3``, Tpms at - ``[-L/2, +L/2]^3``). - """ - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeWire, - BRepBuilderAPI_Sewing, - ) - from OCP.gp import gp_Pnt - from OCP.TopAbs import TopAbs_FACE - from OCP.TopExp import TopExp_Explorer - - from microgen.cad import CadShape as _CadShape - from microgen.cad import mesh_to_planar_face - - pts = np.asarray(points, dtype=np.float64) - tris = np.asarray(triangles, dtype=np.int64).reshape(-1, 3) - - drift_tol = 1e-9 * float(max(abs(b) for b in bounds) or 1.0) - - consumed = np.zeros(tris.shape[0], dtype=bool) - on_plane: list[tuple[int, int, float, npt.NDArray[np.int64]]] = [] - for axis in range(3): - for sign in (-1, +1): - extremum = ( - float(pts[:, axis].max()) if sign > 0 else float(pts[:, axis].min()) - ) - expected = bounds[2 * axis + (1 if sign > 0 else 0)] - if abs(extremum - expected) > drift_tol: - continue - vert_on = pts[:, axis] == extremum - tri_on = np.all(vert_on[tris], axis=1) & ~consumed - if tri_on.any(): - on_plane.append((axis, sign, extremum, np.where(tri_on)[0])) - consumed |= tri_on - interior_idx = np.where(~consumed)[0] - - bbox_diag = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0))) - sew_tol = max(1e-9, 1e-6 * bbox_diag) - sewing = BRepBuilderAPI_Sewing(sew_tol) - - for axis, sign, extremum, tri_idx in on_plane: - origin = [0.0, 0.0, 0.0] - origin[axis] = extremum - normal = [0.0, 0.0, 0.0] - normal[axis] = float(sign) - planar = mesh_to_planar_face(pts, tris[tri_idx], origin, normal) - exp = TopExp_Explorer(planar.wrapped, TopAbs_FACE) - while exp.More(): - sewing.Add(exp.Current()) - exp.Next() - - interior_tris = tris[interior_idx] - used_vertices = np.unique(interior_tris) - pnt_cache = { - int(i): gp_Pnt(float(pts[i, 0]), float(pts[i, 1]), float(pts[i, 2])) - for i in used_vertices - } - for a, b, c in interior_tris: - ga, gb, gc = pnt_cache[int(a)], pnt_cache[int(b)], pnt_cache[int(c)] - e1 = BRepBuilderAPI_MakeEdge(ga, gb).Edge() - e2 = BRepBuilderAPI_MakeEdge(gb, gc).Edge() - e3 = BRepBuilderAPI_MakeEdge(gc, ga).Edge() - wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() - face = BRepBuilderAPI_MakeFace(wire).Face() - sewing.Add(face) - - sewing.Perform() - return _CadShape(sewing.SewedShape()) - - def _try_make_solid(shape: CadShape) -> CadShape: """Best-effort upgrade of a sewn shell (or compound of shells) to a Solid. diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 66a37723..8d1f77d5 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -26,7 +26,8 @@ from microgen.operations import fuse_shapes, rotate -from .shape import BoundsType, Shape, ShellCreationError +from ._types import BoundsType, Field +from .shape import Shape, ShellCreationError if TYPE_CHECKING: from microgen.cad import CadShape @@ -36,10 +37,6 @@ from .tpms_grading import OffsetGrading logging.basicConfig(level=logging.INFO) -Field = Callable[ - [npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]], - npt.NDArray[np.float64], -] _DIM = 3 @@ -480,13 +477,18 @@ def _finalize_frep( raw_field: Field, bounds: tuple[float, float, float, float, float, float], ) -> None: - """Normalize a raw field to SDF and set ``_func`` / ``_bounds``.""" + """Normalize a raw field to SDF and set ``_func`` / ``_bounds`` / ``_period``.""" from .implicit_ops import from_field, normalize_to_sdf self._raw_field_func = raw_field sdf_shape = normalize_to_sdf(from_field(raw_field)) self._func = sdf_shape.func self._bounds = bounds + # The TPMS field is intrinsically periodic on ``cell_size`` along each + # axis (the 2π/cell_size wavenumbers in ``_setup_frep_field`` make + # this a data-structure invariant, not a flag). + cs = np.asarray(self.cell_size, dtype=float) + self._period = (float(cs[0]), float(cs[1]), float(cs[2])) def _setup_frep_field(self: Tpms) -> None: """Build the F-rep implicit field (SDF-normalized) for this TPMS.""" @@ -691,107 +693,17 @@ def offset( def _mesh_to_periodic_shell(self: Tpms, mesh: pv.PolyData) -> CadShape: """Build a closed, sewn OCCT shell with planar BREP faces on cell sides. - Splits the triangle mesh into seven groups: one per unit-cell - boundary plane (x=±half, y=±half, z=±half) plus the TPMS interior. - Each cell-side group goes through :func:`mesh_to_planar_face`, - producing one or more :class:`TopoDS_Face` whose underlying surface - is a ``Geom_Plane`` and whose wires trace the actual cell-side - outline (so STEP shows the gyroid cuts, not a bounding cube; gmsh - ``setPeriodic`` matches opposite cell sides by their plane equation). - The TPMS interior contributes one planar face per triangle. - - All faces — caps + interior — are sewn together via - :class:`BRepBuilderAPI_Sewing` so the cap/interior seam shares edges - and face orientations are reconciled. The result is a closed shell - whose ``BRepGProp::VolumeProperties`` matches the underlying VTK - volume, and which is a valid input to boolean ops. + Delegates to :func:`microgen.shape.periodic_shell.mesh_to_periodic_shell`, + which sews TPMS-interior triangles with per-face cap planes into a + single closed shell (see that function's docstring for details). """ - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeWire, - BRepBuilderAPI_Sewing, - ) - from OCP.gp import gp_Pnt - from OCP.TopAbs import TopAbs_FACE - from OCP.TopExp import TopExp_Explorer - - from microgen.cad import ( - CadShape as _CadShape, - ) - from microgen.cad import ( - mesh_to_planar_face, - ) + from .periodic_shell import mesh_to_periodic_shell # noqa: PLC0415 if not mesh.is_all_triangles: mesh.triangulate(inplace=True) pts = np.asarray(mesh.points, dtype=np.float64) tris = mesh.faces.reshape(-1, 4)[:, 1:].astype(np.int64) - - half = 0.5 * np.asarray(self.cell_size) * np.asarray(self.repeat_cell) - # A vertex is "on" a cell-side plane iff its coordinate on that axis - # equals the mesh's exact extremum on that axis. Marching cubes on - # the periodic-aligned grid places cap vertices on in-plane grid - # edges, so they share the linspace endpoint exactly — no tolerance - # ball, just exact equality on the actual data. Slanted surface - # triangles near the boundary land on perpendicular grid edges and - # never produce that exact value. - # Sanity-check the extremum is the expected ±half (skip otherwise so - # we never misclassify a surface-only mesh whose extremum is not on - # a cube face). - drift_tol = 1e-9 * float(np.max(np.abs(half)) or 1.0) - - consumed = np.zeros(tris.shape[0], dtype=bool) - on_plane: list[tuple[int, int, npt.NDArray[np.int64]]] = [] - for axis in range(3): - for sign in (-1, +1): - extremum = ( - float(pts[:, axis].max()) if sign > 0 else float(pts[:, axis].min()) - ) - if abs(extremum - sign * float(half[axis])) > drift_tol: - continue - vert_on = pts[:, axis] == extremum - tri_on = np.all(vert_on[tris], axis=1) & ~consumed - if tri_on.any(): - on_plane.append((axis, sign, np.where(tri_on)[0])) - consumed |= tri_on - interior_idx = np.where(~consumed)[0] - - # Sewing tolerance: a small fraction of the bbox diagonal absorbs - # numerical drift between cap-wire vertices and interior-triangle - # vertices that should match exactly along the cell-side seam. - bbox_diag = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0))) - sew_tol = max(1e-9, 1e-6 * bbox_diag) - sewing = BRepBuilderAPI_Sewing(sew_tol) - - for axis, sign, tri_idx in on_plane: - origin = [0.0, 0.0, 0.0] - origin[axis] = sign * float(half[axis]) - normal = [0.0, 0.0, 0.0] - normal[axis] = float(sign) - planar = mesh_to_planar_face(pts, tris[tri_idx], origin, normal) - exp = TopExp_Explorer(planar.wrapped, TopAbs_FACE) - while exp.More(): - sewing.Add(exp.Current()) - exp.Next() - - # Interior TPMS triangles: contribute as raw per-triangle faces so - # sewing can stitch them to the cap wires (a pre-sewn interior would - # have shared edges already, blocking the seam stitch). - for a, b, c in tris[interior_idx]: - pa, pb, pc = pts[int(a)], pts[int(b)], pts[int(c)] - ga = gp_Pnt(float(pa[0]), float(pa[1]), float(pa[2])) - gb = gp_Pnt(float(pb[0]), float(pb[1]), float(pb[2])) - gc = gp_Pnt(float(pc[0]), float(pc[1]), float(pc[2])) - e1 = BRepBuilderAPI_MakeEdge(ga, gb).Edge() - e2 = BRepBuilderAPI_MakeEdge(gb, gc).Edge() - e3 = BRepBuilderAPI_MakeEdge(gc, ga).Edge() - wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() - face = BRepBuilderAPI_MakeFace(wire).Face() - sewing.Add(face) - - sewing.Perform() - return _CadShape(sewing.SewedShape()) + return mesh_to_periodic_shell(pts, tris, self._bounds) def _mesh_to_shell(self: Tpms, mesh: pv.PolyData) -> CadShape: """Convert a triangulated PyVista mesh to an OCCT ``CadShape``. diff --git a/tests/shapes/test_shape_period.py b/tests/shapes/test_shape_period.py new file mode 100644 index 00000000..72ad52ef --- /dev/null +++ b/tests/shapes/test_shape_period.py @@ -0,0 +1,100 @@ +"""Tests for ``Shape.period``: the intrinsic-periodicity attribute. + +When set, ``shape.period == (Lx, Ly, Lz)`` is a data-structure invariant +guaranteeing ``shape.evaluate(p + L) == shape.evaluate(p)`` along each axis. +``Tpms`` / ``Spinodoid`` populate it from ``cell_size * repeat_cell`` / +``cell_size`` respectively; free shapes built via ``from_field`` or boolean +composition leave it ``None``. +""" + +# ruff: noqa: S101 + +from __future__ import annotations + +import numpy as np + +from microgen import Box, Sphere, Spinodoid, Tpms, surface_functions +from microgen.shape.implicit_ops import from_field +from microgen.shape.shape import Shape + + +def test_bare_shape_period_is_none() -> None: + """A free ``Shape(func=...)`` has no intrinsic period.""" + s = Shape(func=lambda x, y, z: x * x + y * y + z * z - 1.0) + assert s.period is None + + +def test_box_sphere_have_no_period() -> None: + """Non-periodic primitives expose ``period is None``.""" + assert Box().period is None + assert Sphere().period is None + + +def test_tpms_period_matches_cell_size() -> None: + """``Tpms.period`` equals ``cell_size`` (per-axis).""" + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.5, cell_size=2.0) + assert tpms.period == (2.0, 2.0, 2.0) + + +def test_tpms_anisotropic_cell_size_period() -> None: + """``Tpms.period`` is per-axis when ``cell_size`` is a tuple.""" + tpms = Tpms( + surface_function=surface_functions.gyroid, + offset=0.5, + cell_size=(1.0, 2.0, 3.0), + ) + assert tpms.period == (1.0, 2.0, 3.0) + + +def test_spinodoid_period_matches_cell_size() -> None: + """``Spinodoid.period`` equals ``cell_size``.""" + sp = Spinodoid(offset=0.0, cell_size=1.5, resolution=8, seed=42) + assert sp.period == (1.5, 1.5, 1.5) + + +def test_from_field_propagates_no_period() -> None: + """``from_field`` produces a free shape with ``period is None``.""" + s = from_field(lambda x, y, z: x + y + z) + assert s.period is None + + +def test_boolean_composition_does_not_inherit_period() -> None: + """``a | b`` produces a free shape; intrinsic periodicity is not preserved + by composition (the union of two periodic fields may or may not be + periodic — we don't claim it is). + """ + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.5) + sphere = Sphere(radius=0.5) + combined = tpms | sphere + assert combined.period is None + + +def test_tpms_field_actually_periodic_at_declared_period() -> None: + """If ``shape.period == (Lx, Ly, Lz)`` then ``f(p + L) == f(p)``.""" + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.5, cell_size=1.0) + lx, ly, lz = tpms.period + rng = np.random.default_rng(seed=0) + pts = rng.uniform(-0.5, 0.5, size=(20, 3)) + x, y, z = pts[:, 0], pts[:, 1], pts[:, 2] + f0 = tpms.evaluate(x, y, z) + fp = tpms.evaluate(x + lx, y + ly, z + lz) + # TPMS field is approximately periodic after SDF normalization (not bit-exact); + # tight but not exact tolerance. + assert np.allclose(f0, fp, atol=1e-6) + + +def test_spinodoid_field_periodic_at_declared_period() -> None: + """Spinodoid's reciprocal-lattice modes give (float-precision) periodicity. + + Bit-exactness is asserted by ``test_spinodoid_field_is_bit_exact_periodic`` + in tests/test_spinodoid.py on the raw ``_frep.evaluate``; via ``Shape.evaluate`` + the sign-flip + shifted-coord path picks up ULP-level drift (~1e-15). + """ + sp = Spinodoid(offset=0.0, cell_size=1.0, resolution=8, seed=42) + lx, ly, lz = sp.period + rng = np.random.default_rng(seed=1) + pts = rng.uniform(0.0, 1.0, size=(20, 3)) + x, y, z = pts[:, 0], pts[:, 1], pts[:, 2] + f0 = sp.evaluate(x, y, z) + fp = sp.evaluate(x + lx, y + ly, z + lz) + assert np.allclose(f0, fp, atol=1e-12, rtol=0) From 20e6541a844c68b82e697e0cc91a9901adbce351 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Tue, 26 May 2026 14:06:36 +0200 Subject: [PATCH 5/8] Split cad.py into microgen.cad package Replace the single monolithic microgen/cad.py with a microgen.cad subpackage. The functionality has been split across new modules: __init__.py, _install.py, io.py, meshbridge.py, primitives.py, shape.py and topo.py to isolate responsibilities and enable lazy OCP imports (OCP remains an optional dependency). Tests were updated to reflect the package layout (no top-level OCP import). This reorganization improves maintainability and ensures import-time safety when OCP is not installed. --- microgen/cad.py | 1338 ------------------------ microgen/cad/__init__.py | 124 +++ microgen/cad/_install.py | 22 + microgen/cad/io.py | 34 + microgen/cad/meshbridge.py | 478 +++++++++ microgen/cad/primitives.py | 233 +++++ microgen/cad/shape.py | 376 +++++++ microgen/cad/topo.py | 238 +++++ tests/test_no_top_level_ocp_imports.py | 14 +- 9 files changed, 1511 insertions(+), 1346 deletions(-) delete mode 100644 microgen/cad.py create mode 100644 microgen/cad/__init__.py create mode 100644 microgen/cad/_install.py create mode 100644 microgen/cad/io.py create mode 100644 microgen/cad/meshbridge.py create mode 100644 microgen/cad/primitives.py create mode 100644 microgen/cad/shape.py create mode 100644 microgen/cad/topo.py diff --git a/microgen/cad.py b/microgen/cad.py deleted file mode 100644 index da92524e..00000000 --- a/microgen/cad.py +++ /dev/null @@ -1,1338 +0,0 @@ -"""CAD backend — direct OCCT (via OCP) replacement for CadQuery. - -========================================================= -CAD backend (:mod:`microgen.cad`) -========================================================= - -All CadQuery calls in microgen have been replaced by direct OCP -(``cadquery-ocp-novtk``) calls housed in this module. OCP is an *optional* -dependency — install via ``pip install microgen[cad]``. - -The module's top-level body does not import OCP, so ``import microgen.cad`` -always succeeds. The OCP-dependent functions import it lazily and raise a -helpful ``ImportError`` with install instructions if OCP is missing. - -Return type ------------ - -CAD-producing functions return a :class:`CadShape` — a thin wrapper around -an OCCT ``TopoDS_Shape`` exposing ``.wrapped`` for downstream OCP calls, plus -convenience methods (``translate``, ``rotate``, ``fuse``, ``cut``, -``export_stl``, ``export_step``, ``export_brep``). ``.wrapped`` matches the -attribute name CadQuery's ``Shape`` exposed, so most legacy call sites keep -working unchanged. -""" - -from __future__ import annotations - -from collections.abc import Iterable, Sequence -from typing import TYPE_CHECKING, Any - -import numpy as np -import numpy.typing as npt - -if TYPE_CHECKING: - from pathlib import Path - - from OCP.TopoDS import TopoDS_Shape - - from .shape.shape import Shape - from .shape._types import BoundsType - - -_INSTALL_HINT = ( - "microgen's CAD backend requires the OCP (OCCT) Python bindings. " - "Install with: pip install 'microgen[cad]' " - "(this pulls cadquery-ocp-novtk; on conda-forge use `ocp` instead)." -) - - -class _Centre(tuple): - """Tuple-like 3D point exposing ``.x``, ``.y``, ``.z`` and ``.to_tuple()``. - - Returned by :meth:`CadShape.center`. Mimics just enough of - ``cadquery.Vector`` to drop-in where existing code uses - ``shape.center().to_tuple()`` or ``shape.center().x``. - """ - - __slots__ = () - - def __new__(cls, x: float, y: float, z: float) -> _Centre: - """Create a 3-tuple ``(x, y, z)``.""" - return super().__new__(cls, (float(x), float(y), float(z))) - - @property - def x(self) -> float: - """X coordinate.""" - return self[0] - - @property - def y(self) -> float: - """Y coordinate.""" - return self[1] - - @property - def z(self) -> float: - """Z coordinate.""" - return self[2] - - def to_tuple(self) -> tuple[float, float, float]: - """Return ``(x, y, z)`` as a plain tuple.""" - return (self[0], self[1], self[2]) - - -class _BBox: - """Axis-aligned bounding box exposing ``xmin`` / ``xmax`` / …. - - Returned by :meth:`CadShape.bounding_box`. Also indexable as a 6-tuple - ``(xmin, ymin, zmin, xmax, ymax, zmax)`` matching OCCT's ``Bnd_Box.Get``. - """ - - __slots__ = ("xmax", "xmin", "ymax", "ymin", "zmax", "zmin") - - def __init__( - self, - xmin: float, - ymin: float, - zmin: float, - xmax: float, - ymax: float, - zmax: float, - ) -> None: - """Initialize from the 6 axis-aligned extents.""" - self.xmin = float(xmin) - self.ymin = float(ymin) - self.zmin = float(zmin) - self.xmax = float(xmax) - self.ymax = float(ymax) - self.zmax = float(zmax) - - @property - def diagonal_length(self) -> float: - """Length of the box's space diagonal.""" - dx = self.xmax - self.xmin - dy = self.ymax - self.ymin - dz = self.zmax - self.zmin - return float((dx * dx + dy * dy + dz * dz) ** 0.5) - - -def require_cad() -> None: - """Raise :class:`ImportError` if the CAD backend (OCP) is not importable.""" - try: - import OCP # noqa: F401 - except ImportError as err: - raise ImportError(_INSTALL_HINT) from err - - -def _run_boolean(op_cls: Any, a: CadShape, b: CadShape, label: str) -> TopoDS_Shape: - """Run an OCCT boolean op and raise on failure. - - Older OCP releases don't expose ``HasErrors()`` / ``IsDone()`` on the - ``BRepAlgoAPI_*`` classes; we probe via ``getattr`` and skip the check - when the API isn't available. - """ - op = op_cls(a.wrapped, b.wrapped) - has_errors = getattr(op, "HasErrors", None) - if callable(has_errors) and has_errors(): - err_msg = f"BRepAlgoAPI_{label} failed" - raise RuntimeError(err_msg) - return op.Shape() - - -def _topods_cast(name: str) -> Any: - """Return ``TopoDS.`` cast helper, tolerant of OCP version drift. - - Older OCP releases expose the static cast as ``TopoDS.Shell_s`` (pybind11 - ``_s`` convention); newer releases expose the unsuffixed ``TopoDS.Shell``. - Try the suffixed form first, fall back to unsuffixed. - """ - from OCP.TopoDS import TopoDS - - return getattr(TopoDS, f"{name}_s", None) or getattr(TopoDS, name) - - -# --------------------------------------------------------------------------- -# CadShape wrapper -# --------------------------------------------------------------------------- - - -class CadShape: - """Thin wrapper around an OCCT ``TopoDS_Shape``. - - Preserves the ``.wrapped`` attribute name used by CadQuery so downstream - OCP calls (``BRepAlgoAPI_Fuse(a.wrapped, b.wrapped)``) keep working. - - ``_mesh_volume`` (optional) is a trusted volume in the source mesh's - units, set by mesh-derived constructors (e.g. the TPMS periodic shell) - where OCCT's surface-integral volume is unreliable on invalid topology. - :meth:`volume` prefers it over the OCCT integral when present. - """ - - __slots__ = ("_mesh_volume", "wrapped") - - def __init__(self, shape: TopoDS_Shape) -> None: - """Wrap an OCCT ``TopoDS_Shape``.""" - self.wrapped = shape - self._mesh_volume: float | None = None - - # -- transforms -------------------------------------------------------- - - def translate(self, offset: Sequence[float]) -> CadShape: - """Return a translated copy.""" - from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform - from OCP.gp import gp_Trsf, gp_Vec - - trsf = gp_Trsf() - trsf.SetTranslation( - gp_Vec(float(offset[0]), float(offset[1]), float(offset[2])) - ) - transformed = BRepBuilderAPI_Transform(self.wrapped, trsf, True).Shape() - return CadShape(transformed) - - def rotate( - self, - center: Sequence[float], - axis: Sequence[float], - angle_degrees: float, - ) -> CadShape: - """Return a rotated copy (angle in degrees, axis is a unit vector).""" - from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform - from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf - - trsf = gp_Trsf() - ax = gp_Ax1( - gp_Pnt(float(center[0]), float(center[1]), float(center[2])), - gp_Dir(float(axis[0]), float(axis[1]), float(axis[2])), - ) - trsf.SetRotation(ax, float(np.deg2rad(angle_degrees))) - transformed = BRepBuilderAPI_Transform(self.wrapped, trsf, True).Shape() - return CadShape(transformed) - - def copy(self) -> CadShape: - """Return an independent copy (deep topology copy).""" - from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy - - return CadShape(BRepBuilderAPI_Copy(self.wrapped).Shape()) - - # -- boolean ops ------------------------------------------------------- - - def fuse(self, other: CadShape) -> CadShape: - """Boolean fusion: ``self ∪ other``.""" - from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse - - return CadShape(_run_boolean(BRepAlgoAPI_Fuse, self, other, "Fuse")) - - def cut(self, other: CadShape) -> CadShape: - """Boolean difference: ``self \\ other``.""" - from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut - - return CadShape(_run_boolean(BRepAlgoAPI_Cut, self, other, "Cut")) - - def intersect(self, other: CadShape) -> CadShape: - """Boolean intersection: ``self ∩ other``.""" - from OCP.BRepAlgoAPI import BRepAlgoAPI_Common - - return CadShape(_run_boolean(BRepAlgoAPI_Common, self, other, "Common")) - - # -- topology queries -------------------------------------------------- - - def solids(self) -> list[CadShape]: - """Enumerate contained solids.""" - from OCP.TopAbs import TopAbs_SOLID - from OCP.TopExp import TopExp_Explorer - - cast_solid = _topods_cast("Solid") - out: list[CadShape] = [] - exp = TopExp_Explorer(self.wrapped, TopAbs_SOLID) - while exp.More(): - out.append(CadShape(cast_solid(exp.Current()))) - exp.Next() - return out - - def vertices(self) -> list[tuple[float, float, float]]: - """Enumerate the vertex coordinates of the shape. - - Callers use this to check that a generated mesh has any vertices at - all (``assert np.any(shape.vertices())``). - """ - from OCP.BRep import BRep_Tool - from OCP.TopAbs import TopAbs_VERTEX - from OCP.TopExp import TopExp_Explorer - - cast_vertex = _topods_cast("Vertex") - pnt = getattr(BRep_Tool, "Pnt_s", None) or BRep_Tool.Pnt - out: list[tuple[float, float, float]] = [] - exp = TopExp_Explorer(self.wrapped, TopAbs_VERTEX) - while exp.More(): - v = cast_vertex(exp.Current()) - p = pnt(v) - out.append((float(p.X()), float(p.Y()), float(p.Z()))) - exp.Next() - return out - - def faces(self) -> list[CadShape]: - """Enumerate the faces of the shape.""" - from OCP.TopAbs import TopAbs_FACE - from OCP.TopExp import TopExp_Explorer - - cast_face = _topods_cast("Face") - out: list[CadShape] = [] - exp = TopExp_Explorer(self.wrapped, TopAbs_FACE) - while exp.More(): - out.append(CadShape(cast_face(exp.Current()))) - exp.Next() - return out - - def is_closed(self) -> bool: - """Whether the shape is topologically closed. - - Solids are always closed; for shells/compounds we read OCCT's - per-shape ``Closed`` flag (set by ``BRep_Builder::IsClosed`` when the - shell was built from a watertight set of faces). - """ - from OCP.TopAbs import TopAbs_SOLID - - if self.wrapped.ShapeType() == TopAbs_SOLID: - return True - return bool(self.wrapped.Closed()) - - def volume(self) -> float: - """Return the (unsigned) volume of the shape. - - OCCT's ``BRepGProp::VolumeProperties`` returns a *signed* volume that - depends on face orientation; mesh-built shells from - :func:`mesh_to_shell_brep` can carry inverted orientation and yield a - negative value. We return ``abs(...)`` so volumes are non-negative. - - If a mesh-derived volume was stashed on ``_mesh_volume`` AND the OCCT - solid is not valid (BRepCheck_Analyzer flags self-intersection / - non-manifold edges, common on raw marching-cubes input), we trust the - mesh volume — the OCCT surface integral on an invalid topology is - meaningless. - """ - from OCP.BRepCheck import BRepCheck_Analyzer - from OCP.BRepGProp import BRepGProp - from OCP.GProp import GProp_GProps - - if ( - self._mesh_volume is not None - and not BRepCheck_Analyzer( - self.wrapped, - ).IsValid() - ): - return float(abs(self._mesh_volume)) - - props = GProp_GProps() - BRepGProp.VolumeProperties_s(self.wrapped, props) - return float(abs(props.Mass())) - - def center(self) -> _Centre: - """Return the volumetric center of mass. - - The result is a :class:`_Centre` — exposes ``.x``, ``.y``, ``.z``, - ``.to_tuple()``, and unpacks like a tuple. - """ - from OCP.BRepGProp import BRepGProp - from OCP.GProp import GProp_GProps - - props = GProp_GProps() - BRepGProp.VolumeProperties_s(self.wrapped, props) - c = props.CentreOfMass() - return _Centre(float(c.X()), float(c.Y()), float(c.Z())) - - def bounding_box(self) -> _BBox: - """Return the axis-aligned bounding box. - - The result exposes ``xmin`` / ``xmax`` / … attributes (see - :class:`_BBox`). - """ - from OCP.Bnd import Bnd_Box - from OCP.BRepBndLib import BRepBndLib - - box = Bnd_Box() - # AddOptimal uses exact geometric bounds (not cached triangulation). - BRepBndLib.AddOptimal_s(self.wrapped, box, True, True) - xmin, ymin, zmin, xmax, ymax, zmax = box.Get() - return _BBox(xmin, ymin, zmin, xmax, ymax, zmax) - - # -- exports ----------------------------------------------------------- - - def export_stl( - self, - path: str | Path, - linear_deflection: float = 0.01, - angular_deflection: float = 0.5, - *, - ascii_mode: bool = False, - ) -> None: - """Export to STL. Mesh is regenerated at the given deflection.""" - from OCP.BRepMesh import BRepMesh_IncrementalMesh - from OCP.StlAPI import StlAPI_Writer - - BRepMesh_IncrementalMesh( - self.wrapped, - float(linear_deflection), - False, - float(angular_deflection), - True, - ) - writer = StlAPI_Writer() - writer.ASCIIMode = bool(ascii_mode) - if not writer.Write(self.wrapped, str(path)): - err_msg = f"STL write failed for {path!r}" - raise RuntimeError(err_msg) - - def export_step(self, path: str | Path) -> None: - """Export to STEP (AP214).""" - from OCP.IFSelect import IFSelect_RetDone - from OCP.STEPControl import ( - STEPControl_AsIs, - STEPControl_Writer, - ) - - writer = STEPControl_Writer() - status = writer.Transfer(self.wrapped, STEPControl_AsIs) - if status != IFSelect_RetDone: - err_msg = f"STEP transfer failed with status {status!r}" - raise RuntimeError(err_msg) - status = writer.Write(str(path)) - if status != IFSelect_RetDone: - err_msg = f"STEP write failed with status {status!r}" - raise RuntimeError(err_msg) - - def export_brep(self, path: str | Path) -> None: - """Export to OCCT native BREP.""" - from OCP.BRepTools import BRepTools - - ok = BRepTools.Write_s(self.wrapped, str(path)) - if not ok: - err_msg = f"BREP write failed for {path!r}" - raise RuntimeError(err_msg) - - -def import_step(path: str | Path) -> CadShape: - """Import a STEP file and return the resulting :class:`CadShape`. - - Multi-root STEP files are merged into a single ``TopoDS_Compound``. - """ - require_cad() - from OCP.IFSelect import IFSelect_RetDone - from OCP.STEPControl import STEPControl_Reader - - reader = STEPControl_Reader() - status = reader.ReadFile(str(path)) - if status != IFSelect_RetDone: - err_msg = f"STEP read failed for {path!r} with status {status!r}" - raise RuntimeError(err_msg) - reader.TransferRoots() - return CadShape(reader.OneShape()) - - -# --------------------------------------------------------------------------- -# Mesh → Shell (SOTA path: one TopoDS_Face with attached Poly_Triangulation) -# --------------------------------------------------------------------------- - - -class ShellCreationError(RuntimeError): - """Raised when a mesh cannot be converted into an OCCT shell.""" - - -def mesh_to_shape( - points: npt.NDArray[np.float64], - triangles: npt.NDArray[np.int64], -) -> CadShape: - """Convert a triangle mesh to a :class:`CadShape` via ``Poly_Triangulation``. - - One ``TopoDS_Face`` carries the full triangulation (OCCT native tessellated - BREP representation). This is the SOTA fast path: O(N) in pure OCCT C++ - with no Python-per-triangle overhead, and exports cleanly to STEP AP242 - (tessellated) and STL. - - :param points: ``(N, 3)`` array of vertex coordinates - :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices - :return: wrapped ``TopoDS_Shell`` containing one tessellated face - :raises ShellCreationError: if the triangulation cannot be built - """ - require_cad() - from OCP.BRep import BRep_Builder - from OCP.gp import gp_Pnt - from OCP.Poly import Poly_Triangle, Poly_Triangulation - from OCP.TopoDS import TopoDS_Face, TopoDS_Shell - - pts = np.asarray(points, dtype=np.float64) - tris = np.asarray(triangles, dtype=np.int64) - if pts.ndim != 2 or pts.shape[1] != 3: - err_msg = f"points must be (N, 3), got {pts.shape}" - raise ValueError(err_msg) - if tris.ndim != 2 or tris.shape[1] != 3: - err_msg = f"triangles must be (M, 3), got {tris.shape}" - raise ValueError(err_msg) - if tris.size == 0: - err_msg = "Cannot build a shell from an empty triangle list" - raise ShellCreationError(err_msg) - - nb_nodes = int(pts.shape[0]) - nb_tri = int(tris.shape[0]) - triangulation = Poly_Triangulation(nb_nodes, nb_tri, False) - for i in range(nb_nodes): - triangulation.SetNode( - i + 1, gp_Pnt(float(pts[i, 0]), float(pts[i, 1]), float(pts[i, 2])) - ) - for i in range(nb_tri): - a, b, c = int(tris[i, 0]), int(tris[i, 1]), int(tris[i, 2]) - triangulation.SetTriangle(i + 1, Poly_Triangle(a + 1, b + 1, c + 1)) - - builder = BRep_Builder() - face = TopoDS_Face() - try: - builder.MakeFace(face, triangulation) - except Exception as err: - err_msg = "OCCT refused the triangulation — check bounds and field." - raise ShellCreationError(err_msg) from err - - shell = TopoDS_Shell() - builder.MakeShell(shell) - builder.Add(shell, face) - return CadShape(shell) - - -def shape_to_cad( - shape: Shape, - bounds: BoundsType | None = None, - resolution: int = 50, -) -> CadShape: - """Bridge an implicit :class:`~microgen.shape.shape.Shape` to a CAD BREP. - - Runs the shape's :meth:`generate_surface_mesh` (marching cubes on the - SDF for free Shapes; native renderer for concrete subclasses) then - wraps the resulting triangle mesh into a single tessellated BREP face - via :func:`mesh_to_shape`. - - Concrete subclasses with native primitive paths (``Box``, ``Sphere``, - ``Tpms``, ``Spinodoid`` …) usually skip this bridge and call their - own ``generate_cad``. This function is the generic fallback for - bare ``Shape`` instances built via :func:`microgen.shape.implicit_ops.from_field` - or boolean composition (``a | b``, ``a - b``, ...). - - Requires the optional ``[cad]`` install extra. - - :param shape: the implicit shape to materialise - :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)``; defaults to - ``shape.bounds`` if set, else raises ``ValueError`` - :param resolution: marching-cubes grid resolution per axis - :return: :class:`CadShape` wrapping the tessellated ``TopoDS_Shell`` - """ - require_cad() - if shape.func is None: - err_msg = "No implicit field defined — cannot build BREP from an empty Shape" - raise NotImplementedError(err_msg) - - mesh = shape.generate_surface_mesh(bounds=bounds, resolution=resolution) - if mesh.n_cells == 0: - err_msg = "Generated mesh is empty — check bounds and field function" - raise ValueError(err_msg) - - if not mesh.is_all_triangles: - mesh.triangulate(inplace=True) - triangles = mesh.faces.reshape(-1, 4)[:, 1:] - points = np.asarray(mesh.points, dtype=np.float64) - - from .shape.shape import ShellCreationError # noqa: PLC0415 - - try: - return mesh_to_shape(points, triangles) - except Exception as err: - err_msg = ( - "Failed to build the OCCT shell from the mesh; " - "try to increase the resolution or adjust bounds." - ) - raise ShellCreationError(err_msg) from err - - -def _triangle_components( - triangles: npt.NDArray[np.int64], -) -> npt.NDArray[np.int64]: - """Label connected components of a triangle mesh by edge adjacency.""" - from collections import defaultdict - - n_tri = int(triangles.shape[0]) - edges_sorted = np.sort( - np.vstack( - [triangles[:, [0, 1]], triangles[:, [1, 2]], triangles[:, [2, 0]]], - ), - axis=1, - ) - _keys, inv = np.unique(edges_sorted, axis=0, return_inverse=True) - owner = np.tile(np.arange(n_tri), 3) - - edge_to_tris: dict[int, list[int]] = defaultdict(list) - for global_i, key_i in enumerate(inv): - edge_to_tris[int(key_i)].append(int(owner[global_i])) - - adj: list[list[int]] = [[] for _ in range(n_tri)] - for tlist in edge_to_tris.values(): - if len(tlist) == 2: - adj[tlist[0]].append(tlist[1]) - adj[tlist[1]].append(tlist[0]) - - component = -np.ones(n_tri, dtype=np.int64) - n_comp = 0 - for start in range(n_tri): - if component[start] >= 0: - continue - component[start] = n_comp - stack = [start] - while stack: - t = stack.pop() - for nb in adj[t]: - if component[nb] < 0: - component[nb] = n_comp - stack.append(nb) - n_comp += 1 - return component - - -def _walk_boundary_loops( - comp_tris: npt.NDArray[np.int64], -) -> list[list[int]]: - """Extract directed closed loops from the boundary edges of a triangle group. - - A boundary edge appears in exactly one triangle of the group; the loops - are the closed chains of those edges in their original triangle direction. - """ - from collections import defaultdict - - ce = np.vstack( - [comp_tris[:, [0, 1]], comp_tris[:, [1, 2]], comp_tris[:, [2, 0]]], - ) - cs = np.sort(ce, axis=1) - _keys, inv, counts = np.unique( - cs, - axis=0, - return_inverse=True, - return_counts=True, - ) - bedges = ce[counts[inv] == 1] - if len(bedges) == 0: - return [] - - used = np.zeros(len(bedges), dtype=bool) - start_map: dict[int, list[int]] = defaultdict(list) - for i, (a, _b) in enumerate(bedges): - start_map[int(a)].append(i) - - loops: list[list[int]] = [] - for start_i in range(len(bedges)): - if used[start_i]: - continue - cur = start_i - used[cur] = True - loop = [int(bedges[cur, 0])] - while True: - b = int(bedges[cur, 1]) - loop.append(b) - if b == loop[0]: - break - nxt = next( - (cand for cand in start_map[b] if not used[cand]), - None, - ) - if nxt is None: - break - used[nxt] = True - cur = nxt - if len(loop) >= 4 and loop[-1] == loop[0]: - loops.append(loop) - return loops - - -def mesh_to_planar_face( # noqa: C901 - points: npt.NDArray[np.float64], - triangles: npt.NDArray[np.int64], - plane_origin: Sequence[float], - plane_normal: Sequence[float], -) -> CadShape: - """Build planar BREP face(s) whose wires trace the triangle-group boundary. - - Each connected component of the triangle group becomes a - :class:`TopoDS_Face` whose underlying surface is ``Geom_Plane`` and - whose outer/inner wires follow the boundary edges of the group on the - plane. Suitable both for STEP export (the BRep represents the right - region, not a bounding rectangle) and for gmsh ``setPeriodic`` (the - plane equation is recognised and the trimmed wires define the slave/ - master mesh region identically on opposite cell sides). - - All triangle vertices must lie on the plane within OCCT tolerance. - - :param points: ``(N, 3)`` array of vertex coordinates - :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices - :param plane_origin: a point on the plane - :param plane_normal: the plane's outward unit normal - :return: wrapped face (single component) or shell (multiple components) - :raises ShellCreationError: if no usable boundary loop is found - """ - require_cad() - from OCP.BRep import BRep_Builder - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeWire, - ) - from OCP.gp import gp_Dir, gp_Pln, gp_Pnt - from OCP.TopoDS import TopoDS_Shell - - cast_wire = _topods_cast("Wire") - - pts = np.asarray(points, dtype=np.float64) - tris = np.asarray(triangles, dtype=np.int64) - if tris.size == 0: - err_msg = "Cannot build a planar face from an empty triangle list" - raise ShellCreationError(err_msg) - - plane = gp_Pln( - gp_Pnt(float(plane_origin[0]), float(plane_origin[1]), float(plane_origin[2])), - gp_Dir(float(plane_normal[0]), float(plane_normal[1]), float(plane_normal[2])), - ) - - n = np.asarray(plane_normal, dtype=np.float64) - n = n / (float(np.linalg.norm(n)) or 1.0) - helper = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0]) - u_ax = np.cross(n, helper) - u_ax /= float(np.linalg.norm(u_ax)) or 1.0 - v_ax = np.cross(n, u_ax) - o_arr = np.asarray(plane_origin, dtype=np.float64) - - component = _triangle_components(tris) - n_comp = int(component.max()) + 1 if component.size else 0 - - pnt_cache: dict[int, gp_Pnt] = {} - - def _occ_pnt(idx: int) -> gp_Pnt: - cached = pnt_cache.get(idx) - if cached is None: - v = pts[idx] - cached = gp_Pnt(float(v[0]), float(v[1]), float(v[2])) - pnt_cache[idx] = cached - return cached - - def _signed_area(loop: list[int]) -> float: - rel = pts[loop[:-1]] - o_arr - u = rel @ u_ax - v = rel @ v_ax - return 0.5 * float(np.sum(u * np.roll(v, -1) - np.roll(u, -1) * v)) - - def _build_wire(loop: list[int]): - wb = BRepBuilderAPI_MakeWire() - for k in range(len(loop) - 1): - edge_builder = BRepBuilderAPI_MakeEdge( - _occ_pnt(loop[k]), _occ_pnt(loop[k + 1]) - ) - if not edge_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeEdge failed for boundary segment" - raise ShellCreationError(err_msg) - wb.Add(edge_builder.Edge()) - if not wb.IsDone(): - err_msg = "BRepBuilderAPI_MakeWire failed for boundary loop" - raise ShellCreationError(err_msg) - return wb.Wire() - - def _build_component_face(loops: list[list[int]]): - areas = [_signed_area(L) for L in loops] - outer_local = int(np.argmax([abs(a) for a in areas])) - if areas[outer_local] < 0: - loops = [list(reversed(L)) for L in loops] - wires = [_build_wire(L) for L in loops] - face_builder = BRepBuilderAPI_MakeFace(plane, wires[outer_local]) - for k, w in enumerate(wires): - if k == outer_local: - continue - face_builder.Add(cast_wire(w.Reversed())) - if not face_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeFace failed building a planar face" - raise ShellCreationError(err_msg) - return face_builder.Face() - - component_faces = [] - for ci in range(n_comp): - comp_tris = tris[np.where(component == ci)[0]] - loops = _walk_boundary_loops(comp_tris) - if loops: - component_faces.append(_build_component_face(loops)) - - if not component_faces: - err_msg = "No planar face could be built from the triangle group" - raise ShellCreationError(err_msg) - - if len(component_faces) == 1: - return CadShape(component_faces[0]) - - builder = BRep_Builder() - shell = TopoDS_Shell() - builder.MakeShell(shell) - for f in component_faces: - builder.Add(shell, f) - return CadShape(shell) - - -def mesh_to_shell_brep( - points: npt.NDArray[np.float64], - triangles: npt.NDArray[np.int64], -) -> CadShape: - """Convert a triangle mesh to a shell with one planar BREP face per triangle. - - Slower than :func:`mesh_to_shape` (which uses a single tessellated face) - but produces a shell with *real* geometric surfaces — required whenever - the resulting shell is used as a cutting tool in boolean ops - (``BRepAlgoAPI_Cut``, ``Workplane.split()``, etc.), which refuse a - tessellated-only face. - - :param points: ``(N, 3)`` array of vertex coordinates - :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices - :return: wrapped ``TopoDS_Shell`` with one planar face per triangle - :raises ShellCreationError: if any triangle cannot be built into a face - """ - require_cad() - from OCP.BRep import BRep_Builder - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeWire, - ) - from OCP.gp import gp_Pnt - from OCP.TopoDS import TopoDS_Shell - - pts = np.asarray(points, dtype=np.float64) - tris = np.asarray(triangles, dtype=np.int64) - if tris.size == 0: - err_msg = "Cannot build a shell from an empty triangle list" - raise ShellCreationError(err_msg) - - occ_points = [gp_Pnt(float(p[0]), float(p[1]), float(p[2])) for p in pts] - - builder = BRep_Builder() - shell = TopoDS_Shell() - builder.MakeShell(shell) - - try: - for a, b, c in tris: - e1 = BRepBuilderAPI_MakeEdge(occ_points[int(a)], occ_points[int(b)]).Edge() - e2 = BRepBuilderAPI_MakeEdge(occ_points[int(b)], occ_points[int(c)]).Edge() - e3 = BRepBuilderAPI_MakeEdge(occ_points[int(c)], occ_points[int(a)]).Edge() - wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() - face = BRepBuilderAPI_MakeFace(wire).Face() - builder.Add(shell, face) - except Exception as err: - err_msg = ( - "Failed to build the OCCT shell from the mesh; " - "try to increase the resolution or adjust bounds." - ) - raise ShellCreationError(err_msg) from err - - return CadShape(shell) - - -def mesh_to_sewn_shell( - points: npt.NDArray[np.float64], - triangles: npt.NDArray[np.int64], - tolerance: float | None = None, -) -> CadShape: - """Convert a triangle mesh to a sewn shell with shared edges. - - Builds one planar BREP face per triangle then sews them via - :class:`BRepBuilderAPI_Sewing` so coincident edges/vertices are merged - into shared topology. The resulting shell has valid topology, which - makes any subsequent boolean op (``Cut``, ``Common``, ``Fuse``) tractable - — unlike :func:`mesh_to_shell_brep` whose triangle-soup output is - pathologically slow for booleans. - - :param points: ``(N, 3)`` array of vertex coordinates - :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices - :param tolerance: sewing tolerance; defaults to ``1e-6 * bbox_diag`` - :return: wrapped sewn ``TopoDS_Shape`` (Shell, or Compound of shells if - the input is disconnected) - :raises ShellCreationError: if any triangle cannot be built into a face - """ - require_cad() - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeWire, - BRepBuilderAPI_Sewing, - ) - from OCP.gp import gp_Pnt - - pts = np.asarray(points, dtype=np.float64) - tris = np.asarray(triangles, dtype=np.int64) - if tris.size == 0: - err_msg = "Cannot build a shell from an empty triangle list" - raise ShellCreationError(err_msg) - - if tolerance is None: - bbox_diag = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0))) - tolerance = max(1e-9, 1e-6 * bbox_diag) - - occ_points = [gp_Pnt(float(p[0]), float(p[1]), float(p[2])) for p in pts] - - sewing = BRepBuilderAPI_Sewing(tolerance) - try: - for a, b, c in tris: - e1 = BRepBuilderAPI_MakeEdge(occ_points[int(a)], occ_points[int(b)]).Edge() - e2 = BRepBuilderAPI_MakeEdge(occ_points[int(b)], occ_points[int(c)]).Edge() - e3 = BRepBuilderAPI_MakeEdge(occ_points[int(c)], occ_points[int(a)]).Edge() - wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() - face = BRepBuilderAPI_MakeFace(wire).Face() - sewing.Add(face) - except Exception as err: - err_msg = ( - "Failed to build the OCCT shell from the mesh; " - "try to increase the resolution or adjust bounds." - ) - raise ShellCreationError(err_msg) from err - - sewing.Perform() - return CadShape(sewing.SewedShape()) - - -# --------------------------------------------------------------------------- -# Primitive builders -# --------------------------------------------------------------------------- - - -def make_box(dim: Sequence[float], center: Sequence[float]) -> CadShape: - """Axis-aligned box of size ``dim`` centered at ``center``.""" - require_cad() - from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox - from OCP.gp import gp_Pnt - - dx, dy, dz = (float(d) for d in dim) - cx, cy, cz = (float(c) for c in center) - corner = gp_Pnt(cx - dx / 2.0, cy - dy / 2.0, cz - dz / 2.0) - return CadShape(BRepPrimAPI_MakeBox(corner, dx, dy, dz).Shape()) - - -def make_sphere(radius: float, center: Sequence[float]) -> CadShape: - """Sphere of given radius at ``center``.""" - require_cad() - from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere - from OCP.gp import gp_Pnt - - pnt = gp_Pnt(float(center[0]), float(center[1]), float(center[2])) - return CadShape(BRepPrimAPI_MakeSphere(pnt, float(radius)).Shape()) - - -def make_cylinder( - radius: float, - height: float, - center: Sequence[float], - axis: Sequence[float] = (1.0, 0.0, 0.0), -) -> CadShape: - """Cylinder of given radius and height centered at ``center`` along ``axis``.""" - require_cad() - from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder - from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt - - h = float(height) - ax_vec = np.asarray(axis, dtype=np.float64) - ax_vec = ax_vec / np.linalg.norm(ax_vec) - base = ( - float(center[0]) - h / 2.0 * ax_vec[0], - float(center[1]) - h / 2.0 * ax_vec[1], - float(center[2]) - h / 2.0 * ax_vec[2], - ) - ax = gp_Ax2( - gp_Pnt(*base), - gp_Dir(float(ax_vec[0]), float(ax_vec[1]), float(ax_vec[2])), - ) - return CadShape(BRepPrimAPI_MakeCylinder(ax, float(radius), h).Shape()) - - -def make_capsule( - radius: float, - height: float, - center: Sequence[float], -) -> CadShape: - """Capsule (cylinder along X with hemispherical caps).""" - require_cad() - from OCP.BRepPrimAPI import ( - BRepPrimAPI_MakeCylinder, - BRepPrimAPI_MakeSphere, - ) - from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt - - cx, cy, cz = (float(c) for c in center) - h = float(height) - r = float(radius) - base_axis = gp_Ax2(gp_Pnt(cx - h / 2.0, cy, cz), gp_Dir(1.0, 0.0, 0.0)) - cyl = BRepPrimAPI_MakeCylinder(base_axis, r, h).Shape() - left = BRepPrimAPI_MakeSphere(gp_Pnt(cx - h / 2.0, cy, cz), r).Shape() - right = BRepPrimAPI_MakeSphere(gp_Pnt(cx + h / 2.0, cy, cz), r).Shape() - fused = CadShape(cyl).fuse(CadShape(left)).fuse(CadShape(right)) - return fused - - -def make_ellipsoid(radii: Sequence[float], center: Sequence[float]) -> CadShape: - """Ellipsoid of the given axis-aligned radii at ``center``. - - Built as a unit sphere transformed by a non-uniform scaling matrix. - """ - require_cad() - from OCP.BRepBuilderAPI import BRepBuilderAPI_GTransform - from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere - from OCP.gp import gp_GTrsf, gp_Mat, gp_Pnt, gp_XYZ - - rx, ry, rz = (float(r) for r in radii) - cx, cy, cz = (float(c) for c in center) - sphere = BRepPrimAPI_MakeSphere(gp_Pnt(0.0, 0.0, 0.0), 1.0).Shape() - - gtrsf = gp_GTrsf() - gtrsf.SetVectorialPart( - gp_Mat( - rx, - 0.0, - 0.0, - 0.0, - ry, - 0.0, - 0.0, - 0.0, - rz, - ), - ) - gtrsf.SetTranslationPart(gp_XYZ(cx, cy, cz)) - return CadShape(BRepBuilderAPI_GTransform(sphere, gtrsf, True).Shape()) - - -def make_polyhedron( - vertices: Sequence[Sequence[float]], - faces_ixs: Sequence[Sequence[int]], - center: Sequence[float] = (0.0, 0.0, 0.0), -) -> CadShape: - """Polyhedron from vertex list and face→vertex index list. - - Each face's vertex index list must be closed (last == first). - - Uses ``BRepBuilderAPI_Sewing`` to stitch the independently-constructed - faces into a shared-edge shell with consistent outward orientation, - then ``BRepBuilderAPI_MakeSolid`` to close it. (Raw ``BRep_Builder.Add`` - does not orient; the resulting solid would have mixed-sign volume.) - """ - require_cad() - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeSolid, - BRepBuilderAPI_MakeWire, - BRepBuilderAPI_Sewing, - ) - from OCP.gp import gp_Pnt - from OCP.ShapeFix import ShapeFix_Solid - from OCP.TopAbs import TopAbs_SHELL - from OCP.TopExp import TopExp_Explorer - - cx, cy, cz = (float(c) for c in center) - points = [ - gp_Pnt(float(v[0]) + cx, float(v[1]) + cy, float(v[2]) + cz) for v in vertices - ] - - sewing = BRepBuilderAPI_Sewing() - for ixs in faces_ixs: - wire_builder = BRepBuilderAPI_MakeWire() - for i1, i2 in zip(ixs, ixs[1:], strict=False): - edge_builder = BRepBuilderAPI_MakeEdge(points[i1], points[i2]) - if not edge_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeEdge failed for polyhedron face" - raise ShellCreationError(err_msg) - wire_builder.Add(edge_builder.Edge()) - if not wire_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeWire failed for polyhedron face" - raise ShellCreationError(err_msg) - face_builder = BRepBuilderAPI_MakeFace(wire_builder.Wire()) - if not face_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeFace failed for polyhedron face" - raise ShellCreationError(err_msg) - sewing.Add(face_builder.Face()) - sewing.Perform() - sewn = sewing.SewedShape() - - # Extract the shell (sewing may return it directly or wrapped in a compound). - exp = TopExp_Explorer(sewn, TopAbs_SHELL) - if not exp.More(): - err_msg = "Sewing did not produce a shell — check face connectivity" - raise ShellCreationError(err_msg) - shell = _topods_cast("Shell")(exp.Current()) - - solid_builder = BRepBuilderAPI_MakeSolid(shell) - if not solid_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeSolid failed; sewn shell is not closed" - raise ShellCreationError(err_msg) - fixer = ShapeFix_Solid(solid_builder.Solid()) - fixer.Perform() - return CadShape(fixer.Solid()) - - -def make_extruded_polygon( - list_corners: Sequence[tuple[float, float]], - height: float, - center: Sequence[float], -) -> CadShape: - """Extrude a 2D polygon (in the YZ plane) along the X axis.""" - require_cad() - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeWire, - ) - from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism - from OCP.gp import gp_Pnt, gp_Vec - - cx, cy, cz = (float(c) for c in center) - h = float(height) - x_base = cx - h / 2.0 - pts = [gp_Pnt(x_base, cy + float(y), cz + float(z)) for (y, z) in list_corners] - if (list_corners[0][0], list_corners[0][1]) != ( - list_corners[-1][0], - list_corners[-1][1], - ): - pts.append(pts[0]) - - wire_builder = BRepBuilderAPI_MakeWire() - for i in range(len(pts) - 1): - edge_builder = BRepBuilderAPI_MakeEdge(pts[i], pts[i + 1]) - if not edge_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeEdge failed for extruded polygon" - raise ShellCreationError(err_msg) - wire_builder.Add(edge_builder.Edge()) - if not wire_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeWire failed for extruded polygon" - raise ShellCreationError(err_msg) - face_builder = BRepBuilderAPI_MakeFace(wire_builder.Wire()) - if not face_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeFace failed for extruded polygon" - raise ShellCreationError(err_msg) - extruded = BRepPrimAPI_MakePrism(face_builder.Face(), gp_Vec(h, 0.0, 0.0)).Shape() - return CadShape(extruded) - - -# --------------------------------------------------------------------------- -# Compound assembly (for lattice) -# --------------------------------------------------------------------------- - - -def make_compound(shapes: Iterable[CadShape]) -> CadShape: - """Assemble shapes into a single OCCT ``TopoDS_Compound``.""" - require_cad() - from OCP.BRep import BRep_Builder - from OCP.TopoDS import TopoDS_Compound - - builder = BRep_Builder() - compound = TopoDS_Compound() - builder.MakeCompound(compound) - for s in shapes: - builder.Add(compound, s.wrapped) - return CadShape(compound) - - -def make_compound_from_solids(solids: Iterable[Any]) -> CadShape: - """Assemble raw OCCT ``TopoDS_Shape`` solids (not ``CadShape``) into a compound.""" - require_cad() - from OCP.BRep import BRep_Builder - from OCP.TopoDS import TopoDS_Compound - - builder = BRep_Builder() - compound = TopoDS_Compound() - builder.MakeCompound(compound) - for s in solids: - shape = s.wrapped if hasattr(s, "wrapped") else s - builder.Add(compound, shape) - return CadShape(compound) - - -# --------------------------------------------------------------------------- -# Topology exploration and splitting -# --------------------------------------------------------------------------- - - -def enumerate_solids(shape: CadShape) -> list[Any]: - """Return the list of ``TopoDS_Solid`` inside a shape (empty if none).""" - require_cad() - from OCP.TopAbs import TopAbs_SOLID - from OCP.TopExp import TopExp_Explorer - - cast_solid = _topods_cast("Solid") - out: list[Any] = [] - exp = TopExp_Explorer(shape.wrapped, TopAbs_SOLID) - while exp.More(): - out.append(cast_solid(exp.Current())) - exp.Next() - return out - - -def split_shape( - shape: CadShape, - tool: CadShape | Iterable[CadShape], - *, - fuzzy_value: float = 1e-4, -) -> CadShape: - """Split *shape* by *tool* using OCCT's ``BRepAlgoAPI_Splitter``. - - The result is a :class:`CadShape` wrapping a ``TopoDS_Compound`` that - contains the sub-shapes produced by the split. Use - :func:`enumerate_solids` to iterate over the resulting solids. - - :param tool: a single :class:`CadShape` or an iterable of them. Pass - multiple tools when each tool is a separate ``TopoDS_Shell`` and - you want to keep them topologically distinct — the OCCT splitter - treats each list entry as one cutting tool. This matters because - ``BRepAlgoAPI_Fuse`` on two shells *decomposes* them into a - compound of per-face shells, which then defeats the splitter. - :param fuzzy_value: tolerance forwarded to ``SetFuzzyValue`` so OCCT - recognises near-coincident geometry as touching. Necessary when - a tool is a tessellated shell whose boundary edges lie on - ``shape``'s planar faces only up to a few microns of drift. - """ - require_cad() - from OCP.BRepAlgoAPI import BRepAlgoAPI_Splitter - from OCP.TopTools import TopTools_ListOfShape - - args = TopTools_ListOfShape() - args.Append(shape.wrapped) - tools = TopTools_ListOfShape() - if isinstance(tool, CadShape): - tools.Append(tool.wrapped) - else: - for t in tool: - tools.Append(t.wrapped) - - splitter = BRepAlgoAPI_Splitter() - splitter.SetArguments(args) - splitter.SetTools(tools) - if fuzzy_value > 0: - splitter.SetFuzzyValue(float(fuzzy_value)) - splitter.Build() - return CadShape(splitter.Shape()) - - -def make_plane_face( - base_pnt: Sequence[float], - direction: Sequence[float], - half_size: float = 1.0e6, -) -> CadShape: - """Build a large planar face used as a cutting tool. - - :param base_pnt: a point on the plane - :param direction: plane normal - :param half_size: half-edge of the square face (default large so the plane - reaches far outside any realistic shape) - """ - require_cad() - from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace - from OCP.gp import gp_Ax3, gp_Dir, gp_Pln, gp_Pnt - - pnt = gp_Pnt(float(base_pnt[0]), float(base_pnt[1]), float(base_pnt[2])) - nrm = gp_Dir(float(direction[0]), float(direction[1]), float(direction[2])) - plane = gp_Pln(gp_Ax3(pnt, nrm)) - face = BRepBuilderAPI_MakeFace( - plane, - -float(half_size), - float(half_size), - -float(half_size), - float(half_size), - ).Face() - return CadShape(face) - - -def transform_geometry(shape: CadShape, matrix: npt.NDArray[np.float64]) -> CadShape: - """Apply a 3x4 affine matrix (linear + translation). - - Wraps OCCT ``BRepBuilderAPI_GTransform``. - - :param matrix: ``(3, 4)`` array; rows are ``[a b c tx; d e f ty; g h i tz]``. - """ - require_cad() - from OCP.BRepBuilderAPI import BRepBuilderAPI_GTransform - from OCP.gp import gp_GTrsf, gp_Mat, gp_XYZ - - m = np.asarray(matrix, dtype=np.float64) - gtrsf = gp_GTrsf() - gtrsf.SetVectorialPart( - gp_Mat( - float(m[0, 0]), - float(m[0, 1]), - float(m[0, 2]), - float(m[1, 0]), - float(m[1, 1]), - float(m[1, 2]), - float(m[2, 0]), - float(m[2, 1]), - float(m[2, 2]), - ), - ) - gtrsf.SetTranslationPart(gp_XYZ(float(m[0, 3]), float(m[1, 3]), float(m[2, 3]))) - return CadShape(BRepBuilderAPI_GTransform(shape.wrapped, gtrsf, True).Shape()) - - -def translate_solid(solid: Any, offset: Sequence[float]) -> Any: - """Translate a raw OCCT solid/shape by ``offset`` and return the same type.""" - require_cad() - from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform - from OCP.gp import gp_Trsf, gp_Vec - - shape = solid.wrapped if hasattr(solid, "wrapped") else solid - trsf = gp_Trsf() - trsf.SetTranslation( - gp_Vec(float(offset[0]), float(offset[1]), float(offset[2])), - ) - return BRepBuilderAPI_Transform(shape, trsf, True).Shape() - - -def solid_center(shape: Any) -> tuple[float, float, float]: - """Return the volumetric center of mass of a raw OCCT shape/solid.""" - require_cad() - from OCP.BRepGProp import BRepGProp - from OCP.GProp import GProp_GProps - - s = shape.wrapped if hasattr(shape, "wrapped") else shape - props = GProp_GProps() - BRepGProp.VolumeProperties_s(s, props) - com = props.CentreOfMass() - return (float(com.X()), float(com.Y()), float(com.Z())) - - -def select_solids_on_side( - shape: CadShape, - base_pnt: Sequence[float], - side_direction: Sequence[float], -) -> list[Any]: - """Enumerate a compound's solids, keep those whose centroid is on the - positive side of the plane through ``base_pnt`` normal to ``side_direction``. - - Matches CadQuery's ``.solids(">X")`` / ``.solids(" 0: - selected.append(solid) - return selected - - -def intersect_solids_with_box(solids: Iterable[Any], box: CadShape) -> CadShape: - """Intersect each solid with *box* and fuse the results into a ``CadShape``. - - Returns a :class:`CadShape` wrapping a compound (possibly empty). - """ - require_cad() - from OCP.BRepAlgoAPI import BRepAlgoAPI_Common - - parts: list[Any] = [] - for solid in solids: - s = solid.wrapped if hasattr(solid, "wrapped") else solid - common = BRepAlgoAPI_Common(s, box.wrapped).Shape() - # Keep if it contains at least one solid. - if enumerate_solids(CadShape(common)): - parts.append(common) - return make_compound_from_solids(parts) diff --git a/microgen/cad/__init__.py b/microgen/cad/__init__.py new file mode 100644 index 00000000..e532525c --- /dev/null +++ b/microgen/cad/__init__.py @@ -0,0 +1,124 @@ +"""CAD backend — direct OCCT (via OCP) replacement for CadQuery. + +========================================================= +CAD backend (:mod:`microgen.cad`) +========================================================= + +All CadQuery calls in microgen have been replaced by direct OCP +(``cadquery-ocp-novtk``) calls housed in this subpackage. OCP is an +*optional* dependency — install via ``pip install microgen[cad]``. + +The subpackage's submodules don't import OCP at top level, so +``import microgen.cad`` always succeeds. OCP-dependent functions import +it lazily and raise a helpful ``ImportError`` with install instructions if +OCP is missing. + +Return type +----------- + +CAD-producing functions return a :class:`CadShape` — a thin wrapper around +an OCCT ``TopoDS_Shape`` exposing ``.wrapped`` for downstream OCP calls, plus +convenience methods (``translate``, ``rotate``, ``fuse``, ``cut``, +``export_stl``, ``export_step``, ``export_brep``). ``.wrapped`` matches the +attribute name CadQuery's ``Shape`` exposed, so most legacy call sites keep +working unchanged. + +Layout +------ + +- ``cad._install`` — ``require_cad`` / ``_INSTALL_HINT`` gate +- ``cad.shape`` — ``CadShape`` class + ``_Centre`` / ``_BBox`` / + ``_run_boolean`` / ``_topods_cast`` / ``ShellCreationError`` +- ``cad.io`` — ``import_step`` (export methods live on ``CadShape``) +- ``cad.meshbridge`` — ``mesh_to_shape`` / ``shape_to_cad`` / + ``mesh_to_planar_face`` / ``mesh_to_shell_brep`` / ``mesh_to_sewn_shell`` +- ``cad.primitives`` — ``make_box`` / ``make_sphere`` / ``make_cylinder`` / + ``make_capsule`` / ``make_ellipsoid`` / ``make_polyhedron`` / + ``make_extruded_polygon`` +- ``cad.topo`` — ``make_compound`` / ``make_compound_from_solids`` / + ``enumerate_solids`` / ``split_shape`` / ``make_plane_face`` / + ``transform_geometry`` / ``translate_solid`` / ``solid_center`` / + ``select_solids_on_side`` / ``intersect_solids_with_box`` + +All public symbols are re-exported here so existing +``from microgen.cad import …`` imports keep working unchanged. +""" + +from __future__ import annotations + +from ._install import _INSTALL_HINT, require_cad +from .io import import_step +from .meshbridge import ( + _triangle_components, + _walk_boundary_loops, + mesh_to_planar_face, + mesh_to_sewn_shell, + mesh_to_shape, + mesh_to_shell_brep, + shape_to_cad, +) +from .primitives import ( + make_box, + make_capsule, + make_cylinder, + make_ellipsoid, + make_extruded_polygon, + make_polyhedron, + make_sphere, +) +from .shape import ( + CadShape, + ShellCreationError, + _BBox, + _Centre, + _run_boolean, + _topods_cast, +) +from .topo import ( + enumerate_solids, + intersect_solids_with_box, + make_compound, + make_compound_from_solids, + make_plane_face, + select_solids_on_side, + solid_center, + split_shape, + transform_geometry, + translate_solid, +) + +__all__ = [ + "CadShape", + "ShellCreationError", + "_BBox", + "_Centre", + "_INSTALL_HINT", + "_run_boolean", + "_topods_cast", + "_triangle_components", + "_walk_boundary_loops", + "enumerate_solids", + "import_step", + "intersect_solids_with_box", + "make_box", + "make_capsule", + "make_compound", + "make_compound_from_solids", + "make_cylinder", + "make_ellipsoid", + "make_extruded_polygon", + "make_plane_face", + "make_polyhedron", + "make_sphere", + "mesh_to_planar_face", + "mesh_to_sewn_shell", + "mesh_to_shape", + "mesh_to_shell_brep", + "require_cad", + "select_solids_on_side", + "shape_to_cad", + "solid_center", + "split_shape", + "transform_geometry", + "translate_solid", +] diff --git a/microgen/cad/_install.py b/microgen/cad/_install.py new file mode 100644 index 00000000..a46f54b6 --- /dev/null +++ b/microgen/cad/_install.py @@ -0,0 +1,22 @@ +"""CAD backend install gate. + +``require_cad()`` is the canonical way for lazy CAD code paths to verify +that the optional ``[cad]`` extra is available before touching OCP, and to +raise a user-friendly ``ImportError`` with install instructions otherwise. +""" + +from __future__ import annotations + +_INSTALL_HINT = ( + "microgen's CAD backend requires the OCP (OCCT) Python bindings. " + "Install with: pip install 'microgen[cad]' " + "(this pulls cadquery-ocp-novtk; on conda-forge use `ocp` instead)." +) + + +def require_cad() -> None: + """Raise :class:`ImportError` if the CAD backend (OCP) is not importable.""" + try: + import OCP # noqa: F401, PLC0415 + except ImportError as err: + raise ImportError(_INSTALL_HINT) from err diff --git a/microgen/cad/io.py b/microgen/cad/io.py new file mode 100644 index 00000000..22599c8b --- /dev/null +++ b/microgen/cad/io.py @@ -0,0 +1,34 @@ +"""STEP file I/O (read). + +STEP/BREP/STL *export* is exposed as methods on :class:`CadShape` itself +(``export_step``, ``export_brep``, ``export_stl``) — see ``cad/shape.py``. +This module holds the standalone ``import_step`` reader. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ._install import require_cad +from .shape import CadShape + +if TYPE_CHECKING: + from pathlib import Path + + +def import_step(path: str | Path) -> CadShape: + """Import a STEP file and return the resulting :class:`CadShape`. + + Multi-root STEP files are merged into a single ``TopoDS_Compound``. + """ + require_cad() + from OCP.IFSelect import IFSelect_RetDone # noqa: PLC0415 + from OCP.STEPControl import STEPControl_Reader # noqa: PLC0415 + + reader = STEPControl_Reader() + status = reader.ReadFile(str(path)) + if status != IFSelect_RetDone: + err_msg = f"STEP read failed for {path!r} with status {status!r}" + raise RuntimeError(err_msg) + reader.TransferRoots() + return CadShape(reader.OneShape()) diff --git a/microgen/cad/meshbridge.py b/microgen/cad/meshbridge.py new file mode 100644 index 00000000..ceab2d43 --- /dev/null +++ b/microgen/cad/meshbridge.py @@ -0,0 +1,478 @@ +"""Mesh ↔ BREP bridges. + +Functions in this module convert triangle meshes (numpy point/index arrays, +or pyvista ``PolyData``) into OCCT ``CadShape`` representations, or vice +versa (via the implicit ``Shape`` → BREP bridge in :func:`shape_to_cad`). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import numpy.typing as npt + +from ._install import require_cad +from .shape import CadShape, ShellCreationError, _topods_cast + +if TYPE_CHECKING: + from collections.abc import Sequence + + from ..shape._types import BoundsType + from ..shape.shape import Shape + + +def mesh_to_shape( + points: npt.NDArray[np.float64], + triangles: npt.NDArray[np.int64], +) -> CadShape: + """Convert a triangle mesh to a :class:`CadShape` via ``Poly_Triangulation``. + + One ``TopoDS_Face`` carries the full triangulation (OCCT native tessellated + BREP representation). This is the SOTA fast path: O(N) in pure OCCT C++ + with no Python-per-triangle overhead, and exports cleanly to STEP AP242 + (tessellated) and STL. + + :param points: ``(N, 3)`` array of vertex coordinates + :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices + :return: wrapped ``TopoDS_Shell`` containing one tessellated face + :raises ShellCreationError: if the triangulation cannot be built + """ + require_cad() + from OCP.BRep import BRep_Builder # noqa: PLC0415 + from OCP.gp import gp_Pnt # noqa: PLC0415 + from OCP.Poly import Poly_Triangle, Poly_Triangulation # noqa: PLC0415 + from OCP.TopoDS import TopoDS_Face, TopoDS_Shell # noqa: PLC0415 + + pts = np.asarray(points, dtype=np.float64) + tris = np.asarray(triangles, dtype=np.int64) + if pts.ndim != 2 or pts.shape[1] != 3: + err_msg = f"points must be (N, 3), got {pts.shape}" + raise ValueError(err_msg) + if tris.ndim != 2 or tris.shape[1] != 3: + err_msg = f"triangles must be (M, 3), got {tris.shape}" + raise ValueError(err_msg) + if tris.size == 0: + err_msg = "Cannot build a shell from an empty triangle list" + raise ShellCreationError(err_msg) + + nb_nodes = int(pts.shape[0]) + nb_tri = int(tris.shape[0]) + triangulation = Poly_Triangulation(nb_nodes, nb_tri, False) + for i in range(nb_nodes): + triangulation.SetNode( + i + 1, gp_Pnt(float(pts[i, 0]), float(pts[i, 1]), float(pts[i, 2])) + ) + for i in range(nb_tri): + a, b, c = int(tris[i, 0]), int(tris[i, 1]), int(tris[i, 2]) + triangulation.SetTriangle(i + 1, Poly_Triangle(a + 1, b + 1, c + 1)) + + builder = BRep_Builder() + face = TopoDS_Face() + try: + builder.MakeFace(face, triangulation) + except Exception as err: + err_msg = "OCCT refused the triangulation — check bounds and field." + raise ShellCreationError(err_msg) from err + + shell = TopoDS_Shell() + builder.MakeShell(shell) + builder.Add(shell, face) + return CadShape(shell) + + +def shape_to_cad( + shape: Shape, + bounds: BoundsType | None = None, + resolution: int = 50, +) -> CadShape: + """Bridge an implicit :class:`~microgen.shape.shape.Shape` to a CAD BREP. + + Runs the shape's :meth:`generate_surface_mesh` (marching cubes on the + SDF for free Shapes; native renderer for concrete subclasses) then + wraps the resulting triangle mesh into a single tessellated BREP face + via :func:`mesh_to_shape`. + + Concrete subclasses with native primitive paths (``Box``, ``Sphere``, + ``Tpms``, ``Spinodoid`` …) usually skip this bridge and call their + own ``generate_cad``. This function is the generic fallback for + bare ``Shape`` instances built via :func:`microgen.shape.implicit_ops.from_field` + or boolean composition (``a | b``, ``a - b``, ...). + + Requires the optional ``[cad]`` install extra. + + :param shape: the implicit shape to materialise + :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)``; defaults to + ``shape.bounds`` if set, else raises ``ValueError`` + :param resolution: marching-cubes grid resolution per axis + :return: :class:`CadShape` wrapping the tessellated ``TopoDS_Shell`` + """ + require_cad() + if shape.func is None: + err_msg = "No implicit field defined — cannot build BREP from an empty Shape" + raise NotImplementedError(err_msg) + + mesh = shape.generate_surface_mesh(bounds=bounds, resolution=resolution) + if mesh.n_cells == 0: + err_msg = "Generated mesh is empty — check bounds and field function" + raise ValueError(err_msg) + + if not mesh.is_all_triangles: + mesh.triangulate(inplace=True) + triangles = mesh.faces.reshape(-1, 4)[:, 1:] + points = np.asarray(mesh.points, dtype=np.float64) + + from ..shape.shape import ShellCreationError as _ShapeShellError # noqa: PLC0415 + + try: + return mesh_to_shape(points, triangles) + except Exception as err: + err_msg = ( + "Failed to build the OCCT shell from the mesh; " + "try to increase the resolution or adjust bounds." + ) + raise _ShapeShellError(err_msg) from err + + +def _triangle_components( + triangles: npt.NDArray[np.int64], +) -> npt.NDArray[np.int64]: + """Label connected components of a triangle mesh by edge adjacency.""" + from collections import defaultdict # noqa: PLC0415 + + n_tri = int(triangles.shape[0]) + edges_sorted = np.sort( + np.vstack( + [triangles[:, [0, 1]], triangles[:, [1, 2]], triangles[:, [2, 0]]], + ), + axis=1, + ) + _keys, inv = np.unique(edges_sorted, axis=0, return_inverse=True) + owner = np.tile(np.arange(n_tri), 3) + + edge_to_tris: dict[int, list[int]] = defaultdict(list) + for global_i, key_i in enumerate(inv): + edge_to_tris[int(key_i)].append(int(owner[global_i])) + + adj: list[list[int]] = [[] for _ in range(n_tri)] + for tlist in edge_to_tris.values(): + if len(tlist) == 2: + adj[tlist[0]].append(tlist[1]) + adj[tlist[1]].append(tlist[0]) + + component = -np.ones(n_tri, dtype=np.int64) + n_comp = 0 + for start in range(n_tri): + if component[start] >= 0: + continue + component[start] = n_comp + stack = [start] + while stack: + t = stack.pop() + for nb in adj[t]: + if component[nb] < 0: + component[nb] = n_comp + stack.append(nb) + n_comp += 1 + return component + + +def _walk_boundary_loops( + comp_tris: npt.NDArray[np.int64], +) -> list[list[int]]: + """Extract directed closed loops from the boundary edges of a triangle group. + + A boundary edge appears in exactly one triangle of the group; the loops + are the closed chains of those edges in their original triangle direction. + """ + from collections import defaultdict # noqa: PLC0415 + + ce = np.vstack( + [comp_tris[:, [0, 1]], comp_tris[:, [1, 2]], comp_tris[:, [2, 0]]], + ) + cs = np.sort(ce, axis=1) + _keys, inv, counts = np.unique( + cs, + axis=0, + return_inverse=True, + return_counts=True, + ) + bedges = ce[counts[inv] == 1] + if len(bedges) == 0: + return [] + + used = np.zeros(len(bedges), dtype=bool) + start_map: dict[int, list[int]] = defaultdict(list) + for i, (a, _b) in enumerate(bedges): + start_map[int(a)].append(i) + + loops: list[list[int]] = [] + for start_i in range(len(bedges)): + if used[start_i]: + continue + cur = start_i + used[cur] = True + loop = [int(bedges[cur, 0])] + while True: + b = int(bedges[cur, 1]) + loop.append(b) + if b == loop[0]: + break + nxt = next( + (cand for cand in start_map[b] if not used[cand]), + None, + ) + if nxt is None: + break + used[nxt] = True + cur = nxt + if len(loop) >= 4 and loop[-1] == loop[0]: + loops.append(loop) + return loops + + +def mesh_to_planar_face( # noqa: C901 + points: npt.NDArray[np.float64], + triangles: npt.NDArray[np.int64], + plane_origin: Sequence[float], + plane_normal: Sequence[float], +) -> CadShape: + """Build planar BREP face(s) whose wires trace the triangle-group boundary. + + Each connected component of the triangle group becomes a + :class:`TopoDS_Face` whose underlying surface is ``Geom_Plane`` and + whose outer/inner wires follow the boundary edges of the group on the + plane. Suitable both for STEP export (the BRep represents the right + region, not a bounding rectangle) and for gmsh ``setPeriodic`` (the + plane equation is recognised and the trimmed wires define the slave/ + master mesh region identically on opposite cell sides). + + All triangle vertices must lie on the plane within OCCT tolerance. + + :param points: ``(N, 3)`` array of vertex coordinates + :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices + :param plane_origin: a point on the plane + :param plane_normal: the plane's outward unit normal + :return: wrapped face (single component) or shell (multiple components) + :raises ShellCreationError: if no usable boundary loop is found + """ + require_cad() + from OCP.BRep import BRep_Builder # noqa: PLC0415 + from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeWire, + ) + from OCP.gp import gp_Dir, gp_Pln, gp_Pnt # noqa: PLC0415 + from OCP.TopoDS import TopoDS_Shell # noqa: PLC0415 + + cast_wire = _topods_cast("Wire") + + pts = np.asarray(points, dtype=np.float64) + tris = np.asarray(triangles, dtype=np.int64) + if tris.size == 0: + err_msg = "Cannot build a planar face from an empty triangle list" + raise ShellCreationError(err_msg) + + plane = gp_Pln( + gp_Pnt(float(plane_origin[0]), float(plane_origin[1]), float(plane_origin[2])), + gp_Dir(float(plane_normal[0]), float(plane_normal[1]), float(plane_normal[2])), + ) + + n = np.asarray(plane_normal, dtype=np.float64) + n = n / (float(np.linalg.norm(n)) or 1.0) + helper = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0]) + u_ax = np.cross(n, helper) + u_ax /= float(np.linalg.norm(u_ax)) or 1.0 + v_ax = np.cross(n, u_ax) + o_arr = np.asarray(plane_origin, dtype=np.float64) + + component = _triangle_components(tris) + n_comp = int(component.max()) + 1 if component.size else 0 + + pnt_cache: dict[int, gp_Pnt] = {} + + def _occ_pnt(idx: int) -> gp_Pnt: + cached = pnt_cache.get(idx) + if cached is None: + v = pts[idx] + cached = gp_Pnt(float(v[0]), float(v[1]), float(v[2])) + pnt_cache[idx] = cached + return cached + + def _signed_area(loop: list[int]) -> float: + rel = pts[loop[:-1]] - o_arr + u = rel @ u_ax + v = rel @ v_ax + return 0.5 * float(np.sum(u * np.roll(v, -1) - np.roll(u, -1) * v)) + + def _build_wire(loop: list[int]): + wb = BRepBuilderAPI_MakeWire() + for k in range(len(loop) - 1): + edge_builder = BRepBuilderAPI_MakeEdge( + _occ_pnt(loop[k]), _occ_pnt(loop[k + 1]) + ) + if not edge_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeEdge failed for boundary segment" + raise ShellCreationError(err_msg) + wb.Add(edge_builder.Edge()) + if not wb.IsDone(): + err_msg = "BRepBuilderAPI_MakeWire failed for boundary loop" + raise ShellCreationError(err_msg) + return wb.Wire() + + def _build_component_face(loops: list[list[int]]): + areas = [_signed_area(L) for L in loops] + outer_local = int(np.argmax([abs(a) for a in areas])) + if areas[outer_local] < 0: + loops = [list(reversed(L)) for L in loops] + wires = [_build_wire(L) for L in loops] + face_builder = BRepBuilderAPI_MakeFace(plane, wires[outer_local]) + for k, w in enumerate(wires): + if k == outer_local: + continue + face_builder.Add(cast_wire(w.Reversed())) + if not face_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeFace failed building a planar face" + raise ShellCreationError(err_msg) + return face_builder.Face() + + component_faces = [] + for ci in range(n_comp): + comp_tris = tris[np.where(component == ci)[0]] + loops = _walk_boundary_loops(comp_tris) + if loops: + component_faces.append(_build_component_face(loops)) + + if not component_faces: + err_msg = "No planar face could be built from the triangle group" + raise ShellCreationError(err_msg) + + if len(component_faces) == 1: + return CadShape(component_faces[0]) + + builder = BRep_Builder() + shell = TopoDS_Shell() + builder.MakeShell(shell) + for f in component_faces: + builder.Add(shell, f) + return CadShape(shell) + + +def mesh_to_shell_brep( + points: npt.NDArray[np.float64], + triangles: npt.NDArray[np.int64], +) -> CadShape: + """Convert a triangle mesh to a shell with one planar BREP face per triangle. + + Slower than :func:`mesh_to_shape` (which uses a single tessellated face) + but produces a shell with *real* geometric surfaces — required whenever + the resulting shell is used as a cutting tool in boolean ops + (``BRepAlgoAPI_Cut``, ``Workplane.split()``, etc.), which refuse a + tessellated-only face. + + :param points: ``(N, 3)`` array of vertex coordinates + :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices + :return: wrapped ``TopoDS_Shell`` with one planar face per triangle + :raises ShellCreationError: if any triangle cannot be built into a face + """ + require_cad() + from OCP.BRep import BRep_Builder # noqa: PLC0415 + from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeWire, + ) + from OCP.gp import gp_Pnt # noqa: PLC0415 + from OCP.TopoDS import TopoDS_Shell # noqa: PLC0415 + + pts = np.asarray(points, dtype=np.float64) + tris = np.asarray(triangles, dtype=np.int64) + if tris.size == 0: + err_msg = "Cannot build a shell from an empty triangle list" + raise ShellCreationError(err_msg) + + occ_points = [gp_Pnt(float(p[0]), float(p[1]), float(p[2])) for p in pts] + + builder = BRep_Builder() + shell = TopoDS_Shell() + builder.MakeShell(shell) + + try: + for a, b, c in tris: + e1 = BRepBuilderAPI_MakeEdge(occ_points[int(a)], occ_points[int(b)]).Edge() + e2 = BRepBuilderAPI_MakeEdge(occ_points[int(b)], occ_points[int(c)]).Edge() + e3 = BRepBuilderAPI_MakeEdge(occ_points[int(c)], occ_points[int(a)]).Edge() + wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() + face = BRepBuilderAPI_MakeFace(wire).Face() + builder.Add(shell, face) + except Exception as err: + err_msg = ( + "Failed to build the OCCT shell from the mesh; " + "try to increase the resolution or adjust bounds." + ) + raise ShellCreationError(err_msg) from err + + return CadShape(shell) + + +def mesh_to_sewn_shell( + points: npt.NDArray[np.float64], + triangles: npt.NDArray[np.int64], + tolerance: float | None = None, +) -> CadShape: + """Convert a triangle mesh to a sewn shell with shared edges. + + Builds one planar BREP face per triangle then sews them via + :class:`BRepBuilderAPI_Sewing` so coincident edges/vertices are merged + into shared topology. The resulting shell has valid topology, which + makes any subsequent boolean op (``Cut``, ``Common``, ``Fuse``) tractable + — unlike :func:`mesh_to_shell_brep` whose triangle-soup output is + pathologically slow for booleans. + + :param points: ``(N, 3)`` array of vertex coordinates + :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices + :param tolerance: sewing tolerance; defaults to ``1e-6 * bbox_diag`` + :return: wrapped sewn ``TopoDS_Shape`` (Shell, or Compound of shells if + the input is disconnected) + :raises ShellCreationError: if any triangle cannot be built into a face + """ + require_cad() + from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeWire, + BRepBuilderAPI_Sewing, + ) + from OCP.gp import gp_Pnt # noqa: PLC0415 + + pts = np.asarray(points, dtype=np.float64) + tris = np.asarray(triangles, dtype=np.int64) + if tris.size == 0: + err_msg = "Cannot build a shell from an empty triangle list" + raise ShellCreationError(err_msg) + + if tolerance is None: + bbox_diag = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0))) + tolerance = max(1e-9, 1e-6 * bbox_diag) + + occ_points = [gp_Pnt(float(p[0]), float(p[1]), float(p[2])) for p in pts] + + sewing = BRepBuilderAPI_Sewing(tolerance) + try: + for a, b, c in tris: + e1 = BRepBuilderAPI_MakeEdge(occ_points[int(a)], occ_points[int(b)]).Edge() + e2 = BRepBuilderAPI_MakeEdge(occ_points[int(b)], occ_points[int(c)]).Edge() + e3 = BRepBuilderAPI_MakeEdge(occ_points[int(c)], occ_points[int(a)]).Edge() + wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() + face = BRepBuilderAPI_MakeFace(wire).Face() + sewing.Add(face) + except Exception as err: + err_msg = ( + "Failed to build the OCCT shell from the mesh; " + "try to increase the resolution or adjust bounds." + ) + raise ShellCreationError(err_msg) from err + + sewing.Perform() + return CadShape(sewing.SewedShape()) diff --git a/microgen/cad/primitives.py b/microgen/cad/primitives.py new file mode 100644 index 00000000..567b9c36 --- /dev/null +++ b/microgen/cad/primitives.py @@ -0,0 +1,233 @@ +"""OCCT primitive builders. + +Free factory functions producing :class:`CadShape` instances for the core +parametric primitives (box, sphere, cylinder, capsule, ellipsoid, +polyhedron, extruded polygon). These are the back-ends the implicit shape +classes (``Box``, ``Sphere``, …) call from their ``generate_cad`` methods. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from ._install import require_cad +from .shape import CadShape, ShellCreationError, _topods_cast + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def make_box(dim: Sequence[float], center: Sequence[float]) -> CadShape: + """Axis-aligned box of size ``dim`` centered at ``center``.""" + require_cad() + from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox # noqa: PLC0415 + from OCP.gp import gp_Pnt # noqa: PLC0415 + + dx, dy, dz = (float(d) for d in dim) + cx, cy, cz = (float(c) for c in center) + corner = gp_Pnt(cx - dx / 2.0, cy - dy / 2.0, cz - dz / 2.0) + return CadShape(BRepPrimAPI_MakeBox(corner, dx, dy, dz).Shape()) + + +def make_sphere(radius: float, center: Sequence[float]) -> CadShape: + """Sphere of given radius at ``center``.""" + require_cad() + from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere # noqa: PLC0415 + from OCP.gp import gp_Pnt # noqa: PLC0415 + + pnt = gp_Pnt(float(center[0]), float(center[1]), float(center[2])) + return CadShape(BRepPrimAPI_MakeSphere(pnt, float(radius)).Shape()) + + +def make_cylinder( + radius: float, + height: float, + center: Sequence[float], + axis: Sequence[float] = (1.0, 0.0, 0.0), +) -> CadShape: + """Cylinder of given radius and height centered at ``center`` along ``axis``.""" + require_cad() + from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder # noqa: PLC0415 + from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt # noqa: PLC0415 + + h = float(height) + ax_vec = np.asarray(axis, dtype=np.float64) + ax_vec = ax_vec / np.linalg.norm(ax_vec) + base = ( + float(center[0]) - h / 2.0 * ax_vec[0], + float(center[1]) - h / 2.0 * ax_vec[1], + float(center[2]) - h / 2.0 * ax_vec[2], + ) + ax = gp_Ax2( + gp_Pnt(*base), + gp_Dir(float(ax_vec[0]), float(ax_vec[1]), float(ax_vec[2])), + ) + return CadShape(BRepPrimAPI_MakeCylinder(ax, float(radius), h).Shape()) + + +def make_capsule( + radius: float, + height: float, + center: Sequence[float], +) -> CadShape: + """Capsule (cylinder along X with hemispherical caps).""" + require_cad() + from OCP.BRepPrimAPI import ( # noqa: PLC0415 + BRepPrimAPI_MakeCylinder, + BRepPrimAPI_MakeSphere, + ) + from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt # noqa: PLC0415 + + cx, cy, cz = (float(c) for c in center) + h = float(height) + r = float(radius) + base_axis = gp_Ax2(gp_Pnt(cx - h / 2.0, cy, cz), gp_Dir(1.0, 0.0, 0.0)) + cyl = BRepPrimAPI_MakeCylinder(base_axis, r, h).Shape() + left = BRepPrimAPI_MakeSphere(gp_Pnt(cx - h / 2.0, cy, cz), r).Shape() + right = BRepPrimAPI_MakeSphere(gp_Pnt(cx + h / 2.0, cy, cz), r).Shape() + return CadShape(cyl).fuse(CadShape(left)).fuse(CadShape(right)) + + +def make_ellipsoid(radii: Sequence[float], center: Sequence[float]) -> CadShape: + """Ellipsoid of the given axis-aligned radii at ``center``. + + Built as a unit sphere transformed by a non-uniform scaling matrix. + """ + require_cad() + from OCP.BRepBuilderAPI import BRepBuilderAPI_GTransform # noqa: PLC0415 + from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere # noqa: PLC0415 + from OCP.gp import gp_GTrsf, gp_Mat, gp_Pnt, gp_XYZ # noqa: PLC0415 + + rx, ry, rz = (float(r) for r in radii) + cx, cy, cz = (float(c) for c in center) + sphere = BRepPrimAPI_MakeSphere(gp_Pnt(0.0, 0.0, 0.0), 1.0).Shape() + + gtrsf = gp_GTrsf() + gtrsf.SetVectorialPart( + gp_Mat( + rx, + 0.0, + 0.0, + 0.0, + ry, + 0.0, + 0.0, + 0.0, + rz, + ), + ) + gtrsf.SetTranslationPart(gp_XYZ(cx, cy, cz)) + return CadShape(BRepBuilderAPI_GTransform(sphere, gtrsf, True).Shape()) + + +def make_polyhedron( + vertices: Sequence[Sequence[float]], + faces_ixs: Sequence[Sequence[int]], + center: Sequence[float] = (0.0, 0.0, 0.0), +) -> CadShape: + """Polyhedron from vertex list and face→vertex index list. + + Each face's vertex index list must be closed (last == first). + + Uses ``BRepBuilderAPI_Sewing`` to stitch the independently-constructed + faces into a shared-edge shell with consistent outward orientation, + then ``BRepBuilderAPI_MakeSolid`` to close it. (Raw ``BRep_Builder.Add`` + does not orient; the resulting solid would have mixed-sign volume.) + """ + require_cad() + from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeSolid, + BRepBuilderAPI_MakeWire, + BRepBuilderAPI_Sewing, + ) + from OCP.gp import gp_Pnt # noqa: PLC0415 + from OCP.ShapeFix import ShapeFix_Solid # noqa: PLC0415 + from OCP.TopAbs import TopAbs_SHELL # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + + cx, cy, cz = (float(c) for c in center) + points = [ + gp_Pnt(float(v[0]) + cx, float(v[1]) + cy, float(v[2]) + cz) for v in vertices + ] + + sewing = BRepBuilderAPI_Sewing() + for ixs in faces_ixs: + wire_builder = BRepBuilderAPI_MakeWire() + for i1, i2 in zip(ixs, ixs[1:], strict=False): + edge_builder = BRepBuilderAPI_MakeEdge(points[i1], points[i2]) + if not edge_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeEdge failed for polyhedron face" + raise ShellCreationError(err_msg) + wire_builder.Add(edge_builder.Edge()) + if not wire_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeWire failed for polyhedron face" + raise ShellCreationError(err_msg) + face_builder = BRepBuilderAPI_MakeFace(wire_builder.Wire()) + if not face_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeFace failed for polyhedron face" + raise ShellCreationError(err_msg) + sewing.Add(face_builder.Face()) + sewing.Perform() + sewn = sewing.SewedShape() + + # Extract the shell (sewing may return it directly or wrapped in a compound). + exp = TopExp_Explorer(sewn, TopAbs_SHELL) + if not exp.More(): + err_msg = "Sewing did not produce a shell — check face connectivity" + raise ShellCreationError(err_msg) + shell = _topods_cast("Shell")(exp.Current()) + + solid_builder = BRepBuilderAPI_MakeSolid(shell) + if not solid_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeSolid failed; sewn shell is not closed" + raise ShellCreationError(err_msg) + fixer = ShapeFix_Solid(solid_builder.Solid()) + fixer.Perform() + return CadShape(fixer.Solid()) + + +def make_extruded_polygon( + list_corners: Sequence[tuple[float, float]], + height: float, + center: Sequence[float], +) -> CadShape: + """Extrude a 2D polygon (in the YZ plane) along the X axis.""" + require_cad() + from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeWire, + ) + from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism # noqa: PLC0415 + from OCP.gp import gp_Pnt, gp_Vec # noqa: PLC0415 + + cx, cy, cz = (float(c) for c in center) + h = float(height) + x_base = cx - h / 2.0 + pts = [gp_Pnt(x_base, cy + float(y), cz + float(z)) for (y, z) in list_corners] + if (list_corners[0][0], list_corners[0][1]) != ( + list_corners[-1][0], + list_corners[-1][1], + ): + pts.append(pts[0]) + + wire_builder = BRepBuilderAPI_MakeWire() + for i in range(len(pts) - 1): + edge_builder = BRepBuilderAPI_MakeEdge(pts[i], pts[i + 1]) + if not edge_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeEdge failed for extruded polygon" + raise ShellCreationError(err_msg) + wire_builder.Add(edge_builder.Edge()) + if not wire_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeWire failed for extruded polygon" + raise ShellCreationError(err_msg) + face_builder = BRepBuilderAPI_MakeFace(wire_builder.Wire()) + if not face_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeFace failed for extruded polygon" + raise ShellCreationError(err_msg) + extruded = BRepPrimAPI_MakePrism(face_builder.Face(), gp_Vec(h, 0.0, 0.0)).Shape() + return CadShape(extruded) diff --git a/microgen/cad/shape.py b/microgen/cad/shape.py new file mode 100644 index 00000000..108be59c --- /dev/null +++ b/microgen/cad/shape.py @@ -0,0 +1,376 @@ +"""``CadShape`` wrapper and OCCT topology helpers. + +This module holds the ``CadShape`` thin wrapper around ``TopoDS_Shape`` plus +the low-level OCP utilities used by the rest of the CAD subpackage +(``_run_boolean``, ``_topods_cast``, ``_Centre``, ``_BBox``, +``ShellCreationError``). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ._install import require_cad # noqa: F401 (re-exported for back-compat) + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + from OCP.TopoDS import TopoDS_Shape + + +class _Centre(tuple): + """Tuple-like 3D point exposing ``.x``, ``.y``, ``.z`` and ``.to_tuple()``. + + Returned by :meth:`CadShape.center`. Mimics just enough of + ``cadquery.Vector`` to drop-in where existing code uses + ``shape.center().to_tuple()`` or ``shape.center().x``. + """ + + __slots__ = () + + def __new__(cls, x: float, y: float, z: float) -> _Centre: + """Create a 3-tuple ``(x, y, z)``.""" + return super().__new__(cls, (float(x), float(y), float(z))) + + @property + def x(self) -> float: + """X coordinate.""" + return self[0] + + @property + def y(self) -> float: + """Y coordinate.""" + return self[1] + + @property + def z(self) -> float: + """Z coordinate.""" + return self[2] + + def to_tuple(self) -> tuple[float, float, float]: + """Return ``(x, y, z)`` as a plain tuple.""" + return (self[0], self[1], self[2]) + + +class _BBox: + """Axis-aligned bounding box exposing ``xmin`` / ``xmax`` / …. + + Returned by :meth:`CadShape.bounding_box`. Also indexable as a 6-tuple + ``(xmin, ymin, zmin, xmax, ymax, zmax)`` matching OCCT's ``Bnd_Box.Get``. + """ + + __slots__ = ("xmax", "xmin", "ymax", "ymin", "zmax", "zmin") + + def __init__( + self, + xmin: float, + ymin: float, + zmin: float, + xmax: float, + ymax: float, + zmax: float, + ) -> None: + """Initialize from the 6 axis-aligned extents.""" + self.xmin = float(xmin) + self.ymin = float(ymin) + self.zmin = float(zmin) + self.xmax = float(xmax) + self.ymax = float(ymax) + self.zmax = float(zmax) + + @property + def diagonal_length(self) -> float: + """Length of the box's space diagonal.""" + dx = self.xmax - self.xmin + dy = self.ymax - self.ymin + dz = self.zmax - self.zmin + return float((dx * dx + dy * dy + dz * dz) ** 0.5) + + +class ShellCreationError(RuntimeError): + """Raised when a mesh cannot be converted into an OCCT shell.""" + + +def _run_boolean(op_cls: Any, a: CadShape, b: CadShape, label: str) -> TopoDS_Shape: + """Run an OCCT boolean op and raise on failure. + + Older OCP releases don't expose ``HasErrors()`` / ``IsDone()`` on the + ``BRepAlgoAPI_*`` classes; we probe via ``getattr`` and skip the check + when the API isn't available. + """ + op = op_cls(a.wrapped, b.wrapped) + has_errors = getattr(op, "HasErrors", None) + if callable(has_errors) and has_errors(): + err_msg = f"BRepAlgoAPI_{label} failed" + raise RuntimeError(err_msg) + return op.Shape() + + +def _topods_cast(name: str) -> Any: + """Return ``TopoDS.`` cast helper, tolerant of OCP version drift. + + Older OCP releases expose the static cast as ``TopoDS.Shell_s`` (pybind11 + ``_s`` convention); newer releases expose the unsuffixed ``TopoDS.Shell``. + Try the suffixed form first, fall back to unsuffixed. + """ + from OCP.TopoDS import TopoDS # noqa: PLC0415 + + return getattr(TopoDS, f"{name}_s", None) or getattr(TopoDS, name) + + +class CadShape: + """Thin wrapper around an OCCT ``TopoDS_Shape``. + + Preserves the ``.wrapped`` attribute name used by CadQuery so downstream + OCP calls (``BRepAlgoAPI_Fuse(a.wrapped, b.wrapped)``) keep working. + + ``_mesh_volume`` (optional) is a trusted volume in the source mesh's + units, set by mesh-derived constructors (e.g. the TPMS periodic shell) + where OCCT's surface-integral volume is unreliable on invalid topology. + :meth:`volume` prefers it over the OCCT integral when present. + """ + + __slots__ = ("_mesh_volume", "wrapped") + + def __init__(self, shape: TopoDS_Shape) -> None: + """Wrap an OCCT ``TopoDS_Shape``.""" + self.wrapped = shape + self._mesh_volume: float | None = None + + # -- transforms -------------------------------------------------------- + + def translate(self, offset: Sequence[float]) -> CadShape: + """Return a translated copy.""" + from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform # noqa: PLC0415 + from OCP.gp import gp_Trsf, gp_Vec # noqa: PLC0415 + + trsf = gp_Trsf() + trsf.SetTranslation( + gp_Vec(float(offset[0]), float(offset[1]), float(offset[2])) + ) + transformed = BRepBuilderAPI_Transform(self.wrapped, trsf, True).Shape() + return CadShape(transformed) + + def rotate( + self, + center: Sequence[float], + axis: Sequence[float], + angle_degrees: float, + ) -> CadShape: + """Return a rotated copy (angle in degrees, axis is a unit vector).""" + from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform # noqa: PLC0415 + from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf # noqa: PLC0415 + + trsf = gp_Trsf() + ax = gp_Ax1( + gp_Pnt(float(center[0]), float(center[1]), float(center[2])), + gp_Dir(float(axis[0]), float(axis[1]), float(axis[2])), + ) + trsf.SetRotation(ax, float(np.deg2rad(angle_degrees))) + transformed = BRepBuilderAPI_Transform(self.wrapped, trsf, True).Shape() + return CadShape(transformed) + + def copy(self) -> CadShape: + """Return an independent copy (deep topology copy).""" + from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy # noqa: PLC0415 + + return CadShape(BRepBuilderAPI_Copy(self.wrapped).Shape()) + + # -- boolean ops ------------------------------------------------------- + + def fuse(self, other: CadShape) -> CadShape: + """Boolean fusion: ``self ∪ other``.""" + from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse # noqa: PLC0415 + + return CadShape(_run_boolean(BRepAlgoAPI_Fuse, self, other, "Fuse")) + + def cut(self, other: CadShape) -> CadShape: + """Boolean difference: ``self \\ other``.""" + from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut # noqa: PLC0415 + + return CadShape(_run_boolean(BRepAlgoAPI_Cut, self, other, "Cut")) + + def intersect(self, other: CadShape) -> CadShape: + """Boolean intersection: ``self ∩ other``.""" + from OCP.BRepAlgoAPI import BRepAlgoAPI_Common # noqa: PLC0415 + + return CadShape(_run_boolean(BRepAlgoAPI_Common, self, other, "Common")) + + # -- topology queries -------------------------------------------------- + + def solids(self) -> list[CadShape]: + """Enumerate contained solids.""" + from OCP.TopAbs import TopAbs_SOLID # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + + cast_solid = _topods_cast("Solid") + out: list[CadShape] = [] + exp = TopExp_Explorer(self.wrapped, TopAbs_SOLID) + while exp.More(): + out.append(CadShape(cast_solid(exp.Current()))) + exp.Next() + return out + + def vertices(self) -> list[tuple[float, float, float]]: + """Enumerate the vertex coordinates of the shape. + + Callers use this to check that a generated mesh has any vertices at + all (``assert np.any(shape.vertices())``). + """ + from OCP.BRep import BRep_Tool # noqa: PLC0415 + from OCP.TopAbs import TopAbs_VERTEX # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + + cast_vertex = _topods_cast("Vertex") + pnt = getattr(BRep_Tool, "Pnt_s", None) or BRep_Tool.Pnt + out: list[tuple[float, float, float]] = [] + exp = TopExp_Explorer(self.wrapped, TopAbs_VERTEX) + while exp.More(): + v = cast_vertex(exp.Current()) + p = pnt(v) + out.append((float(p.X()), float(p.Y()), float(p.Z()))) + exp.Next() + return out + + def faces(self) -> list[CadShape]: + """Enumerate the faces of the shape.""" + from OCP.TopAbs import TopAbs_FACE # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + + cast_face = _topods_cast("Face") + out: list[CadShape] = [] + exp = TopExp_Explorer(self.wrapped, TopAbs_FACE) + while exp.More(): + out.append(CadShape(cast_face(exp.Current()))) + exp.Next() + return out + + def is_closed(self) -> bool: + """Whether the shape is topologically closed. + + Solids are always closed; for shells/compounds we read OCCT's + per-shape ``Closed`` flag (set by ``BRep_Builder::IsClosed`` when the + shell was built from a watertight set of faces). + """ + from OCP.TopAbs import TopAbs_SOLID # noqa: PLC0415 + + if self.wrapped.ShapeType() == TopAbs_SOLID: + return True + return bool(self.wrapped.Closed()) + + def volume(self) -> float: + """Return the (unsigned) volume of the shape. + + OCCT's ``BRepGProp::VolumeProperties`` returns a *signed* volume that + depends on face orientation; mesh-built shells from + :func:`microgen.cad.mesh_to_shell_brep` can carry inverted orientation + and yield a negative value. We return ``abs(...)`` so volumes are + non-negative. + + If a mesh-derived volume was stashed on ``_mesh_volume`` AND the OCCT + solid is not valid (BRepCheck_Analyzer flags self-intersection / + non-manifold edges, common on raw marching-cubes input), we trust the + mesh volume — the OCCT surface integral on an invalid topology is + meaningless. + """ + from OCP.BRepCheck import BRepCheck_Analyzer # noqa: PLC0415 + from OCP.BRepGProp import BRepGProp # noqa: PLC0415 + from OCP.GProp import GProp_GProps # noqa: PLC0415 + + if ( + self._mesh_volume is not None + and not BRepCheck_Analyzer( + self.wrapped, + ).IsValid() + ): + return float(abs(self._mesh_volume)) + + props = GProp_GProps() + BRepGProp.VolumeProperties_s(self.wrapped, props) + return float(abs(props.Mass())) + + def center(self) -> _Centre: + """Return the volumetric center of mass. + + The result is a :class:`_Centre` — exposes ``.x``, ``.y``, ``.z``, + ``.to_tuple()``, and unpacks like a tuple. + """ + from OCP.BRepGProp import BRepGProp # noqa: PLC0415 + from OCP.GProp import GProp_GProps # noqa: PLC0415 + + props = GProp_GProps() + BRepGProp.VolumeProperties_s(self.wrapped, props) + c = props.CentreOfMass() + return _Centre(float(c.X()), float(c.Y()), float(c.Z())) + + def bounding_box(self) -> _BBox: + """Return the axis-aligned bounding box. + + The result exposes ``xmin`` / ``xmax`` / … attributes (see + :class:`_BBox`). + """ + from OCP.Bnd import Bnd_Box # noqa: PLC0415 + from OCP.BRepBndLib import BRepBndLib # noqa: PLC0415 + + box = Bnd_Box() + # AddOptimal uses exact geometric bounds (not cached triangulation). + BRepBndLib.AddOptimal_s(self.wrapped, box, True, True) + xmin, ymin, zmin, xmax, ymax, zmax = box.Get() + return _BBox(xmin, ymin, zmin, xmax, ymax, zmax) + + # -- exports ----------------------------------------------------------- + + def export_stl( + self, + path: str | Path, + linear_deflection: float = 0.01, + angular_deflection: float = 0.5, + *, + ascii_mode: bool = False, + ) -> None: + """Export to STL. Mesh is regenerated at the given deflection.""" + from OCP.BRepMesh import BRepMesh_IncrementalMesh # noqa: PLC0415 + from OCP.StlAPI import StlAPI_Writer # noqa: PLC0415 + + BRepMesh_IncrementalMesh( + self.wrapped, + float(linear_deflection), + False, + float(angular_deflection), + True, + ) + writer = StlAPI_Writer() + writer.ASCIIMode = bool(ascii_mode) + if not writer.Write(self.wrapped, str(path)): + err_msg = f"STL write failed for {path!r}" + raise RuntimeError(err_msg) + + def export_step(self, path: str | Path) -> None: + """Export to STEP (AP214).""" + from OCP.IFSelect import IFSelect_RetDone # noqa: PLC0415 + from OCP.STEPControl import ( # noqa: PLC0415 + STEPControl_AsIs, + STEPControl_Writer, + ) + + writer = STEPControl_Writer() + status = writer.Transfer(self.wrapped, STEPControl_AsIs) + if status != IFSelect_RetDone: + err_msg = f"STEP transfer failed with status {status!r}" + raise RuntimeError(err_msg) + status = writer.Write(str(path)) + if status != IFSelect_RetDone: + err_msg = f"STEP write failed with status {status!r}" + raise RuntimeError(err_msg) + + def export_brep(self, path: str | Path) -> None: + """Export to OCCT native BREP.""" + from OCP.BRepTools import BRepTools # noqa: PLC0415 + + ok = BRepTools.Write_s(self.wrapped, str(path)) + if not ok: + err_msg = f"BREP write failed for {path!r}" + raise RuntimeError(err_msg) diff --git a/microgen/cad/topo.py b/microgen/cad/topo.py new file mode 100644 index 00000000..293bad30 --- /dev/null +++ b/microgen/cad/topo.py @@ -0,0 +1,238 @@ +"""Topology assembly, exploration, and splitting helpers. + +Free functions for working with OCCT ``TopoDS_Solid`` / ``TopoDS_Compound`` +collections: building compounds, enumerating solids, splitting shapes by +tool, plane-face cutting tools, affine transforms, side selection, and +box-clipping a list of solids. + +These power the periodic split-and-translate algorithm in +``microgen.periodic`` and the rasterisation pipeline on ``Phase``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np +import numpy.typing as npt + +from ._install import require_cad +from .shape import CadShape, _topods_cast + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + +def make_compound(shapes: Iterable[CadShape]) -> CadShape: + """Assemble shapes into a single OCCT ``TopoDS_Compound``.""" + require_cad() + from OCP.BRep import BRep_Builder # noqa: PLC0415 + from OCP.TopoDS import TopoDS_Compound # noqa: PLC0415 + + builder = BRep_Builder() + compound = TopoDS_Compound() + builder.MakeCompound(compound) + for s in shapes: + builder.Add(compound, s.wrapped) + return CadShape(compound) + + +def make_compound_from_solids(solids: Iterable[Any]) -> CadShape: + """Assemble raw OCCT ``TopoDS_Shape`` solids (not ``CadShape``) into a compound.""" + require_cad() + from OCP.BRep import BRep_Builder # noqa: PLC0415 + from OCP.TopoDS import TopoDS_Compound # noqa: PLC0415 + + builder = BRep_Builder() + compound = TopoDS_Compound() + builder.MakeCompound(compound) + for s in solids: + shape = s.wrapped if hasattr(s, "wrapped") else s + builder.Add(compound, shape) + return CadShape(compound) + + +def enumerate_solids(shape: CadShape) -> list[Any]: + """Return the list of ``TopoDS_Solid`` inside a shape (empty if none).""" + require_cad() + from OCP.TopAbs import TopAbs_SOLID # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + + cast_solid = _topods_cast("Solid") + out: list[Any] = [] + exp = TopExp_Explorer(shape.wrapped, TopAbs_SOLID) + while exp.More(): + out.append(cast_solid(exp.Current())) + exp.Next() + return out + + +def split_shape( + shape: CadShape, + tool: CadShape | Iterable[CadShape], + *, + fuzzy_value: float = 1e-4, +) -> CadShape: + """Split *shape* by *tool* using OCCT's ``BRepAlgoAPI_Splitter``. + + The result is a :class:`CadShape` wrapping a ``TopoDS_Compound`` that + contains the sub-shapes produced by the split. Use + :func:`enumerate_solids` to iterate over the resulting solids. + + :param tool: a single :class:`CadShape` or an iterable of them. Pass + multiple tools when each tool is a separate ``TopoDS_Shell`` and + you want to keep them topologically distinct — the OCCT splitter + treats each list entry as one cutting tool. This matters because + ``BRepAlgoAPI_Fuse`` on two shells *decomposes* them into a + compound of per-face shells, which then defeats the splitter. + :param fuzzy_value: tolerance forwarded to ``SetFuzzyValue`` so OCCT + recognises near-coincident geometry as touching. Necessary when + a tool is a tessellated shell whose boundary edges lie on + ``shape``'s planar faces only up to a few microns of drift. + """ + require_cad() + from OCP.BRepAlgoAPI import BRepAlgoAPI_Splitter # noqa: PLC0415 + from OCP.TopTools import TopTools_ListOfShape # noqa: PLC0415 + + args = TopTools_ListOfShape() + args.Append(shape.wrapped) + tools = TopTools_ListOfShape() + if isinstance(tool, CadShape): + tools.Append(tool.wrapped) + else: + for t in tool: + tools.Append(t.wrapped) + + splitter = BRepAlgoAPI_Splitter() + splitter.SetArguments(args) + splitter.SetTools(tools) + if fuzzy_value > 0: + splitter.SetFuzzyValue(float(fuzzy_value)) + splitter.Build() + return CadShape(splitter.Shape()) + + +def make_plane_face( + base_pnt: Sequence[float], + direction: Sequence[float], + half_size: float = 1.0e6, +) -> CadShape: + """Build a large planar face used as a cutting tool. + + :param base_pnt: a point on the plane + :param direction: plane normal + :param half_size: half-edge of the square face (default large so the plane + reaches far outside any realistic shape) + """ + require_cad() + from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace # noqa: PLC0415 + from OCP.gp import gp_Ax3, gp_Dir, gp_Pln, gp_Pnt # noqa: PLC0415 + + pnt = gp_Pnt(float(base_pnt[0]), float(base_pnt[1]), float(base_pnt[2])) + nrm = gp_Dir(float(direction[0]), float(direction[1]), float(direction[2])) + plane = gp_Pln(gp_Ax3(pnt, nrm)) + face = BRepBuilderAPI_MakeFace( + plane, + -float(half_size), + float(half_size), + -float(half_size), + float(half_size), + ).Face() + return CadShape(face) + + +def transform_geometry(shape: CadShape, matrix: npt.NDArray[np.float64]) -> CadShape: + """Apply a 3x4 affine matrix (linear + translation). + + Wraps OCCT ``BRepBuilderAPI_GTransform``. + + :param matrix: ``(3, 4)`` array; rows are ``[a b c tx; d e f ty; g h i tz]``. + """ + require_cad() + from OCP.BRepBuilderAPI import BRepBuilderAPI_GTransform # noqa: PLC0415 + from OCP.gp import gp_GTrsf, gp_Mat, gp_XYZ # noqa: PLC0415 + + m = np.asarray(matrix, dtype=np.float64) + gtrsf = gp_GTrsf() + gtrsf.SetVectorialPart( + gp_Mat( + float(m[0, 0]), + float(m[0, 1]), + float(m[0, 2]), + float(m[1, 0]), + float(m[1, 1]), + float(m[1, 2]), + float(m[2, 0]), + float(m[2, 1]), + float(m[2, 2]), + ), + ) + gtrsf.SetTranslationPart(gp_XYZ(float(m[0, 3]), float(m[1, 3]), float(m[2, 3]))) + return CadShape(BRepBuilderAPI_GTransform(shape.wrapped, gtrsf, True).Shape()) + + +def translate_solid(solid: Any, offset: Sequence[float]) -> Any: + """Translate a raw OCCT solid/shape by ``offset`` and return the same type.""" + require_cad() + from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform # noqa: PLC0415 + from OCP.gp import gp_Trsf, gp_Vec # noqa: PLC0415 + + shape = solid.wrapped if hasattr(solid, "wrapped") else solid + trsf = gp_Trsf() + trsf.SetTranslation( + gp_Vec(float(offset[0]), float(offset[1]), float(offset[2])), + ) + return BRepBuilderAPI_Transform(shape, trsf, True).Shape() + + +def solid_center(shape: Any) -> tuple[float, float, float]: + """Return the volumetric center of mass of a raw OCCT shape/solid.""" + require_cad() + from OCP.BRepGProp import BRepGProp # noqa: PLC0415 + from OCP.GProp import GProp_GProps # noqa: PLC0415 + + s = shape.wrapped if hasattr(shape, "wrapped") else shape + props = GProp_GProps() + BRepGProp.VolumeProperties_s(s, props) + com = props.CentreOfMass() + return (float(com.X()), float(com.Y()), float(com.Z())) + + +def select_solids_on_side( + shape: CadShape, + base_pnt: Sequence[float], + side_direction: Sequence[float], +) -> list[Any]: + """Enumerate a compound's solids; keep those on the positive side of a plane. + + The plane passes through ``base_pnt`` normal to ``side_direction``. + Matches CadQuery's ``.solids(">X")`` / ``.solids(" 0: + selected.append(solid) + return selected + + +def intersect_solids_with_box(solids: Iterable[Any], box: CadShape) -> CadShape: + """Intersect each solid with *box* and fuse the results into a ``CadShape``. + + Returns a :class:`CadShape` wrapping a compound (possibly empty). + """ + require_cad() + from OCP.BRepAlgoAPI import BRepAlgoAPI_Common # noqa: PLC0415 + + parts: list[Any] = [] + for solid in solids: + s = solid.wrapped if hasattr(solid, "wrapped") else solid + common = BRepAlgoAPI_Common(s, box.wrapped).Shape() + # Keep if it contains at least one solid. + if enumerate_solids(CadShape(common)): + parts.append(common) + return make_compound_from_solids(parts) diff --git a/tests/test_no_top_level_ocp_imports.py b/tests/test_no_top_level_ocp_imports.py index beb2800c..bc060c0d 100644 --- a/tests/test_no_top_level_ocp_imports.py +++ b/tests/test_no_top_level_ocp_imports.py @@ -24,12 +24,10 @@ MICROGEN_ROOT = Path(__file__).resolve().parent.parent / "microgen" -# Files allowed to have top-level OCP imports (the CAD boundary). Paths are -# relative to ``microgen/``. Extend this list as the CAD subpackage grows -# (PR 4 splits ``cad.py`` into a ``cad/`` subpackage). -_CAD_BOUNDARY = { - "cad.py", -} +# The CAD boundary: every file under ``microgen/cad/`` is allowed to have +# top-level OCP imports. Everything outside it must keep OCP imports lazy +# (inside a function body) or guarded by ``if TYPE_CHECKING:``. +_CAD_BOUNDARY_PREFIX = "cad/" def _is_type_checking_block(node: ast.stmt) -> bool: @@ -74,7 +72,7 @@ def test_no_top_level_ocp_imports_outside_cad_boundary() -> None: offenders: list[str] = [] for py in MICROGEN_ROOT.rglob("*.py"): rel = py.relative_to(MICROGEN_ROOT).as_posix() - if rel in _CAD_BOUNDARY: + if rel.startswith(_CAD_BOUNDARY_PREFIX): continue tree = ast.parse(py.read_text(encoding="utf-8"), filename=str(py)) for node in _top_level_imports(tree): @@ -82,7 +80,7 @@ def test_no_top_level_ocp_imports_outside_cad_boundary() -> None: offenders.append(f"{rel}:{node.lineno}") assert not offenders, ( "Top-level OCP imports outside the CAD boundary " - f"({sorted(_CAD_BOUNDARY)}): {offenders}. " + f"(microgen/{_CAD_BOUNDARY_PREFIX}*): {offenders}. " "Move them inside a function body or under " "``if TYPE_CHECKING:``." ) From 62e80c68f1eccccbdc01ea4e9d1c5aaeb2ef9fed Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Tue, 26 May 2026 09:59:15 +0200 Subject: [PATCH 6/8] Add periodic-shell sewing and Shape.period Introduce shared shape type aliases and an intrinsic period attribute, and add a generic CAD bridge and periodic-shell sewing. - Add microgen/shape/_types.py with Field, BoundsType and PeriodType aliases to centralize type defs. - Add microgen/shape/periodic_shell.py: mesh_to_periodic_shell builds sewn OCCT shells with planar cap faces for periodic meshes. - Add shape_to_cad in microgen/cad.py: generic fallback to build a tessellated CadShape from an implicit Shape. - Update microgen/shape/shape.py: import shared types, add Shape.period property, and delegate default generate_cad to shape_to_cad. - Update Tpms and Spinodoid to set their intrinsic _period and reuse mesh_to_periodic_shell; remove duplicated periodic-sewing code. - Add tests/tests_shapes/test_shape_period.py to verify period behavior and periodicity for Tpms/Spinodoid. These changes centralize type annotations, avoid duplicated sewing logic, and expose intrinsic periodicity as a data-structure invariant used by TPMS/Spinodoid shapes. The periodic shell code requires the optional CAD backend. --- microgen/cad.py | 56 ++++++++++++++ microgen/shape/_types.py | 29 +++++++ microgen/shape/periodic_shell.py | 124 ++++++++++++++++++++++++++++++ microgen/shape/shape.py | 62 ++++++--------- microgen/shape/spinodoid.py | 98 +++-------------------- microgen/shape/tpms.py | 114 ++++----------------------- tests/shapes/test_shape_period.py | 100 ++++++++++++++++++++++++ 7 files changed, 356 insertions(+), 227 deletions(-) create mode 100644 microgen/shape/_types.py create mode 100644 microgen/shape/periodic_shell.py create mode 100644 tests/shapes/test_shape_period.py diff --git a/microgen/cad.py b/microgen/cad.py index e6dd3668..da92524e 100644 --- a/microgen/cad.py +++ b/microgen/cad.py @@ -36,6 +36,9 @@ from OCP.TopoDS import TopoDS_Shape + from .shape.shape import Shape + from .shape._types import BoundsType + _INSTALL_HINT = ( "microgen's CAD backend requires the OCP (OCCT) Python bindings. " @@ -493,6 +496,59 @@ def mesh_to_shape( return CadShape(shell) +def shape_to_cad( + shape: Shape, + bounds: BoundsType | None = None, + resolution: int = 50, +) -> CadShape: + """Bridge an implicit :class:`~microgen.shape.shape.Shape` to a CAD BREP. + + Runs the shape's :meth:`generate_surface_mesh` (marching cubes on the + SDF for free Shapes; native renderer for concrete subclasses) then + wraps the resulting triangle mesh into a single tessellated BREP face + via :func:`mesh_to_shape`. + + Concrete subclasses with native primitive paths (``Box``, ``Sphere``, + ``Tpms``, ``Spinodoid`` …) usually skip this bridge and call their + own ``generate_cad``. This function is the generic fallback for + bare ``Shape`` instances built via :func:`microgen.shape.implicit_ops.from_field` + or boolean composition (``a | b``, ``a - b``, ...). + + Requires the optional ``[cad]`` install extra. + + :param shape: the implicit shape to materialise + :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)``; defaults to + ``shape.bounds`` if set, else raises ``ValueError`` + :param resolution: marching-cubes grid resolution per axis + :return: :class:`CadShape` wrapping the tessellated ``TopoDS_Shell`` + """ + require_cad() + if shape.func is None: + err_msg = "No implicit field defined — cannot build BREP from an empty Shape" + raise NotImplementedError(err_msg) + + mesh = shape.generate_surface_mesh(bounds=bounds, resolution=resolution) + if mesh.n_cells == 0: + err_msg = "Generated mesh is empty — check bounds and field function" + raise ValueError(err_msg) + + if not mesh.is_all_triangles: + mesh.triangulate(inplace=True) + triangles = mesh.faces.reshape(-1, 4)[:, 1:] + points = np.asarray(mesh.points, dtype=np.float64) + + from .shape.shape import ShellCreationError # noqa: PLC0415 + + try: + return mesh_to_shape(points, triangles) + except Exception as err: + err_msg = ( + "Failed to build the OCCT shell from the mesh; " + "try to increase the resolution or adjust bounds." + ) + raise ShellCreationError(err_msg) from err + + def _triangle_components( triangles: npt.NDArray[np.int64], ) -> npt.NDArray[np.int64]: diff --git a/microgen/shape/_types.py b/microgen/shape/_types.py new file mode 100644 index 00000000..d6959833 --- /dev/null +++ b/microgen/shape/_types.py @@ -0,0 +1,29 @@ +"""Shared type aliases for the shape package. + +Centralising these here avoids duplicate definitions across ``shape.py``, +``tpms.py`` and downstream modules. All implicit shapes use the same +``Field`` callable and ``BoundsType`` AABB representation. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import numpy as np +import numpy.typing as npt + +# Implicit scalar field: ``(x, y, z) -> array``, with negative values inside. +Field = Callable[ + [npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]], + npt.NDArray[np.float64], +] + +# Axis-aligned bounding box: ``(xmin, xmax, ymin, ymax, zmin, zmax)``. +BoundsType = tuple[float, float, float, float, float, float] + +# Period of an intrinsically-periodic shape: ``(Lx, Ly, Lz)``. +# When set, ``field(x + Lx, y, z) == field(x, y, z)`` (and analogously for y, z). +# A ``None`` period means the shape is not intrinsically periodic. +PeriodType = tuple[float, float, float] + +__all__ = ["BoundsType", "Field", "PeriodType"] diff --git a/microgen/shape/periodic_shell.py b/microgen/shape/periodic_shell.py new file mode 100644 index 00000000..3c1a4e7c --- /dev/null +++ b/microgen/shape/periodic_shell.py @@ -0,0 +1,124 @@ +"""Sew a triangulated periodic surface into a closed OCCT shell. + +Given a marching-cubes (or otherwise periodic-aligned) triangle mesh of an +implicit field whose iso-surface meets the cell boundary on cap planes, +:func:`mesh_to_periodic_shell` groups boundary triangles per cap plane, +builds **one planar BREP face per cap** (via :func:`microgen.cad.mesh_to_planar_face`) +carrying the actual cap-wire trace, and sews them together with the +interior triangles into a closed shell suitable for boolean ops, STEP +export, and gmsh ``setPeriodic`` constraints. + +Shared by :class:`microgen.shape.tpms.Tpms` and +:class:`microgen.shape.spinodoid.Spinodoid` — the two TPMS/F-rep shapes +that produce periodic meshes that need this cap-aware sewing. + +Requires the optional ``[cad]`` install extra. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import numpy.typing as npt + +if TYPE_CHECKING: + from collections.abc import Sequence + + from microgen.cad import CadShape + + from ._types import BoundsType + + +def mesh_to_periodic_shell( + points: npt.NDArray[np.float64], + triangles: npt.NDArray[np.int64], + bounds: BoundsType | Sequence[float], +) -> CadShape: + """Build a sewn OCCT shell from a triangulated surface in an AABB cell. + + Triangles whose three vertices share the mesh's exact extremum on an + AABB cap plane are grouped per face and converted to a single planar + BREP face via :func:`microgen.cad.mesh_to_planar_face` (so STEP shows + the actual gyroid/spinodoid cuts, not a bounding cube, and gmsh + ``setPeriodic`` can pair opposite cap faces by plane equation). + Remaining triangles become one planar BREP face per triangle (raw, + not pre-sewn — sewing must stitch them to the cap wires along the + seam). Everything is sewn into a closed shell via + :class:`OCP.BRepBuilderAPI.BRepBuilderAPI_Sewing`. + + :param points: ``(N, 3)`` vertex coordinates + :param triangles: ``(M, 3)`` vertex-index triplets + :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)`` of the cell + :return: :class:`microgen.cad.CadShape` wrapping the sewn ``TopoDS_Shell`` + """ + from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeWire, + BRepBuilderAPI_Sewing, + ) + from OCP.gp import gp_Pnt # noqa: PLC0415 + from OCP.TopAbs import TopAbs_FACE # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + + from microgen.cad import CadShape as _CadShape # noqa: PLC0415 + from microgen.cad import mesh_to_planar_face # noqa: PLC0415 + + pts = np.asarray(points, dtype=np.float64) + tris = np.asarray(triangles, dtype=np.int64).reshape(-1, 3) + + drift_tol = 1e-9 * float(max(abs(b) for b in bounds) or 1.0) + + consumed = np.zeros(tris.shape[0], dtype=bool) + on_plane: list[tuple[int, int, float, npt.NDArray[np.int64]]] = [] + for axis in range(3): + for sign in (-1, +1): + extremum = ( + float(pts[:, axis].max()) if sign > 0 else float(pts[:, axis].min()) + ) + expected = bounds[2 * axis + (1 if sign > 0 else 0)] + if abs(extremum - expected) > drift_tol: + continue + vert_on = pts[:, axis] == extremum + tri_on = np.all(vert_on[tris], axis=1) & ~consumed + if tri_on.any(): + on_plane.append((axis, sign, extremum, np.where(tri_on)[0])) + consumed |= tri_on + interior_idx = np.where(~consumed)[0] + + bbox_diag = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0))) + sew_tol = max(1e-9, 1e-6 * bbox_diag) + sewing = BRepBuilderAPI_Sewing(sew_tol) + + for axis, sign, extremum, tri_idx in on_plane: + origin = [0.0, 0.0, 0.0] + origin[axis] = extremum + normal = [0.0, 0.0, 0.0] + normal[axis] = float(sign) + planar = mesh_to_planar_face(pts, tris[tri_idx], origin, normal) + exp = TopExp_Explorer(planar.wrapped, TopAbs_FACE) + while exp.More(): + sewing.Add(exp.Current()) + exp.Next() + + interior_tris = tris[interior_idx] + used_vertices = np.unique(interior_tris) + pnt_cache = { + int(i): gp_Pnt(float(pts[i, 0]), float(pts[i, 1]), float(pts[i, 2])) + for i in used_vertices + } + for a, b, c in interior_tris: + ga, gb, gc = pnt_cache[int(a)], pnt_cache[int(b)], pnt_cache[int(c)] + e1 = BRepBuilderAPI_MakeEdge(ga, gb).Edge() + e2 = BRepBuilderAPI_MakeEdge(gb, gc).Edge() + e3 = BRepBuilderAPI_MakeEdge(gc, ga).Edge() + wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() + face = BRepBuilderAPI_MakeFace(wire).Face() + sewing.Add(face) + + sewing.Perform() + return _CadShape(sewing.SewedShape()) + + +__all__ = ["mesh_to_periodic_shell"] diff --git a/microgen/shape/shape.py b/microgen/shape/shape.py index 7e153e74..fa074a39 100644 --- a/microgen/shape/shape.py +++ b/microgen/shape/shape.py @@ -8,7 +8,6 @@ from __future__ import annotations import itertools -from collections.abc import Callable from typing import TYPE_CHECKING import numpy as np @@ -17,18 +16,12 @@ from scipy.spatial.transform import Rotation from . import implicit_ops as _ops +from ._types import BoundsType, Field, PeriodType if TYPE_CHECKING: from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType -Field = Callable[ - [npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]], - npt.NDArray[np.float64], -] - -BoundsType = tuple[float, float, float, float, float, float] - # Scalar-field name on the StructuredGrid used by the default mesh generators. _IMPLICIT_SCALAR = "implicit" @@ -91,6 +84,9 @@ class Shape: :param orientation: orientation of the shape :param func: implicit scalar field ``(x, y, z) -> array``, or ``None`` :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)`` or ``None`` + :param period: ``(Lx, Ly, Lz)`` if the field is intrinsically periodic + (``func(p + L) == func(p)`` along each axis), or ``None``. + Set by ``Tpms`` and ``Spinodoid`` from ``cell_size * repeat_cell``. """ def __init__( @@ -99,6 +95,7 @@ def __init__( orientation: Vector3DType | Rotation = (0, 0, 0), func: Field | None = None, bounds: BoundsType | None = None, + period: PeriodType | None = None, ) -> None: """Initialize the shape.""" self._center = center @@ -109,6 +106,7 @@ def __init__( ) self._func = func self._bounds = bounds + self._period: PeriodType | None = period # Cache of sampled structured grids keyed on (bounds, resolution). # Shared between generate_surface_mesh and generate_volume_mesh so # users calling both on the same instance only pay one N^3 field @@ -152,6 +150,17 @@ def bounds(self: Shape) -> BoundsType | None: """The bounding box ``(xmin, xmax, ymin, ymax, zmin, zmax)``, or ``None``.""" return self._bounds + @property + def period(self: Shape) -> PeriodType | None: + """The intrinsic period ``(Lx, Ly, Lz)`` if the field is periodic, else ``None``. + + When non-``None``, ``self.evaluate(x + Lx, y, z) == self.evaluate(x, y, z)`` + (and analogously for y, z) — i.e. periodicity is a data-structure + invariant of the field, not a runtime flag. ``Tpms`` and + ``Spinodoid`` set this from ``cell_size * repeat_cell``. + """ + return self._period + def require_func(self: Shape) -> Field: """Return ``_func`` or raise if not set.""" if self._func is None: @@ -297,10 +306,12 @@ def generate_cad( ) -> CadShape: """Generate a CAD shape. - The default implementation builds an OCCT tessellated BREP from the - implicit-field VTK mesh, via :func:`microgen.cad.mesh_to_shape` - (single ``TopoDS_Face`` carrying a ``Poly_Triangulation``). Subclasses - override this with native primitive construction. + The default implementation delegates to + :func:`microgen.cad.shape_to_cad`, which builds a tessellated OCCT BREP + from the implicit-field marching-cubes mesh. Concrete subclasses + with native primitive paths (``Box``, ``Sphere``, ``Cylinder``, + ``Capsule``, ``Ellipsoid``, ``Tpms``, ``Spinodoid`` …) override + this method with native OCCT construction. Requires the optional ``[cad]`` install extra (``cadquery-ocp-novtk``). @@ -308,32 +319,9 @@ def generate_cad( :param resolution: number of grid points per axis :return: :class:`microgen.cad.CadShape` wrapping an OCCT ``TopoDS_Shell`` """ - if self._func is None: - err_msg = ( - "No implicit field defined — subclasses must override generate_cad()" - ) - raise NotImplementedError(err_msg) - - from microgen.cad import mesh_to_shape # noqa: PLC0415 + from microgen.cad import shape_to_cad # noqa: PLC0415 - mesh = self.generate_surface_mesh(bounds=bounds, resolution=resolution) - if mesh.n_cells == 0: - err_msg = "Generated mesh is empty — check bounds and field function" - raise ValueError(err_msg) - - if not mesh.is_all_triangles: - mesh.triangulate(inplace=True) - triangles = mesh.faces.reshape(-1, 4)[:, 1:] - points = np.asarray(mesh.points, dtype=np.float64) - - try: - return mesh_to_shape(points, triangles) - except Exception as err: - err_msg = ( - "Failed to build the OCCT shell from the mesh; " - "try to increase the resolution or adjust bounds." - ) - raise ShellCreationError(err_msg) from err + return shape_to_cad(self, bounds=bounds, resolution=resolution) # ------------------------------------------------------------------ # Boolean operators (on implicit field) diff --git a/microgen/shape/spinodoid.py b/microgen/shape/spinodoid.py index 087240bd..685a98b3 100644 --- a/microgen/shape/spinodoid.py +++ b/microgen/shape/spinodoid.py @@ -29,12 +29,14 @@ from ..operations import rotate from ._frep_grf import _FrepGRF, _normalize_cell_size, compute_threshold_for_porosity +from .periodic_shell import mesh_to_periodic_shell from .shape import Shape if TYPE_CHECKING: from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType - from microgen.shape.shape import BoundsType + + from ._types import BoundsType class Spinodoid(Shape): @@ -181,6 +183,11 @@ def _signed_field( self._func = _signed_field lx, ly, lz = (self.cell_size * self.repeat_cell).tolist() self._bounds = (0.0, float(lx), 0.0, float(ly), 0.0, float(lz)) + # Spinodoid's field is *bit-exact* periodic on ``cell_size`` along each + # axis — every kept Fourier mode lives on the reciprocal lattice, so + # ``frep.evaluate(p + cell_size) == frep.evaluate(p)`` (see _frep_grf.py). + cs = np.asarray(self.cell_size, dtype=float) + self._period = (float(cs[0]), float(cs[1]), float(cs[2])) @cached_property def grid(self: Spinodoid) -> pv.StructuredGrid: @@ -256,7 +263,7 @@ def generate_cad( pts = np.asarray(mesh.points, dtype=np.float64) tris = mesh.faces.reshape(-1, 4)[:, 1:].astype(np.int64) - shape = _try_make_solid(_mesh_to_periodic_shell(pts, tris, self._bounds)) + shape = _try_make_solid(mesh_to_periodic_shell(pts, tris, self._bounds)) shape = rotate(obj=shape, center=(0, 0, 0), rotation=self.orientation) shape = shape.translate(self.center) # Fallback for OCCT Volume() on solids it flags invalid (rigid transforms preserve volume). @@ -265,93 +272,6 @@ def generate_cad( return shape -def _mesh_to_periodic_shell( - points: npt.NDArray[np.float64], - triangles: npt.NDArray[np.int64], - bounds: Sequence[float], -) -> CadShape: - """Build a sewn OCCT shell from a triangulated surface inside an axis-aligned box. - - Triangles whose three vertices share the mesh's exact extremum on a cube - plane are grouped per face and converted to a single planar BREP face via - :func:`microgen.cad.mesh_to_planar_face`. Remaining triangles become one - planar BREP face per triangle (raw, not pre-sewn — sewing must stitch - them to cap wires along the seam). Everything is sewn into a closed shell. - - Mirrors :meth:`microgen.Tpms._mesh_to_periodic_shell` (tpms.py:683) but - parameterised by ``bounds`` rather than an instance's - ``cell_size × repeat_cell`` (Spinodoid lives at ``[0, L]^3``, Tpms at - ``[-L/2, +L/2]^3``). - """ - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeWire, - BRepBuilderAPI_Sewing, - ) - from OCP.gp import gp_Pnt - from OCP.TopAbs import TopAbs_FACE - from OCP.TopExp import TopExp_Explorer - - from microgen.cad import CadShape as _CadShape - from microgen.cad import mesh_to_planar_face - - pts = np.asarray(points, dtype=np.float64) - tris = np.asarray(triangles, dtype=np.int64).reshape(-1, 3) - - drift_tol = 1e-9 * float(max(abs(b) for b in bounds) or 1.0) - - consumed = np.zeros(tris.shape[0], dtype=bool) - on_plane: list[tuple[int, int, float, npt.NDArray[np.int64]]] = [] - for axis in range(3): - for sign in (-1, +1): - extremum = ( - float(pts[:, axis].max()) if sign > 0 else float(pts[:, axis].min()) - ) - expected = bounds[2 * axis + (1 if sign > 0 else 0)] - if abs(extremum - expected) > drift_tol: - continue - vert_on = pts[:, axis] == extremum - tri_on = np.all(vert_on[tris], axis=1) & ~consumed - if tri_on.any(): - on_plane.append((axis, sign, extremum, np.where(tri_on)[0])) - consumed |= tri_on - interior_idx = np.where(~consumed)[0] - - bbox_diag = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0))) - sew_tol = max(1e-9, 1e-6 * bbox_diag) - sewing = BRepBuilderAPI_Sewing(sew_tol) - - for axis, sign, extremum, tri_idx in on_plane: - origin = [0.0, 0.0, 0.0] - origin[axis] = extremum - normal = [0.0, 0.0, 0.0] - normal[axis] = float(sign) - planar = mesh_to_planar_face(pts, tris[tri_idx], origin, normal) - exp = TopExp_Explorer(planar.wrapped, TopAbs_FACE) - while exp.More(): - sewing.Add(exp.Current()) - exp.Next() - - interior_tris = tris[interior_idx] - used_vertices = np.unique(interior_tris) - pnt_cache = { - int(i): gp_Pnt(float(pts[i, 0]), float(pts[i, 1]), float(pts[i, 2])) - for i in used_vertices - } - for a, b, c in interior_tris: - ga, gb, gc = pnt_cache[int(a)], pnt_cache[int(b)], pnt_cache[int(c)] - e1 = BRepBuilderAPI_MakeEdge(ga, gb).Edge() - e2 = BRepBuilderAPI_MakeEdge(gb, gc).Edge() - e3 = BRepBuilderAPI_MakeEdge(gc, ga).Edge() - wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() - face = BRepBuilderAPI_MakeFace(wire).Face() - sewing.Add(face) - - sewing.Perform() - return _CadShape(sewing.SewedShape()) - - def _try_make_solid(shape: CadShape) -> CadShape: """Best-effort upgrade of a sewn shell (or compound of shells) to a Solid. diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 66a37723..8d1f77d5 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -26,7 +26,8 @@ from microgen.operations import fuse_shapes, rotate -from .shape import BoundsType, Shape, ShellCreationError +from ._types import BoundsType, Field +from .shape import Shape, ShellCreationError if TYPE_CHECKING: from microgen.cad import CadShape @@ -36,10 +37,6 @@ from .tpms_grading import OffsetGrading logging.basicConfig(level=logging.INFO) -Field = Callable[ - [npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]], - npt.NDArray[np.float64], -] _DIM = 3 @@ -480,13 +477,18 @@ def _finalize_frep( raw_field: Field, bounds: tuple[float, float, float, float, float, float], ) -> None: - """Normalize a raw field to SDF and set ``_func`` / ``_bounds``.""" + """Normalize a raw field to SDF and set ``_func`` / ``_bounds`` / ``_period``.""" from .implicit_ops import from_field, normalize_to_sdf self._raw_field_func = raw_field sdf_shape = normalize_to_sdf(from_field(raw_field)) self._func = sdf_shape.func self._bounds = bounds + # The TPMS field is intrinsically periodic on ``cell_size`` along each + # axis (the 2π/cell_size wavenumbers in ``_setup_frep_field`` make + # this a data-structure invariant, not a flag). + cs = np.asarray(self.cell_size, dtype=float) + self._period = (float(cs[0]), float(cs[1]), float(cs[2])) def _setup_frep_field(self: Tpms) -> None: """Build the F-rep implicit field (SDF-normalized) for this TPMS.""" @@ -691,107 +693,17 @@ def offset( def _mesh_to_periodic_shell(self: Tpms, mesh: pv.PolyData) -> CadShape: """Build a closed, sewn OCCT shell with planar BREP faces on cell sides. - Splits the triangle mesh into seven groups: one per unit-cell - boundary plane (x=±half, y=±half, z=±half) plus the TPMS interior. - Each cell-side group goes through :func:`mesh_to_planar_face`, - producing one or more :class:`TopoDS_Face` whose underlying surface - is a ``Geom_Plane`` and whose wires trace the actual cell-side - outline (so STEP shows the gyroid cuts, not a bounding cube; gmsh - ``setPeriodic`` matches opposite cell sides by their plane equation). - The TPMS interior contributes one planar face per triangle. - - All faces — caps + interior — are sewn together via - :class:`BRepBuilderAPI_Sewing` so the cap/interior seam shares edges - and face orientations are reconciled. The result is a closed shell - whose ``BRepGProp::VolumeProperties`` matches the underlying VTK - volume, and which is a valid input to boolean ops. + Delegates to :func:`microgen.shape.periodic_shell.mesh_to_periodic_shell`, + which sews TPMS-interior triangles with per-face cap planes into a + single closed shell (see that function's docstring for details). """ - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeWire, - BRepBuilderAPI_Sewing, - ) - from OCP.gp import gp_Pnt - from OCP.TopAbs import TopAbs_FACE - from OCP.TopExp import TopExp_Explorer - - from microgen.cad import ( - CadShape as _CadShape, - ) - from microgen.cad import ( - mesh_to_planar_face, - ) + from .periodic_shell import mesh_to_periodic_shell # noqa: PLC0415 if not mesh.is_all_triangles: mesh.triangulate(inplace=True) pts = np.asarray(mesh.points, dtype=np.float64) tris = mesh.faces.reshape(-1, 4)[:, 1:].astype(np.int64) - - half = 0.5 * np.asarray(self.cell_size) * np.asarray(self.repeat_cell) - # A vertex is "on" a cell-side plane iff its coordinate on that axis - # equals the mesh's exact extremum on that axis. Marching cubes on - # the periodic-aligned grid places cap vertices on in-plane grid - # edges, so they share the linspace endpoint exactly — no tolerance - # ball, just exact equality on the actual data. Slanted surface - # triangles near the boundary land on perpendicular grid edges and - # never produce that exact value. - # Sanity-check the extremum is the expected ±half (skip otherwise so - # we never misclassify a surface-only mesh whose extremum is not on - # a cube face). - drift_tol = 1e-9 * float(np.max(np.abs(half)) or 1.0) - - consumed = np.zeros(tris.shape[0], dtype=bool) - on_plane: list[tuple[int, int, npt.NDArray[np.int64]]] = [] - for axis in range(3): - for sign in (-1, +1): - extremum = ( - float(pts[:, axis].max()) if sign > 0 else float(pts[:, axis].min()) - ) - if abs(extremum - sign * float(half[axis])) > drift_tol: - continue - vert_on = pts[:, axis] == extremum - tri_on = np.all(vert_on[tris], axis=1) & ~consumed - if tri_on.any(): - on_plane.append((axis, sign, np.where(tri_on)[0])) - consumed |= tri_on - interior_idx = np.where(~consumed)[0] - - # Sewing tolerance: a small fraction of the bbox diagonal absorbs - # numerical drift between cap-wire vertices and interior-triangle - # vertices that should match exactly along the cell-side seam. - bbox_diag = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0))) - sew_tol = max(1e-9, 1e-6 * bbox_diag) - sewing = BRepBuilderAPI_Sewing(sew_tol) - - for axis, sign, tri_idx in on_plane: - origin = [0.0, 0.0, 0.0] - origin[axis] = sign * float(half[axis]) - normal = [0.0, 0.0, 0.0] - normal[axis] = float(sign) - planar = mesh_to_planar_face(pts, tris[tri_idx], origin, normal) - exp = TopExp_Explorer(planar.wrapped, TopAbs_FACE) - while exp.More(): - sewing.Add(exp.Current()) - exp.Next() - - # Interior TPMS triangles: contribute as raw per-triangle faces so - # sewing can stitch them to the cap wires (a pre-sewn interior would - # have shared edges already, blocking the seam stitch). - for a, b, c in tris[interior_idx]: - pa, pb, pc = pts[int(a)], pts[int(b)], pts[int(c)] - ga = gp_Pnt(float(pa[0]), float(pa[1]), float(pa[2])) - gb = gp_Pnt(float(pb[0]), float(pb[1]), float(pb[2])) - gc = gp_Pnt(float(pc[0]), float(pc[1]), float(pc[2])) - e1 = BRepBuilderAPI_MakeEdge(ga, gb).Edge() - e2 = BRepBuilderAPI_MakeEdge(gb, gc).Edge() - e3 = BRepBuilderAPI_MakeEdge(gc, ga).Edge() - wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() - face = BRepBuilderAPI_MakeFace(wire).Face() - sewing.Add(face) - - sewing.Perform() - return _CadShape(sewing.SewedShape()) + return mesh_to_periodic_shell(pts, tris, self._bounds) def _mesh_to_shell(self: Tpms, mesh: pv.PolyData) -> CadShape: """Convert a triangulated PyVista mesh to an OCCT ``CadShape``. diff --git a/tests/shapes/test_shape_period.py b/tests/shapes/test_shape_period.py new file mode 100644 index 00000000..72ad52ef --- /dev/null +++ b/tests/shapes/test_shape_period.py @@ -0,0 +1,100 @@ +"""Tests for ``Shape.period``: the intrinsic-periodicity attribute. + +When set, ``shape.period == (Lx, Ly, Lz)`` is a data-structure invariant +guaranteeing ``shape.evaluate(p + L) == shape.evaluate(p)`` along each axis. +``Tpms`` / ``Spinodoid`` populate it from ``cell_size * repeat_cell`` / +``cell_size`` respectively; free shapes built via ``from_field`` or boolean +composition leave it ``None``. +""" + +# ruff: noqa: S101 + +from __future__ import annotations + +import numpy as np + +from microgen import Box, Sphere, Spinodoid, Tpms, surface_functions +from microgen.shape.implicit_ops import from_field +from microgen.shape.shape import Shape + + +def test_bare_shape_period_is_none() -> None: + """A free ``Shape(func=...)`` has no intrinsic period.""" + s = Shape(func=lambda x, y, z: x * x + y * y + z * z - 1.0) + assert s.period is None + + +def test_box_sphere_have_no_period() -> None: + """Non-periodic primitives expose ``period is None``.""" + assert Box().period is None + assert Sphere().period is None + + +def test_tpms_period_matches_cell_size() -> None: + """``Tpms.period`` equals ``cell_size`` (per-axis).""" + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.5, cell_size=2.0) + assert tpms.period == (2.0, 2.0, 2.0) + + +def test_tpms_anisotropic_cell_size_period() -> None: + """``Tpms.period`` is per-axis when ``cell_size`` is a tuple.""" + tpms = Tpms( + surface_function=surface_functions.gyroid, + offset=0.5, + cell_size=(1.0, 2.0, 3.0), + ) + assert tpms.period == (1.0, 2.0, 3.0) + + +def test_spinodoid_period_matches_cell_size() -> None: + """``Spinodoid.period`` equals ``cell_size``.""" + sp = Spinodoid(offset=0.0, cell_size=1.5, resolution=8, seed=42) + assert sp.period == (1.5, 1.5, 1.5) + + +def test_from_field_propagates_no_period() -> None: + """``from_field`` produces a free shape with ``period is None``.""" + s = from_field(lambda x, y, z: x + y + z) + assert s.period is None + + +def test_boolean_composition_does_not_inherit_period() -> None: + """``a | b`` produces a free shape; intrinsic periodicity is not preserved + by composition (the union of two periodic fields may or may not be + periodic — we don't claim it is). + """ + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.5) + sphere = Sphere(radius=0.5) + combined = tpms | sphere + assert combined.period is None + + +def test_tpms_field_actually_periodic_at_declared_period() -> None: + """If ``shape.period == (Lx, Ly, Lz)`` then ``f(p + L) == f(p)``.""" + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.5, cell_size=1.0) + lx, ly, lz = tpms.period + rng = np.random.default_rng(seed=0) + pts = rng.uniform(-0.5, 0.5, size=(20, 3)) + x, y, z = pts[:, 0], pts[:, 1], pts[:, 2] + f0 = tpms.evaluate(x, y, z) + fp = tpms.evaluate(x + lx, y + ly, z + lz) + # TPMS field is approximately periodic after SDF normalization (not bit-exact); + # tight but not exact tolerance. + assert np.allclose(f0, fp, atol=1e-6) + + +def test_spinodoid_field_periodic_at_declared_period() -> None: + """Spinodoid's reciprocal-lattice modes give (float-precision) periodicity. + + Bit-exactness is asserted by ``test_spinodoid_field_is_bit_exact_periodic`` + in tests/test_spinodoid.py on the raw ``_frep.evaluate``; via ``Shape.evaluate`` + the sign-flip + shifted-coord path picks up ULP-level drift (~1e-15). + """ + sp = Spinodoid(offset=0.0, cell_size=1.0, resolution=8, seed=42) + lx, ly, lz = sp.period + rng = np.random.default_rng(seed=1) + pts = rng.uniform(0.0, 1.0, size=(20, 3)) + x, y, z = pts[:, 0], pts[:, 1], pts[:, 2] + f0 = sp.evaluate(x, y, z) + fp = sp.evaluate(x + lx, y + ly, z + lz) + assert np.allclose(f0, fp, atol=1e-12, rtol=0) From a7177207a464efae1a2aa5b83b98c80b6a3c2b99 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Tue, 26 May 2026 14:06:36 +0200 Subject: [PATCH 7/8] Split cad.py into microgen.cad package Replace the single monolithic microgen/cad.py with a microgen.cad subpackage. The functionality has been split across new modules: __init__.py, _install.py, io.py, meshbridge.py, primitives.py, shape.py and topo.py to isolate responsibilities and enable lazy OCP imports (OCP remains an optional dependency). Tests were updated to reflect the package layout (no top-level OCP import). This reorganization improves maintainability and ensures import-time safety when OCP is not installed. --- microgen/cad.py | 1338 ------------------------ microgen/cad/__init__.py | 124 +++ microgen/cad/_install.py | 22 + microgen/cad/io.py | 34 + microgen/cad/meshbridge.py | 478 +++++++++ microgen/cad/primitives.py | 233 +++++ microgen/cad/shape.py | 376 +++++++ microgen/cad/topo.py | 238 +++++ tests/test_no_top_level_ocp_imports.py | 14 +- 9 files changed, 1511 insertions(+), 1346 deletions(-) delete mode 100644 microgen/cad.py create mode 100644 microgen/cad/__init__.py create mode 100644 microgen/cad/_install.py create mode 100644 microgen/cad/io.py create mode 100644 microgen/cad/meshbridge.py create mode 100644 microgen/cad/primitives.py create mode 100644 microgen/cad/shape.py create mode 100644 microgen/cad/topo.py diff --git a/microgen/cad.py b/microgen/cad.py deleted file mode 100644 index da92524e..00000000 --- a/microgen/cad.py +++ /dev/null @@ -1,1338 +0,0 @@ -"""CAD backend — direct OCCT (via OCP) replacement for CadQuery. - -========================================================= -CAD backend (:mod:`microgen.cad`) -========================================================= - -All CadQuery calls in microgen have been replaced by direct OCP -(``cadquery-ocp-novtk``) calls housed in this module. OCP is an *optional* -dependency — install via ``pip install microgen[cad]``. - -The module's top-level body does not import OCP, so ``import microgen.cad`` -always succeeds. The OCP-dependent functions import it lazily and raise a -helpful ``ImportError`` with install instructions if OCP is missing. - -Return type ------------ - -CAD-producing functions return a :class:`CadShape` — a thin wrapper around -an OCCT ``TopoDS_Shape`` exposing ``.wrapped`` for downstream OCP calls, plus -convenience methods (``translate``, ``rotate``, ``fuse``, ``cut``, -``export_stl``, ``export_step``, ``export_brep``). ``.wrapped`` matches the -attribute name CadQuery's ``Shape`` exposed, so most legacy call sites keep -working unchanged. -""" - -from __future__ import annotations - -from collections.abc import Iterable, Sequence -from typing import TYPE_CHECKING, Any - -import numpy as np -import numpy.typing as npt - -if TYPE_CHECKING: - from pathlib import Path - - from OCP.TopoDS import TopoDS_Shape - - from .shape.shape import Shape - from .shape._types import BoundsType - - -_INSTALL_HINT = ( - "microgen's CAD backend requires the OCP (OCCT) Python bindings. " - "Install with: pip install 'microgen[cad]' " - "(this pulls cadquery-ocp-novtk; on conda-forge use `ocp` instead)." -) - - -class _Centre(tuple): - """Tuple-like 3D point exposing ``.x``, ``.y``, ``.z`` and ``.to_tuple()``. - - Returned by :meth:`CadShape.center`. Mimics just enough of - ``cadquery.Vector`` to drop-in where existing code uses - ``shape.center().to_tuple()`` or ``shape.center().x``. - """ - - __slots__ = () - - def __new__(cls, x: float, y: float, z: float) -> _Centre: - """Create a 3-tuple ``(x, y, z)``.""" - return super().__new__(cls, (float(x), float(y), float(z))) - - @property - def x(self) -> float: - """X coordinate.""" - return self[0] - - @property - def y(self) -> float: - """Y coordinate.""" - return self[1] - - @property - def z(self) -> float: - """Z coordinate.""" - return self[2] - - def to_tuple(self) -> tuple[float, float, float]: - """Return ``(x, y, z)`` as a plain tuple.""" - return (self[0], self[1], self[2]) - - -class _BBox: - """Axis-aligned bounding box exposing ``xmin`` / ``xmax`` / …. - - Returned by :meth:`CadShape.bounding_box`. Also indexable as a 6-tuple - ``(xmin, ymin, zmin, xmax, ymax, zmax)`` matching OCCT's ``Bnd_Box.Get``. - """ - - __slots__ = ("xmax", "xmin", "ymax", "ymin", "zmax", "zmin") - - def __init__( - self, - xmin: float, - ymin: float, - zmin: float, - xmax: float, - ymax: float, - zmax: float, - ) -> None: - """Initialize from the 6 axis-aligned extents.""" - self.xmin = float(xmin) - self.ymin = float(ymin) - self.zmin = float(zmin) - self.xmax = float(xmax) - self.ymax = float(ymax) - self.zmax = float(zmax) - - @property - def diagonal_length(self) -> float: - """Length of the box's space diagonal.""" - dx = self.xmax - self.xmin - dy = self.ymax - self.ymin - dz = self.zmax - self.zmin - return float((dx * dx + dy * dy + dz * dz) ** 0.5) - - -def require_cad() -> None: - """Raise :class:`ImportError` if the CAD backend (OCP) is not importable.""" - try: - import OCP # noqa: F401 - except ImportError as err: - raise ImportError(_INSTALL_HINT) from err - - -def _run_boolean(op_cls: Any, a: CadShape, b: CadShape, label: str) -> TopoDS_Shape: - """Run an OCCT boolean op and raise on failure. - - Older OCP releases don't expose ``HasErrors()`` / ``IsDone()`` on the - ``BRepAlgoAPI_*`` classes; we probe via ``getattr`` and skip the check - when the API isn't available. - """ - op = op_cls(a.wrapped, b.wrapped) - has_errors = getattr(op, "HasErrors", None) - if callable(has_errors) and has_errors(): - err_msg = f"BRepAlgoAPI_{label} failed" - raise RuntimeError(err_msg) - return op.Shape() - - -def _topods_cast(name: str) -> Any: - """Return ``TopoDS.`` cast helper, tolerant of OCP version drift. - - Older OCP releases expose the static cast as ``TopoDS.Shell_s`` (pybind11 - ``_s`` convention); newer releases expose the unsuffixed ``TopoDS.Shell``. - Try the suffixed form first, fall back to unsuffixed. - """ - from OCP.TopoDS import TopoDS - - return getattr(TopoDS, f"{name}_s", None) or getattr(TopoDS, name) - - -# --------------------------------------------------------------------------- -# CadShape wrapper -# --------------------------------------------------------------------------- - - -class CadShape: - """Thin wrapper around an OCCT ``TopoDS_Shape``. - - Preserves the ``.wrapped`` attribute name used by CadQuery so downstream - OCP calls (``BRepAlgoAPI_Fuse(a.wrapped, b.wrapped)``) keep working. - - ``_mesh_volume`` (optional) is a trusted volume in the source mesh's - units, set by mesh-derived constructors (e.g. the TPMS periodic shell) - where OCCT's surface-integral volume is unreliable on invalid topology. - :meth:`volume` prefers it over the OCCT integral when present. - """ - - __slots__ = ("_mesh_volume", "wrapped") - - def __init__(self, shape: TopoDS_Shape) -> None: - """Wrap an OCCT ``TopoDS_Shape``.""" - self.wrapped = shape - self._mesh_volume: float | None = None - - # -- transforms -------------------------------------------------------- - - def translate(self, offset: Sequence[float]) -> CadShape: - """Return a translated copy.""" - from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform - from OCP.gp import gp_Trsf, gp_Vec - - trsf = gp_Trsf() - trsf.SetTranslation( - gp_Vec(float(offset[0]), float(offset[1]), float(offset[2])) - ) - transformed = BRepBuilderAPI_Transform(self.wrapped, trsf, True).Shape() - return CadShape(transformed) - - def rotate( - self, - center: Sequence[float], - axis: Sequence[float], - angle_degrees: float, - ) -> CadShape: - """Return a rotated copy (angle in degrees, axis is a unit vector).""" - from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform - from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf - - trsf = gp_Trsf() - ax = gp_Ax1( - gp_Pnt(float(center[0]), float(center[1]), float(center[2])), - gp_Dir(float(axis[0]), float(axis[1]), float(axis[2])), - ) - trsf.SetRotation(ax, float(np.deg2rad(angle_degrees))) - transformed = BRepBuilderAPI_Transform(self.wrapped, trsf, True).Shape() - return CadShape(transformed) - - def copy(self) -> CadShape: - """Return an independent copy (deep topology copy).""" - from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy - - return CadShape(BRepBuilderAPI_Copy(self.wrapped).Shape()) - - # -- boolean ops ------------------------------------------------------- - - def fuse(self, other: CadShape) -> CadShape: - """Boolean fusion: ``self ∪ other``.""" - from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse - - return CadShape(_run_boolean(BRepAlgoAPI_Fuse, self, other, "Fuse")) - - def cut(self, other: CadShape) -> CadShape: - """Boolean difference: ``self \\ other``.""" - from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut - - return CadShape(_run_boolean(BRepAlgoAPI_Cut, self, other, "Cut")) - - def intersect(self, other: CadShape) -> CadShape: - """Boolean intersection: ``self ∩ other``.""" - from OCP.BRepAlgoAPI import BRepAlgoAPI_Common - - return CadShape(_run_boolean(BRepAlgoAPI_Common, self, other, "Common")) - - # -- topology queries -------------------------------------------------- - - def solids(self) -> list[CadShape]: - """Enumerate contained solids.""" - from OCP.TopAbs import TopAbs_SOLID - from OCP.TopExp import TopExp_Explorer - - cast_solid = _topods_cast("Solid") - out: list[CadShape] = [] - exp = TopExp_Explorer(self.wrapped, TopAbs_SOLID) - while exp.More(): - out.append(CadShape(cast_solid(exp.Current()))) - exp.Next() - return out - - def vertices(self) -> list[tuple[float, float, float]]: - """Enumerate the vertex coordinates of the shape. - - Callers use this to check that a generated mesh has any vertices at - all (``assert np.any(shape.vertices())``). - """ - from OCP.BRep import BRep_Tool - from OCP.TopAbs import TopAbs_VERTEX - from OCP.TopExp import TopExp_Explorer - - cast_vertex = _topods_cast("Vertex") - pnt = getattr(BRep_Tool, "Pnt_s", None) or BRep_Tool.Pnt - out: list[tuple[float, float, float]] = [] - exp = TopExp_Explorer(self.wrapped, TopAbs_VERTEX) - while exp.More(): - v = cast_vertex(exp.Current()) - p = pnt(v) - out.append((float(p.X()), float(p.Y()), float(p.Z()))) - exp.Next() - return out - - def faces(self) -> list[CadShape]: - """Enumerate the faces of the shape.""" - from OCP.TopAbs import TopAbs_FACE - from OCP.TopExp import TopExp_Explorer - - cast_face = _topods_cast("Face") - out: list[CadShape] = [] - exp = TopExp_Explorer(self.wrapped, TopAbs_FACE) - while exp.More(): - out.append(CadShape(cast_face(exp.Current()))) - exp.Next() - return out - - def is_closed(self) -> bool: - """Whether the shape is topologically closed. - - Solids are always closed; for shells/compounds we read OCCT's - per-shape ``Closed`` flag (set by ``BRep_Builder::IsClosed`` when the - shell was built from a watertight set of faces). - """ - from OCP.TopAbs import TopAbs_SOLID - - if self.wrapped.ShapeType() == TopAbs_SOLID: - return True - return bool(self.wrapped.Closed()) - - def volume(self) -> float: - """Return the (unsigned) volume of the shape. - - OCCT's ``BRepGProp::VolumeProperties`` returns a *signed* volume that - depends on face orientation; mesh-built shells from - :func:`mesh_to_shell_brep` can carry inverted orientation and yield a - negative value. We return ``abs(...)`` so volumes are non-negative. - - If a mesh-derived volume was stashed on ``_mesh_volume`` AND the OCCT - solid is not valid (BRepCheck_Analyzer flags self-intersection / - non-manifold edges, common on raw marching-cubes input), we trust the - mesh volume — the OCCT surface integral on an invalid topology is - meaningless. - """ - from OCP.BRepCheck import BRepCheck_Analyzer - from OCP.BRepGProp import BRepGProp - from OCP.GProp import GProp_GProps - - if ( - self._mesh_volume is not None - and not BRepCheck_Analyzer( - self.wrapped, - ).IsValid() - ): - return float(abs(self._mesh_volume)) - - props = GProp_GProps() - BRepGProp.VolumeProperties_s(self.wrapped, props) - return float(abs(props.Mass())) - - def center(self) -> _Centre: - """Return the volumetric center of mass. - - The result is a :class:`_Centre` — exposes ``.x``, ``.y``, ``.z``, - ``.to_tuple()``, and unpacks like a tuple. - """ - from OCP.BRepGProp import BRepGProp - from OCP.GProp import GProp_GProps - - props = GProp_GProps() - BRepGProp.VolumeProperties_s(self.wrapped, props) - c = props.CentreOfMass() - return _Centre(float(c.X()), float(c.Y()), float(c.Z())) - - def bounding_box(self) -> _BBox: - """Return the axis-aligned bounding box. - - The result exposes ``xmin`` / ``xmax`` / … attributes (see - :class:`_BBox`). - """ - from OCP.Bnd import Bnd_Box - from OCP.BRepBndLib import BRepBndLib - - box = Bnd_Box() - # AddOptimal uses exact geometric bounds (not cached triangulation). - BRepBndLib.AddOptimal_s(self.wrapped, box, True, True) - xmin, ymin, zmin, xmax, ymax, zmax = box.Get() - return _BBox(xmin, ymin, zmin, xmax, ymax, zmax) - - # -- exports ----------------------------------------------------------- - - def export_stl( - self, - path: str | Path, - linear_deflection: float = 0.01, - angular_deflection: float = 0.5, - *, - ascii_mode: bool = False, - ) -> None: - """Export to STL. Mesh is regenerated at the given deflection.""" - from OCP.BRepMesh import BRepMesh_IncrementalMesh - from OCP.StlAPI import StlAPI_Writer - - BRepMesh_IncrementalMesh( - self.wrapped, - float(linear_deflection), - False, - float(angular_deflection), - True, - ) - writer = StlAPI_Writer() - writer.ASCIIMode = bool(ascii_mode) - if not writer.Write(self.wrapped, str(path)): - err_msg = f"STL write failed for {path!r}" - raise RuntimeError(err_msg) - - def export_step(self, path: str | Path) -> None: - """Export to STEP (AP214).""" - from OCP.IFSelect import IFSelect_RetDone - from OCP.STEPControl import ( - STEPControl_AsIs, - STEPControl_Writer, - ) - - writer = STEPControl_Writer() - status = writer.Transfer(self.wrapped, STEPControl_AsIs) - if status != IFSelect_RetDone: - err_msg = f"STEP transfer failed with status {status!r}" - raise RuntimeError(err_msg) - status = writer.Write(str(path)) - if status != IFSelect_RetDone: - err_msg = f"STEP write failed with status {status!r}" - raise RuntimeError(err_msg) - - def export_brep(self, path: str | Path) -> None: - """Export to OCCT native BREP.""" - from OCP.BRepTools import BRepTools - - ok = BRepTools.Write_s(self.wrapped, str(path)) - if not ok: - err_msg = f"BREP write failed for {path!r}" - raise RuntimeError(err_msg) - - -def import_step(path: str | Path) -> CadShape: - """Import a STEP file and return the resulting :class:`CadShape`. - - Multi-root STEP files are merged into a single ``TopoDS_Compound``. - """ - require_cad() - from OCP.IFSelect import IFSelect_RetDone - from OCP.STEPControl import STEPControl_Reader - - reader = STEPControl_Reader() - status = reader.ReadFile(str(path)) - if status != IFSelect_RetDone: - err_msg = f"STEP read failed for {path!r} with status {status!r}" - raise RuntimeError(err_msg) - reader.TransferRoots() - return CadShape(reader.OneShape()) - - -# --------------------------------------------------------------------------- -# Mesh → Shell (SOTA path: one TopoDS_Face with attached Poly_Triangulation) -# --------------------------------------------------------------------------- - - -class ShellCreationError(RuntimeError): - """Raised when a mesh cannot be converted into an OCCT shell.""" - - -def mesh_to_shape( - points: npt.NDArray[np.float64], - triangles: npt.NDArray[np.int64], -) -> CadShape: - """Convert a triangle mesh to a :class:`CadShape` via ``Poly_Triangulation``. - - One ``TopoDS_Face`` carries the full triangulation (OCCT native tessellated - BREP representation). This is the SOTA fast path: O(N) in pure OCCT C++ - with no Python-per-triangle overhead, and exports cleanly to STEP AP242 - (tessellated) and STL. - - :param points: ``(N, 3)`` array of vertex coordinates - :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices - :return: wrapped ``TopoDS_Shell`` containing one tessellated face - :raises ShellCreationError: if the triangulation cannot be built - """ - require_cad() - from OCP.BRep import BRep_Builder - from OCP.gp import gp_Pnt - from OCP.Poly import Poly_Triangle, Poly_Triangulation - from OCP.TopoDS import TopoDS_Face, TopoDS_Shell - - pts = np.asarray(points, dtype=np.float64) - tris = np.asarray(triangles, dtype=np.int64) - if pts.ndim != 2 or pts.shape[1] != 3: - err_msg = f"points must be (N, 3), got {pts.shape}" - raise ValueError(err_msg) - if tris.ndim != 2 or tris.shape[1] != 3: - err_msg = f"triangles must be (M, 3), got {tris.shape}" - raise ValueError(err_msg) - if tris.size == 0: - err_msg = "Cannot build a shell from an empty triangle list" - raise ShellCreationError(err_msg) - - nb_nodes = int(pts.shape[0]) - nb_tri = int(tris.shape[0]) - triangulation = Poly_Triangulation(nb_nodes, nb_tri, False) - for i in range(nb_nodes): - triangulation.SetNode( - i + 1, gp_Pnt(float(pts[i, 0]), float(pts[i, 1]), float(pts[i, 2])) - ) - for i in range(nb_tri): - a, b, c = int(tris[i, 0]), int(tris[i, 1]), int(tris[i, 2]) - triangulation.SetTriangle(i + 1, Poly_Triangle(a + 1, b + 1, c + 1)) - - builder = BRep_Builder() - face = TopoDS_Face() - try: - builder.MakeFace(face, triangulation) - except Exception as err: - err_msg = "OCCT refused the triangulation — check bounds and field." - raise ShellCreationError(err_msg) from err - - shell = TopoDS_Shell() - builder.MakeShell(shell) - builder.Add(shell, face) - return CadShape(shell) - - -def shape_to_cad( - shape: Shape, - bounds: BoundsType | None = None, - resolution: int = 50, -) -> CadShape: - """Bridge an implicit :class:`~microgen.shape.shape.Shape` to a CAD BREP. - - Runs the shape's :meth:`generate_surface_mesh` (marching cubes on the - SDF for free Shapes; native renderer for concrete subclasses) then - wraps the resulting triangle mesh into a single tessellated BREP face - via :func:`mesh_to_shape`. - - Concrete subclasses with native primitive paths (``Box``, ``Sphere``, - ``Tpms``, ``Spinodoid`` …) usually skip this bridge and call their - own ``generate_cad``. This function is the generic fallback for - bare ``Shape`` instances built via :func:`microgen.shape.implicit_ops.from_field` - or boolean composition (``a | b``, ``a - b``, ...). - - Requires the optional ``[cad]`` install extra. - - :param shape: the implicit shape to materialise - :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)``; defaults to - ``shape.bounds`` if set, else raises ``ValueError`` - :param resolution: marching-cubes grid resolution per axis - :return: :class:`CadShape` wrapping the tessellated ``TopoDS_Shell`` - """ - require_cad() - if shape.func is None: - err_msg = "No implicit field defined — cannot build BREP from an empty Shape" - raise NotImplementedError(err_msg) - - mesh = shape.generate_surface_mesh(bounds=bounds, resolution=resolution) - if mesh.n_cells == 0: - err_msg = "Generated mesh is empty — check bounds and field function" - raise ValueError(err_msg) - - if not mesh.is_all_triangles: - mesh.triangulate(inplace=True) - triangles = mesh.faces.reshape(-1, 4)[:, 1:] - points = np.asarray(mesh.points, dtype=np.float64) - - from .shape.shape import ShellCreationError # noqa: PLC0415 - - try: - return mesh_to_shape(points, triangles) - except Exception as err: - err_msg = ( - "Failed to build the OCCT shell from the mesh; " - "try to increase the resolution or adjust bounds." - ) - raise ShellCreationError(err_msg) from err - - -def _triangle_components( - triangles: npt.NDArray[np.int64], -) -> npt.NDArray[np.int64]: - """Label connected components of a triangle mesh by edge adjacency.""" - from collections import defaultdict - - n_tri = int(triangles.shape[0]) - edges_sorted = np.sort( - np.vstack( - [triangles[:, [0, 1]], triangles[:, [1, 2]], triangles[:, [2, 0]]], - ), - axis=1, - ) - _keys, inv = np.unique(edges_sorted, axis=0, return_inverse=True) - owner = np.tile(np.arange(n_tri), 3) - - edge_to_tris: dict[int, list[int]] = defaultdict(list) - for global_i, key_i in enumerate(inv): - edge_to_tris[int(key_i)].append(int(owner[global_i])) - - adj: list[list[int]] = [[] for _ in range(n_tri)] - for tlist in edge_to_tris.values(): - if len(tlist) == 2: - adj[tlist[0]].append(tlist[1]) - adj[tlist[1]].append(tlist[0]) - - component = -np.ones(n_tri, dtype=np.int64) - n_comp = 0 - for start in range(n_tri): - if component[start] >= 0: - continue - component[start] = n_comp - stack = [start] - while stack: - t = stack.pop() - for nb in adj[t]: - if component[nb] < 0: - component[nb] = n_comp - stack.append(nb) - n_comp += 1 - return component - - -def _walk_boundary_loops( - comp_tris: npt.NDArray[np.int64], -) -> list[list[int]]: - """Extract directed closed loops from the boundary edges of a triangle group. - - A boundary edge appears in exactly one triangle of the group; the loops - are the closed chains of those edges in their original triangle direction. - """ - from collections import defaultdict - - ce = np.vstack( - [comp_tris[:, [0, 1]], comp_tris[:, [1, 2]], comp_tris[:, [2, 0]]], - ) - cs = np.sort(ce, axis=1) - _keys, inv, counts = np.unique( - cs, - axis=0, - return_inverse=True, - return_counts=True, - ) - bedges = ce[counts[inv] == 1] - if len(bedges) == 0: - return [] - - used = np.zeros(len(bedges), dtype=bool) - start_map: dict[int, list[int]] = defaultdict(list) - for i, (a, _b) in enumerate(bedges): - start_map[int(a)].append(i) - - loops: list[list[int]] = [] - for start_i in range(len(bedges)): - if used[start_i]: - continue - cur = start_i - used[cur] = True - loop = [int(bedges[cur, 0])] - while True: - b = int(bedges[cur, 1]) - loop.append(b) - if b == loop[0]: - break - nxt = next( - (cand for cand in start_map[b] if not used[cand]), - None, - ) - if nxt is None: - break - used[nxt] = True - cur = nxt - if len(loop) >= 4 and loop[-1] == loop[0]: - loops.append(loop) - return loops - - -def mesh_to_planar_face( # noqa: C901 - points: npt.NDArray[np.float64], - triangles: npt.NDArray[np.int64], - plane_origin: Sequence[float], - plane_normal: Sequence[float], -) -> CadShape: - """Build planar BREP face(s) whose wires trace the triangle-group boundary. - - Each connected component of the triangle group becomes a - :class:`TopoDS_Face` whose underlying surface is ``Geom_Plane`` and - whose outer/inner wires follow the boundary edges of the group on the - plane. Suitable both for STEP export (the BRep represents the right - region, not a bounding rectangle) and for gmsh ``setPeriodic`` (the - plane equation is recognised and the trimmed wires define the slave/ - master mesh region identically on opposite cell sides). - - All triangle vertices must lie on the plane within OCCT tolerance. - - :param points: ``(N, 3)`` array of vertex coordinates - :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices - :param plane_origin: a point on the plane - :param plane_normal: the plane's outward unit normal - :return: wrapped face (single component) or shell (multiple components) - :raises ShellCreationError: if no usable boundary loop is found - """ - require_cad() - from OCP.BRep import BRep_Builder - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeWire, - ) - from OCP.gp import gp_Dir, gp_Pln, gp_Pnt - from OCP.TopoDS import TopoDS_Shell - - cast_wire = _topods_cast("Wire") - - pts = np.asarray(points, dtype=np.float64) - tris = np.asarray(triangles, dtype=np.int64) - if tris.size == 0: - err_msg = "Cannot build a planar face from an empty triangle list" - raise ShellCreationError(err_msg) - - plane = gp_Pln( - gp_Pnt(float(plane_origin[0]), float(plane_origin[1]), float(plane_origin[2])), - gp_Dir(float(plane_normal[0]), float(plane_normal[1]), float(plane_normal[2])), - ) - - n = np.asarray(plane_normal, dtype=np.float64) - n = n / (float(np.linalg.norm(n)) or 1.0) - helper = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0]) - u_ax = np.cross(n, helper) - u_ax /= float(np.linalg.norm(u_ax)) or 1.0 - v_ax = np.cross(n, u_ax) - o_arr = np.asarray(plane_origin, dtype=np.float64) - - component = _triangle_components(tris) - n_comp = int(component.max()) + 1 if component.size else 0 - - pnt_cache: dict[int, gp_Pnt] = {} - - def _occ_pnt(idx: int) -> gp_Pnt: - cached = pnt_cache.get(idx) - if cached is None: - v = pts[idx] - cached = gp_Pnt(float(v[0]), float(v[1]), float(v[2])) - pnt_cache[idx] = cached - return cached - - def _signed_area(loop: list[int]) -> float: - rel = pts[loop[:-1]] - o_arr - u = rel @ u_ax - v = rel @ v_ax - return 0.5 * float(np.sum(u * np.roll(v, -1) - np.roll(u, -1) * v)) - - def _build_wire(loop: list[int]): - wb = BRepBuilderAPI_MakeWire() - for k in range(len(loop) - 1): - edge_builder = BRepBuilderAPI_MakeEdge( - _occ_pnt(loop[k]), _occ_pnt(loop[k + 1]) - ) - if not edge_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeEdge failed for boundary segment" - raise ShellCreationError(err_msg) - wb.Add(edge_builder.Edge()) - if not wb.IsDone(): - err_msg = "BRepBuilderAPI_MakeWire failed for boundary loop" - raise ShellCreationError(err_msg) - return wb.Wire() - - def _build_component_face(loops: list[list[int]]): - areas = [_signed_area(L) for L in loops] - outer_local = int(np.argmax([abs(a) for a in areas])) - if areas[outer_local] < 0: - loops = [list(reversed(L)) for L in loops] - wires = [_build_wire(L) for L in loops] - face_builder = BRepBuilderAPI_MakeFace(plane, wires[outer_local]) - for k, w in enumerate(wires): - if k == outer_local: - continue - face_builder.Add(cast_wire(w.Reversed())) - if not face_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeFace failed building a planar face" - raise ShellCreationError(err_msg) - return face_builder.Face() - - component_faces = [] - for ci in range(n_comp): - comp_tris = tris[np.where(component == ci)[0]] - loops = _walk_boundary_loops(comp_tris) - if loops: - component_faces.append(_build_component_face(loops)) - - if not component_faces: - err_msg = "No planar face could be built from the triangle group" - raise ShellCreationError(err_msg) - - if len(component_faces) == 1: - return CadShape(component_faces[0]) - - builder = BRep_Builder() - shell = TopoDS_Shell() - builder.MakeShell(shell) - for f in component_faces: - builder.Add(shell, f) - return CadShape(shell) - - -def mesh_to_shell_brep( - points: npt.NDArray[np.float64], - triangles: npt.NDArray[np.int64], -) -> CadShape: - """Convert a triangle mesh to a shell with one planar BREP face per triangle. - - Slower than :func:`mesh_to_shape` (which uses a single tessellated face) - but produces a shell with *real* geometric surfaces — required whenever - the resulting shell is used as a cutting tool in boolean ops - (``BRepAlgoAPI_Cut``, ``Workplane.split()``, etc.), which refuse a - tessellated-only face. - - :param points: ``(N, 3)`` array of vertex coordinates - :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices - :return: wrapped ``TopoDS_Shell`` with one planar face per triangle - :raises ShellCreationError: if any triangle cannot be built into a face - """ - require_cad() - from OCP.BRep import BRep_Builder - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeWire, - ) - from OCP.gp import gp_Pnt - from OCP.TopoDS import TopoDS_Shell - - pts = np.asarray(points, dtype=np.float64) - tris = np.asarray(triangles, dtype=np.int64) - if tris.size == 0: - err_msg = "Cannot build a shell from an empty triangle list" - raise ShellCreationError(err_msg) - - occ_points = [gp_Pnt(float(p[0]), float(p[1]), float(p[2])) for p in pts] - - builder = BRep_Builder() - shell = TopoDS_Shell() - builder.MakeShell(shell) - - try: - for a, b, c in tris: - e1 = BRepBuilderAPI_MakeEdge(occ_points[int(a)], occ_points[int(b)]).Edge() - e2 = BRepBuilderAPI_MakeEdge(occ_points[int(b)], occ_points[int(c)]).Edge() - e3 = BRepBuilderAPI_MakeEdge(occ_points[int(c)], occ_points[int(a)]).Edge() - wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() - face = BRepBuilderAPI_MakeFace(wire).Face() - builder.Add(shell, face) - except Exception as err: - err_msg = ( - "Failed to build the OCCT shell from the mesh; " - "try to increase the resolution or adjust bounds." - ) - raise ShellCreationError(err_msg) from err - - return CadShape(shell) - - -def mesh_to_sewn_shell( - points: npt.NDArray[np.float64], - triangles: npt.NDArray[np.int64], - tolerance: float | None = None, -) -> CadShape: - """Convert a triangle mesh to a sewn shell with shared edges. - - Builds one planar BREP face per triangle then sews them via - :class:`BRepBuilderAPI_Sewing` so coincident edges/vertices are merged - into shared topology. The resulting shell has valid topology, which - makes any subsequent boolean op (``Cut``, ``Common``, ``Fuse``) tractable - — unlike :func:`mesh_to_shell_brep` whose triangle-soup output is - pathologically slow for booleans. - - :param points: ``(N, 3)`` array of vertex coordinates - :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices - :param tolerance: sewing tolerance; defaults to ``1e-6 * bbox_diag`` - :return: wrapped sewn ``TopoDS_Shape`` (Shell, or Compound of shells if - the input is disconnected) - :raises ShellCreationError: if any triangle cannot be built into a face - """ - require_cad() - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeWire, - BRepBuilderAPI_Sewing, - ) - from OCP.gp import gp_Pnt - - pts = np.asarray(points, dtype=np.float64) - tris = np.asarray(triangles, dtype=np.int64) - if tris.size == 0: - err_msg = "Cannot build a shell from an empty triangle list" - raise ShellCreationError(err_msg) - - if tolerance is None: - bbox_diag = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0))) - tolerance = max(1e-9, 1e-6 * bbox_diag) - - occ_points = [gp_Pnt(float(p[0]), float(p[1]), float(p[2])) for p in pts] - - sewing = BRepBuilderAPI_Sewing(tolerance) - try: - for a, b, c in tris: - e1 = BRepBuilderAPI_MakeEdge(occ_points[int(a)], occ_points[int(b)]).Edge() - e2 = BRepBuilderAPI_MakeEdge(occ_points[int(b)], occ_points[int(c)]).Edge() - e3 = BRepBuilderAPI_MakeEdge(occ_points[int(c)], occ_points[int(a)]).Edge() - wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() - face = BRepBuilderAPI_MakeFace(wire).Face() - sewing.Add(face) - except Exception as err: - err_msg = ( - "Failed to build the OCCT shell from the mesh; " - "try to increase the resolution or adjust bounds." - ) - raise ShellCreationError(err_msg) from err - - sewing.Perform() - return CadShape(sewing.SewedShape()) - - -# --------------------------------------------------------------------------- -# Primitive builders -# --------------------------------------------------------------------------- - - -def make_box(dim: Sequence[float], center: Sequence[float]) -> CadShape: - """Axis-aligned box of size ``dim`` centered at ``center``.""" - require_cad() - from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox - from OCP.gp import gp_Pnt - - dx, dy, dz = (float(d) for d in dim) - cx, cy, cz = (float(c) for c in center) - corner = gp_Pnt(cx - dx / 2.0, cy - dy / 2.0, cz - dz / 2.0) - return CadShape(BRepPrimAPI_MakeBox(corner, dx, dy, dz).Shape()) - - -def make_sphere(radius: float, center: Sequence[float]) -> CadShape: - """Sphere of given radius at ``center``.""" - require_cad() - from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere - from OCP.gp import gp_Pnt - - pnt = gp_Pnt(float(center[0]), float(center[1]), float(center[2])) - return CadShape(BRepPrimAPI_MakeSphere(pnt, float(radius)).Shape()) - - -def make_cylinder( - radius: float, - height: float, - center: Sequence[float], - axis: Sequence[float] = (1.0, 0.0, 0.0), -) -> CadShape: - """Cylinder of given radius and height centered at ``center`` along ``axis``.""" - require_cad() - from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder - from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt - - h = float(height) - ax_vec = np.asarray(axis, dtype=np.float64) - ax_vec = ax_vec / np.linalg.norm(ax_vec) - base = ( - float(center[0]) - h / 2.0 * ax_vec[0], - float(center[1]) - h / 2.0 * ax_vec[1], - float(center[2]) - h / 2.0 * ax_vec[2], - ) - ax = gp_Ax2( - gp_Pnt(*base), - gp_Dir(float(ax_vec[0]), float(ax_vec[1]), float(ax_vec[2])), - ) - return CadShape(BRepPrimAPI_MakeCylinder(ax, float(radius), h).Shape()) - - -def make_capsule( - radius: float, - height: float, - center: Sequence[float], -) -> CadShape: - """Capsule (cylinder along X with hemispherical caps).""" - require_cad() - from OCP.BRepPrimAPI import ( - BRepPrimAPI_MakeCylinder, - BRepPrimAPI_MakeSphere, - ) - from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt - - cx, cy, cz = (float(c) for c in center) - h = float(height) - r = float(radius) - base_axis = gp_Ax2(gp_Pnt(cx - h / 2.0, cy, cz), gp_Dir(1.0, 0.0, 0.0)) - cyl = BRepPrimAPI_MakeCylinder(base_axis, r, h).Shape() - left = BRepPrimAPI_MakeSphere(gp_Pnt(cx - h / 2.0, cy, cz), r).Shape() - right = BRepPrimAPI_MakeSphere(gp_Pnt(cx + h / 2.0, cy, cz), r).Shape() - fused = CadShape(cyl).fuse(CadShape(left)).fuse(CadShape(right)) - return fused - - -def make_ellipsoid(radii: Sequence[float], center: Sequence[float]) -> CadShape: - """Ellipsoid of the given axis-aligned radii at ``center``. - - Built as a unit sphere transformed by a non-uniform scaling matrix. - """ - require_cad() - from OCP.BRepBuilderAPI import BRepBuilderAPI_GTransform - from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere - from OCP.gp import gp_GTrsf, gp_Mat, gp_Pnt, gp_XYZ - - rx, ry, rz = (float(r) for r in radii) - cx, cy, cz = (float(c) for c in center) - sphere = BRepPrimAPI_MakeSphere(gp_Pnt(0.0, 0.0, 0.0), 1.0).Shape() - - gtrsf = gp_GTrsf() - gtrsf.SetVectorialPart( - gp_Mat( - rx, - 0.0, - 0.0, - 0.0, - ry, - 0.0, - 0.0, - 0.0, - rz, - ), - ) - gtrsf.SetTranslationPart(gp_XYZ(cx, cy, cz)) - return CadShape(BRepBuilderAPI_GTransform(sphere, gtrsf, True).Shape()) - - -def make_polyhedron( - vertices: Sequence[Sequence[float]], - faces_ixs: Sequence[Sequence[int]], - center: Sequence[float] = (0.0, 0.0, 0.0), -) -> CadShape: - """Polyhedron from vertex list and face→vertex index list. - - Each face's vertex index list must be closed (last == first). - - Uses ``BRepBuilderAPI_Sewing`` to stitch the independently-constructed - faces into a shared-edge shell with consistent outward orientation, - then ``BRepBuilderAPI_MakeSolid`` to close it. (Raw ``BRep_Builder.Add`` - does not orient; the resulting solid would have mixed-sign volume.) - """ - require_cad() - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeSolid, - BRepBuilderAPI_MakeWire, - BRepBuilderAPI_Sewing, - ) - from OCP.gp import gp_Pnt - from OCP.ShapeFix import ShapeFix_Solid - from OCP.TopAbs import TopAbs_SHELL - from OCP.TopExp import TopExp_Explorer - - cx, cy, cz = (float(c) for c in center) - points = [ - gp_Pnt(float(v[0]) + cx, float(v[1]) + cy, float(v[2]) + cz) for v in vertices - ] - - sewing = BRepBuilderAPI_Sewing() - for ixs in faces_ixs: - wire_builder = BRepBuilderAPI_MakeWire() - for i1, i2 in zip(ixs, ixs[1:], strict=False): - edge_builder = BRepBuilderAPI_MakeEdge(points[i1], points[i2]) - if not edge_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeEdge failed for polyhedron face" - raise ShellCreationError(err_msg) - wire_builder.Add(edge_builder.Edge()) - if not wire_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeWire failed for polyhedron face" - raise ShellCreationError(err_msg) - face_builder = BRepBuilderAPI_MakeFace(wire_builder.Wire()) - if not face_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeFace failed for polyhedron face" - raise ShellCreationError(err_msg) - sewing.Add(face_builder.Face()) - sewing.Perform() - sewn = sewing.SewedShape() - - # Extract the shell (sewing may return it directly or wrapped in a compound). - exp = TopExp_Explorer(sewn, TopAbs_SHELL) - if not exp.More(): - err_msg = "Sewing did not produce a shell — check face connectivity" - raise ShellCreationError(err_msg) - shell = _topods_cast("Shell")(exp.Current()) - - solid_builder = BRepBuilderAPI_MakeSolid(shell) - if not solid_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeSolid failed; sewn shell is not closed" - raise ShellCreationError(err_msg) - fixer = ShapeFix_Solid(solid_builder.Solid()) - fixer.Perform() - return CadShape(fixer.Solid()) - - -def make_extruded_polygon( - list_corners: Sequence[tuple[float, float]], - height: float, - center: Sequence[float], -) -> CadShape: - """Extrude a 2D polygon (in the YZ plane) along the X axis.""" - require_cad() - from OCP.BRepBuilderAPI import ( - BRepBuilderAPI_MakeEdge, - BRepBuilderAPI_MakeFace, - BRepBuilderAPI_MakeWire, - ) - from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism - from OCP.gp import gp_Pnt, gp_Vec - - cx, cy, cz = (float(c) for c in center) - h = float(height) - x_base = cx - h / 2.0 - pts = [gp_Pnt(x_base, cy + float(y), cz + float(z)) for (y, z) in list_corners] - if (list_corners[0][0], list_corners[0][1]) != ( - list_corners[-1][0], - list_corners[-1][1], - ): - pts.append(pts[0]) - - wire_builder = BRepBuilderAPI_MakeWire() - for i in range(len(pts) - 1): - edge_builder = BRepBuilderAPI_MakeEdge(pts[i], pts[i + 1]) - if not edge_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeEdge failed for extruded polygon" - raise ShellCreationError(err_msg) - wire_builder.Add(edge_builder.Edge()) - if not wire_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeWire failed for extruded polygon" - raise ShellCreationError(err_msg) - face_builder = BRepBuilderAPI_MakeFace(wire_builder.Wire()) - if not face_builder.IsDone(): - err_msg = "BRepBuilderAPI_MakeFace failed for extruded polygon" - raise ShellCreationError(err_msg) - extruded = BRepPrimAPI_MakePrism(face_builder.Face(), gp_Vec(h, 0.0, 0.0)).Shape() - return CadShape(extruded) - - -# --------------------------------------------------------------------------- -# Compound assembly (for lattice) -# --------------------------------------------------------------------------- - - -def make_compound(shapes: Iterable[CadShape]) -> CadShape: - """Assemble shapes into a single OCCT ``TopoDS_Compound``.""" - require_cad() - from OCP.BRep import BRep_Builder - from OCP.TopoDS import TopoDS_Compound - - builder = BRep_Builder() - compound = TopoDS_Compound() - builder.MakeCompound(compound) - for s in shapes: - builder.Add(compound, s.wrapped) - return CadShape(compound) - - -def make_compound_from_solids(solids: Iterable[Any]) -> CadShape: - """Assemble raw OCCT ``TopoDS_Shape`` solids (not ``CadShape``) into a compound.""" - require_cad() - from OCP.BRep import BRep_Builder - from OCP.TopoDS import TopoDS_Compound - - builder = BRep_Builder() - compound = TopoDS_Compound() - builder.MakeCompound(compound) - for s in solids: - shape = s.wrapped if hasattr(s, "wrapped") else s - builder.Add(compound, shape) - return CadShape(compound) - - -# --------------------------------------------------------------------------- -# Topology exploration and splitting -# --------------------------------------------------------------------------- - - -def enumerate_solids(shape: CadShape) -> list[Any]: - """Return the list of ``TopoDS_Solid`` inside a shape (empty if none).""" - require_cad() - from OCP.TopAbs import TopAbs_SOLID - from OCP.TopExp import TopExp_Explorer - - cast_solid = _topods_cast("Solid") - out: list[Any] = [] - exp = TopExp_Explorer(shape.wrapped, TopAbs_SOLID) - while exp.More(): - out.append(cast_solid(exp.Current())) - exp.Next() - return out - - -def split_shape( - shape: CadShape, - tool: CadShape | Iterable[CadShape], - *, - fuzzy_value: float = 1e-4, -) -> CadShape: - """Split *shape* by *tool* using OCCT's ``BRepAlgoAPI_Splitter``. - - The result is a :class:`CadShape` wrapping a ``TopoDS_Compound`` that - contains the sub-shapes produced by the split. Use - :func:`enumerate_solids` to iterate over the resulting solids. - - :param tool: a single :class:`CadShape` or an iterable of them. Pass - multiple tools when each tool is a separate ``TopoDS_Shell`` and - you want to keep them topologically distinct — the OCCT splitter - treats each list entry as one cutting tool. This matters because - ``BRepAlgoAPI_Fuse`` on two shells *decomposes* them into a - compound of per-face shells, which then defeats the splitter. - :param fuzzy_value: tolerance forwarded to ``SetFuzzyValue`` so OCCT - recognises near-coincident geometry as touching. Necessary when - a tool is a tessellated shell whose boundary edges lie on - ``shape``'s planar faces only up to a few microns of drift. - """ - require_cad() - from OCP.BRepAlgoAPI import BRepAlgoAPI_Splitter - from OCP.TopTools import TopTools_ListOfShape - - args = TopTools_ListOfShape() - args.Append(shape.wrapped) - tools = TopTools_ListOfShape() - if isinstance(tool, CadShape): - tools.Append(tool.wrapped) - else: - for t in tool: - tools.Append(t.wrapped) - - splitter = BRepAlgoAPI_Splitter() - splitter.SetArguments(args) - splitter.SetTools(tools) - if fuzzy_value > 0: - splitter.SetFuzzyValue(float(fuzzy_value)) - splitter.Build() - return CadShape(splitter.Shape()) - - -def make_plane_face( - base_pnt: Sequence[float], - direction: Sequence[float], - half_size: float = 1.0e6, -) -> CadShape: - """Build a large planar face used as a cutting tool. - - :param base_pnt: a point on the plane - :param direction: plane normal - :param half_size: half-edge of the square face (default large so the plane - reaches far outside any realistic shape) - """ - require_cad() - from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace - from OCP.gp import gp_Ax3, gp_Dir, gp_Pln, gp_Pnt - - pnt = gp_Pnt(float(base_pnt[0]), float(base_pnt[1]), float(base_pnt[2])) - nrm = gp_Dir(float(direction[0]), float(direction[1]), float(direction[2])) - plane = gp_Pln(gp_Ax3(pnt, nrm)) - face = BRepBuilderAPI_MakeFace( - plane, - -float(half_size), - float(half_size), - -float(half_size), - float(half_size), - ).Face() - return CadShape(face) - - -def transform_geometry(shape: CadShape, matrix: npt.NDArray[np.float64]) -> CadShape: - """Apply a 3x4 affine matrix (linear + translation). - - Wraps OCCT ``BRepBuilderAPI_GTransform``. - - :param matrix: ``(3, 4)`` array; rows are ``[a b c tx; d e f ty; g h i tz]``. - """ - require_cad() - from OCP.BRepBuilderAPI import BRepBuilderAPI_GTransform - from OCP.gp import gp_GTrsf, gp_Mat, gp_XYZ - - m = np.asarray(matrix, dtype=np.float64) - gtrsf = gp_GTrsf() - gtrsf.SetVectorialPart( - gp_Mat( - float(m[0, 0]), - float(m[0, 1]), - float(m[0, 2]), - float(m[1, 0]), - float(m[1, 1]), - float(m[1, 2]), - float(m[2, 0]), - float(m[2, 1]), - float(m[2, 2]), - ), - ) - gtrsf.SetTranslationPart(gp_XYZ(float(m[0, 3]), float(m[1, 3]), float(m[2, 3]))) - return CadShape(BRepBuilderAPI_GTransform(shape.wrapped, gtrsf, True).Shape()) - - -def translate_solid(solid: Any, offset: Sequence[float]) -> Any: - """Translate a raw OCCT solid/shape by ``offset`` and return the same type.""" - require_cad() - from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform - from OCP.gp import gp_Trsf, gp_Vec - - shape = solid.wrapped if hasattr(solid, "wrapped") else solid - trsf = gp_Trsf() - trsf.SetTranslation( - gp_Vec(float(offset[0]), float(offset[1]), float(offset[2])), - ) - return BRepBuilderAPI_Transform(shape, trsf, True).Shape() - - -def solid_center(shape: Any) -> tuple[float, float, float]: - """Return the volumetric center of mass of a raw OCCT shape/solid.""" - require_cad() - from OCP.BRepGProp import BRepGProp - from OCP.GProp import GProp_GProps - - s = shape.wrapped if hasattr(shape, "wrapped") else shape - props = GProp_GProps() - BRepGProp.VolumeProperties_s(s, props) - com = props.CentreOfMass() - return (float(com.X()), float(com.Y()), float(com.Z())) - - -def select_solids_on_side( - shape: CadShape, - base_pnt: Sequence[float], - side_direction: Sequence[float], -) -> list[Any]: - """Enumerate a compound's solids, keep those whose centroid is on the - positive side of the plane through ``base_pnt`` normal to ``side_direction``. - - Matches CadQuery's ``.solids(">X")`` / ``.solids(" 0: - selected.append(solid) - return selected - - -def intersect_solids_with_box(solids: Iterable[Any], box: CadShape) -> CadShape: - """Intersect each solid with *box* and fuse the results into a ``CadShape``. - - Returns a :class:`CadShape` wrapping a compound (possibly empty). - """ - require_cad() - from OCP.BRepAlgoAPI import BRepAlgoAPI_Common - - parts: list[Any] = [] - for solid in solids: - s = solid.wrapped if hasattr(solid, "wrapped") else solid - common = BRepAlgoAPI_Common(s, box.wrapped).Shape() - # Keep if it contains at least one solid. - if enumerate_solids(CadShape(common)): - parts.append(common) - return make_compound_from_solids(parts) diff --git a/microgen/cad/__init__.py b/microgen/cad/__init__.py new file mode 100644 index 00000000..e532525c --- /dev/null +++ b/microgen/cad/__init__.py @@ -0,0 +1,124 @@ +"""CAD backend — direct OCCT (via OCP) replacement for CadQuery. + +========================================================= +CAD backend (:mod:`microgen.cad`) +========================================================= + +All CadQuery calls in microgen have been replaced by direct OCP +(``cadquery-ocp-novtk``) calls housed in this subpackage. OCP is an +*optional* dependency — install via ``pip install microgen[cad]``. + +The subpackage's submodules don't import OCP at top level, so +``import microgen.cad`` always succeeds. OCP-dependent functions import +it lazily and raise a helpful ``ImportError`` with install instructions if +OCP is missing. + +Return type +----------- + +CAD-producing functions return a :class:`CadShape` — a thin wrapper around +an OCCT ``TopoDS_Shape`` exposing ``.wrapped`` for downstream OCP calls, plus +convenience methods (``translate``, ``rotate``, ``fuse``, ``cut``, +``export_stl``, ``export_step``, ``export_brep``). ``.wrapped`` matches the +attribute name CadQuery's ``Shape`` exposed, so most legacy call sites keep +working unchanged. + +Layout +------ + +- ``cad._install`` — ``require_cad`` / ``_INSTALL_HINT`` gate +- ``cad.shape`` — ``CadShape`` class + ``_Centre`` / ``_BBox`` / + ``_run_boolean`` / ``_topods_cast`` / ``ShellCreationError`` +- ``cad.io`` — ``import_step`` (export methods live on ``CadShape``) +- ``cad.meshbridge`` — ``mesh_to_shape`` / ``shape_to_cad`` / + ``mesh_to_planar_face`` / ``mesh_to_shell_brep`` / ``mesh_to_sewn_shell`` +- ``cad.primitives`` — ``make_box`` / ``make_sphere`` / ``make_cylinder`` / + ``make_capsule`` / ``make_ellipsoid`` / ``make_polyhedron`` / + ``make_extruded_polygon`` +- ``cad.topo`` — ``make_compound`` / ``make_compound_from_solids`` / + ``enumerate_solids`` / ``split_shape`` / ``make_plane_face`` / + ``transform_geometry`` / ``translate_solid`` / ``solid_center`` / + ``select_solids_on_side`` / ``intersect_solids_with_box`` + +All public symbols are re-exported here so existing +``from microgen.cad import …`` imports keep working unchanged. +""" + +from __future__ import annotations + +from ._install import _INSTALL_HINT, require_cad +from .io import import_step +from .meshbridge import ( + _triangle_components, + _walk_boundary_loops, + mesh_to_planar_face, + mesh_to_sewn_shell, + mesh_to_shape, + mesh_to_shell_brep, + shape_to_cad, +) +from .primitives import ( + make_box, + make_capsule, + make_cylinder, + make_ellipsoid, + make_extruded_polygon, + make_polyhedron, + make_sphere, +) +from .shape import ( + CadShape, + ShellCreationError, + _BBox, + _Centre, + _run_boolean, + _topods_cast, +) +from .topo import ( + enumerate_solids, + intersect_solids_with_box, + make_compound, + make_compound_from_solids, + make_plane_face, + select_solids_on_side, + solid_center, + split_shape, + transform_geometry, + translate_solid, +) + +__all__ = [ + "CadShape", + "ShellCreationError", + "_BBox", + "_Centre", + "_INSTALL_HINT", + "_run_boolean", + "_topods_cast", + "_triangle_components", + "_walk_boundary_loops", + "enumerate_solids", + "import_step", + "intersect_solids_with_box", + "make_box", + "make_capsule", + "make_compound", + "make_compound_from_solids", + "make_cylinder", + "make_ellipsoid", + "make_extruded_polygon", + "make_plane_face", + "make_polyhedron", + "make_sphere", + "mesh_to_planar_face", + "mesh_to_sewn_shell", + "mesh_to_shape", + "mesh_to_shell_brep", + "require_cad", + "select_solids_on_side", + "shape_to_cad", + "solid_center", + "split_shape", + "transform_geometry", + "translate_solid", +] diff --git a/microgen/cad/_install.py b/microgen/cad/_install.py new file mode 100644 index 00000000..a46f54b6 --- /dev/null +++ b/microgen/cad/_install.py @@ -0,0 +1,22 @@ +"""CAD backend install gate. + +``require_cad()`` is the canonical way for lazy CAD code paths to verify +that the optional ``[cad]`` extra is available before touching OCP, and to +raise a user-friendly ``ImportError`` with install instructions otherwise. +""" + +from __future__ import annotations + +_INSTALL_HINT = ( + "microgen's CAD backend requires the OCP (OCCT) Python bindings. " + "Install with: pip install 'microgen[cad]' " + "(this pulls cadquery-ocp-novtk; on conda-forge use `ocp` instead)." +) + + +def require_cad() -> None: + """Raise :class:`ImportError` if the CAD backend (OCP) is not importable.""" + try: + import OCP # noqa: F401, PLC0415 + except ImportError as err: + raise ImportError(_INSTALL_HINT) from err diff --git a/microgen/cad/io.py b/microgen/cad/io.py new file mode 100644 index 00000000..22599c8b --- /dev/null +++ b/microgen/cad/io.py @@ -0,0 +1,34 @@ +"""STEP file I/O (read). + +STEP/BREP/STL *export* is exposed as methods on :class:`CadShape` itself +(``export_step``, ``export_brep``, ``export_stl``) — see ``cad/shape.py``. +This module holds the standalone ``import_step`` reader. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ._install import require_cad +from .shape import CadShape + +if TYPE_CHECKING: + from pathlib import Path + + +def import_step(path: str | Path) -> CadShape: + """Import a STEP file and return the resulting :class:`CadShape`. + + Multi-root STEP files are merged into a single ``TopoDS_Compound``. + """ + require_cad() + from OCP.IFSelect import IFSelect_RetDone # noqa: PLC0415 + from OCP.STEPControl import STEPControl_Reader # noqa: PLC0415 + + reader = STEPControl_Reader() + status = reader.ReadFile(str(path)) + if status != IFSelect_RetDone: + err_msg = f"STEP read failed for {path!r} with status {status!r}" + raise RuntimeError(err_msg) + reader.TransferRoots() + return CadShape(reader.OneShape()) diff --git a/microgen/cad/meshbridge.py b/microgen/cad/meshbridge.py new file mode 100644 index 00000000..ceab2d43 --- /dev/null +++ b/microgen/cad/meshbridge.py @@ -0,0 +1,478 @@ +"""Mesh ↔ BREP bridges. + +Functions in this module convert triangle meshes (numpy point/index arrays, +or pyvista ``PolyData``) into OCCT ``CadShape`` representations, or vice +versa (via the implicit ``Shape`` → BREP bridge in :func:`shape_to_cad`). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import numpy.typing as npt + +from ._install import require_cad +from .shape import CadShape, ShellCreationError, _topods_cast + +if TYPE_CHECKING: + from collections.abc import Sequence + + from ..shape._types import BoundsType + from ..shape.shape import Shape + + +def mesh_to_shape( + points: npt.NDArray[np.float64], + triangles: npt.NDArray[np.int64], +) -> CadShape: + """Convert a triangle mesh to a :class:`CadShape` via ``Poly_Triangulation``. + + One ``TopoDS_Face`` carries the full triangulation (OCCT native tessellated + BREP representation). This is the SOTA fast path: O(N) in pure OCCT C++ + with no Python-per-triangle overhead, and exports cleanly to STEP AP242 + (tessellated) and STL. + + :param points: ``(N, 3)`` array of vertex coordinates + :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices + :return: wrapped ``TopoDS_Shell`` containing one tessellated face + :raises ShellCreationError: if the triangulation cannot be built + """ + require_cad() + from OCP.BRep import BRep_Builder # noqa: PLC0415 + from OCP.gp import gp_Pnt # noqa: PLC0415 + from OCP.Poly import Poly_Triangle, Poly_Triangulation # noqa: PLC0415 + from OCP.TopoDS import TopoDS_Face, TopoDS_Shell # noqa: PLC0415 + + pts = np.asarray(points, dtype=np.float64) + tris = np.asarray(triangles, dtype=np.int64) + if pts.ndim != 2 or pts.shape[1] != 3: + err_msg = f"points must be (N, 3), got {pts.shape}" + raise ValueError(err_msg) + if tris.ndim != 2 or tris.shape[1] != 3: + err_msg = f"triangles must be (M, 3), got {tris.shape}" + raise ValueError(err_msg) + if tris.size == 0: + err_msg = "Cannot build a shell from an empty triangle list" + raise ShellCreationError(err_msg) + + nb_nodes = int(pts.shape[0]) + nb_tri = int(tris.shape[0]) + triangulation = Poly_Triangulation(nb_nodes, nb_tri, False) + for i in range(nb_nodes): + triangulation.SetNode( + i + 1, gp_Pnt(float(pts[i, 0]), float(pts[i, 1]), float(pts[i, 2])) + ) + for i in range(nb_tri): + a, b, c = int(tris[i, 0]), int(tris[i, 1]), int(tris[i, 2]) + triangulation.SetTriangle(i + 1, Poly_Triangle(a + 1, b + 1, c + 1)) + + builder = BRep_Builder() + face = TopoDS_Face() + try: + builder.MakeFace(face, triangulation) + except Exception as err: + err_msg = "OCCT refused the triangulation — check bounds and field." + raise ShellCreationError(err_msg) from err + + shell = TopoDS_Shell() + builder.MakeShell(shell) + builder.Add(shell, face) + return CadShape(shell) + + +def shape_to_cad( + shape: Shape, + bounds: BoundsType | None = None, + resolution: int = 50, +) -> CadShape: + """Bridge an implicit :class:`~microgen.shape.shape.Shape` to a CAD BREP. + + Runs the shape's :meth:`generate_surface_mesh` (marching cubes on the + SDF for free Shapes; native renderer for concrete subclasses) then + wraps the resulting triangle mesh into a single tessellated BREP face + via :func:`mesh_to_shape`. + + Concrete subclasses with native primitive paths (``Box``, ``Sphere``, + ``Tpms``, ``Spinodoid`` …) usually skip this bridge and call their + own ``generate_cad``. This function is the generic fallback for + bare ``Shape`` instances built via :func:`microgen.shape.implicit_ops.from_field` + or boolean composition (``a | b``, ``a - b``, ...). + + Requires the optional ``[cad]`` install extra. + + :param shape: the implicit shape to materialise + :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)``; defaults to + ``shape.bounds`` if set, else raises ``ValueError`` + :param resolution: marching-cubes grid resolution per axis + :return: :class:`CadShape` wrapping the tessellated ``TopoDS_Shell`` + """ + require_cad() + if shape.func is None: + err_msg = "No implicit field defined — cannot build BREP from an empty Shape" + raise NotImplementedError(err_msg) + + mesh = shape.generate_surface_mesh(bounds=bounds, resolution=resolution) + if mesh.n_cells == 0: + err_msg = "Generated mesh is empty — check bounds and field function" + raise ValueError(err_msg) + + if not mesh.is_all_triangles: + mesh.triangulate(inplace=True) + triangles = mesh.faces.reshape(-1, 4)[:, 1:] + points = np.asarray(mesh.points, dtype=np.float64) + + from ..shape.shape import ShellCreationError as _ShapeShellError # noqa: PLC0415 + + try: + return mesh_to_shape(points, triangles) + except Exception as err: + err_msg = ( + "Failed to build the OCCT shell from the mesh; " + "try to increase the resolution or adjust bounds." + ) + raise _ShapeShellError(err_msg) from err + + +def _triangle_components( + triangles: npt.NDArray[np.int64], +) -> npt.NDArray[np.int64]: + """Label connected components of a triangle mesh by edge adjacency.""" + from collections import defaultdict # noqa: PLC0415 + + n_tri = int(triangles.shape[0]) + edges_sorted = np.sort( + np.vstack( + [triangles[:, [0, 1]], triangles[:, [1, 2]], triangles[:, [2, 0]]], + ), + axis=1, + ) + _keys, inv = np.unique(edges_sorted, axis=0, return_inverse=True) + owner = np.tile(np.arange(n_tri), 3) + + edge_to_tris: dict[int, list[int]] = defaultdict(list) + for global_i, key_i in enumerate(inv): + edge_to_tris[int(key_i)].append(int(owner[global_i])) + + adj: list[list[int]] = [[] for _ in range(n_tri)] + for tlist in edge_to_tris.values(): + if len(tlist) == 2: + adj[tlist[0]].append(tlist[1]) + adj[tlist[1]].append(tlist[0]) + + component = -np.ones(n_tri, dtype=np.int64) + n_comp = 0 + for start in range(n_tri): + if component[start] >= 0: + continue + component[start] = n_comp + stack = [start] + while stack: + t = stack.pop() + for nb in adj[t]: + if component[nb] < 0: + component[nb] = n_comp + stack.append(nb) + n_comp += 1 + return component + + +def _walk_boundary_loops( + comp_tris: npt.NDArray[np.int64], +) -> list[list[int]]: + """Extract directed closed loops from the boundary edges of a triangle group. + + A boundary edge appears in exactly one triangle of the group; the loops + are the closed chains of those edges in their original triangle direction. + """ + from collections import defaultdict # noqa: PLC0415 + + ce = np.vstack( + [comp_tris[:, [0, 1]], comp_tris[:, [1, 2]], comp_tris[:, [2, 0]]], + ) + cs = np.sort(ce, axis=1) + _keys, inv, counts = np.unique( + cs, + axis=0, + return_inverse=True, + return_counts=True, + ) + bedges = ce[counts[inv] == 1] + if len(bedges) == 0: + return [] + + used = np.zeros(len(bedges), dtype=bool) + start_map: dict[int, list[int]] = defaultdict(list) + for i, (a, _b) in enumerate(bedges): + start_map[int(a)].append(i) + + loops: list[list[int]] = [] + for start_i in range(len(bedges)): + if used[start_i]: + continue + cur = start_i + used[cur] = True + loop = [int(bedges[cur, 0])] + while True: + b = int(bedges[cur, 1]) + loop.append(b) + if b == loop[0]: + break + nxt = next( + (cand for cand in start_map[b] if not used[cand]), + None, + ) + if nxt is None: + break + used[nxt] = True + cur = nxt + if len(loop) >= 4 and loop[-1] == loop[0]: + loops.append(loop) + return loops + + +def mesh_to_planar_face( # noqa: C901 + points: npt.NDArray[np.float64], + triangles: npt.NDArray[np.int64], + plane_origin: Sequence[float], + plane_normal: Sequence[float], +) -> CadShape: + """Build planar BREP face(s) whose wires trace the triangle-group boundary. + + Each connected component of the triangle group becomes a + :class:`TopoDS_Face` whose underlying surface is ``Geom_Plane`` and + whose outer/inner wires follow the boundary edges of the group on the + plane. Suitable both for STEP export (the BRep represents the right + region, not a bounding rectangle) and for gmsh ``setPeriodic`` (the + plane equation is recognised and the trimmed wires define the slave/ + master mesh region identically on opposite cell sides). + + All triangle vertices must lie on the plane within OCCT tolerance. + + :param points: ``(N, 3)`` array of vertex coordinates + :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices + :param plane_origin: a point on the plane + :param plane_normal: the plane's outward unit normal + :return: wrapped face (single component) or shell (multiple components) + :raises ShellCreationError: if no usable boundary loop is found + """ + require_cad() + from OCP.BRep import BRep_Builder # noqa: PLC0415 + from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeWire, + ) + from OCP.gp import gp_Dir, gp_Pln, gp_Pnt # noqa: PLC0415 + from OCP.TopoDS import TopoDS_Shell # noqa: PLC0415 + + cast_wire = _topods_cast("Wire") + + pts = np.asarray(points, dtype=np.float64) + tris = np.asarray(triangles, dtype=np.int64) + if tris.size == 0: + err_msg = "Cannot build a planar face from an empty triangle list" + raise ShellCreationError(err_msg) + + plane = gp_Pln( + gp_Pnt(float(plane_origin[0]), float(plane_origin[1]), float(plane_origin[2])), + gp_Dir(float(plane_normal[0]), float(plane_normal[1]), float(plane_normal[2])), + ) + + n = np.asarray(plane_normal, dtype=np.float64) + n = n / (float(np.linalg.norm(n)) or 1.0) + helper = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0]) + u_ax = np.cross(n, helper) + u_ax /= float(np.linalg.norm(u_ax)) or 1.0 + v_ax = np.cross(n, u_ax) + o_arr = np.asarray(plane_origin, dtype=np.float64) + + component = _triangle_components(tris) + n_comp = int(component.max()) + 1 if component.size else 0 + + pnt_cache: dict[int, gp_Pnt] = {} + + def _occ_pnt(idx: int) -> gp_Pnt: + cached = pnt_cache.get(idx) + if cached is None: + v = pts[idx] + cached = gp_Pnt(float(v[0]), float(v[1]), float(v[2])) + pnt_cache[idx] = cached + return cached + + def _signed_area(loop: list[int]) -> float: + rel = pts[loop[:-1]] - o_arr + u = rel @ u_ax + v = rel @ v_ax + return 0.5 * float(np.sum(u * np.roll(v, -1) - np.roll(u, -1) * v)) + + def _build_wire(loop: list[int]): + wb = BRepBuilderAPI_MakeWire() + for k in range(len(loop) - 1): + edge_builder = BRepBuilderAPI_MakeEdge( + _occ_pnt(loop[k]), _occ_pnt(loop[k + 1]) + ) + if not edge_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeEdge failed for boundary segment" + raise ShellCreationError(err_msg) + wb.Add(edge_builder.Edge()) + if not wb.IsDone(): + err_msg = "BRepBuilderAPI_MakeWire failed for boundary loop" + raise ShellCreationError(err_msg) + return wb.Wire() + + def _build_component_face(loops: list[list[int]]): + areas = [_signed_area(L) for L in loops] + outer_local = int(np.argmax([abs(a) for a in areas])) + if areas[outer_local] < 0: + loops = [list(reversed(L)) for L in loops] + wires = [_build_wire(L) for L in loops] + face_builder = BRepBuilderAPI_MakeFace(plane, wires[outer_local]) + for k, w in enumerate(wires): + if k == outer_local: + continue + face_builder.Add(cast_wire(w.Reversed())) + if not face_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeFace failed building a planar face" + raise ShellCreationError(err_msg) + return face_builder.Face() + + component_faces = [] + for ci in range(n_comp): + comp_tris = tris[np.where(component == ci)[0]] + loops = _walk_boundary_loops(comp_tris) + if loops: + component_faces.append(_build_component_face(loops)) + + if not component_faces: + err_msg = "No planar face could be built from the triangle group" + raise ShellCreationError(err_msg) + + if len(component_faces) == 1: + return CadShape(component_faces[0]) + + builder = BRep_Builder() + shell = TopoDS_Shell() + builder.MakeShell(shell) + for f in component_faces: + builder.Add(shell, f) + return CadShape(shell) + + +def mesh_to_shell_brep( + points: npt.NDArray[np.float64], + triangles: npt.NDArray[np.int64], +) -> CadShape: + """Convert a triangle mesh to a shell with one planar BREP face per triangle. + + Slower than :func:`mesh_to_shape` (which uses a single tessellated face) + but produces a shell with *real* geometric surfaces — required whenever + the resulting shell is used as a cutting tool in boolean ops + (``BRepAlgoAPI_Cut``, ``Workplane.split()``, etc.), which refuse a + tessellated-only face. + + :param points: ``(N, 3)`` array of vertex coordinates + :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices + :return: wrapped ``TopoDS_Shell`` with one planar face per triangle + :raises ShellCreationError: if any triangle cannot be built into a face + """ + require_cad() + from OCP.BRep import BRep_Builder # noqa: PLC0415 + from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeWire, + ) + from OCP.gp import gp_Pnt # noqa: PLC0415 + from OCP.TopoDS import TopoDS_Shell # noqa: PLC0415 + + pts = np.asarray(points, dtype=np.float64) + tris = np.asarray(triangles, dtype=np.int64) + if tris.size == 0: + err_msg = "Cannot build a shell from an empty triangle list" + raise ShellCreationError(err_msg) + + occ_points = [gp_Pnt(float(p[0]), float(p[1]), float(p[2])) for p in pts] + + builder = BRep_Builder() + shell = TopoDS_Shell() + builder.MakeShell(shell) + + try: + for a, b, c in tris: + e1 = BRepBuilderAPI_MakeEdge(occ_points[int(a)], occ_points[int(b)]).Edge() + e2 = BRepBuilderAPI_MakeEdge(occ_points[int(b)], occ_points[int(c)]).Edge() + e3 = BRepBuilderAPI_MakeEdge(occ_points[int(c)], occ_points[int(a)]).Edge() + wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() + face = BRepBuilderAPI_MakeFace(wire).Face() + builder.Add(shell, face) + except Exception as err: + err_msg = ( + "Failed to build the OCCT shell from the mesh; " + "try to increase the resolution or adjust bounds." + ) + raise ShellCreationError(err_msg) from err + + return CadShape(shell) + + +def mesh_to_sewn_shell( + points: npt.NDArray[np.float64], + triangles: npt.NDArray[np.int64], + tolerance: float | None = None, +) -> CadShape: + """Convert a triangle mesh to a sewn shell with shared edges. + + Builds one planar BREP face per triangle then sews them via + :class:`BRepBuilderAPI_Sewing` so coincident edges/vertices are merged + into shared topology. The resulting shell has valid topology, which + makes any subsequent boolean op (``Cut``, ``Common``, ``Fuse``) tractable + — unlike :func:`mesh_to_shell_brep` whose triangle-soup output is + pathologically slow for booleans. + + :param points: ``(N, 3)`` array of vertex coordinates + :param triangles: ``(M, 3)`` array of 0-indexed triangle vertex indices + :param tolerance: sewing tolerance; defaults to ``1e-6 * bbox_diag`` + :return: wrapped sewn ``TopoDS_Shape`` (Shell, or Compound of shells if + the input is disconnected) + :raises ShellCreationError: if any triangle cannot be built into a face + """ + require_cad() + from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeWire, + BRepBuilderAPI_Sewing, + ) + from OCP.gp import gp_Pnt # noqa: PLC0415 + + pts = np.asarray(points, dtype=np.float64) + tris = np.asarray(triangles, dtype=np.int64) + if tris.size == 0: + err_msg = "Cannot build a shell from an empty triangle list" + raise ShellCreationError(err_msg) + + if tolerance is None: + bbox_diag = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0))) + tolerance = max(1e-9, 1e-6 * bbox_diag) + + occ_points = [gp_Pnt(float(p[0]), float(p[1]), float(p[2])) for p in pts] + + sewing = BRepBuilderAPI_Sewing(tolerance) + try: + for a, b, c in tris: + e1 = BRepBuilderAPI_MakeEdge(occ_points[int(a)], occ_points[int(b)]).Edge() + e2 = BRepBuilderAPI_MakeEdge(occ_points[int(b)], occ_points[int(c)]).Edge() + e3 = BRepBuilderAPI_MakeEdge(occ_points[int(c)], occ_points[int(a)]).Edge() + wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() + face = BRepBuilderAPI_MakeFace(wire).Face() + sewing.Add(face) + except Exception as err: + err_msg = ( + "Failed to build the OCCT shell from the mesh; " + "try to increase the resolution or adjust bounds." + ) + raise ShellCreationError(err_msg) from err + + sewing.Perform() + return CadShape(sewing.SewedShape()) diff --git a/microgen/cad/primitives.py b/microgen/cad/primitives.py new file mode 100644 index 00000000..567b9c36 --- /dev/null +++ b/microgen/cad/primitives.py @@ -0,0 +1,233 @@ +"""OCCT primitive builders. + +Free factory functions producing :class:`CadShape` instances for the core +parametric primitives (box, sphere, cylinder, capsule, ellipsoid, +polyhedron, extruded polygon). These are the back-ends the implicit shape +classes (``Box``, ``Sphere``, …) call from their ``generate_cad`` methods. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from ._install import require_cad +from .shape import CadShape, ShellCreationError, _topods_cast + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def make_box(dim: Sequence[float], center: Sequence[float]) -> CadShape: + """Axis-aligned box of size ``dim`` centered at ``center``.""" + require_cad() + from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox # noqa: PLC0415 + from OCP.gp import gp_Pnt # noqa: PLC0415 + + dx, dy, dz = (float(d) for d in dim) + cx, cy, cz = (float(c) for c in center) + corner = gp_Pnt(cx - dx / 2.0, cy - dy / 2.0, cz - dz / 2.0) + return CadShape(BRepPrimAPI_MakeBox(corner, dx, dy, dz).Shape()) + + +def make_sphere(radius: float, center: Sequence[float]) -> CadShape: + """Sphere of given radius at ``center``.""" + require_cad() + from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere # noqa: PLC0415 + from OCP.gp import gp_Pnt # noqa: PLC0415 + + pnt = gp_Pnt(float(center[0]), float(center[1]), float(center[2])) + return CadShape(BRepPrimAPI_MakeSphere(pnt, float(radius)).Shape()) + + +def make_cylinder( + radius: float, + height: float, + center: Sequence[float], + axis: Sequence[float] = (1.0, 0.0, 0.0), +) -> CadShape: + """Cylinder of given radius and height centered at ``center`` along ``axis``.""" + require_cad() + from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder # noqa: PLC0415 + from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt # noqa: PLC0415 + + h = float(height) + ax_vec = np.asarray(axis, dtype=np.float64) + ax_vec = ax_vec / np.linalg.norm(ax_vec) + base = ( + float(center[0]) - h / 2.0 * ax_vec[0], + float(center[1]) - h / 2.0 * ax_vec[1], + float(center[2]) - h / 2.0 * ax_vec[2], + ) + ax = gp_Ax2( + gp_Pnt(*base), + gp_Dir(float(ax_vec[0]), float(ax_vec[1]), float(ax_vec[2])), + ) + return CadShape(BRepPrimAPI_MakeCylinder(ax, float(radius), h).Shape()) + + +def make_capsule( + radius: float, + height: float, + center: Sequence[float], +) -> CadShape: + """Capsule (cylinder along X with hemispherical caps).""" + require_cad() + from OCP.BRepPrimAPI import ( # noqa: PLC0415 + BRepPrimAPI_MakeCylinder, + BRepPrimAPI_MakeSphere, + ) + from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt # noqa: PLC0415 + + cx, cy, cz = (float(c) for c in center) + h = float(height) + r = float(radius) + base_axis = gp_Ax2(gp_Pnt(cx - h / 2.0, cy, cz), gp_Dir(1.0, 0.0, 0.0)) + cyl = BRepPrimAPI_MakeCylinder(base_axis, r, h).Shape() + left = BRepPrimAPI_MakeSphere(gp_Pnt(cx - h / 2.0, cy, cz), r).Shape() + right = BRepPrimAPI_MakeSphere(gp_Pnt(cx + h / 2.0, cy, cz), r).Shape() + return CadShape(cyl).fuse(CadShape(left)).fuse(CadShape(right)) + + +def make_ellipsoid(radii: Sequence[float], center: Sequence[float]) -> CadShape: + """Ellipsoid of the given axis-aligned radii at ``center``. + + Built as a unit sphere transformed by a non-uniform scaling matrix. + """ + require_cad() + from OCP.BRepBuilderAPI import BRepBuilderAPI_GTransform # noqa: PLC0415 + from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere # noqa: PLC0415 + from OCP.gp import gp_GTrsf, gp_Mat, gp_Pnt, gp_XYZ # noqa: PLC0415 + + rx, ry, rz = (float(r) for r in radii) + cx, cy, cz = (float(c) for c in center) + sphere = BRepPrimAPI_MakeSphere(gp_Pnt(0.0, 0.0, 0.0), 1.0).Shape() + + gtrsf = gp_GTrsf() + gtrsf.SetVectorialPart( + gp_Mat( + rx, + 0.0, + 0.0, + 0.0, + ry, + 0.0, + 0.0, + 0.0, + rz, + ), + ) + gtrsf.SetTranslationPart(gp_XYZ(cx, cy, cz)) + return CadShape(BRepBuilderAPI_GTransform(sphere, gtrsf, True).Shape()) + + +def make_polyhedron( + vertices: Sequence[Sequence[float]], + faces_ixs: Sequence[Sequence[int]], + center: Sequence[float] = (0.0, 0.0, 0.0), +) -> CadShape: + """Polyhedron from vertex list and face→vertex index list. + + Each face's vertex index list must be closed (last == first). + + Uses ``BRepBuilderAPI_Sewing`` to stitch the independently-constructed + faces into a shared-edge shell with consistent outward orientation, + then ``BRepBuilderAPI_MakeSolid`` to close it. (Raw ``BRep_Builder.Add`` + does not orient; the resulting solid would have mixed-sign volume.) + """ + require_cad() + from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeSolid, + BRepBuilderAPI_MakeWire, + BRepBuilderAPI_Sewing, + ) + from OCP.gp import gp_Pnt # noqa: PLC0415 + from OCP.ShapeFix import ShapeFix_Solid # noqa: PLC0415 + from OCP.TopAbs import TopAbs_SHELL # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + + cx, cy, cz = (float(c) for c in center) + points = [ + gp_Pnt(float(v[0]) + cx, float(v[1]) + cy, float(v[2]) + cz) for v in vertices + ] + + sewing = BRepBuilderAPI_Sewing() + for ixs in faces_ixs: + wire_builder = BRepBuilderAPI_MakeWire() + for i1, i2 in zip(ixs, ixs[1:], strict=False): + edge_builder = BRepBuilderAPI_MakeEdge(points[i1], points[i2]) + if not edge_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeEdge failed for polyhedron face" + raise ShellCreationError(err_msg) + wire_builder.Add(edge_builder.Edge()) + if not wire_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeWire failed for polyhedron face" + raise ShellCreationError(err_msg) + face_builder = BRepBuilderAPI_MakeFace(wire_builder.Wire()) + if not face_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeFace failed for polyhedron face" + raise ShellCreationError(err_msg) + sewing.Add(face_builder.Face()) + sewing.Perform() + sewn = sewing.SewedShape() + + # Extract the shell (sewing may return it directly or wrapped in a compound). + exp = TopExp_Explorer(sewn, TopAbs_SHELL) + if not exp.More(): + err_msg = "Sewing did not produce a shell — check face connectivity" + raise ShellCreationError(err_msg) + shell = _topods_cast("Shell")(exp.Current()) + + solid_builder = BRepBuilderAPI_MakeSolid(shell) + if not solid_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeSolid failed; sewn shell is not closed" + raise ShellCreationError(err_msg) + fixer = ShapeFix_Solid(solid_builder.Solid()) + fixer.Perform() + return CadShape(fixer.Solid()) + + +def make_extruded_polygon( + list_corners: Sequence[tuple[float, float]], + height: float, + center: Sequence[float], +) -> CadShape: + """Extrude a 2D polygon (in the YZ plane) along the X axis.""" + require_cad() + from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeWire, + ) + from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism # noqa: PLC0415 + from OCP.gp import gp_Pnt, gp_Vec # noqa: PLC0415 + + cx, cy, cz = (float(c) for c in center) + h = float(height) + x_base = cx - h / 2.0 + pts = [gp_Pnt(x_base, cy + float(y), cz + float(z)) for (y, z) in list_corners] + if (list_corners[0][0], list_corners[0][1]) != ( + list_corners[-1][0], + list_corners[-1][1], + ): + pts.append(pts[0]) + + wire_builder = BRepBuilderAPI_MakeWire() + for i in range(len(pts) - 1): + edge_builder = BRepBuilderAPI_MakeEdge(pts[i], pts[i + 1]) + if not edge_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeEdge failed for extruded polygon" + raise ShellCreationError(err_msg) + wire_builder.Add(edge_builder.Edge()) + if not wire_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeWire failed for extruded polygon" + raise ShellCreationError(err_msg) + face_builder = BRepBuilderAPI_MakeFace(wire_builder.Wire()) + if not face_builder.IsDone(): + err_msg = "BRepBuilderAPI_MakeFace failed for extruded polygon" + raise ShellCreationError(err_msg) + extruded = BRepPrimAPI_MakePrism(face_builder.Face(), gp_Vec(h, 0.0, 0.0)).Shape() + return CadShape(extruded) diff --git a/microgen/cad/shape.py b/microgen/cad/shape.py new file mode 100644 index 00000000..108be59c --- /dev/null +++ b/microgen/cad/shape.py @@ -0,0 +1,376 @@ +"""``CadShape`` wrapper and OCCT topology helpers. + +This module holds the ``CadShape`` thin wrapper around ``TopoDS_Shape`` plus +the low-level OCP utilities used by the rest of the CAD subpackage +(``_run_boolean``, ``_topods_cast``, ``_Centre``, ``_BBox``, +``ShellCreationError``). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ._install import require_cad # noqa: F401 (re-exported for back-compat) + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + from OCP.TopoDS import TopoDS_Shape + + +class _Centre(tuple): + """Tuple-like 3D point exposing ``.x``, ``.y``, ``.z`` and ``.to_tuple()``. + + Returned by :meth:`CadShape.center`. Mimics just enough of + ``cadquery.Vector`` to drop-in where existing code uses + ``shape.center().to_tuple()`` or ``shape.center().x``. + """ + + __slots__ = () + + def __new__(cls, x: float, y: float, z: float) -> _Centre: + """Create a 3-tuple ``(x, y, z)``.""" + return super().__new__(cls, (float(x), float(y), float(z))) + + @property + def x(self) -> float: + """X coordinate.""" + return self[0] + + @property + def y(self) -> float: + """Y coordinate.""" + return self[1] + + @property + def z(self) -> float: + """Z coordinate.""" + return self[2] + + def to_tuple(self) -> tuple[float, float, float]: + """Return ``(x, y, z)`` as a plain tuple.""" + return (self[0], self[1], self[2]) + + +class _BBox: + """Axis-aligned bounding box exposing ``xmin`` / ``xmax`` / …. + + Returned by :meth:`CadShape.bounding_box`. Also indexable as a 6-tuple + ``(xmin, ymin, zmin, xmax, ymax, zmax)`` matching OCCT's ``Bnd_Box.Get``. + """ + + __slots__ = ("xmax", "xmin", "ymax", "ymin", "zmax", "zmin") + + def __init__( + self, + xmin: float, + ymin: float, + zmin: float, + xmax: float, + ymax: float, + zmax: float, + ) -> None: + """Initialize from the 6 axis-aligned extents.""" + self.xmin = float(xmin) + self.ymin = float(ymin) + self.zmin = float(zmin) + self.xmax = float(xmax) + self.ymax = float(ymax) + self.zmax = float(zmax) + + @property + def diagonal_length(self) -> float: + """Length of the box's space diagonal.""" + dx = self.xmax - self.xmin + dy = self.ymax - self.ymin + dz = self.zmax - self.zmin + return float((dx * dx + dy * dy + dz * dz) ** 0.5) + + +class ShellCreationError(RuntimeError): + """Raised when a mesh cannot be converted into an OCCT shell.""" + + +def _run_boolean(op_cls: Any, a: CadShape, b: CadShape, label: str) -> TopoDS_Shape: + """Run an OCCT boolean op and raise on failure. + + Older OCP releases don't expose ``HasErrors()`` / ``IsDone()`` on the + ``BRepAlgoAPI_*`` classes; we probe via ``getattr`` and skip the check + when the API isn't available. + """ + op = op_cls(a.wrapped, b.wrapped) + has_errors = getattr(op, "HasErrors", None) + if callable(has_errors) and has_errors(): + err_msg = f"BRepAlgoAPI_{label} failed" + raise RuntimeError(err_msg) + return op.Shape() + + +def _topods_cast(name: str) -> Any: + """Return ``TopoDS.`` cast helper, tolerant of OCP version drift. + + Older OCP releases expose the static cast as ``TopoDS.Shell_s`` (pybind11 + ``_s`` convention); newer releases expose the unsuffixed ``TopoDS.Shell``. + Try the suffixed form first, fall back to unsuffixed. + """ + from OCP.TopoDS import TopoDS # noqa: PLC0415 + + return getattr(TopoDS, f"{name}_s", None) or getattr(TopoDS, name) + + +class CadShape: + """Thin wrapper around an OCCT ``TopoDS_Shape``. + + Preserves the ``.wrapped`` attribute name used by CadQuery so downstream + OCP calls (``BRepAlgoAPI_Fuse(a.wrapped, b.wrapped)``) keep working. + + ``_mesh_volume`` (optional) is a trusted volume in the source mesh's + units, set by mesh-derived constructors (e.g. the TPMS periodic shell) + where OCCT's surface-integral volume is unreliable on invalid topology. + :meth:`volume` prefers it over the OCCT integral when present. + """ + + __slots__ = ("_mesh_volume", "wrapped") + + def __init__(self, shape: TopoDS_Shape) -> None: + """Wrap an OCCT ``TopoDS_Shape``.""" + self.wrapped = shape + self._mesh_volume: float | None = None + + # -- transforms -------------------------------------------------------- + + def translate(self, offset: Sequence[float]) -> CadShape: + """Return a translated copy.""" + from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform # noqa: PLC0415 + from OCP.gp import gp_Trsf, gp_Vec # noqa: PLC0415 + + trsf = gp_Trsf() + trsf.SetTranslation( + gp_Vec(float(offset[0]), float(offset[1]), float(offset[2])) + ) + transformed = BRepBuilderAPI_Transform(self.wrapped, trsf, True).Shape() + return CadShape(transformed) + + def rotate( + self, + center: Sequence[float], + axis: Sequence[float], + angle_degrees: float, + ) -> CadShape: + """Return a rotated copy (angle in degrees, axis is a unit vector).""" + from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform # noqa: PLC0415 + from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf # noqa: PLC0415 + + trsf = gp_Trsf() + ax = gp_Ax1( + gp_Pnt(float(center[0]), float(center[1]), float(center[2])), + gp_Dir(float(axis[0]), float(axis[1]), float(axis[2])), + ) + trsf.SetRotation(ax, float(np.deg2rad(angle_degrees))) + transformed = BRepBuilderAPI_Transform(self.wrapped, trsf, True).Shape() + return CadShape(transformed) + + def copy(self) -> CadShape: + """Return an independent copy (deep topology copy).""" + from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy # noqa: PLC0415 + + return CadShape(BRepBuilderAPI_Copy(self.wrapped).Shape()) + + # -- boolean ops ------------------------------------------------------- + + def fuse(self, other: CadShape) -> CadShape: + """Boolean fusion: ``self ∪ other``.""" + from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse # noqa: PLC0415 + + return CadShape(_run_boolean(BRepAlgoAPI_Fuse, self, other, "Fuse")) + + def cut(self, other: CadShape) -> CadShape: + """Boolean difference: ``self \\ other``.""" + from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut # noqa: PLC0415 + + return CadShape(_run_boolean(BRepAlgoAPI_Cut, self, other, "Cut")) + + def intersect(self, other: CadShape) -> CadShape: + """Boolean intersection: ``self ∩ other``.""" + from OCP.BRepAlgoAPI import BRepAlgoAPI_Common # noqa: PLC0415 + + return CadShape(_run_boolean(BRepAlgoAPI_Common, self, other, "Common")) + + # -- topology queries -------------------------------------------------- + + def solids(self) -> list[CadShape]: + """Enumerate contained solids.""" + from OCP.TopAbs import TopAbs_SOLID # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + + cast_solid = _topods_cast("Solid") + out: list[CadShape] = [] + exp = TopExp_Explorer(self.wrapped, TopAbs_SOLID) + while exp.More(): + out.append(CadShape(cast_solid(exp.Current()))) + exp.Next() + return out + + def vertices(self) -> list[tuple[float, float, float]]: + """Enumerate the vertex coordinates of the shape. + + Callers use this to check that a generated mesh has any vertices at + all (``assert np.any(shape.vertices())``). + """ + from OCP.BRep import BRep_Tool # noqa: PLC0415 + from OCP.TopAbs import TopAbs_VERTEX # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + + cast_vertex = _topods_cast("Vertex") + pnt = getattr(BRep_Tool, "Pnt_s", None) or BRep_Tool.Pnt + out: list[tuple[float, float, float]] = [] + exp = TopExp_Explorer(self.wrapped, TopAbs_VERTEX) + while exp.More(): + v = cast_vertex(exp.Current()) + p = pnt(v) + out.append((float(p.X()), float(p.Y()), float(p.Z()))) + exp.Next() + return out + + def faces(self) -> list[CadShape]: + """Enumerate the faces of the shape.""" + from OCP.TopAbs import TopAbs_FACE # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + + cast_face = _topods_cast("Face") + out: list[CadShape] = [] + exp = TopExp_Explorer(self.wrapped, TopAbs_FACE) + while exp.More(): + out.append(CadShape(cast_face(exp.Current()))) + exp.Next() + return out + + def is_closed(self) -> bool: + """Whether the shape is topologically closed. + + Solids are always closed; for shells/compounds we read OCCT's + per-shape ``Closed`` flag (set by ``BRep_Builder::IsClosed`` when the + shell was built from a watertight set of faces). + """ + from OCP.TopAbs import TopAbs_SOLID # noqa: PLC0415 + + if self.wrapped.ShapeType() == TopAbs_SOLID: + return True + return bool(self.wrapped.Closed()) + + def volume(self) -> float: + """Return the (unsigned) volume of the shape. + + OCCT's ``BRepGProp::VolumeProperties`` returns a *signed* volume that + depends on face orientation; mesh-built shells from + :func:`microgen.cad.mesh_to_shell_brep` can carry inverted orientation + and yield a negative value. We return ``abs(...)`` so volumes are + non-negative. + + If a mesh-derived volume was stashed on ``_mesh_volume`` AND the OCCT + solid is not valid (BRepCheck_Analyzer flags self-intersection / + non-manifold edges, common on raw marching-cubes input), we trust the + mesh volume — the OCCT surface integral on an invalid topology is + meaningless. + """ + from OCP.BRepCheck import BRepCheck_Analyzer # noqa: PLC0415 + from OCP.BRepGProp import BRepGProp # noqa: PLC0415 + from OCP.GProp import GProp_GProps # noqa: PLC0415 + + if ( + self._mesh_volume is not None + and not BRepCheck_Analyzer( + self.wrapped, + ).IsValid() + ): + return float(abs(self._mesh_volume)) + + props = GProp_GProps() + BRepGProp.VolumeProperties_s(self.wrapped, props) + return float(abs(props.Mass())) + + def center(self) -> _Centre: + """Return the volumetric center of mass. + + The result is a :class:`_Centre` — exposes ``.x``, ``.y``, ``.z``, + ``.to_tuple()``, and unpacks like a tuple. + """ + from OCP.BRepGProp import BRepGProp # noqa: PLC0415 + from OCP.GProp import GProp_GProps # noqa: PLC0415 + + props = GProp_GProps() + BRepGProp.VolumeProperties_s(self.wrapped, props) + c = props.CentreOfMass() + return _Centre(float(c.X()), float(c.Y()), float(c.Z())) + + def bounding_box(self) -> _BBox: + """Return the axis-aligned bounding box. + + The result exposes ``xmin`` / ``xmax`` / … attributes (see + :class:`_BBox`). + """ + from OCP.Bnd import Bnd_Box # noqa: PLC0415 + from OCP.BRepBndLib import BRepBndLib # noqa: PLC0415 + + box = Bnd_Box() + # AddOptimal uses exact geometric bounds (not cached triangulation). + BRepBndLib.AddOptimal_s(self.wrapped, box, True, True) + xmin, ymin, zmin, xmax, ymax, zmax = box.Get() + return _BBox(xmin, ymin, zmin, xmax, ymax, zmax) + + # -- exports ----------------------------------------------------------- + + def export_stl( + self, + path: str | Path, + linear_deflection: float = 0.01, + angular_deflection: float = 0.5, + *, + ascii_mode: bool = False, + ) -> None: + """Export to STL. Mesh is regenerated at the given deflection.""" + from OCP.BRepMesh import BRepMesh_IncrementalMesh # noqa: PLC0415 + from OCP.StlAPI import StlAPI_Writer # noqa: PLC0415 + + BRepMesh_IncrementalMesh( + self.wrapped, + float(linear_deflection), + False, + float(angular_deflection), + True, + ) + writer = StlAPI_Writer() + writer.ASCIIMode = bool(ascii_mode) + if not writer.Write(self.wrapped, str(path)): + err_msg = f"STL write failed for {path!r}" + raise RuntimeError(err_msg) + + def export_step(self, path: str | Path) -> None: + """Export to STEP (AP214).""" + from OCP.IFSelect import IFSelect_RetDone # noqa: PLC0415 + from OCP.STEPControl import ( # noqa: PLC0415 + STEPControl_AsIs, + STEPControl_Writer, + ) + + writer = STEPControl_Writer() + status = writer.Transfer(self.wrapped, STEPControl_AsIs) + if status != IFSelect_RetDone: + err_msg = f"STEP transfer failed with status {status!r}" + raise RuntimeError(err_msg) + status = writer.Write(str(path)) + if status != IFSelect_RetDone: + err_msg = f"STEP write failed with status {status!r}" + raise RuntimeError(err_msg) + + def export_brep(self, path: str | Path) -> None: + """Export to OCCT native BREP.""" + from OCP.BRepTools import BRepTools # noqa: PLC0415 + + ok = BRepTools.Write_s(self.wrapped, str(path)) + if not ok: + err_msg = f"BREP write failed for {path!r}" + raise RuntimeError(err_msg) diff --git a/microgen/cad/topo.py b/microgen/cad/topo.py new file mode 100644 index 00000000..293bad30 --- /dev/null +++ b/microgen/cad/topo.py @@ -0,0 +1,238 @@ +"""Topology assembly, exploration, and splitting helpers. + +Free functions for working with OCCT ``TopoDS_Solid`` / ``TopoDS_Compound`` +collections: building compounds, enumerating solids, splitting shapes by +tool, plane-face cutting tools, affine transforms, side selection, and +box-clipping a list of solids. + +These power the periodic split-and-translate algorithm in +``microgen.periodic`` and the rasterisation pipeline on ``Phase``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np +import numpy.typing as npt + +from ._install import require_cad +from .shape import CadShape, _topods_cast + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + +def make_compound(shapes: Iterable[CadShape]) -> CadShape: + """Assemble shapes into a single OCCT ``TopoDS_Compound``.""" + require_cad() + from OCP.BRep import BRep_Builder # noqa: PLC0415 + from OCP.TopoDS import TopoDS_Compound # noqa: PLC0415 + + builder = BRep_Builder() + compound = TopoDS_Compound() + builder.MakeCompound(compound) + for s in shapes: + builder.Add(compound, s.wrapped) + return CadShape(compound) + + +def make_compound_from_solids(solids: Iterable[Any]) -> CadShape: + """Assemble raw OCCT ``TopoDS_Shape`` solids (not ``CadShape``) into a compound.""" + require_cad() + from OCP.BRep import BRep_Builder # noqa: PLC0415 + from OCP.TopoDS import TopoDS_Compound # noqa: PLC0415 + + builder = BRep_Builder() + compound = TopoDS_Compound() + builder.MakeCompound(compound) + for s in solids: + shape = s.wrapped if hasattr(s, "wrapped") else s + builder.Add(compound, shape) + return CadShape(compound) + + +def enumerate_solids(shape: CadShape) -> list[Any]: + """Return the list of ``TopoDS_Solid`` inside a shape (empty if none).""" + require_cad() + from OCP.TopAbs import TopAbs_SOLID # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + + cast_solid = _topods_cast("Solid") + out: list[Any] = [] + exp = TopExp_Explorer(shape.wrapped, TopAbs_SOLID) + while exp.More(): + out.append(cast_solid(exp.Current())) + exp.Next() + return out + + +def split_shape( + shape: CadShape, + tool: CadShape | Iterable[CadShape], + *, + fuzzy_value: float = 1e-4, +) -> CadShape: + """Split *shape* by *tool* using OCCT's ``BRepAlgoAPI_Splitter``. + + The result is a :class:`CadShape` wrapping a ``TopoDS_Compound`` that + contains the sub-shapes produced by the split. Use + :func:`enumerate_solids` to iterate over the resulting solids. + + :param tool: a single :class:`CadShape` or an iterable of them. Pass + multiple tools when each tool is a separate ``TopoDS_Shell`` and + you want to keep them topologically distinct — the OCCT splitter + treats each list entry as one cutting tool. This matters because + ``BRepAlgoAPI_Fuse`` on two shells *decomposes* them into a + compound of per-face shells, which then defeats the splitter. + :param fuzzy_value: tolerance forwarded to ``SetFuzzyValue`` so OCCT + recognises near-coincident geometry as touching. Necessary when + a tool is a tessellated shell whose boundary edges lie on + ``shape``'s planar faces only up to a few microns of drift. + """ + require_cad() + from OCP.BRepAlgoAPI import BRepAlgoAPI_Splitter # noqa: PLC0415 + from OCP.TopTools import TopTools_ListOfShape # noqa: PLC0415 + + args = TopTools_ListOfShape() + args.Append(shape.wrapped) + tools = TopTools_ListOfShape() + if isinstance(tool, CadShape): + tools.Append(tool.wrapped) + else: + for t in tool: + tools.Append(t.wrapped) + + splitter = BRepAlgoAPI_Splitter() + splitter.SetArguments(args) + splitter.SetTools(tools) + if fuzzy_value > 0: + splitter.SetFuzzyValue(float(fuzzy_value)) + splitter.Build() + return CadShape(splitter.Shape()) + + +def make_plane_face( + base_pnt: Sequence[float], + direction: Sequence[float], + half_size: float = 1.0e6, +) -> CadShape: + """Build a large planar face used as a cutting tool. + + :param base_pnt: a point on the plane + :param direction: plane normal + :param half_size: half-edge of the square face (default large so the plane + reaches far outside any realistic shape) + """ + require_cad() + from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace # noqa: PLC0415 + from OCP.gp import gp_Ax3, gp_Dir, gp_Pln, gp_Pnt # noqa: PLC0415 + + pnt = gp_Pnt(float(base_pnt[0]), float(base_pnt[1]), float(base_pnt[2])) + nrm = gp_Dir(float(direction[0]), float(direction[1]), float(direction[2])) + plane = gp_Pln(gp_Ax3(pnt, nrm)) + face = BRepBuilderAPI_MakeFace( + plane, + -float(half_size), + float(half_size), + -float(half_size), + float(half_size), + ).Face() + return CadShape(face) + + +def transform_geometry(shape: CadShape, matrix: npt.NDArray[np.float64]) -> CadShape: + """Apply a 3x4 affine matrix (linear + translation). + + Wraps OCCT ``BRepBuilderAPI_GTransform``. + + :param matrix: ``(3, 4)`` array; rows are ``[a b c tx; d e f ty; g h i tz]``. + """ + require_cad() + from OCP.BRepBuilderAPI import BRepBuilderAPI_GTransform # noqa: PLC0415 + from OCP.gp import gp_GTrsf, gp_Mat, gp_XYZ # noqa: PLC0415 + + m = np.asarray(matrix, dtype=np.float64) + gtrsf = gp_GTrsf() + gtrsf.SetVectorialPart( + gp_Mat( + float(m[0, 0]), + float(m[0, 1]), + float(m[0, 2]), + float(m[1, 0]), + float(m[1, 1]), + float(m[1, 2]), + float(m[2, 0]), + float(m[2, 1]), + float(m[2, 2]), + ), + ) + gtrsf.SetTranslationPart(gp_XYZ(float(m[0, 3]), float(m[1, 3]), float(m[2, 3]))) + return CadShape(BRepBuilderAPI_GTransform(shape.wrapped, gtrsf, True).Shape()) + + +def translate_solid(solid: Any, offset: Sequence[float]) -> Any: + """Translate a raw OCCT solid/shape by ``offset`` and return the same type.""" + require_cad() + from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform # noqa: PLC0415 + from OCP.gp import gp_Trsf, gp_Vec # noqa: PLC0415 + + shape = solid.wrapped if hasattr(solid, "wrapped") else solid + trsf = gp_Trsf() + trsf.SetTranslation( + gp_Vec(float(offset[0]), float(offset[1]), float(offset[2])), + ) + return BRepBuilderAPI_Transform(shape, trsf, True).Shape() + + +def solid_center(shape: Any) -> tuple[float, float, float]: + """Return the volumetric center of mass of a raw OCCT shape/solid.""" + require_cad() + from OCP.BRepGProp import BRepGProp # noqa: PLC0415 + from OCP.GProp import GProp_GProps # noqa: PLC0415 + + s = shape.wrapped if hasattr(shape, "wrapped") else shape + props = GProp_GProps() + BRepGProp.VolumeProperties_s(s, props) + com = props.CentreOfMass() + return (float(com.X()), float(com.Y()), float(com.Z())) + + +def select_solids_on_side( + shape: CadShape, + base_pnt: Sequence[float], + side_direction: Sequence[float], +) -> list[Any]: + """Enumerate a compound's solids; keep those on the positive side of a plane. + + The plane passes through ``base_pnt`` normal to ``side_direction``. + Matches CadQuery's ``.solids(">X")`` / ``.solids(" 0: + selected.append(solid) + return selected + + +def intersect_solids_with_box(solids: Iterable[Any], box: CadShape) -> CadShape: + """Intersect each solid with *box* and fuse the results into a ``CadShape``. + + Returns a :class:`CadShape` wrapping a compound (possibly empty). + """ + require_cad() + from OCP.BRepAlgoAPI import BRepAlgoAPI_Common # noqa: PLC0415 + + parts: list[Any] = [] + for solid in solids: + s = solid.wrapped if hasattr(solid, "wrapped") else solid + common = BRepAlgoAPI_Common(s, box.wrapped).Shape() + # Keep if it contains at least one solid. + if enumerate_solids(CadShape(common)): + parts.append(common) + return make_compound_from_solids(parts) diff --git a/tests/test_no_top_level_ocp_imports.py b/tests/test_no_top_level_ocp_imports.py index beb2800c..bc060c0d 100644 --- a/tests/test_no_top_level_ocp_imports.py +++ b/tests/test_no_top_level_ocp_imports.py @@ -24,12 +24,10 @@ MICROGEN_ROOT = Path(__file__).resolve().parent.parent / "microgen" -# Files allowed to have top-level OCP imports (the CAD boundary). Paths are -# relative to ``microgen/``. Extend this list as the CAD subpackage grows -# (PR 4 splits ``cad.py`` into a ``cad/`` subpackage). -_CAD_BOUNDARY = { - "cad.py", -} +# The CAD boundary: every file under ``microgen/cad/`` is allowed to have +# top-level OCP imports. Everything outside it must keep OCP imports lazy +# (inside a function body) or guarded by ``if TYPE_CHECKING:``. +_CAD_BOUNDARY_PREFIX = "cad/" def _is_type_checking_block(node: ast.stmt) -> bool: @@ -74,7 +72,7 @@ def test_no_top_level_ocp_imports_outside_cad_boundary() -> None: offenders: list[str] = [] for py in MICROGEN_ROOT.rglob("*.py"): rel = py.relative_to(MICROGEN_ROOT).as_posix() - if rel in _CAD_BOUNDARY: + if rel.startswith(_CAD_BOUNDARY_PREFIX): continue tree = ast.parse(py.read_text(encoding="utf-8"), filename=str(py)) for node in _top_level_imports(tree): @@ -82,7 +80,7 @@ def test_no_top_level_ocp_imports_outside_cad_boundary() -> None: offenders.append(f"{rel}:{node.lineno}") assert not offenders, ( "Top-level OCP imports outside the CAD boundary " - f"({sorted(_CAD_BOUNDARY)}): {offenders}. " + f"(microgen/{_CAD_BOUNDARY_PREFIX}*): {offenders}. " "Move them inside a function body or under " "``if TYPE_CHECKING:``." ) From a7d842bf770a6bd36ab56eda96da6c6711f33001 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Fri, 29 May 2026 15:23:59 +0200 Subject: [PATCH 8/8] Rework Phase API: implicit-first, CAD lazy Large refactor of Phase to an implicit-first, CAD-optional design (Phase 2.0). Introduced Phase.from_cad/from_shape/from_mesh, lazy cad materialisation (.cad), piece extraction, PyVista-backed grid/mesh views, cached moments (center_of_mass, inertia_matrix), and immutable transform methods. Updated mesh/operations/periodic code to use the new Phase API (use phase.cad, Phase.from_cad, and new helpers), added _split_cad_in_grid and improved raster/repeat logic. Added _phase_solid_count in mesh to correctly account for CAD solids. Updated many examples and tests to match the new API and added a new test (tests/test_phase_pieces.py). Overall: unify implicit and CAD workflows, isolate CAD seam, and make Phase behaviour more robust and lazy. --- .../rasterEllipsoid/rasterEllipsoid.py | 4 +- examples/3Doperations/voronoi/voronoi.py | 2 +- .../voronoiGyroid/voronoiGyroid.py | 4 +- examples/Lattices/honeycomb/honeycomb.py | 6 +- examples/Lattices/octetTruss/octetTruss.py | 4 +- examples/Mesh/gyroid/gyroid_step_remesh.py | 4 +- microgen/mesh.py | 13 +- microgen/operations.py | 144 ++- microgen/periodic.py | 8 +- microgen/phase.py | 960 +++++++++++++----- .../shape/strut_lattice/abstract_lattice.py | 8 +- tests/test_mesh.py | 4 +- tests/test_mesh_periodic.py | 18 +- tests/test_periodic.py | 36 +- tests/test_phase.py | 138 ++- tests/test_phase_pieces.py | 82 ++ tests/test_sample.py | 2 +- 17 files changed, 1017 insertions(+), 420 deletions(-) create mode 100644 tests/test_phase_pieces.py diff --git a/examples/3Doperations/rasterEllipsoid/rasterEllipsoid.py b/examples/3Doperations/rasterEllipsoid/rasterEllipsoid.py index dce49148..e418a8cb 100644 --- a/examples/3Doperations/rasterEllipsoid/rasterEllipsoid.py +++ b/examples/3Doperations/rasterEllipsoid/rasterEllipsoid.py @@ -8,10 +8,10 @@ elem = Ellipsoid(radii=(0.15, 0.31, 0.4)) elli = elem.generate_cad() -raster = raster_phase(phase=Phase(shape=elli), rve=rve, grid=[5, 5, 5]) +raster = raster_phase(phase=Phase.from_cad(elli), rve=rve, grid=[5, 5, 5]) compound = make_compound_from_solids( - [solid for phase in raster for solid in phase.solids] + [solid.wrapped for phase in raster for solid in phase.cad.solids()] ) step_file = str(Path(__file__).parent / "compound.step") compound.export_step(step_file) diff --git a/examples/3Doperations/voronoi/voronoi.py b/examples/3Doperations/voronoi/voronoi.py index 42356d2d..9e280c88 100644 --- a/examples/3Doperations/voronoi/voronoi.py +++ b/examples/3Doperations/voronoi/voronoi.py @@ -39,7 +39,7 @@ step_file = str(Path(__file__).parent / "compound.step") compound.export_step(step_file) -phases = [Phase(shape=shape) for shape in shapes] +phases = [Phase.from_cad(shape) for shape in shapes] vtk_file = str(Path(__file__).parent / "Voronoi.vtk") mesh( diff --git a/examples/3Doperations/voronoiGyroid/voronoiGyroid.py b/examples/3Doperations/voronoiGyroid/voronoiGyroid.py index 8090b698..b99b3de1 100644 --- a/examples/3Doperations/voronoiGyroid/voronoiGyroid.py +++ b/examples/3Doperations/voronoiGyroid/voronoiGyroid.py @@ -18,9 +18,9 @@ phases = [] for polyhedron in polyhedra: shape = polyhedron.generate_cad() - phases.append(Phase(shape=shape.intersect(gyroid))) + phases.append(Phase.from_cad(shape.intersect(gyroid))) -compound = make_compound([phase.shape for phase in phases]) +compound = make_compound([phase.cad for phase in phases]) step_file = str(Path(__file__).parent / "compound.step") compound.export_step(step_file) diff --git a/examples/Lattices/honeycomb/honeycomb.py b/examples/Lattices/honeycomb/honeycomb.py index e29e1b10..6968e7b5 100644 --- a/examples/Lattices/honeycomb/honeycomb.py +++ b/examples/Lattices/honeycomb/honeycomb.py @@ -38,14 +38,14 @@ ) shapeList.append(poly.generate_cad()) -boxPhase = Phase(shape=box.generate_cad()) +boxPhase = Phase.from_cad(box.generate_cad()) honeycomb = cut_phase_by_shape_list(phase_to_cut=boxPhase, shapes=shapeList) step_file = str(Path(__file__).parent / "honeycomb.step") stl_file = str(Path(__file__).parent / "honeycomb.stl") -honeycomb.shape.export_step(step_file) -honeycomb.shape.export_stl(stl_file) +honeycomb.cad.export_step(step_file) +honeycomb.cad.export_stl(stl_file) vtk_file = str(Path(__file__).parent / "honeycomb.vtk") mesh( mesh_file=step_file, diff --git a/examples/Lattices/octetTruss/octetTruss.py b/examples/Lattices/octetTruss/octetTruss.py index 3e724ca2..a832f1f0 100644 --- a/examples/Lattices/octetTruss/octetTruss.py +++ b/examples/Lattices/octetTruss/octetTruss.py @@ -71,14 +71,14 @@ height=height[i], radius=radius[i], ) - list_phases.append(Phase(shape=elem.generate_cad())) + list_phases.append(Phase.from_cad(elem.generate_cad())) for phase_elem in list_phases: periodicPhase = periodic_split_and_translate(phase=phase_elem, rve=rve) listPeriodicPhases.append(periodicPhase) phases_cut = cut_phases(phases=listPeriodicPhases, reverse_order=False) -compound = make_compound([phase.shape for phase in phases_cut]) +compound = make_compound([phase.cad for phase in phases_cut]) step_file = str(Path(__file__).parent / "octettruss.step") stl_file = str(Path(__file__).parent / "octettruss.stl") diff --git a/examples/Mesh/gyroid/gyroid_step_remesh.py b/examples/Mesh/gyroid/gyroid_step_remesh.py index 0164829d..3fe2fbd8 100755 --- a/examples/Mesh/gyroid/gyroid_step_remesh.py +++ b/examples/Mesh/gyroid/gyroid_step_remesh.py @@ -35,12 +35,12 @@ # 2. Wrap the geometry into a microgen Phase object. phases = [] -phases.append(Phase(shape=geometry.generate_cad())) +phases.append(Phase.from_cad(geometry.generate_cad())) rve = Rve(dim=1) # 3. Export the geometry as a STEP file. step_file = str(Path(__file__).parent / "gyroid.step") -phases[0].shape.export_step(step_file) +phases[0].cad.export_step(step_file) # 4. Import the STEP file and create a mesh with periodic constraints and export as VTK. diff --git a/microgen/mesh.py b/microgen/mesh.py index d15a8026..51fd8ce5 100644 --- a/microgen/mesh.py +++ b/microgen/mesh.py @@ -219,18 +219,27 @@ def is_periodic( return True +def _phase_solid_count(phase: Phase) -> int: + """Number of TopoDS_Solid in a phase's CAD representation. + + Always materialises (and caches) the CAD view — required because + gmsh allocates entity tags per OCCT solid. + """ + return len(phase.cad.solids()) + + def _generate_list_tags(list_phases: list[Phase]) -> list[list[int]]: list_tags: list[list[int]] = [] start: int = 1 for phase in list_phases: - stop = start + len(phase.solids) + stop = start + _phase_solid_count(phase) list_tags.append(list(range(start, stop))) start = stop return list_tags def _generate_list_dim_tags(list_phases: list[Phase]) -> list[tuple[int, int]]: - nb_tags = sum(len(phase.solids) for phase in list_phases) + nb_tags = sum(_phase_solid_count(phase) for phase in list_phases) return [(_DIM_COUNT, tag) for tag in range(1, nb_tags + 1)] diff --git a/microgen/operations.py b/microgen/operations.py index 1a3982bc..e2c80342 100644 --- a/microgen/operations.py +++ b/microgen/operations.py @@ -127,10 +127,31 @@ def rotate_euler( def rescale(shape: CadShape, scale: float | tuple[float, float, float]) -> CadShape: - """Rescale given object according to scale parameters [dim_x, dim_y, dim_z].""" - from .phase import Phase # noqa: PLC0415 + """Rescale ``shape`` by ``scale = (sx, sy, sz)`` (or a scalar) about its centroid. + + Preserves the shape's center of mass — scaling is performed about it. + """ + require_cad() + from .cad import transform_geometry # noqa: PLC0415 - return Phase.rescale_shape(shape, scale) + if isinstance(scale, (int, float)): + sx = sy = sz = float(scale) + else: + sx, sy, sz = (float(s) for s in scale) + + center = shape.center() + cx, cy, cz = center.x, center.y, center.z + + # translate(-c) → scale about origin → translate(+c), as a single 3x4 affine + matrix = np.array( + [ + [sx, 0.0, 0.0, cx - sx * cx], + [0.0, sy, 0.0, cy - sy * cy], + [0.0, 0.0, sz, cz - sz * cz], + ], + dtype=np.float64, + ) + return transform_geometry(shape, matrix) def _unify_solids(shape: TopoDS_Shape) -> CadShape: @@ -177,7 +198,7 @@ def fuse_shapes(shapes: list[CadShape], *, retain_edges: bool) -> CadShape: def cut_phases_by_shape(phases: list[Phase], cut_obj: CadShape) -> list[Phase]: """Cut list of phases by a given shape. - :param phases: list of phases to cut + :param phases: list of phases (must be CAD-backed) :param cut_obj: cutting object :return phase_cut: final result @@ -190,18 +211,18 @@ def cut_phases_by_shape(phases: list[Phase], cut_obj: CadShape) -> list[Phase]: phase_cut: list[Phase] = [] for phase in phases: - if phase.shape is None: + if phase.is_empty: continue - cut = CadShape(BRepAlgoAPI_Cut(phase.shape.wrapped, cut_obj.wrapped).Shape()) + cut = CadShape(BRepAlgoAPI_Cut(phase.cad.wrapped, cut_obj.wrapped).Shape()) if len(cut.solids()) > 0: - phase_cut.append(Phase(shape=cut)) + phase_cut.append(Phase.from_cad(cut)) return phase_cut def cut_phase_by_shape_list(phase_to_cut: Phase, shapes: list[CadShape]) -> Phase: """Cut a phase by a list of shapes. - :param phase_to_cut: phase to cut + :param phase_to_cut: phase to cut (must be CAD-backed) :param shapes: list of cutting shapes :return resultCut: cut phase @@ -211,13 +232,13 @@ def cut_phase_by_shape_list(phase_to_cut: Phase, shapes: list[CadShape]) -> Phas from .phase import Phase # noqa: PLC0415 - result = phase_to_cut.shape - if result is None: - err_msg = "phase_to_cut has no shape to cut" + if phase_to_cut.is_empty: + err_msg = "phase_to_cut is empty" raise ValueError(err_msg) + result = phase_to_cut.cad for shape in shapes: result = CadShape(BRepAlgoAPI_Cut(result.wrapped, shape.wrapped).Shape()) - return Phase(shape=result) + return Phase.from_cad(result) def cut_shapes(shapes: list[CadShape], *, reverse_order: bool = True) -> list[CadShape]: @@ -253,20 +274,48 @@ def cut_shapes(shapes: list[CadShape], *, reverse_order: bool = True) -> list[Ca def cut_phases(phases: list[Phase], *, reverse_order: bool = True) -> list[Phase]: - """Cut list of shapes in the given order (or reverse) and fuse them. + """Cut list of phases in the given order (or reverse) and fuse them. - :param phases: list of phases to cut - :param reverse_order: bool, order for cutting shapes, \ - when True: the last shape of the list is not cut + :param phases: list of phases to cut (each must be CAD-backed) + :param reverse_order: order for cutting shapes; + when ``True``, the last shape of the list is not cut - :return list of phases + :return: list of phases """ from .phase import Phase # noqa: PLC0415 - shapes = [phase.shape for phase in phases] + shapes = [phase.cad for phase in phases] cutted_shapes = cut_shapes(shapes, reverse_order=reverse_order) - return [Phase(shape=shape) for shape in cutted_shapes] + return [Phase.from_cad(shape) for shape in cutted_shapes] + + +def _split_cad_in_grid( + cad: CadShape, + rve: Rve, + grid: list[int], +) -> list[TopoDS_Shape]: + """Split a CAD shape into solids per (grid-1)^3 interior cell planes.""" + require_cad() + from .cad import enumerate_solids, make_plane_face, split_shape # noqa: PLC0415 + + result: list[TopoDS_Shape] = [] + for solid in enumerate_solids(cad): + current = CadShape(solid) + for axis in range(3): + direction = tuple(int(axis == i) for i in range(3)) + coords = np.linspace( + start=rve.min_point[axis], + stop=rve.max_point[axis], + num=grid[axis], + endpoint=False, + )[1:] + for pos in coords: + base_pnt = tuple(float(pos) * direction[k] for k in range(3)) + plane = make_plane_face(base_pnt, direction) + current = split_shape(current, plane) + result.extend(enumerate_solids(current)) + return result def raster_phase( @@ -276,22 +325,51 @@ def raster_phase( *, phase_per_raster: bool = True, ) -> Phase | list[Phase]: - """Raster solids from phase according to the rve divided by the given grid. + """Raster a phase according to the RVE divided by the given grid. + + Each solid in the phase is split by the (grid-1) interior planes per + axis (CAD-backed phases only). When ``phase_per_raster`` is true, + each non-empty grid cell becomes one :class:`Phase`; otherwise the + split sub-solids are fused into one new :class:`Phase`. - :param phase: phase to raster + :param phase: CAD-backed phase to raster :param rve: RVE divided by the given grid - :param grid: number of divisions in each direction [x, y, z] + :param grid: number of divisions in each direction ``[x, y, z]`` :param phase_per_raster: if True, returns list of phases :return: Phase or list of Phases """ + require_cad() + from OCP.BRepGProp import BRepGProp # noqa: PLC0415 + from OCP.GProp import GProp_GProps # noqa: PLC0415 + + from .cad import make_compound_from_solids # noqa: PLC0415 from .phase import Phase # noqa: PLC0415 - solids = phase.split_solids(rve, grid) + if phase.is_empty: + err_msg = "Cannot raster an empty phase" + raise ValueError(err_msg) - if phase_per_raster: - return Phase.generate_phase_per_raster(solids, rve, grid) - return Phase(solids=solids) + solids = _split_cad_in_grid(phase.cad, rve, grid) + + if not phase_per_raster: + return Phase.from_cad(make_compound_from_solids(solids)) + + grid_arr = np.array(grid) + buckets: list[list[TopoDS_Shape]] = [[] for _ in range(int(np.prod(grid_arr)))] + for solid in solids: + props = GProp_GProps() + BRepGProp.VolumeProperties_s(solid, props) + com = props.CentreOfMass() + center = np.array([com.X(), com.Y(), com.Z()]) + i, j, k = np.floor( + grid_arr * (center - rve.min_point) / rve.dim, + ).astype(int) + ind = i + grid_arr[0] * j + grid_arr[0] * grid_arr[1] * k + buckets[int(ind)].append(solid) + return [ + Phase.from_cad(make_compound_from_solids(group)) for group in buckets if group + ] def repeat_shape(unit_geom: CadShape, rve: Rve, grid: tuple[int, int, int]) -> CadShape: @@ -301,11 +379,17 @@ def repeat_shape(unit_geom: CadShape, rve: Rve, grid: tuple[int, int, int]) -> C :param rve: RVE of the geometry to repeat :param grid: list of number of geometry repetitions in each direction - :return: cq shape of the repeated geometry + :return: CadShape of the repeated geometry """ - from .phase import Phase # noqa: PLC0415 - - return Phase.repeat_shape(unit_geom, rve, grid) + require_cad() + from .cad import make_compound_from_solids, translate_solid # noqa: PLC0415 + + center = np.array(unit_geom.center().to_tuple()) + copies = [] + for idx in np.ndindex(*grid): + pos = center - rve.dim * (0.5 * np.array(grid) - 0.5 - np.array(idx)) + copies.append(translate_solid(unit_geom.wrapped, pos)) + return make_compound_from_solids(copies) def repeat_polydata( diff --git a/microgen/periodic.py b/microgen/periodic.py index 44282768..0444a6fa 100644 --- a/microgen/periodic.py +++ b/microgen/periodic.py @@ -289,10 +289,10 @@ def periodic_split_and_translate(phase: Phase, rve: Rve) -> Phase: :return: resulting Phase """ - shape = phase.shape - if shape is None: + if phase.is_empty: err_msg = "Cannot apply periodic_split_and_translate to an empty phase" raise ValueError(err_msg) + shape = phase.cad intersected_faces, rve_planes, partitions = _detect_intersected_faces(shape, rve) @@ -341,8 +341,8 @@ def periodic_split_and_translate(phase: Phase, rve: Rve) -> Phase: "periodic_split_and_translate produced no solids", stacklevel=2, ) - return Phase(shape=shape) + return Phase.from_cad(shape, name=phase.name) to_fuse = [CadShape(s) for s in all_solids] fused = fuse_shapes(to_fuse, retain_edges=False) - return Phase(shape=fused) + return Phase.from_cad(fused, name=phase.name) diff --git a/microgen/phase.py b/microgen/phase.py index 5f803158..4965d1fd 100644 --- a/microgen/phase.py +++ b/microgen/phase.py @@ -1,351 +1,749 @@ -"""Phase class: a collection of OCCT solids belonging to the same phase. - -The CAD path goes through OCCT directly via ``OCP`` (installed as the -``[cad]`` extra — ``cadquery-ocp-novtk``); no ``cadquery`` anywhere. Shapes are -stored as :class:`microgen.cad.CadShape` and solids as raw OCCT -``TopoDS_Solid``. +"""Phase 2.0 — implicit-first, CAD-optional, ``Piece``-aware container. + +A :class:`Phase` is a region of space identified by an implicit scalar +field (the canonical representation), with derived materialisations on +demand: + +- :meth:`grid` / :meth:`surface_mesh` / :meth:`volume_mesh` — PyVista views +- :attr:`cad` — OCCT BREP via :func:`microgen.cad.shape_to_cad` +- :attr:`pieces` — connected components of ``{field < iso}`` (the + "Phase = collection of cut/split sub-solids" invariant) +- :attr:`center_of_mass` / :attr:`inertia_matrix` — moments via grid + quadrature (field-backed) or BRepGProp (CAD-backed) + +Three construction paths: + +- :class:`Phase` ``(field=..., bounds=..., iso=..., period=...)`` — + field-first (no CAD required). +- :meth:`Phase.from_shape` — sugar over the field-first path; bridges + from a :class:`~microgen.shape.shape.Shape`. +- :meth:`Phase.from_cad` — CAD-backed; required when loading a STEP file + or wrapping a pre-built BREP. + +The CAD seam is isolated in :meth:`_materialise_cad`. Switching from +``microgen.cad`` to ``pyvista-cad`` later means swapping that one method; +no other code in microgen needs to change. + +A :class:`Phase` is **immutable**: transforms (:meth:`translated`, +:meth:`scaled`, :meth:`rotated`, :meth:`tiled`) return a new instance. +This makes cache invalidation impossible by construction. """ from __future__ import annotations -import warnings -from typing import TYPE_CHECKING +import itertools +from dataclasses import dataclass +from functools import cached_property +from typing import TYPE_CHECKING, Any import numpy as np -import numpy.typing as npt - -from .cad import ( - CadShape, - enumerate_solids, - make_compound_from_solids, - make_plane_face, - split_shape, - transform_geometry, - translate_solid, -) if TYPE_CHECKING: from collections.abc import Sequence - from OCP.TopoDS import TopoDS_Shape, TopoDS_Solid + import numpy.typing as npt + import pyvista as pv from .rve import Rve + from .shape._types import BoundsType, Field, PeriodType + from .shape.shape import Shape - ShapeLike = CadShape | TopoDS_Shape +# Module-level counter for auto-naming (replaces the old mutable +# ``Phase.num_instances`` class attribute, which contaminated test runs). +_PHASE_AUTONAME_COUNTER = itertools.count() -def _require_ocp() -> None: - """Raise :class:`ImportError` with an install hint if OCP isn't available.""" - try: - import OCP # noqa: F401, PLC0415 - except ImportError as err: - err_msg = ( - "This Phase operation requires the CAD extra: pip install 'microgen[cad]'" - ) - raise ImportError(err_msg) from err +_IMPLICIT_SCALAR = "implicit" -def _to_cad_shape(obj: ShapeLike) -> CadShape: - """Coerce a ``CadShape`` or raw ``TopoDS_Shape`` into a :class:`CadShape`.""" - if isinstance(obj, CadShape): - return obj - return CadShape(obj.wrapped if hasattr(obj, "wrapped") else obj) +@dataclass(frozen=True) +class Piece: + """One connected sub-region of a :class:`Phase`. -class Phase: - """Phase class: a collection of solids with shared material properties. + Pieces are what survives "split this phase under periodicity" or + "raster this phase into a per-cell grid" — they expose the per-piece + geometric moments without forcing every Phase to be a list of solids. - Exposes: + Payload fields are populated lazily and may be ``None`` depending on + how the parent :class:`Phase` was constructed: a CAD-backed phase + populates ``cad``; a field-backed phase populates ``voxel_mask``; + a mesh-backed phase populates ``mesh``. + """ - - :attr:`center_of_mass` - - :attr:`inertia_matrix` - - :attr:`shape` (a :class:`~microgen.cad.CadShape`) - - :attr:`solids` (a list of raw OCCT ``TopoDS_Solid``) + com: tuple[float, float, float] + volume: float + bounds: BoundsType + cad: Any | None = None + mesh: pv.PolyData | None = None + voxel_mask: npt.NDArray[np.bool_] | None = None - :param shape: a :class:`~microgen.cad.CadShape` or raw ``TopoDS_Shape`` - :param solids: list of raw OCCT solids - :param center: center - :param orientation: orientation - """ - num_instances = 0 +class Phase: + """Microstructure phase (implicit-first, CAD-optional). + + A phase is **one** of the three: + + - field-backed: a callable ``field(x,y,z) -> array`` with negative + values inside, plus an AABB ``bounds`` and an ``iso`` value (the + solid is ``{p : field(p) < iso}``). + - mesh-backed: a triangulated surface ``pv.PolyData``. + - CAD-backed: a CAD shape (``microgen.cad.CadShape`` today, any + duck-typed CAD object — including future ``pyvista-cad`` shapes — + tomorrow). + + The other two materialisations are derived on demand. + + :param field: SDF / level-set ``(x, y, z) -> array``; negative inside. + Required for field-backed construction. + :param bounds: axis-aligned bbox ``(xmin, xmax, ymin, ymax, zmin, zmax)`` + spanning the field. Required if ``field`` is set. + :param iso: iso-value; the solid is ``{p : field(p) < iso}``. + Defaults to ``0.0``. + :param period: ``(Lx, Ly, Lz)`` if the field is intrinsically + periodic. Optional. + :param name: phase name (defaults to auto-generated ``Phase_N``). + :param resolution: default sampling resolution used by lazy + :meth:`grid` / :meth:`surface_mesh` / :meth:`pieces`. + + Use :meth:`from_shape`, :meth:`from_cad` or :meth:`from_mesh` to + build from a :class:`~microgen.shape.shape.Shape`, a pre-built CAD + object, or a triangulated mesh, respectively. + """ def __init__( self: Phase, - shape: ShapeLike | None = None, - solids: list[TopoDS_Solid] | None = None, - center: tuple[float, float, float] | None = None, - orientation: tuple[float, float, float] | None = None, + *, + field: Field | None = None, + bounds: BoundsType | None = None, + iso: float = 0.0, + period: PeriodType | None = None, + name: str | None = None, + resolution: int = 50, ) -> None: - """Initialize the phase object.""" - self._shape: CadShape | None = ( - _to_cad_shape(shape) if shape is not None else None - ) - self._solids: list[TopoDS_Solid] = solids if solids is not None else [] - self.center = center - self.orientation = orientation - - if shape is None and solids == []: - warnings.warn("Empty phase", stacklevel=2) - - self.name = f"Phase_{self.num_instances}" + """Initialize the phase (keyword-only; positional args rejected).""" + if field is not None and bounds is None: + err_msg = "bounds must be provided when field is set" + raise ValueError(err_msg) - self._center_of_mass = None - self._inertia_matrix = None + self._field: Field | None = field + self._bounds: BoundsType | None = bounds + self._iso: float = float(iso) + self._period: PeriodType | None = period + self._resolution: int = int(resolution) + # Internal caches for non-field-backed payloads. Materialisation + # rules: CAD-backed phases set ``_cad`` at construction; field-backed + # phases populate it lazily in :attr:`cad`; mesh-backed phases set + # ``_surface_mesh`` at construction. + self._cad: Any | None = None + self._surface_mesh: pv.PolyData | None = None + + self.name: str = ( + name if name is not None else f"Phase_{next(_PHASE_AUTONAME_COUNTER)}" + ) - Phase.num_instances += 1 + # ------------------------------------------------------------------ + # Constructors + # ------------------------------------------------------------------ - def get_center_of_mass( - self: Phase, + @classmethod + def from_shape( + cls: type[Phase], + shape: Shape, *, - compute: bool = True, - ) -> npt.NDArray[np.float64]: - """Return the center of 'mass' of the phase. - - :param compute: if False and center_of_mass already exists, \ - does not compute it (use carefully) + bounds: BoundsType | None = None, + iso: float = 0.0, + name: str | None = None, + resolution: int = 50, + ) -> Phase: + """Construct a field-backed :class:`Phase` from an implicit :class:`Shape`. + + Inherits ``field``, ``bounds``, and ``period`` from the shape. + ``bounds`` may be overridden (e.g., to clip a periodic field to a + sub-region). + + :param shape: source implicit shape; must have ``func is not None``. + :param bounds: override the shape's bounds; defaults to ``shape.bounds``. + :param iso: iso-value (default ``0.0``). + :param name: phase name (auto-generated if omitted). + :param resolution: default sampling resolution. """ - if isinstance(self._center_of_mass, np.ndarray) and not compute: - return self._center_of_mass - self._center_of_mass = self._compute_center_of_mass() - return self._center_of_mass + if shape.func is None: + err_msg = "Cannot build Phase from a Shape without an implicit field" + raise ValueError(err_msg) + actual_bounds = bounds if bounds is not None else shape.bounds + if actual_bounds is None: + err_msg = ( + "Source Shape has no bounds — pass `bounds=` " + "explicitly to Phase.from_shape()" + ) + raise ValueError(err_msg) + return cls( + field=shape.func, + bounds=actual_bounds, + iso=iso, + period=shape.period, + name=name, + resolution=resolution, + ) - center_of_mass = property(get_center_of_mass) + @classmethod + def from_cad( + cls: type[Phase], + cad: Any, + *, + name: str | None = None, + ) -> Phase: + """Construct a CAD-backed :class:`Phase`. - def _compute_center_of_mass(self: Phase) -> npt.NDArray[np.float64]: - """Calculate the center of 'mass' of the phase.""" - _require_ocp() - from OCP.BRepGProp import BRepGProp # noqa: PLC0415 - from OCP.GProp import GProp_GProps # noqa: PLC0415 + ``cad`` may be a :class:`microgen.cad.CadShape` or any duck-typed + object with ``.solids()``, ``.center()``, ``.volume()``, + ``.bounding_box()`` methods (this is the seam that lets a future + ``pyvista-cad`` backend drop in without touching :class:`Phase`). - if self.shape is None: - err_msg = "Cannot compute center of mass on an empty phase" - raise ValueError(err_msg) + :param cad: a CAD shape (``CadShape`` today) + :param name: phase name (auto-generated if omitted) + """ + from .cad import CadShape # noqa: PLC0415 - properties = GProp_GProps() - BRepGProp.VolumeProperties_s(self.shape.wrapped, properties) + if not isinstance(cad, CadShape) and hasattr(cad, "wrapped"): + cad = CadShape(cad.wrapped) - com = properties.CentreOfMass() - return np.array([com.X(), com.Y(), com.Z()]) + instance = cls(name=name) + instance._cad = cad # noqa: SLF001 + return instance - def get_inertia_matrix( - self: Phase, + @classmethod + def from_mesh( + cls: type[Phase], + mesh: pv.PolyData, *, - compute: bool = True, - ) -> npt.NDArray[np.float64]: - """Calculate the inertia matrix of the phase. + name: str | None = None, + ) -> Phase: + """Construct a mesh-backed :class:`Phase` from a closed surface mesh. - :param compute: if False and inertia_matrix already exists, \ - does not compute it (use carefully) + :param mesh: closed triangulated surface + :param name: phase name (auto-generated if omitted) """ - if isinstance(self._inertia_matrix, np.ndarray) and not compute: - return self._inertia_matrix - self._inertia_matrix = self._compute_inertia_matrix() - return self._inertia_matrix + instance = cls(name=name) + instance._surface_mesh = mesh # noqa: SLF001 + return instance - inertia_matrix = property(get_inertia_matrix) + # ------------------------------------------------------------------ + # Read-only accessors + # ------------------------------------------------------------------ - def _compute_inertia_matrix(self: Phase) -> npt.NDArray[np.float64]: - """Calculate the inertia matrix of the phase.""" - _require_ocp() - from OCP.BRepGProp import BRepGProp # noqa: PLC0415 - from OCP.GProp import GProp_GProps # noqa: PLC0415 + @property + def field(self: Phase) -> Field | None: + """The implicit scalar field, or ``None`` for non-field-backed phases.""" + return self._field - if self.shape is None: - err_msg = "Cannot compute inertia matrix on an empty phase" - raise ValueError(err_msg) + @property + def bounds(self: Phase) -> BoundsType | None: + """AABB ``(xmin, xmax, ymin, ymax, zmin, zmax)``. - properties = GProp_GProps() - BRepGProp.VolumeProperties_s(self.shape.wrapped, properties) + For field-backed phases this is set at construction. For CAD- or + mesh-backed phases it's derived from the underlying representation. + """ + if self._bounds is not None: + return self._bounds + if self._cad is not None: + bb = self._cad.bounding_box() + return (bb.xmin, bb.xmax, bb.ymin, bb.ymax, bb.zmin, bb.zmax) + if self._surface_mesh is not None: + xmin, xmax, ymin, ymax, zmin, zmax = self._surface_mesh.bounds + return ( + float(xmin), + float(xmax), + float(ymin), + float(ymax), + float(zmin), + float(zmax), + ) + return None - inm = properties.MatrixOfInertia() - return np.array( - [ - [inm.Value(1, 1), inm.Value(1, 2), inm.Value(1, 3)], - [inm.Value(2, 1), inm.Value(2, 2), inm.Value(2, 3)], - [inm.Value(3, 1), inm.Value(3, 2), inm.Value(3, 3)], - ], - ) + @property + def iso(self: Phase) -> float: + """Iso-value for the implicit field (the solid is ``{field < iso}``).""" + return self._iso @property - def shape(self: Phase) -> CadShape | None: - """Return the shape of the phase as a :class:`~microgen.cad.CadShape`.""" - if self._shape is not None: - return self._shape - if len(self._solids) > 0: - self._shape = make_compound_from_solids(self._solids) - return self._shape - - warnings.warn("No shape or solids", stacklevel=2) - return None + def period(self: Phase) -> PeriodType | None: + """Intrinsic period ``(Lx, Ly, Lz)`` if the field is periodic.""" + return self._period @property - def solids(self: Phase) -> list[TopoDS_Solid]: - """Return the list of OCCT ``TopoDS_Solid`` in the phase.""" - if len(self._solids) > 0: - return self._solids - if self._shape is not None: - self._solids = enumerate_solids(self._shape) - return self._solids - - warnings.warn("No solids or shape", stacklevel=2) - return [] + def resolution(self: Phase) -> int: + """Default sampling resolution for lazy grid/mesh/pieces materialisation.""" + return self._resolution - def translate(self: Phase, vec: Sequence[float]) -> None: - """Translate phase by a given vector (in place).""" - if self._shape is None: - err_msg = "Cannot translate a phase with no shape" - raise ValueError(err_msg) - self._shape = self._shape.translate(vec) - self._center_of_mass = self._compute_center_of_mass() + @property + def is_empty(self: Phase) -> bool: + """True if the phase has no backing representation.""" + return self._field is None and self._cad is None and self._surface_mesh is None - @staticmethod - def rescale_shape( - shape: ShapeLike, - scale: float | tuple[float, float, float], - ) -> CadShape: - """Rescale ``shape`` by ``scale`` = ``(sx, sy, sz)`` (or a scalar). + # ------------------------------------------------------------------ + # CAD materialisation (the pyvista-cad seam lives here) + # ------------------------------------------------------------------ - Preserves the shape's center of mass — scaling is performed about it. - """ - shape = _to_cad_shape(shape) - if isinstance(scale, float): - scale = (scale, scale, scale) - - center = shape.center() - cx, cy, cz = center.x, center.y, center.z - sx, sy, sz = (float(s) for s in scale) - - # Equivalent to: translate(-c) → scale about origin → translate(+c) - # Expressed as a single 3x4 affine matrix for BRepBuilderAPI_GTransform. - matrix = np.array( - [ - [sx, 0.0, 0.0, cx - sx * cx], - [0.0, sy, 0.0, cy - sy * cy], - [0.0, 0.0, sz, cz - sz * cz], - ], - dtype=np.float64, - ) - return transform_geometry(shape, matrix) + def _materialise_cad(self: Phase) -> Any: + """Build a CAD representation from the field (or surface mesh). - def rescale(self: Phase, scale: float | tuple[float, float, float]) -> None: - """Rescale phase (in place) by ``scale = (sx, sy, sz)`` or a scalar.""" - if self._shape is None: - err_msg = "Cannot rescale a phase with no shape" - raise ValueError(err_msg) - self._shape = self.rescale_shape(self._shape, scale) - - @staticmethod - def repeat_shape( - unit_geom: ShapeLike, - rve: Rve, - grid: tuple[int, int, int], - ) -> CadShape: - """Repeat ``unit_geom`` on a ``grid`` within the ``rve`` periodicity cell. - - Returns a :class:`~microgen.cad.CadShape` wrapping an OCCT compound - of translated copies of ``unit_geom``. + This is the **single seam** where :class:`Phase` talks to a CAD + backend. Swapping ``microgen.cad`` for ``pyvista-cad`` later + means rewriting this method only. + + Requires the optional ``[cad]`` extra today. """ - unit_geom = _to_cad_shape(unit_geom) - center = np.array(unit_geom.center().to_tuple()) + if self._field is not None and self._bounds is not None: + # Wrap field+bounds in a transient Shape and call shape_to_cad. + from .cad import shape_to_cad # noqa: PLC0415 + from .shape.shape import Shape # noqa: PLC0415 + + transient = Shape(func=self._field, bounds=self._bounds) + return shape_to_cad( + transient, bounds=self._bounds, resolution=self._resolution + ) + if self._surface_mesh is not None: + from .cad import mesh_to_shape # noqa: PLC0415 + + mesh = self._surface_mesh + if not mesh.is_all_triangles: + mesh = mesh.triangulate() + triangles = mesh.faces.reshape(-1, 4)[:, 1:] + points = np.asarray(mesh.points, dtype=np.float64) + return mesh_to_shape(points, triangles) + err_msg = "Cannot materialise CAD: phase has no field or mesh" + raise ValueError(err_msg) + + @cached_property + def cad(self: Phase) -> Any: + """The CAD representation (``microgen.cad.CadShape`` today, lazy). + + For CAD-backed phases this returns the stored shape. For + field-backed or mesh-backed phases it's lazily materialised via + :meth:`_materialise_cad`. + + Requires the ``[cad]`` extra (raises ``ImportError`` otherwise). + """ + if self._cad is not None: + return self._cad + return self._materialise_cad() - copies: list[TopoDS_Solid] = [] - for idx in np.ndindex(*grid): - pos = center - rve.dim * (0.5 * np.array(grid) - 0.5 - np.array(idx)) - copies.append(translate_solid(unit_geom.wrapped, pos)) - return make_compound_from_solids(copies) + # ------------------------------------------------------------------ + # PyVista views (lazy, per-resolution caches via @cached_property are + # not used here because the resolution kwarg differs per call) + # ------------------------------------------------------------------ + + def grid(self: Phase, resolution: int | None = None) -> pv.StructuredGrid: + """Return a structured grid sampling of the field. - def repeat(self: Phase, rve: Rve, grid: tuple[int, int, int]) -> None: - """Repeat phase in place on a ``grid`` within the ``rve`` periodicity cell.""" - if self.shape is None: - err_msg = "Cannot repeat a phase with no shape" + Only meaningful for field-backed phases. + """ + if self._field is None or self._bounds is None: + err_msg = "grid() requires a field-backed Phase" raise ValueError(err_msg) - self._shape = self.repeat_shape(self.shape, rve, grid) + import pyvista as pv # noqa: PLC0415 + + res = int(resolution) if resolution is not None else self._resolution + xmin, xmax, ymin, ymax, zmin, zmax = self._bounds + xi = np.linspace(xmin, xmax, res) + yi = np.linspace(ymin, ymax, res) + zi = np.linspace(zmin, zmax, res) + x, y, z = np.meshgrid(xi, yi, zi, indexing="ij") + sg = pv.StructuredGrid(x, y, z) + sg[_IMPLICIT_SCALAR] = self._field( + x.ravel(order="F"), y.ravel(order="F"), z.ravel(order="F") + ) + return sg - def split_solids(self: Phase, rve: Rve, grid: list[int]) -> list[TopoDS_Solid]: - """Split solids from phase according to the rve divided by the given grid. + def surface_mesh(self: Phase, resolution: int | None = None) -> pv.PolyData: + """Return a triangulated surface mesh of the solid boundary. - Each solid is split by the (grid-1) interior planes along each axis; - planes are constructed with :func:`microgen.cad.make_plane_face` and - applied through OCCT's ``BRepAlgoAPI_Splitter``. + - Mesh-backed phase: returns the stored mesh. + - Field-backed phase: marching cubes on the sampled grid. + - CAD-backed phase: tessellates via OCCT incremental mesh. + """ + import pyvista as pv # noqa: PLC0415 + + if self._surface_mesh is not None: + return self._surface_mesh + if self._field is not None: + sg = self.grid(resolution) + iso = pv.PolyData( + sg.contour(isosurfaces=[self._iso], scalars=_IMPLICIT_SCALAR) + ) + return iso.clean().triangulate() if iso.n_cells > 0 else pv.PolyData() + if self._cad is not None: + err_msg = ( + "surface_mesh() on a CAD-backed Phase is not implemented yet " + "(would need OCCT BRepMesh_IncrementalMesh tessellation)." + ) + raise NotImplementedError(err_msg) + err_msg = "Cannot build surface_mesh on an empty Phase" + raise ValueError(err_msg) + + def volume_mesh(self: Phase, resolution: int | None = None) -> pv.UnstructuredGrid: + """Return the volumetric cells where ``field < iso``. + + Only meaningful for field-backed phases. + """ + if self._field is None: + err_msg = "volume_mesh() requires a field-backed Phase" + raise ValueError(err_msg) + sg = self.grid(resolution) + return sg.clip_scalar(scalars=_IMPLICIT_SCALAR, value=self._iso, invert=True) - :param rve: RVE divided by the given grid - :param grid: number of divisions in each direction ``[x, y, z]`` + # ------------------------------------------------------------------ + # Pieces — the "Phase = collection of cut/split sub-solids" invariant + # ------------------------------------------------------------------ - :return: list of raw OCCT ``TopoDS_Solid`` + @cached_property + def pieces(self: Phase) -> list[Piece]: + """Connected components of the phase (the "sub-pieces" invariant). + + - CAD-backed phase: one :class:`Piece` per ``TopoDS_Solid``. + - Field-backed phase: ``scipy.ndimage.label`` on + ``grid_field < iso``; one piece per connected component. + - Mesh-backed phase: ``polydata.connectivity().split_bodies()``. """ - result: list[TopoDS_Solid] = [] - for solid in self.solids: - current = CadShape(solid) - for dim in range(3): - direction = tuple(int(dim == i) for i in range(3)) - coords = np.linspace( - start=rve.min_point[dim], - stop=rve.max_point[dim], - num=grid[dim], - endpoint=False, - )[1:] - for pos in coords: - base_pnt = tuple(float(pos) * direction[k] for k in range(3)) - plane = make_plane_face(base_pnt, direction) - current = split_shape(current, plane) - result.extend(enumerate_solids(current)) - return result - - def rasterize( - self: Phase, - rve: Rve, - grid: list[int], - *, - phase_per_raster: bool = True, - ) -> list[Phase] | None: - """Raster solids from phase according to the rve divided by the given grid. + if self._cad is not None: + return self._pieces_from_cad() + if self._field is not None: + return self._pieces_from_field() + if self._surface_mesh is not None: + return self._pieces_from_mesh() + return [] - :param rve: RVE divided by the given grid - :param grid: number of divisions in each direction [x, y, z] - :param phase_per_raster: if True, returns list of phases + def _pieces_from_cad(self: Phase) -> list[Piece]: + from .cad import CadShape # noqa: PLC0415 + + out: list[Piece] = [] + for solid in self._cad.solids(): + wrapped = solid if isinstance(solid, CadShape) else CadShape(solid) + c = wrapped.center() + bb = wrapped.bounding_box() + out.append( + Piece( + com=(float(c.x), float(c.y), float(c.z)), + volume=float(wrapped.volume()), + bounds=(bb.xmin, bb.xmax, bb.ymin, bb.ymax, bb.zmin, bb.zmax), + cad=wrapped, + ) + ) + return out + + def _pieces_from_field(self: Phase) -> list[Piece]: + from scipy.ndimage import center_of_mass, find_objects, label # noqa: PLC0415 + + sg = self.grid() + res = self._resolution + scalar = np.asarray(sg[_IMPLICIT_SCALAR]).reshape((res, res, res), order="F") + inside = scalar < self._iso + labels, n_labels = label(inside) + if n_labels == 0: + return [] + + xmin, xmax, ymin, ymax, zmin, zmax = self._bounds # type: ignore[misc] + dx = (xmax - xmin) / (res - 1) + dy = (ymax - ymin) / (res - 1) + dz = (zmax - zmin) / (res - 1) + cell_volume = dx * dy * dz + + coms = center_of_mass(inside, labels=labels, index=range(1, n_labels + 1)) + slices = find_objects(labels) + + out: list[Piece] = [] + for label_id in range(1, n_labels + 1): + mask = labels == label_id + voxel_count = int(mask.sum()) + com_voxel = coms[label_id - 1] + com_world = ( + xmin + com_voxel[0] * dx, + ymin + com_voxel[1] * dy, + zmin + com_voxel[2] * dz, + ) + sl = slices[label_id - 1] + piece_bounds = ( + xmin + sl[0].start * dx, + xmin + (sl[0].stop - 1) * dx, + ymin + sl[1].start * dy, + ymin + (sl[1].stop - 1) * dy, + zmin + sl[2].start * dz, + zmin + (sl[2].stop - 1) * dz, + ) + out.append( + Piece( + com=com_world, + volume=voxel_count * cell_volume, + bounds=piece_bounds, + voxel_mask=mask, + ) + ) + return out + + def _pieces_from_mesh(self: Phase) -> list[Piece]: + out: list[Piece] = [] + for body in self._surface_mesh.split_bodies(): # type: ignore[union-attr] + poly = body.extract_surface() + com = poly.center_of_mass() + xmin, xmax, ymin, ymax, zmin, zmax = poly.bounds + out.append( + Piece( + com=(float(com[0]), float(com[1]), float(com[2])), + volume=float(poly.volume), + bounds=( + float(xmin), + float(xmax), + float(ymin), + float(ymax), + float(zmin), + float(zmax), + ), + mesh=poly, + ) + ) + return out + + # ------------------------------------------------------------------ + # Moments + # ------------------------------------------------------------------ + + @cached_property + def center_of_mass(self: Phase) -> npt.NDArray[np.float64]: + """Volumetric center of mass. + + For field-backed phases this is computed by quadrature on the + sampled grid (no OCCT needed). For CAD-backed phases this uses + ``BRepGProp.VolumeProperties_s``. + """ + if self._cad is not None: + from OCP.BRepGProp import BRepGProp # noqa: PLC0415 + from OCP.GProp import GProp_GProps # noqa: PLC0415 - :return: list of Phases if required + props = GProp_GProps() + BRepGProp.VolumeProperties_s(self._cad.wrapped, props) + com = props.CentreOfMass() + return np.array([com.X(), com.Y(), com.Z()]) + if self._field is not None: + sg = self.grid() + res = self._resolution + scalar = np.asarray(sg[_IMPLICIT_SCALAR]).reshape( + (res, res, res), order="F" + ) + inside = scalar < self._iso + if not inside.any(): + err_msg = "center_of_mass: field is positive everywhere on the grid" + raise ValueError(err_msg) + pts = np.asarray(sg.points).reshape((res, res, res, 3), order="F") + mask = inside.astype(np.float64) + denom = float(mask.sum()) + com = ( + float((mask * pts[..., 0]).sum() / denom), + float((mask * pts[..., 1]).sum() / denom), + float((mask * pts[..., 2]).sum() / denom), + ) + return np.array(com) + err_msg = "Cannot compute center_of_mass on an empty Phase" + raise ValueError(err_msg) + + @cached_property + def inertia_matrix(self: Phase) -> npt.NDArray[np.float64]: + """Inertia tensor (about the origin) of the phase. + + Field-backed: grid quadrature. CAD-backed: ``BRepGProp``. """ - solids = self.split_solids(rve, grid) + if self._cad is not None: + from OCP.BRepGProp import BRepGProp # noqa: PLC0415 + from OCP.GProp import GProp_GProps # noqa: PLC0415 - if phase_per_raster: - return self.generate_phase_per_raster(solids, rve, grid) - self._solids = solids - self._shape = make_compound_from_solids(self._solids) - return None + props = GProp_GProps() + BRepGProp.VolumeProperties_s(self._cad.wrapped, props) + inm = props.MatrixOfInertia() + return np.array( + [ + [inm.Value(1, 1), inm.Value(1, 2), inm.Value(1, 3)], + [inm.Value(2, 1), inm.Value(2, 2), inm.Value(2, 3)], + [inm.Value(3, 1), inm.Value(3, 2), inm.Value(3, 3)], + ] + ) + if self._field is not None: + sg = self.grid() + res = self._resolution + scalar = np.asarray(sg[_IMPLICIT_SCALAR]).reshape( + (res, res, res), order="F" + ) + inside = scalar < self._iso + if not inside.any(): + err_msg = "inertia_matrix: field is positive everywhere on the grid" + raise ValueError(err_msg) + pts = np.asarray(sg.points).reshape((res, res, res, 3), order="F") + x = pts[..., 0][inside] + y = pts[..., 1][inside] + z = pts[..., 2][inside] + xmin, xmax, ymin, ymax, zmin, zmax = self._bounds # type: ignore[misc] + dx = (xmax - xmin) / (res - 1) + dy = (ymax - ymin) / (res - 1) + dz = (zmax - zmin) / (res - 1) + cell_volume = dx * dy * dz + ixx = float(((y * y + z * z) * cell_volume).sum()) + iyy = float(((x * x + z * z) * cell_volume).sum()) + izz = float(((x * x + y * y) * cell_volume).sum()) + ixy = -float((x * y * cell_volume).sum()) + ixz = -float((x * z * cell_volume).sum()) + iyz = -float((y * z * cell_volume).sum()) + return np.array( + [[ixx, ixy, ixz], [ixy, iyy, iyz], [ixz, iyz, izz]], + dtype=np.float64, + ) + err_msg = "Cannot compute inertia_matrix on an empty Phase" + raise ValueError(err_msg) + + # ------------------------------------------------------------------ + # Immutable transforms + # ------------------------------------------------------------------ + + def translated(self: Phase, offset: Sequence[float]) -> Phase: + """Return a new :class:`Phase` translated by ``offset``.""" + dx, dy, dz = float(offset[0]), float(offset[1]), float(offset[2]) + + if self._field is not None and self._bounds is not None: + f = self._field + new_bounds = ( + self._bounds[0] + dx, + self._bounds[1] + dx, + self._bounds[2] + dy, + self._bounds[3] + dy, + self._bounds[4] + dz, + self._bounds[5] + dz, + ) + return Phase( + field=lambda x, y, z, _f=f, _dx=dx, _dy=dy, _dz=dz: _f( + x - _dx, y - _dy, z - _dz + ), + bounds=new_bounds, + iso=self._iso, + period=self._period, + name=self.name, + resolution=self._resolution, + ) + if self._cad is not None: + new = Phase(name=self.name, resolution=self._resolution) + new._cad = self._cad.translate((dx, dy, dz)) # noqa: SLF001 + return new + err_msg = "Cannot translate an empty Phase" + raise ValueError(err_msg) + + def scaled(self: Phase, factor: float | tuple[float, float, float]) -> Phase: + """Return a new :class:`Phase` scaled by ``factor`` about its center of mass. + + CAD-backed phases use OCCT ``BRepBuilderAPI_GTransform`` about + the BRep center. Field-backed phases compose the field via + ``f'(p) = f((p - c) / s + c) * s_min`` (uniform scale only; a + per-axis scale is not exact for an SDF, so non-uniform factors + rescale the bbox only and leave the field's iso-distance + approximate — caller's responsibility). + """ + if self._cad is not None: + from .cad import transform_geometry # noqa: PLC0415 + + if isinstance(factor, (int, float)): + sx = sy = sz = float(factor) + else: + sx, sy, sz = (float(s) for s in factor) + c = self._cad.center() + cx, cy, cz = c.x, c.y, c.z + matrix = np.array( + [ + [sx, 0.0, 0.0, cx - sx * cx], + [0.0, sy, 0.0, cy - sy * cy], + [0.0, 0.0, sz, cz - sz * cz], + ], + dtype=np.float64, + ) + new = Phase(name=self.name, resolution=self._resolution) + new._cad = transform_geometry(self._cad, matrix) # noqa: SLF001 + return new + if self._field is not None and self._bounds is not None: + if isinstance(factor, (int, float)): + sx = sy = sz = float(factor) + else: + sx, sy, sz = (float(s) for s in factor) + cx, cy, cz = self.center_of_mass.tolist() + f = self._field + new_bounds = ( + cx + (self._bounds[0] - cx) * sx, + cx + (self._bounds[1] - cx) * sx, + cy + (self._bounds[2] - cy) * sy, + cy + (self._bounds[3] - cy) * sy, + cz + (self._bounds[4] - cz) * sz, + cz + (self._bounds[5] - cz) * sz, + ) + s_min = min(sx, sy, sz) + return Phase( + field=lambda x, y, z, _f=f, _sx=sx, _sy=sy, _sz=sz, _cx=cx, _cy=cy, _cz=cz, _sm=s_min: ( + _f( + (x - _cx) / _sx + _cx, + (y - _cy) / _sy + _cy, + (z - _cz) / _sz + _cz, + ) + * _sm + ), + bounds=new_bounds, + iso=self._iso, + period=self._period, + name=self.name, + resolution=self._resolution, + ) + err_msg = "Cannot scale an empty Phase" + raise ValueError(err_msg) + + def tiled(self: Phase, rve: Rve, grid: tuple[int, int, int]) -> Phase: + """Return a new :class:`Phase` periodically tiled on the RVE. + + Builds ``∏ grid`` translated copies of the current phase and + fuses them. Only implemented for CAD-backed phases today (the + field-backed equivalent — domain folding via ``mod`` — lands in + a follow-up; the periodic-shape work in + :mod:`microgen.shape.implicit_ops` already covers it for + :class:`Shape` directly). + """ + if self._cad is None: + err_msg = ( + "Phase.tiled is implemented for CAD-backed phases today; " + "field-backed tiling lives on the source Shape (use " + "microgen.shape.implicit_ops.repeat there)." + ) + raise NotImplementedError(err_msg) + from .cad import ( # noqa: PLC0415 + make_compound_from_solids, + translate_solid, + ) - @classmethod - def generate_phase_per_raster( - cls: type[Phase], - solids: list[TopoDS_Solid], - rve: Rve, - grid: list[int], - ) -> list[Phase]: - """Raster solids into per-grid-cell Phases. + center = np.array(self._cad.center().to_tuple()) + copies = [] + for idx in np.ndindex(*grid): + pos = center - rve.dim * (0.5 * np.array(grid) - 0.5 - np.array(idx)) + copies.append(translate_solid(self._cad.wrapped, pos)) + new = Phase(name=self.name, resolution=self._resolution) + new._cad = make_compound_from_solids(copies) # noqa: SLF001 + return new + + # ------------------------------------------------------------------ + # Misc + # ------------------------------------------------------------------ + + def __repr__(self: Phase) -> str: + kind = ( + "field" + if self._field is not None + else "cad" + if self._cad is not None + else "mesh" + if self._surface_mesh is not None + else "empty" + ) + return f"Phase(name={self.name!r}, kind={kind!r}, bounds={self.bounds})" - :param solids: list of OCCT solids - :param rve: RVE divided by the given grid - :param grid: number of divisions in each direction ``[x, y, z]`` - :return: list of :class:`Phase` - """ - _require_ocp() - from OCP.BRepGProp import BRepGProp # noqa: PLC0415 - from OCP.GProp import GProp_GProps # noqa: PLC0415 - - grid_arr = np.array(grid) - solids_phases: list[list[TopoDS_Solid]] = [ - [] for _ in range(int(np.prod(grid_arr))) - ] - for solid in solids: - props = GProp_GProps() - BRepGProp.VolumeProperties_s(solid, props) - com = props.CentreOfMass() - center = np.array([com.X(), com.Y(), com.Z()]) - i, j, k = np.floor( - grid_arr * (center - rve.min_point) / rve.dim, - ).astype(int) - ind = i + grid_arr[0] * j + grid_arr[0] * grid_arr[1] * k - solids_phases[int(ind)].append(solid) - return [Phase(solids=solids) for solids in solids_phases if len(solids) > 0] +__all__ = ["Phase", "Piece"] diff --git a/microgen/shape/strut_lattice/abstract_lattice.py b/microgen/shape/strut_lattice/abstract_lattice.py index d0a57658..a3d120c4 100644 --- a/microgen/shape/strut_lattice/abstract_lattice.py +++ b/microgen/shape/strut_lattice/abstract_lattice.py @@ -243,7 +243,7 @@ def _generate_cad(self, **_: KwargsGenerateType) -> CadShape: radius=self.strut_radius, ) shape = strut.generate_cad() - list_phases.append(Phase(shape)) + list_phases.append(Phase.from_cad(shape)) if self.strut_joints: for vertex in self.vertices: joint = Sphere( @@ -251,14 +251,14 @@ def _generate_cad(self, **_: KwargsGenerateType) -> CadShape: radius=self.strut_radius, ) shape = joint.generate_cad() - list_phases.append(Phase(shape)) + list_phases.append(Phase.from_cad(shape)) for phase in list_phases: periodic_phase = periodic_split_and_translate(phase=phase, rve=self.rve) list_periodic_phases.append(periodic_phase) lattice = fuse_shapes( - [phase.shape for phase in list_periodic_phases], + [phase.cad for phase in list_periodic_phases], retain_edges=False, ) @@ -325,7 +325,7 @@ def mesh_for_fem( return cached_mesh cad_lattice = self.cad_shape - list_phases = [Phase(cad_lattice)] + list_phases = [Phase.from_cad(cad_lattice)] with ( NamedTemporaryFile(suffix=".step", delete=False) as cad_step_file, diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 286f9513..ea70dd5d 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -22,12 +22,12 @@ def test_mesh_rastered_sphere_must_have_correct_number_of_cells() -> None: grid = [3 for _ in range(3)] phases = raster_phase( - phase=Phase(shape=Sphere(radius=0.5).generate_cad()), + phase=Phase.from_cad(Sphere(radius=0.5).generate_cad()), rve=Rve(), grid=grid, ) compound = make_compound_from_solids( - [solid for phase in phases for solid in phase.solids], + [solid.wrapped for phase in phases for solid in phase.cad.solids()], ) compound.export_step("tests/data/compound.step") diff --git a/tests/test_mesh_periodic.py b/tests/test_mesh_periodic.py index 1a909da6..b796fe13 100644 --- a/tests/test_mesh_periodic.py +++ b/tests/test_mesh_periodic.py @@ -94,7 +94,7 @@ def _generate_cqcompound_octettruss(rve: Rve) -> list[Phase]: height=height[i], radius=radius[i], ) - phase = Phase(shape=elem.generate_cad()) + phase = Phase.from_cad(elem.generate_cad()) phases.append(phase) return [ @@ -110,7 +110,7 @@ def box_homogeneous_unit(rve_unit: Rve) -> tuple[CadShape, list[Phase], Rve]: orientation=(0.0, 0.0, 0.0), dim=(rve_unit.dim[0], rve_unit.dim[1], rve_unit.dim[2]), ).generate_cad() - listcqphases = [Phase(shape=shape)] + listcqphases = [Phase.from_cad(shape)] return (shape, listcqphases, rve_unit) @@ -124,7 +124,7 @@ def box_homogeneous_double( orientation=(0.0, 0.0, 0.0), dim=tuple(rve_double.dim), ).generate_cad() - listcqphases = [Phase(shape=shape)] + listcqphases = [Phase.from_cad(shape)] return (shape, listcqphases, rve_double) @@ -138,7 +138,7 @@ def box_homogeneous_double_centered( orientation=(0.0, 0.0, 0.0), dim=tuple(rve_double_centered.dim), ).generate_cad() - listcqphases = [Phase(shape=shape)] + listcqphases = [Phase.from_cad(shape)] return (shape, listcqphases, rve_double_centered) @@ -149,10 +149,10 @@ def octet_truss_homogeneous_unit( """Return a homogeneous unit octet truss.""" list_periodic_phases = _generate_cqcompound_octettruss(rve_unit) merged = fuse_shapes( - [phase.shape for phase in list_periodic_phases], + [phase.cad for phase in list_periodic_phases], retain_edges=False, ) - listcqphases = [Phase(shape=merged)] + listcqphases = [Phase.from_cad(merged)] return (merged, listcqphases, rve_unit) @@ -163,10 +163,10 @@ def octet_truss_homogeneous_double_centered( """Return a homogeneous double centered octet truss.""" list_periodic_phases = _generate_cqcompound_octettruss(rve_double_centered) merged = fuse_shapes( - [phase.shape for phase in list_periodic_phases], + [phase.cad for phase in list_periodic_phases], retain_edges=False, ) - listcqphases = [Phase(shape=merged)] + listcqphases = [Phase.from_cad(merged)] return (merged, listcqphases, rve_double_centered) @@ -178,7 +178,7 @@ def octet_truss_heterogeneous( list_periodic_phases = _generate_cqcompound_octettruss(rve_unit) listcqphases = cut_phases(phases=list_periodic_phases, reverse_order=False) return ( - make_compound_from_solids([phase.shape.wrapped for phase in listcqphases]), + make_compound_from_solids([phase.cad.wrapped for phase in listcqphases]), listcqphases, rve_unit, ) diff --git a/tests/test_periodic.py b/tests/test_periodic.py index 12366708..cbf77f34 100644 --- a/tests/test_periodic.py +++ b/tests/test_periodic.py @@ -3,7 +3,6 @@ import pytest from microgen import Phase, Rve, periodic_split_and_translate, shape -from microgen.cad import CadShape # ruff: noqa: S101 assert https://docs.astral.sh/ruff/rules/assert/ # ruff: noqa: E501 line-too-long https://docs.astral.sh/ruff/rules/line-too-long/ @@ -17,7 +16,7 @@ def _generate_sphere(x: float, y: float, z: float, rve: Rve) -> Phase: """Generate a sphere at the given position and test periodicity.""" elem = shape.sphere.Sphere(center=(x, y, z), radius=0.1) - phase = Phase(shape=elem.generate_cad()) + phase = Phase.from_cad(elem.generate_cad()) return periodic_split_and_translate(phase=phase, rve=rve) @@ -26,7 +25,7 @@ def test_periodic_generates_warning_on_intersection_with_opposite_faces() -> Non rve = Rve(dim=1, center=(0.5, 0.5, 0.5)) elem = shape.capsule.Capsule(center=(0.5, 0, 0.5), height=1, radius=0.1) - phase = Phase(shape=elem.generate_cad()) + phase = Phase.from_cad(elem.generate_cad()) expected_warning_msg = r"Object intersecting ([xyz])\+ and ([xyz])\- faces: not doing anything in this direction" with pytest.warns(UserWarning, match=expected_warning_msg): @@ -37,7 +36,7 @@ def test_periodic_when_no_intersection() -> None: """Test that the periodic function does not raise an error when there is no intersection.""" rve = Rve(dim=1, center=(0.5, 0.5, 0.5)) phase = _generate_sphere(x=0.5, y=0.5, z=0.5, rve=rve) - assert len(phase.solids) == N_PARTS_NO_INTERSECTION + assert len(phase.pieces) == N_PARTS_NO_INTERSECTION @pytest.mark.parametrize( @@ -55,7 +54,7 @@ def test_periodic_when_intersection_with_one_face(x: float, y: float, z: float) """Test that the periodic function does not raise an error when there is an intersection with one face.""" rve = Rve(dim=1, center=(0.5, 0.5, 0.5)) phase = _generate_sphere(x=x, y=y, z=z, rve=rve) - assert len(phase.solids) == N_PARTS_ON_FACE + assert len(phase.pieces) == N_PARTS_ON_FACE @pytest.mark.parametrize( @@ -79,7 +78,7 @@ def test_periodic_when_intersection_with_one_edge(x: float, y: float, z: float) """Test that the periodic function does not raise an error when there is an intersection with one face.""" rve = Rve(dim=1, center=(0.5, 0.5, 0.5)) phase = _generate_sphere(x=x, y=y, z=z, rve=rve) - assert len(phase.solids) == N_PARTS_ON_EDGE + assert len(phase.pieces) == N_PARTS_ON_EDGE @pytest.mark.parametrize( @@ -104,7 +103,7 @@ def test_periodic_when_intersection_with_one_corner( rve = Rve(dim=1, center=(0.5, 0.5, 0.5)) phase = _generate_sphere(x=x, y=y, z=z, rve=rve) - assert len(phase.solids) == N_PARTS_ON_CORNER + assert len(phase.pieces) == N_PARTS_ON_CORNER # Volume- and bounds-based regression checks for the OCP-direct rewrite of @@ -122,9 +121,8 @@ def test_periodic_when_intersection_with_one_corner( def _total_volume(phase: Phase) -> float: - # phase.solids is a list of raw TopoDS_Solid; wrap each so we can call - # the CadShape helpers (Volume / BoundingBox). - return sum(float(CadShape(s).volume()) for s in phase.solids) + # ``phase.pieces`` carries the per-sub-solid volume directly. + return sum(piece.volume for piece in phase.pieces) def _all_solids_inside(phase: Phase, rve: Rve, tol: float = 1e-3) -> bool: @@ -133,15 +131,15 @@ def _all_solids_inside(phase: Phase, rve: Rve, tol: float = 1e-3) -> bool: # box. A misplaced fragment from a sign error would be off by O(rve.dim), # so 1e-3 is loose enough for OCCT noise yet tight enough to catch the # bug class this test targets. - for solid in phase.solids: - bb = CadShape(solid).bounding_box() + for piece in phase.pieces: + xmin, xmax, ymin, ymax, zmin, zmax = piece.bounds if ( - bb.xmin < rve.min_point[0] - tol - or bb.xmax > rve.max_point[0] + tol - or bb.ymin < rve.min_point[1] - tol - or bb.ymax > rve.max_point[1] + tol - or bb.zmin < rve.min_point[2] - tol - or bb.zmax > rve.max_point[2] + tol + xmin < rve.min_point[0] - tol + or xmax > rve.max_point[0] + tol + or ymin < rve.min_point[1] - tol + or ymax > rve.max_point[1] + tol + or zmin < rve.min_point[2] - tol + or zmax > rve.max_point[2] + tol ): return False return True @@ -180,7 +178,7 @@ def test_periodic_split_conserves_volume_and_stays_inside_rve( phase = _generate_sphere(x=x, y=y, z=z, rve=rve) - assert len(phase.solids) == expected_parts + assert len(phase.pieces) == expected_parts assert _all_solids_inside(phase, rve), ( f"At least one fragment lies outside the RVE for seed ({x}, {y}, {z}); " "this typically means a translate(...) call moved the wrong half." diff --git a/tests/test_phase.py b/tests/test_phase.py index 82767dd6..a9657f6c 100644 --- a/tests/test_phase.py +++ b/tests/test_phase.py @@ -1,4 +1,11 @@ -"""Test for the phase module.""" +"""Tests for the Phase 2.0 module. + +Phase is now field-first (implicit-first) with CAD as an optional view. +Construction goes through ``Phase.from_cad`` for CAD-backed phases and +``Phase.from_shape`` for field-backed ones. Transforms are immutable — +``translated`` / ``scaled`` / ``tiled`` return new :class:`Phase` +instances rather than mutating in place. +""" import numpy as np @@ -9,110 +16,129 @@ def test_phase_sphere_rasterize_must_have_correct_number_of_solids() -> None: - """Rasterize Sphere by 3x3x3 grid must have 27 solids.""" + """Rasterize Sphere by 3x3x3 grid must have 27 phases / 27 sub-solids.""" rve = Rve(dim=1) sphere = Sphere(radius=0.5).generate_cad() grid = [3 for _ in range(3)] - phase = Phase(shape=sphere) - phases = phase.rasterize(rve=rve, grid=grid) + phase = Phase.from_cad(sphere) + phases = raster_phase(phase=phase, rve=rve, grid=grid) raster = raster_phase(phase=phase, rve=rve, grid=grid, phase_per_raster=False) assert len(phases) == np.prod(grid) - assert len(phases) == len(raster.solids) + assert len(raster.cad.solids()) == np.prod(grid) def test_phase_rasterize_phase_per_raster_should_return_the_right_object() -> None: - """Test the Phase class with a shape.""" + """Test rastering via the operations free function.""" rve = Rve(dim=1) sphere = Sphere(radius=0.5).generate_cad() - phase = Phase(shape=sphere) + phase = Phase.from_cad(sphere) grid = [3, 3, 3] - phases = phase.rasterize(rve, grid, phase_per_raster=True) + phases = raster_phase(phase=phase, rve=rve, grid=grid, phase_per_raster=True) assert isinstance(phases, list) assert len(phases) == np.prod(grid) grid = [2, 2, 2] - phase.rasterize(rve, grid, phase_per_raster=False) - assert len(phase.solids) == np.prod(grid) + rastered = raster_phase(phase=phase, rve=rve, grid=grid, phase_per_raster=False) + assert len(rastered.cad.solids()) == np.prod(grid) -def test_phase_repeat_should_repeat_the_shape_in_the_rve() -> None: - """Test the Phase class with a shape.""" +def test_phase_tiled_should_repeat_the_shape_in_the_rve() -> None: + """Phase.tiled returns a new Phase with the geometry tiled on a grid.""" rve = Rve(dim=1) box = Box(dim=(1.0, 1.0, 1.0)).generate_cad() volume_before = box.volume() - phase = Phase(shape=box) + phase = Phase.from_cad(box) repeat = (1, 2, 1) - phase.repeat(rve, grid=repeat) - assert len(phase.solids) == np.prod(repeat) - assert phase.shape.volume() == 2.0 * volume_before + tiled = phase.tiled(rve, grid=repeat) + assert len(tiled.cad.solids()) == np.prod(repeat) + assert np.isclose(tiled.cad.volume(), 2.0 * volume_before) -def test_phase_rescale_should_change_the_size_of_the_shape() -> None: - """Test the Phase class with a shape.""" +def test_phase_scaled_should_change_the_size_of_the_shape() -> None: + """Phase.scaled returns a new Phase with cubed volume.""" radius = 1.0 scale = 1.5 - phase = Phase(shape=Sphere(radius=radius).generate_cad()) - volume_before = phase.shape.volume() - phase.rescale(scale) - assert np.isclose(phase.shape.volume(), volume_before * scale**3, rtol=1e-2) + phase = Phase.from_cad(Sphere(radius=radius).generate_cad()) + volume_before = phase.cad.volume() + scaled = phase.scaled(scale) + assert np.isclose(scaled.cad.volume(), volume_before * scale**3, rtol=1e-2) -def test_phase_translate_should_shift_centers_corresponding_to_the_translation() -> ( - None -): - """Test the Phase class with a shape.""" +def test_phase_translated_should_shift_centers() -> None: + """Phase.translated returns a new Phase with the COM shifted.""" center = (1.0, 0.5, -0.5) - phase = Phase( - shape=Ellipsoid(center=center, radii=(0.15, 0.31, 0.4)).generate_cad() + phase = Phase.from_cad( + Ellipsoid(center=center, radii=(0.15, 0.31, 0.4)).generate_cad() ) - phase.translate((1, 0, 0)) - assert np.allclose(phase.center_of_mass, (2.0, 0.5, -0.5), rtol=1e-4) - phase.translate(np.array([0, 1, 1])) - assert np.allclose(phase.center_of_mass, (2.0, 1.5, 0.5), rtol=1e-4) + moved = phase.translated((1, 0, 0)) + assert np.allclose(moved.center_of_mass, (2.0, 0.5, -0.5), rtol=1e-4) + moved2 = moved.translated(np.array([0, 1, 1])) + assert np.allclose(moved2.center_of_mass, (2.0, 1.5, 0.5), rtol=1e-4) def test_phase_center_of_mass_should_return_the_right_values() -> None: - """Test the Phase class with a shape.""" + """``phase.center_of_mass`` is the BREP volumetric COM.""" center = (1.0, 0.5, -0.5) - phase = Phase( - shape=Ellipsoid(center=center, radii=(0.15, 0.31, 0.4)).generate_cad() + phase = Phase.from_cad( + Ellipsoid(center=center, radii=(0.15, 0.31, 0.4)).generate_cad() ) assert np.allclose(phase.center_of_mass, center, rtol=1e-4) - assert np.allclose( - phase.get_center_of_mass(compute=False), - phase.center_of_mass, - rtol=1e-4, - ) + # Cached: accessing twice yields the same array object. + assert phase.center_of_mass is phase.center_of_mass def test_phase_inertia_matrix_should_return_the_right_values() -> None: - """Test the Phase class with a shape.""" + """Inertia tensor of a uniform sphere is ``(2/5) m R²`` on the diagonal.""" radius = 1.5 - phase = Phase(shape=Sphere(radius=radius).generate_cad()) + phase = Phase.from_cad(Sphere(radius=radius).generate_cad()) assert np.allclose( phase.inertia_matrix, - np.diag([phase.shape.volume() * 2 / 5 * radius**2] * 3), + np.diag([phase.cad.volume() * 2 / 5 * radius**2] * 3), ) - assert np.allclose(phase.get_inertia_matrix(compute=False), phase.inertia_matrix) + # Cached. + assert phase.inertia_matrix is phase.inertia_matrix -def test_phase_solids_and_shape_properties_should_return_the_right_values() -> None: - """Test the Phase class with a shape.""" +def test_phase_cad_and_pieces_properties() -> None: + """``phase.cad`` exposes the CAD shape; ``phase.pieces`` enumerates solids.""" ellipsoid = Ellipsoid(radii=(0.15, 0.31, 0.4)).generate_cad() - phase = Phase(shape=ellipsoid) - # Phase.solids returns raw TopoDS_Solid objects; identity-by-`==` doesn't - # apply, so check topological sameness against the wrapped solid instead. - assert len(phase.solids) == 1 - assert phase.solids[0].IsSame(ellipsoid.wrapped) - assert phase.shape is ellipsoid + phase = Phase.from_cad(ellipsoid) + assert phase.cad is ellipsoid + pieces = phase.pieces + assert len(pieces) == 1 + # The Piece carries its CAD payload. + assert pieces[0].cad is not None + assert pieces[0].volume > 0.0 -def test_phase_empty_should_have_empty_shape_and_solids() -> None: - """Test the Phase class with no shape.""" +def test_phase_empty_is_reported_as_such() -> None: + """``Phase()`` (no field/cad/mesh) is empty.""" void_phase = Phase() - assert void_phase.shape is None - assert void_phase.solids == [] + assert void_phase.is_empty + assert void_phase.field is None + assert void_phase.bounds is None + + +def test_phase_from_shape_field_backed() -> None: + """``Phase.from_shape`` builds a field-backed phase from an implicit Shape.""" + sphere = Sphere(radius=0.5) + phase = Phase.from_shape(sphere) + assert phase.field is not None + assert phase.bounds is not None + # Center of mass via grid quadrature on a centered sphere ≈ origin. + assert np.allclose(phase.center_of_mass, (0.0, 0.0, 0.0), atol=2e-2) + # The sphere is one connected piece. + assert len(phase.pieces) == 1 + + +def test_phase_from_shape_propagates_period() -> None: + """When the source Shape has a period, the Phase carries it.""" + from microgen import Tpms, surface_functions + + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.5, cell_size=1.0) + phase = Phase.from_shape(tpms) + assert phase.period == (1.0, 1.0, 1.0) diff --git a/tests/test_phase_pieces.py b/tests/test_phase_pieces.py new file mode 100644 index 00000000..cbc96347 --- /dev/null +++ b/tests/test_phase_pieces.py @@ -0,0 +1,82 @@ +"""Tests for the ``Phase.pieces`` invariant — the "collection of sub-pieces". + +A :class:`Phase` is conceptually "one labelled material region", but +practically it may split into multiple sub-pieces under periodicity or +rasterisation. :attr:`Phase.pieces` surfaces that structure uniformly +across backends: + +- field-backed: ``scipy.ndimage.label`` on ``{field < iso}``. +- CAD-backed: one :class:`Piece` per ``TopoDS_Solid``. +""" + +import numpy as np + +from microgen import Phase, Sphere +from microgen.phase import Piece +from microgen.shape.implicit_ops import union + +# ruff: noqa: S101 + + +def test_pieces_field_single_sphere() -> None: + """A single sphere has exactly one piece.""" + phase = Phase.from_shape(Sphere(radius=0.4)) + pieces = phase.pieces + assert len(pieces) == 1 + p = pieces[0] + assert isinstance(p, Piece) + assert p.voxel_mask is not None + assert p.cad is None + # The volume estimate is correct up to grid resolution. + expected = (4.0 / 3.0) * np.pi * 0.4**3 + assert np.isclose(p.volume, expected, rtol=0.05) + + +def test_pieces_field_two_disjoint_spheres() -> None: + """Two disjoint spheres produce two connected components. + + Built by SDF union — the union field is negative inside *either* + sphere; the two solid regions are spatially disjoint, so + ``scipy.ndimage.label`` yields two components. + """ + a = Sphere(center=(-0.6, 0.0, 0.0), radius=0.2) + b = Sphere(center=(0.6, 0.0, 0.0), radius=0.2) + merged = union(a, b) + # The unioned shape's auto-bounds is the AABB of the two — explicitly + # bound the phase to span both spheres. + phase = Phase.from_shape(merged, bounds=(-1.0, 1.0, -0.4, 0.4, -0.4, 0.4)) + pieces = phase.pieces + assert len(pieces) == 2 + coms = sorted(p.com[0] for p in pieces) + assert np.isclose(coms[0], -0.6, atol=0.05) + assert np.isclose(coms[1], 0.6, atol=0.05) + + +def test_pieces_cad_single_sphere() -> None: + """A CAD-backed sphere has one piece carrying a CadShape payload.""" + cad = Sphere(radius=0.5).generate_cad() + phase = Phase.from_cad(cad) + pieces = phase.pieces + assert len(pieces) == 1 + assert pieces[0].cad is not None + assert pieces[0].voxel_mask is None + + +def test_pieces_cached() -> None: + """``phase.pieces`` is a ``@cached_property`` — second access is the same list.""" + phase = Phase.from_shape(Sphere(radius=0.4)) + assert phase.pieces is phase.pieces + + +def test_phase_translated_preserves_period_and_invalidates_cache() -> None: + """``translated()`` returns a new Phase — pieces and COM are recomputed.""" + a = Sphere(center=(-0.6, 0.0, 0.0), radius=0.2) + b = Sphere(center=(0.6, 0.0, 0.0), radius=0.2) + merged = union(a, b) + phase = Phase.from_shape(merged, bounds=(-1.0, 1.0, -0.4, 0.4, -0.4, 0.4)) + moved = phase.translated((1.0, 0.0, 0.0)) + # New Phase => caches independent; both still report 2 pieces. + assert len(moved.pieces) == 2 + shifts = sorted(p.com[0] for p in moved.pieces) + assert np.isclose(shifts[0], -0.6 + 1.0, atol=0.05) + assert np.isclose(shifts[1], 0.6 + 1.0, atol=0.05) diff --git a/tests/test_sample.py b/tests/test_sample.py index 4b54bb60..7a55d5f4 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -29,7 +29,7 @@ def test_operations() -> None: """Test operations on shapes.""" elem = microgen.Box(center=(0.5, 0.5, 0.5), dim=(1, 1, 1)) shape1 = elem.generate_cad() - phase1 = microgen.Phase(shape=shape1) + phase1 = microgen.Phase.from_cad(shape1) elem = microgen.Box(center=(0, 0, 0), dim=(0.5, 0.5, 0.5)) shape2 = elem.generate_cad()