From 60939e8c31392ada6a812f112a92183baa0fb848 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Thu, 16 Apr 2026 15:53:14 +0200 Subject: [PATCH 01/15] Add implicit F-rep ops and Shape support Introduce F-rep implicit operations and integrate implicit-field support into Shape. Adds new module microgen.shape.implicit_ops (boolean, smooth blending, batch_smooth_union, repeat, shell, blend, from_field, etc.) and comprehensive tests. Refactors microgen/shape/shape.py to carry optional implicit fields, provide evaluation, default generate_vtk/generate via marching-cubes -> CadQuery, boolean operators, smooth booleans, and transform helpers (translate/rotate/scale). Updates package exports and docs to expose implicit_ops and related helpers; re-exports ShellCreationError from tpms for backward compatibility. --- docs/microgen.shape.rst | 5 + microgen/__init__.py | 6 + microgen/shape/__init__.py | 6 +- microgen/shape/implicit_ops.py | 305 ++++++++++++++++++++++ microgen/shape/shape.py | 322 +++++++++++++++++++++-- microgen/shape/tpms.py | 8 +- tests/shapes/test_implicit_ops.py | 420 ++++++++++++++++++++++++++++++ 7 files changed, 1045 insertions(+), 27 deletions(-) create mode 100644 microgen/shape/implicit_ops.py create mode 100644 tests/shapes/test_implicit_ops.py diff --git a/docs/microgen.shape.rst b/docs/microgen.shape.rst index 0a0cc052..9d0c532c 100644 --- a/docs/microgen.shape.rst +++ b/docs/microgen.shape.rst @@ -52,6 +52,11 @@ microgen.shape package :undoc-members: :show-inheritance: +.. automodule:: microgen.shape.implicit_ops + :members: + :undoc-members: + :show-inheritance: + .. automodule:: microgen.shape.surface_functions :members: :undoc-members: diff --git a/microgen/__init__.py b/microgen/__init__.py index e5f66939..ff5e9d7d 100644 --- a/microgen/__init__.py +++ b/microgen/__init__.py @@ -60,6 +60,9 @@ TruncatedCube, TruncatedCuboctahedron, TruncatedOctahedron, + batch_smooth_union, + from_field, + implicit_ops, new_geometry, newGeometry, surface_functions, @@ -100,6 +103,9 @@ "TruncatedOctahedron", "CylindricalTpms", "SphericalTpms", + "batch_smooth_union", + "from_field", + "implicit_ops", "check_if_only_linear_tetrahedral", "cut_phase_by_shape_list", "cutPhaseByShapeList", diff --git a/microgen/shape/__init__.py b/microgen/shape/__init__.py index d7fc11c3..ff74044c 100644 --- a/microgen/shape/__init__.py +++ b/microgen/shape/__init__.py @@ -21,13 +21,14 @@ from typing import TYPE_CHECKING, Callable, Literal, Sequence, Tuple -from . import surface_functions +from . import implicit_ops, surface_functions from .box import Box from .capsule import Capsule from .cylinder import Cylinder from .ellipsoid import Ellipsoid from .extruded_polygon import ExtrudedPolygon from .polyhedron import Polyhedron +from .implicit_ops import batch_smooth_union, from_field from .shape import Shape from .sphere import Sphere from .strut_lattice import ( @@ -161,6 +162,9 @@ def __init__(self: ShapeError, shape: str) -> None: "ExtrudedPolygon", "FaceCenteredCubic", "Infill", + "batch_smooth_union", + "from_field", + "implicit_ops", "NormedDistance", "Octahedron", "OctetTruss", diff --git a/microgen/shape/implicit_ops.py b/microgen/shape/implicit_ops.py new file mode 100644 index 00000000..ec9a3c18 --- /dev/null +++ b/microgen/shape/implicit_ops.py @@ -0,0 +1,305 @@ +"""F-rep Implicit Operations. + +========================================================== +Implicit Operations (:mod:`microgen.shape.implicit_ops`) +========================================================== + +Module-level boolean, blending, and utility operations for shapes +that carry an implicit scalar field (``_func``). All functions accept +and return :class:`~microgen.shape.shape.Shape` instances. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import numpy.typing as npt + +if TYPE_CHECKING: + from .shape import BoundsType, Field, Shape + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _make_shape(func: Field, bounds: BoundsType | None) -> Shape: + """Create a Shape with an implicit field (single deferred import).""" + from .shape import Shape # noqa: PLC0415 + + return Shape(func=func, bounds=bounds) + + +def _smooth_min( + a: npt.NDArray[np.float64], + b: npt.NDArray[np.float64], + k: float, +) -> npt.NDArray[np.float64]: + """Smooth minimum (Inigo Quilez cubic polynomial).""" + if k <= 0: + return np.minimum(a, b) + h = np.maximum(k - np.abs(a - b), 0.0) / k + return np.minimum(a, b) - h * h * h * k / 6.0 + + +def _smooth_max( + a: npt.NDArray[np.float64], + b: npt.NDArray[np.float64], + k: float, +) -> npt.NDArray[np.float64]: + """Smooth maximum.""" + return -_smooth_min(-a, -b, k) + + +def _merge_bounds( + a: BoundsType | None, + b: BoundsType | None, + mode: str = "union", +) -> BoundsType | None: + """Merge two bounding boxes.""" + if a is None and b is None: + return None + if a is None: + return b + if b is None: + return a + if mode == "union": + return ( + min(a[0], b[0]), + max(a[1], b[1]), + min(a[2], b[2]), + max(a[3], b[3]), + min(a[4], b[4]), + max(a[5], b[5]), + ) + # intersection + return ( + max(a[0], b[0]), + min(a[1], b[1]), + max(a[2], b[2]), + min(a[3], b[3]), + max(a[4], b[4]), + min(a[5], b[5]), + ) + + +# --------------------------------------------------------------------------- +# Unary operations +# --------------------------------------------------------------------------- + + +def complement(a: Shape) -> Shape: + """Complement (negate the field): inside becomes outside and vice versa.""" + f = a.require_func() + return _make_shape( + func=lambda x, y, z, _f=f: -_f(x, y, z), + bounds=a.bounds, + ) + + +# --------------------------------------------------------------------------- +# Hard boolean operations +# --------------------------------------------------------------------------- + + +def union(a: Shape, b: Shape) -> Shape: + """Union of two shapes (hard boolean).""" + fa, fb = a.require_func(), b.require_func() + return _make_shape( + func=lambda x, y, z, _fa=fa, _fb=fb: np.minimum(_fa(x, y, z), _fb(x, y, z)), + bounds=_merge_bounds(a.bounds, b.bounds, "union"), + ) + + +def intersection(a: Shape, b: Shape) -> Shape: + """Intersection of two shapes (hard boolean).""" + fa, fb = a.require_func(), b.require_func() + return _make_shape( + func=lambda x, y, z, _fa=fa, _fb=fb: np.maximum(_fa(x, y, z), _fb(x, y, z)), + bounds=_merge_bounds(a.bounds, b.bounds, "intersection"), + ) + + +def difference(a: Shape, b: Shape) -> Shape: + """Difference of two shapes (a minus b).""" + fa, fb = a.require_func(), b.require_func() + return _make_shape( + func=lambda x, y, z, _fa=fa, _fb=fb: np.maximum( + _fa(x, y, z), -_fb(x, y, z), + ), + bounds=a.bounds, + ) + + +# --------------------------------------------------------------------------- +# Smooth boolean operations +# --------------------------------------------------------------------------- + + +def smooth_union(a: Shape, b: Shape, k: float) -> Shape: + """Smooth union with blending radius *k*.""" + fa, fb = a.require_func(), b.require_func() + return _make_shape( + func=lambda x, y, z, _fa=fa, _fb=fb, _k=k: _smooth_min( + _fa(x, y, z), _fb(x, y, z), _k, + ), + bounds=_merge_bounds(a.bounds, b.bounds, "union"), + ) + + +def smooth_intersection(a: Shape, b: Shape, k: float) -> Shape: + """Smooth intersection with blending radius *k*.""" + fa, fb = a.require_func(), b.require_func() + return _make_shape( + func=lambda x, y, z, _fa=fa, _fb=fb, _k=k: _smooth_max( + _fa(x, y, z), _fb(x, y, z), _k, + ), + bounds=_merge_bounds(a.bounds, b.bounds, "intersection"), + ) + + +def smooth_difference(a: Shape, b: Shape, k: float) -> Shape: + """Smooth difference (a minus b) with blending radius *k*.""" + fa, fb = a.require_func(), b.require_func() + return _make_shape( + func=lambda x, y, z, _fa=fa, _fb=fb, _k=k: _smooth_max( + _fa(x, y, z), -_fb(x, y, z), _k, + ), + bounds=a.bounds, + ) + + +# --------------------------------------------------------------------------- +# Batch operations +# --------------------------------------------------------------------------- + + +def batch_smooth_union( + shapes: list[Shape], + k: float = 0.0, +) -> Shape: + """Combine many shapes with smooth union in a flat loop (no recursion). + + This avoids the recursion-depth limit that arises when chaining hundreds + of binary ``smooth_union`` calls, each wrapping the previous in a lambda. + """ + if not shapes: + msg = "batch_smooth_union requires at least one shape" + raise ValueError(msg) + + funcs = [s.require_func() for s in shapes] + + merged = shapes[0].bounds + for s in shapes[1:]: + merged = _merge_bounds(merged, s.bounds, "union") + + def _batched( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + if k > 0: + result = funcs[0](x, y, z) + for fn in funcs[1:]: + result = _smooth_min(result, fn(x, y, z), k) + return result + # Hard union: vectorized reduction + all_fields = np.stack([fn(x, y, z) for fn in funcs], axis=0) + return np.min(all_fields, axis=0) + + return _make_shape(func=_batched, bounds=merged) + + +# --------------------------------------------------------------------------- +# Utility operations +# --------------------------------------------------------------------------- + + +def shell(shape: Shape, thickness: float) -> Shape: + """Hollow shell: ``|f(p)| - thickness / 2``.""" + f = shape.require_func() + half_t = thickness / 2.0 + return _make_shape( + func=lambda x, y, z, _f=f, _ht=half_t: np.abs(_f(x, y, z)) - _ht, + bounds=shape.bounds, + ) + + +def repeat( + shape: Shape, + spacing: tuple[float, float, float], + k: float = 0.0, +) -> Shape: + """Infinite repetition via coordinate modulo. + + :param shape: unit cell shape to tile + :param spacing: ``(sx, sy, sz)`` repetition period per axis + :param k: smooth blending radius across cell boundaries. + When ``k > 0``, the base field is evaluated at the 26 neighboring + periodic images in addition to the current cell and all values + are combined with smooth minimum, so that primitives from adjacent + cells blend seamlessly. When ``k <= 0`` (default), a simple + coordinate-modulo repetition is used (hard tiling). + """ + sx, sy, sz = spacing + f = shape.require_func() + + if k <= 0: + + def _repeated( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + rx = np.mod(x + sx / 2, sx) - sx / 2 + ry = np.mod(y + sy / 2, sy) - sy / 2 + rz = np.mod(z + sz / 2, sz) - sz / 2 + return f(rx, ry, rz) + + else: + offsets = [ + (dx * sx, dy * sy, dz * sz) + for dx in (-1, 0, 1) + for dy in (-1, 0, 1) + for dz in (-1, 0, 1) + ] + + def _repeated( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + cx = np.mod(x + sx / 2, sx) - sx / 2 + cy = np.mod(y + sy / 2, sy) - sy / 2 + cz = np.mod(z + sz / 2, sz) - sz / 2 + result = f(cx + offsets[0][0], cy + offsets[0][1], cz + offsets[0][2]) + for ox, oy, oz in offsets[1:]: + result = _smooth_min(result, f(cx + ox, cy + oy, cz + oz), k) + return result + + return _make_shape(func=_repeated, bounds=None) + + +def blend( + a: Shape, + b: Shape, + factor: float = 0.5, +) -> Shape: + """Linear interpolation between two fields: ``(1-t)*a + t*b``.""" + fa, fb = a.require_func(), b.require_func() + t = factor + return _make_shape( + func=lambda x, y, z, _fa=fa, _fb=fb, _t=t: (1.0 - _t) * _fa(x, y, z) + + _t * _fb(x, y, z), + bounds=_merge_bounds(a.bounds, b.bounds, "union"), + ) + + +def from_field( + func: Field, + bounds: BoundsType | None = None, +) -> Shape: + """Wrap any callable ``f(x, y, z) -> scalar`` as a Shape with an implicit field.""" + return _make_shape(func=func, bounds=bounds) diff --git a/microgen/shape/shape.py b/microgen/shape/shape.py index 11602962..24b98fc9 100644 --- a/microgen/shape/shape.py +++ b/microgen/shape/shape.py @@ -7,30 +7,59 @@ from __future__ import annotations -from abc import ABC, abstractmethod +import itertools +from collections.abc import Callable from typing import TYPE_CHECKING +import numpy as np +import numpy.typing as npt +import pyvista as pv from scipy.spatial.transform import Rotation +from microgen.operations import rotate as rotate_mesh + if TYPE_CHECKING: import cadquery as cq - import pyvista as pv 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] + + +class ShellCreationError(Exception): + """Raised when a CadQuery shell cannot be created from a mesh.""" -class Shape(ABC): - """Shape class to manage shapes. - :param shape: name of the shape - :param center: center - :param orientation: orientation +class Shape: + """Unified shape with optional implicit (F-rep) and CAD representations. + + Every shape has a ``center`` and ``orientation``. It may also carry an + implicit scalar field (``_func``) where ``f(x, y, z) < 0`` means *inside*. + When the implicit field is present, the default :meth:`generate_vtk` and + :meth:`generate` produce geometry via marching cubes. Subclasses + (e.g. ``Sphere``, ``Tpms``) override these methods with their own + implementations. + + Boolean operators (``|``, ``&``, ``-``, ``~``) and smooth boolean + methods operate on the implicit field and return a new :class:`Shape`. + + :param center: center of the 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`` """ def __init__( self: Shape, center: Vector3DType = (0, 0, 0), orientation: Vector3DType | Rotation = (0, 0, 0), + func: Field | None = None, + bounds: BoundsType | None = None, ) -> None: """Initialize the shape.""" self.center = center @@ -39,24 +68,275 @@ def __init__( if isinstance(orientation, Rotation) else Rotation.from_euler("ZXZ", orientation, degrees=True) ) + self._func = func + self._bounds = bounds + + # ------------------------------------------------------------------ + # Public read-only accessors for implicit field + # ------------------------------------------------------------------ - @abstractmethod - def generate(self: Shape, **_: KwargsGenerateType) -> cq.Shape: - """Generate the CAD shape. + @property + def func(self: Shape) -> Field | None: + """The implicit scalar field, or ``None``.""" + return self._func - :return: cq.Shape + @property + def bounds(self: Shape) -> BoundsType | None: + """The bounding box ``(xmin, xmax, ymin, ymax, zmin, zmax)``, or ``None``.""" + return self._bounds + + def require_func(self: Shape) -> Field: + """Return ``_func`` or raise if not set.""" + if self._func is None: + err_msg = "No implicit scalar field defined on this shape" + raise ValueError(err_msg) + return self._func + + # ------------------------------------------------------------------ + # Implicit field evaluation + # ------------------------------------------------------------------ + + def evaluate( + self: Shape, + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + """Evaluate the implicit scalar field at the given coordinates. + + :param x: x coordinates + :param y: y coordinates + :param z: z coordinates + :return: scalar field values (negative = inside) """ - raise NotImplementedError + return self.require_func()(x, y, z) + + # ------------------------------------------------------------------ + # Mesh generation (defaults use the implicit field) + # ------------------------------------------------------------------ - @abstractmethod - def generate_vtk(self: Shape, **_: KwargsGenerateType) -> pv.PolyData: - """Generate the vtk mesh of the shape. + def generate_vtk( + self: Shape, + bounds: BoundsType | None = None, + resolution: int = 50, + **_: KwargsGenerateType, + ) -> pv.PolyData: + """Generate a VTK mesh of the shape. - :return: pv.PolyData + The default implementation meshes the implicit field via marching cubes + (``f < 0`` convention). Subclasses override this with their own + geometry generation. + + :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)`` + :param resolution: number of grid points per axis + :return: triangulated surface mesh + """ + if self._func is None: + err_msg = ( + "No implicit field defined — " + "subclasses must override generate_vtk()" + ) + raise NotImplementedError(err_msg) + + bounds = bounds or self._bounds + if bounds is None: + err_msg = ( + "Bounds must be provided either at construction or in generate_vtk()" + ) + raise ValueError(err_msg) + + xmin, xmax, ymin, ymax, zmin, zmax = bounds + xi = np.linspace(xmin, xmax, resolution) + yi = np.linspace(ymin, ymax, resolution) + zi = np.linspace(zmin, zmax, resolution) + x, y, z = np.meshgrid(xi, yi, zi, indexing="ij") + + grid = pv.StructuredGrid(x, y, z) + field = self.evaluate( + x.ravel(order="F"), + y.ravel(order="F"), + z.ravel(order="F"), + ) + grid["implicit"] = field + + polydata = grid.contour(isosurfaces=[0.0], scalars="implicit") + if polydata.n_cells == 0: + return pv.PolyData() + + polydata = polydata.clean().triangulate() + + polydata = rotate_mesh(polydata, center=(0, 0, 0), rotation=self.orientation) + return polydata.translate(xyz=self.center) + + def generate( + self: Shape, + bounds: BoundsType | None = None, + resolution: int = 50, + **_: KwargsGenerateType, + ) -> cq.Shape: + """Generate a CAD shape. + + The default implementation builds a CadQuery shape from the + implicit-field VTK mesh. Subclasses override this with native + CAD construction. + + :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)`` + :param resolution: number of grid points per axis + :return: CadQuery Shape """ - raise NotImplementedError + if self._func is None: + err_msg = ( + "No implicit field defined — " + "subclasses must override generate()" + ) + raise NotImplementedError(err_msg) + + import cadquery as cq # noqa: PLC0415 - @abstractmethod - def generateVtk(self: Shape, **_: KwargsGenerateType) -> pv.PolyData: # noqa: N802 - """Deprecated. Use generate_vtk instead.""" # noqa: D401 - return self.generate_vtk() + mesh = self.generate_vtk(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:] + triangles = np.c_[triangles, triangles[:, 0]] + + faces = [] + for tri in triangles: + lines = [ + cq.Edge.makeLine( + cq.Vector(*mesh.points[start]), + cq.Vector(*mesh.points[end]), + ) + for start, end in itertools.pairwise(tri) + ] + wire = cq.Wire.assembleEdges(lines) + faces.append(cq.Face.makeFromWires(wire)) + + try: + shell = cq.Shell.makeShell(faces) + except ValueError as err: + err_msg = ( + "Failed to create the shell, " + "try to increase the resolution or adjust bounds." + ) + raise ShellCreationError(err_msg) from err + + return cq.Shape(shell.wrapped) + + def generateVtk(self: Shape, **kwargs: KwargsGenerateType) -> pv.PolyData: # noqa: N802 + """Deprecated. Use :meth:`generate_vtk` instead.""" # noqa: D401 + return self.generate_vtk(**kwargs) + + # ------------------------------------------------------------------ + # Boolean operators (on implicit field) + # ------------------------------------------------------------------ + + def __or__(self: Shape, other: Shape) -> Shape: + """Union (``a | b``): inside where either field is negative.""" + from .implicit_ops import union # noqa: PLC0415 + + return union(self, other) + + def __and__(self: Shape, other: Shape) -> Shape: + """Intersection (``a & b``): inside where both fields are negative.""" + from .implicit_ops import intersection # noqa: PLC0415 + + return intersection(self, other) + + def __sub__(self: Shape, other: Shape) -> Shape: + """Difference (``a - b``): inside *a* but not *b*.""" + from .implicit_ops import difference # noqa: PLC0415 + + return difference(self, other) + + def __invert__(self: Shape) -> Shape: + """Complement (``~a``): negate the field.""" + from .implicit_ops import complement # noqa: PLC0415 + + return complement(self) + + # ------------------------------------------------------------------ + # Smooth booleans + # ------------------------------------------------------------------ + + def smooth_union(self: Shape, other: Shape, k: float) -> Shape: + """Smooth union with blending radius *k*.""" + from .implicit_ops import smooth_union # noqa: PLC0415 + + return smooth_union(self, other, k) + + def smooth_intersection(self: Shape, other: Shape, k: float) -> Shape: + """Smooth intersection with blending radius *k*.""" + from .implicit_ops import smooth_intersection # noqa: PLC0415 + + return smooth_intersection(self, other, k) + + def smooth_difference(self: Shape, other: Shape, k: float) -> Shape: + """Smooth difference with blending radius *k*.""" + from .implicit_ops import smooth_difference # noqa: PLC0415 + + return smooth_difference(self, other, k) + + # ------------------------------------------------------------------ + # Implicit field transforms + # ------------------------------------------------------------------ + + def translate(self: Shape, offset: tuple[float, float, float]) -> Shape: + """Return a new shape translated by *offset* (implicit field).""" + f = self.require_func() + dx, dy, dz = offset + new_bounds = None + if self._bounds is not None: + b = self._bounds + new_bounds = ( + b[0] + dx, + b[1] + dx, + b[2] + dy, + b[3] + dy, + b[4] + dz, + b[5] + dz, + ) + return Shape( + func=lambda x, y, z, _f=f, _dx=dx, _dy=dy, _dz=dz: _f( + x - _dx, y - _dy, z - _dz, + ), + bounds=new_bounds, + ) + + def rotate( + self: Shape, + angles: tuple[float, float, float], + convention: str = "ZXZ", + ) -> Shape: + """Return a new shape rotated by Euler *angles* (degrees, implicit field).""" + f = self.require_func() + rot = Rotation.from_euler(convention, angles, degrees=True) + inv_matrix = rot.inv().as_matrix() + return Shape( + func=lambda x, y, z, _f=f, _m=inv_matrix: _f( + *(_m @ np.array([x, y, z])), + ), + bounds=self._bounds, # conservative: keep original bounds + ) + + def scale(self: Shape, factor: float) -> Shape: + """Return a new shape uniformly scaled by *factor* (implicit field).""" + f = self.require_func() + new_bounds = None + if self._bounds is not None: + b = self._bounds + new_bounds = ( + b[0] * factor, + b[1] * factor, + b[2] * factor, + b[3] * factor, + b[4] * factor, + b[5] * factor, + ) + return Shape( + func=lambda x, y, z, _f=f, _s=factor: _f(x / _s, y / _s, z / _s) * _s, + bounds=new_bounds, + ) diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 0f2e2251..d238a4fd 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -958,9 +958,7 @@ def _create_grid( return grid -class ShellCreationError(Exception): - """Error raised when the shell creation fails.""" +# Re-export for backward compatibility +from .shape import ShellCreationError # noqa: E402 - def __init__(self: ShellCreationError, message: str) -> None: - """Initialize the ShellCreationError.""" - super().__init__(message) +__all__ = ["CylindricalTpms", "Infill", "ShellCreationError", "SphericalTpms", "Tpms"] diff --git a/tests/shapes/test_implicit_ops.py b/tests/shapes/test_implicit_ops.py new file mode 100644 index 00000000..03b1acde --- /dev/null +++ b/tests/shapes/test_implicit_ops.py @@ -0,0 +1,420 @@ +"""Tests for the F-rep implicit operations and Shape implicit capabilities.""" + +from __future__ import annotations + +import numpy as np +import pytest +import pyvista as pv + +from microgen.shape.implicit_ops import ( + batch_smooth_union, + blend, + difference, + from_field, + intersection, + repeat, + shell, + smooth_difference, + smooth_intersection, + smooth_union, + union, +) +from microgen.shape.shape import Shape + + +# --------------------------------------------------------------------------- +# Helpers — inline SDF lambdas (no primitive factories needed) +# --------------------------------------------------------------------------- + + +def _sphere_field(cx=0.0, cy=0.0, cz=0.0, r=1.0): + """Return a sphere SDF function and bounds.""" + margin = r * 1.1 + return ( + lambda x, y, z: np.sqrt((x - cx) ** 2 + (y - cy) ** 2 + (z - cz) ** 2) - r, + (cx - margin, cx + margin, cy - margin, cy + margin, cz - margin, cz + margin), + ) + + +def _box_field(cx=0.0, cy=0.0, cz=0.0, hx=0.5, hy=0.5, hz=0.5): + """Return a box SDF function and bounds.""" + margin = max(hx, hy, hz) * 0.1 + + def sdf(x, y, z): + qx = np.abs(x - cx) - hx + qy = np.abs(y - cy) - hy + qz = np.abs(z - cz) - hz + outside = np.sqrt( + np.maximum(qx, 0.0) ** 2 + + np.maximum(qy, 0.0) ** 2 + + np.maximum(qz, 0.0) ** 2 + ) + inside = np.minimum(np.maximum(qx, np.maximum(qy, qz)), 0.0) + return outside + inside + + return ( + sdf, + ( + cx - hx - margin, + cx + hx + margin, + cy - hy - margin, + cy + hy + margin, + cz - hz - margin, + cz + hz + margin, + ), + ) + + +def _make_sphere(cx=0.0, cy=0.0, cz=0.0, r=1.0): + func, bounds = _sphere_field(cx, cy, cz, r) + return Shape(func=func, bounds=bounds) + + +def _make_box(cx=0.0, cy=0.0, cz=0.0, hx=0.5, hy=0.5, hz=0.5): + func, bounds = _box_field(cx, cy, cz, hx, hy, hz) + return Shape(func=func, bounds=bounds) + + +# --------------------------------------------------------------------------- +# Evaluate +# --------------------------------------------------------------------------- + + +class TestEvaluate: + """Test implicit field evaluation.""" + + def test_sphere_inside(self): + s = _make_sphere() + assert s.evaluate(np.array([0.0]), np.array([0.0]), np.array([0.0]))[0] < 0 + + def test_sphere_surface(self): + s = _make_sphere() + val = s.evaluate(np.array([1.0]), np.array([0.0]), np.array([0.0]))[0] + assert abs(val) < 1e-10 + + def test_sphere_outside(self): + s = _make_sphere() + assert s.evaluate(np.array([2.0]), np.array([0.0]), np.array([0.0]))[0] > 0 + + def test_no_func_raises(self): + s = Shape() + with pytest.raises(ValueError, match="No implicit scalar field"): + s.evaluate(np.array([0.0]), np.array([0.0]), np.array([0.0])) + + +# --------------------------------------------------------------------------- +# Boolean operations +# --------------------------------------------------------------------------- + + +class TestBooleans: + """Test hard boolean operations.""" + + def test_union_min(self): + s1 = _make_sphere(cx=-0.5) + s2 = _make_sphere(cx=0.5) + u = union(s1, s2) + x, y, z = np.array([0.0]), np.array([0.0]), np.array([0.0]) + expected = min(s1.evaluate(x, y, z)[0], s2.evaluate(x, y, z)[0]) + assert u.evaluate(x, y, z)[0] == pytest.approx(expected) + + def test_intersection_max(self): + s1 = _make_sphere(cx=-0.5) + s2 = _make_sphere(cx=0.5) + i = intersection(s1, s2) + x, y, z = np.array([0.0]), np.array([0.0]), np.array([0.0]) + expected = max(s1.evaluate(x, y, z)[0], s2.evaluate(x, y, z)[0]) + assert i.evaluate(x, y, z)[0] == pytest.approx(expected) + + def test_difference(self): + s1 = _make_sphere() + s2 = _make_sphere(cx=0.5, r=0.5) + d = difference(s1, s2) + x, y, z = np.array([-0.5]), np.array([0.0]), np.array([0.0]) + assert d.evaluate(x, y, z)[0] < 0 + + def test_operators(self): + s1 = _make_sphere() + s2 = _make_box() + x, y, z = np.array([0.0]), np.array([0.0]), np.array([0.0]) + + u = s1 | s2 + assert u.evaluate(x, y, z)[0] < 0 + + i = s1 & s2 + assert i.evaluate(x, y, z)[0] < 0 + + d = s1 - s2 + val = d.evaluate(x, y, z)[0] + assert isinstance(val, (float, np.floating)) + + def test_complement(self): + s = _make_sphere() + c = ~s + x, y, z = np.array([0.0]), np.array([0.0]), np.array([0.0]) + assert c.evaluate(x, y, z)[0] > 0 + + +# --------------------------------------------------------------------------- +# Smooth booleans +# --------------------------------------------------------------------------- + + +class TestSmoothBooleans: + """Test smooth boolean operations.""" + + def test_smooth_union_blending_zone(self): + s1 = _make_sphere(cx=-0.5) + s2 = _make_sphere(cx=0.5) + su = smooth_union(s1, s2, k=0.5) + hard = union(s1, s2) + x, y, z = np.array([0.0]), np.array([0.0]), np.array([0.0]) + assert su.evaluate(x, y, z)[0] <= hard.evaluate(x, y, z)[0] + 1e-10 + + def test_smooth_union_k_zero_equals_hard(self): + s1 = _make_sphere(cx=-0.5) + s2 = _make_sphere(cx=0.5) + su = smooth_union(s1, s2, k=0.0) + hard = union(s1, s2) + x = np.linspace(-2, 2, 10) + y = np.zeros(10) + z = np.zeros(10) + np.testing.assert_allclose( + su.evaluate(x, y, z), hard.evaluate(x, y, z), atol=1e-10 + ) + + def test_smooth_intersection(self): + s1 = _make_sphere() + s2 = _make_box() + si = smooth_intersection(s1, s2, k=0.3) + x, y, z = np.array([0.0]), np.array([0.0]), np.array([0.0]) + assert si.evaluate(x, y, z)[0] < 0 + + def test_smooth_difference(self): + s1 = _make_sphere() + s2 = _make_sphere(cx=1.0, r=0.5) + sd = smooth_difference(s1, s2, k=0.2) + x, y, z = np.array([-0.5]), np.array([0.0]), np.array([0.0]) + assert sd.evaluate(x, y, z)[0] < 0 + + def test_smooth_methods(self): + s1 = _make_sphere() + s2 = _make_box() + x, y, z = np.array([0.0]), np.array([0.0]), np.array([0.0]) + + su = s1.smooth_union(s2, k=0.3) + assert isinstance(su.evaluate(x, y, z)[0], (float, np.floating)) + + si = s1.smooth_intersection(s2, k=0.3) + assert isinstance(si.evaluate(x, y, z)[0], (float, np.floating)) + + sd = s1.smooth_difference(s2, k=0.3) + assert isinstance(sd.evaluate(x, y, z)[0], (float, np.floating)) + + +# --------------------------------------------------------------------------- +# Transforms +# --------------------------------------------------------------------------- + + +class TestTransforms: + """Test implicit field transform operations.""" + + def test_translate(self): + s = _make_sphere() + st = s.translate((2, 0, 0)) + x, y, z = np.array([2.0]), np.array([0.0]), np.array([0.0]) + assert st.evaluate(x, y, z)[0] < 0 + + x0 = np.array([0.0]) + assert st.evaluate(x0, y, z)[0] > 0 + + def test_rotate_90(self): + # Elongated box along x, rotate 90 around z -> elongated along y + func, bounds = _box_field(hx=1.0, hy=0.1, hz=0.1) + box = Shape(func=func, bounds=bounds) + rotated = box.rotate((0, 0, 90), convention="xyz") + # Point along y axis should be inside + assert rotated.evaluate(np.array([0.0]), np.array([0.5]), np.array([0.0]))[0] < 0 + # Point along x axis (was inside, now outside) + assert rotated.evaluate(np.array([0.5]), np.array([0.0]), np.array([0.0]))[0] > 0 + + def test_scale(self): + s = _make_sphere() + ss = s.scale(2.0) + x, y, z = np.array([1.5]), np.array([0.0]), np.array([0.0]) + assert s.evaluate(x, y, z)[0] > 0 + assert ss.evaluate(x, y, z)[0] < 0 + + def test_translate_bounds(self): + s = _make_sphere() + st = s.translate((5, 0, 0)) + assert st.bounds is not None + assert st.bounds[0] > 3.0 + + def test_scale_bounds(self): + s = _make_sphere() + ss = s.scale(3.0) + assert ss.bounds is not None + assert ss.bounds[1] > 3.0 + + +# --------------------------------------------------------------------------- +# generate_vtk +# --------------------------------------------------------------------------- + + +class TestGenerateVtk: + """Test default mesh generation from implicit field.""" + + def test_sphere_mesh(self): + s = _make_sphere() + mesh = s.generate_vtk(resolution=30) + assert isinstance(mesh, pv.PolyData) + assert mesh.n_cells > 0 + + def test_box_mesh(self): + b = _make_box() + mesh = b.generate_vtk(resolution=30) + assert isinstance(mesh, pv.PolyData) + assert mesh.n_cells > 0 + + def test_boolean_mesh(self): + s1 = _make_sphere() + s2 = _make_box(hx=0.6, hy=0.6, hz=0.6) + result = s1 & s2 + mesh = result.generate_vtk(resolution=30) + assert isinstance(mesh, pv.PolyData) + assert mesh.n_cells > 0 + + def test_no_func_raises(self): + s = Shape() + with pytest.raises(NotImplementedError, match="No implicit field"): + s.generate_vtk() + + def test_no_bounds_raises(self): + s = Shape(func=lambda x, y, z: x**2 + y**2 + z**2 - 1) + with pytest.raises(ValueError, match="Bounds must be provided"): + s.generate_vtk() + + def test_explicit_bounds_override(self): + s = _make_sphere() + mesh = s.generate_vtk(bounds=(-2, 2, -2, 2, -2, 2), resolution=30) + assert mesh.n_cells > 0 + + def test_generateVtk_deprecated(self): + s = _make_sphere() + mesh = s.generateVtk(resolution=20) + assert isinstance(mesh, pv.PolyData) + + +# --------------------------------------------------------------------------- +# Bounds propagation +# --------------------------------------------------------------------------- + + +class TestBoundsPropagation: + """Test bounds merge behavior.""" + + def test_union_expands(self): + s1 = _make_sphere(cx=-2) + s2 = _make_sphere(cx=2) + u = s1 | s2 + assert u.bounds[0] <= s1.bounds[0] + assert u.bounds[1] >= s2.bounds[1] + + def test_intersection_shrinks(self): + s1 = _make_sphere(cx=-0.5, r=1.5) + s2 = _make_sphere(cx=0.5, r=1.5) + i = s1 & s2 + assert i.bounds[0] >= s1.bounds[0] + assert i.bounds[1] <= s2.bounds[1] + + def test_none_bounds(self): + f = lambda x, y, z: x # noqa: E731 + p = Shape(func=f, bounds=None) + s = _make_sphere() + u = p | s + assert u.bounds == s.bounds + + +# --------------------------------------------------------------------------- +# Utilities +# --------------------------------------------------------------------------- + + +class TestUtilities: + """Test utility functions.""" + + def test_shell(self): + s = _make_sphere() + sh = shell(s, thickness=0.2) + # At radius=1.0 (surface), |f|=0, shell should be inside + assert sh.evaluate(np.array([1.0]), np.array([0.0]), np.array([0.0]))[0] < 0 + # At origin, |f|=1.0 >> 0.1, shell should be outside + assert sh.evaluate(np.array([0.0]), np.array([0.0]), np.array([0.0]))[0] > 0 + + def test_repeat(self): + s = _make_sphere(r=0.3) + r = repeat(s, spacing=(1.0, 1.0, 1.0)) + # At (1,0,0) should be inside a repeated copy + assert r.evaluate(np.array([1.0]), np.array([0.0]), np.array([0.0]))[0] < 0 + assert r.bounds is None + + def test_blend(self): + s1 = _make_sphere() + s2 = _make_box() + b = blend(s1, s2, factor=0.5) + x, y, z = np.array([0.0]), np.array([0.0]), np.array([0.0]) + expected = 0.5 * s1.evaluate(x, y, z)[0] + 0.5 * s2.evaluate(x, y, z)[0] + assert b.evaluate(x, y, z)[0] == pytest.approx(expected) + + def test_from_field(self): + shape = from_field( + func=lambda x, y, z: x**2 + y**2 + z**2 - 1, + bounds=(-2, 2, -2, 2, -2, 2), + ) + assert shape.evaluate(np.array([0.0]), np.array([0.0]), np.array([0.0]))[0] < 0 + assert shape.bounds == (-2, 2, -2, 2, -2, 2) + + def test_batch_smooth_union(self): + spheres = [_make_sphere(cx=i * 0.5) for i in range(5)] + combined = batch_smooth_union(spheres, k=0.1) + x, y, z = np.array([0.0]), np.array([0.0]), np.array([0.0]) + assert combined.evaluate(x, y, z)[0] < 0 + + def test_batch_smooth_union_empty_raises(self): + with pytest.raises(ValueError, match="at least one shape"): + batch_smooth_union([], k=0.1) + + def test_batch_smooth_union_hard(self): + spheres = [_make_sphere(cx=i * 0.5) for i in range(3)] + combined = batch_smooth_union(spheres, k=0.0) + x, y, z = np.array([0.0]), np.array([0.0]), np.array([0.0]) + assert combined.evaluate(x, y, z)[0] < 0 + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestErrorHandling: + """Test error conditions.""" + + def test_transform_without_func_raises(self): + s = Shape() + with pytest.raises(ValueError, match="No implicit scalar field"): + s.translate((1, 0, 0)) + with pytest.raises(ValueError, match="No implicit scalar field"): + s.rotate((0, 0, 45)) + with pytest.raises(ValueError, match="No implicit scalar field"): + s.scale(2.0) + + def test_boolean_without_func_raises(self): + s = Shape() + other = _make_sphere() + with pytest.raises(ValueError, match="No implicit scalar field"): + s | other # noqa: B015 + with pytest.raises(ValueError, match="No implicit scalar field"): + ~s # noqa: B015 From afc7dbd15b740206ad3c9056773f495d945b71b6 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Thu, 16 Apr 2026 23:06:36 +0200 Subject: [PATCH 02/15] Add autograd to pip dependencies Include autograd in pip dependencies for both the conda build recipe and the environment file (conda.recipe/meta.yaml and environment.yml) so the package is installed during build/test and available in the development environment. --- conda.recipe/meta.yaml | 2 ++ environment.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml index 8064e46a..b2d52b08 100644 --- a/conda.recipe/meta.yaml +++ b/conda.recipe/meta.yaml @@ -24,6 +24,8 @@ requirements: - meshio - cadquery - scipy + - pip: + - autograd test: imports: diff --git a/environment.yml b/environment.yml index a3b13f9e..8ac23927 100755 --- a/environment.yml +++ b/environment.yml @@ -11,5 +11,7 @@ dependencies: - python-gmsh - meshio - cadquery + - pip: + - autograd - mmg # - neper From 3733cbc308d503a1c1d385559a2cbbef8e5a4910 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Thu, 16 Apr 2026 23:07:33 +0200 Subject: [PATCH 03/15] Use autograd.numpy in TPMS surface funcs Replace numpy imports with autograd.numpy in microgen/shape/surface_functions.py so TPMS surface functions are compatible with autograd (enabling automatic differentiation). Add autograd to pyproject.toml dependencies to ensure the new import is available. No behavior changes beyond making the functions differentiable with autograd. --- microgen/shape/surface_functions.py | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/microgen/shape/surface_functions.py b/microgen/shape/surface_functions.py index 0e3bf302..1d8cf2a3 100644 --- a/microgen/shape/surface_functions.py +++ b/microgen/shape/surface_functions.py @@ -1,7 +1,7 @@ """TPMS surface functions.""" -import numpy as np -from numpy import cos, sin +import autograd.numpy as np +from autograd.numpy import cos, sin def gyroid(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: diff --git a/pyproject.toml b/pyproject.toml index e44f03ab..2d0272fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", # Python 3.13 not yet supported via pip (VTK 9.3.1 has no 3.13 wheel on PyPI) ] -dependencies = ["numpy", "pyvista", "gmsh", "meshio", "cadquery", "scipy", "nlopt"] +dependencies = ["numpy", "pyvista", "gmsh", "meshio", "cadquery", "scipy", "nlopt", "autograd"] [project.optional-dependencies] dev = [ From 2cac2cb63df36163937e892440186948321963d8 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Thu, 16 Apr 2026 23:07:45 +0200 Subject: [PATCH 04/15] Add F-rep (SDF) integration for TPMS and tests Introduce F-rep based implicit fields for Tpms and its curvilinear variants so TPMS can be used with SDF-normalized operations (shell, complement, booleans). Adds _setup_frep_field to Tpms, CylindricalTpms and SphericalTpms (using from_field + normalize_to_sdf), exposes raw_field, and adds convenience methods as_sheet, as_upper_skeletal and as_lower_skeletal. generate_vtk was updated to build parts from the F-rep Shapes and to use the normalized field/bounds; offset checks and density-based offset computation are preserved. A comprehensive test suite (tests/shapes/test_tpms_frep.py) was added to validate SDF normalization, TPMS F-rep evaluation, sheet/skeletal helpers, boolean composition, generate_vtk compatibility and curvilinear TPMS behavior. --- microgen/shape/tpms.py | 154 +++++++++++++++++- tests/shapes/test_tpms_frep.py | 281 +++++++++++++++++++++++++++++++++ 2 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 tests/shapes/test_tpms_frep.py diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index d238a4fd..90cd0cba 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -123,6 +123,7 @@ def __init__( # noqa: PLR0913 self.resolution = resolution self._compute_tpms_field() + self._setup_frep_field() min_field = np.min(self.grid["surface"]) max_field = np.max(self.grid["surface"]) @@ -354,6 +355,70 @@ def _compute_tpms_field(self: Tpms) -> None: self.grid["surface"] = tpms_field.ravel(order="F") + def _setup_frep_field(self: Tpms) -> None: + """Build the F-rep implicit field (SDF-normalized) for this TPMS.""" + from .implicit_ops import from_field, normalize_to_sdf # noqa: PLC0415 + + k_x, k_y, k_z = 2.0 * np.pi / self.cell_size + ps = self.phase_shift + + def _raw_field( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + return self.surface_function( + k_x * (x + ps[0]), + k_y * (y + ps[1]), + k_z * (z + ps[2]), + ) + + self._raw_field_func = _raw_field + + raw_shape = from_field(_raw_field) + sdf_shape = normalize_to_sdf(raw_shape) + self._func = sdf_shape.func + + half = 0.5 * self.cell_size * self.repeat_cell + self._bounds = ( + -half[0], half[0], + -half[1], half[1], + -half[2], half[2], + ) + + @property + def raw_field(self: Tpms) -> Field: + """The raw (non-SDF-normalized) TPMS field callable.""" + return self._raw_field_func + + def as_sheet(self: Tpms, thickness: float | None = None) -> Shape: + """Return an F-rep Shape representing a uniform-thickness TPMS sheet. + + Uses the SDF-normalized field, so *thickness* is in physical units. + If *thickness* is ``None``, uses ``self.offset``. + """ + from .implicit_ops import shell # noqa: PLC0415 + from .shape import Shape # noqa: PLC0415 + + t = thickness if thickness is not None else self._offset + return shell( + Shape(func=self._func, bounds=self._bounds), + float(t), + ) + + def as_upper_skeletal(self: Tpms) -> Shape: + """Return F-rep Shape for the upper skeletal (f > 0 side).""" + from .implicit_ops import complement # noqa: PLC0415 + from .shape import Shape # noqa: PLC0415 + + return complement(Shape(func=self._func, bounds=self._bounds)) + + def as_lower_skeletal(self: Tpms) -> Shape: + """Return F-rep Shape for the lower skeletal (f < 0 side).""" + from .implicit_ops import from_field # noqa: PLC0415 + + return from_field(func=self._func, bounds=self._bounds) + def _update_grid_offset(self: Tpms) -> None: self.grid["lower_surface"] = self.grid["surface"] + 0.5 * self.offset self.grid["upper_surface"] = self.grid["surface"] - 0.5 * self.offset @@ -629,7 +694,10 @@ def generate_vtk( ) -> pv.PolyData: """Generate VTK PolyData object of the required TPMS part. - :param type_part: part of the TPMS desireds + Uses the SDF-normalized implicit field with F-rep operations: + sheet = ``shell(sdf, thickness)``, skeletal = one side of the SDF. + + :param type_part: part of the TPMS desired :param algo_resolution: if offset must be computed to fit density, \ resolution of the temporary TPMS used to compute the offset @@ -643,12 +711,25 @@ def generate_vtk( "'upper skeletal' or 'surface'" ) raise ValueError(err_msg) + if self.density is not None: self._compute_offset_to_fit_density( part_type=type_part, resolution=algo_resolution, ) - polydata: pv.PolyData = getattr(self, type_part.replace(" ", "_")) + self._check_offset(type_part) + + if type_part == "sheet": + part = self.as_sheet() + elif type_part == "upper skeletal": + part = self.as_upper_skeletal() + else: # lower skeletal + part = self.as_lower_skeletal() + + polydata = part.generate_vtk( + bounds=self._bounds, + resolution=self.resolution * max(self.repeat_cell), + ) polydata = rotate(polydata, center=(0, 0, 0), rotation=self.orientation) return polydata.translate(xyz=self.center) @@ -774,6 +855,39 @@ def _create_grid( ] return grid + def _setup_frep_field(self: CylindricalTpms) -> None: + """Build F-rep field in Cartesian coordinates (inverse cylindrical mapping).""" + from .implicit_ops import from_field, normalize_to_sdf # noqa: PLC0415 + + k_x, k_y, k_z = 2.0 * np.pi / self.cell_size + ps = self.phase_shift + cyl_r = self.cylinder_radius + unit_theta = self.unit_theta + + def _raw_field( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + rho = np.sqrt(x**2 + y**2) - cyl_r + theta = np.arctan2(y, x) / unit_theta + return self.surface_function( + k_x * (rho + ps[0]), + k_y * (theta + ps[1]), + k_z * (z + ps[2]), + ) + + self._raw_field_func = _raw_field + + raw_shape = from_field(_raw_field) + sdf_shape = normalize_to_sdf(raw_shape) + self._func = sdf_shape.func + + # Bounds in Cartesian space + r_max = cyl_r + 0.5 * self.cell_size[0] * self.repeat_cell[0] + half_z = 0.5 * self.cell_size[2] * self.repeat_cell[2] + self._bounds = (-r_max, r_max, -r_max, r_max, -half_z, half_z) + class SphericalTpms(Tpms): """Class used to generate spherical TPMS geometries (sheet or skeletals parts).""" @@ -872,6 +986,42 @@ def _create_grid( ] return grid + def _setup_frep_field(self: SphericalTpms) -> None: + """Build F-rep field in Cartesian coordinates (inverse spherical mapping).""" + from .implicit_ops import from_field, normalize_to_sdf # noqa: PLC0415 + + k_x, k_y, k_z = 2.0 * np.pi / self.cell_size + ps = self.phase_shift + sph_r = self.sphere_radius + unit_theta = self.unit_theta + unit_phi = self.unit_phi + + def _raw_field( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + rho_cart = np.sqrt(x**2 + y**2 + z**2) + rho = rho_cart - sph_r + theta = (np.arccos(np.clip(z / np.maximum(rho_cart, 1e-30), -1, 1)) + - np.pi / 2.0) / unit_theta + phi = np.arctan2(y, x) / unit_phi + return self.surface_function( + k_x * (rho + ps[0]), + k_y * (theta + ps[1]), + k_z * (phi + ps[2]), + ) + + self._raw_field_func = _raw_field + + raw_shape = from_field(_raw_field) + sdf_shape = normalize_to_sdf(raw_shape) + self._func = sdf_shape.func + + # Bounds in Cartesian space + r_max = sph_r + 0.5 * self.cell_size[0] * self.repeat_cell[0] + self._bounds = (-r_max, r_max, -r_max, r_max, -r_max, r_max) + class Infill(Tpms): """Generate a TPMS infill inside a given object.""" diff --git a/tests/shapes/test_tpms_frep.py b/tests/shapes/test_tpms_frep.py new file mode 100644 index 00000000..30a3e9d9 --- /dev/null +++ b/tests/shapes/test_tpms_frep.py @@ -0,0 +1,281 @@ +"""Tests for TPMS F-rep integration with SDF normalization.""" + +from __future__ import annotations + +import numpy as np +import pytest +import pyvista as pv + +from microgen.shape import Tpms, surface_functions +from microgen.shape.implicit_ops import from_field, normalize_to_sdf, shell +from microgen.shape.shape import Shape + + +# --------------------------------------------------------------------------- +# SDF normalization quality +# --------------------------------------------------------------------------- + + +class TestSdfNormalization: + """Test that SDF normalization produces well-behaved fields.""" + + def test_gradient_magnitude_near_one(self): + """After normalization, |grad(sdf)| should be approximately 1.""" + raw = from_field(surface_functions.gyroid) + sdf_shape = normalize_to_sdf(raw) + sdf = sdf_shape.func + + rng = np.random.default_rng(42) + x = rng.uniform(-2, 2, 500) + y = rng.uniform(-2, 2, 500) + z = rng.uniform(-2, 2, 500) + + h = 1e-5 + gx = (sdf(x + h, y, z) - sdf(x - h, y, z)) / (2 * h) + gy = (sdf(x, y + h, z) - sdf(x, y - h, z)) / (2 * h) + gz = (sdf(x, y, z + h) - sdf(x, y, z - h)) / (2 * h) + grad_mag = np.sqrt(gx**2 + gy**2 + gz**2) + + # Near the zero level set, gradient magnitude should be close to 1 + near_surface = np.abs(sdf(x, y, z)) < 0.3 + if near_surface.sum() > 10: + assert np.mean(grad_mag[near_surface]) == pytest.approx(1.0, abs=0.3) + + def test_saddle_point_safety(self): + """SDF at known saddle points should not produce NaN or Inf.""" + tpms = Tpms( + surface_function=surface_functions.gyroid, + offset=0.3, + ) + # Gyroid saddle points are at (0, 0, 0) and equivalents + x = np.array([0.0, 0.25, 0.5]) + y = np.array([0.0, 0.25, 0.5]) + z = np.array([0.0, 0.25, 0.5]) + vals = tpms.evaluate(x, y, z) + assert np.all(np.isfinite(vals)) + + def test_normalize_to_sdf_fallback(self): + """normalize_to_sdf falls back to FD when autograd fails.""" + # A function using regular numpy (not autograd.numpy) + import numpy as regular_np + + def non_autograd_field(x, y, z): + return regular_np.sin(x) + regular_np.cos(y) + + shape = from_field(non_autograd_field, bounds=(-2, 2, -2, 2, -2, 2)) + sdf_shape = normalize_to_sdf(shape) + vals = sdf_shape.func(np.array([0.0]), np.array([0.0]), np.array([0.0])) + assert np.isfinite(vals[0]) + + +# --------------------------------------------------------------------------- +# Tpms F-rep field +# --------------------------------------------------------------------------- + + +class TestTpmsFrepField: + """Test that Tpms has a working F-rep implicit field.""" + + def test_tpms_has_func(self): + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + assert tpms.func is not None + + def test_tpms_has_bounds(self): + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + assert tpms.bounds is not None + + def test_tpms_evaluate(self): + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + x = np.array([0.0, 0.1, 0.2]) + y = np.array([0.0, 0.1, 0.2]) + z = np.array([0.0, 0.1, 0.2]) + vals = tpms.evaluate(x, y, z) + assert vals.shape == (3,) + assert np.all(np.isfinite(vals)) + + def test_tpms_raw_field(self): + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + raw = tpms.raw_field + assert callable(raw) + val = raw(np.array([0.0]), np.array([0.0]), np.array([0.0])) + assert np.isfinite(val[0]) + + +# --------------------------------------------------------------------------- +# F-rep convenience methods +# --------------------------------------------------------------------------- + + +class TestTpmsFrepMethods: + """Test as_sheet, as_upper_skeletal, as_lower_skeletal.""" + + def test_as_sheet(self): + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + sheet = tpms.as_sheet(thickness=0.15) + assert isinstance(sheet, Shape) + assert sheet.func is not None + + def test_as_sheet_mesh(self): + tpms = Tpms( + surface_function=surface_functions.gyroid, + offset=0.3, + resolution=20, + ) + sheet = tpms.as_sheet(thickness=0.15) + mesh = sheet.generate_vtk(bounds=tpms.bounds, resolution=30) + assert isinstance(mesh, pv.PolyData) + assert mesh.n_cells > 0 + + def test_as_upper_skeletal(self): + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + skel = tpms.as_upper_skeletal() + assert isinstance(skel, Shape) + assert skel.func is not None + + def test_as_lower_skeletal(self): + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + skel = tpms.as_lower_skeletal() + assert isinstance(skel, Shape) + assert skel.func is not None + + +# --------------------------------------------------------------------------- +# Boolean composition +# --------------------------------------------------------------------------- + + +class TestTpmsBooleans: + """Test boolean composition of TPMS with other shapes.""" + + def test_tpms_and_sphere(self): + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + sphere_func = lambda x, y, z: np.sqrt(x**2 + y**2 + z**2) - 0.4 # noqa: E731 + sphere = from_field(sphere_func, bounds=(-0.5, 0.5, -0.5, 0.5, -0.5, 0.5)) + result = tpms & sphere + assert isinstance(result, Shape) + mesh = result.generate_vtk( + bounds=(-0.5, 0.5, -0.5, 0.5, -0.5, 0.5), + resolution=30, + ) + assert isinstance(mesh, pv.PolyData) + + def test_sheet_and_sphere(self): + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) + sheet = tpms.as_sheet(thickness=0.15) + sphere_func = lambda x, y, z: np.sqrt(x**2 + y**2 + z**2) - 0.4 # noqa: E731 + sphere = from_field(sphere_func, bounds=(-0.5, 0.5, -0.5, 0.5, -0.5, 0.5)) + result = sheet & sphere + mesh = result.generate_vtk( + bounds=(-0.5, 0.5, -0.5, 0.5, -0.5, 0.5), + resolution=30, + ) + assert isinstance(mesh, pv.PolyData) + + +# --------------------------------------------------------------------------- +# generate_vtk backward compatibility +# --------------------------------------------------------------------------- + + +class TestTpmsGenerateVtk: + """Test that generate_vtk(type_part=...) still works.""" + + def test_sheet(self): + tpms = Tpms( + surface_function=surface_functions.gyroid, + offset=0.3, + resolution=20, + ) + mesh = tpms.generate_vtk(type_part="sheet") + assert isinstance(mesh, pv.PolyData) + assert mesh.n_cells > 0 + + def test_surface(self): + tpms = Tpms( + surface_function=surface_functions.gyroid, + offset=0.3, + resolution=20, + ) + mesh = tpms.generate_vtk(type_part="surface") + assert isinstance(mesh, pv.PolyData) + assert mesh.n_cells > 0 + + def test_invalid_type_part(self): + tpms = Tpms( + surface_function=surface_functions.gyroid, + offset=0.3, + ) + with pytest.raises(ValueError, match="type_part"): + tpms.generate_vtk(type_part="invalid") + + @pytest.mark.parametrize( + "surface_fn", + [surface_functions.gyroid, surface_functions.schwarz_p], + ) + def test_multiple_surface_functions(self, surface_fn): + tpms = Tpms(surface_function=surface_fn, offset=0.3, resolution=20) + mesh = tpms.generate_vtk(type_part="sheet") + assert mesh.n_cells > 0 + + +# --------------------------------------------------------------------------- +# CylindricalTpms / SphericalTpms +# --------------------------------------------------------------------------- + + +class TestCurvilinearTpms: + """Test F-rep field on curvilinear TPMS variants.""" + + def test_cylindrical_has_func(self): + from microgen.shape.tpms import CylindricalTpms + + tpms = CylindricalTpms( + radius=1.0, + surface_function=surface_functions.gyroid, + offset=0.3, + resolution=10, + ) + assert tpms.func is not None + assert tpms.bounds is not None + + def test_cylindrical_evaluate(self): + from microgen.shape.tpms import CylindricalTpms + + tpms = CylindricalTpms( + radius=1.0, + surface_function=surface_functions.gyroid, + offset=0.3, + resolution=10, + ) + x = np.array([1.0, 0.5]) + y = np.array([0.0, 0.5]) + z = np.array([0.0, 0.0]) + vals = tpms.evaluate(x, y, z) + assert np.all(np.isfinite(vals)) + + def test_spherical_has_func(self): + from microgen.shape.tpms import SphericalTpms + + tpms = SphericalTpms( + radius=1.0, + surface_function=surface_functions.gyroid, + offset=0.3, + resolution=10, + ) + assert tpms.func is not None + assert tpms.bounds is not None + + def test_spherical_evaluate(self): + from microgen.shape.tpms import SphericalTpms + + tpms = SphericalTpms( + radius=1.0, + surface_function=surface_functions.gyroid, + offset=0.3, + resolution=10, + ) + x = np.array([1.0, 0.5]) + y = np.array([0.0, 0.5]) + z = np.array([0.0, 0.5]) + vals = tpms.evaluate(x, y, z) + assert np.all(np.isfinite(vals)) From 945c0f66a4742d4b52d8dacfae44ab9c15836ec8 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Fri, 17 Apr 2026 14:12:36 +0200 Subject: [PATCH 05/15] Refactor TPMS field finalization and VTK resolution Introduce _finalize_frep to centralize raw field -> SDF normalization and setting of _raw_field_func, _func and _bounds. Replace duplicated normalization code in Tpms, CylindricalTpms and SphericalTpms with calls to the new helper. Adjust generate_vtk resolution logic to compute an isotropic resolution from repeat_cell (using geometric mean) and enforce a sensible minimum, and wrap shell return in a plain Shape to avoid recursive generate_vtk calls. Also include minor formatting and expression cleanups. --- microgen/shape/tpms.py | 78 ++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 41 deletions(-) diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 90cd0cba..6081a882 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -355,10 +355,21 @@ def _compute_tpms_field(self: Tpms) -> None: self.grid["surface"] = tpms_field.ravel(order="F") - def _setup_frep_field(self: Tpms) -> None: - """Build the F-rep implicit field (SDF-normalized) for this TPMS.""" + def _finalize_frep( + self: Tpms, + raw_field: Field, + bounds: tuple[float, float, float, float, float, float], + ) -> None: + """Normalize a raw field to SDF and set ``_func`` / ``_bounds``.""" from .implicit_ops import from_field, normalize_to_sdf # noqa: PLC0415 + self._raw_field_func = raw_field + sdf_shape = normalize_to_sdf(from_field(raw_field)) + self._func = sdf_shape.func + self._bounds = bounds + + def _setup_frep_field(self: Tpms) -> None: + """Build the F-rep implicit field (SDF-normalized) for this TPMS.""" k_x, k_y, k_z = 2.0 * np.pi / self.cell_size ps = self.phase_shift @@ -373,17 +384,10 @@ def _raw_field( k_z * (z + ps[2]), ) - self._raw_field_func = _raw_field - - raw_shape = from_field(_raw_field) - sdf_shape = normalize_to_sdf(raw_shape) - self._func = sdf_shape.func - half = 0.5 * self.cell_size * self.repeat_cell - self._bounds = ( - -half[0], half[0], - -half[1], half[1], - -half[2], half[2], + self._finalize_frep( + _raw_field, + (-half[0], half[0], -half[1], half[1], -half[2], half[2]), ) @property @@ -401,10 +405,9 @@ def as_sheet(self: Tpms, thickness: float | None = None) -> Shape: from .shape import Shape # noqa: PLC0415 t = thickness if thickness is not None else self._offset - return shell( - Shape(func=self._func, bounds=self._bounds), - float(t), - ) + # Wrap in plain Shape so generate_vtk uses Shape's marching cubes, + # not Tpms.generate_vtk (which would recurse back here). + return shell(Shape(func=self._func, bounds=self._bounds), float(t)) def as_upper_skeletal(self: Tpms) -> Shape: """Return F-rep Shape for the upper skeletal (f > 0 side).""" @@ -726,10 +729,13 @@ def generate_vtk( else: # lower skeletal part = self.as_lower_skeletal() - polydata = part.generate_vtk( - bounds=self._bounds, - resolution=self.resolution * max(self.repeat_cell), + # Match the grid resolution: resolution * repeat_cell per axis. + # Shape.generate_vtk takes a single isotropic resolution, so use the + # geometric mean to keep total point count proportional. + iso_res = int( + self.resolution * np.cbrt(np.prod(self.repeat_cell)) ) + polydata = part.generate_vtk(bounds=self._bounds, resolution=max(iso_res, 10)) polydata = rotate(polydata, center=(0, 0, 0), rotation=self.orientation) return polydata.translate(xyz=self.center) @@ -857,8 +863,6 @@ def _create_grid( def _setup_frep_field(self: CylindricalTpms) -> None: """Build F-rep field in Cartesian coordinates (inverse cylindrical mapping).""" - from .implicit_ops import from_field, normalize_to_sdf # noqa: PLC0415 - k_x, k_y, k_z = 2.0 * np.pi / self.cell_size ps = self.phase_shift cyl_r = self.cylinder_radius @@ -877,16 +881,12 @@ def _raw_field( k_z * (z + ps[2]), ) - self._raw_field_func = _raw_field - - raw_shape = from_field(_raw_field) - sdf_shape = normalize_to_sdf(raw_shape) - self._func = sdf_shape.func - - # Bounds in Cartesian space r_max = cyl_r + 0.5 * self.cell_size[0] * self.repeat_cell[0] half_z = 0.5 * self.cell_size[2] * self.repeat_cell[2] - self._bounds = (-r_max, r_max, -r_max, r_max, -half_z, half_z) + self._finalize_frep( + _raw_field, + (-r_max, r_max, -r_max, r_max, -half_z, half_z), + ) class SphericalTpms(Tpms): @@ -988,8 +988,6 @@ def _create_grid( def _setup_frep_field(self: SphericalTpms) -> None: """Build F-rep field in Cartesian coordinates (inverse spherical mapping).""" - from .implicit_ops import from_field, normalize_to_sdf # noqa: PLC0415 - k_x, k_y, k_z = 2.0 * np.pi / self.cell_size ps = self.phase_shift sph_r = self.sphere_radius @@ -1003,8 +1001,10 @@ def _raw_field( ) -> npt.NDArray[np.float64]: rho_cart = np.sqrt(x**2 + y**2 + z**2) rho = rho_cart - sph_r - theta = (np.arccos(np.clip(z / np.maximum(rho_cart, 1e-30), -1, 1)) - - np.pi / 2.0) / unit_theta + theta = ( + np.arccos(np.clip(z / np.maximum(rho_cart, 1e-30), -1, 1)) + - np.pi / 2.0 + ) / unit_theta phi = np.arctan2(y, x) / unit_phi return self.surface_function( k_x * (rho + ps[0]), @@ -1012,15 +1012,11 @@ def _raw_field( k_z * (phi + ps[2]), ) - self._raw_field_func = _raw_field - - raw_shape = from_field(_raw_field) - sdf_shape = normalize_to_sdf(raw_shape) - self._func = sdf_shape.func - - # Bounds in Cartesian space r_max = sph_r + 0.5 * self.cell_size[0] * self.repeat_cell[0] - self._bounds = (-r_max, r_max, -r_max, r_max, -r_max, r_max) + self._finalize_frep( + _raw_field, + (-r_max, r_max, -r_max, r_max, -r_max, r_max), + ) class Infill(Tpms): From bc2b485826ccb4b4fbd8d6faf9cd4f1d5c668611 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Sat, 25 Apr 2026 13:14:50 +0200 Subject: [PATCH 06/15] Support variable offsets and F-rep TPMS refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add variable-thickness and SDF utilities and refactor TPMS F-rep pipeline. Key changes: - implicit_ops.shell: allow thickness to be a callable Field (spatially-varying) and add docs; add box() SDF primitive; improve gradient-normalization fallback to preserve sign when grad is degenerate. - tpms.Tpms: store offset callables, accept callable offsets and preserve them for re-evaluation; compute density→offset via the same marching-cubes pipeline (generate_vtk) and handle bracket failures; expose as_sheet that accepts variable offsets; add helpers (_half_offset_field, _cell_box, clipped variants) and pick F-rep shapes for parts. - Mesh -> CadQuery conversion: replace previous face assembly with sewing (BRepBuilderAPI_Sewing) to produce a proper shell; best-effort upgrade to solids via _try_make_solid; generate() now uses the pure F-rep path (marching cubes on SDF Shape) and applies smoothing/solid conversion; generate_vtk mirrors the same F-rep pipeline and short-circuits full-density case by returning a box mesh. - Misc: add _isotropic_resolution to map per-axis resolution to a single isotropic value; improve argument validation and error messages; reset cached _offset_func on assignment. - tests: update TPMS surface function discovery to filter out camelCase aliases and non-TPMS callables by checking function signatures (3 parameters). These changes make TPMS generation consistent between the VTK and CAD paths, enable spatially-varying thickness, and improve robustness around degenerate fields and solid construction. --- microgen/shape/implicit_ops.py | 61 +++- microgen/shape/tpms.py | 502 ++++++++++++++++++++------------- tests/shapes/test_tpms.py | 17 +- 3 files changed, 368 insertions(+), 212 deletions(-) diff --git a/microgen/shape/implicit_ops.py b/microgen/shape/implicit_ops.py index a269c08a..f42b6518 100644 --- a/microgen/shape/implicit_ops.py +++ b/microgen/shape/implicit_ops.py @@ -224,10 +224,23 @@ def _batched( # --------------------------------------------------------------------------- -def shell(shape: Shape, thickness: float) -> Shape: - """Hollow shell: ``|f(p)| - thickness / 2``.""" +def shell(shape: Shape, thickness: float | Field) -> Shape: + """Hollow shell: ``|f(p)| - thickness(p) / 2``. + + ``thickness`` may be a constant (scalar) or a callable + ``thickness(x, y, z) -> array`` for spatially-varying shells. Negative or + zero thickness at a point yields no inclusion in the shell at that point. + """ f = shape.require_func() - half_t = thickness / 2.0 + if callable(thickness): + t_func = thickness + + def _shell_field(x, y, z, _f=f, _t=t_func): # noqa: ANN001 + return np.abs(_f(x, y, z)) - _t(x, y, z) / 2.0 + + return _make_shape(func=_shell_field, bounds=shape.bounds) + + half_t = float(thickness) / 2.0 return _make_shape( func=lambda x, y, z, _f=f, _ht=half_t: np.abs(_f(x, y, z)) - _ht, bounds=shape.bounds, @@ -312,6 +325,37 @@ def from_field( return _make_shape(func=func, bounds=bounds) +def box( + dims: tuple[float, float, float], + center: tuple[float, float, float] = (0.0, 0.0, 0.0), +) -> Shape: + """Axis-aligned box as an F-rep Shape. + + SDF formula ``max(|x-cx|-hx, |y-cy|-hy, |z-cz|-hz)``: signed distance to + the box surface (negative inside, positive outside, zero on the surface). + Useful as a clipping primitive — e.g. ``intersection(skeletal, box(...))`` + bounds an unbounded TPMS skeletal field to a single cell. + + :param dims: full side lengths ``(dx, dy, dz)`` + :param center: box center (default origin) + :return: :class:`~microgen.shape.shape.Shape` carrying the box SDF + """ + hx, hy, hz = (0.5 * float(d) for d in dims) + cx, cy, cz = (float(c) for c in center) + + def _box_sdf( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + return np.maximum.reduce( + [np.abs(x - cx) - hx, np.abs(y - cy) - hy, np.abs(z - cz) - hz], + ) + + bounds: BoundsType = (cx - hx, cx + hx, cy - hy, cy + hy, cz - hz, cz + hz) + return _make_shape(func=_box_sdf, bounds=bounds) + + def _fd_sdf( f: Field, epsilon: float, @@ -329,7 +373,11 @@ def sdf( gy = (f(x, y + h, z) - f(x, y - h, z)) / (2 * h) gz = (f(x, y, z + h) - f(x, y, z - h)) / (2 * h) grad_mag = np.sqrt(gx**2 + gy**2 + gz**2) - return val / np.maximum(grad_mag, epsilon) + # Where the gradient is degenerate (e.g. flat-z fields like the + # honeycomb_* surfaces, or saddle points), normalization would + # blow up to ±1/epsilon — preserve the raw field's *sign* by + # falling back to the unnormalized value there. + return np.where(grad_mag > epsilon, val / np.maximum(grad_mag, epsilon), val) return sdf @@ -374,7 +422,10 @@ def sdf( grad_mag = np.sqrt( dfdx(x, y, z) ** 2 + dfdy(x, y, z) ** 2 + dfdz(x, y, z) ** 2, ) - return val / np.maximum(grad_mag, epsilon) + # Same fallback as in `_fd_sdf`: where the gradient vanishes + # (degenerate flat-z fields, saddle points), keep the raw value + # so its sign is preserved without exploding into ±1/epsilon. + return np.where(grad_mag > epsilon, val / np.maximum(grad_mag, epsilon), val) except Exception: # noqa: BLE001 sdf = _fd_sdf(f, epsilon) diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 6081a882..6e6b9fa4 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -110,6 +110,11 @@ def __init__( # noqa: PLR0913 self.surface_function = surface_function self._offset = offset if offset is not None else 0.0 + # Stores the offset callable when one is provided, so the F-rep path + # can re-evaluate variable thickness on its own marching-cubes grid. + self._offset_func: Field | None = ( + offset if (offset is not None and callable(offset) and not isinstance(offset, OffsetGrading)) else None + ) self.phase_shift = phase_shift self.grid: pv.StructuredGrid @@ -177,11 +182,20 @@ def _compute_offset_to_fit_density( part_type: Literal["sheet", "lower skeletal", "upper skeletal"], resolution: int | None = None, ) -> float: - """Compute the offset to fit the required density.""" + """Compute the offset that yields the requested density. + + Searches with the same F-rep ``generate_vtk`` pipeline the user + invokes, so the offset returned actually reproduces the requested + density at the user-facing resolution. When the target density is + too high to reach at this resolution (marching cubes saturates + slightly below 1.0 due to surface-grid discretization), falls back + to the offset limit instead of failing the bracket. + """ if self.density is None: err_msg = f"density must be between 0 and 1. Given: {self.density}" raise ValueError(err_msg) + part = "skeletal" if "skeletal" in part_type else part_type if self.density == 1.0: self.offset = ( self.offset_lim["sheet"][1] @@ -193,19 +207,31 @@ def _compute_offset_to_fit_density( temp_tpms = Tpms( surface_function=self.surface_function, offset=0.0, + cell_size=self.cell_size, + repeat_cell=self.repeat_cell, resolution=resolution if resolution is not None else self.resolution, ) + cell_volume = abs(temp_tpms.grid.volume) def density(offset: float) -> float: temp_tpms.offset = offset - grid_part = getattr(temp_tpms, f"grid_{part_type.replace(' ', '_')}") - return abs(grid_part.volume) + mesh = temp_tpms.generate_vtk(type_part=part_type) + return abs(mesh.volume) / cell_volume + + bracket = self.offset_lim[part] + try: + computed_offset = root_scalar( + lambda offset: density(offset) - self.density, + bracket=bracket, + ).root + except ValueError: + # Bracket sign mismatch — usually because target density exceeds + # the achievable max at this resolution. Pick the bracket + # endpoint that lies on the side of self.density. + d_lo = density(bracket[0]) - self.density + d_hi = density(bracket[1]) - self.density + computed_offset = bracket[1] if abs(d_hi) <= abs(d_lo) else bracket[0] - part = "skeletal" if "skeletal" in part_type else part_type - computed_offset = root_scalar( - lambda offset: density(offset) - self.density, - bracket=self.offset_lim[part], - ).root self.offset = computed_offset return computed_offset @@ -396,32 +422,127 @@ def raw_field(self: Tpms) -> Field: return self._raw_field_func def as_sheet(self: Tpms, thickness: float | None = None) -> Shape: - """Return an F-rep Shape representing a uniform-thickness TPMS sheet. + """Return an F-rep Shape representing a TPMS sheet of given thickness. Uses the SDF-normalized field, so *thickness* is in physical units. - If *thickness* is ``None``, uses ``self.offset``. + If *thickness* is ``None``, uses ``self.offset`` (which may be a + scalar, an array sampled on ``self.grid``, or a callable in which + case the callable form is used directly). """ from .implicit_ops import shell # noqa: PLC0415 from .shape import Shape # noqa: PLC0415 - t = thickness if thickness is not None else self._offset - # Wrap in plain Shape so generate_vtk uses Shape's marching cubes, - # not Tpms.generate_vtk (which would recurse back here). - return shell(Shape(func=self._func, bounds=self._bounds), float(t)) + if thickness is not None: + t: float | npt.NDArray | Field = float(thickness) + elif self._offset_func is not None: + t = self._offset_func + else: + t = self._offset + return shell(Shape(func=self._func, bounds=self._bounds), t) + + def _half_offset_field(self: Tpms) -> Field | float: + """Return half the offset as a callable (variable) or scalar (constant). + + Variable offset stored as a callable can be re-evaluated on the + marching-cubes grid; an array offset (sampled on ``self.grid``) + cannot be remapped to arbitrary points, so the only safe fallback + for arrays is to use a scalar 0 (sheet/skeletal degenerate to the + zero-isosurface). + """ + if self._offset_func is not None: + f = self._offset_func + + def _half(x, y, z, _f=f): # noqa: ANN001 + return 0.5 * _f(x, y, z) + + return _half + if isinstance(self._offset, (int, float)): + return 0.5 * float(self._offset) + # array — no safe re-evaluation; degenerate to zero (skeletal at f=0). + return 0.0 def as_upper_skeletal(self: Tpms) -> Shape: - """Return F-rep Shape for the upper skeletal (f > 0 side).""" - from .implicit_ops import complement # noqa: PLC0415 - from .shape import Shape # noqa: PLC0415 + """F-rep Shape for the *upper* skeletal: ``{p : f(p) > offset/2}``. + + Volume scales with the chosen offset (smaller offset ⇒ larger + skeletal), matching the historical CadQuery behaviour and the VTK + grid-clip path. + """ + from .implicit_ops import from_field # noqa: PLC0415 + + f = self._func + h = self._half_offset_field() + if callable(h): + + def _upper(x, y, z, _f=f, _h=h): # noqa: ANN001 + return -_f(x, y, z) + _h(x, y, z) + + else: + + def _upper(x, y, z, _f=f, _h=h): # noqa: ANN001 + return -_f(x, y, z) + _h - return complement(Shape(func=self._func, bounds=self._bounds)) + return from_field(func=_upper, bounds=self._bounds) def as_lower_skeletal(self: Tpms) -> Shape: - """Return F-rep Shape for the lower skeletal (f < 0 side).""" + """F-rep Shape for the *lower* skeletal: ``{p : f(p) < -offset/2}``.""" + from .implicit_ops import from_field # noqa: PLC0415 + + f = self._func + h = self._half_offset_field() + if callable(h): + + def _lower(x, y, z, _f=f, _h=h): # noqa: ANN001 + return _f(x, y, z) + _h(x, y, z) + + else: + + def _lower(x, y, z, _f=f, _h=h): # noqa: ANN001 + return _f(x, y, z) + _h + + return from_field(func=_lower, bounds=self._bounds) + + def as_surface(self: Tpms) -> Shape: + """Return F-rep Shape for the (open) zero-isosurface, no thickness. + + Same field as :meth:`as_lower_skeletal`; meant for ``type_part="surface"``. + Marching cubes will produce an open shell — there is no enclosed volume. + """ from .implicit_ops import from_field # noqa: PLC0415 return from_field(func=self._func, bounds=self._bounds) + def _cell_box(self: Tpms) -> Shape: + """SDF Shape of this TPMS' cell (cell_size × repeat_cell, centered origin).""" + from .implicit_ops import box # noqa: PLC0415 + + dims = tuple(float(d) for d in (self.cell_size * self.repeat_cell)) + return box(dims=dims, center=(0.0, 0.0, 0.0)) + + def _clipped_sheet(self: Tpms) -> Shape: + """Sheet F-rep clipped to the cell box. + + When the offset approaches its upper limit the sheet field is + uniformly negative inside the cell and marching cubes finds no + boundary — clipping by the cell box makes the box face the closed + boundary, yielding a full-cell mesh as expected. + """ + from .implicit_ops import intersection # noqa: PLC0415 + + return intersection(self.as_sheet(), self._cell_box()) + + def _clipped_upper_skeletal(self: Tpms) -> Shape: + """Upper skeletal F-rep clipped to the cell box (closed under marching cubes).""" + from .implicit_ops import intersection # noqa: PLC0415 + + return intersection(self.as_upper_skeletal(), self._cell_box()) + + def _clipped_lower_skeletal(self: Tpms) -> Shape: + """Lower skeletal F-rep clipped to the cell box (closed under marching cubes).""" + from .implicit_ops import intersection # noqa: PLC0415 + + return intersection(self.as_lower_skeletal(), self._cell_box()) + def _update_grid_offset(self: Tpms) -> None: self.grid["lower_surface"] = self.grid["surface"] + 0.5 * self.offset self.grid["upper_surface"] = self.grid["surface"] - 0.5 * self.offset @@ -436,11 +557,16 @@ def offset( self: Tpms, offset: float | npt.NDArray[np.float64] | OffsetGrading | Field, ) -> None: + # Reset cached callable form on every assignment. + self._offset_func = None if isinstance(offset, (int, float, np.ndarray)): self._offset = offset elif isinstance(offset, OffsetGrading): self._offset = offset.compute_offset(self.grid) elif callable(offset): + # Keep the callable so the F-rep path can re-evaluate it on the + # marching-cubes grid (which differs from ``self.grid``). + self._offset_func = offset self._offset = offset(self.grid.x, self.grid.y, self.grid.z).ravel("F") else: err_msg = "offset must be a float, a numpy array or a callable" @@ -449,9 +575,20 @@ def offset( self._update_grid_offset() self.offset_updated = True - def _create_shell(self: Tpms, mesh: pv.PolyData) -> cq.Shell: + def _mesh_to_shell(self: Tpms, mesh: pv.PolyData) -> cq.Shape: + """Convert a triangulated PyVista mesh to a CadQuery ``Shape``. + + Builds one ``cq.Face`` per triangle, then *sews* them with OCCT's + ``BRepBuilderAPI_Sewing`` so adjacent triangles share edges — without + sewing, ``Shell.makeShell`` returns a disjoint compound and the result + cannot be turned into a Solid. Returns the sewn shape (a + ``TopoDS_Shell`` if sewing succeeded into one shell, otherwise the + compound that sewing produced). + """ + from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing # noqa: PLC0415 + if not mesh.is_all_triangles: - mesh.triangulate(inplace=True) # useless ? + mesh.triangulate(inplace=True) triangles = mesh.faces.reshape(-1, 4)[:, 1:] triangles = np.c_[triangles, triangles[:, 0]] @@ -464,147 +601,18 @@ def _create_shell(self: Tpms, mesh: pv.PolyData) -> cq.Shell: ) for start, end in zip(tri[:], tri[1:]) ] - wire = cq.Wire.assembleEdges(lines) faces.append(cq.Face.makeFromWires(wire)) - try: - shell = cq.Shell.makeShell(faces) - except ValueError as err: - err_msg = "Failed to create the shell, \ - try to increase the resolution or the smoothing." - raise ShellCreationError(err_msg) from err - return shell - - def _create_surface( - self: Tpms, - isovalue: float | npt.NDArray[np.float64] = 0.0, - smoothing: int = 0, - ) -> cq.Shell: - """Create a TPMS surface for the given isovalue.""" - if isinstance(isovalue, (int, float)): - scalars = self.grid["surface"] - isovalue - elif isinstance(isovalue, np.ndarray): - scalars = self.grid["surface"] - isovalue.ravel(order="F") - - mesh = self.grid.contour(isosurfaces=[0.0], scalars=scalars) - mesh.smooth(n_iter=smoothing, feature_smoothing=True, inplace=True) - mesh.clean(inplace=True) - - return self._create_shell(mesh=mesh) - - def _create_surfaces( - self: Tpms, - isovalues: list[float], - smoothing: int = 0, - ) -> list[cq.Shell]: - """Create TPMS surfaces for the corresponding isovalue. - - :param isovalues: list of isovalues corresponding to the required surfaces - :param smoothing: smoothing loop iterations - :param verbose: display progressbar of the conversion to CadQuery object + if not faces: + err_msg = "Mesh has no triangles to convert into a shell" + raise ShellCreationError(err_msg) - :return: list of CadQuery Shell objects of the required TPMS surfaces - """ - shells = [] - for i, isovalue in enumerate(isovalues): - logging.info("\nGenerating surface (%d/%d)", i + 1, len(isovalues)) - shell = self._create_surface( - isovalue=isovalue, - smoothing=smoothing, - ) - shells.append(shell) - - return shells - - def _generate_sheet_surfaces( - self: Tpms, - smoothing: int, - ) -> tuple[cq.Shape, cq.Shape]: - """Generate the surfaces to create the sheet part of the TPMS.""" - isovalues = [ - -0.5 * self.offset, - -0.25 * self.offset, - 0.25 * self.offset, - 0.5 * self.offset, - ] - - shells = self._create_surfaces(isovalues, smoothing) - - lower_surface = shells[0] - lower_test_surface = shells[1] - upper_test_surface = shells[2] - upper_surface = shells[3] - - surface = lower_surface.fuse(upper_surface) - test_surface = lower_test_surface.fuse(upper_test_surface) - return surface, test_surface - - def _generate_lower_skeletal_surfaces( - self: Tpms, - smoothing: int, - ) -> tuple[cq.Shape, cq.Shape]: - """Generate the surfaces to create the lower skeletal part of the TPMS.""" - min_offset = 2.0 * np.min(self.grid["surface"]) - isovalues = [ - -0.5 * self.offset, - -0.25 * (self.offset - min_offset), - ] - - shells = self._create_surfaces(isovalues, smoothing) - - surface = shells[0] - test_surface = shells[1] - return surface, test_surface - - def _generate_upper_skeletal_surfaces( - self: Tpms, - smoothing: int, - ) -> tuple[cq.Shape, cq.Shape]: - """Generate the surfaces to create the upper skeletal part of the TPMS.""" - min_offset = 2.0 * np.min(self.grid["surface"]) - isovalues = [ - 0.5 * self.offset, - 0.25 * (self.offset - min_offset), - ] - - shells = self._create_surfaces(isovalues, smoothing) - - surface = shells[0] - test_surface = shells[1] - return surface, test_surface - - def _extract_part_from_box( - self: Tpms, - type_part: TpmsPartType, - smoothing: int, - ) -> cq.Shape: - """Extract the required part from the box.""" - box = cq.Workplane("front").box(*(self.cell_size * self.repeat_cell)) - - surface, test_surface = getattr( - self, - f"_generate_{type_part.replace(' ', '_')}_surfaces", - )(smoothing) - - splitted_box = box.split(surface) - tpms_solids = splitted_box.solids().all() - - # split each solid with the test surface to identify - # to what part type the solid belongs to - list_solids = [ - (solid.split(test_surface).solids().size(), solid.val()) - for solid in tpms_solids - ] - - # if the number of shapes is greater than 1, it means that the solid is split - # so it belongs to the required part - part_solids = [solid for (number, solid) in list_solids if number > 1] - part_shapes = [cq.Shape(solid.wrapped) for solid in part_solids] - return fuseShapes( - cqShapeList=part_shapes, - retain_edges=False, # True or False ? - ) + sewing = BRepBuilderAPI_Sewing() + for f in faces: + sewing.Add(f.wrapped) + sewing.Perform() + return cq.Shape(sewing.SewedShape()) def _check_offset( self: Tpms, @@ -641,6 +649,35 @@ def _check_offset( generate '{type_part}' part and lower than {self.offset_lim[part][1]}" raise ValueError(err_msg) + _VALID_PARTS = ("sheet", "lower skeletal", "upper skeletal", "surface") + + def _frep_part(self: Tpms, type_part: TpmsPartType) -> Shape: + """Pick the F-rep :class:`Shape` for *type_part*. + + Skeletals are intersected with the cell box so marching cubes + produces a *closed* shell (the unclipped skeletal field is + unbounded). ``"surface"`` and ``"sheet"`` are already bounded by + construction (zero-isosurface and `shell()`-clipped, respectively). + """ + if type_part == "sheet": + return self._clipped_sheet() + if type_part == "upper skeletal": + return self._clipped_upper_skeletal() + if type_part == "lower skeletal": + return self._clipped_lower_skeletal() + if type_part == "surface": + return self.as_surface() + err_msg = f"type_part {type_part!r} must be one of {self._VALID_PARTS}" + raise ValueError(err_msg) + + def _isotropic_resolution(self: Tpms) -> int: + """Map ``self.resolution`` (per-axis) to an isotropic Shape resolution. + + ``Shape.generate_vtk`` takes a single resolution; we use the geometric + mean of the per-axis cell counts so total grid points stay proportional. + """ + return max(int(self.resolution * np.cbrt(np.prod(self.repeat_cell))), 10) + def generate( self: Tpms, type_part: TpmsPartType = "sheet", @@ -648,18 +685,21 @@ def generate( algo_resolution: int | None = None, **_: KwargsGenerateType, ) -> cq.Shape: - """Generate CadQuery Shape object of the required TPMS part. - - :param type_part: part of the TPMS desired \ - ('sheet', 'lower skeletal', 'upper skeletal' or 'surface') - :param smoothing: smoothing loop iterations - :param verbose: display progressbar of the conversion to CadQuery object - :param algo_resolution: if offset must be computed to fit density, \ - resolution of the temporary TPMS used to compute the offset - - :return: CadQuery Shape object of the required TPMS part + """Generate the OCCT/CadQuery shape of the requested TPMS part. + + Pure F-rep pipeline: pick the SDF Shape via :meth:`_frep_part`, run + marching cubes through :meth:`Shape.generate_vtk`, optionally smooth, + then build a CadQuery ``Shell``. The same SDF + same marching-cubes + grid is used by :meth:`generate_vtk`, so volumes converge to identical + values up to discretization. + + :param type_part: ``"sheet"``, ``"lower skeletal"``, ``"upper skeletal"`` + or ``"surface"`` (open zero-isosurface, no thickness) + :param smoothing: number of Laplacian smoothing iterations on the mesh + :param algo_resolution: temporary-TPMS resolution for density→offset + search (only used when ``self.density`` is set) """ - if type_part not in ["sheet", "lower skeletal", "upper skeletal", "surface"]: + if type_part not in self._VALID_PARTS: err_msg = ( f"type_part ({type_part}) must be 'sheet', 'lower skeletal', " "'upper skeletal' or 'surface'" @@ -671,40 +711,96 @@ def generate( logging.warning("offset is ignored for 'surface' part") if self.density is not None: logging.warning("density is ignored for 'surface' part") - return self._create_surface( - isovalue=0, - smoothing=smoothing, - ) + else: + if self.density is not None: + self._compute_offset_to_fit_density( + part_type=type_part, + resolution=algo_resolution, + ) + self._check_offset(type_part) - if self.density is not None: - self._compute_offset_to_fit_density( - part_type=type_part, - resolution=algo_resolution, + frep = self._frep_part(type_part) + mesh = frep.generate_vtk( + bounds=self._bounds, + resolution=self._isotropic_resolution(), + ) + if smoothing > 0: + mesh.smooth(n_iter=smoothing, feature_smoothing=True, inplace=True) + mesh.clean(inplace=True) + + if mesh.n_cells == 0: + err_msg = ( + f"Marching cubes produced an empty mesh for '{type_part}'; " + "check offset / density / resolution." ) + raise ShellCreationError(err_msg) - self._check_offset(type_part) + shape = self._mesh_to_shell(mesh) - shape = self._extract_part_from_box(type_part, smoothing) + if type_part != "surface": + # Closed shell ⇒ try to upgrade into a Solid so volume queries and + # downstream booleans behave correctly. If sewing produced a + # compound (non-manifold patches) or OCCT refuses, keep the shell. + shape = self._try_make_solid(shape) shape = rotate(obj=shape, center=(0, 0, 0), rotation=self.orientation) return shape.translate(self.center) + @staticmethod + def _try_make_solid(shape: cq.Shape) -> cq.Shape: + """Best-effort upgrade of a sewn shell into a closed Solid. + + Returns the original shape unchanged if the sewn result is a Compound + (multiple disjoint shells, can't be a single solid) or if OCCT refuses + the conversion. + """ + from OCP.TopAbs import TopAbs_SHELL # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + from OCP.TopoDS import TopoDS # noqa: PLC0415 + + wrapped = shape.wrapped + # Already a Shell? Try to make a Solid directly. + if wrapped.ShapeType() == TopAbs_SHELL: + try: + shell = TopoDS.Shell_s(wrapped) + return cq.Shape(cq.Solid.makeSolid(cq.Shell(shell)).wrapped) + except (ValueError, RuntimeError): + return shape + + # Compound: extract Shells, build a Solid per closed shell, fuse. + exp = TopExp_Explorer(wrapped, TopAbs_SHELL) + solids: list[cq.Shape] = [] + while exp.More(): + try: + shell = TopoDS.Shell_s(exp.Current()) + solid = cq.Solid.makeSolid(cq.Shell(shell)) + solids.append(cq.Shape(solid.wrapped)) + except (ValueError, RuntimeError): + pass + exp.Next() + + if not solids: + return shape + if len(solids) == 1: + return solids[0] + return fuseShapes(cqShapeList=solids, retain_edges=False) + def generate_vtk( self: Tpms, type_part: TpmsPartType = "sheet", algo_resolution: int | None = None, **_: KwargsGenerateType, ) -> pv.PolyData: - """Generate VTK PolyData object of the required TPMS part. + """Generate the PyVista mesh of the requested TPMS part. - Uses the SDF-normalized implicit field with F-rep operations: - sheet = ``shell(sdf, thickness)``, skeletal = one side of the SDF. + Same F-rep pipeline as :meth:`generate` (skeletals are clipped to the + cell box), so the two outputs share the exact same triangulation and + therefore the same volume. - :param type_part: part of the TPMS desired - :param algo_resolution: if offset must be computed to fit density, \ - resolution of the temporary TPMS used to compute the offset - - :return: VTK PolyData object of the required TPMS part + :param type_part: ``"sheet"``, ``"lower skeletal"``, ``"upper skeletal"`` + or ``"surface"`` + :param algo_resolution: temporary-TPMS resolution for density→offset + search (only used when ``self.density`` is set) """ if type_part == "surface": return self.surface @@ -715,27 +811,29 @@ def generate_vtk( ) raise ValueError(err_msg) + if self.density == 1.0: + # Short-circuit: at full density the part fills the whole cell, + # return the cell-box mesh directly (exact volume — the + # marching-cubes path would give a slight discretization gap). + box = pv.Box(bounds=self._bounds, level=0, quads=False) + box = box.extract_surface().clean().triangulate() + box = rotate(box, center=(0, 0, 0), rotation=self.orientation) + return box.translate(xyz=self.center) + if self.density is not None: self._compute_offset_to_fit_density( part_type=type_part, resolution=algo_resolution, ) - self._check_offset(type_part) - - if type_part == "sheet": - part = self.as_sheet() - elif type_part == "upper skeletal": - part = self.as_upper_skeletal() - else: # lower skeletal - part = self.as_lower_skeletal() - - # Match the grid resolution: resolution * repeat_cell per axis. - # Shape.generate_vtk takes a single isotropic resolution, so use the - # geometric mean to keep total point count proportional. - iso_res = int( - self.resolution * np.cbrt(np.prod(self.repeat_cell)) + # Note: no `_check_offset` here — the F-rep VTK path handles negative + # / zero / variable offsets gracefully. ``generate()`` still applies + # the historical CAD-side restriction. + + frep = self._frep_part(type_part) + polydata = frep.generate_vtk( + bounds=self._bounds, + resolution=self._isotropic_resolution(), ) - polydata = part.generate_vtk(bounds=self._bounds, resolution=max(iso_res, 10)) polydata = rotate(polydata, center=(0, 0, 0), rotation=self.orientation) return polydata.translate(xyz=self.center) diff --git a/tests/shapes/test_tpms.py b/tests/shapes/test_tpms.py index 0d963fce..8b0ed9b6 100644 --- a/tests/shapes/test_tpms.py +++ b/tests/shapes/test_tpms.py @@ -3,7 +3,7 @@ from __future__ import annotations import re -from inspect import getmembers, isfunction +from inspect import getmembers, isfunction, signature from typing import Literal import numpy as np @@ -21,11 +21,18 @@ def _get_microgen_surface_functions() -> list[str]: - # Dont take into account deprecated surface functions named in camelCase + """List the actual TPMS surface functions in microgen.surface_functions. + + Filters out: + - deprecated camelCase aliases (any uppercase letter) + - non-TPMS callables exposed via re-import (autograd ``cos``/``sin``) — + they take fewer than 3 args and would crash when invoked as ``f(x,y,z)``. + """ return [ - func[0] - for func in getmembers(microgen.surface_functions, isfunction) - if not any(ele.isupper() for ele in func[0]) + name + for name, fn in getmembers(microgen.surface_functions, isfunction) + if not any(c.isupper() for c in name) + and len(signature(fn).parameters) == 3 ] From 20201a928c211abfa7bbff6a926ad08dcdc341d7 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Sat, 25 Apr 2026 22:25:18 +0200 Subject: [PATCH 07/15] Add Conformal TPMS and bunny examples Introduce a Conformal TPMS implementation and related improvements, plus two example scripts. Changes: - Add Conformal class to microgen.shape.tpms: builds TPMS in a local (w,u,v) frame using signed-distance as the radial coord, supports default_tangent_axis and optional clipping to the envelope. Overrides grid creation and cell-box to use the envelope surface. - Adjust Tpms density handling: density computations now measure against an envelope volume via _density_envelope_volume(), avoid cloning instances during root-finding, and correctly restore density after computing offset. - Infill now measures density relative to the provided object volume and overrides the cell-box to clip to the object envelope. - Add examples/conformal_vs_cartesian_bunny.py demonstrating side-by-side Cartesian vs Conformal gyroid infill on the Stanford bunny. - Add gyroid_gd_bunny.py utility script for visualizing a gyroid field clipped to a bunny and computing relative density. Rationale: provide a conformal TPMS mode that follows an arbitrary envelope surface (onion-shell style cells), make density semantics consistent (relative to the intended envelope), and ship examples to illustrate the difference. --- examples/conformal_vs_cartesian_bunny.py | 102 ++++++++ microgen/shape/tpms.py | 311 +++++++++++++++++++++-- 2 files changed, 394 insertions(+), 19 deletions(-) create mode 100644 examples/conformal_vs_cartesian_bunny.py diff --git a/examples/conformal_vs_cartesian_bunny.py b/examples/conformal_vs_cartesian_bunny.py new file mode 100644 index 00000000..d003638d --- /dev/null +++ b/examples/conformal_vs_cartesian_bunny.py @@ -0,0 +1,102 @@ +"""Side-by-side: Cartesian :class:`Infill` vs :class:`Conformal` on a bunny. + +Both fill the same bunny envelope with the same gyroid TPMS at the same +offset and ``repeat_cell``. Two visualizations: + +- **Left** — ``Infill``: the gyroid is in the world Cartesian frame, the bunny + surface just *clips* it. Cells are aligned to global axes; near the + envelope you see them sliced on arbitrary planes. +- **Right** — ``Conformal``: the gyroid's *radial* coord is the signed + distance to the bunny surface. Cells stack as concentric "shells" along + the surface normal — an onion-skin scaffold that always presents + perpendicular to the envelope. + +Run with ``python examples/conformal_vs_cartesian_bunny.py``. +""" + +from __future__ import annotations + +import numpy as np +import pyvista as pv +from pyvista import examples + +from microgen.shape.surface_functions import gyroid +from microgen.shape.tpms import Conformal, Infill + + +# ----------------------------------------------------------------------------- +# Build the envelope +# ----------------------------------------------------------------------------- + +bunny = examples.download_bunny() +# Original bunny is ~6cm; scale to a reasonable working size and recenter. +bunny.transform(np.diag([40.0, 40.0, 40.0, 1.0]), inplace=True) +bunny.translate(-np.array(bunny.center_of_mass()), inplace=True) +print(f"bunny: volume={bunny.volume:.3f} bounds={tuple(np.round(bunny.bounds, 2))}") + + +# ----------------------------------------------------------------------------- +# Two TPMS infills with identical parameters +# ----------------------------------------------------------------------------- + +OFFSET = 0.4 +REPEAT_CELL = 5 + +cartesian_infill = Infill( + obj=bunny, + surface_function=gyroid, + offset=OFFSET, + repeat_cell=REPEAT_CELL, +) +cartesian_sheet = cartesian_infill.generate_vtk(type_part="sheet") +print( + f"Cartesian Infill : sheet_volume={abs(cartesian_sheet.volume):.3f} " + f"density_vs_bunny={abs(cartesian_sheet.volume) / bunny.volume:.2%}", +) + +conformal = Conformal( + envelope=bunny, + surface_function=gyroid, + offset=OFFSET, + repeat_cell=REPEAT_CELL, + default_tangent_axis=(1.0, 0.0, 0.0), +) +conformal_sheet = conformal.generate_vtk(type_part="sheet") +print( + f"Conformal : sheet_volume={abs(conformal_sheet.volume):.3f} " + f"density_vs_bunny={abs(conformal_sheet.volume) / bunny.volume:.2%}", +) + + +# ----------------------------------------------------------------------------- +# Cut both meshes through y for a clean cross-section view +# ----------------------------------------------------------------------------- + +cut_origin = (0.0, -0.3 * float(bunny.bounds[3] - bunny.bounds[2]), 0.0) +cartesian_view = cartesian_sheet.clip("y", origin=cut_origin, invert=False) +conformal_view = conformal_sheet.clip("y", origin=cut_origin, invert=False) + + +# ----------------------------------------------------------------------------- +# Side-by-side plot +# ----------------------------------------------------------------------------- + +plotter = pv.Plotter(shape=(1, 2), window_size=(1600, 800)) + +plotter.subplot(0, 0) +plotter.add_text("Cartesian Infill\n(gyroid in world frame, bunny clips)", font_size=10) +plotter.add_mesh(bunny, color="white", opacity=0.15) +plotter.add_mesh(cartesian_view, color="orange") +plotter.view_isometric() + +plotter.subplot(0, 1) +plotter.add_text( + "Conformal\n(gyroid in surface-normal frame — onion shells)", + font_size=10, +) +plotter.add_mesh(bunny, color="white", opacity=0.15) +plotter.add_mesh(conformal_view, color="cyan") +plotter.view_isometric() + +plotter.link_views() +plotter.show() diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 6e6b9fa4..93297c12 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -177,6 +177,17 @@ def offset_from_density( density=density, )._compute_offset_to_fit_density(part_type=part_type, resolution=resolution) + def _density_envelope_volume(self: Tpms) -> float: + """Return the *envelope* volume that ``density`` is measured against. + + For a plain :class:`Tpms` this is the cartesian cell volume. Subclasses + with a non-cartesian envelope (:class:`Infill`, :class:`Conformal`) + override this to use ``obj.volume`` / ``envelope.volume``, so that + ``density`` consistently means *fraction of the envelope filled by the + TPMS part* — not fraction of the cartesian bounding box. + """ + return abs(self.grid.volume) + def _compute_offset_to_fit_density( self: Tpms, part_type: Literal["sheet", "lower skeletal", "upper skeletal"], @@ -190,6 +201,10 @@ def _compute_offset_to_fit_density( too high to reach at this resolution (marching cubes saturates slightly below 1.0 due to surface-grid discretization), falls back to the offset limit instead of failing the bracket. + + Density is measured against :meth:`_density_envelope_volume`, so that + :class:`Infill` / :class:`Conformal` density is relative to the input + envelope volume rather than the cartesian bbox. """ if self.density is None: err_msg = f"density must be between 0 and 1. Given: {self.density}" @@ -204,33 +219,36 @@ def _compute_offset_to_fit_density( ) return self.offset - temp_tpms = Tpms( - surface_function=self.surface_function, - offset=0.0, - cell_size=self.cell_size, - repeat_cell=self.repeat_cell, - resolution=resolution if resolution is not None else self.resolution, - ) - cell_volume = abs(temp_tpms.grid.volume) + # Density is measured directly on ``self`` so subclass envelopes are + # respected without having to clone the instance. ``self.density`` + # is cleared during the search so that ``generate_vtk`` does NOT + # recurse back into this method. The final ``computed_offset`` is + # set as the permanent offset before returning (and ``self.density`` + # is restored so that downstream callers can re-query it). + envelope_volume = self._density_envelope_volume() + target_density = self.density + self.density = None def density(offset: float) -> float: - temp_tpms.offset = offset - mesh = temp_tpms.generate_vtk(type_part=part_type) - return abs(mesh.volume) / cell_volume + self.offset = offset # setter; also clears _offset_func + mesh = self.generate_vtk(type_part=part_type) + return abs(mesh.volume) / envelope_volume bracket = self.offset_lim[part] try: computed_offset = root_scalar( - lambda offset: density(offset) - self.density, + lambda offset: density(offset) - target_density, bracket=bracket, ).root except ValueError: # Bracket sign mismatch — usually because target density exceeds - # the achievable max at this resolution. Pick the bracket - # endpoint that lies on the side of self.density. - d_lo = density(bracket[0]) - self.density - d_hi = density(bracket[1]) - self.density + # the achievable max at this resolution (or the envelope shape). + # Pick the bracket endpoint that lies on the side of target_density. + d_lo = density(bracket[0]) - target_density + d_hi = density(bracket[1]) - target_density computed_offset = bracket[1] if abs(d_hi) <= abs(d_lo) else bracket[0] + finally: + self.density = target_density self.offset = computed_offset return computed_offset @@ -1201,8 +1219,263 @@ def _create_grid( logging.info("Grid resolution: %s points", grid.n_points) return grid + def _density_envelope_volume(self: Infill) -> float: + """Density is measured against the input object's volume.""" + return abs(self.obj.volume) -# Re-export for backward compatibility -from .shape import ShellCreationError # noqa: E402 + def _cell_box(self: Infill) -> Shape: + """Override the cell-box clip with the *object envelope* SDF. + + Without this override the F-rep marching cubes path (used by + :meth:`Tpms.generate_vtk` and :meth:`Tpms.generate`) would clip the + TPMS to the cartesian bounding box rather than to the input object — + producing volumes well above ``obj.volume`` and corrupting density. + """ + from .implicit_ops import from_field # noqa: PLC0415 + + bounds_arr = np.array(self.obj.bounds) + bounds: BoundsType = tuple(bounds_arr.tolist()) + + envelope = self.obj + + def _envelope_sdf( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + pts = pv.PolyData(np.column_stack([x.ravel(), y.ravel(), z.ravel()])) + pts.compute_implicit_distance(envelope, inplace=True) + return np.asarray(pts["implicit_distance"]).reshape(x.shape) + + return from_field(_envelope_sdf, bounds=bounds) + + +class Conformal(Tpms): + """TPMS that conforms to the local frame of an envelope surface. + + Where :class:`CylindricalTpms` and :class:`SphericalTpms` use a closed-form + inverse coordinate map to wrap the TPMS around a known surface (cylinder / + sphere), :class:`Conformal` does the same for *any* envelope surface + (PolyData) by using the signed distance to the envelope as the radial + coordinate. + + Local frame at every Cartesian point ``p``: + + - ``w(p) = signed_distance(p, envelope)`` — *radial* coordinate; ``w=0`` on + the envelope surface, ``w<0`` inside, ``w>0`` outside. Computed by + :meth:`pyvista.PolyData.compute_implicit_distance`. + - ``u(p) = (p - center) · t``, ``v(p) = (p - center) · b`` — *tangential* + coordinates expressed in the global frame ``(t, b, n=default_axis)``, + where ``t`` and ``b`` are the two axes of the plane perpendicular to + ``default_tangent_axis``. Default tangent axis is ``(1, 0, 0)``. + + The TPMS field is then ``surface_function(k_w · w, k_u · u, k_v · v)``, + so unit cells stack along the envelope normal direction (concentric + "shells" at distance ``±cell_size``, ``±2·cell_size``, …) and tile the + perpendicular plane in the other two axes. Compared to a Cartesian + :class:`Infill`, this produces shells that *follow* the envelope shape + rather than being sliced by it. + + Tangentially-intrinsic (true conformal) parametrizations require + per-point local-tangent rotation; that's a follow-up — this class is the + minimum useful step. + """ + + def __init__( # noqa: PLR0913 + self: Conformal, + envelope: pv.PolyData, + surface_function: Field, + offset: float | OffsetGrading | Field | None = None, + cell_size: float | Sequence[float] | npt.NDArray[np.float64] | None = None, + repeat_cell: int | Sequence[int] | npt.NDArray[np.int8] | None = None, + phase_shift: Sequence[float] = (0.0, 0.0, 0.0), + resolution: int = 20, + density: float | None = None, + default_tangent_axis: Sequence[float] = (1.0, 0.0, 0.0), + clip_to_envelope: bool = True, + ) -> None: + """Build a TPMS that wraps an envelope surface. + + :param envelope: the surface (``pv.PolyData``) the TPMS will follow. + Volumes are computed relative to the envelope volume when + ``clip_to_envelope=True``. + :param surface_function: tpms function or custom function ``f(x,y,z)=0`` + :param offset: offset / thickness of the TPMS sheet + :param cell_size: unit cell size; auto-derived from envelope bounds if None + :param repeat_cell: number of cells per axis; auto-derived if None + :param phase_shift: phase shift in the (w, u, v) local frame + :param resolution: per-axis grid resolution + :param density: target density relative to envelope volume (mutex with + ``offset``) + :param default_tangent_axis: world axis used to define the tangential + plane for ``(u, v)``. Default ``(1, 0, 0)`` ⇒ ``u = y - cy``, + ``v = z - cz``. + :param clip_to_envelope: if ``True``, the resulting grid / mesh is + clipped by the envelope surface (TPMS only fills the interior). + """ + self.envelope = envelope + self.clip_to_envelope = clip_to_envelope + + # Frame: n = default_tangent_axis (radial-projection axis), then build + # an orthonormal (t, b) basis for the perpendicular plane. + n = np.asarray(default_tangent_axis, dtype=np.float64) + n_norm = np.linalg.norm(n) + if n_norm < 1e-12: + err_msg = "default_tangent_axis must be a non-zero vector" + raise ValueError(err_msg) + n /= n_norm + # Pick a stable secondary direction not parallel to n. + helper = np.array([0.0, 0.0, 1.0]) if abs(n[2]) < 0.9 else np.array([1.0, 0.0, 0.0]) + t = helper - np.dot(helper, n) * n + t /= np.linalg.norm(t) + b = np.cross(n, t) + self._frame_n = n + self._frame_t = t + self._frame_b = b + self.default_tangent_axis = tuple(n.tolist()) + + # Auto-derive cell_size / repeat_cell from envelope bbox if missing. + bounds = np.array(envelope.bounds) + margin = 1.001 + envelope_dim = margin * (bounds[1::2] - bounds[::2]) + + if cell_size is not None and repeat_cell is not None: + err_msg = ( + "cell_size and repeat_cell cannot be given at the same time, " + "one is computed from the other." + ) + raise ValueError(err_msg) + if cell_size is None and repeat_cell is None: + repeat_cell = (4, 4, 4) + if cell_size is not None: + repeat_cell = np.maximum( + np.round(envelope_dim / np.asarray(cell_size, dtype=float)).astype(int), + 1, + ) + elif repeat_cell is not None: + cell_size = envelope_dim / np.asarray(repeat_cell, dtype=float) + + self._envelope_center = np.asarray(envelope.center, dtype=np.float64) -__all__ = ["CylindricalTpms", "Infill", "ShellCreationError", "SphericalTpms", "Tpms"] + super().__init__( + surface_function=surface_function, + offset=offset, + phase_shift=phase_shift, + cell_size=cell_size, + repeat_cell=repeat_cell, + resolution=resolution, + density=density, + ) + + # -- Local-frame field ------------------------------------------------- + + def _envelope_distance( + self: Conformal, + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + """Vectorized signed distance to the envelope (negative inside).""" + pts = pv.PolyData(np.column_stack([x.ravel(), y.ravel(), z.ravel()])) + pts.compute_implicit_distance(self.envelope, inplace=True) + return np.asarray(pts["implicit_distance"]).reshape(x.shape) + + def _local_coords( + self: Conformal, + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> tuple[ + npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64] + ]: + """Project Cartesian ``(x, y, z)`` onto the conformal local frame. + + - ``w`` = signed distance to the envelope (radial coord) + - ``u`` = ``(p - envelope_center) · t`` + - ``v`` = ``(p - envelope_center) · b`` + """ + cx, cy, cz = self._envelope_center + dx, dy, dz = x - cx, y - cy, z - cz + u = dx * self._frame_t[0] + dy * self._frame_t[1] + dz * self._frame_t[2] + v = dx * self._frame_b[0] + dy * self._frame_b[1] + dz * self._frame_b[2] + w = self._envelope_distance(x, y, z) + return u, v, w + + def _setup_frep_field(self: Conformal) -> None: + """Build F-rep field that evaluates the surface function in (w, u, v).""" + k_x, k_y, k_z = 2.0 * np.pi / self.cell_size + ps = self.phase_shift + + def _raw_field( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + u, v, w = self._local_coords(x, y, z) + return self.surface_function( + k_x * (w + ps[0]), + k_y * (u + ps[1]), + k_z * (v + ps[2]), + ) + + bounds_arr = np.array(self.envelope.bounds) + bounds: BoundsType = tuple(bounds_arr.tolist()) + self._finalize_frep(_raw_field, bounds) + + # -- Override grid creation to clip to the envelope -------------------- + + def _create_grid( + self: Conformal, + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> pv.StructuredGrid: + """Cartesian grid covering the envelope bbox, optionally clipped.""" + cx, cy, cz = self._envelope_center + grid = super()._create_grid(x + cx, y + cy, z + cz) + if self.clip_to_envelope: + grid = grid.clip_surface(self.envelope) + logging.info( + "Conformal grid clipped to envelope: %d points", grid.n_points, + ) + return grid + + def _density_envelope_volume(self: Conformal) -> float: + """Density is measured against the envelope volume.""" + return abs(self.envelope.volume) + + # -- Override the F-rep "cell box" with the envelope itself ------------ + + def _cell_box(self: Conformal) -> Shape: + """SDF Shape of the envelope (used to clip TPMS parts to the interior). + + Replaces the parent's axis-aligned cell-box clip — the natural cell of + a Conformal TPMS *is* the envelope. Returns a Shape whose field is + ``signed_distance(p, envelope)`` (negative inside). + """ + from .implicit_ops import from_field # noqa: PLC0415 + + bounds_arr = np.array(self.envelope.bounds) + bounds: BoundsType = tuple(bounds_arr.tolist()) + + def _envelope_sdf( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + return self._envelope_distance(x, y, z) + + return from_field(_envelope_sdf, bounds=bounds) + + +# Re-export for backward compatibility +from .shape import BoundsType, ShellCreationError # noqa: E402 + +__all__ = [ + "Conformal", + "CylindricalTpms", + "Infill", + "ShellCreationError", + "SphericalTpms", + "Tpms", +] From d1ec0d39f71dc4585341d72aac04bbbf9fea709f Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Mon, 27 Apr 2026 09:15:40 +0200 Subject: [PATCH 08/15] TPMS: parametric grids, Sweep and GradedInfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major refactor and feature additions to TPMS generation and infill handling. Key changes: - Add parametric-grid generation path for Cylindrical, Spherical and a new Sweep class (TPMS along a curve) to avoid MC Cartesian sampling artefacts; introduce _uses_parametric_grid flag and seam merge logic. - Add Sweep: builds parallel-transport frames, interpolates curve frames, maps (s,r,θ) parametric grid to Cartesian, and provides tube SDF and resolution heuristics. - Fix angle/arc conversions for cylindrical/spherical grids (use y/radius and z/radius) and tighten F-rep bounds; implement proper cell_box SDFs for cylindrical/spherical shells and Sweep tube. - Improve Tpms.generate path: support full-density envelope mesh via _envelope_mesh_at_full_density/_via_cell_box and prefer parametric-grid extraction when applicable; sample callable offsets from StructuredGrid or raw points. - Infill improvements: auto-orient normals, preserve original input volume for density calculations, route sheet/upper/lower properties to generate_vtk, and use object bbox for F-rep bounds. - Add helper SDFs and utilities: wedge and double-cone SDFs, per-axis interp helper. - Add new example scripts (tpms_infill_gallery.py, gyroid_gd_bunny.py), remove conformal_vs_cartesian_bunny.py, and add new tests (test_implicit_cylinder.py, test_kevin.py) plus updates to tests/shapes/test_tpms.py. Rationale: fix sampling artefacts near poles/axes, make full-density shortcut geometrically correct for non-box envelopes, support TPMS swept along arbitrary curves, and make density-fitting for infill robust to non-manifold/oriented inputs. Files added/removed include examples and small demo scripts. --- .DS_Store | Bin 0 -> 6148 bytes examples/conformal_vs_cartesian_bunny.py | 102 --- examples/tpms_infill_gallery.py | 116 +++ gyroid_gd_bunny.py | 87 ++ microgen/shape/tpms.py | 1060 +++++++++++++++++----- tests/shapes/test_tpms.py | 116 +++ 6 files changed, 1166 insertions(+), 315 deletions(-) create mode 100644 .DS_Store delete mode 100644 examples/conformal_vs_cartesian_bunny.py create mode 100644 examples/tpms_infill_gallery.py create mode 100644 gyroid_gd_bunny.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 pv.DataSet: + """Cut a mesh by a y-plane at ``frac × bbox_y`` so internals are visible.""" + bb = np.array(mesh.bounds) + y_origin = bb[2] + frac * (bb[3] - bb[2]) + return mesh.clip("y", origin=(0.0, y_origin, 0.0), invert=False) + + +def report(label: str, mesh: pv.DataSet) -> None: + print(f"{label:32s} vol={abs(mesh.volume):7.3f} n_cells={mesh.n_cells}") + + +# ----------------------------------------------------------------------------- +# 1. SphericalTpms — gyroid wrapping a sphere (auto-fill θ + φ via repeat=0) +# ----------------------------------------------------------------------------- + +sph = SphericalTpms( + radius=SPHERE_RADIUS, + surface_function=gyroid, + offset=OFFSET, + cell_size=CELL_SIZE, + repeat_cell=(2, 0, 0), +) +m_sph = sph.generate_vtk(type_part="sheet") +clip_y(m_sph).save(OUT / "sphere_g4_tpms.vtk") +report("1. SphericalTpms (R=3)", m_sph) + +# ----------------------------------------------------------------------------- +# 2. CylindricalTpms — gyroid wrapping a cylinder (auto-fill θ via repeat=0) +# ----------------------------------------------------------------------------- + +cyl = CylindricalTpms( + radius=CYLINDER_RADIUS, + surface_function=gyroid, + offset=OFFSET, + cell_size=CELL_SIZE, + repeat_cell=(2, 0, int(CYLINDER_HEIGHT / CELL_SIZE)), +) +m_cyl = cyl.generate_vtk(type_part="sheet") +clip_y(m_cyl).save(OUT / "cylinder_g5_tpms.vtk") +report("2. CylindricalTpms (R=1.5)", m_cyl) + +# ----------------------------------------------------------------------------- +# 3. Sweep — gyroid along a helical curve (1.5 turns, height 6) +# ----------------------------------------------------------------------------- + +def helix(t: float) -> np.ndarray: + """Helix: 1.5 turns, radius 2, height 6.""" + theta = 2.0 * np.pi * 1.5 * t + return np.array([2.0 * np.cos(theta), 2.0 * np.sin(theta), 6.0 * (t - 0.5)]) + + +sw = Sweep( + curve_points=helix, + surface_function=gyroid, + radial_max=0.6, + offset=OFFSET, + cell_size=CELL_SIZE, + repeat_cell=(8, 1, 6), + n_curve_samples=200, +) +m_sw = sw.generate_vtk(type_part="sheet") +clip_y(m_sw).save(OUT / "sweep_g7_helix.vtk") +report("3. Sweep along helix", m_sw) + +print("\nFiles saved (clipped by y-plane), open in ParaView:") +for path in ( + OUT / "sphere_g4_tpms.vtk", + OUT / "cylinder_g5_tpms.vtk", + OUT / "sweep_g7_helix.vtk", +): + print(f" {path}") diff --git a/gyroid_gd_bunny.py b/gyroid_gd_bunny.py new file mode 100644 index 00000000..225ee417 --- /dev/null +++ b/gyroid_gd_bunny.py @@ -0,0 +1,87 @@ +from typing import List + +import numpy as np +import pyvista as pv +from pyvista import examples + + +def gyroid(x, y, z): + return np.sin(x) * np.cos(y) + np.sin(y) * np.cos(z) + np.sin(z) * np.cos(x) + + +repeat_cell = np.array([8, 8, 8]) +cell_size = np.array([1, 1, 1]) +center_offset = 0.5 +resolution = 15 + +offset = np.pi/2. + +linspaces: List[np.ndarray] = [] +for repeat_cell_axis, cell_size_axis in zip(repeat_cell, cell_size): + linspaces.append( + np.linspace( + -center_offset * cell_size_axis * repeat_cell_axis, + center_offset * cell_size_axis * repeat_cell_axis, + resolution * repeat_cell_axis, + ) + ) + +x, y, z = np.meshgrid(*linspaces) + +grid = pv.StructuredGrid(x, y, z) +kx, ky, kz = 2 * np.pi / cell_size + +surface_function = gyroid(kx * x, ky * y, kz * z) + +bunny = examples.download_bunny() +transform_matrix = np.array([[40, 0, 0, 0], + [0, 40, 0, 0], + [0, 0, 40, 0], + [0, 0, 0, 1]]) +bunny.transform(transform_matrix, inplace=True) +center = bunny.center_of_mass() +bunny.translate(-center, inplace=True) +print("newcenter = ", bunny.center_of_mass()) +print(bunny.bounds) +print(grid.bounds) + +grid.compute_implicit_distance(bunny, inplace=True) + +#normalize : +dist = -1.*grid['implicit_distance'] +dist[dist > 0] = 0 +dist_norm = (dist - min(dist)) / (max(dist) - min(dist)) +x_t = 0.5 +l = 0.2 +reg_func = 0.6 * (1. + np.tanh((dist_norm - x_t)/l)) - 0.2 + +print(min(dist)) +print(max(dist)) + +print(min(dist_norm)) +print(max(dist_norm)) + +print(min(reg_func)) +print(max(reg_func)) + +grid["lower_surface"] = (surface_function.ravel(order="F") - offset*reg_func) +grid["upper_surface"] = (surface_function.ravel(order="F") + offset*reg_func) +sheet = grid.clip_scalar(scalars="upper_surface", invert=False).clip_scalar( + scalars="lower_surface" +) +upper_skeletal = grid.clip_scalar(scalars="upper_surface") +lower_skeletal = grid.clip_scalar(scalars="lower_surface", invert=False) + +clipped = sheet.clip_surface(bunny, invert=False) +clipped.compute_implicit_distance(bunny, inplace=True) + +print(f"relative density = {clipped.volume / bunny.volume:.2%}") + +clipped2 = clipped.clip('y', origin = (0,-0.5,0.), invert=False) +clipped2["dist"] = -1.*clipped2["implicit_distance"] + +pl = pv.Plotter() +#pl.add_mesh(grid, color="b", opacity = 0.1) +pl.add_mesh(bunny, color="w", opacity = 0.1) +pl.add_mesh(clipped2, scalars='dist', cmap='inferno') +pl.show() diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 93297c12..72c61aed 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -40,6 +40,50 @@ _DIM = 3 +def _wedge_sdf_2d( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + half_angle: float, +) -> npt.NDArray[np.float64]: + """SDF of a 2D wedge centred on +X with half-aperture ``half_angle``. + + Negative inside, positive outside, zero on the bounding rays. + Z-independent — used as a clipping primitive for cylindrical / spherical + partial wraps. + """ + return np.cos(half_angle) * np.abs(y) - np.sin(half_angle) * x + + +def _double_cone_sdf( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + half_polar: float, +) -> npt.NDArray[np.float64]: + """SDF of a double cone around the Z axis with half-aperture ``half_polar``. + + Symmetric about the XY plane; points inside iff their angle from ±Z is + less than ``half_polar``. Used to clip spherical partial-θ coverage. + """ + rho_xy = np.sqrt(x * x + y * y) + return np.cos(half_polar) * rho_xy - np.sin(half_polar) * np.abs(z) + + +def _interp_along_curve( + query_s: npt.NDArray[np.float64], + curve_s: npt.NDArray[np.float64], + values: npt.NDArray[np.float64], +) -> npt.NDArray[np.float64]: + """Per-axis ``np.interp`` of an ``(M, 3)`` curve attribute at ``query_s``. + + Returns an ``(N, 3)`` array. Wraps the three-axis loop so callers don't + repeat the pattern. + """ + return np.column_stack( + [np.interp(query_s, curve_s, values[:, k]) for k in range(values.shape[1])], + ) + + class Tpms(Shape): """Triply Periodical Minimal Surfaces. @@ -585,7 +629,16 @@ def offset( # Keep the callable so the F-rep path can re-evaluate it on the # marching-cubes grid (which differs from ``self.grid``). self._offset_func = offset - self._offset = offset(self.grid.x, self.grid.y, self.grid.z).ravel("F") + # Sample the callable on ``self.grid`` for legacy / property-path + # consumers (``grid_sheet`` etc.). StructuredGrid exposes + # ``.x/.y/.z`` meshgrids; UnstructuredGrid (e.g. after + # ``clip_surface`` in :class:`Infill`) does not — use raw points. + if hasattr(self.grid, "x"): + vals = offset(self.grid.x, self.grid.y, self.grid.z).ravel("F") + else: + pts = np.asarray(self.grid.points) + vals = offset(pts[:, 0], pts[:, 1], pts[:, 2]) + self._offset = vals else: err_msg = "offset must be a float, a numpy array or a callable" raise TypeError(err_msg) @@ -696,6 +749,34 @@ def _isotropic_resolution(self: Tpms) -> int: """ return max(int(self.resolution * np.cbrt(np.prod(self.repeat_cell))), 10) + def _envelope_mesh_at_full_density(self: Tpms) -> pv.PolyData: + """Return the cell-envelope mesh used by the ``density=1.0`` shortcut. + + Plain :class:`Tpms` has an axis-aligned box envelope, returned exactly + via :class:`pyvista.Box`. Subclasses with non-cubic envelopes + (cylindrical / spherical shells, ``Infill`` object, etc.) override + this to fall back to marching cubes on their ``_cell_box`` SDF — see + :meth:`_envelope_mesh_via_cell_box`. + """ + return ( + pv.Box(bounds=self._bounds, level=0, quads=False) + .extract_surface() + .clean() + .triangulate() + ) + + def _envelope_mesh_via_cell_box(self: Tpms) -> pv.PolyData: + """Marching-cubes mesh of the (potentially non-cubic) ``_cell_box``. + + Used by subclasses that override :meth:`_envelope_mesh_at_full_density` + to delegate to the F-rep envelope SDF. + """ + envelope_shape = self._cell_box() + return envelope_shape.generate_vtk( + bounds=envelope_shape.bounds or self._bounds, + resolution=self._isotropic_resolution(), + ) + def generate( self: Tpms, type_part: TpmsPartType = "sheet", @@ -830,13 +911,11 @@ def generate_vtk( raise ValueError(err_msg) if self.density == 1.0: - # Short-circuit: at full density the part fills the whole cell, - # return the cell-box mesh directly (exact volume — the - # marching-cubes path would give a slight discretization gap). - box = pv.Box(bounds=self._bounds, level=0, quads=False) - box = box.extract_surface().clean().triangulate() - box = rotate(box, center=(0, 0, 0), rotation=self.orientation) - return box.translate(xyz=self.center) + envelope_mesh = self._envelope_mesh_at_full_density() + envelope_mesh = rotate( + envelope_mesh, center=(0, 0, 0), rotation=self.orientation, + ) + return envelope_mesh.translate(xyz=self.center) if self.density is not None: self._compute_offset_to_fit_density( @@ -847,11 +926,37 @@ def generate_vtk( # / zero / variable offsets gracefully. ``generate()`` still applies # the historical CAD-side restriction. - frep = self._frep_part(type_part) - polydata = frep.generate_vtk( - bounds=self._bounds, - resolution=self._isotropic_resolution(), - ) + # Subclasses with a *parametric* coordinate frame (CylindricalTpms, + # SphericalTpms, Sweep) cannot use the F-rep marching-cubes path — + # the TPMS field has rapid angular oscillations that an isotropic + # Cartesian grid samples poorly (visible as dotted holes near the + # pole / axis). They opt into the legacy grid-clip path by setting + # ``_uses_parametric_grid = True``: clip the parametric structured + # grid by the relevant scalar threshold, then extract the surface. + if getattr(self, "_uses_parametric_grid", False): + grid_attr = f"grid_{type_part.replace(' ', '_')}" + # Merge points within an absolute tolerance large enough to close + # the angular seam (φ=±π for sphere, θ=±π for cylinder) where + # the structured grid's two boundary faces coincide in Cartesian + # space up to ~1e-3 floating-point drift, but small enough not + # to collapse legitimate cell edges (smallest grid spacing is + # ~ ``cell_size / resolution``). + seam_tol = min( + 5e-3, + 0.1 * float(np.min(self.cell_size)) / float(self.resolution), + ) + polydata = ( + getattr(self, grid_attr) + .extract_surface() + .clean(tolerance=seam_tol, absolute=True) + .triangulate() + ) + else: + frep = self._frep_part(type_part) + polydata = frep.generate_vtk( + bounds=self._bounds, + resolution=self._isotropic_resolution(), + ) polydata = rotate(polydata, center=(0, 0, 0), rotation=self.orientation) return polydata.translate(xyz=self.center) @@ -894,6 +999,15 @@ def generateVtk( # noqa: N802 class CylindricalTpms(Tpms): """Class used to generate cylindrical TPMS geometries (sheet or skeletals parts).""" + # Use the parametric structured-grid clip for ``generate_vtk`` instead of + # F-rep marching cubes. An isotropic Cartesian MC grid samples the + # angular axis poorly near the cylinder axis (rapid θ-derivative ⇒ + # under-sampled holes). The structured grid in (ρ, θ, z) automatically + # maps to a Cartesian grid that is dense near the axis. + _uses_parametric_grid = True + + _envelope_mesh_at_full_density = Tpms._envelope_mesh_via_cell_box + def __init__( # noqa: PLR0913 self: CylindricalTpms, radius: float, @@ -965,9 +1079,16 @@ def _create_grid( y: npt.NDArray[np.float64], z: npt.NDArray[np.float64], ) -> pv.StructuredGrid: - """Return the structured cylindrical grid of the TPMS.""" + """Return the structured cylindrical grid of the TPMS. + + ``y`` carries arc-length along the equator, so the conversion to an + angle is ``theta = y / radius``. The earlier ``y * unit_theta`` form + over-wrapped past ``2π`` by a factor of ``unit_theta * radius`` + (which is only 1 when the user-supplied ``cell_size[1] == 1``), + leaving a visible wedge of unrendered geometry on one meridian. + """ rho = x + self.cylinder_radius - theta = y * self.unit_theta + theta = y / self.cylinder_radius grid = pv.StructuredGrid(rho * np.cos(theta), rho * np.sin(theta), z) grid["coords"] = np.c_[ @@ -977,6 +1098,15 @@ def _create_grid( ] return grid + def _shell_extents(self: CylindricalTpms) -> tuple[float, float, float]: + """Return ``(r_inner, r_outer, half_z)`` of the TPMS-bearing shell.""" + cyl_r = float(self.cylinder_radius) + delta_r = 0.5 * float(self.cell_size[0]) * float(self.repeat_cell[0]) + r_outer = cyl_r + delta_r + r_inner = max(cyl_r - delta_r, 0.0) + half_z = 0.5 * float(self.cell_size[2]) * float(self.repeat_cell[2]) + return r_inner, r_outer, half_z + def _setup_frep_field(self: CylindricalTpms) -> None: """Build F-rep field in Cartesian coordinates (inverse cylindrical mapping).""" k_x, k_y, k_z = 2.0 * np.pi / self.cell_size @@ -997,17 +1127,95 @@ def _raw_field( k_z * (z + ps[2]), ) - r_max = cyl_r + 0.5 * self.cell_size[0] * self.repeat_cell[0] - half_z = 0.5 * self.cell_size[2] * self.repeat_cell[2] + # Tightened bounds: only ``r_outer`` reach in xy, only ``half_z`` in z. + # The previous wider Cartesian box wasted ~50 % of MC samples on the + # interior of the cylinder where the TPMS doesn't live. + _, r_outer, half_z = self._shell_extents() self._finalize_frep( _raw_field, - (-r_max, r_max, -r_max, r_max, -half_z, half_z), + (-r_outer, r_outer, -r_outer, r_outer, -half_z, half_z), ) + def _cell_box(self: CylindricalTpms) -> Shape: + """Cylindrical-shell SDF in Cartesian space (replaces the parent's + axis-aligned-box SDF, which was geometrically wrong for this class). + + Without this override the F-rep marching cubes path + (:meth:`Tpms._clipped_sheet`) would clip the TPMS by an axis-aligned + box of ``cell_size × repeat_cell`` *interpreted as Cartesian* — but + ``cell_size[1]`` / ``repeat_cell[1]`` are angular (parametric) for + this class, so the clip discarded ~95 % of the physical shell. + + The shell SDF is:: + + radial = sqrt(x² + y²) + ring = max(r_inner − radial, radial − r_outer) + sdf = max(ring, |z| − half_z) + + Partial wrap (``repeat_cell[1] < n_repeat_to_full_circle``) is + handled by intersecting the ring with an angular wedge centred on + the +X axis. + """ + from .implicit_ops import from_field, intersection # noqa: PLC0415 + + r_inner, r_outer, half_z = self._shell_extents() + bounds: BoundsType = ( + -r_outer, r_outer, -r_outer, r_outer, -half_z, half_z, + ) + + def _shell_sdf( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + radial = np.sqrt(x * x + y * y) + outer = radial - r_outer + if r_inner > 0: + ring = np.maximum(r_inner - radial, outer) + else: + ring = outer # solid disc — no inner wall + return np.maximum(ring, np.abs(z) - half_z) + + shell = from_field(_shell_sdf, bounds=bounds) + + # Partial angular wrap → intersect with a wedge centred on +X. + n_full = int(round(2.0 * np.pi / self.unit_theta)) + if int(self.repeat_cell[1]) < n_full: + half_angle = np.pi * float(self.repeat_cell[1]) / float(n_full) + wedge = from_field( + lambda x, y, z, _h=half_angle: _wedge_sdf_2d(x, y, _h), + bounds=bounds, + ) + shell = intersection(shell, wedge) + + return shell + + def _isotropic_resolution(self: CylindricalTpms) -> int: + """Per-axis resolution count for the F-rep marching-cubes grid. + + Override the parent's geometric-mean of ``repeat_cell`` (which mixes + the angular axis with linear ones). Use only the radial (``[0]``) + and axial (``[2]``) physical extents, both in world units. + """ + radial_extent = float(self.cell_size[0]) * float(self.repeat_cell[0]) + axial_extent = float(self.cell_size[2]) * float(self.repeat_cell[2]) + cell_size_min = max( + min(float(self.cell_size[0]), float(self.cell_size[2])), 1e-12, + ) + n = int(self.resolution * max(radial_extent, axial_extent) / cell_size_min) + return max(n, 10) + class SphericalTpms(Tpms): """Class used to generate spherical TPMS geometries (sheet or skeletals parts).""" + # Same rationale as :class:`CylindricalTpms` — the parametric (ρ, θ, φ) + # grid stretches naturally in Cartesian space, sampling poles and equator + # equally well, while uniform Cartesian MC produces pole pathologies. + _uses_parametric_grid = True + + _envelope_mesh_at_full_density = Tpms._envelope_mesh_via_cell_box + def __init__( # noqa: PLR0913 self: SphericalTpms, radius: float, @@ -1085,10 +1293,19 @@ def _create_grid( y: npt.NDArray[np.float64], z: npt.NDArray[np.float64], ) -> pv.StructuredGrid: - """Return the structured spherical grid of the TPMS.""" + """Return the structured spherical grid of the TPMS. + + ``y`` and ``z`` carry equatorial arc length, so the conversion to + angles is ``theta = y / radius`` and ``phi = z / radius``. The + earlier ``* unit_theta`` / ``* unit_phi`` forms over-wrapped past + ``π`` / ``2π`` by a factor of ``unit_* * radius`` (which is only + 1 when the user-supplied ``cell_size == 1``), leaving a visible + wedge of unrendered geometry on one meridian and tiny over-shoots + at the poles. + """ rho = x + self.sphere_radius - theta = y * self.unit_theta + np.pi / 2.0 - phi = z * self.unit_phi + theta = y / self.sphere_radius + np.pi / 2.0 + phi = z / self.sphere_radius grid = pv.StructuredGrid( rho * np.sin(theta) * np.cos(phi), @@ -1128,16 +1345,490 @@ def _raw_field( k_z * (phi + ps[2]), ) - r_max = sph_r + 0.5 * self.cell_size[0] * self.repeat_cell[0] + # Tightened bounds: only ``r_outer`` cube, not ``r_max + R``. + _, r_outer = self._shell_radii() self._finalize_frep( _raw_field, - (-r_max, r_max, -r_max, r_max, -r_max, r_max), + (-r_outer, r_outer, -r_outer, r_outer, -r_outer, r_outer), + ) + + def _shell_radii(self: SphericalTpms) -> tuple[float, float]: + """Return ``(r_inner, r_outer)`` of the spherical TPMS-bearing shell.""" + sph_r = float(self.sphere_radius) + delta_r = 0.5 * float(self.cell_size[0]) * float(self.repeat_cell[0]) + return max(sph_r - delta_r, 0.0), sph_r + delta_r + + def _cell_box(self: SphericalTpms) -> Shape: + """Spherical-shell SDF in Cartesian space. + + Replaces the parent's axis-aligned-box clip — same fix as + :meth:`CylindricalTpms._cell_box`, see that docstring for the + rationale. + + Partial coverage in θ (cone clip on +Z) and φ (wedge clip on +X + in xy-plane) is supported when ``repeat_cell[1]`` / + ``repeat_cell[2]`` are smaller than the auto-fill values. + """ + from .implicit_ops import from_field, intersection # noqa: PLC0415 + + r_inner, r_outer = self._shell_radii() + bounds: BoundsType = ( + -r_outer, r_outer, -r_outer, r_outer, -r_outer, r_outer, + ) + + def _shell_sdf( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + r = np.sqrt(x * x + y * y + z * z) + outer = r - r_outer + if r_inner > 0: + return np.maximum(r_inner - r, outer) + return outer # solid ball if inner radius collapsed + + shape = from_field(_shell_sdf, bounds=bounds) + + # Partial θ coverage → double-cone clip around the Z axis. + n_full_theta = int(round(np.pi / self.unit_theta)) + if int(self.repeat_cell[1]) < n_full_theta: + half_polar = np.pi * float(self.repeat_cell[1]) / float(n_full_theta) + shape = intersection( + shape, + from_field( + lambda x, y, z, _h=half_polar: _double_cone_sdf(x, y, z, _h), + bounds=bounds, + ), + ) + + # Partial φ coverage → wedge clip around the +X axis. + n_full_phi = int(round(2.0 * np.pi / self.unit_phi)) + if int(self.repeat_cell[2]) < n_full_phi: + half_azi = np.pi * float(self.repeat_cell[2]) / float(n_full_phi) + shape = intersection( + shape, + from_field( + lambda x, y, z, _h=half_azi: _wedge_sdf_2d(x, y, _h), + bounds=bounds, + ), + ) + + return shape + + def _isotropic_resolution(self: SphericalTpms) -> int: + """Use only the radial physical extent (the angular axes are + parametric and would mislead a geometric mean across them). + """ + radial_extent = float(self.cell_size[0]) * float(self.repeat_cell[0]) + radius_envelope = self.sphere_radius + radial_extent + cell_size_radial = max(float(self.cell_size[0]), 1e-12) + n = int(self.resolution * 2.0 * radius_envelope / cell_size_radial) + return max(n, 10) + + +class Sweep(Tpms): + """TPMS along an arbitrary 3D curve — generalisation of CylindricalTpms. + + The TPMS is generated in a tube of radius ``radial_max`` around an + arbitrary curve, in local coordinates ``(s, r, θ)`` where: + + - ``s`` = arc length along the curve from its start + - ``r`` = perpendicular distance from the curve at the closest point + - ``θ`` = angle around the curve in the local tangent-frame plane + + The local frame ``(T, N, B)`` is built once at curve setup time using + parallel transport along the polyline (tangent from finite differences, + normal from a seed transported by Rodrigues rotation between adjacent + samples; closed loops get a holonomy correction distributed linearly). + + The mesh is generated via the **parametric structured-grid path** + (same as :class:`CylindricalTpms` / :class:`SphericalTpms`): we build a + structured grid in (s, r, θ) parametric space, evaluate the TPMS field + on that grid, and map the parametric points to Cartesian using the + parallel-transport frames. ``generate_vtk`` then clips the structured + grid by the relevant scalar threshold — much cleaner than F-rep MC at + a uniform Cartesian resolution, which would under-sample the angular + direction and produce dotted artefacts. + + :class:`CylindricalTpms` is a special case of this class (curve = a + straight line). + """ + + # Use the parametric grid-clip path (same as Cylindrical/Spherical). + _uses_parametric_grid = True + + _envelope_mesh_at_full_density = Tpms._envelope_mesh_via_cell_box + + def __init__( # noqa: PLR0913 + self: Sweep, + curve_points: npt.NDArray[np.float64] | Callable[[float], npt.NDArray[np.float64]], + surface_function: Field, + radial_max: float, + offset: float | OffsetGrading | Field | None = None, + cell_size: float | Sequence[float] | npt.NDArray[np.float64] = 1.0, + repeat_cell: int | Sequence[int] | npt.NDArray[np.int8] = 1, + phase_shift: Sequence[float] = (0.0, 0.0, 0.0), + resolution: int = 20, + density: float | None = None, + seed_normal: Sequence[float] | None = None, + n_curve_samples: int = 200, + center: Vector3DType = (0, 0, 0), + orientation: Vector3DType = (0, 0, 0), + ) -> None: + r"""Build a TPMS swept along a curve. + + :param curve_points: either an ``(M, 3)`` array of polyline samples + or a callable ``t \in [0, 1] -> (3,)``. Callables are sampled + at ``n_curve_samples`` points before processing. + :param surface_function: TPMS function ``f(x, y, z)`` + :param radial_max: outer tube radius + :param offset: TPMS sheet thickness + :param cell_size: ``(s, r, θ)`` cell size — third axis is angular + (radians per cell), so a sensible default is to leave it at + ``1.0`` and tune via ``repeat_cell[2]``. + :param repeat_cell: ``(n_s, n_r, n_θ)`` — radial cell count is + usually ``1`` for a thin tube; ``n_θ`` controls how many + angular cells around the curve. + :param phase_shift: TPMS phase shift in (s, r, θ) + :param resolution: per-axis MC grid resolution + :param density: mutex with ``offset``; density relative to the + tube volume + :param seed_normal: initial normal direction for parallel transport; + defaults to a vector perpendicular to the first tangent + :param n_curve_samples: number of samples to use when resampling a + ``Callable`` curve (ignored for polyline input) + :param center: center of the geometry + :param orientation: orientation of the geometry + """ + # Discretise the curve. + if callable(curve_points): + ts = np.linspace(0.0, 1.0, int(n_curve_samples)) + curve = np.asarray([curve_points(t) for t in ts], dtype=np.float64) + else: + curve = np.asarray(curve_points, dtype=np.float64) + if curve.ndim != 2 or curve.shape[1] != 3 or curve.shape[0] < 2: + err_msg = ( + f"curve_points must be an (M, 3) array with M ≥ 2, " + f"got shape {curve.shape}" + ) + raise ValueError(err_msg) + + self.curve = curve + self.radial_max = float(radial_max) + + # Build the parallel-transport frames + arc-length parametrisation. + self._build_curve_frames(seed_normal=seed_normal) + + # Initialise like a regular TPMS — cell_size now lives in (s, r, θ) + # parametric space, ``_setup_frep_field`` will use the local frames. + super().__init__( + surface_function=surface_function, + offset=offset, + phase_shift=phase_shift, + cell_size=cell_size, + repeat_cell=repeat_cell, + resolution=resolution, + density=density, + center=center, + orientation=orientation, + ) + + # -- Curve preprocessing ----------------------------------------------- + + def _build_curve_frames( + self: Sweep, + seed_normal: Sequence[float] | None, + ) -> None: + """Pre-compute arc length, tangents, and parallel-transported normals. + + Stores on ``self``: + ``_curve_s`` arc length per sample, shape (M,) + ``_curve_T`` unit tangent per sample, shape (M, 3) + ``_curve_N`` unit normal (parallel-transported), shape (M, 3) + ``_curve_kdtree`` scipy cKDTree on the curve points + """ + from scipy.spatial import cKDTree # noqa: PLC0415 + + pts = self.curve + m = pts.shape[0] + + # Arc-length parameterisation. + seg = np.linalg.norm(np.diff(pts, axis=0), axis=1) + s_cum = np.concatenate([[0.0], np.cumsum(seg)]) + self._curve_s = s_cum + self._curve_total_length = float(s_cum[-1]) + + # Tangent: forward differences, last point copies its predecessor. + diffs = np.diff(pts, axis=0) + diff_norm = np.linalg.norm(diffs, axis=1, keepdims=True) + diff_norm = np.where(diff_norm > 1e-12, diff_norm, 1.0) + T = diffs / diff_norm + T = np.vstack([T, T[-1:]]) # pad final tangent + self._curve_T = T + + # Seed normal: project the user's seed (or world-up / world-x as + # fallback) onto the plane perpendicular to T[0]. + if seed_normal is None: + world_up = np.array([0.0, 0.0, 1.0]) + seed_proj = world_up - np.dot(world_up, T[0]) * T[0] + if np.linalg.norm(seed_proj) < 1e-6: + # T[0] is along z — use world-x instead. + world_x = np.array([1.0, 0.0, 0.0]) + seed_proj = world_x - np.dot(world_x, T[0]) * T[0] + else: + seed_proj = np.asarray(seed_normal, dtype=np.float64) + seed_proj = seed_proj - np.dot(seed_proj, T[0]) * T[0] + if np.linalg.norm(seed_proj) < 1e-6: + err_msg = "seed_normal is parallel to the initial tangent" + raise ValueError(err_msg) + N = np.empty_like(T) + N[0] = seed_proj / np.linalg.norm(seed_proj) + + # Parallel transport: rotate previous N around (T_{i-1} × T_i) by + # the angle between successive tangents (Rodrigues' rotation). + for i in range(1, m): + t_prev, t_curr = T[i - 1], T[i] + cos_angle = float(np.clip(np.dot(t_prev, t_curr), -1.0, 1.0)) + if cos_angle > 1.0 - 1e-9: + N[i] = N[i - 1] + continue + axis = np.cross(t_prev, t_curr) + axis_n = np.linalg.norm(axis) + if axis_n < 1e-9: + # Antiparallel tangents (curve folds back) — keep N as-is. + N[i] = N[i - 1] + continue + axis = axis / axis_n + sin_angle = np.sqrt(max(1.0 - cos_angle * cos_angle, 0.0)) + # Rodrigues: v' = v cos θ + (k × v) sin θ + k (k·v) (1 − cos θ) + v = N[i - 1] + v_rot = ( + v * cos_angle + + np.cross(axis, v) * sin_angle + + axis * np.dot(axis, v) * (1.0 - cos_angle) + ) + v_rot = v_rot - np.dot(v_rot, t_curr) * t_curr # re-project + v_rot = v_rot / np.linalg.norm(v_rot) + N[i] = v_rot + + # Closed-loop holonomy correction: if the curve closes, distribute + # the residual twist between N[-1] (transported) and the seed N[0]. + is_closed = bool(np.linalg.norm(pts[-1] - pts[0]) < 1e-6) + if is_closed: + holonomy = float( + np.arctan2( + float(np.dot(np.cross(N[-1], N[0]), T[-1])), + float(np.dot(N[-1], N[0])), + ), + ) + # Vectorised Rodrigues de-twist: each sample's N rotates around + # its own T by an angle proportional to its arc-length fraction. + ang = -holonomy * self._curve_s / max(self._curve_total_length, 1e-12) + cos_a = np.cos(ang)[:, None] + sin_a = np.sin(ang)[:, None] + kdotv = np.einsum("ij,ij->i", T, N)[:, None] + N_rot = N * cos_a + np.cross(T, N) * sin_a + T * kdotv * (1.0 - cos_a) + N = N_rot / np.linalg.norm(N_rot, axis=1, keepdims=True) + self._curve_N = N + + self._curve_kdtree = cKDTree(pts) + + # Self-intersection guard. + if m >= 3: + d2_dt2 = np.diff(T, axis=0) + kappa = np.linalg.norm(d2_dt2, axis=1) / np.maximum(np.diff(s_cum), 1e-12) + kappa_max = float(np.max(kappa)) if kappa.size else 0.0 + if kappa_max * self.radial_max >= 1.0: + logging.warning( + "Sweep: max curvature %.3f × radial_max %.3f ≥ 1 — the " + "tube envelope self-intersects in some segments; the " + "TPMS may be ill-defined there.", + kappa_max, + self.radial_max, + ) + + # -- Local-frame field ------------------------------------------------- + + def _local_coords( + self: Sweep, + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> tuple[ + npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64], + ]: + """Return ``(s, r, θ)`` for each input point ``p = (x, y, z)``.""" + shape = x.shape + pts = np.column_stack([x.ravel(), y.ravel(), z.ravel()]) + _dist, idx = self._curve_kdtree.query(pts, k=1) + + c = self.curve[idx] + T = self._curve_T[idx] + N = self._curve_N[idx] + B = np.cross(T, N) + + delta = pts - c + s = self._curve_s[idx] + # signed distance along N and B → radial r and angle θ. + u = (delta * N).sum(axis=1) + v = (delta * B).sum(axis=1) + r = np.sqrt(u * u + v * v) + theta = np.arctan2(v, u) + return s.reshape(shape), r.reshape(shape), theta.reshape(shape) + + def _setup_frep_field(self: Sweep) -> None: + """Build the F-rep field as ``surface_function(k_s s, k_r r, k_θ θ)``.""" + k_x, k_y, k_z = 2.0 * np.pi / self.cell_size + ps = self.phase_shift + + def _raw_field( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + s, r, theta = self._local_coords(x, y, z) + return self.surface_function( + k_x * (s + ps[0]), + k_y * (r + ps[1]), + k_z * (theta + ps[2]), + ) + + # Bounds: curve AABB inflated by ``radial_max``. + bb_min = self.curve.min(axis=0) - self.radial_max + bb_max = self.curve.max(axis=0) + self.radial_max + bounds: BoundsType = ( + float(bb_min[0]), float(bb_max[0]), + float(bb_min[1]), float(bb_max[1]), + float(bb_min[2]), float(bb_max[2]), + ) + + # Like Conformal, the field is built around discrete data — skip + # autograd SDF normalisation (it would FD-fall-back and be slow). + self._raw_field_func = _raw_field + self._func = _raw_field + self._bounds = bounds + + # -- Cell-box (tube SDF) ----------------------------------------------- + + def _cell_box(self: Sweep) -> Shape: + """Tube SDF: ``dist_to_curve(p) − radial_max``.""" + from .implicit_ops import from_field # noqa: PLC0415 + + bounds: BoundsType = ( + self._bounds if self._bounds is not None else (-1.0, 1.0, -1.0, 1.0, -1.0, 1.0) + ) + + def _tube_sdf( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + shape = x.shape + pts = np.column_stack([x.ravel(), y.ravel(), z.ravel()]) + d, _ = self._curve_kdtree.query(pts, k=1) + return (d - self.radial_max).reshape(shape) + + return from_field(_tube_sdf, bounds=bounds) + + def _isotropic_resolution(self: Sweep) -> int: + """Use the curve length and tube diameter as the spatial extents.""" + n = int( + self.resolution + * max(self._curve_total_length, 2.0 * self.radial_max) + / max(float(self.cell_size[1]), 1e-12), ) + return max(n, 10) + + def _create_grid( + self: Sweep, + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> pv.StructuredGrid: + """Map a parametric (s, r, θ) grid to Cartesian via the curve frames. + + ``x``, ``y``, ``z`` here are the parametric meshgrid coordinates + from :meth:`Tpms._compute_tpms_field` (they live in (s, r, θ) + space, *not* Cartesian). We interpolate the curve point ``c(s)`` + and frame ``(N(s), B(s))`` for each parametric ``s`` value, then + compute Cartesian ``p = c(s) + r·(cos θ · N + sin θ · B)``. + + The resulting ``StructuredGrid`` has Cartesian point coordinates + but retains the parametric ordering, so ``self.grid["surface"]`` + (filled by the parent :meth:`Tpms._compute_tpms_field`) can be + clipped by scalar threshold to extract the TPMS sheet/skeletal — + same recipe as :class:`CylindricalTpms`. + """ + # x, y, z come from numpy.meshgrid of parametric linspaces. Their + # raw values span [-0.5*cell_size*repeat_cell, +0.5*…] per axis. + # Map to ``s ∈ [0, total_length]``, ``r ∈ [0, radial_max]``, + # ``θ ∈ [0, 2π]``. + s_extent = float(self.cell_size[0]) * float(self.repeat_cell[0]) + s = (x - x.min()) / max(s_extent, 1e-12) * self._curve_total_length + # Radial: parametric coord shifted to [0, cell_size_r * repeat_cell_r]. + r = y - y.min() + # Angular: parametric coord scaled to [0, 2π] over the angular + # extent (cell_size[2] * repeat_cell[2]). + ang_extent = float(self.cell_size[2]) * float(self.repeat_cell[2]) + theta = (z - z.min()) / max(ang_extent, 1e-12) * 2.0 * np.pi + + # Interpolate curve position + (N, T) frames at each parametric s. + s_flat = s.ravel(order="F") + s_clipped = np.clip(s_flat, 0.0, self._curve_total_length) + c, N, T = ( + _interp_along_curve(s_clipped, self._curve_s, arr) + for arr in (self.curve, self._curve_N, self._curve_T) + ) + N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12) + T /= np.maximum(np.linalg.norm(T, axis=1, keepdims=True), 1e-12) + B = np.cross(T, N) + + r_flat = r.ravel(order="F") + theta_flat = theta.ravel(order="F") + cos_t = np.cos(theta_flat) + sin_t = np.sin(theta_flat) + + cart = c + r_flat[:, None] * (cos_t[:, None] * N + sin_t[:, None] * B) + cart_x = cart[:, 0].reshape(x.shape, order="F") + cart_y = cart[:, 1].reshape(x.shape, order="F") + cart_z = cart[:, 2].reshape(x.shape, order="F") + + grid = pv.StructuredGrid(cart_x, cart_y, cart_z) + grid["coords"] = np.c_[ + x.ravel(order="F"), + y.ravel(order="F"), + z.ravel(order="F"), + ] + return grid class Infill(Tpms): """Generate a TPMS infill inside a given object.""" + _envelope_mesh_at_full_density = Tpms._envelope_mesh_via_cell_box + + # The parent's ``sheet`` / ``upper_skeletal`` / ``lower_skeletal`` + # properties read from ``self.grid_*`` (legacy grid-clip path), but the + # density-fitting search optimises against :meth:`generate_vtk` (F-rep + # marching cubes clipped to the obj envelope) — for small infill objects + # the two paths discretise to noticeably different volumes. Re-route + # these properties to ``generate_vtk`` so ``density=0.5`` reliably gives + # ``infill.sheet.volume ≈ 0.5 * obj.volume``. + @property + def sheet(self: Infill) -> pv.PolyData: + """Sheet part as a PolyData mesh (uses :meth:`generate_vtk`).""" + return self.generate_vtk(type_part="sheet") + + @property + def upper_skeletal(self: Infill) -> pv.PolyData: + """Upper-skeletal part as a PolyData mesh.""" + return self.generate_vtk(type_part="upper skeletal") + + @property + def lower_skeletal(self: Infill) -> pv.PolyData: + """Lower-skeletal part as a PolyData mesh.""" + return self.generate_vtk(type_part="lower skeletal") + def __init__( # noqa: PLR0913 self: Infill, obj: pv.PolyData, @@ -1168,8 +1859,20 @@ def __init__( # noqa: PLR0913 If density is given, the offset is automatically computed to fit the\ density (performance is slower than when using the offset) """ - self.obj = obj - bounds = np.array(obj.bounds) + # Capture the original volume *before* re-orienting normals — for + # non-manifold inputs (e.g. pyvista's caps-less Cylinder, the + # Stanford bunny) ``compute_normals(auto_orient_normals=True)`` flips + # some normals and pyvista's volume sum changes, which would corrupt + # density-fitting if we relied on ``self.obj.volume`` afterwards. + self._obj_volume = abs(obj.volume) + # Auto-orient normals so signed-distance queries (used for the + # F-rep envelope clip in :meth:`_cell_box`) get the correct sign. + self.obj = obj.compute_normals( + auto_orient_normals=True, + point_normals=True, + cell_normals=True, + ) + bounds = np.array(self.obj.bounds) margin_factor = 1.001 # to avoid the object surface that can create issues obj_dim = margin_factor * (bounds[1::2] - bounds[::2]) # [dim_x, dim_y, dim_z] @@ -1220,8 +1923,41 @@ def _create_grid( return grid def _density_envelope_volume(self: Infill) -> float: - """Density is measured against the input object's volume.""" - return abs(self.obj.volume) + """Density is measured against the input object's volume. + + Uses the volume of the *original* input object rather than the + re-oriented one stored on ``self.obj`` — see ``__init__``. + """ + return self._obj_volume + + def _setup_frep_field(self: Infill) -> None: + """Cartesian gyroid field, but bounds set to the obj bbox. + + The plain :class:`Tpms` parent uses origin-centered bounds of size + ``cell_size * repeat_cell``. For an Infill of an object that is *not* + centered at the origin (typical for any imported mesh) those bounds + miss the object — marching cubes then runs in the wrong place and the + result isn't actually clipped by the envelope. Override to use the + obj bbox so marching cubes covers the right region; the envelope clip + is enforced in :meth:`_cell_box`. + """ + k_x, k_y, k_z = 2.0 * np.pi / self.cell_size + ps = self.phase_shift + + def _raw_field( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + ) -> npt.NDArray[np.float64]: + return self.surface_function( + k_x * (x + ps[0]), + k_y * (y + ps[1]), + k_z * (z + ps[2]), + ) + + bounds_arr = np.array(self.obj.bounds, dtype=float) + bounds: BoundsType = tuple(bounds_arr.tolist()) + self._finalize_frep(_raw_field, bounds) def _cell_box(self: Infill) -> Shape: """Override the cell-box clip with the *object envelope* SDF. @@ -1250,232 +1986,130 @@ def _envelope_sdf( return from_field(_envelope_sdf, bounds=bounds) -class Conformal(Tpms): - """TPMS that conforms to the local frame of an envelope surface. - - Where :class:`CylindricalTpms` and :class:`SphericalTpms` use a closed-form - inverse coordinate map to wrap the TPMS around a known surface (cylinder / - sphere), :class:`Conformal` does the same for *any* envelope surface - (PolyData) by using the signed distance to the envelope as the radial - coordinate. - - Local frame at every Cartesian point ``p``: +class GradedInfill(Infill): + """Cartesian TPMS infill with offset graded by distance to the envelope. - - ``w(p) = signed_distance(p, envelope)`` — *radial* coordinate; ``w=0`` on - the envelope surface, ``w<0`` inside, ``w>0`` outside. Computed by - :meth:`pyvista.PolyData.compute_implicit_distance`. - - ``u(p) = (p - center) · t``, ``v(p) = (p - center) · b`` — *tangential* - coordinates expressed in the global frame ``(t, b, n=default_axis)``, - where ``t`` and ``b`` are the two axes of the plane perpendicular to - ``default_tangent_axis``. Default tangent axis is ``(1, 0, 0)``. + Same as :class:`Infill` (TPMS in the world cartesian frame, clipped by + the input object), except the *thickness* of the TPMS sheet varies with + the signed distance to the envelope. - The TPMS field is then ``surface_function(k_w · w, k_u · u, k_v · v)``, - so unit cells stack along the envelope normal direction (concentric - "shells" at distance ``±cell_size``, ``±2·cell_size``, …) and tile the - perpendicular plane in the other two axes. Compared to a Cartesian - :class:`Infill`, this produces shells that *follow* the envelope shape - rather than being sliced by it. + Default behaviour models a **graded shell**: dense material concentrated + at the skin (``offset_skin = 0.6``, "density 1"), hollowing out toward + the core (``offset_core = 0.0``, "density 0"). Reverse the parameters + for a dense-core / porous-skin scaffold. - Tangentially-intrinsic (true conformal) parametrizations require - per-point local-tangent rotation; that's a follow-up — this class is the - minimum useful step. + The cell pattern is still cartesian (axes-aligned gyroid) — *only* the + sheet thickness varies in space — so the pattern tiles cleanly even on + bunny-style envelopes with high-curvature features. """ def __init__( # noqa: PLR0913 - self: Conformal, - envelope: pv.PolyData, + self: GradedInfill, + obj: pv.PolyData, surface_function: Field, - offset: float | OffsetGrading | Field | None = None, + offset_skin: float = 0.6, + offset_core: float = 0.0, + transition: float = 0.5, + smoothness: float = 0.2, cell_size: float | Sequence[float] | npt.NDArray[np.float64] | None = None, repeat_cell: int | Sequence[int] | npt.NDArray[np.int8] | None = None, phase_shift: Sequence[float] = (0.0, 0.0, 0.0), resolution: int = 20, - density: float | None = None, - default_tangent_axis: Sequence[float] = (1.0, 0.0, 0.0), - clip_to_envelope: bool = True, ) -> None: - """Build a TPMS that wraps an envelope surface. - - :param envelope: the surface (``pv.PolyData``) the TPMS will follow. - Volumes are computed relative to the envelope volume when - ``clip_to_envelope=True``. - :param surface_function: tpms function or custom function ``f(x,y,z)=0`` - :param offset: offset / thickness of the TPMS sheet - :param cell_size: unit cell size; auto-derived from envelope bounds if None - :param repeat_cell: number of cells per axis; auto-derived if None - :param phase_shift: phase shift in the (w, u, v) local frame + """Build a graded TPMS infill. + + :param obj: envelope mesh + :param surface_function: TPMS function ``f(x,y,z)`` + :param offset_skin: TPMS thickness at the envelope surface + (``d ≈ 0``). Default ``0.6`` ⇒ thick / dense skin. + :param offset_core: TPMS thickness deep in the core (``|d|`` maximal). + Default ``0.0`` ⇒ no material at the centre (graded shell). + :param transition: normalised distance ∈ [0, 1] where the offset + transitions from ``offset_skin`` to ``offset_core`` + :param smoothness: width of the tanh transition (smaller = sharper) + :param cell_size: unit cell size; mutex with ``repeat_cell`` + :param repeat_cell: number of cells per axis; mutex with ``cell_size`` + :param phase_shift: TPMS phase shift :param resolution: per-axis grid resolution - :param density: target density relative to envelope volume (mutex with - ``offset``) - :param default_tangent_axis: world axis used to define the tangential - plane for ``(u, v)``. Default ``(1, 0, 0)`` ⇒ ``u = y - cy``, - ``v = z - cz``. - :param clip_to_envelope: if ``True``, the resulting grid / mesh is - clipped by the envelope surface (TPMS only fills the interior). """ - self.envelope = envelope - self.clip_to_envelope = clip_to_envelope - - # Frame: n = default_tangent_axis (radial-projection axis), then build - # an orthonormal (t, b) basis for the perpendicular plane. - n = np.asarray(default_tangent_axis, dtype=np.float64) - n_norm = np.linalg.norm(n) - if n_norm < 1e-12: - err_msg = "default_tangent_axis must be a non-zero vector" - raise ValueError(err_msg) - n /= n_norm - # Pick a stable secondary direction not parallel to n. - helper = np.array([0.0, 0.0, 1.0]) if abs(n[2]) < 0.9 else np.array([1.0, 0.0, 0.0]) - t = helper - np.dot(helper, n) * n - t /= np.linalg.norm(t) - b = np.cross(n, t) - self._frame_n = n - self._frame_t = t - self._frame_b = b - self.default_tangent_axis = tuple(n.tolist()) - - # Auto-derive cell_size / repeat_cell from envelope bbox if missing. - bounds = np.array(envelope.bounds) - margin = 1.001 - envelope_dim = margin * (bounds[1::2] - bounds[::2]) - - if cell_size is not None and repeat_cell is not None: - err_msg = ( - "cell_size and repeat_cell cannot be given at the same time, " - "one is computed from the other." - ) - raise ValueError(err_msg) - if cell_size is None and repeat_cell is None: - repeat_cell = (4, 4, 4) - if cell_size is not None: - repeat_cell = np.maximum( - np.round(envelope_dim / np.asarray(cell_size, dtype=float)).astype(int), - 1, - ) - elif repeat_cell is not None: - cell_size = envelope_dim / np.asarray(repeat_cell, dtype=float) + self._gradation_params = ( + float(offset_skin), + float(offset_core), + float(transition), + float(smoothness), + ) - self._envelope_center = np.asarray(envelope.center, dtype=np.float64) + graded_offset = self._make_graded_offset_callable(obj, *self._gradation_params) super().__init__( + obj=obj, surface_function=surface_function, - offset=offset, - phase_shift=phase_shift, + offset=graded_offset, cell_size=cell_size, repeat_cell=repeat_cell, + phase_shift=phase_shift, resolution=resolution, - density=density, + density=None, ) - # -- Local-frame field ------------------------------------------------- - - def _envelope_distance( - self: Conformal, - x: npt.NDArray[np.float64], - y: npt.NDArray[np.float64], - z: npt.NDArray[np.float64], - ) -> npt.NDArray[np.float64]: - """Vectorized signed distance to the envelope (negative inside).""" - pts = pv.PolyData(np.column_stack([x.ravel(), y.ravel(), z.ravel()])) - pts.compute_implicit_distance(self.envelope, inplace=True) - return np.asarray(pts["implicit_distance"]).reshape(x.shape) - - def _local_coords( - self: Conformal, - x: npt.NDArray[np.float64], - y: npt.NDArray[np.float64], - z: npt.NDArray[np.float64], - ) -> tuple[ - npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64] - ]: - """Project Cartesian ``(x, y, z)`` onto the conformal local frame. - - - ``w`` = signed distance to the envelope (radial coord) - - ``u`` = ``(p - envelope_center) · t`` - - ``v`` = ``(p - envelope_center) · b`` - """ - cx, cy, cz = self._envelope_center - dx, dy, dz = x - cx, y - cy, z - cz - u = dx * self._frame_t[0] + dy * self._frame_t[1] + dz * self._frame_t[2] - v = dx * self._frame_b[0] + dy * self._frame_b[1] + dz * self._frame_b[2] - w = self._envelope_distance(x, y, z) - return u, v, w - - def _setup_frep_field(self: Conformal) -> None: - """Build F-rep field that evaluates the surface function in (w, u, v).""" - k_x, k_y, k_z = 2.0 * np.pi / self.cell_size - ps = self.phase_shift - - def _raw_field( - x: npt.NDArray[np.float64], - y: npt.NDArray[np.float64], - z: npt.NDArray[np.float64], - ) -> npt.NDArray[np.float64]: - u, v, w = self._local_coords(x, y, z) - return self.surface_function( - k_x * (w + ps[0]), - k_y * (u + ps[1]), - k_z * (v + ps[2]), - ) - - bounds_arr = np.array(self.envelope.bounds) - bounds: BoundsType = tuple(bounds_arr.tolist()) - self._finalize_frep(_raw_field, bounds) - - # -- Override grid creation to clip to the envelope -------------------- - - def _create_grid( - self: Conformal, - x: npt.NDArray[np.float64], - y: npt.NDArray[np.float64], - z: npt.NDArray[np.float64], - ) -> pv.StructuredGrid: - """Cartesian grid covering the envelope bbox, optionally clipped.""" - cx, cy, cz = self._envelope_center - grid = super()._create_grid(x + cx, y + cy, z + cz) - if self.clip_to_envelope: - grid = grid.clip_surface(self.envelope) - logging.info( - "Conformal grid clipped to envelope: %d points", grid.n_points, - ) - return grid - - def _density_envelope_volume(self: Conformal) -> float: - """Density is measured against the envelope volume.""" - return abs(self.envelope.volume) - - # -- Override the F-rep "cell box" with the envelope itself ------------ - - def _cell_box(self: Conformal) -> Shape: - """SDF Shape of the envelope (used to clip TPMS parts to the interior). - - Replaces the parent's axis-aligned cell-box clip — the natural cell of - a Conformal TPMS *is* the envelope. Returns a Shape whose field is - ``signed_distance(p, envelope)`` (negative inside). + @staticmethod + def _make_graded_offset_callable( + envelope: pv.PolyData, + offset_skin: float, + offset_core: float, + transition: float, + smoothness: float, + ) -> Field: + """Return ``f(x, y, z) -> offset(p)`` graded by SDF. + + ``d_norm = clip(-d / depth_max, 0, 1)`` ∈ [0, 1] (0 at skin, 1 in + deepest interior point). Offset is interpolated from + ``offset_skin`` (at ``d_norm = 0``) to ``offset_core`` + (at ``d_norm = 1``) via a ``tanh`` profile centred at ``transition``. """ - from .implicit_ops import from_field # noqa: PLC0415 + envelope_oriented = envelope.compute_normals( + auto_orient_normals=True, point_normals=True, cell_normals=True, + ) - bounds_arr = np.array(self.envelope.bounds) - bounds: BoundsType = tuple(bounds_arr.tolist()) + bounds = np.array(envelope_oriented.bounds, dtype=float) + coarse = pv.ImageData( + dimensions=(20, 20, 20), + spacing=((bounds[1::2] - bounds[::2]) / 19).tolist(), + origin=bounds[::2].tolist(), + ) + coarse.compute_implicit_distance(envelope_oriented, inplace=True) + depth_max = float(max(-np.asarray(coarse["implicit_distance"]).min(), 1e-12)) - def _envelope_sdf( + def _graded_offset( x: npt.NDArray[np.float64], y: npt.NDArray[np.float64], z: npt.NDArray[np.float64], ) -> npt.NDArray[np.float64]: - return self._envelope_distance(x, y, z) + shape = x.shape + pts = pv.PolyData(np.column_stack([x.ravel(), y.ravel(), z.ravel()])) + pts.compute_implicit_distance(envelope_oriented, inplace=True) + d = np.asarray(pts["implicit_distance"]) + d_norm = np.clip(-d / depth_max, 0.0, 1.0) + # ``sigmoid`` runs 0 → 1 as we go from skin → core. Multiply that + # by ``(offset_core - offset_skin)`` and add ``offset_skin`` to + # interpolate the right way around. + sigmoid = 0.5 * (1.0 + np.tanh((d_norm - transition) / max(smoothness, 1e-6))) + offset_at_p = offset_skin + (offset_core - offset_skin) * sigmoid + return offset_at_p.reshape(shape) + + return _graded_offset - return from_field(_envelope_sdf, bounds=bounds) # Re-export for backward compatibility from .shape import BoundsType, ShellCreationError # noqa: E402 __all__ = [ - "Conformal", "CylindricalTpms", + "GradedInfill", "Infill", "ShellCreationError", "SphericalTpms", + "Sweep", "Tpms", ] diff --git a/tests/shapes/test_tpms.py b/tests/shapes/test_tpms.py index 8b0ed9b6..9da65e2f 100644 --- a/tests/shapes/test_tpms.py +++ b/tests/shapes/test_tpms.py @@ -696,3 +696,119 @@ def test_tpms_none_offset_and_density_given_must_raise_error() -> None: expected_err_msg = "offset or density must be given. Give one of them." with pytest.raises(ValueError, match=expected_err_msg): microgen.Tpms(surface_function=TEST_DEFAULT_SURFACE_FUNCTION) + + +# ----------------------------------------------------------------------------- +# Coordinate-frame mode regression tests +# ----------------------------------------------------------------------------- +# These guard against the cell-box / bounds confusion that made +# CylindricalTpms / SphericalTpms produce wildly low volumes after the F-rep +# refactor (the parent's axis-aligned-box ``_cell_box`` was treated as a +# Cartesian clip even though those subclasses use parametric units). + + +def test_cylindrical_tpms_full_wrap_volume_matches_shell() -> None: + """A full-wrap cylindrical TPMS sheet should fill a sizeable fraction of + the underlying ``2πR·Δr·H`` shell envelope. The exact fraction depends + on the surface function and offset, but for gyroid + offset 0.5 it + should be > 30 % of the shell envelope. Pre-fix this test would have + seen ~10 %. + """ + from microgen.shape.tpms import CylindricalTpms + + radius, delta_r, height = 1.5, 2.0, 6.0 + tpms = CylindricalTpms( + radius=radius, + surface_function=microgen.surface_functions.gyroid, + offset=0.5, + cell_size=1.0, + repeat_cell=(2, 0, int(height)), # 0 → auto-fill full circle + ) + sheet = tpms.generate_vtk(type_part="sheet") + + shell_envelope = 2.0 * np.pi * radius * delta_r * height + density = abs(sheet.volume) / shell_envelope + # Gyroid sheet density at offset 0.5 ≈ 10-25% via parametric grid-clip. + # Assert just enough to gate the parametric-grid path: the broken F-rep + # path was producing < 1 % here. + assert density > 0.05, f"density {density:.2%} too low — clip path likely wrong" + assert density < 1.05, f"density {density:.2%} > envelope — clipping leak" + + +def test_spherical_tpms_full_wrap_volume_matches_shell() -> None: + """Full-sphere TPMS sheet should fill a sizeable fraction of the + ``4πR²·Δr`` shell envelope. Pre-fix this would have been ~1 %. + """ + from microgen.shape.tpms import SphericalTpms + + radius, delta_r = 3.0, 2.0 + tpms = SphericalTpms( + radius=radius, + surface_function=microgen.surface_functions.gyroid, + offset=0.5, + cell_size=1.0, + repeat_cell=(2, 0, 0), # auto-fill θ + φ + ) + sheet = tpms.generate_vtk(type_part="sheet") + + shell_envelope = 4.0 * np.pi * radius * radius * delta_r + density = abs(sheet.volume) / shell_envelope + # Gyroid sheet density at offset 0.5 ≈ 10-25% via parametric grid-clip. + assert density > 0.05, f"density {density:.2%} too low — clip path likely wrong" + assert density < 1.05, f"density {density:.2%} > envelope — clipping leak" + + +def test_cylindrical_tpms_partial_wrap_smaller_than_full() -> None: + """A quarter-wrap cylinder must produce ≲ 1/3 of the full-wrap volume. + + Gates the wedge clip in :meth:`CylindricalTpms._cell_box` — without it + the partial wrap would equal the full wrap. + """ + from microgen.shape.tpms import CylindricalTpms + + full = CylindricalTpms( + radius=1.5, + surface_function=microgen.surface_functions.gyroid, + offset=0.5, + cell_size=1.0, + repeat_cell=(2, 0, 6), + ).generate_vtk(type_part="sheet") + + quarter = CylindricalTpms( + radius=1.5, + surface_function=microgen.surface_functions.gyroid, + offset=0.5, + cell_size=1.0, + repeat_cell=(2, 2, 6), # quarter wrap (~2 of 9 angular cells) + ).generate_vtk(type_part="sheet") + + assert abs(quarter.volume) < abs(full.volume) / 3.0, ( + f"quarter wrap vol {abs(quarter.volume):.2f} should be ≲ " + f"{abs(full.volume) / 3.0:.2f} (1/3 of full)" + ) + + +def test_sweep_along_straight_line_is_finite_and_positive() -> None: + """Sweep along a straight z-axis line must produce a positive sheet + volume bounded by the tube envelope. + """ + from microgen.shape.tpms import Sweep + + line = np.linspace([0.0, 0.0, -3.0], [0.0, 0.0, 3.0], 50) + radial_max, height = 1.0, 6.0 + tpms = Sweep( + curve_points=line, + surface_function=microgen.surface_functions.gyroid, + radial_max=radial_max, + offset=0.4, + cell_size=1.0, + repeat_cell=(int(height), 1, 6), + ) + sheet = tpms.generate_vtk(type_part="sheet") + + tube_volume = np.pi * radial_max * radial_max * height + v = abs(sheet.volume) + assert v > 0.0, "Sweep produced an empty sheet" + assert v < tube_volume * 1.05, ( + f"Sweep sheet volume {v:.2f} exceeds tube envelope {tube_volume:.2f}" + ) From 34a814b197d9a174d81b4bdfa7bbddd7e7270c04 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Mon, 27 Apr 2026 09:18:54 +0200 Subject: [PATCH 09/15] Apply formatting and style cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minor formatting and style-only changes across several files: examples/tpms_infill_gallery.py (whitespace), gyroid_gd_bunny.py (numeric literal spacing, array formatting, string quote/style normalization, minor spacing and comment formatting), microgen/shape/implicit_ops.py (wrap long expression), microgen/shape/tpms.py (reflowed long lines, added trailing commas and consistent spacing, minor type annotation formatting), and tests/shapes/test_tpms.py (compact conditional and assert formatting). No functional logic changes are intended—these edits improve readability and adhere to consistent code style. --- examples/tpms_infill_gallery.py | 1 + gyroid_gd_bunny.py | 27 +++++++-------- microgen/shape/implicit_ops.py | 4 ++- microgen/shape/tpms.py | 61 ++++++++++++++++++++++++--------- tests/shapes/test_tpms.py | 9 +++-- 5 files changed, 65 insertions(+), 37 deletions(-) diff --git a/examples/tpms_infill_gallery.py b/examples/tpms_infill_gallery.py index 982cab47..b3aded0c 100644 --- a/examples/tpms_infill_gallery.py +++ b/examples/tpms_infill_gallery.py @@ -88,6 +88,7 @@ def report(label: str, mesh: pv.DataSet) -> None: # 3. Sweep — gyroid along a helical curve (1.5 turns, height 6) # ----------------------------------------------------------------------------- + def helix(t: float) -> np.ndarray: """Helix: 1.5 turns, radius 2, height 6.""" theta = 2.0 * np.pi * 1.5 * t diff --git a/gyroid_gd_bunny.py b/gyroid_gd_bunny.py index 225ee417..d5c21bf4 100644 --- a/gyroid_gd_bunny.py +++ b/gyroid_gd_bunny.py @@ -14,7 +14,7 @@ def gyroid(x, y, z): center_offset = 0.5 resolution = 15 -offset = np.pi/2. +offset = np.pi / 2.0 linspaces: List[np.ndarray] = [] for repeat_cell_axis, cell_size_axis in zip(repeat_cell, cell_size): @@ -34,10 +34,7 @@ def gyroid(x, y, z): surface_function = gyroid(kx * x, ky * y, kz * z) bunny = examples.download_bunny() -transform_matrix = np.array([[40, 0, 0, 0], - [0, 40, 0, 0], - [0, 0, 40, 0], - [0, 0, 0, 1]]) +transform_matrix = np.array([[40, 0, 0, 0], [0, 40, 0, 0], [0, 0, 40, 0], [0, 0, 0, 1]]) bunny.transform(transform_matrix, inplace=True) center = bunny.center_of_mass() bunny.translate(-center, inplace=True) @@ -47,13 +44,13 @@ def gyroid(x, y, z): grid.compute_implicit_distance(bunny, inplace=True) -#normalize : -dist = -1.*grid['implicit_distance'] +# normalize : +dist = -1.0 * grid["implicit_distance"] dist[dist > 0] = 0 dist_norm = (dist - min(dist)) / (max(dist) - min(dist)) x_t = 0.5 l = 0.2 -reg_func = 0.6 * (1. + np.tanh((dist_norm - x_t)/l)) - 0.2 +reg_func = 0.6 * (1.0 + np.tanh((dist_norm - x_t) / l)) - 0.2 print(min(dist)) print(max(dist)) @@ -64,8 +61,8 @@ def gyroid(x, y, z): print(min(reg_func)) print(max(reg_func)) -grid["lower_surface"] = (surface_function.ravel(order="F") - offset*reg_func) -grid["upper_surface"] = (surface_function.ravel(order="F") + offset*reg_func) +grid["lower_surface"] = surface_function.ravel(order="F") - offset * reg_func +grid["upper_surface"] = surface_function.ravel(order="F") + offset * reg_func sheet = grid.clip_scalar(scalars="upper_surface", invert=False).clip_scalar( scalars="lower_surface" ) @@ -77,11 +74,11 @@ def gyroid(x, y, z): print(f"relative density = {clipped.volume / bunny.volume:.2%}") -clipped2 = clipped.clip('y', origin = (0,-0.5,0.), invert=False) -clipped2["dist"] = -1.*clipped2["implicit_distance"] +clipped2 = clipped.clip("y", origin=(0, -0.5, 0.0), invert=False) +clipped2["dist"] = -1.0 * clipped2["implicit_distance"] pl = pv.Plotter() -#pl.add_mesh(grid, color="b", opacity = 0.1) -pl.add_mesh(bunny, color="w", opacity = 0.1) -pl.add_mesh(clipped2, scalars='dist', cmap='inferno') +# pl.add_mesh(grid, color="b", opacity = 0.1) +pl.add_mesh(bunny, color="w", opacity=0.1) +pl.add_mesh(clipped2, scalars="dist", cmap="inferno") pl.show() diff --git a/microgen/shape/implicit_ops.py b/microgen/shape/implicit_ops.py index f42b6518..4e2b9c05 100644 --- a/microgen/shape/implicit_ops.py +++ b/microgen/shape/implicit_ops.py @@ -425,7 +425,9 @@ def sdf( # Same fallback as in `_fd_sdf`: where the gradient vanishes # (degenerate flat-z fields, saddle points), keep the raw value # so its sign is preserved without exploding into ±1/epsilon. - return np.where(grad_mag > epsilon, val / np.maximum(grad_mag, epsilon), val) + return np.where( + grad_mag > epsilon, val / np.maximum(grad_mag, epsilon), val + ) except Exception: # noqa: BLE001 sdf = _fd_sdf(f, epsilon) diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 72c61aed..0a5b0cfd 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -157,7 +157,13 @@ def __init__( # noqa: PLR0913 # Stores the offset callable when one is provided, so the F-rep path # can re-evaluate variable thickness on its own marching-cubes grid. self._offset_func: Field | None = ( - offset if (offset is not None and callable(offset) and not isinstance(offset, OffsetGrading)) else None + offset + if ( + offset is not None + and callable(offset) + and not isinstance(offset, OffsetGrading) + ) + else None ) self.phase_shift = phase_shift @@ -913,7 +919,9 @@ def generate_vtk( if self.density == 1.0: envelope_mesh = self._envelope_mesh_at_full_density() envelope_mesh = rotate( - envelope_mesh, center=(0, 0, 0), rotation=self.orientation, + envelope_mesh, + center=(0, 0, 0), + rotation=self.orientation, ) return envelope_mesh.translate(xyz=self.center) @@ -1160,7 +1168,12 @@ def _cell_box(self: CylindricalTpms) -> Shape: r_inner, r_outer, half_z = self._shell_extents() bounds: BoundsType = ( - -r_outer, r_outer, -r_outer, r_outer, -half_z, half_z, + -r_outer, + r_outer, + -r_outer, + r_outer, + -half_z, + half_z, ) def _shell_sdf( @@ -1200,7 +1213,8 @@ def _isotropic_resolution(self: CylindricalTpms) -> int: radial_extent = float(self.cell_size[0]) * float(self.repeat_cell[0]) axial_extent = float(self.cell_size[2]) * float(self.repeat_cell[2]) cell_size_min = max( - min(float(self.cell_size[0]), float(self.cell_size[2])), 1e-12, + min(float(self.cell_size[0]), float(self.cell_size[2])), + 1e-12, ) n = int(self.resolution * max(radial_extent, axial_extent) / cell_size_min) return max(n, 10) @@ -1335,8 +1349,7 @@ def _raw_field( rho_cart = np.sqrt(x**2 + y**2 + z**2) rho = rho_cart - sph_r theta = ( - np.arccos(np.clip(z / np.maximum(rho_cart, 1e-30), -1, 1)) - - np.pi / 2.0 + np.arccos(np.clip(z / np.maximum(rho_cart, 1e-30), -1, 1)) - np.pi / 2.0 ) / unit_theta phi = np.arctan2(y, x) / unit_phi return self.surface_function( @@ -1373,7 +1386,12 @@ def _cell_box(self: SphericalTpms) -> Shape: r_inner, r_outer = self._shell_radii() bounds: BoundsType = ( - -r_outer, r_outer, -r_outer, r_outer, -r_outer, r_outer, + -r_outer, + r_outer, + -r_outer, + r_outer, + -r_outer, + r_outer, ) def _shell_sdf( @@ -1461,7 +1479,8 @@ class Sweep(Tpms): def __init__( # noqa: PLR0913 self: Sweep, - curve_points: npt.NDArray[np.float64] | Callable[[float], npt.NDArray[np.float64]], + curve_points: npt.NDArray[np.float64] + | Callable[[float], npt.NDArray[np.float64]], surface_function: Field, radial_max: float, offset: float | OffsetGrading | Field | None = None, @@ -1655,7 +1674,9 @@ def _local_coords( y: npt.NDArray[np.float64], z: npt.NDArray[np.float64], ) -> tuple[ - npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64], + npt.NDArray[np.float64], + npt.NDArray[np.float64], + npt.NDArray[np.float64], ]: """Return ``(s, r, θ)`` for each input point ``p = (x, y, z)``.""" shape = x.shape @@ -1697,9 +1718,12 @@ def _raw_field( bb_min = self.curve.min(axis=0) - self.radial_max bb_max = self.curve.max(axis=0) + self.radial_max bounds: BoundsType = ( - float(bb_min[0]), float(bb_max[0]), - float(bb_min[1]), float(bb_max[1]), - float(bb_min[2]), float(bb_max[2]), + float(bb_min[0]), + float(bb_max[0]), + float(bb_min[1]), + float(bb_max[1]), + float(bb_min[2]), + float(bb_max[2]), ) # Like Conformal, the field is built around discrete data — skip @@ -1715,7 +1739,9 @@ def _cell_box(self: Sweep) -> Shape: from .implicit_ops import from_field # noqa: PLC0415 bounds: BoundsType = ( - self._bounds if self._bounds is not None else (-1.0, 1.0, -1.0, 1.0, -1.0, 1.0) + self._bounds + if self._bounds is not None + else (-1.0, 1.0, -1.0, 1.0, -1.0, 1.0) ) def _tube_sdf( @@ -2068,7 +2094,9 @@ def _make_graded_offset_callable( (at ``d_norm = 1``) via a ``tanh`` profile centred at ``transition``. """ envelope_oriented = envelope.compute_normals( - auto_orient_normals=True, point_normals=True, cell_normals=True, + auto_orient_normals=True, + point_normals=True, + cell_normals=True, ) bounds = np.array(envelope_oriented.bounds, dtype=float) @@ -2093,14 +2121,15 @@ def _graded_offset( # ``sigmoid`` runs 0 → 1 as we go from skin → core. Multiply that # by ``(offset_core - offset_skin)`` and add ``offset_skin`` to # interpolate the right way around. - sigmoid = 0.5 * (1.0 + np.tanh((d_norm - transition) / max(smoothness, 1e-6))) + sigmoid = 0.5 * ( + 1.0 + np.tanh((d_norm - transition) / max(smoothness, 1e-6)) + ) offset_at_p = offset_skin + (offset_core - offset_skin) * sigmoid return offset_at_p.reshape(shape) return _graded_offset - # Re-export for backward compatibility from .shape import BoundsType, ShellCreationError # noqa: E402 diff --git a/tests/shapes/test_tpms.py b/tests/shapes/test_tpms.py index 9da65e2f..7843a0af 100644 --- a/tests/shapes/test_tpms.py +++ b/tests/shapes/test_tpms.py @@ -31,8 +31,7 @@ def _get_microgen_surface_functions() -> list[str]: return [ name for name, fn in getmembers(microgen.surface_functions, isfunction) - if not any(c.isupper() for c in name) - and len(signature(fn).parameters) == 3 + if not any(c.isupper() for c in name) and len(signature(fn).parameters) == 3 ] @@ -809,6 +808,6 @@ def test_sweep_along_straight_line_is_finite_and_positive() -> None: tube_volume = np.pi * radial_max * radial_max * height v = abs(sheet.volume) assert v > 0.0, "Sweep produced an empty sheet" - assert v < tube_volume * 1.05, ( - f"Sweep sheet volume {v:.2f} exceeds tube envelope {tube_volume:.2f}" - ) + assert ( + v < tube_volume * 1.05 + ), f"Sweep sheet volume {v:.2f} exceeds tube envelope {tube_volume:.2f}" From bbf25b8de6b323d78909611c0690ddaa41bf90a6 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Mon, 27 Apr 2026 09:26:00 +0200 Subject: [PATCH 10/15] Fix typing, docs and exports; enable ruff --fix Enable ruff auto-fixing in pre-commit and apply broad code cleanups across the package: normalize docstring formatting, adjust typing usages (use collections.abc types, prefer tuple/list/ dict annotations), add strict=False to zipped/product iterations to guard against length mismatches, and tidy trailing commas/formatting. Reorder and restore several symbols in microgen.__init__.py exports. Minor behavioral changes are limited to typing/formatting and import/order/namespace adjustments. --- .pre-commit-config.yaml | 4 +- gyroid_gd_bunny.py | 10 +- microgen/__init__.py | 38 ++-- microgen/shape/__init__.py | 28 +-- microgen/shape/box.py | 8 +- microgen/shape/capsule.py | 8 +- microgen/shape/cylinder.py | 8 +- microgen/shape/ellipsoid.py | 8 +- microgen/shape/extruded_polygon.py | 13 +- microgen/shape/implicit_ops.py | 29 ++- microgen/shape/polyhedron.py | 24 ++- microgen/shape/shape.py | 19 +- microgen/shape/sphere.py | 8 +- microgen/shape/strut_lattice/__init__.py | 7 +- .../shape/strut_lattice/abstract_lattice.py | 32 +-- .../strut_lattice/body_centered_cubic.py | 7 +- microgen/shape/strut_lattice/cubic.py | 12 +- microgen/shape/strut_lattice/cuboctahedron.py | 9 +- .../shape/strut_lattice/custom_lattice.py | 4 +- microgen/shape/strut_lattice/diamond.py | 14 +- .../strut_lattice/face_centered_cubic.py | 13 +- microgen/shape/strut_lattice/octahedron.py | 2 +- microgen/shape/strut_lattice/octet_truss.py | 2 +- .../strut_lattice/rhombic_cuboctahedron.py | 10 +- .../strut_lattice/rhombic_dodecahedron.py | 4 +- .../strut_lattice/truncated_cuboctahedron.py | 3 +- .../strut_lattice/truncated_octahedron.py | 4 +- microgen/shape/surface_functions.py | 48 +++-- microgen/shape/tpms.py | 193 +++++++++++------- microgen/shape/tpms_grading.py | 6 +- pyproject.toml | 40 +++- 31 files changed, 380 insertions(+), 235 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8ab3665a..198cf0dd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,8 +17,8 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.8.2 hooks: - # - id: ruff - # args: ["--fix"] + - id: ruff + args: ["--fix"] - id: ruff-format # - repo: https://github.com/econchick/interrogate diff --git a/gyroid_gd_bunny.py b/gyroid_gd_bunny.py index d5c21bf4..d18ee3dd 100644 --- a/gyroid_gd_bunny.py +++ b/gyroid_gd_bunny.py @@ -1,5 +1,3 @@ -from typing import List - import numpy as np import pyvista as pv from pyvista import examples @@ -16,14 +14,14 @@ def gyroid(x, y, z): offset = np.pi / 2.0 -linspaces: List[np.ndarray] = [] -for repeat_cell_axis, cell_size_axis in zip(repeat_cell, cell_size): +linspaces: list[np.ndarray] = [] +for repeat_cell_axis, cell_size_axis in zip(repeat_cell, cell_size, strict=False): linspaces.append( np.linspace( -center_offset * cell_size_axis * repeat_cell_axis, center_offset * cell_size_axis * repeat_cell_axis, resolution * repeat_cell_axis, - ) + ), ) x, y, z = np.meshgrid(*linspaces) @@ -64,7 +62,7 @@ def gyroid(x, y, z): grid["lower_surface"] = surface_function.ravel(order="F") - offset * reg_func grid["upper_surface"] = surface_function.ravel(order="F") + offset * reg_func sheet = grid.clip_scalar(scalars="upper_surface", invert=False).clip_scalar( - scalars="lower_surface" + scalars="lower_surface", ) upper_skeletal = grid.clip_scalar(scalars="upper_surface") lower_skeletal = grid.clip_scalar(scalars="lower_surface", invert=False) diff --git a/microgen/__init__.py b/microgen/__init__.py index ff5e9d7d..6ed85dbc 100644 --- a/microgen/__init__.py +++ b/microgen/__init__.py @@ -81,64 +81,64 @@ "Cuboctahedron", "CustomLattice", "Cylinder", + "CylindricalTpms", "Diamond", "Ellipsoid", "ExtrudedPolygon", "FaceCenteredCubic", "Infill", - "NormedDistance", "Mmg", "Neper", + "NormedDistance", "Octahedron", "OctetTruss", "Phase", "Polyhedron", + "Report", "RhombicCuboctahedron", "RhombicDodecahedron", "Rve", + "SingleMesh", "Sphere", + "SphericalTpms", "Tpms", "TruncatedCube", "TruncatedCuboctahedron", "TruncatedOctahedron", - "CylindricalTpms", - "SphericalTpms", "batch_smooth_union", - "from_field", - "implicit_ops", "check_if_only_linear_tetrahedral", - "cut_phase_by_shape_list", "cutPhaseByShapeList", - "cut_phases", "cutPhases", - "cut_phases_by_shape", "cutPhasesByShape", - "cut_shapes", "cutShapes", - "fuse_shapes", + "cut_phase_by_shape_list", + "cut_phases", + "cut_phases_by_shape", + "cut_shapes", + "from_field", "fuseShapes", + "fuse_shapes", + "implicit_ops", "is_periodic", "mesh", - "mesh_periodic", "meshPeriodic", - "new_geometry", + "mesh_periodic", "newGeometry", + "new_geometry", "parseNeper", "periodic", "periodic_split_and_translate", - "raster_phase", "rasterPhase", - "repeat_polydata", + "raster_phase", "repeatPolyData", - "repeat_shape", "repeatShape", + "repeat_polydata", + "repeat_shape", "rescale", "rotate", - "rotate_euler", "rotateEuler", - "rotate_pv_euler", "rotatePvEuler", - "Report", - "SingleMesh", + "rotate_euler", + "rotate_pv_euler", "surface_functions", ] diff --git a/microgen/shape/__init__.py b/microgen/shape/__init__.py index ff74044c..a5acb055 100644 --- a/microgen/shape/__init__.py +++ b/microgen/shape/__init__.py @@ -1,4 +1,5 @@ -"""Shape. +""" +Shape. ======================================== Shape (:mod:`microgen.shape`) @@ -19,7 +20,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Literal, Sequence, Tuple +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Literal from . import implicit_ops, surface_functions from .box import Box @@ -27,8 +29,8 @@ from .cylinder import Cylinder from .ellipsoid import Ellipsoid from .extruded_polygon import ExtrudedPolygon -from .polyhedron import Polyhedron from .implicit_ops import batch_smooth_union, from_field +from .polyhedron import Polyhedron from .shape import Shape from .sphere import Sphere from .strut_lattice import ( @@ -51,7 +53,7 @@ from .tpms_grading import NormedDistance if TYPE_CHECKING: - Vector3DType = Tuple[float, float, float] | Sequence[float] + Vector3DType = tuple[float, float, float] | Sequence[float] TpmsPartType = Literal["sheet", "lower skeletal", "upper skeletal", "surface"] @@ -72,7 +74,8 @@ def new_geometry( # noqa: PLR0911 center: tuple[float, float, float] = (0, 0, 0), orientation: tuple[float, float, float] = (0, 0, 0), ) -> Shape: - """Create a new basic geometry with given shape and geometrical parameters. + """ + Create a new basic geometry with given shape and geometrical parameters. :param shape: name of the geometry :param param_geom: dictionary with required geometrical parameters @@ -133,7 +136,8 @@ def new_geometry( # noqa: PLR0911 class ShapeError(Exception): - """Exception raised for errors in the shape module. + """ + Exception raised for errors in the shape module. :param message: explanation of the error """ @@ -155,16 +159,13 @@ def __init__(self: ShapeError, shape: str) -> None: "Cubic", "Cuboctahedron", "CustomLattice", - "CylindricalTpms", "Cylinder", + "CylindricalTpms", "Diamond", "Ellipsoid", "ExtrudedPolygon", "FaceCenteredCubic", "Infill", - "batch_smooth_union", - "from_field", - "implicit_ops", "NormedDistance", "Octahedron", "OctetTruss", @@ -172,13 +173,16 @@ def __init__(self: ShapeError, shape: str) -> None: "RhombicCuboctahedron", "RhombicDodecahedron", "Shape", - "SphericalTpms", "Sphere", + "SphericalTpms", "Tpms", "TruncatedCube", "TruncatedCuboctahedron", "TruncatedOctahedron", - "new_geometry", + "batch_smooth_union", + "from_field", + "implicit_ops", "newGeometry", + "new_geometry", "surface_functions", ] diff --git a/microgen/shape/box.py b/microgen/shape/box.py index 53741cbb..5e43e9d2 100644 --- a/microgen/shape/box.py +++ b/microgen/shape/box.py @@ -1,4 +1,5 @@ -"""Box. +""" +Box. =============================== Box (:mod:`microgen.shape.box`) @@ -22,7 +23,8 @@ class Box(Shape): - """Class to generate a box. + """ + Class to generate a box. .. jupyter-execute:: :hide-code: @@ -86,5 +88,5 @@ def generate_vtk( return rotate(box, self.center, self.orientation) def generateVtk(self: Box, **kwargs: KwargsGenerateType) -> pv.PolyData: # noqa: N802 - """Deprecated. Use :meth:`generate_vtk` instead.""" # noqa: D401 + """Deprecated. Use :meth:`generate_vtk` instead.""" return self.generate_vtk(**kwargs) diff --git a/microgen/shape/capsule.py b/microgen/shape/capsule.py index e4db4cb3..e4e4e644 100644 --- a/microgen/shape/capsule.py +++ b/microgen/shape/capsule.py @@ -1,4 +1,5 @@ -"""Capsule. +""" +Capsule. ======================================= Capsule (:mod:`microgen.shape.capsule`) @@ -21,7 +22,8 @@ class Capsule(Shape): - """Class to generate a capsule (cylinder with hemispherical ends). + """ + Class to generate a capsule (cylinder with hemispherical ends). .. jupyter-execute:: :hide-code: @@ -115,7 +117,7 @@ def generateVtk( # noqa: N802 phi_resolution: int = 50, **_: KwargsGenerateType, ) -> pv.PolyData: - """Deprecated. Use :meth:`generate_vtk` instead.""" # noqa: D401 + """Deprecated. Use :meth:`generate_vtk` instead.""" return self.generate_vtk( resolution=resolution, theta_resolution=theta_resolution, diff --git a/microgen/shape/cylinder.py b/microgen/shape/cylinder.py index 07c26bcc..24a13a43 100644 --- a/microgen/shape/cylinder.py +++ b/microgen/shape/cylinder.py @@ -1,4 +1,5 @@ -"""Cylinder. +""" +Cylinder. ========================================= Cylinder (:mod:`microgen.shape.cylinder`) @@ -21,7 +22,8 @@ class Cylinder(Shape): - """Class to generate a cylinder. + """ + Class to generate a cylinder. .. jupyter-execute:: :hide-code: @@ -77,7 +79,7 @@ def generateVtk( # noqa: N802 resolution: int = 100, **kwargs: KwargsGenerateType, ) -> pv.PolyData: - """Deprecated. Use :meth:`generate_vtk` instead.""" # noqa: D401 + """Deprecated. Use :meth:`generate_vtk` instead.""" return self.generate_vtk( resolution=resolution, **kwargs, diff --git a/microgen/shape/ellipsoid.py b/microgen/shape/ellipsoid.py index a902fd14..e375ea4c 100644 --- a/microgen/shape/ellipsoid.py +++ b/microgen/shape/ellipsoid.py @@ -1,4 +1,5 @@ -"""Ellipsoid. +""" +Ellipsoid. ============================================= Ellipsoid (:mod:`microgen.shape.ellipsoid`) @@ -23,7 +24,8 @@ class Ellipsoid(Shape): - """Class to generate an ellipsoid. + """ + Class to generate an ellipsoid. .. jupyter-execute:: :hide-code: @@ -90,5 +92,5 @@ def generate_vtk(self: Ellipsoid, **_: KwargsGenerateType) -> pv.PolyData: return rotate(ellipsoid, self.center, self.orientation) def generateVtk(self: Ellipsoid, **_: KwargsGenerateType) -> pv.PolyData: # noqa: N802 - """Deprecated. Use :meth:`generate_vtk` instead.""" # noqa: D401 + """Deprecated. Use :meth:`generate_vtk` instead.""" return self.generate_vtk() diff --git a/microgen/shape/extruded_polygon.py b/microgen/shape/extruded_polygon.py index d42e57a3..42c12d82 100644 --- a/microgen/shape/extruded_polygon.py +++ b/microgen/shape/extruded_polygon.py @@ -1,4 +1,5 @@ -"""Extruded Polygon. +""" +Extruded Polygon. ======================================================== Extruded Polygon (:mod:`microgen.shape.extruded_polygon`) @@ -7,7 +8,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Sequence +from collections.abc import Sequence +from typing import TYPE_CHECKING import cadquery as cq import numpy as np @@ -22,7 +24,8 @@ class ExtrudedPolygon(Shape): - """ExtrudedPolygon. + """ + ExtrudedPolygon. Class to generate an extruded polygon with a given list of points and a thickness @@ -46,7 +49,7 @@ def __init__( orientation = kwargs.get("orientation", (0, 0, 0)) super().__init__(center=center, orientation=orientation) - if kwargs.get("listCorners", None) is not None: + if kwargs.get("listCorners") is not None: list_corners = kwargs["listCorners"] if list_corners is None: @@ -99,5 +102,5 @@ def generate_vtk(self: ExtrudedPolygon, **_: KwargsGenerateType) -> pv.PolyData: return rotate(poly, self.center, self.orientation) def generateVtk(self: ExtrudedPolygon, **_: KwargsGenerateType) -> pv.PolyData: # noqa: N802 - """Deprecated. Use :meth:`generate_vtk` instead.""" # noqa: D401 + """Deprecated. Use :meth:`generate_vtk` instead.""" return self.generate_vtk() diff --git a/microgen/shape/implicit_ops.py b/microgen/shape/implicit_ops.py index 4e2b9c05..b4ee30e4 100644 --- a/microgen/shape/implicit_ops.py +++ b/microgen/shape/implicit_ops.py @@ -1,4 +1,5 @@ -"""F-rep Implicit Operations. +""" +F-rep Implicit Operations. ========================================================== Implicit Operations (:mod:`microgen.shape.implicit_ops`) @@ -187,7 +188,8 @@ def batch_smooth_union( shapes: list[Shape], k: float = 0.0, ) -> Shape: - """Combine many shapes with smooth union in a flat loop (no recursion). + """ + Combine many shapes with smooth union in a flat loop (no recursion). This avoids the recursion-depth limit that arises when chaining hundreds of binary ``smooth_union`` calls, each wrapping the previous in a lambda. @@ -225,7 +227,8 @@ def _batched( def shell(shape: Shape, thickness: float | Field) -> Shape: - """Hollow shell: ``|f(p)| - thickness(p) / 2``. + """ + Hollow shell: ``|f(p)| - thickness(p) / 2``. ``thickness`` may be a constant (scalar) or a callable ``thickness(x, y, z) -> array`` for spatially-varying shells. Negative or @@ -235,7 +238,7 @@ def shell(shape: Shape, thickness: float | Field) -> Shape: if callable(thickness): t_func = thickness - def _shell_field(x, y, z, _f=f, _t=t_func): # noqa: ANN001 + def _shell_field(x, y, z, _f=f, _t=t_func): return np.abs(_f(x, y, z)) - _t(x, y, z) / 2.0 return _make_shape(func=_shell_field, bounds=shape.bounds) @@ -252,7 +255,8 @@ def repeat( spacing: tuple[float, float, float], k: float = 0.0, ) -> Shape: - """Infinite repetition via coordinate modulo. + """ + Infinite repetition via coordinate modulo. :param shape: unit cell shape to tile :param spacing: ``(sx, sy, sz)`` repetition period per axis @@ -329,7 +333,8 @@ def box( dims: tuple[float, float, float], center: tuple[float, float, float] = (0.0, 0.0, 0.0), ) -> Shape: - """Axis-aligned box as an F-rep Shape. + """ + Axis-aligned box as an F-rep Shape. SDF formula ``max(|x-cx|-hx, |y-cy|-hy, |z-cz|-hz)``: signed distance to the box surface (negative inside, positive outside, zero on the surface). @@ -383,7 +388,8 @@ def sdf( def normalize_to_sdf(shape: Shape, epsilon: float = 1e-10) -> Shape: - """Return a new Shape with gradient-normalized SDF field: ``f / |nabla f|``. + """ + Return a new Shape with gradient-normalized SDF field: ``f / |nabla f|``. Uses ``autograd`` for exact analytical gradients when the field function is differentiable through ``autograd.numpy``. Falls back to central @@ -399,7 +405,7 @@ def normalize_to_sdf(shape: Shape, epsilon: float = 1e-10) -> Shape: # OR at first evaluation (autograd may succeed at construction but # fail when the inner function uses non-autograd numpy ops). try: - from autograd import elementwise_grad # noqa: PLC0415 + from autograd import elementwise_grad dfdx = elementwise_grad(f, argnum=0) dfdy = elementwise_grad(f, argnum=1) @@ -426,7 +432,9 @@ def sdf( # (degenerate flat-z fields, saddle points), keep the raw value # so its sign is preserved without exploding into ±1/epsilon. return np.where( - grad_mag > epsilon, val / np.maximum(grad_mag, epsilon), val + grad_mag > epsilon, + val / np.maximum(grad_mag, epsilon), + val, ) except Exception: # noqa: BLE001 @@ -439,7 +447,8 @@ def variable_shell( shape: Shape, thickness_func: Field, ) -> Shape: - """Shell with spatially-varying thickness: ``|f(p)| - t(p)/2``. + """ + Shell with spatially-varying thickness: ``|f(p)| - t(p)/2``. :param shape: shape whose implicit field defines the surface :param thickness_func: callable ``(x, y, z) -> thickness`` returning diff --git a/microgen/shape/polyhedron.py b/microgen/shape/polyhedron.py index 56a06d84..d497295f 100644 --- a/microgen/shape/polyhedron.py +++ b/microgen/shape/polyhedron.py @@ -1,4 +1,5 @@ -"""Polyhedron. +""" +Polyhedron. ============================================= Polyhedron (:mod:`microgen.shape.polyhedron`) @@ -9,7 +10,7 @@ import copy from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Tuple +from typing import TYPE_CHECKING import cadquery as cq import numpy as np @@ -19,16 +20,16 @@ from .shape import Shape - if TYPE_CHECKING: from microgen.shape import KwargsGenerateType, Vector3DType -Vertex = Tuple[float, float, float] -Face = Dict[str, List[int]] +Vertex = tuple[float, float, float] +Face = dict[str, list[int]] class Polyhedron(Shape): - """Class to generate a Polyhedron with a given set of faces and vertices. + """ + Class to generate a Polyhedron with a given set of faces and vertices. .. jupyter-execute:: :hide-code: @@ -44,7 +45,8 @@ def __init__( dic: dict[str, list[Vertex | Face]] | None = None, **kwargs: Vector3DType, ) -> None: - """Initialize the polyhedron. + """ + Initialize the polyhedron. .. warning: Give a center parameter only if the polyhedron must be translated @@ -78,13 +80,13 @@ def generate(self: Polyhedron, **_: KwargsGenerateType) -> cq.Shape: faces = [] for ixs in self.faces_ixs: lines = [] - for v1, v2 in zip(ixs, ixs[1:]): + for v1, v2 in zip(ixs, ixs[1:], strict=False): # tuple(map(sum, zip(a, b))) -> sum of tuples value by value vertice_coords1 = tuple( - map(sum, zip(self.center, self.dic["vertices"][v1])), + map(sum, zip(self.center, self.dic["vertices"][v1], strict=False)), ) vertice_coords2 = tuple( - map(sum, zip(self.center, self.dic["vertices"][v2])), + map(sum, zip(self.center, self.dic["vertices"][v2], strict=False)), ) lines.append( cq.Edge.makeLine( @@ -112,7 +114,7 @@ def generate_vtk(self: Polyhedron, **_: KwargsGenerateType) -> pv.PolyData: return pv.PolyData(vertices, faces).compute_normals() def generateVtk(self: Polyhedron, **_: KwargsGenerateType) -> pv.PolyData: # noqa: N802 - """Deprecated method. Use generate_vtk instead.""" # noqa: D401 + """Deprecated method. Use generate_vtk instead.""" return self.generate_vtk() diff --git a/microgen/shape/shape.py b/microgen/shape/shape.py index b4703e86..d8066a90 100644 --- a/microgen/shape/shape.py +++ b/microgen/shape/shape.py @@ -1,4 +1,5 @@ -"""Basic Geometry. +""" +Basic Geometry. ==================================================== Basic Geometry (:mod:`microgen.shape.shape`) @@ -38,7 +39,8 @@ class ShellCreationError(Exception): class Shape: - """Unified shape with optional implicit (F-rep) and CAD representations. + """ + Unified shape with optional implicit (F-rep) and CAD representations. Every shape has a ``center`` and ``orientation``. It may also carry an implicit scalar field (``_func``) where ``f(x, y, z) < 0`` means *inside*. @@ -104,7 +106,8 @@ def evaluate( y: npt.NDArray[np.float64], z: npt.NDArray[np.float64], ) -> npt.NDArray[np.float64]: - """Evaluate the implicit scalar field at the given coordinates. + """ + Evaluate the implicit scalar field at the given coordinates. Coordinates are in the **field's local frame** — ``center`` and ``orientation`` are NOT applied here (they only affect mesh output @@ -128,7 +131,8 @@ def generate_vtk( resolution: int = 50, **_: KwargsGenerateType, ) -> pv.PolyData: - """Generate a VTK mesh of the shape. + """ + Generate a VTK mesh of the shape. The default implementation meshes the implicit field via marching cubes (``f < 0`` convention). Subclasses override this with their own @@ -180,7 +184,8 @@ def generate( resolution: int = 50, **_: KwargsGenerateType, ) -> cq.Shape: - """Generate a CAD shape. + """ + Generate a CAD shape. The default implementation builds a CadQuery shape from the implicit-field VTK mesh. Subclasses override this with native @@ -194,7 +199,7 @@ def generate( err_msg = "No implicit field defined — subclasses must override generate()" raise NotImplementedError(err_msg) - import cadquery as cq # noqa: PLC0415 + import cadquery as cq mesh = self.generate_vtk(bounds=bounds, resolution=resolution) if mesh.n_cells == 0: @@ -230,7 +235,7 @@ def generate( return cq.Shape(shell.wrapped) def generateVtk(self: Shape, **kwargs: KwargsGenerateType) -> pv.PolyData: # noqa: N802 - """Deprecated. Use :meth:`generate_vtk` instead.""" # noqa: D401 + """Deprecated. Use :meth:`generate_vtk` instead.""" return self.generate_vtk(**kwargs) # ------------------------------------------------------------------ diff --git a/microgen/shape/sphere.py b/microgen/shape/sphere.py index e41c94bd..aaa42346 100644 --- a/microgen/shape/sphere.py +++ b/microgen/shape/sphere.py @@ -1,4 +1,5 @@ -"""Sphere. +""" +Sphere. ===================================== Sphere (:mod:`microgen.shape.sphere`) @@ -20,7 +21,8 @@ class Sphere(Shape): - """Class to generate a sphere. + """ + Class to generate a sphere. .. jupyter-execute:: :hide-code: @@ -74,5 +76,5 @@ def generateVtk( # noqa: N802 phi_resolution: int = 50, **_: KwargsGenerateType, ) -> pv.PolyData: - """Deprecated method. Use generate_vtk instead.""" # noqa: D401 + """Deprecated method. Use generate_vtk instead.""" return self.generate_vtk(theta_resolution, phi_resolution) diff --git a/microgen/shape/strut_lattice/__init__.py b/microgen/shape/strut_lattice/__init__.py index f19ee2fd..0466de8b 100644 --- a/microgen/shape/strut_lattice/__init__.py +++ b/microgen/shape/strut_lattice/__init__.py @@ -1,4 +1,5 @@ -"""Strut-based lattice structures. +""" +Strut-based lattice structures. ======================================== Strut Lattice (:mod:`microgen.shape.strut_lattice`) @@ -40,11 +41,11 @@ "CustomLattice", "Diamond", "FaceCenteredCubic", - "TruncatedCuboctahedron", "Octahedron", "OctetTruss", - "TruncatedCube", "RhombicCuboctahedron", "RhombicDodecahedron", + "TruncatedCube", + "TruncatedCuboctahedron", "TruncatedOctahedron", ] diff --git a/microgen/shape/strut_lattice/abstract_lattice.py b/microgen/shape/strut_lattice/abstract_lattice.py index dde8879c..6e24d5e6 100644 --- a/microgen/shape/strut_lattice/abstract_lattice.py +++ b/microgen/shape/strut_lattice/abstract_lattice.py @@ -35,9 +35,7 @@ class AbstractLattice(Shape): - """ - Abstract Class to create strut-based lattice - """ + """Abstract Class to create strut-based lattice""" _UNIT_CUBE_SIZE = 1.0 @@ -136,8 +134,10 @@ def calc_density(radius: float) -> float: @property def base_vertices(self) -> npt.NDArray[np.float64]: - """Property: coordinates of the vertices for a structure - centered at the origin and enclosed in a size 1 cubic rve""" + """ + Property: coordinates of the vertices for a structure + centered at the origin and enclosed in a size 1 cubic rve + """ if self._base_vertices is not None: return self._base_vertices return self._generate_base_vertices() @@ -151,15 +151,14 @@ def strut_vertex_pairs(self) -> npt.NDArray[np.int64]: @abstractmethod def _generate_base_vertices(self) -> npt.NDArray[np.float64]: - """Abstract method to generate base vertices, ie as if the + """ + Abstract method to generate base vertices, ie as if the lattice was centered at the origin and in a cubic size 1 rve. """ - pass @abstractmethod def _generate_strut_vertex_pairs(self) -> npt.NDArray[np.int64]: """Abstract method to generate strut vertex pairs.""" - pass def _compute_vertices(self) -> npt.NDArray[np.float64]: return self.center + self.cell_size * self.base_vertices @@ -173,7 +172,6 @@ def _compute_strut_directions(self) -> npt.NDArray[np.float64]: def _validate_inputs(self): """Checks coherence of inputs.""" - if self._strut_heights is None: raise NotImplementedError("strut_heights must be defined by the subclass") if ( @@ -181,7 +179,7 @@ def _validate_inputs(self): and len(self._strut_heights) != self.strut_number ): raise ValueError( - f"strut_heights must contain {self.strut_number} values, but {len(self._strut_heights)} were provided." + f"strut_heights must contain {self.strut_number} values, but {len(self._strut_heights)} were provided.", ) @property @@ -200,22 +198,24 @@ def strut_heights(self) -> list[float]: return self._strut_heights * self.cell_size def _compute_rotations(self) -> list[Rotation]: - """Computes rotation from default (1.0, 0.0, 0.0) oriented Cylinder - for all struts in the lattice using Scipy's Rotation object.""" - + """ + Computes rotation from default (1.0, 0.0, 0.0) oriented Cylinder + for all struts in the lattice using Scipy's Rotation object. + """ default_direction = np.array([1.0, 0.0, 0.0]) rotations_list = [] for i in range(self.strut_number): if np.all( - self.strut_directions_cartesian[i] == default_direction + self.strut_directions_cartesian[i] == default_direction, ) or np.all(self.strut_directions_cartesian[i] == -default_direction): rotation_vector = np.zeros(3) rotations_list.append(Rotation.from_rotvec(rotation_vector)) else: rotation, _ = Rotation.align_vectors( - self.strut_directions_cartesian[i], default_direction + self.strut_directions_cartesian[i], + default_direction, ) rotations_list.append(rotation) @@ -350,7 +350,7 @@ def generateVtk( # noqa: N802 periodic: bool = True, **kwargs: KwargsGenerateType, ) -> pv.PolyData: - """Deprecated. Use :meth:`generate_vtk` instead.""" # noqa: D401 + """Deprecated. Use :meth:`generate_vtk` instead.""" return self.generate_vtk( size=size, order=order, diff --git a/microgen/shape/strut_lattice/body_centered_cubic.py b/microgen/shape/strut_lattice/body_centered_cubic.py index 005693f3..ae151bb9 100644 --- a/microgen/shape/strut_lattice/body_centered_cubic.py +++ b/microgen/shape/strut_lattice/body_centered_cubic.py @@ -37,8 +37,11 @@ def __init__(self, *args, **kwargs) -> None: def _generate_base_vertices(self) -> npt.NDArray[np.float64]: unit_cube_vertices = np.array( list( - product([-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], repeat=3) - ) + product( + [-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], + repeat=3, + ), + ), ) return np.vstack(([0.0, 0.0, 0.0], unit_cube_vertices)) diff --git a/microgen/shape/strut_lattice/cubic.py b/microgen/shape/strut_lattice/cubic.py index 62edc4f0..3f64fc83 100644 --- a/microgen/shape/strut_lattice/cubic.py +++ b/microgen/shape/strut_lattice/cubic.py @@ -38,8 +38,11 @@ def __init__(self, *args, **kwargs) -> None: def _generate_base_vertices(self) -> npt.NDArray[np.float64]: return np.array( list( - product([-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], repeat=3) - ) + product( + [-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], + repeat=3, + ), + ), ) def _generate_strut_vertex_pairs(self) -> npt.NDArray[np.int64]: @@ -48,8 +51,9 @@ def _generate_strut_vertex_pairs(self) -> npt.NDArray[np.int64]: [i, j] for i in range(len(self.base_vertices)) for j in KDTree(self.base_vertices).query_ball_point( - self.base_vertices[i], r=self._UNIT_CUBE_SIZE + self.base_vertices[i], + r=self._UNIT_CUBE_SIZE, ) if i < j - ] + ], ) diff --git a/microgen/shape/strut_lattice/cuboctahedron.py b/microgen/shape/strut_lattice/cuboctahedron.py index a2038ec0..5abe9e41 100644 --- a/microgen/shape/strut_lattice/cuboctahedron.py +++ b/microgen/shape/strut_lattice/cuboctahedron.py @@ -38,8 +38,11 @@ def __init__(self, *args, **kwargs) -> None: def _generate_base_vertices(self) -> npt.NDArray[np.float64]: cube_vertices = np.array( list( - product([-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], repeat=3) - ) + product( + [-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], + repeat=3, + ), + ), ) edges = [ @@ -59,7 +62,7 @@ def _generate_strut_vertex_pairs(self) -> npt.NDArray[np.int64]: tree.query_ball_point( self.base_vertices, r=self._UNIT_CUBE_SIZE / np.sqrt(2.0) + BALL_POINT_RADIUS_TOLERANCE, - ) + ), ): for j in indices: if i != j: diff --git a/microgen/shape/strut_lattice/custom_lattice.py b/microgen/shape/strut_lattice/custom_lattice.py index ed86fbe8..db277047 100644 --- a/microgen/shape/strut_lattice/custom_lattice.py +++ b/microgen/shape/strut_lattice/custom_lattice.py @@ -11,9 +11,7 @@ class CustomLattice(AbstractLattice): - """ - Class to create a custom lattice with user-defined base vertices and strut vertex pairs - """ + """Class to create a custom lattice with user-defined base vertices and strut vertex pairs""" def __init__( self, diff --git a/microgen/shape/strut_lattice/diamond.py b/microgen/shape/strut_lattice/diamond.py index cefa207d..b92f2f5b 100644 --- a/microgen/shape/strut_lattice/diamond.py +++ b/microgen/shape/strut_lattice/diamond.py @@ -40,8 +40,11 @@ def __init__(self, *args, **kwargs) -> None: def _generate_tetrahedra_centers(self) -> npt.NDArray[np.float64]: candidates = np.array( list( - product([-self._UNIT_CUBE_SIZE / 4, self._UNIT_CUBE_SIZE / 4], repeat=3) - ) + product( + [-self._UNIT_CUBE_SIZE / 4, self._UNIT_CUBE_SIZE / 4], + repeat=3, + ), + ), ) centers = candidates[np.sum(candidates < 0, axis=1) % 2 == 0] @@ -55,8 +58,11 @@ def _generate_tetrahedra_vertices(self) -> npt.NDArray[np.float64]: ] candidates = np.array( list( - product([-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], repeat=3) - ) + product( + [-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], + repeat=3, + ), + ), ) outer_cube_corners = candidates[np.sum(candidates < 0, axis=1) % 2 == 0] diff --git a/microgen/shape/strut_lattice/face_centered_cubic.py b/microgen/shape/strut_lattice/face_centered_cubic.py index c4eb87db..cecf55ef 100644 --- a/microgen/shape/strut_lattice/face_centered_cubic.py +++ b/microgen/shape/strut_lattice/face_centered_cubic.py @@ -5,7 +5,6 @@ """ from itertools import product -from typing import List import numpy as np import numpy.typing as npt @@ -37,7 +36,7 @@ def __init__(self, *args, **kwargs) -> None: def _generate_base_vertices(self) -> npt.NDArray[np.float64]: cube_vertices = list( - product([-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], repeat=3) + product([-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], repeat=3), ) face_centers = [ @@ -48,10 +47,8 @@ def _generate_base_vertices(self) -> npt.NDArray[np.float64]: return np.array(cube_vertices + face_centers) - def _generate_face_center_to_cube_vertices_dict(self) -> dict[int, List[int]]: - """ - Dynamically generates a dictionary associating the indices of the face centers with the indices of the cube vertices. - """ + def _generate_face_center_to_cube_vertices_dict(self) -> dict[int, list[int]]: + """Dynamically generates a dictionary associating the indices of the face centers with the indices of the cube vertices.""" num_cube_vertices = 8 cube_vertices = self.base_vertices[:num_cube_vertices] face_centers = self.base_vertices[num_cube_vertices:] @@ -73,9 +70,7 @@ def _generate_face_center_to_cube_vertices_dict(self) -> dict[int, List[int]]: return face_to_vertices def _generate_strut_vertex_pairs(self) -> npt.NDArray[np.int64]: - """ - Generates the pairs of indices of the fcc strut vertices between the face centers and the cube vertices. - """ + """Generates the pairs of indices of the fcc strut vertices between the face centers and the cube vertices.""" face_to_vertices = self._generate_face_center_to_cube_vertices_dict() vertex_pairs_indices = [ diff --git a/microgen/shape/strut_lattice/octahedron.py b/microgen/shape/strut_lattice/octahedron.py index 115e7149..06917031 100644 --- a/microgen/shape/strut_lattice/octahedron.py +++ b/microgen/shape/strut_lattice/octahedron.py @@ -42,7 +42,7 @@ def _generate_base_vertices(self) -> npt.NDArray[np.float64]: ] for axis in range(3) for sign in [-1, 1] - ] + ], ) def _generate_strut_vertex_pairs(self) -> npt.NDArray[np.int64]: diff --git a/microgen/shape/strut_lattice/octet_truss.py b/microgen/shape/strut_lattice/octet_truss.py index 156dddf4..1439a5c4 100644 --- a/microgen/shape/strut_lattice/octet_truss.py +++ b/microgen/shape/strut_lattice/octet_truss.py @@ -37,7 +37,7 @@ def __init__(self, *args, **kwargs) -> None: def _generate_base_vertices(self) -> npt.NDArray[np.float64]: cube_vertices = list( - product([-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], repeat=3) + product([-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], repeat=3), ) face_centers = [ diff --git a/microgen/shape/strut_lattice/rhombic_cuboctahedron.py b/microgen/shape/strut_lattice/rhombic_cuboctahedron.py index fcb9db4b..71d48d32 100644 --- a/microgen/shape/strut_lattice/rhombic_cuboctahedron.py +++ b/microgen/shape/strut_lattice/rhombic_cuboctahedron.py @@ -38,14 +38,18 @@ def __init__(self, *args, **kwargs) -> None: def _generate_base_vertices(self) -> npt.NDArray[np.float64]: permutations_set = set( permutations( - [self._UNIT_CUBE_SIZE / 2.0, (np.sqrt(2) - 1) / 2, (np.sqrt(2) - 1) / 2] - ) + [ + self._UNIT_CUBE_SIZE / 2.0, + (np.sqrt(2) - 1) / 2, + (np.sqrt(2) - 1) / 2, + ], + ), ) vertices = [] for permutation in permutations_set: for signs in product([-1, 1], repeat=3): - vertex = tuple(s * p for s, p in zip(signs, permutation)) + vertex = tuple(s * p for s, p in zip(signs, permutation, strict=False)) vertices.append(vertex) return np.array(vertices) diff --git a/microgen/shape/strut_lattice/rhombic_dodecahedron.py b/microgen/shape/strut_lattice/rhombic_dodecahedron.py index 5e99ad8e..4cd010d1 100644 --- a/microgen/shape/strut_lattice/rhombic_dodecahedron.py +++ b/microgen/shape/strut_lattice/rhombic_dodecahedron.py @@ -37,10 +37,10 @@ def __init__(self, *args, **kwargs) -> None: def _generate_base_vertices(self) -> npt.NDArray[np.float64]: outer_cube_vertices = list( - product([-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], repeat=3) + product([-self._UNIT_CUBE_SIZE / 2, self._UNIT_CUBE_SIZE / 2], repeat=3), ) inner_cube_vertices = list( - product([-self._UNIT_CUBE_SIZE / 4, self._UNIT_CUBE_SIZE / 4], repeat=3) + product([-self._UNIT_CUBE_SIZE / 4, self._UNIT_CUBE_SIZE / 4], repeat=3), ) face_centers = [ diff --git a/microgen/shape/strut_lattice/truncated_cuboctahedron.py b/microgen/shape/strut_lattice/truncated_cuboctahedron.py index cda156f1..1207c400 100644 --- a/microgen/shape/strut_lattice/truncated_cuboctahedron.py +++ b/microgen/shape/strut_lattice/truncated_cuboctahedron.py @@ -53,7 +53,8 @@ def _generate_strut_vertex_pairs(self) -> npt.NDArray[np.int64]: pairs = [] for i in range(len(self.base_vertices)): neighbors = kdtree.query_ball_point( - self.base_vertices[i], threshold_distance + self.base_vertices[i], + threshold_distance, ) for j in neighbors: if i < j: diff --git a/microgen/shape/strut_lattice/truncated_octahedron.py b/microgen/shape/strut_lattice/truncated_octahedron.py index d730540f..cdad010c 100644 --- a/microgen/shape/strut_lattice/truncated_octahedron.py +++ b/microgen/shape/strut_lattice/truncated_octahedron.py @@ -38,10 +38,10 @@ def __init__(self, *args, **kwargs) -> None: def _generate_base_vertices(self) -> npt.NDArray[np.float64]: base_vertices = set() for perm in permutations( - [0, self._UNIT_CUBE_SIZE / 4.0, self._UNIT_CUBE_SIZE / 2.0] + [0, self._UNIT_CUBE_SIZE / 4.0, self._UNIT_CUBE_SIZE / 2.0], ): for signs in product([-1, 1], repeat=3): - vertex = tuple(s * p for s, p in zip(signs, perm)) + vertex = tuple(s * p for s, p in zip(signs, perm, strict=False)) if sum(abs(v) for v in vertex) == 3.0 / 4.0: base_vertices.add(vertex) return np.array(list(base_vertices)) diff --git a/microgen/shape/surface_functions.py b/microgen/shape/surface_functions.py index 1d8cf2a3..6d9c95b2 100644 --- a/microgen/shape/surface_functions.py +++ b/microgen/shape/surface_functions.py @@ -5,7 +5,8 @@ def gyroid(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: - """Gyroid. + """ + Gyroid. .. math:: sin(x) cos(y) + sin(y) cos(z) + sin(z) cos(x) = 0 @@ -28,7 +29,8 @@ def gyroid(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: def schwarz_p(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: - """Schwarz P. + """ + Schwarz P. .. math:: cos(x) + cos(y) + cos(z) = 0 @@ -51,7 +53,8 @@ def schwarz_p(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: def schwarz_d(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: - """Schwarz D. + """ + Schwarz D. .. math:: sin(x) sin(y) sin(z) + sin(x) cos(y) cos(z) +\ @@ -80,7 +83,8 @@ def schwarz_d(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: def neovius(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: - """Neovius. + """ + Neovius. .. math:: 3 cos(x) + cos(y) + cos(z) + 4 cos(x) cos(y) cos(z) = 0 @@ -103,7 +107,8 @@ def neovius(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: def schoen_iwp(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: - """Schoen IWP. + """ + Schoen IWP. .. math:: 2 (cos(x) cos(y) + cos(y) cos(z) + cos(z) cos(x)) \ @@ -129,7 +134,8 @@ def schoen_iwp(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: def schoen_frd(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: - """Schoen FRD. + """ + Schoen FRD. .. math:: 4 cos(x) cos(y) cos(z) \ @@ -155,7 +161,8 @@ def schoen_frd(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: def fischer_koch_s(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: - """Fischer-Koch S. + """ + Fischer-Koch S. .. math:: cos(2 x) sin(y) cos(z) + cos(x) cos(2 y) sin(z) + sin(x) cos(y) cos(2 z) = 0 @@ -182,7 +189,8 @@ def fischer_koch_s(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: def pmy(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: - """PMY. + """ + PMY. .. math:: 2 cos(x) cos(y) cos(z) + sin(2 x) sin(y) + sin(x) sin(2 z) + sin(2 y) sin(z) = 0 @@ -210,7 +218,8 @@ def pmy(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: def honeycomb(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: - """Honneycomb. + """ + Honneycomb. .. math:: sin(x) cos(y) + sin(y) + cos(z) = 0 @@ -233,7 +242,8 @@ def honeycomb(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: def lidinoid(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: - """Lidinoid. + """ + Lidinoid. .. math:: 0.5 (sin(2 x) cos(y) sin(z) + @@ -271,7 +281,8 @@ def lidinoid(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: def split_p(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: - """Split P. + """ + Split P. .. math:: 1.1 (sin(2 x) cos(y) sin(z) + @@ -310,7 +321,8 @@ def split_p(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: def honeycomb_gyroid(x: float, y: float, _: float) -> float: - """Honeycomb Gyroid. + """ + Honeycomb Gyroid. .. math:: sin(x) cos(y) + sin(y) + cos(x) = 0 @@ -333,7 +345,8 @@ def honeycomb_gyroid(x: float, y: float, _: float) -> float: def honeycomb_schwarz_p(x: float, y: float, _: float) -> float: - """Honeycomb Schwarz P. + """ + Honeycomb Schwarz P. .. math:: cos(x) + cos(y) = 0 @@ -356,7 +369,8 @@ def honeycomb_schwarz_p(x: float, y: float, _: float) -> float: def honeycomb_schwarz_d(x: float, y: float, _: float) -> float: - """Honneycomb Schwarz D. + """ + Honneycomb Schwarz D. .. math:: cos(x) cos(y) + sin(x) sin(y) + sin(x) cos(y) + cos(x) sin(y) = 0 @@ -379,7 +393,8 @@ def honeycomb_schwarz_d(x: float, y: float, _: float) -> float: def honeycomb_schoen_iwp(x: float, y: float, _: float) -> float: - """Honneycomb Schoen IWP. + """ + Honneycomb Schoen IWP. .. math:: cos(x) cos(y) + cos(y) + cos(x) = 0 @@ -402,7 +417,8 @@ def honeycomb_schoen_iwp(x: float, y: float, _: float) -> float: def honeycomb_lidinoid(x: float, y: float, _: float) -> float: - """Honeycomb Lidinoid. + """ + Honeycomb Lidinoid. .. math:: 1.1 (sin(2 x) cos(y) + sin(2 y) sin(x) + cos(x) sin(y)) diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 0a5b0cfd..9f98c1fc 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -1,4 +1,5 @@ -"""TPMS. +""" +TPMS. ============================================= TPMS (:mod:`microgen.shape.tpms`) @@ -15,7 +16,8 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Callable, Literal, Sequence +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Literal import cadquery as cq import numpy as np @@ -45,7 +47,8 @@ def _wedge_sdf_2d( y: npt.NDArray[np.float64], half_angle: float, ) -> npt.NDArray[np.float64]: - """SDF of a 2D wedge centred on +X with half-aperture ``half_angle``. + """ + SDF of a 2D wedge centred on +X with half-aperture ``half_angle``. Negative inside, positive outside, zero on the bounding rays. Z-independent — used as a clipping primitive for cylindrical / spherical @@ -60,7 +63,8 @@ def _double_cone_sdf( z: npt.NDArray[np.float64], half_polar: float, ) -> npt.NDArray[np.float64]: - """SDF of a double cone around the Z axis with half-aperture ``half_polar``. + """ + SDF of a double cone around the Z axis with half-aperture ``half_polar``. Symmetric about the XY plane; points inside iff their angle from ±Z is less than ``half_polar``. Used to clip spherical partial-θ coverage. @@ -74,7 +78,8 @@ def _interp_along_curve( curve_s: npt.NDArray[np.float64], values: npt.NDArray[np.float64], ) -> npt.NDArray[np.float64]: - """Per-axis ``np.interp`` of an ``(M, 3)`` curve attribute at ``query_s``. + """ + Per-axis ``np.interp`` of an ``(M, 3)`` curve attribute at ``query_s``. Returns an ``(N, 3)`` array. Wraps the three-axis loop so callers don't repeat the pattern. @@ -85,7 +90,8 @@ def _interp_along_curve( class Tpms(Shape): - """Triply Periodical Minimal Surfaces. + """ + Triply Periodical Minimal Surfaces. Class to generate Triply Periodical Minimal Surfaces (TPMS) geometry from a given mathematical function, with given offset @@ -109,7 +115,7 @@ class Tpms(Shape): - :class:`~microgen.shape.surface_functions.honeycomb_lidinoid` """ - def __init__( # noqa: PLR0913 + def __init__( self: Tpms, surface_function: Field, offset: float | OffsetGrading | Field | None = None, @@ -120,7 +126,8 @@ def __init__( # noqa: PLR0913 density: float | None = None, **kwargs: Vector3DType, ) -> None: - r"""Class used to generate TPMS geometries (sheet or skeletals parts). + r""" + Class used to generate TPMS geometries (sheet or skeletals parts). TPMS are created by default in a cube. The geometry of the cube can be modified using 'cell_size' parameter. @@ -213,7 +220,8 @@ def offset_from_density( density: float, resolution: int = 20, ) -> float: - """Return the offset corresponding to the required density. + """ + Return the offset corresponding to the required density. :param surface_function: tpms function :param part_type: type of the part (sheet, lower skeletal or upper skeletal) @@ -222,13 +230,14 @@ def offset_from_density( :return: corresponding offset value """ - return Tpms( # noqa: SLF001 + return Tpms( surface_function=surface_function, density=density, )._compute_offset_to_fit_density(part_type=part_type, resolution=resolution) def _density_envelope_volume(self: Tpms) -> float: - """Return the *envelope* volume that ``density`` is measured against. + """ + Return the *envelope* volume that ``density`` is measured against. For a plain :class:`Tpms` this is the cartesian cell volume. Subclasses with a non-cartesian envelope (:class:`Infill`, :class:`Conformal`) @@ -243,7 +252,8 @@ def _compute_offset_to_fit_density( part_type: Literal["sheet", "lower skeletal", "upper skeletal"], resolution: int | None = None, ) -> float: - """Compute the offset that yields the requested density. + """ + Compute the offset that yields the requested density. Searches with the same F-rep ``generate_vtk`` pipeline the user invokes, so the offset returned actually reproduces the requested @@ -432,6 +442,7 @@ def _compute_tpms_field(self: Tpms) -> None: for repeat_cell_axis, cell_size_axis in zip( self.repeat_cell, self.cell_size, + strict=False, ) ] @@ -455,7 +466,7 @@ def _finalize_frep( bounds: tuple[float, float, float, float, float, float], ) -> None: """Normalize a raw field to SDF and set ``_func`` / ``_bounds``.""" - from .implicit_ops import from_field, normalize_to_sdf # noqa: PLC0415 + from .implicit_ops import from_field, normalize_to_sdf self._raw_field_func = raw_field sdf_shape = normalize_to_sdf(from_field(raw_field)) @@ -490,15 +501,16 @@ def raw_field(self: Tpms) -> Field: return self._raw_field_func def as_sheet(self: Tpms, thickness: float | None = None) -> Shape: - """Return an F-rep Shape representing a TPMS sheet of given thickness. + """ + Return an F-rep Shape representing a TPMS sheet of given thickness. Uses the SDF-normalized field, so *thickness* is in physical units. If *thickness* is ``None``, uses ``self.offset`` (which may be a scalar, an array sampled on ``self.grid``, or a callable in which case the callable form is used directly). """ - from .implicit_ops import shell # noqa: PLC0415 - from .shape import Shape # noqa: PLC0415 + from .implicit_ops import shell + from .shape import Shape if thickness is not None: t: float | npt.NDArray | Field = float(thickness) @@ -509,7 +521,8 @@ def as_sheet(self: Tpms, thickness: float | None = None) -> Shape: return shell(Shape(func=self._func, bounds=self._bounds), t) def _half_offset_field(self: Tpms) -> Field | float: - """Return half the offset as a callable (variable) or scalar (constant). + """ + Return half the offset as a callable (variable) or scalar (constant). Variable offset stored as a callable can be re-evaluated on the marching-cubes grid; an array offset (sampled on ``self.grid``) @@ -520,7 +533,7 @@ def _half_offset_field(self: Tpms) -> Field | float: if self._offset_func is not None: f = self._offset_func - def _half(x, y, z, _f=f): # noqa: ANN001 + def _half(x, y, z, _f=f): return 0.5 * _f(x, y, z) return _half @@ -530,84 +543,87 @@ def _half(x, y, z, _f=f): # noqa: ANN001 return 0.0 def as_upper_skeletal(self: Tpms) -> Shape: - """F-rep Shape for the *upper* skeletal: ``{p : f(p) > offset/2}``. + """ + F-rep Shape for the *upper* skeletal: ``{p : f(p) > offset/2}``. Volume scales with the chosen offset (smaller offset ⇒ larger skeletal), matching the historical CadQuery behaviour and the VTK grid-clip path. """ - from .implicit_ops import from_field # noqa: PLC0415 + from .implicit_ops import from_field f = self._func h = self._half_offset_field() if callable(h): - def _upper(x, y, z, _f=f, _h=h): # noqa: ANN001 + def _upper(x, y, z, _f=f, _h=h): return -_f(x, y, z) + _h(x, y, z) else: - def _upper(x, y, z, _f=f, _h=h): # noqa: ANN001 + def _upper(x, y, z, _f=f, _h=h): return -_f(x, y, z) + _h return from_field(func=_upper, bounds=self._bounds) def as_lower_skeletal(self: Tpms) -> Shape: """F-rep Shape for the *lower* skeletal: ``{p : f(p) < -offset/2}``.""" - from .implicit_ops import from_field # noqa: PLC0415 + from .implicit_ops import from_field f = self._func h = self._half_offset_field() if callable(h): - def _lower(x, y, z, _f=f, _h=h): # noqa: ANN001 + def _lower(x, y, z, _f=f, _h=h): return _f(x, y, z) + _h(x, y, z) else: - def _lower(x, y, z, _f=f, _h=h): # noqa: ANN001 + def _lower(x, y, z, _f=f, _h=h): return _f(x, y, z) + _h return from_field(func=_lower, bounds=self._bounds) def as_surface(self: Tpms) -> Shape: - """Return F-rep Shape for the (open) zero-isosurface, no thickness. + """ + Return F-rep Shape for the (open) zero-isosurface, no thickness. Same field as :meth:`as_lower_skeletal`; meant for ``type_part="surface"``. Marching cubes will produce an open shell — there is no enclosed volume. """ - from .implicit_ops import from_field # noqa: PLC0415 + from .implicit_ops import from_field return from_field(func=self._func, bounds=self._bounds) def _cell_box(self: Tpms) -> Shape: """SDF Shape of this TPMS' cell (cell_size × repeat_cell, centered origin).""" - from .implicit_ops import box # noqa: PLC0415 + from .implicit_ops import box dims = tuple(float(d) for d in (self.cell_size * self.repeat_cell)) return box(dims=dims, center=(0.0, 0.0, 0.0)) def _clipped_sheet(self: Tpms) -> Shape: - """Sheet F-rep clipped to the cell box. + """ + Sheet F-rep clipped to the cell box. When the offset approaches its upper limit the sheet field is uniformly negative inside the cell and marching cubes finds no boundary — clipping by the cell box makes the box face the closed boundary, yielding a full-cell mesh as expected. """ - from .implicit_ops import intersection # noqa: PLC0415 + from .implicit_ops import intersection return intersection(self.as_sheet(), self._cell_box()) def _clipped_upper_skeletal(self: Tpms) -> Shape: """Upper skeletal F-rep clipped to the cell box (closed under marching cubes).""" - from .implicit_ops import intersection # noqa: PLC0415 + from .implicit_ops import intersection return intersection(self.as_upper_skeletal(), self._cell_box()) def _clipped_lower_skeletal(self: Tpms) -> Shape: """Lower skeletal F-rep clipped to the cell box (closed under marching cubes).""" - from .implicit_ops import intersection # noqa: PLC0415 + from .implicit_ops import intersection return intersection(self.as_lower_skeletal(), self._cell_box()) @@ -653,7 +669,8 @@ def offset( self.offset_updated = True def _mesh_to_shell(self: Tpms, mesh: pv.PolyData) -> cq.Shape: - """Convert a triangulated PyVista mesh to a CadQuery ``Shape``. + """ + Convert a triangulated PyVista mesh to a CadQuery ``Shape``. Builds one ``cq.Face`` per triangle, then *sews* them with OCCT's ``BRepBuilderAPI_Sewing`` so adjacent triangles share edges — without @@ -662,7 +679,7 @@ def _mesh_to_shell(self: Tpms, mesh: pv.PolyData) -> cq.Shape: ``TopoDS_Shell`` if sewing succeeded into one shell, otherwise the compound that sewing produced). """ - from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing # noqa: PLC0415 + from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing if not mesh.is_all_triangles: mesh.triangulate(inplace=True) @@ -676,7 +693,7 @@ def _mesh_to_shell(self: Tpms, mesh: pv.PolyData) -> cq.Shape: cq.Vector(*mesh.points[start]), cq.Vector(*mesh.points[end]), ) - for start, end in zip(tri[:], tri[1:]) + for start, end in zip(tri[:], tri[1:], strict=False) ] wire = cq.Wire.assembleEdges(lines) faces.append(cq.Face.makeFromWires(wire)) @@ -729,7 +746,8 @@ def _check_offset( _VALID_PARTS = ("sheet", "lower skeletal", "upper skeletal", "surface") def _frep_part(self: Tpms, type_part: TpmsPartType) -> Shape: - """Pick the F-rep :class:`Shape` for *type_part*. + """ + Pick the F-rep :class:`Shape` for *type_part*. Skeletals are intersected with the cell box so marching cubes produces a *closed* shell (the unclipped skeletal field is @@ -748,7 +766,8 @@ def _frep_part(self: Tpms, type_part: TpmsPartType) -> Shape: raise ValueError(err_msg) def _isotropic_resolution(self: Tpms) -> int: - """Map ``self.resolution`` (per-axis) to an isotropic Shape resolution. + """ + Map ``self.resolution`` (per-axis) to an isotropic Shape resolution. ``Shape.generate_vtk`` takes a single resolution; we use the geometric mean of the per-axis cell counts so total grid points stay proportional. @@ -756,7 +775,8 @@ def _isotropic_resolution(self: Tpms) -> int: return max(int(self.resolution * np.cbrt(np.prod(self.repeat_cell))), 10) def _envelope_mesh_at_full_density(self: Tpms) -> pv.PolyData: - """Return the cell-envelope mesh used by the ``density=1.0`` shortcut. + """ + Return the cell-envelope mesh used by the ``density=1.0`` shortcut. Plain :class:`Tpms` has an axis-aligned box envelope, returned exactly via :class:`pyvista.Box`. Subclasses with non-cubic envelopes @@ -772,7 +792,8 @@ def _envelope_mesh_at_full_density(self: Tpms) -> pv.PolyData: ) def _envelope_mesh_via_cell_box(self: Tpms) -> pv.PolyData: - """Marching-cubes mesh of the (potentially non-cubic) ``_cell_box``. + """ + Marching-cubes mesh of the (potentially non-cubic) ``_cell_box``. Used by subclasses that override :meth:`_envelope_mesh_at_full_density` to delegate to the F-rep envelope SDF. @@ -790,7 +811,8 @@ def generate( algo_resolution: int | None = None, **_: KwargsGenerateType, ) -> cq.Shape: - """Generate the OCCT/CadQuery shape of the requested TPMS part. + """ + Generate the OCCT/CadQuery shape of the requested TPMS part. Pure F-rep pipeline: pick the SDF Shape via :meth:`_frep_part`, run marching cubes through :meth:`Shape.generate_vtk`, optionally smooth, @@ -853,15 +875,16 @@ def generate( @staticmethod def _try_make_solid(shape: cq.Shape) -> cq.Shape: - """Best-effort upgrade of a sewn shell into a closed Solid. + """ + Best-effort upgrade of a sewn shell into a closed Solid. Returns the original shape unchanged if the sewn result is a Compound (multiple disjoint shells, can't be a single solid) or if OCCT refuses the conversion. """ - from OCP.TopAbs import TopAbs_SHELL # noqa: PLC0415 - from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 - from OCP.TopoDS import TopoDS # noqa: PLC0415 + from OCP.TopAbs import TopAbs_SHELL + from OCP.TopExp import TopExp_Explorer + from OCP.TopoDS import TopoDS wrapped = shape.wrapped # Already a Shell? Try to make a Solid directly. @@ -896,7 +919,8 @@ def generate_vtk( algo_resolution: int | None = None, **_: KwargsGenerateType, ) -> pv.PolyData: - """Generate the PyVista mesh of the requested TPMS part. + """ + Generate the PyVista mesh of the requested TPMS part. Same F-rep pipeline as :meth:`generate` (skeletals are clipped to the cell box), so the two outputs share the exact same triangulation and @@ -997,7 +1021,7 @@ def generateVtk( # noqa: N802 algo_resolution: int | None = None, **_: KwargsGenerateType, ) -> pv.PolyData: - """Deprecated. Use :meth:`generate_vtk` instead.""" # noqa: D401 + """Deprecated. Use :meth:`generate_vtk` instead.""" return self.generate_vtk( type_part=type_part, algo_resolution=algo_resolution, @@ -1016,7 +1040,7 @@ class CylindricalTpms(Tpms): _envelope_mesh_at_full_density = Tpms._envelope_mesh_via_cell_box - def __init__( # noqa: PLR0913 + def __init__( self: CylindricalTpms, radius: float, surface_function: Field, @@ -1029,7 +1053,8 @@ def __init__( # noqa: PLR0913 resolution: int = 20, density: float | None = None, ) -> None: - r"""Cylindrical TPMS geometry. + r""" + Cylindrical TPMS geometry. Directions of cell_size and repeat_cell must be taken as the cylindrical \ coordinate system $\\left(\\rho, \\theta, z\\right)$. @@ -1087,7 +1112,8 @@ def _create_grid( y: npt.NDArray[np.float64], z: npt.NDArray[np.float64], ) -> pv.StructuredGrid: - """Return the structured cylindrical grid of the TPMS. + """ + Return the structured cylindrical grid of the TPMS. ``y`` carries arc-length along the equator, so the conversion to an angle is ``theta = y / radius``. The earlier ``y * unit_theta`` form @@ -1145,7 +1171,8 @@ def _raw_field( ) def _cell_box(self: CylindricalTpms) -> Shape: - """Cylindrical-shell SDF in Cartesian space (replaces the parent's + """ + Cylindrical-shell SDF in Cartesian space (replaces the parent's axis-aligned-box SDF, which was geometrically wrong for this class). Without this override the F-rep marching cubes path @@ -1164,7 +1191,7 @@ def _cell_box(self: CylindricalTpms) -> Shape: handled by intersecting the ring with an angular wedge centred on the +X axis. """ - from .implicit_ops import from_field, intersection # noqa: PLC0415 + from .implicit_ops import from_field, intersection r_inner, r_outer, half_z = self._shell_extents() bounds: BoundsType = ( @@ -1204,7 +1231,8 @@ def _shell_sdf( return shell def _isotropic_resolution(self: CylindricalTpms) -> int: - """Per-axis resolution count for the F-rep marching-cubes grid. + """ + Per-axis resolution count for the F-rep marching-cubes grid. Override the parent's geometric-mean of ``repeat_cell`` (which mixes the angular axis with linear ones). Use only the radial (``[0]``) @@ -1230,7 +1258,7 @@ class SphericalTpms(Tpms): _envelope_mesh_at_full_density = Tpms._envelope_mesh_via_cell_box - def __init__( # noqa: PLR0913 + def __init__( self: SphericalTpms, radius: float, surface_function: Field, @@ -1243,7 +1271,8 @@ def __init__( # noqa: PLR0913 resolution: int = 20, density: float | None = None, ) -> None: - r"""Spherical TPMS geometry. + r""" + Spherical TPMS geometry. Directions of cell_size and repeat_cell must be taken as the spherical \ coordinate system $\\left(r, \\theta, \\phi\\right)$. @@ -1307,7 +1336,8 @@ def _create_grid( y: npt.NDArray[np.float64], z: npt.NDArray[np.float64], ) -> pv.StructuredGrid: - """Return the structured spherical grid of the TPMS. + """ + Return the structured spherical grid of the TPMS. ``y`` and ``z`` carry equatorial arc length, so the conversion to angles is ``theta = y / radius`` and ``phi = z / radius``. The @@ -1372,7 +1402,8 @@ def _shell_radii(self: SphericalTpms) -> tuple[float, float]: return max(sph_r - delta_r, 0.0), sph_r + delta_r def _cell_box(self: SphericalTpms) -> Shape: - """Spherical-shell SDF in Cartesian space. + """ + Spherical-shell SDF in Cartesian space. Replaces the parent's axis-aligned-box clip — same fix as :meth:`CylindricalTpms._cell_box`, see that docstring for the @@ -1382,7 +1413,7 @@ def _cell_box(self: SphericalTpms) -> Shape: in xy-plane) is supported when ``repeat_cell[1]`` / ``repeat_cell[2]`` are smaller than the auto-fill values. """ - from .implicit_ops import from_field, intersection # noqa: PLC0415 + from .implicit_ops import from_field, intersection r_inner, r_outer = self._shell_radii() bounds: BoundsType = ( @@ -1434,7 +1465,8 @@ def _shell_sdf( return shape def _isotropic_resolution(self: SphericalTpms) -> int: - """Use only the radial physical extent (the angular axes are + """ + Use only the radial physical extent (the angular axes are parametric and would mislead a geometric mean across them). """ radial_extent = float(self.cell_size[0]) * float(self.repeat_cell[0]) @@ -1445,7 +1477,8 @@ def _isotropic_resolution(self: SphericalTpms) -> int: class Sweep(Tpms): - """TPMS along an arbitrary 3D curve — generalisation of CylindricalTpms. + """ + TPMS along an arbitrary 3D curve — generalisation of CylindricalTpms. The TPMS is generated in a tube of radius ``radial_max`` around an arbitrary curve, in local coordinates ``(s, r, θ)`` where: @@ -1477,7 +1510,7 @@ class Sweep(Tpms): _envelope_mesh_at_full_density = Tpms._envelope_mesh_via_cell_box - def __init__( # noqa: PLR0913 + def __init__( self: Sweep, curve_points: npt.NDArray[np.float64] | Callable[[float], npt.NDArray[np.float64]], @@ -1494,7 +1527,8 @@ def __init__( # noqa: PLR0913 center: Vector3DType = (0, 0, 0), orientation: Vector3DType = (0, 0, 0), ) -> None: - r"""Build a TPMS swept along a curve. + r""" + Build a TPMS swept along a curve. :param curve_points: either an ``(M, 3)`` array of polyline samples or a callable ``t \in [0, 1] -> (3,)``. Callables are sampled @@ -1558,7 +1592,8 @@ def _build_curve_frames( self: Sweep, seed_normal: Sequence[float] | None, ) -> None: - """Pre-compute arc length, tangents, and parallel-transported normals. + """ + Pre-compute arc length, tangents, and parallel-transported normals. Stores on ``self``: ``_curve_s`` arc length per sample, shape (M,) @@ -1566,7 +1601,7 @@ def _build_curve_frames( ``_curve_N`` unit normal (parallel-transported), shape (M, 3) ``_curve_kdtree`` scipy cKDTree on the curve points """ - from scipy.spatial import cKDTree # noqa: PLC0415 + from scipy.spatial import cKDTree pts = self.curve m = pts.shape[0] @@ -1736,7 +1771,7 @@ def _raw_field( def _cell_box(self: Sweep) -> Shape: """Tube SDF: ``dist_to_curve(p) − radial_max``.""" - from .implicit_ops import from_field # noqa: PLC0415 + from .implicit_ops import from_field bounds: BoundsType = ( self._bounds @@ -1771,7 +1806,8 @@ def _create_grid( y: npt.NDArray[np.float64], z: npt.NDArray[np.float64], ) -> pv.StructuredGrid: - """Map a parametric (s, r, θ) grid to Cartesian via the curve frames. + """ + Map a parametric (s, r, θ) grid to Cartesian via the curve frames. ``x``, ``y``, ``z`` here are the parametric meshgrid coordinates from :meth:`Tpms._compute_tpms_field` (they live in (s, r, θ) @@ -1855,7 +1891,7 @@ def lower_skeletal(self: Infill) -> pv.PolyData: """Lower-skeletal part as a PolyData mesh.""" return self.generate_vtk(type_part="lower skeletal") - def __init__( # noqa: PLR0913 + def __init__( self: Infill, obj: pv.PolyData, surface_function: Field, @@ -1866,7 +1902,8 @@ def __init__( # noqa: PLR0913 resolution: int = 20, density: float | None = None, ) -> None: - r"""Initialize the Infill object. + r""" + Initialize the Infill object. :param obj: object in which the infill is generated. Normals must be oriented\ towards the outside of the object. Use the `flip_normals` method if\ @@ -1949,7 +1986,8 @@ def _create_grid( return grid def _density_envelope_volume(self: Infill) -> float: - """Density is measured against the input object's volume. + """ + Density is measured against the input object's volume. Uses the volume of the *original* input object rather than the re-oriented one stored on ``self.obj`` — see ``__init__``. @@ -1957,7 +1995,8 @@ def _density_envelope_volume(self: Infill) -> float: return self._obj_volume def _setup_frep_field(self: Infill) -> None: - """Cartesian gyroid field, but bounds set to the obj bbox. + """ + Cartesian gyroid field, but bounds set to the obj bbox. The plain :class:`Tpms` parent uses origin-centered bounds of size ``cell_size * repeat_cell``. For an Infill of an object that is *not* @@ -1986,14 +2025,15 @@ def _raw_field( self._finalize_frep(_raw_field, bounds) def _cell_box(self: Infill) -> Shape: - """Override the cell-box clip with the *object envelope* SDF. + """ + Override the cell-box clip with the *object envelope* SDF. Without this override the F-rep marching cubes path (used by :meth:`Tpms.generate_vtk` and :meth:`Tpms.generate`) would clip the TPMS to the cartesian bounding box rather than to the input object — producing volumes well above ``obj.volume`` and corrupting density. """ - from .implicit_ops import from_field # noqa: PLC0415 + from .implicit_ops import from_field bounds_arr = np.array(self.obj.bounds) bounds: BoundsType = tuple(bounds_arr.tolist()) @@ -2013,7 +2053,8 @@ def _envelope_sdf( class GradedInfill(Infill): - """Cartesian TPMS infill with offset graded by distance to the envelope. + """ + Cartesian TPMS infill with offset graded by distance to the envelope. Same as :class:`Infill` (TPMS in the world cartesian frame, clipped by the input object), except the *thickness* of the TPMS sheet varies with @@ -2029,7 +2070,7 @@ class GradedInfill(Infill): bunny-style envelopes with high-curvature features. """ - def __init__( # noqa: PLR0913 + def __init__( self: GradedInfill, obj: pv.PolyData, surface_function: Field, @@ -2042,7 +2083,8 @@ def __init__( # noqa: PLR0913 phase_shift: Sequence[float] = (0.0, 0.0, 0.0), resolution: int = 20, ) -> None: - """Build a graded TPMS infill. + """ + Build a graded TPMS infill. :param obj: envelope mesh :param surface_function: TPMS function ``f(x,y,z)`` @@ -2086,7 +2128,8 @@ def _make_graded_offset_callable( transition: float, smoothness: float, ) -> Field: - """Return ``f(x, y, z) -> offset(p)`` graded by SDF. + """ + Return ``f(x, y, z) -> offset(p)`` graded by SDF. ``d_norm = clip(-d / depth_max, 0, 1)`` ∈ [0, 1] (0 at skin, 1 in deepest interior point). Offset is interpolated from diff --git a/microgen/shape/tpms_grading.py b/microgen/shape/tpms_grading.py index 91cced18..5a50566f 100644 --- a/microgen/shape/tpms_grading.py +++ b/microgen/shape/tpms_grading.py @@ -22,7 +22,8 @@ def compute_offset( self: OffsetGrading, grid: pv.UnstructuredGrid | pv.StructuredGrid, ) -> npt.NDArray[np.float64]: - """Compute the offset of the grid. + """ + Compute the offset of the grid. This method should compute the offset on each point of the grid and return \ it as a 1D array. @@ -54,7 +55,8 @@ def __init__( furthest_offset: float, boundary_weight: float = 1.0, ) -> None: - """Initialize the ImplicitDistance object. + """ + Initialize the ImplicitDistance object. Parameters ---------- diff --git a/pyproject.toml b/pyproject.toml index d2db71a6..d32acc5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,9 +85,47 @@ exclude = [ "microgen/rve.py", "microgen/single_mesh.py", "examples/*", + "gyroid_gd_bunny.py", ] # external = ["E131", "D102", "D105"] select = ["ALL"] -ignore = ["E501", "S101", "ANN101", "ANN102", "ANN003"] +ignore = [ + "E501", # line too long — ruff-format handles wrapping + "S101", # assert in tests is fine + "ANN101", # removed rule + "ANN102", # removed rule + "ANN001", # missing type hint on argument + "ANN002", # missing type hint on *args + "ANN003", # missing **kwargs annotation + "ANN202", # missing return-type on private function + "PLC0415", # late imports needed to avoid circular deps + "N806", # capitalised local variables (T, N, B for tangent/normal/binormal) + "PLR2004", # magic-value comparison — too noisy in scientific code + "PLR0913", # too many arguments — APIs are wide on purpose + "PLR0915", # too many statements + "LOG015", # root logger usage + "SLF001", # private member access between class siblings + "RUF001", # ambiguous unicode (we use Greek letters intentionally) + "RUF002", # same, in docstrings + "RUF003", # same, in comments + "RUF046", # int(...) on already-int — bookkeeping + "RUF007", # zip strict + "ARG002", # unused method argument (pyvista hook signatures) + "ARG005", # unused lambda argument (kwargs trampolines) + "TID252", # relative imports are the project convention + "FBT001", # boolean positional argument + "FBT002", # boolean default-value + "TC003", # typing-only imports — minor + "RET504", # unnecessary assignment before return + "PERF401", # list comprehension — readability tradeoff + "TRY003", # long messages outside exception class + "EM101", # raw string in exception + "EM102", # f-string in exception + "SIM108", # ternary instead of if/else + "UP038", # isinstance with X | Y — deprecated rule (perf regression) + # Docstring style — too noisy for scientific code + "D100", "D101", "D102", "D103", "D104", "D105", "D107", + "D203", "D205", "D211", "D212", "D213", "D400", "D401", "D415", +] [tool.ruff.format] docstring-code-format = true From 881484f9a442c97adccca039575559040d6f1880 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Fri, 24 Apr 2026 23:37:38 +0200 Subject: [PATCH 11/15] Add optional OCP CAD backend and CI split Introduce a new optional CAD backend (microgen.cad) that uses cadquery-ocp / OCP as a direct OCCT binding, and refactor boolean/transform operations to use a CadShape wrapper with deferred OCP imports. Make the CAD path optional via the [cad] extra and update docs (README), conda recipe and environment files to recommend ocp and relax VTK/pyvista pinning for the core (mesh + F-rep) install. Split GitHub Actions into a lightweight core-no-cad job (tests for mesh/implicit-field with VTK 9.4+) and a full cad job that installs the [cad] extras. Add a test for optional CAD import (tests/test_cad_optional.py) and remove jupyter-cadquery from example deps. These changes decouple heavy OCCT/CadQuery constraints from the default install, reducing dependency footprint while preserving a full CAD-enabled mode. --- .github/workflows/build-and-test.yml | 42 +- README.md | 26 +- conda.recipe/meta.yaml | 10 +- environment.yml | 9 +- examples/jupyter_notebooks/requirements.txt | 1 - microgen/cad.py | 817 ++++++++++++++++++ microgen/operations.py | 153 ++-- microgen/periodic.py | 383 ++++---- microgen/phase.py | 284 +++--- microgen/report.py | 3 +- microgen/rve.py | 36 +- microgen/shape/box.py | 13 +- microgen/shape/capsule.py | 44 +- microgen/shape/cylinder.py | 23 +- microgen/shape/ellipsoid.py | 19 +- microgen/shape/extruded_polygon.py | 23 +- microgen/shape/polyhedron.py | 37 +- microgen/shape/shape.py | 45 +- microgen/shape/sphere.py | 21 +- .../shape/strut_lattice/abstract_lattice.py | 12 +- microgen/shape/tpms.py | 92 +- pyproject.toml | 20 +- tests/shapes/test_lattice.py | 3 +- tests/test_cad_optional.py | 75 ++ tests/test_mesh.py | 9 +- tests/test_mesh_periodic.py | 22 +- 26 files changed, 1609 insertions(+), 613 deletions(-) create mode 100644 microgen/cad.py create mode 100644 tests/test_cad_optional.py diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 20d70465..bb94e66e 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -15,19 +15,16 @@ concurrency: cancel-in-progress: true jobs: - build-and-test: + # --- Core suite: mesh + implicit-field only. No OCP installed. --- + # Proves `import microgen` + `.generate_vtk()` + F-rep work on VTK 9.4+ + # with no CAD stack. + core-no-cad: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ - "ubuntu-latest", - "windows-latest", - # "macos-13", # x86_64 - "macos-latest", # arm64 - ] - python-version: ["3.10", "3.12"] - # Python 3.13 excluded: CadQuery does not support 3.13 (tested via conda in integration.yml) + os: ["ubuntu-latest", "macos-latest"] + python-version: ["3.10", "3.12", "3.13"] steps: - uses: actions/checkout@v4 @@ -41,4 +38,31 @@ jobs: - run: uv sync --extra dev - run: uv pip install . + # Pin to VTK 9.4 to prove it actually installs cleanly now that + # cadquery no longer constrains the dep tree. + - run: uv pip install 'vtk>=9.4' 'pyvista>=0.47' + - run: | + uv run pytest tests/test_cad_optional.py tests/shapes/test_implicit_ops.py tests/shapes/test_tpms_frep.py -q + + # --- CAD suite: full test run with OCP installed via the [cad] extra. --- + cad: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ["ubuntu-latest", "macos-latest"] + python-version: ["3.10", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v4 + with: + python-version: ${{ matrix.python-version }} + + - if: runner.os == 'Linux' + run: sudo apt-get install libglu1-mesa + + - run: uv sync --extra dev --extra cad + - run: uv pip install '.[cad]' - run: uv run pytest --numprocesses=auto diff --git a/README.md b/README.md index 7c32a156..4b3963ec 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ - **3D Voronoi tessellation**: Simulation of granular materials and polycrystalline metals. - **Meshing**: Regular and **periodic** meshing using Gmsh, **remeshing** using Mmg. -Generation of 3D objects is achievable through functions that utilize Open CASCADE (via Cadquery) or VTK (using PyVista). Neper offers tools for 3D tessellation, while Gmsh handles the generation of both regular and periodic meshes, with Mmg handling remeshing tasks. +Generation of 3D objects is achievable through functions that utilize Open CASCADE (via [cadquery-ocp](https://github.com/CadQuery/ocp-build-system), the direct OCCT Python binding) or VTK (using PyVista). Neper offers tools for 3D tessellation, while Gmsh handles the generation of both regular and periodic meshes, with Mmg handling remeshing tasks. + +The CAD path (`.generate()` on shapes, `Phase`, `fuse_shapes`, periodic split, lattice CAD) is **optional** — install with `pip install 'microgen[cad]'`. The default `pip install microgen` gives you mesh + implicit-field (F-rep) workflows only, which is sufficient for many applications and has a much lighter dependency footprint (no OCCT, no VTK version pin).

Gyroid @@ -30,18 +32,36 @@ Generation of 3D objects is achievable through functions that utilize Open CASCA ## Installation -With pip: +**Core (mesh + implicit-field / F-rep, no CAD kernel)** — lighter, works with VTK 9.4+: ```bash pip install microgen ``` +**With CAD capabilities** (OCCT via `cadquery-ocp`, enables `.generate()`, `Phase`, `fuse_shapes`, periodic split, lattice CAD): + +```bash +pip install 'microgen[cad]' +``` + With conda: ```bash -conda install conda-forge::microgen +conda install conda-forge::microgen # core +conda install conda-forge::microgen ocp # core + CAD ``` +What you can do in each mode: + +| | Core install | `[cad]` install | +| ------------------------------------- | :----------: | :-------------: | +| `Shape.generate_vtk()` (mesh output) | ✅ | ✅ | +| Implicit fields, TPMS F-rep, booleans | ✅ | ✅ | +| `Shape.generate()` (OCCT BREP) | ❌ | ✅ | +| `Phase`, `fuse_shapes`, `cut_*` | ❌ | ✅ | +| Periodic split, lattice CAD export | ❌ | ✅ | +| VTK 9.4+ | ✅ | ✅ | + ------------------------------------------------------------------------------------------------------- To modify the sources, clone this repository and install microgen: diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml index 26295ea4..a3080a1e 100644 --- a/conda.recipe/meta.yaml +++ b/conda.recipe/meta.yaml @@ -13,16 +13,16 @@ build: requirements: host: - - python >=3.10,<3.13 + - python >=3.10,<3.14 - pip run: - - python >=3.10,<3.13 + - python >=3.10,<3.14 - numpy - - vtk - - pyvista <0.47 + - vtk # unpinned — VTK 9.4+ unblocked (cadquery removed) + - pyvista # unpinned — same reason - python-gmsh - meshio - - cadquery + - ocp # OCCT Python binding — enables the CAD path - scipy - pip: - autograd diff --git a/environment.yml b/environment.yml index 2c1171cc..8fec1c0e 100755 --- a/environment.yml +++ b/environment.yml @@ -3,14 +3,19 @@ channels: - conda-forge - set3mah +# Core deps only. cadquery is REMOVED from microgen; the CAD path goes +# through OCCT directly via `ocp` (conda-forge name for cadquery-ocp). +# Install `ocp` if you need Shape.generate() / Phase / fuse_shapes / +# periodic / lattice CAD. Without it, mesh + implicit-field workflows +# still work. dependencies: - numpy - - pyvista <0.47 # pyvista 0.47 requires vtkRenderingMatplotlib, missing from conda VTK on Windows + - pyvista # unpinned: VTK 9.4+ is unblocked (cadquery no longer in the tree) - matplotlib - scipy - python-gmsh - meshio - - cadquery + - ocp # OCP (OCCT Python binding) — enables the CAD path - pip: - autograd - mmg diff --git a/examples/jupyter_notebooks/requirements.txt b/examples/jupyter_notebooks/requirements.txt index 29bb96c3..e091237a 100644 --- a/examples/jupyter_notebooks/requirements.txt +++ b/examples/jupyter_notebooks/requirements.txt @@ -1,4 +1,3 @@ jupyterlab -jupyter-cadquery sidecar jupyterview diff --git a/microgen/cad.py b/microgen/cad.py new file mode 100644 index 00000000..4c8eea6b --- /dev/null +++ b/microgen/cad.py @@ -0,0 +1,817 @@ +"""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``) 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 typing import TYPE_CHECKING, Iterable, Sequence + +import numpy as np +import numpy.typing as npt + +if TYPE_CHECKING: + from pathlib import Path + + from OCP.TopoDS import TopoDS_Compound, TopoDS_Shape + + +_INSTALL_HINT = ( + "microgen's CAD backend requires cadquery-ocp. " + "Install it with: pip install 'microgen[cad]' " + "or: pip install cadquery-ocp" +) + + +class _Centre(tuple): + """Tuple-like 3D point exposing ``.x``, ``.y``, ``.z`` and ``.toTuple()``. + + Returned by :meth:`CadShape.Center`. Mimics just enough of + ``cadquery.Vector`` to drop-in where existing code uses + ``shape.Center().toTuple()`` 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 toTuple(self) -> tuple[float, float, float]: # noqa: N802 + """CadQuery-compatible alias returning ``(x, y, z)``.""" + return (self[0], self[1], self[2]) + + +class _BBox: + """Axis-aligned bounding box exposing CadQuery-style ``xmin``/``xmax``/… + + Returned by :meth:`CadShape.BoundingBox`. Also indexable as a 6-tuple + ``(xmin, ymin, zmin, xmax, ymax, zmax)`` matching OCCT's ``Bnd_Box.Get``. + """ + + __slots__ = ("xmin", "ymin", "zmin", "xmax", "ymax", "zmax") + + 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 DiagonalLength(self) -> float: # noqa: N802 + """CadQuery-compatible diagonal length.""" + 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, PLC0415 + except ImportError as err: + raise ImportError(_INSTALL_HINT) from err + + +# --------------------------------------------------------------------------- +# 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. + """ + + __slots__ = ("wrapped",) + + def __init__(self, shape: TopoDS_Shape) -> None: + """Wrap an OCCT ``TopoDS_Shape``.""" + self.wrapped = shape + + # -- 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(BRepAlgoAPI_Fuse(self.wrapped, other.wrapped).Shape()) + + def cut(self, other: CadShape) -> CadShape: + """Boolean difference: ``self \\ other``.""" + from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut # noqa: PLC0415 + + return CadShape(BRepAlgoAPI_Cut(self.wrapped, other.wrapped).Shape()) + + def intersect(self, other: CadShape) -> CadShape: + """Boolean intersection: ``self ∩ other``.""" + from OCP.BRepAlgoAPI import BRepAlgoAPI_Common # noqa: PLC0415 + + return CadShape(BRepAlgoAPI_Common(self.wrapped, other.wrapped).Shape()) + + # -- 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 + from OCP.TopoDS import TopoDS # noqa: PLC0415 + + out: list[CadShape] = [] + exp = TopExp_Explorer(self.wrapped, TopAbs_SOLID) + while exp.More(): + out.append(CadShape(TopoDS.Solid_s(exp.Current()))) + exp.Next() + return out + + # CadQuery compatibility alias — some legacy callers use the camel-cased form. + def Solids(self) -> list[CadShape]: # noqa: N802 + """CadQuery-compatible alias for :meth:`solids`.""" + return self.solids() + + def Volume(self) -> float: # noqa: N802 + """Return the volume of the shape (uses OCCT ``BRepGProp``).""" + from OCP.BRepGProp import BRepGProp # noqa: PLC0415 + from OCP.GProp import GProp_GProps # noqa: PLC0415 + + props = GProp_GProps() + BRepGProp.VolumeProperties_s(self.wrapped, props) + return float(props.Mass()) + + def Center(self) -> _Centre: # noqa: N802 + """Return the volumetric center of mass. + + The result is a :class:`_Centre` — exposes ``.x``, ``.y``, ``.z``, + ``.toTuple()`` (CadQuery compatibility), 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 BoundingBox(self) -> _BBox: # noqa: N802 + """Return the axis-aligned bounding box. + + The result exposes CadQuery-compatible ``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), + # matching CadQuery's BoundingBox() behaviour. + 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) + writer.Write(self.wrapped, str(path)) + + def export_step(self, path: str | Path) -> None: + """Export to STEP (AP214).""" + from OCP.IFSelect import IFSelect_RetDone # noqa: PLC0415 + from OCP.STEPControl import STEPControl_AsIs, STEPControl_Writer # noqa: PLC0415 + + 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) + + +# --------------------------------------------------------------------------- +# 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 # 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: # noqa: BLE001 + 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 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: # noqa: BLE001 + 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) + + +# --------------------------------------------------------------------------- +# 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 # 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() + 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 # 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 + from OCP.TopoDS import TopoDS # 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:]): + edge = BRepBuilderAPI_MakeEdge(points[i1], points[i2]).Edge() + wire_builder.Add(edge) + face = BRepBuilderAPI_MakeFace(wire_builder.Wire()).Face() + sewing.Add(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.Shell_s(exp.Current()) + + solid = BRepBuilderAPI_MakeSolid(shell).Solid() + fixer = ShapeFix_Solid(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 = BRepBuilderAPI_MakeEdge(pts[i], pts[i + 1]).Edge() + wire_builder.Add(edge) + face = BRepBuilderAPI_MakeFace(wire_builder.Wire()).Face() + extruded = BRepPrimAPI_MakePrism(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 # 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) + + +# --------------------------------------------------------------------------- +# 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 # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + from OCP.TopoDS import TopoDS # noqa: PLC0415 + + out: list[Any] = [] + exp = TopExp_Explorer(shape.wrapped, TopAbs_SOLID) + while exp.More(): + out.append(TopoDS.Solid_s(exp.Current())) + exp.Next() + return out + + +def split_shape(shape: CadShape, tool: CadShape) -> 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. + """ + 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() + tools.Append(tool.wrapped) + + splitter = BRepAlgoAPI_Splitter() + splitter.SetArguments(args) + splitter.SetTools(tools) + 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) via ``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 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 # 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/microgen/operations.py b/microgen/operations.py index 5c668360..e131c74a 100644 --- a/microgen/operations.py +++ b/microgen/operations.py @@ -1,41 +1,44 @@ -"""Boolean operations.""" +"""Boolean operations. + +All OCP imports are deferred so the module loads cleanly in a mesh-only +install. Functions that use OCCT raise a clear ``ImportError`` (via +:func:`microgen.cad.require_cad`) if the ``[cad]`` extra isn't installed. +""" from __future__ import annotations import itertools import warnings -from typing import TYPE_CHECKING, Sequence, TypeVar, Union +from typing import TYPE_CHECKING, Any, Sequence -import cadquery as cq import numpy as np import numpy.typing as npt import pyvista as pv -from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse -from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain from scipy.spatial.transform import Rotation -from .phase import Phase +from .cad import CadShape, require_cad if TYPE_CHECKING: import OCP + from .phase import Phase from .rve import Rve -T = TypeVar("T", Union[cq.Shape, cq.Workplane], pv.PolyData) - def rotate( - obj: T, + obj: Any, center: npt.NDArray[np.float64] | Sequence[float], rotation: Rotation, -) -> T: +) -> Any: """Rotate object according to given rotation. + Supports :class:`microgen.cad.CadShape` and :class:`pyvista.PolyData`. + :param obj: Object to rotate :param center: numpy array (x, y, z) :param rotation: scipy Rotation object - :return: Rotated object + :return: Rotated object (same type as input) :raises ValueError: if object type is not supported @@ -46,13 +49,12 @@ def rotate( return obj axis = rotvec / angle - if isinstance(obj, cq.Shape) or isinstance(obj, cq.Workplane): - center = cq.Vector(*center) - return obj.rotate(center, center + cq.Vector(*axis), angle) + if isinstance(obj, CadShape): + return obj.rotate(center, axis, float(angle)) if isinstance(obj, pv.PolyData): return obj.rotate_vector(axis, angle, center) - err_msg = "Object type not supported." + err_msg = f"rotate(): object type {type(obj).__name__} not supported." raise ValueError(err_msg) @@ -82,31 +84,25 @@ def _get_rotation_axes( def rotate_euler( - obj: cq.Shape | cq.Workplane, + obj: Any, center: npt.NDArray[np.float64] | Sequence[float], angles_or_rotation: Sequence[float] | Rotation, -) -> cq.Shape | cq.Workplane: +) -> Any: """Rotate object according to ZXZ Euler angle convention. + Accepts :class:`~microgen.cad.CadShape` or :class:`pyvista.PolyData`. + :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 scipy Rotation object :return: Rotated object """ - center_vector = cq.Vector(*center) - 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(center_vector, center_vector + cq.Vector(*axis), angle) + return rotate(obj, center, rotation) def rotate_pv_euler( @@ -141,54 +137,52 @@ def rotate_pv_euler( ) -def rescale(shape: cq.Shape, scale: float | tuple[float, float, float]) -> cq.Shape: - """Rescale given object according to scale parameters [dim_x, dim_y, dim_z]. +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 - :param shape: Shape - :param scale: float or list of scale factor in each direction - - :return shape: rescaled Shape - """ return Phase.rescaleShape(shape, scale) -def _unify_solids(solids: list[OCP.TopoDS_Shape]) -> cq.Shape: - unify_edges = True - unify_faces = True - concat_bsplines = True +def _unify_solids(shape: Any) -> CadShape: + require_cad() + from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain # noqa: PLC0415 + upgrader = ShapeUpgrade_UnifySameDomain( - solids, - unify_edges, - unify_faces, - concat_bsplines, + shape, + True, # unify edges + True, # unify faces + True, # concat bsplines ) upgrader.Build() - shape: OCP.TopoDS_Shape = upgrader.Shape() - return cq.Shape(shape) + return CadShape(upgrader.Shape()) -def fuse_shapes(shapes: list[cq.Shape], *, retain_edges: bool) -> cq.Shape: - """Fuse all shapes in cqShapeList. +def fuse_shapes(shapes: list[CadShape], *, retain_edges: bool) -> CadShape: + """Fuse all shapes in the list. :param shapes: list of shapes to fuse :param retain_edges: retain intersecting edges - :return fused object + :return: fused shape """ + require_cad() + from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse # noqa: PLC0415 + fused = shapes[0].wrapped for i in range(1, len(shapes)): fused = BRepAlgoAPI_Fuse(fused, shapes[i].wrapped).Shape() if retain_edges: - return cq.Shape(fused) + return CadShape(fused) try: return _unify_solids(fused) - except Exception: # which exception? - return cq.Shape(fused) + except Exception: # noqa: BLE001 + return CadShape(fused) -def cut_phases_by_shape(phases: list[Phase], cut_obj: cq.Shape) -> list[Phase]: +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 @@ -196,16 +190,21 @@ def cut_phases_by_shape(phases: list[Phase], cut_obj: cq.Shape) -> list[Phase]: :return phase_cut: final result """ + require_cad() + from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut # noqa: PLC0415 + + from .phase import Phase # noqa: PLC0415 + phase_cut: list[Phase] = [] for phase in phases: - cut = cq.Shape(BRepAlgoAPI_Cut(phase.shape.wrapped, cut_obj.wrapped).Shape()) + cut = CadShape(BRepAlgoAPI_Cut(phase.shape.wrapped, cut_obj.wrapped).Shape()) if len(cut.Solids()) > 0: phase_cut.append(Phase(shape=cut)) return phase_cut -def cut_phase_by_shape_list(phase_to_cut: Phase, shapes: list[cq.Shape]) -> Phase: +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 @@ -213,31 +212,39 @@ def cut_phase_by_shape_list(phase_to_cut: Phase, shapes: list[cq.Shape]) -> Phas :return resultCut: cut phase """ + require_cad() + from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut # noqa: PLC0415 + + from .phase import Phase # noqa: PLC0415 + result = phase_to_cut.shape for shape in shapes: - result = cq.Shape(BRepAlgoAPI_Cut(result.wrapped, shape.wrapped).Shape()) + result = CadShape(BRepAlgoAPI_Cut(result.wrapped, shape.wrapped).Shape()) return Phase(shape=result) -def cut_shapes(shapes: list[cq.Shape], *, reverse_order: bool = True) -> list[cq.Shape]: +def cut_shapes(shapes: list[CadShape], *, reverse_order: bool = True) -> list[CadShape]: """Cut list of shapes in the given order (or reverse) and fuse them. - :param shapes: list of CQ Shape to cut + :param shapes: list of shapes to cut :param reverse_order: bool, order for cutting shapes, \ when True: the last shape of the list is not cut - :return cutted_shapes: list of CQ Shape + :return cutted_shapes: list of CadShape """ - cutted_shapes: list[cq.Shape] = [] + require_cad() + from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse # noqa: PLC0415 + + cutted_shapes: list[CadShape] = [] - shapes_inv = reversed(shapes) if reverse_order else shapes + shapes_inv = list(reversed(shapes)) if reverse_order else list(shapes) cut_shape = shapes_inv[0].copy() cutted_shapes.append(cut_shape) - for shape in shapes_inv[1::]: - copy = shape.copy() - cut = cq.Shape(BRepAlgoAPI_Cut(copy.wrapped, cut_shape.wrapped).Shape()) + for shape in shapes_inv[1:]: + copy_shape = shape.copy() + cut = CadShape(BRepAlgoAPI_Cut(copy_shape.wrapped, cut_shape.wrapped).Shape()) cutted_shapes.append(cut) fused = BRepAlgoAPI_Fuse(cut_shape.wrapped, shape.wrapped).Shape() @@ -257,6 +264,8 @@ def cut_phases(phases: list[Phase], *, reverse_order: bool = True) -> list[Phase :return list of phases """ + from .phase import Phase # noqa: PLC0415 + shapes = [phase.shape for phase in phases] cutted_shapes = cut_shapes(shapes, reverse_order=reverse_order) @@ -279,14 +288,16 @@ def raster_phase( :return: Phase or list of Phases """ - solids: list[cq.Solid] = phase.split_solids(rve, grid) + from .phase import Phase # noqa: PLC0415 + + solids = phase.split_solids(rve, grid) if phase_per_raster: return Phase.generate_phase_per_raster(solids, rve, grid) return Phase(solids=solids) -def repeat_shape(unit_geom: cq.Shape, rve: Rve, grid: tuple[int, int, int]) -> cq.Shape: +def repeat_shape(unit_geom: CadShape, rve: Rve, grid: tuple[int, int, int]) -> CadShape: """Repeat unit geometry in each direction according to the given grid. :param unit_geom: Shape to repeat @@ -295,6 +306,8 @@ def repeat_shape(unit_geom: cq.Shape, rve: Rve, grid: tuple[int, int, int]) -> c :return: cq shape of the repeated geometry """ + from .phase import Phase # noqa: PLC0415 + return Phase.repeat_shape(unit_geom, rve, grid) @@ -326,12 +339,12 @@ def repeat_polydata( # Deprecated functions def rotateEuler( # noqa: N802 - obj: cq.Shape | cq.Workplane, + obj: Any, center: np.ndarray | tuple[float, float, float], psi: float, theta: float, phi: float, -) -> cq.Shape | cq.Workplane: +) -> Any: """See rotate_euler. Deprecated in favor of rotate_euler. @@ -363,7 +376,7 @@ def rotatePvEuler( # noqa: N802 return rotate_pv_euler(obj, center, (psi, theta, phi)) -def fuseShapes(cqShapeList: list[cq.Shape], retain_edges: bool) -> cq.Shape: # noqa: N802, N803, FBT001 +def fuseShapes(cqShapeList: list[CadShape], retain_edges: bool) -> CadShape: # noqa: N802, N803, FBT001 """See fuse_shapes. Deprecated in favor of fuse_shapes. @@ -376,7 +389,7 @@ def fuseShapes(cqShapeList: list[cq.Shape], retain_edges: bool) -> cq.Shape: # return fuse_shapes(cqShapeList, retain_edges=retain_edges) -def cutPhasesByShape(phaseList: list[Phase], cut_obj: cq.Shape) -> list[Phase]: # noqa: N802, N803 +def cutPhasesByShape(phaseList: list[Phase], cut_obj: CadShape) -> list[Phase]: # noqa: N802, N803 """See cut_phases_by_shape. Deprecated in favor of cut_phases_by_shape. @@ -389,7 +402,7 @@ def cutPhasesByShape(phaseList: list[Phase], cut_obj: cq.Shape) -> list[Phase]: return cut_phases_by_shape(phaseList, cut_obj) -def cutPhaseByShapeList(phaseToCut: Phase, cqShapeList: list[cq.Shape]) -> Phase: # noqa: N802, N803 +def cutPhaseByShapeList(phaseToCut: Phase, cqShapeList: list[CadShape]) -> Phase: # noqa: N802, N803 """See cut_phase_by_shape_list. Deprecated in favor of cut_phase_by_shape_list. @@ -402,7 +415,7 @@ def cutPhaseByShapeList(phaseToCut: Phase, cqShapeList: list[cq.Shape]) -> Phase return cut_phase_by_shape_list(phaseToCut, cqShapeList) -def cutShapes(cqShapeList: list[cq.Shape], reverseOrder: bool = True) -> list[cq.Shape]: # noqa: N802, N803, FBT001, FBT002 +def cutShapes(cqShapeList: list[CadShape], reverseOrder: bool = True) -> list[CadShape]: # noqa: N802, N803, FBT001, FBT002 """See cut_shapes. Deprecated in favor of cut_shapes. @@ -446,7 +459,7 @@ def rasterPhase( # noqa: N802 return raster_phase(phase, rve, grid, phase_per_raster=phasePerRaster) -def repeatShape(unit_geom: cq.Shape, rve: Rve, grid: tuple[int, int, int]) -> cq.Shape: # noqa: N802 +def repeatShape(unit_geom: CadShape, rve: Rve, grid: tuple[int, int, int]) -> CadShape: # noqa: N802 """See repeat_shape. Deprecated in favor of repeat_shape. diff --git a/microgen/periodic.py b/microgen/periodic.py index 3d8cfdc6..f59fb756 100644 --- a/microgen/periodic.py +++ b/microgen/periodic.py @@ -1,25 +1,44 @@ -"""Periodic function to cut a shape periodically according to a RVE.""" +"""Periodic cut — rearrange a shape so it wraps around an RVE. + +Pure OCP implementation (``cadquery-ocp``); no ``cadquery`` dependency. +Requires the ``[cad]`` extra. +""" from __future__ import annotations import warnings -from typing import TYPE_CHECKING - -import cadquery as cq - -from .operations import fuseShapes +from typing import TYPE_CHECKING, Any + +import numpy as np + +from .cad import ( + CadShape, + enumerate_solids, + intersect_solids_with_box, + make_compound_from_solids, + make_plane_face, + select_solids_on_side, + split_shape, + translate_solid, +) +from .operations import fuse_shapes from .phase import Phase if TYPE_CHECKING: from .rve import Rve + +# -- Face labels and directions ------------------------------------------------ _NO_INTERSECTION = 0 _FACE = 1 _EDGE = 2 _CORNER = 3 _FACES = ["x-", "x+", "y-", "y+", "z-", "z+"] -_DIRECTION = { + +# Outward normal at each RVE face; used both to pick the base point of the +# cutting plane and to pick which side a partitioned solid belongs to. +_DIRECTION: dict[str, tuple[int, int, int]] = { "x-": (-1, 0, 0), "x+": (1, 0, 0), "y-": (0, -1, 0), @@ -27,44 +46,79 @@ "z-": (0, 0, -1), "z+": (0, 0, 1), } -FACE_DIR = {"x-": ">X", "x+": "Y", "y+": "Z", "z+": "X", - "y-": "Y", - "z-": "Z", + +# For face "x-" (minus-X face of the RVE), ">X" = solids on the +X side of +# the cutting plane — i.e. inside the RVE. " tuple[float, float, float]: + if face == "x-": + return (float(rve.min_point[0]), 0.0, 0.0) + if face == "x+": + return (float(rve.max_point[0]), 0.0, 0.0) + if face == "y-": + return (0.0, float(rve.min_point[1]), 0.0) + if face == "y+": + return (0.0, float(rve.max_point[1]), 0.0) + if face == "z-": + return (0.0, 0.0, float(rve.min_point[2])) + if face == "z+": + return (0.0, 0.0, float(rve.max_point[2])) + err_msg = f"Unknown face label {face!r}" + raise ValueError(err_msg) + + +def _translate(face: str, rve: Rve) -> tuple[float, float, float]: + """Translation applied to solids on the 'outside' half of *face* + to wrap them through the opposite RVE face. + """ + if face == "x-": + return (float(rve.dim[0]), 0.0, 0.0) + if face == "x+": + return (-float(rve.dim[0]), 0.0, 0.0) + if face == "y-": + return (0.0, float(rve.dim[1]), 0.0) + if face == "y+": + return (0.0, -float(rve.dim[1]), 0.0) + if face == "z-": + return (0.0, 0.0, float(rve.dim[2])) + if face == "z+": + return (0.0, 0.0, -float(rve.dim[2])) + err_msg = f"Unknown face label {face!r}" + raise ValueError(err_msg) + + +# -- Partitioning -------------------------------------------------------------- + + def _detect_intersected_faces( - wk_plane: cq.Workplane, + shape: CadShape, rve: Rve, -) -> tuple[list[str], dict[str, cq.Face], dict[str, cq.Workplane]]: - base_pnt = { - "x-": (rve.min_point[0], 0, 0), - "x+": (rve.max_point[0], 0, 0), - "y-": (0, rve.min_point[1], 0), - "y+": (0, rve.max_point[1], 0), - "z-": (0, 0, rve.min_point[2]), - "z+": (0, 0, rve.max_point[2]), - } +) -> tuple[list[str], dict[str, CadShape], dict[str, CadShape]]: + """Split *shape* by each of the six RVE faces; report which faces cut it.""" intersected_faces: list[str] = [] - rve_planes: dict[str, cq.Face] = {} - partitions: dict[str, cq.Workplane] = {} + rve_planes: dict[str, CadShape] = {} + partitions: dict[str, CadShape] = {} + for face in _FACES: - rve_planes[face] = cq.Face.makePlane( - basePnt=base_pnt[face], - dir=_DIRECTION[face], - ) - partitions[face] = wk_plane.split(cq.Workplane().add(rve_planes[face])) + plane = make_plane_face(_base_pnt(face, rve), _DIRECTION[face]) + rve_planes[face] = plane + partitions[face] = split_shape(shape, plane) - # detection of intersected faces - if len(partitions[face].solids().all()) > 1: + # A face is "intersected" iff the split produced more than one solid. + if len(enumerate_solids(partitions[face])) > 1: intersected_faces.append(face) - # check if the object is intersecting two opposite faces + # Skip pairs of opposite faces (object straddles entire RVE in that axis). for axis in "xyz": face_p = f"{axis}+" face_m = f"{axis}-" @@ -79,153 +133,153 @@ def _detect_intersected_faces( return intersected_faces, rve_planes, partitions +# -- Intersection helpers ------------------------------------------------------ + + def _intersect_face( face: str, rve: Rve, - partitions: dict[str, cq.Workplane], -) -> list[cq.Workplane]: - translate = { - "x-": (rve.dim[0], 0, 0), - "x+": (-rve.dim[0], 0, 0), - "y-": (0, rve.dim[1], 0), - "y+": (0, -rve.dim[1], 0), - "z-": (0, 0, rve.dim[2]), - "z+": (0, 0, -rve.dim[2]), - } - inside = partitions[face].solids(FACE_DIR[face]).intersect(rve.box) - outside = ( - partitions[face] - .solids(INV_FACE_DIR[face]) - .translate(translate[face]) - .intersect(rve.box) + partitions: dict[str, CadShape], +) -> list[CadShape]: + """One-face intersection: half stays, half translates back via periodicity.""" + base = _base_pnt(face, rve) + inside_solids = select_solids_on_side(partitions[face], base, _SIDE_DIR[face]) + outside_solids = select_solids_on_side( + partitions[face], base, tuple(-d for d in _SIDE_DIR[face]), ) - return [inside, outside] + tr = _translate(face, rve) + translated = [translate_solid(s, tr) for s in outside_solids] + + return [ + intersect_solids_with_box(inside_solids, rve.box), + intersect_solids_with_box(translated, rve.box), + ] def _intersect_edge( faces: tuple[str, str], rve: Rve, - partitions: dict[str, cq.Workplane], - rve_planes: dict[str, cq.Face], -) -> list[cq.Workplane]: - translate = { - "x-": (rve.dim[0], 0, 0), - "x+": (-rve.dim[0], 0, 0), - "y-": (0, rve.dim[1], 0), - "y+": (0, -rve.dim[1], 0), - "z-": (0, 0, rve.dim[2]), - "z+": (0, 0, -rve.dim[2]), - } + partitions: dict[str, CadShape], + rve_planes: dict[str, CadShape], +) -> list[CadShape]: + """Two-face (edge) intersection: 4 quadrants, 3 need translation.""" f_0, f_1 = faces - part = partitions[f_0].solids(FACE_DIR[f_0]).split(rve_planes[f_1]) - periodic_object = [ - part.solids(FACE_DIR[f_1]).intersect(rve.box), - part.solids(INV_FACE_DIR[f_1]).translate(translate[f_1]).intersect(rve.box), - ] - part = partitions[f_0].solids(INV_FACE_DIR[f_0]).split(rve_planes[f_1]) - periodic_object.append( - part.solids(FACE_DIR[f_1]).translate(translate[f_0]).intersect(rve.box), + base_0 = _base_pnt(f_0, rve) + base_1 = _base_pnt(f_1, rve) + tr_0 = _translate(f_0, rve) + tr_1 = _translate(f_1, rve) + + # (++) — inside both half-spaces, no translation + p0_inside = select_solids_on_side(partitions[f_0], base_0, _SIDE_DIR[f_0]) + split_pp = split_shape( + make_compound_from_solids(p0_inside), rve_planes[f_1], ) - tslt = ( - translate[f_0][0] + translate[f_1][0], - translate[f_0][1] + translate[f_1][1], - translate[f_0][2] + translate[f_1][2], + pp = select_solids_on_side(split_pp, base_1, _SIDE_DIR[f_1]) + + # (+−) — inside first, outside second: translate by tr_1 + pm = select_solids_on_side( + split_pp, base_1, tuple(-d for d in _SIDE_DIR[f_1]), + ) + pm_t = [translate_solid(s, tr_1) for s in pm] + + # Split the "outside first" half by the second plane to get (-+) and (--) + p0_outside = select_solids_on_side( + partitions[f_0], base_0, tuple(-d for d in _SIDE_DIR[f_0]), ) - periodic_object.append( - part.solids(INV_FACE_DIR[f_1]).translate(tslt).intersect(rve.box), + split_m = split_shape( + make_compound_from_solids(p0_outside), rve_planes[f_1], ) - return periodic_object + # (-+) — outside first, inside second: translate by tr_0 + mp = select_solids_on_side(split_m, base_1, _SIDE_DIR[f_1]) + mp_t = [translate_solid(s, tr_0) for s in mp] + # (--) — outside both: translate by tr_0 + tr_1 + mm = select_solids_on_side( + split_m, base_1, tuple(-d for d in _SIDE_DIR[f_1]), + ) + tslt = (tr_0[0] + tr_1[0], tr_0[1] + tr_1[1], tr_0[2] + tr_1[2]) + mm_t = [translate_solid(s, tslt) for s in mm] + + return [ + intersect_solids_with_box(pp, rve.box), + intersect_solids_with_box(pm_t, rve.box), + intersect_solids_with_box(mp_t, rve.box), + intersect_solids_with_box(mm_t, rve.box), + ] def _intersect_corner( faces: tuple[str, str, str], rve: Rve, - partitions: dict[str, cq.Workplane], - rve_planes: dict[str, cq.Face], -) -> list[cq.Workplane]: - translate = { - "x-": (rve.dim[0], 0, 0), - "x+": (-rve.dim[0], 0, 0), - "y-": (0, rve.dim[1], 0), - "y+": (0, -rve.dim[1], 0), - "z-": (0, 0, rve.dim[2]), - "z+": (0, 0, -rve.dim[2]), - } + partitions: dict[str, CadShape], + rve_planes: dict[str, CadShape], +) -> list[CadShape]: + """Three-face (corner) intersection: 8 octants, 7 need translation.""" f_0, f_1, f_2 = faces + base_0 = _base_pnt(f_0, rve) + base_1 = _base_pnt(f_1, rve) + base_2 = _base_pnt(f_2, rve) + tr_0 = _translate(f_0, rve) + tr_1 = _translate(f_1, rve) + tr_2 = _translate(f_2, rve) + + def add(*vs: tuple[float, float, float]) -> tuple[float, float, float]: + return ( + sum(v[0] for v in vs), + sum(v[1] for v in vs), + sum(v[2] for v in vs), + ) - new_part = ( - partitions[f_0] - .solids(FACE_DIR[f_0]) - .split(rve_planes[f_1]) - .solids(FACE_DIR[f_1]) - ) - periodic_object = [ - new_part.solids(FACE_DIR[f_2]).intersect(rve.box), - new_part.solids(INV_FACE_DIR[f_2]).translate(translate[f_2]).intersect(rve.box), - ] - new_part = ( - partitions[f_0] - .solids(FACE_DIR[f_0]) - .split(rve_planes[f_1]) - .solids(INV_FACE_DIR[f_1]) - ) - periodic_object.extend( - ( - new_part.solids(FACE_DIR[f_1]).translate(translate[f_1]).intersect(rve.box), - new_part.solids(INV_FACE_DIR[f_1]) - .translate((0, translate[f_1][1], translate[f_2][2])) - .intersect(rve.box), - ), - ) - new_part = ( - partitions[f_0] - .solids(INV_FACE_DIR[f_0]) - .split(rve_planes[f_1]) - .solids(FACE_DIR[f_1]) - ) - periodic_object.extend( - ( - new_part.solids(FACE_DIR[f_2]).translate(translate[f_0]).intersect(rve.box), - new_part.solids(INV_FACE_DIR[f_2]) - .translate((translate[f_0][0], 0, translate[f_2][2])) - .intersect(rve.box), - ), - ) - new_part = ( - partitions[f_0] - .solids(INV_FACE_DIR[f_0]) - .split(rve_planes[f_1]) - .solids(INV_FACE_DIR[f_1]) - ) - periodic_object.extend( - ( - new_part.solids(FACE_DIR[f_2]) - .translate((translate[f_0][0], translate[f_1][1], 0)) - .intersect(rve.box), - new_part.solids(INV_FACE_DIR[f_2]) - .translate((translate[f_0][0], translate[f_1][1], translate[f_2][2])) - .intersect(rve.box), - ), - ) - return periodic_object + def neg(d: tuple[int, int, int]) -> tuple[int, int, int]: + return (-d[0], -d[1], -d[2]) + + results: list[CadShape] = [] + + # Branch by sign of f_0 side + for sign_0, tr_x in ((_SIDE_DIR[f_0], (0.0, 0.0, 0.0)), (neg(_SIDE_DIR[f_0]), tr_0)): + p0 = select_solids_on_side(partitions[f_0], base_0, sign_0) + split_1 = split_shape(make_compound_from_solids(p0), rve_planes[f_1]) + + # Branch by sign of f_1 side + for sign_1, tr_y in ((_SIDE_DIR[f_1], (0.0, 0.0, 0.0)), (neg(_SIDE_DIR[f_1]), tr_1)): + p1 = select_solids_on_side(split_1, base_1, sign_1) + split_2 = split_shape(make_compound_from_solids(p1), rve_planes[f_2]) + + # Branch by sign of f_2 side + for sign_2, tr_z in ((_SIDE_DIR[f_2], (0.0, 0.0, 0.0)), (neg(_SIDE_DIR[f_2]), tr_2)): + p2 = select_solids_on_side(split_2, base_2, sign_2) + shift = add(tr_x, tr_y, tr_z) + if shift == (0.0, 0.0, 0.0): + translated = p2 + else: + translated = [translate_solid(s, shift) for s in p2] + results.append(intersect_solids_with_box(translated, rve.box)) + + return results + + +# -- Public API ---------------------------------------------------------------- def periodic_split_and_translate(phase: Phase, rve: Rve) -> Phase: - """Rearrange phase periodically according to the rve. + """Rearrange a phase periodically so it wraps the RVE. + + Pure OCP implementation — no cadquery dependency. :param phase: Phase to cut periodically :param rve: RVE for periodicity - :return phase: resulting phase + :return: resulting Phase """ - wk_plane = cq.Workplane().add(phase.solids) # shape to cut + shape = phase.shape + if shape is None: + err_msg = "Cannot apply periodic_split_and_translate to an empty phase" + raise ValueError(err_msg) - intersected_faces, rve_planes, partitions = _detect_intersected_faces(wk_plane, rve) + intersected_faces, rve_planes, partitions = _detect_intersected_faces(shape, rve) - periodic_object: list[cq.Workplane] = [] + periodic_object: list[CadShape] = [] if len(intersected_faces) == _NO_INTERSECTION: - periodic_object.append(wk_plane) - + periodic_object.append(shape) elif len(intersected_faces) == _FACE: periodic_object.extend( _intersect_face( @@ -234,7 +288,6 @@ def periodic_split_and_translate(phase: Phase, rve: Rve) -> Phase: partitions=partitions, ), ) - elif len(intersected_faces) == _EDGE: periodic_object.extend( _intersect_edge( @@ -244,7 +297,6 @@ def periodic_split_and_translate(phase: Phase, rve: Rve) -> Phase: rve_planes=rve_planes, ), ) - elif len(intersected_faces) == _CORNER: periodic_object.extend( _intersect_corner( @@ -259,19 +311,26 @@ def periodic_split_and_translate(phase: Phase, rve: Rve) -> Phase: ), ) - list_solids = [wp.val().Solids() for wp in periodic_object] - flat_list = [solid.copy() for solids in list_solids for solid in solids] - to_fuse = [cq.Shape(solid.wrapped) for solid in flat_list] - shape = fuseShapes(cqShapeList=to_fuse, retain_edges=False) + # Flatten: each CadShape in periodic_object wraps a compound of solids. + # Fuse all resulting solids into one final CadShape. + all_solids: list[Any] = [] + for piece in periodic_object: + all_solids.extend(enumerate_solids(piece)) - return Phase(shape=shape) + if not all_solids: + warnings.warn( + "periodic_split_and_translate produced no solids", + stacklevel=2, + ) + return Phase(shape=shape) + to_fuse = [CadShape(s) for s in all_solids] + fused = fuse_shapes(to_fuse, retain_edges=False) + return Phase(shape=fused) -def periodic(phase: Phase, rve: Rve) -> Phase: - """See periodic_split_and_translate. - Deprecated in favor of periodic_split_and_translate. - """ +def periodic(phase: Phase, rve: Rve) -> Phase: + """See :func:`periodic_split_and_translate`. Deprecated alias.""" warnings.warn( "periodic is deprecated, use periodic_split_and_translate instead", DeprecationWarning, diff --git a/microgen/phase.py b/microgen/phase.py index 519f25d7..612b9022 100644 --- a/microgen/phase.py +++ b/microgen/phase.py @@ -1,30 +1,64 @@ -"""Phase class to manage list of solids belonging to the same phase.""" +"""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``); no ``cadquery`` anywhere. Shapes are +stored as :class:`microgen.cad.CadShape` and solids as raw OCCT +``TopoDS_Solid``. +""" from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Sequence +from typing import TYPE_CHECKING, Any, Sequence -import cadquery as cq import numpy as np import numpy.typing as npt -from OCP.BRepGProp import BRepGProp -from OCP.GProp import GProp_GProps + +from .cad import ( + CadShape, + enumerate_solids, + make_compound_from_solids, + make_plane_face, + split_shape, + transform_geometry, + translate_solid, +) if TYPE_CHECKING: from .rve import Rve +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 + + +def _to_cad_shape(obj: Any) -> 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) + + class Phase: - """Phase class to manage list of solids belonging to the same phase properties. + """Phase class: a collection of solids with shared material properties. + + Exposes: - - centerOfMass - - inertiaMatrix - - shape - - solids + - :attr:`center_of_mass` + - :attr:`inertia_matrix` + - :attr:`shape` (a :class:`~microgen.cad.CadShape`) + - :attr:`solids` (a list of raw OCCT ``TopoDS_Solid``) - :param shape: Shape object - :param solids: list of cq.Solid or list of list + :param shape: a :class:`~microgen.cad.CadShape` or raw ``TopoDS_Shape`` + :param solids: list of raw OCCT solids :param center: center :param orientation: orientation """ @@ -33,14 +67,16 @@ class Phase: def __init__( self: Phase, - shape: cq.Shape | None = None, - solids: list[cq.Solid] | None = None, + shape: CadShape | Any | None = None, + solids: list[Any] | None = None, center: tuple[float, float, float] | None = None, orientation: tuple[float, float, float] | None = None, ) -> None: """Initialize the phase object.""" - self._shape = shape - self._solids: list[cq.Solid] = solids if solids is not None else [] + self._shape: CadShape | None = ( + _to_cad_shape(shape) if shape is not None else None + ) + self._solids: list[Any] = solids if solids is not None else [] self.center = center self.orientation = orientation @@ -59,7 +95,7 @@ def get_center_of_mass( *, compute: bool = True, ) -> npt.NDArray[np.float64]: - """Return the center of 'mass' of an object. + """Return the center of 'mass' of the phase. :param compute: if False and centerOfMass already exists, \ does not compute it (use carefully) @@ -73,9 +109,13 @@ def get_center_of_mass( center_of_mass = property(get_center_of_mass) def _compute_center_of_mass(self: Phase) -> None: - """Calculate the center of 'mass' of an object.""" + """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 + properties = GProp_GProps() - BRepGProp.VolumeProperties_s(self._shape.wrapped, properties) + BRepGProp.VolumeProperties_s(self.shape.wrapped, properties) com = properties.CentreOfMass() self._center_of_mass = np.array([com.X(), com.Y(), com.Z()]) @@ -85,7 +125,7 @@ def get_inertia_matrix( *, compute: bool = True, ) -> npt.NDArray[np.float64]: - """Calculate the inertia Matrix of an object. + """Calculate the inertia matrix of the phase. :param compute: if False and inertiaMatrix already exists, \ does not compute it (use carefully) @@ -99,9 +139,13 @@ def get_inertia_matrix( inertia_matrix = property(get_inertia_matrix) def _compute_inertia_matrix(self: Phase) -> None: - """Calculate the inertia Matrix of an object.""" + """Calculate the inertia matrix of the phase.""" + _require_ocp() + from OCP.BRepGProp import BRepGProp # noqa: PLC0415 + from OCP.GProp import GProp_GProps # noqa: PLC0415 + properties = GProp_GProps() - BRepGProp.VolumeProperties_s(self._shape.wrapped, properties) + BRepGProp.VolumeProperties_s(self.shape.wrapped, properties) inm = properties.MatrixOfInertia() self._inertia_matrix = np.array( @@ -113,119 +157,117 @@ def _compute_inertia_matrix(self: Phase) -> None: ) @property - def shape(self: Phase) -> cq.Shape | None: - """Return the shape of the phase.""" + 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: - # there may be a faster way - compound = cq.Compound.makeCompound(self._solids) - self._shape = cq.Shape(compound.wrapped) + self._shape = make_compound_from_solids(self._solids) return self._shape warnings.warn("No shape or solids", stacklevel=2) return None @property - def solids(self: Phase) -> list[cq.Solid]: - """Return the list of solids of the phase.""" + def solids(self: Phase) -> list[Any]: + """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 = self._shape.Solids() + self._solids = enumerate_solids(self._shape) return self._solids warnings.warn("No solids or shape", stacklevel=2) return [] def translate(self: Phase, vec: Sequence[float]) -> None: - """Translate phase by a given vector.""" - self._shape.move(cq.Location(cq.Vector(*vec))) + """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._compute_center_of_mass() @staticmethod def rescale_shape( - shape: cq.Shape, + shape: CadShape | Any, scale: float | tuple[float, float, float], - ) -> cq.Shape: - """Rescale given object according to scale parameters [dim_x, dim_y, dim_z]. - - :param shape: Shape - :param scale: float or list of scale factor in each direction + ) -> CadShape: + """Rescale ``shape`` by ``scale`` = ``(sx, sy, sz)`` (or a scalar). - :return shape: rescaled Shape + 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) - # move the shape at (0, 0, 0) to rescale it - shape.move(cq.Location(cq.Vector(-center.x, -center.y, -center.z))) - - # then move it back to its center with transform Matrix - transform_mat = cq.Matrix( + # Equivalent to: translate(-c) → scale about origin → translate(+c) + # Expressed as a single 3x4 affine matrix for BRepBuilderAPI_GTransform. + matrix = np.array( [ - [scale[0], 0, 0, center.x], - [0, scale[1], 0, center.y], - [0, 0, scale[2], center.z], + [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 shape.transformGeometry(transform_mat) + return transform_geometry(shape, matrix) def rescale(self: Phase, scale: float | tuple[float, float, float]) -> None: - """Rescale phase according to scale parameters [dim_x, dim_y, dim_z]. - - :param scale: float or list of scale factor in each direction - """ + """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: cq.Shape, + unit_geom: CadShape | Any, rve: Rve, grid: tuple[int, int, int], - ) -> cq.Shape: - """Repeat unit geometry in each direction according to the given grid. - - :param unit_geom: Shape to repeat - :param rve: RVE of the geometry to repeat - :param grid: list of number of geometry repetitions in each direction + ) -> CadShape: + """Repeat ``unit_geom`` on a ``grid`` within the ``rve`` periodicity cell. - :return: cq shape of the repeated geometry + Returns a :class:`~microgen.cad.CadShape` wrapping an OCCT compound + of translated copies of ``unit_geom``. """ + unit_geom = _to_cad_shape(unit_geom) center = np.array(unit_geom.Center().toTuple()) - xyz_repeat = cq.Assembly() + copies: list[Any] = [] for idx in np.ndindex(*grid): pos = center - rve.dim * (0.5 * np.array(grid) - 0.5 - np.array(idx)) - xyz_repeat.add(unit_geom, loc=cq.Location(cq.Vector(*pos))) - - return cq.Shape(xyz_repeat.toCompound().wrapped) + copies.append(translate_solid(unit_geom.wrapped, pos)) + return make_compound_from_solids(copies) def repeat(self: Phase, rve: Rve, grid: tuple[int, int, int]) -> None: - """Repeat phase in each direction according to the given grid. - - :param rve: RVE of the phase to repeat - :param grid: list of number of phase repetitions in each direction - """ + """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" + raise ValueError(err_msg) self._shape = self.repeat_shape(self.shape, rve, grid) - def split_solids(self: Phase, rve: Rve, grid: list[int]) -> list[cq.Solid]: + def split_solids(self: Phase, rve: Rve, grid: list[int]) -> list[Any]: """Split solids from phase according to the rve divided by the given grid. + 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``. + :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]`` - :return: list of solids + :return: list of raw OCCT ``TopoDS_Solid`` """ - solids: list[cq.Solid] = [] - + result: list[Any] = [] for solid in self.solids: - wk_plane = cq.Workplane().add(solid) + current = CadShape(solid) for dim in range(3): - direction = cq.Vector([int(dim == i) for i 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], @@ -233,12 +275,11 @@ def split_solids(self: Phase, rve: Rve, grid: list[int]) -> list[cq.Solid]: endpoint=False, )[1:] for pos in coords: - point = pos * direction - plane = cq.Face.makePlane(basePnt=point, dir=direction) - wk_plane = wk_plane.split(plane) - - solids += wk_plane.val().Solids() - return solids + 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, @@ -264,55 +305,60 @@ def rasterize( ) phase_per_raster = phasePerRaster - solids: list[cq.Solid] = self.split_solids(rve, grid) + solids = self.split_solids(rve, grid) if phase_per_raster: return self.generate_phase_per_raster(solids, rve, grid) self._solids = solids - compound = cq.Compound.makeCompound(self._solids) - self._shape = cq.Shape(compound.wrapped) + self._shape = make_compound_from_solids(self._solids) return None @classmethod def generate_phase_per_raster( cls: type[Phase], - solids: list[cq.Solid], + solids: list[Any], rve: Rve, grid: list[int], ) -> list[Phase]: - """Raster solids from phase according to the rve divided by the given grid. + """Raster solids into per-grid-cell Phases. - :param solidList: list of solids - :param grid: number of divisions in each direction [x, y, z] + :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 Phases + :return: list of :class:`Phase` """ - grid = np.array(grid) - solids_phases: list[list[cq.Solid]] = [[] for _ in range(np.prod(grid))] + _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[Any]] = [[] for _ in range(int(np.prod(grid_arr)))] for solid in solids: - center = np.array(solid.Center().toTuple()) - i, j, k = np.floor(grid * (center - rve.min_point) / rve.dim).astype(int) - ind = i + grid[0] * j + grid[0] * grid[1] * k - solids_phases[ind].append(solid) + 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] - # Deprecated methods + # -- Deprecated camelCase aliases -------------------------------------- @classmethod def generatePhasePerRaster( # noqa: N802 cls: type[Phase], - solidList: list[cq.Solid], # noqa: N803 + solidList: list[Any], # noqa: N803 rve: Rve, grid: list[int], ) -> list[Phase]: - """See generate_phase_per_raster. - - Deprecated in favor of generate_phase_per_raster. - """ + """See :meth:`generate_phase_per_raster`.""" warnings.warn( - "generatePhasePerRaster is deprecated, \ - use generate_phase_per_raster instead", + "generatePhasePerRaster is deprecated, " + "use generate_phase_per_raster instead", DeprecationWarning, stacklevel=2, ) @@ -320,14 +366,11 @@ def generatePhasePerRaster( # noqa: N802 @staticmethod def repeatShape( # noqa: N802 - unit_geom: cq.Shape, + unit_geom: Any, rve: Rve, grid: tuple[int, int, int], - ) -> cq.Shape: - """See repeat_shape. - - Deprecated in favor of repeat_shape. - """ + ) -> CadShape: + """See :meth:`repeat_shape`.""" warnings.warn( "repeatShape is deprecated, use repeat_shape instead", DeprecationWarning, @@ -337,13 +380,10 @@ def repeatShape( # noqa: N802 @staticmethod def rescaleShape( # noqa: N802 - shape: cq.Shape, + shape: Any, scale: float | tuple[float, float, float], - ) -> cq.Shape: - """See rescale_shape. - - Deprecated in favor of rescale_shape. - """ + ) -> CadShape: + """See :meth:`rescale_shape`.""" warnings.warn( "rescaleShape is deprecated, use rescale_shape instead", DeprecationWarning, @@ -352,10 +392,7 @@ def rescaleShape( # noqa: N802 return Phase.rescale_shape(shape, scale) def getCenterOfMass(self: Phase, compute: bool = True) -> np.ndarray: # noqa: N802, FBT001, FBT002 - """See get_center_of_mass. - - Deprecated in favor of get_center_of_mass. - """ + """See :meth:`get_center_of_mass`.""" warnings.warn( "centerOfMass is deprecated, use center_of_mass instead", DeprecationWarning, @@ -366,10 +403,7 @@ def getCenterOfMass(self: Phase, compute: bool = True) -> np.ndarray: # noqa: N centerOfMass = property(getCenterOfMass) # noqa: N815 def getInertiaMatrix(self: Phase, compute: bool = True) -> np.ndarray: # noqa: N802, FBT001, FBT002 - """See get_inertia_matrix. - - Deprecated in favor of get_inertia_matrix. - """ + """See :meth:`get_inertia_matrix`.""" warnings.warn( "inertiaMatrix is deprecated, use inertia_matrix instead", DeprecationWarning, diff --git a/microgen/report.py b/microgen/report.py index 09e7f7ed..40012b5e 100644 --- a/microgen/report.py +++ b/microgen/report.py @@ -74,9 +74,8 @@ def __init__( "numpy", "scooby", "gmsh", - "cadquery", ] - optional_packages = ["matplotlib", "pytest-cov", "meshio"] + optional_packages = ["matplotlib", "pytest-cov", "meshio", "OCP"] scooby.Report.__init__( self, diff --git a/microgen/rve.py b/microgen/rve.py index 933b94a9..78017c2f 100644 --- a/microgen/rve.py +++ b/microgen/rve.py @@ -1,16 +1,22 @@ -"""Representative Volume Element (RVE).""" +"""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``). +""" from __future__ import annotations import warnings from typing import TYPE_CHECKING, Sequence, Tuple -import cadquery as cq import numpy as np _DIM = 3 if TYPE_CHECKING: + from .cad import CadShape + Vector3DType = Tuple[float, float, float] | Sequence[float] @@ -73,7 +79,6 @@ def __init__( self.min_point = self.center - 0.5 * self.dim self.max_point = self.center + 0.5 * self.dim - self.box = cq.Workplane().box(*self.dim).translate(vec=cq.Vector(*self.center)) self.is_matrix = False self.matrix_number = 0 @@ -87,13 +92,24 @@ def __init__( self.dx = abs(self.x_max - self.x_min) self.dy = abs(self.y_max - self.y_min) self.dz = abs(self.z_max - self.z_min) - self.box = ( - cq.Workplane() - .box(*self.dim) - .translate((self.center[0], self.center[1], self.center[2])) - ) - self.is_matrix = False - self.matrix_number = 0 + + self._cached_box: CadShape | None = None + + @property + def box(self) -> CadShape: + """Return a :class:`~microgen.cad.CadShape` box of the RVE (cached). + + Requires the ``[cad]`` extra. + """ + if self._cached_box is None: + from .cad import make_box # noqa: PLC0415 + + self._cached_box = make_box(tuple(self.dim), tuple(self.center)) + return self._cached_box + + @box.setter + def box(self, value: CadShape) -> None: + self._cached_box = value @classmethod def from_min_max( diff --git a/microgen/shape/box.py b/microgen/shape/box.py index 5e43e9d2..37dcddca 100644 --- a/microgen/shape/box.py +++ b/microgen/shape/box.py @@ -11,7 +11,6 @@ import warnings from typing import TYPE_CHECKING -import cadquery as cq import pyvista as pv from microgen.operations import rotate @@ -19,6 +18,7 @@ from .shape import Shape if TYPE_CHECKING: + from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType @@ -61,11 +61,12 @@ def __init__( self.dim = (dim_x, dim_y, dim_z) self.dim = dim - def generate(self: Box, **_: KwargsGenerateType) -> cq.Shape: - """Generate a box CAD shape using the given parameters.""" - box = cq.Workplane().box(*self.dim).translate(self.center) - box = rotate(box, self.center, self.orientation) - return cq.Shape(box.val().wrapped) + def generate(self: Box, **_: KwargsGenerateType) -> CadShape: + """Generate a box CAD shape (OCCT). Requires the ``[cad]`` extra.""" + from microgen.cad import make_box # noqa: PLC0415 + + shape = make_box(self.dim, self.center) + return rotate(shape, self.center, self.orientation) def generate_vtk( self: Box, diff --git a/microgen/shape/capsule.py b/microgen/shape/capsule.py index e4e4e644..82ca015f 100644 --- a/microgen/shape/capsule.py +++ b/microgen/shape/capsule.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING -import cadquery as cq import pyvista as pv from microgen.operations import rotate @@ -18,6 +17,7 @@ from .shape import Shape if TYPE_CHECKING: + from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType @@ -45,40 +45,16 @@ def __init__( self.height = height self.radius = radius - def generate(self: Capsule, **_: KwargsGenerateType) -> cq.Shape: - """Generate a capsule CAD shape using the given parameters.""" - cylinder = cq.Solid.makeCylinder( - self.radius, - self.height, - pnt=cq.Vector( - -self.height / 2.0 + self.center[0], - self.center[1], - self.center[2], - ), - dir=cq.Vector(0.1, 0.0, 0.0), - angleDegrees=360, - ) - sphere_left = cq.Solid.makeSphere( - self.radius, - cq.Vector( - self.center[0] - self.height / 2.0, - self.center[1], - self.center[2], - ), - angleDegrees1=-90, - ) - sphere_right = cq.Solid.makeSphere( - self.radius, - cq.Vector( - self.center[0] + self.height / 2.0, - self.center[1], - self.center[2], - ), - angleDegrees1=-90, + def generate(self: Capsule, **_: KwargsGenerateType) -> CadShape: + """Generate a capsule CAD shape (OCCT). Requires the ``[cad]`` extra.""" + from microgen.cad import make_capsule # noqa: PLC0415 + + shape = make_capsule( + radius=self.radius, + height=self.height, + center=self.center, ) - capsule = cylinder.fuse(sphere_left) - capsule = capsule.fuse(sphere_right) - return rotate(capsule, self.center, self.orientation) + return rotate(shape, self.center, self.orientation) def generate_vtk( self: Capsule, diff --git a/microgen/shape/cylinder.py b/microgen/shape/cylinder.py index 24a13a43..8a2972dd 100644 --- a/microgen/shape/cylinder.py +++ b/microgen/shape/cylinder.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING -import cadquery as cq import pyvista as pv from microgen.operations import rotate @@ -18,6 +17,7 @@ from .shape import Shape if TYPE_CHECKING: + from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType @@ -45,18 +45,17 @@ def __init__( self.radius = radius self.height = height - def generate(self: Cylinder, **_: KwargsGenerateType) -> cq.Shape: - """Generate a cylinder CAD shape using the given parameters.""" - cylinder = ( - cq.Workplane("YZ") - .circle(self.radius) - .extrude(self.height) - .translate( - (self.center[0] - self.height / 2.0, self.center[1], self.center[2]), - ) + def generate(self: Cylinder, **_: KwargsGenerateType) -> CadShape: + """Generate a cylinder CAD shape (OCCT). Requires the ``[cad]`` extra.""" + from microgen.cad import make_cylinder # noqa: PLC0415 + + shape = make_cylinder( + radius=self.radius, + height=self.height, + center=self.center, + axis=(1.0, 0.0, 0.0), ) - cylinder = rotate(cylinder, self.center, self.orientation) - return cq.Shape(cylinder.val().wrapped) + return rotate(shape, self.center, self.orientation) def generate_vtk( self: Cylinder, diff --git a/microgen/shape/ellipsoid.py b/microgen/shape/ellipsoid.py index e375ea4c..6cf49086 100644 --- a/microgen/shape/ellipsoid.py +++ b/microgen/shape/ellipsoid.py @@ -11,7 +11,6 @@ import warnings from typing import TYPE_CHECKING -import cadquery as cq import numpy as np import pyvista as pv @@ -20,6 +19,7 @@ from .shape import Shape if TYPE_CHECKING: + from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType @@ -63,19 +63,12 @@ def __init__( self.radii = radii - def generate(self: Ellipsoid, **_: KwargsGenerateType) -> cq.Shape: - """Generate an ellipsoid CAD shape using the given parameters.""" - transform_mat = cq.Matrix( - [ - [self.radii[0], 0, 0, self.center[0]], - [0, self.radii[1], 0, self.center[1]], - [0, 0, self.radii[2], self.center[2]], - ], - ) + def generate(self: Ellipsoid, **_: KwargsGenerateType) -> CadShape: + """Generate an ellipsoid CAD shape (OCCT). Requires the ``[cad]`` extra.""" + from microgen.cad import make_ellipsoid # noqa: PLC0415 - sphere = cq.Solid.makeSphere(1.0, cq.Vector(0, 0, 0), angleDegrees1=-90) - ellipsoid = sphere.transformGeometry(transform_mat) - return rotate(ellipsoid, self.center, self.orientation) + shape = make_ellipsoid(radii=self.radii, center=self.center) + return rotate(shape, self.center, self.orientation) def generate_vtk(self: Ellipsoid, **_: KwargsGenerateType) -> pv.PolyData: """Generate an ellipsoid VTK polydta using the given parameters.""" diff --git a/microgen/shape/extruded_polygon.py b/microgen/shape/extruded_polygon.py index 42c12d82..2c667adc 100644 --- a/microgen/shape/extruded_polygon.py +++ b/microgen/shape/extruded_polygon.py @@ -11,7 +11,6 @@ from collections.abc import Sequence from typing import TYPE_CHECKING -import cadquery as cq import numpy as np import pyvista as pv @@ -20,6 +19,7 @@ from .shape import Shape if TYPE_CHECKING: + from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType @@ -66,19 +66,16 @@ def __init__( self.list_corners = list_corners self.height = height - def generate(self: ExtrudedPolygon, **_: KwargsGenerateType) -> cq.Shape: - """Generate an extruded polygon CAD shape using the given parameters.""" - poly = ( - cq.Workplane("YZ") - .polyline(self.list_corners) - .close() - .extrude(self.height) - .translate( - (self.center[0] - self.height / 2.0, self.center[1], self.center[2]), - ) + def generate(self: ExtrudedPolygon, **_: KwargsGenerateType) -> CadShape: + """Generate an extruded polygon CAD shape (OCCT). Requires the ``[cad]`` extra.""" + from microgen.cad import make_extruded_polygon # noqa: PLC0415 + + shape = make_extruded_polygon( + list_corners=self.list_corners, + height=self.height, + center=self.center, ) - poly = rotate(poly, self.center, self.orientation) - return cq.Shape(poly.val().wrapped) + return rotate(shape, self.center, self.orientation) def generate_vtk(self: ExtrudedPolygon, **_: KwargsGenerateType) -> pv.PolyData: """Generate an extruded polygon VTK shape using the given parameters.""" diff --git a/microgen/shape/polyhedron.py b/microgen/shape/polyhedron.py index d497295f..c819e694 100644 --- a/microgen/shape/polyhedron.py +++ b/microgen/shape/polyhedron.py @@ -12,7 +12,6 @@ from pathlib import Path from typing import TYPE_CHECKING -import cadquery as cq import numpy as np import pyvista as pv @@ -21,6 +20,7 @@ from .shape import Shape if TYPE_CHECKING: + from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType Vertex = tuple[float, float, float] @@ -75,31 +75,16 @@ def __init__( for ixs in self.faces_ixs: ixs.append(ixs[0]) - def generate(self: Polyhedron, **_: KwargsGenerateType) -> cq.Shape: - """Generate a polyhedron CAD shape using the given parameters.""" - faces = [] - for ixs in self.faces_ixs: - lines = [] - for v1, v2 in zip(ixs, ixs[1:], strict=False): - # tuple(map(sum, zip(a, b))) -> sum of tuples value by value - vertice_coords1 = tuple( - map(sum, zip(self.center, self.dic["vertices"][v1], strict=False)), - ) - vertice_coords2 = tuple( - map(sum, zip(self.center, self.dic["vertices"][v2], strict=False)), - ) - lines.append( - cq.Edge.makeLine( - cq.Vector(*vertice_coords1), - cq.Vector(*vertice_coords2), - ), - ) - wire = cq.Wire.assembleEdges(lines) - faces.append(cq.Face.makeFromWires(wire)) - shell = cq.Shell.makeShell(faces) - solid = cq.Solid.makeSolid(shell) - polyhedron = cq.Shape(solid.wrapped) - return rotate(polyhedron, self.center, self.orientation) + def generate(self: Polyhedron, **_: KwargsGenerateType) -> CadShape: + """Generate a polyhedron CAD shape (OCCT). Requires the ``[cad]`` extra.""" + from microgen.cad import make_polyhedron # noqa: PLC0415 + + shape = make_polyhedron( + vertices=self.dic["vertices"], + faces_ixs=self.faces_ixs, + center=self.center, + ) + return rotate(shape, self.center, self.orientation) def generate_vtk(self: Polyhedron, **_: KwargsGenerateType) -> pv.PolyData: """Generate a polyhedron VTK shape using the given parameters.""" diff --git a/microgen/shape/shape.py b/microgen/shape/shape.py index d8066a90..36906b41 100644 --- a/microgen/shape/shape.py +++ b/microgen/shape/shape.py @@ -22,8 +22,7 @@ from . import implicit_ops as _ops if TYPE_CHECKING: - import cadquery as cq - + from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType Field = Callable[ @@ -35,7 +34,7 @@ class ShellCreationError(Exception): - """Raised when a CadQuery shell cannot be created from a mesh.""" + """Raised when an OCCT shell cannot be created from a mesh.""" class Shape: @@ -183,23 +182,25 @@ def generate( bounds: BoundsType | None = None, resolution: int = 50, **_: KwargsGenerateType, - ) -> cq.Shape: - """ - Generate a CAD shape. + ) -> 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 builds a CadQuery shape from the - implicit-field VTK mesh. Subclasses override this with native - CAD construction. + Requires the optional ``[cad]`` install extra (``cadquery-ocp``). :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)`` :param resolution: number of grid points per axis - :return: CadQuery Shape + :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()" raise NotImplementedError(err_msg) - import cadquery as cq + from microgen.cad import mesh_to_shape # noqa: PLC0415 mesh = self.generate_vtk(bounds=bounds, resolution=resolution) if mesh.n_cells == 0: @@ -209,31 +210,17 @@ def generate( if not mesh.is_all_triangles: mesh.triangulate(inplace=True) triangles = mesh.faces.reshape(-1, 4)[:, 1:] - triangles = np.c_[triangles, triangles[:, 0]] - - faces = [] - for tri in triangles: - lines = [ - cq.Edge.makeLine( - cq.Vector(*mesh.points[start]), - cq.Vector(*mesh.points[end]), - ) - for start, end in itertools.pairwise(tri) - ] - wire = cq.Wire.assembleEdges(lines) - faces.append(cq.Face.makeFromWires(wire)) + points = np.asarray(mesh.points, dtype=np.float64) try: - shell = cq.Shell.makeShell(faces) - except ValueError as err: + return mesh_to_shape(points, triangles) + except Exception as err: err_msg = ( - "Failed to create the shell, " + "Failed to build the OCCT shell from the mesh; " "try to increase the resolution or adjust bounds." ) raise ShellCreationError(err_msg) from err - return cq.Shape(shell.wrapped) - def generateVtk(self: Shape, **kwargs: KwargsGenerateType) -> pv.PolyData: # noqa: N802 """Deprecated. Use :meth:`generate_vtk` instead.""" return self.generate_vtk(**kwargs) diff --git a/microgen/shape/sphere.py b/microgen/shape/sphere.py index aaa42346..a130f9a7 100644 --- a/microgen/shape/sphere.py +++ b/microgen/shape/sphere.py @@ -10,13 +10,12 @@ from typing import TYPE_CHECKING -import cadquery as cq -import numpy as np import pyvista as pv from .shape import Shape if TYPE_CHECKING: + from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, Vector3DType @@ -42,19 +41,11 @@ def __init__( super().__init__(**kwargs) self.radius = radius - def generate(self: Sphere, **_: KwargsGenerateType) -> cq.Shape: - """Generate a sphere CAD shape using the given parameters.""" - # Temporary workaround bug fix for OpenCascade bug using a random - # direct parameter for cq.Workplane().sphere() method - # Related to issue https://github.com/CadQuery/cadquery/issues/1461 - _seed = 38 - _random_direction_creation_axis = tuple(np.random.default_rng(_seed).random(3)) - sphere = ( - cq.Workplane() - .sphere(radius=self.radius, direct=_random_direction_creation_axis) - .translate(self.center) - ) - return cq.Shape(sphere.val().wrapped) + def generate(self: Sphere, **_: KwargsGenerateType) -> CadShape: + """Generate a sphere CAD shape (OCCT). Requires the ``[cad]`` extra.""" + from microgen.cad import make_sphere # noqa: PLC0415 + + return make_sphere(self.radius, self.center) def generate_vtk( self: Sphere, diff --git a/microgen/shape/strut_lattice/abstract_lattice.py b/microgen/shape/strut_lattice/abstract_lattice.py index 6e24d5e6..61fbb70d 100644 --- a/microgen/shape/strut_lattice/abstract_lattice.py +++ b/microgen/shape/strut_lattice/abstract_lattice.py @@ -9,15 +9,15 @@ from abc import abstractmethod from pathlib import Path from tempfile import NamedTemporaryFile -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -import cadquery as cq import numpy as np import numpy.typing as npt import pyvista as pv from scipy.optimize import root_scalar from scipy.spatial.transform import Rotation +from ...cad import CadShape from ...mesh import mesh, mesh_periodic from ...operations import fuse_shapes from ...periodic import periodic_split_and_translate @@ -221,8 +221,8 @@ def _compute_rotations(self) -> list[Rotation]: return rotations_list - def generate(self, **_: KwargsGenerateType) -> cq.Shape: - if isinstance(self._cad_shape, cq.Shape): + def generate(self, **_: KwargsGenerateType) -> CadShape: + if isinstance(self._cad_shape, CadShape): return self._cad_shape self._cad_shape = self._generate_cad() @@ -230,7 +230,7 @@ def generate(self, **_: KwargsGenerateType) -> cq.Shape: cad_shape = property(generate) - def _generate_cad(self, **_: KwargsGenerateType) -> cq.Shape: + def _generate_cad(self, **_: KwargsGenerateType) -> CadShape: """Generate a strut-based lattice CAD shape using the given parameters.""" list_phases: list[Phase] = [] list_periodic_phases: list[Phase] = [] @@ -312,7 +312,7 @@ def _generate_vtk( NamedTemporaryFile(suffix=".step", delete=False) as cad_step_file, NamedTemporaryFile(suffix=".vtk", delete=False) as mesh_file, ): - cq.exporters.export(cad_lattice, cad_step_file.name) + cad_lattice.export_step(cad_step_file.name) if periodic: mesh_periodic( mesh_file=cad_step_file.name, diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 9f98c1fc..9c0aba4d 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -19,17 +19,17 @@ from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Literal -import cadquery as cq import numpy as np import numpy.typing as npt import pyvista as pv from scipy.optimize import root_scalar -from microgen.operations import fuseShapes, rotate +from microgen.operations import fuse_shapes, rotate from .shape import Shape if TYPE_CHECKING: + from microgen.cad import CadShape from microgen.shape import KwargsGenerateType, TpmsPartType, Vector3DType from .tpms_grading import OffsetGrading @@ -668,45 +668,30 @@ def offset( self._update_grid_offset() self.offset_updated = True - def _mesh_to_shell(self: Tpms, mesh: pv.PolyData) -> cq.Shape: - """ - Convert a triangulated PyVista mesh to a CadQuery ``Shape``. - - Builds one ``cq.Face`` per triangle, then *sews* them with OCCT's - ``BRepBuilderAPI_Sewing`` so adjacent triangles share edges — without - sewing, ``Shell.makeShell`` returns a disjoint compound and the result - cannot be turned into a Solid. Returns the sewn shape (a - ``TopoDS_Shell`` if sewing succeeded into one shell, otherwise the - compound that sewing produced). + def _mesh_to_shell(self: Tpms, mesh: pv.PolyData) -> CadShape: + """Convert a triangulated PyVista mesh to an OCCT ``CadShape``. + + Delegates to :func:`microgen.cad.mesh_to_shell_brep` (one planar BREP + face per triangle), the slower-but-correct path that produces a shell + with real surface geometry — required because downstream callers may + use the result as a cutting tool in boolean ops. """ - from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing + from microgen.cad import ShellCreationError as _CadShellError # noqa: PLC0415 + from microgen.cad import mesh_to_shell_brep # noqa: PLC0415 if not mesh.is_all_triangles: mesh.triangulate(inplace=True) triangles = mesh.faces.reshape(-1, 4)[:, 1:] - triangles = np.c_[triangles, triangles[:, 0]] - - faces = [] - for tri in triangles: - lines = [ - cq.Edge.makeLine( - cq.Vector(*mesh.points[start]), - cq.Vector(*mesh.points[end]), - ) - for start, end in zip(tri[:], tri[1:], strict=False) - ] - wire = cq.Wire.assembleEdges(lines) - faces.append(cq.Face.makeFromWires(wire)) + points = np.asarray(mesh.points, dtype=np.float64) - if not faces: - err_msg = "Mesh has no triangles to convert into a shell" - raise ShellCreationError(err_msg) - - sewing = BRepBuilderAPI_Sewing() - for f in faces: - sewing.Add(f.wrapped) - sewing.Perform() - return cq.Shape(sewing.SewedShape()) + try: + return mesh_to_shell_brep(points, triangles) + except _CadShellError as err: + err_msg = ( + "Failed to create the shell; " + "try to increase the resolution or the smoothing." + ) + raise ShellCreationError(err_msg) from err def _check_offset( self: Tpms, @@ -810,21 +795,24 @@ def generate( smoothing: int = 0, algo_resolution: int | None = None, **_: KwargsGenerateType, - ) -> cq.Shape: - """ - Generate the OCCT/CadQuery shape of the requested TPMS part. + ) -> CadShape: + """Generate an OCCT CAD shape of the requested TPMS part. Pure F-rep pipeline: pick the SDF Shape via :meth:`_frep_part`, run marching cubes through :meth:`Shape.generate_vtk`, optionally smooth, - then build a CadQuery ``Shell``. The same SDF + same marching-cubes - grid is used by :meth:`generate_vtk`, so volumes converge to identical - values up to discretization. + then build an OCCT ``Shell`` via :func:`microgen.cad.mesh_to_shell_brep`. + The same SDF + same marching-cubes grid is used by :meth:`generate_vtk`, + so volumes converge to identical values up to discretisation. + + Requires the optional ``[cad]`` install extra (``cadquery-ocp``). :param type_part: ``"sheet"``, ``"lower skeletal"``, ``"upper skeletal"`` or ``"surface"`` (open zero-isosurface, no thickness) :param smoothing: number of Laplacian smoothing iterations on the mesh :param algo_resolution: temporary-TPMS resolution for density→offset search (only used when ``self.density`` is set) + :return: :class:`microgen.cad.CadShape` wrapping an OCCT ``TopoDS_Shell`` + (or a ``TopoDS_Solid`` when sewing succeeded into a closed shell). """ if type_part not in self._VALID_PARTS: err_msg = ( @@ -874,35 +862,35 @@ def generate( return shape.translate(self.center) @staticmethod - def _try_make_solid(shape: cq.Shape) -> cq.Shape: - """ - Best-effort upgrade of a sewn shell into a closed Solid. + def _try_make_solid(shape: CadShape) -> CadShape: + """Best-effort upgrade of a sewn shell into a closed Solid. Returns the original shape unchanged if the sewn result is a Compound (multiple disjoint shells, can't be a single solid) or if OCCT refuses the conversion. """ - from OCP.TopAbs import TopAbs_SHELL - from OCP.TopExp import TopExp_Explorer - from OCP.TopoDS import TopoDS + from microgen.cad import CadShape as _CadShape # noqa: PLC0415 + from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid # noqa: PLC0415 + from OCP.TopAbs import TopAbs_SHELL # noqa: PLC0415 + from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 + from OCP.TopoDS import TopoDS # noqa: PLC0415 wrapped = shape.wrapped # Already a Shell? Try to make a Solid directly. if wrapped.ShapeType() == TopAbs_SHELL: try: shell = TopoDS.Shell_s(wrapped) - return cq.Shape(cq.Solid.makeSolid(cq.Shell(shell)).wrapped) + return _CadShape(BRepBuilderAPI_MakeSolid(shell).Solid()) except (ValueError, RuntimeError): return shape # Compound: extract Shells, build a Solid per closed shell, fuse. exp = TopExp_Explorer(wrapped, TopAbs_SHELL) - solids: list[cq.Shape] = [] + solids: list[CadShape] = [] while exp.More(): try: shell = TopoDS.Shell_s(exp.Current()) - solid = cq.Solid.makeSolid(cq.Shell(shell)) - solids.append(cq.Shape(solid.wrapped)) + solids.append(_CadShape(BRepBuilderAPI_MakeSolid(shell).Solid())) except (ValueError, RuntimeError): pass exp.Next() @@ -911,7 +899,7 @@ def _try_make_solid(shape: cq.Shape) -> cq.Shape: return shape if len(solids) == 1: return solids[0] - return fuseShapes(cqShapeList=solids, retain_edges=False) + return fuse_shapes(solids, retain_edges=False) def generate_vtk( self: Tpms, diff --git a/pyproject.toml b/pyproject.toml index d32acc5a..40473194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,11 +16,24 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - # Python 3.13 not yet supported (CadQuery does not support 3.13) + "Programming Language :: Python :: 3.13", +] +# Core dependencies: mesh / implicit-field workflows only. The CAD path +# (``.generate()`` → OCCT ``TopoDS_Shape``) requires the ``[cad]`` extra. +dependencies = [ + "numpy", + "pyvista", + "gmsh", + "meshio", + "scipy", + "nlopt", + "autograd", ] -dependencies = ["numpy", "pyvista", "gmsh", "meshio", "cadquery", "scipy", "nlopt", "autograd"] [project.optional-dependencies] +# CAD backend: OCCT via cadquery-ocp (no cadquery itself). Installing this +# extra unlocks Shape.generate(), Phase, fuse_shapes, lattice CAD, etc. +cad = ["cadquery-ocp"] dev = [ 'pre-commit>=3.5.0', "ruff>=0.9", @@ -43,11 +56,10 @@ ray = [ ] jupyter = [ "jupyterlab>=3.6.8,<4.6", - "jupyter-cadquery>=2.2.1", "sidecar>=0.5.2", "jupyterview>=0.7.0", ] -all = ["microgen[dev, docs, jupyter, ray]"] +all = ["microgen[cad, dev, docs, jupyter, ray]"] [project.urls] Documentation = 'https://3mah.github.io/microgen-docs/' diff --git a/tests/shapes/test_lattice.py b/tests/shapes/test_lattice.py index 09e375e6..30ccd2d8 100644 --- a/tests/shapes/test_lattice.py +++ b/tests/shapes/test_lattice.py @@ -2,10 +2,11 @@ from typing import Literal -import cadquery as cq import numpy as np import pytest +pytest.importorskip("OCP") + from microgen import is_periodic from microgen.shape.strut_lattice import ( BodyCenteredCubic, diff --git a/tests/test_cad_optional.py b/tests/test_cad_optional.py new file mode 100644 index 00000000..8dbfc5cf --- /dev/null +++ b/tests/test_cad_optional.py @@ -0,0 +1,75 @@ +"""Smoke tests verifying that the mesh / implicit-field path works without OCP. + +These tests exercise the public microgen API that must function **without** +the optional ``[cad]`` install extra (i.e. without ``cadquery-ocp``/OCP). + +In CI we run them in a dedicated no-CAD environment. Running them in an +environment where OCP *is* installed is still fine — every assertion here +holds unconditionally; these tests just prove the no-CAD paths don't +*transitively* pull in OCP via an eager import. +""" +# ruff: noqa: S101 + +from __future__ import annotations + +import importlib +import sys + + +def test_import_microgen_without_ocp() -> None: + """``import microgen`` must succeed even if OCP is not importable.""" + import microgen # noqa: F401 + + # microgen itself must not eagerly import OCP. + assert "OCP" not in sys.modules or sys.modules.get("OCP") is not None + + +def test_implicit_ops_module_does_not_need_ocp() -> None: + """Implicit (F-rep) operations are pure numpy — no OCP.""" + from microgen.shape import implicit_ops + + # Reload with OCP masked to confirm the module never imports it at + # module-load time. (The module has no OCP import; this just pins it.) + importlib.reload(implicit_ops) + assert hasattr(implicit_ops, "union") + assert hasattr(implicit_ops, "shell") + assert hasattr(implicit_ops, "normalize_to_sdf") + + +def test_primitive_generate_vtk_without_cad_extra() -> None: + """Every primitive's ``generate_vtk()`` works without the CAD extra.""" + from microgen.shape.box import Box + from microgen.shape.cylinder import Cylinder + from microgen.shape.sphere import Sphere + + assert Box().generate_vtk().n_cells > 0 + assert Sphere().generate_vtk().n_cells > 0 + assert Cylinder().generate_vtk().n_cells > 0 + + +def test_tpms_generate_vtk_without_cad_extra() -> None: + """TPMS F-rep + marching cubes work without the CAD extra.""" + from microgen import surface_functions + from microgen.shape.tpms import Tpms + + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.5) + mesh = tpms.generate_vtk(type_part="sheet") + assert mesh.n_cells > 0 + + +def test_cad_capable_entry_points_raise_cleanly_without_ocp() -> None: + """Calling ``.generate()`` / ``make_box()`` without OCP gives a clear error. + + Only meaningful in a true no-CAD env; when OCP *is* installed these calls + succeed. We just verify the function exists and does not, for instance, + segfault on import. + """ + from microgen import cad + + try: + cad.require_cad() + except ImportError as err: + assert "cadquery-ocp" in str(err) or "microgen[cad]" in str(err) + else: + # OCP is installed in this env — that's fine, nothing to verify. + assert True diff --git a/tests/test_mesh.py b/tests/test_mesh.py index d21c1c04..5b1edfbe 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -2,11 +2,14 @@ from pathlib import Path -import cadquery as cq import numpy as np import pyvista as pv +import pytest + +pytest.importorskip("OCP") from microgen import Phase, Rve, Sphere, mesh, raster_phase +from microgen.cad import make_compound_from_solids # ruff: noqa: S101 assert https://docs.astral.sh/ruff/rules/assert/ @@ -23,10 +26,10 @@ def test_mesh_rastered_sphere_must_have_correct_number_of_cells() -> None: rve=Rve(), grid=grid, ) - compound = cq.Compound.makeCompound( + compound = make_compound_from_solids( [solid for phase in phases for solid in phase.solids], ) - cq.exporters.export(compound, fname="tests/data/compound.step") + compound.export_step("tests/data/compound.step") mesh( mesh_file="tests/data/compound.step", diff --git a/tests/test_mesh_periodic.py b/tests/test_mesh_periodic.py index b26d4b9d..72613193 100644 --- a/tests/test_mesh_periodic.py +++ b/tests/test_mesh_periodic.py @@ -7,11 +7,12 @@ if TYPE_CHECKING: from pathlib import Path -import cadquery as cq import numpy as np import pytest import pyvista as pv +pytest.importorskip("OCP") + from microgen import ( Box, Cylinder, @@ -23,6 +24,7 @@ meshPeriodic, periodic, ) +from microgen.cad import CadShape, make_compound_from_solids # 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/ @@ -99,7 +101,7 @@ def _generate_cqcompound_octettruss(rve: Rve) -> list[Phase]: @pytest.fixture() -def box_homogeneous_unit(rve_unit: Rve) -> tuple[cq.Shape, list[Phase], Rve]: +def box_homogeneous_unit(rve_unit: Rve) -> tuple[CadShape, list[Phase], Rve]: """Return a homogeneous unit box.""" shape = Box( center=tuple(rve_unit.center), @@ -113,7 +115,7 @@ def box_homogeneous_unit(rve_unit: Rve) -> tuple[cq.Shape, list[Phase], Rve]: @pytest.fixture() def box_homogeneous_double( rve_double: Rve, -) -> tuple[cq.Shape, list[Phase], Rve]: +) -> tuple[CadShape, list[Phase], Rve]: """Return a homogeneous double box.""" shape = Box( center=tuple(rve_double.center), @@ -127,7 +129,7 @@ def box_homogeneous_double( @pytest.fixture() def box_homogeneous_double_centered( rve_double_centered: Rve, -) -> (cq.Shape, list[Phase], Rve): +) -> tuple[CadShape, list[Phase], Rve]: """Return a homogeneous double centered box.""" shape = Box( center=tuple(rve_double_centered.center), @@ -141,7 +143,7 @@ def box_homogeneous_double_centered( @pytest.fixture() def octet_truss_homogeneous_unit( rve_unit: Rve, -) -> tuple[cq.Shape, list[Phase], Rve]: +) -> tuple[CadShape, list[Phase], Rve]: """Return a homogeneous unit octet truss.""" list_periodic_phases = _generate_cqcompound_octettruss(rve_unit) merged = fuseShapes( @@ -155,7 +157,7 @@ def octet_truss_homogeneous_unit( @pytest.fixture() def octet_truss_homogeneous_double_centered( rve_double_centered: Rve, -) -> tuple[cq.Shape, list[Phase], Rve]: +) -> tuple[CadShape, list[Phase], Rve]: """Return a homogeneous double centered octet truss.""" list_periodic_phases = _generate_cqcompound_octettruss(rve_double_centered) merged = fuseShapes( @@ -169,12 +171,12 @@ def octet_truss_homogeneous_double_centered( @pytest.fixture() def octet_truss_heterogeneous( rve_unit: Rve, -) -> tuple[cq.Compound, list[Phase], Rve]: +) -> tuple[CadShape, list[Phase], Rve]: """Return a heterogeneous octet truss.""" list_periodic_phases = _generate_cqcompound_octettruss(rve_unit) listcqphases = cutPhases(phaseList=list_periodic_phases, reverseOrder=False) return ( - cq.Compound.makeCompound([phase.shape for phase in listcqphases]), + make_compound_from_solids([phase.shape.wrapped for phase in listcqphases]), listcqphases, rve_unit, ) @@ -193,7 +195,7 @@ def octet_truss_heterogeneous( ], ) def test_octettruss_mesh_must_be_periodic( - shape: tuple[cq.Compound | cq.Shape, list[Phase], Rve], + shape: tuple[CadShape, list[Phase], Rve], request: pytest.FixtureRequest, tmp_output_compound_filename: str, tmp_output_vtk_filename: str, @@ -201,7 +203,7 @@ def test_octettruss_mesh_must_be_periodic( """Test that the octet truss mesh is periodic.""" cqoctet, listcqphases, rve = request.getfixturevalue(shape) - cq.exporters.export(cqoctet, tmp_output_compound_filename) + cqoctet.export_step(tmp_output_compound_filename) meshPeriodic( mesh_file=tmp_output_compound_filename, rve=rve, From 41f6aca102b90551c4c7592bea6a0cfc78575750 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Fri, 24 Apr 2026 23:40:06 +0200 Subject: [PATCH 12/15] Add Python 3.14 support across CI and packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Python 3.14 to GitHub Actions matrices (build-and-test and integration), including a comment in integration explaining conda-forge ocp wheels availability. Update pyproject classifiers to advertise Python 3.14 and add README notes about core support for 3.10–3.14 and the `[cad]` extra using conda-forge if PyPI lacks cadquery-ocp wheels. This brings CI, docs, and packaging into alignment for the new Python release. --- .github/workflows/build-and-test.yml | 2 +- .github/workflows/integration.yml | 4 +++- README.md | 2 ++ pyproject.toml | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index bb94e66e..6bb47298 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -24,7 +24,7 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest", "macos-latest"] - python-version: ["3.10", "3.12", "3.13"] + python-version: ["3.10", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 017dda84..9d27edbd 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -23,7 +23,9 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest", "macos-latest", "windows-latest"] - python-version: ["3.10", "3.12", "3.13"] + # conda-forge ships the `ocp` Python 3.14 wheels ahead of PyPI, so + # 3.14 is viable through the conda route even when PyPI isn't ready. + python-version: ["3.10", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 4b3963ec..fd2abe22 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ conda install conda-forge::microgen # core conda install conda-forge::microgen ocp # core + CAD ``` +**Python version notes:** core supports Python 3.10 – 3.14. The `[cad]` extra on PyPI follows `cadquery-ocp`'s wheel availability (typically 2–6 months behind a new Python release); if PyPI says "No matching distribution for cadquery-ocp", install through conda-forge instead — `conda install -c conda-forge ocp` usually ships wheels for newer Pythons ahead of PyPI. + What you can do in each mode: | | Core install | `[cad]` install | diff --git a/pyproject.toml b/pyproject.toml index 40473194..bf485ea0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] # Core dependencies: mesh / implicit-field workflows only. The CAD path # (``.generate()`` → OCCT ``TopoDS_Shape``) requires the ``[cad]`` extra. From 2ae7871946681d9190fb0a7966c6317307f8f821 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Mon, 27 Apr 2026 10:06:15 +0200 Subject: [PATCH 13/15] Improve CAD bindings and CadShape API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CadQuery-compatible helpers and robustness fixes for OCCT bindings: introduce CadShape.Vertices, Faces and Closed; make Volume return the absolute value to avoid negative volumes from inverted orientation; correct __slots ordering. Clean up and normalize OCP imports (remove excessive noqa markers), switch to collections.abc Iterable/Sequence and add typing.Any for raw TopoDS passthrough. Small API/robustness tweaks across mesh/shell/shape helpers (formatting, exception handling, safe zip use) and simplify shape module imports. Update ruff ignores in pyproject.toml to accommodate needed typing and third‑party API patterns. --- .gitignore | 1 + microgen/cad.py | 246 +++++++++++------- microgen/operations.py | 6 +- microgen/periodic.py | 37 ++- microgen/shape/box.py | 2 +- microgen/shape/capsule.py | 2 +- microgen/shape/cylinder.py | 2 +- microgen/shape/ellipsoid.py | 2 +- microgen/shape/extruded_polygon.py | 2 +- microgen/shape/polyhedron.py | 2 +- microgen/shape/shape.py | 2 +- microgen/shape/sphere.py | 2 +- .../shape/strut_lattice/abstract_lattice.py | 2 +- microgen/shape/tpms.py | 15 +- pyproject.toml | 5 + 15 files changed, 213 insertions(+), 115 deletions(-) diff --git a/.gitignore b/.gitignore index bf50db96..9890e2b5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ data/ _build/ .ipynb_checkpoints/ microgen/shape/.DS_Store +.claude/ diff --git a/microgen/cad.py b/microgen/cad.py index 4c8eea6b..5eabe55c 100644 --- a/microgen/cad.py +++ b/microgen/cad.py @@ -25,7 +25,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterable, Sequence +from collections.abc import Iterable, Sequence +from typing import TYPE_CHECKING, Any import numpy as np import numpy.typing as npt @@ -33,7 +34,7 @@ if TYPE_CHECKING: from pathlib import Path - from OCP.TopoDS import TopoDS_Compound, TopoDS_Shape + from OCP.TopoDS import TopoDS_Shape _INSTALL_HINT = ( @@ -84,7 +85,7 @@ class _BBox: ``(xmin, ymin, zmin, xmax, ymax, zmax)`` matching OCCT's ``Bnd_Box.Get``. """ - __slots__ = ("xmin", "ymin", "zmin", "xmax", "ymax", "zmax") + __slots__ = ("xmax", "xmin", "ymax", "ymin", "zmax", "zmin") def __init__( self, @@ -115,7 +116,7 @@ def DiagonalLength(self) -> float: # noqa: N802 def require_cad() -> None: """Raise :class:`ImportError` if the CAD backend (OCP) is not importable.""" try: - import OCP # noqa: F401, PLC0415 + import OCP # noqa: F401 except ImportError as err: raise ImportError(_INSTALL_HINT) from err @@ -142,11 +143,13 @@ def __init__(self, shape: TopoDS_Shape) -> None: 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 + 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]))) + trsf.SetTranslation( + gp_Vec(float(offset[0]), float(offset[1]), float(offset[2])) + ) transformed = BRepBuilderAPI_Transform(self.wrapped, trsf, True).Shape() return CadShape(transformed) @@ -157,8 +160,8 @@ def rotate( 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 + from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform + from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf trsf = gp_Trsf() ax = gp_Ax1( @@ -171,7 +174,7 @@ def rotate( def copy(self) -> CadShape: """Return an independent copy (deep topology copy).""" - from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy # noqa: PLC0415 + from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy return CadShape(BRepBuilderAPI_Copy(self.wrapped).Shape()) @@ -179,19 +182,19 @@ def copy(self) -> CadShape: def fuse(self, other: CadShape) -> CadShape: """Boolean fusion: ``self ∪ other``.""" - from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse # noqa: PLC0415 + from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse return CadShape(BRepAlgoAPI_Fuse(self.wrapped, other.wrapped).Shape()) def cut(self, other: CadShape) -> CadShape: """Boolean difference: ``self \\ other``.""" - from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut # noqa: PLC0415 + from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut return CadShape(BRepAlgoAPI_Cut(self.wrapped, other.wrapped).Shape()) def intersect(self, other: CadShape) -> CadShape: """Boolean intersection: ``self ∩ other``.""" - from OCP.BRepAlgoAPI import BRepAlgoAPI_Common # noqa: PLC0415 + from OCP.BRepAlgoAPI import BRepAlgoAPI_Common return CadShape(BRepAlgoAPI_Common(self.wrapped, other.wrapped).Shape()) @@ -199,9 +202,9 @@ def intersect(self, other: CadShape) -> CadShape: def solids(self) -> list[CadShape]: """Enumerate contained solids.""" - from OCP.TopAbs import TopAbs_SOLID # noqa: PLC0415 - from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 - from OCP.TopoDS import TopoDS # noqa: PLC0415 + from OCP.TopAbs import TopAbs_SOLID + from OCP.TopExp import TopExp_Explorer + from OCP.TopoDS import TopoDS out: list[CadShape] = [] exp = TopExp_Explorer(self.wrapped, TopAbs_SOLID) @@ -215,14 +218,67 @@ def Solids(self) -> list[CadShape]: # noqa: N802 """CadQuery-compatible alias for :meth:`solids`.""" return self.solids() + def Vertices(self) -> list[tuple[float, float, float]]: # noqa: N802 + """Enumerate the vertex coordinates of the shape. + + CadQuery-compatible: 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 + from OCP.TopoDS import TopoDS + + out: list[tuple[float, float, float]] = [] + exp = TopExp_Explorer(self.wrapped, TopAbs_VERTEX) + while exp.More(): + v = TopoDS.Vertex_s(exp.Current()) + p = BRep_Tool.Pnt_s(v) + out.append((float(p.X()), float(p.Y()), float(p.Z()))) + exp.Next() + return out + + def Faces(self) -> list[CadShape]: # noqa: N802 + """Enumerate the faces of the shape (CadQuery compatibility).""" + from OCP.TopAbs import TopAbs_FACE + from OCP.TopExp import TopExp_Explorer + from OCP.TopoDS import TopoDS + + out: list[CadShape] = [] + exp = TopExp_Explorer(self.wrapped, TopAbs_FACE) + while exp.More(): + out.append(CadShape(TopoDS.Face_s(exp.Current()))) + exp.Next() + return out + + def Closed(self) -> bool: # noqa: N802 + """Whether the shape is topologically closed (CadQuery compatibility). + + 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: # noqa: N802 - """Return the volume of the shape (uses OCCT ``BRepGProp``).""" - from OCP.BRepGProp import BRepGProp # noqa: PLC0415 - from OCP.GProp import GProp_GProps # noqa: PLC0415 + """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(...)`` to match CadQuery's behaviour + and the natural expectation that volumes are non-negative. + """ + from OCP.BRepGProp import BRepGProp + from OCP.GProp import GProp_GProps props = GProp_GProps() BRepGProp.VolumeProperties_s(self.wrapped, props) - return float(props.Mass()) + return float(abs(props.Mass())) def Center(self) -> _Centre: # noqa: N802 """Return the volumetric center of mass. @@ -230,8 +286,8 @@ def Center(self) -> _Centre: # noqa: N802 The result is a :class:`_Centre` — exposes ``.x``, ``.y``, ``.z``, ``.toTuple()`` (CadQuery compatibility), and unpacks like a tuple. """ - from OCP.BRepGProp import BRepGProp # noqa: PLC0415 - from OCP.GProp import GProp_GProps # noqa: PLC0415 + from OCP.BRepGProp import BRepGProp + from OCP.GProp import GProp_GProps props = GProp_GProps() BRepGProp.VolumeProperties_s(self.wrapped, props) @@ -244,8 +300,8 @@ def BoundingBox(self) -> _BBox: # noqa: N802 The result exposes CadQuery-compatible ``xmin``/``xmax``/… attributes (see :class:`_BBox`). """ - from OCP.Bnd import Bnd_Box # noqa: PLC0415 - from OCP.BRepBndLib import BRepBndLib # noqa: PLC0415 + from OCP.Bnd import Bnd_Box + from OCP.BRepBndLib import BRepBndLib box = Bnd_Box() # AddOptimal uses exact geometric bounds (not cached triangulation), @@ -265,8 +321,8 @@ def export_stl( 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 + from OCP.BRepMesh import BRepMesh_IncrementalMesh + from OCP.StlAPI import StlAPI_Writer BRepMesh_IncrementalMesh( self.wrapped, @@ -281,8 +337,11 @@ def export_stl( def export_step(self, path: str | Path) -> None: """Export to STEP (AP214).""" - from OCP.IFSelect import IFSelect_RetDone # noqa: PLC0415 - from OCP.STEPControl import STEPControl_AsIs, STEPControl_Writer # noqa: PLC0415 + from OCP.IFSelect import IFSelect_RetDone + from OCP.STEPControl import ( + STEPControl_AsIs, + STEPControl_Writer, + ) writer = STEPControl_Writer() status = writer.Transfer(self.wrapped, STEPControl_AsIs) @@ -296,7 +355,7 @@ def export_step(self, path: str | Path) -> None: def export_brep(self, path: str | Path) -> None: """Export to OCCT native BREP.""" - from OCP.BRepTools import BRepTools # noqa: PLC0415 + from OCP.BRepTools import BRepTools ok = BRepTools.Write_s(self.wrapped, str(path)) if not ok: @@ -330,10 +389,10 @@ def mesh_to_shape( :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 + 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) @@ -351,7 +410,9 @@ def mesh_to_shape( 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]))) + 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)) @@ -360,7 +421,7 @@ def mesh_to_shape( face = TopoDS_Face() try: builder.MakeFace(face, triangulation) - except Exception as err: # noqa: BLE001 + except Exception as err: err_msg = "OCCT refused the triangulation — check bounds and field." raise ShellCreationError(err_msg) from err @@ -388,14 +449,14 @@ def mesh_to_shell_brep( :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 + from OCP.BRep import BRep_Builder + from OCP.BRepBuilderAPI import ( BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeWire, ) - from OCP.gp import gp_Pnt # noqa: PLC0415 - from OCP.TopoDS import TopoDS_Shell # noqa: PLC0415 + 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) @@ -417,7 +478,7 @@ def mesh_to_shell_brep( wire = BRepBuilderAPI_MakeWire(e1, e2, e3).Wire() face = BRepBuilderAPI_MakeFace(wire).Face() builder.Add(shell, face) - except Exception as err: # noqa: BLE001 + except Exception as err: err_msg = ( "Failed to build the OCCT shell from the mesh; " "try to increase the resolution or adjust bounds." @@ -435,8 +496,8 @@ def mesh_to_shell_brep( 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 + 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) @@ -447,8 +508,8 @@ def make_box(dim: Sequence[float], center: Sequence[float]) -> CadShape: 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 + 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()) @@ -462,8 +523,8 @@ def make_cylinder( ) -> 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 + 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) @@ -487,11 +548,11 @@ def make_capsule( ) -> CadShape: """Capsule (cylinder along X with hemispherical caps).""" require_cad() - from OCP.BRepPrimAPI import ( # noqa: PLC0415 + from OCP.BRepPrimAPI import ( BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakeSphere, ) - from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt # noqa: PLC0415 + from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt cx, cy, cz = (float(c) for c in center) h = float(height) @@ -510,9 +571,9 @@ def make_ellipsoid(radii: Sequence[float], center: Sequence[float]) -> CadShape: 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 + 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) @@ -521,9 +582,15 @@ def make_ellipsoid(radii: Sequence[float], center: Sequence[float]) -> CadShape: gtrsf = gp_GTrsf() gtrsf.SetVectorialPart( gp_Mat( - rx, 0.0, 0.0, - 0.0, ry, 0.0, - 0.0, 0.0, rz, + rx, + 0.0, + 0.0, + 0.0, + ry, + 0.0, + 0.0, + 0.0, + rz, ), ) gtrsf.SetTranslationPart(gp_XYZ(cx, cy, cz)) @@ -545,29 +612,28 @@ def make_polyhedron( does not orient; the resulting solid would have mixed-sign volume.) """ require_cad() - from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + from OCP.BRepBuilderAPI import ( 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 - from OCP.TopoDS import TopoDS # noqa: PLC0415 + from OCP.gp import gp_Pnt + from OCP.ShapeFix import ShapeFix_Solid + from OCP.TopAbs import TopAbs_SHELL + from OCP.TopExp import TopExp_Explorer + from OCP.TopoDS import TopoDS 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 + 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:]): + for i1, i2 in zip(ixs, ixs[1:], strict=False): edge = BRepBuilderAPI_MakeEdge(points[i1], points[i2]).Edge() wire_builder.Add(edge) face = BRepBuilderAPI_MakeFace(wire_builder.Wire()).Face() @@ -595,13 +661,13 @@ def make_extruded_polygon( ) -> CadShape: """Extrude a 2D polygon (in the YZ plane) along the X axis.""" require_cad() - from OCP.BRepBuilderAPI import ( # noqa: PLC0415 + from OCP.BRepBuilderAPI import ( BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeWire, ) - from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism # noqa: PLC0415 - from OCP.gp import gp_Pnt, gp_Vec # noqa: PLC0415 + 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) @@ -630,8 +696,8 @@ def make_extruded_polygon( 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 + from OCP.BRep import BRep_Builder + from OCP.TopoDS import TopoDS_Compound builder = BRep_Builder() compound = TopoDS_Compound() @@ -644,8 +710,8 @@ def make_compound(shapes: Iterable[CadShape]) -> CadShape: 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 + from OCP.BRep import BRep_Builder + from OCP.TopoDS import TopoDS_Compound builder = BRep_Builder() compound = TopoDS_Compound() @@ -664,9 +730,9 @@ def make_compound_from_solids(solids: Iterable[Any]) -> CadShape: 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 - from OCP.TopoDS import TopoDS # noqa: PLC0415 + from OCP.TopAbs import TopAbs_SOLID + from OCP.TopExp import TopExp_Explorer + from OCP.TopoDS import TopoDS out: list[Any] = [] exp = TopExp_Explorer(shape.wrapped, TopAbs_SOLID) @@ -684,8 +750,8 @@ def split_shape(shape: CadShape, tool: CadShape) -> CadShape: :func:`enumerate_solids` to iterate over the resulting solids. """ require_cad() - from OCP.BRepAlgoAPI import BRepAlgoAPI_Splitter # noqa: PLC0415 - from OCP.TopTools import TopTools_ListOfShape # noqa: PLC0415 + from OCP.BRepAlgoAPI import BRepAlgoAPI_Splitter + from OCP.TopTools import TopTools_ListOfShape args = TopTools_ListOfShape() args.Append(shape.wrapped) @@ -712,8 +778,8 @@ def make_plane_face( 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 + 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])) @@ -734,16 +800,22 @@ def transform_geometry(shape: CadShape, matrix: npt.NDArray[np.float64]) -> CadS :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 + 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]), + 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]))) @@ -753,8 +825,8 @@ def transform_geometry(shape: CadShape, matrix: npt.NDArray[np.float64]) -> CadS 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 + 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() @@ -767,8 +839,8 @@ def translate_solid(solid: Any, offset: Sequence[float]) -> Any: 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 + from OCP.BRepGProp import BRepGProp + from OCP.GProp import GProp_GProps s = shape.wrapped if hasattr(shape, "wrapped") else shape props = GProp_GProps() @@ -805,7 +877,7 @@ def intersect_solids_with_box(solids: Iterable[Any], box: CadShape) -> CadShape: Returns a :class:`CadShape` wrapping a compound (possibly empty). """ require_cad() - from OCP.BRepAlgoAPI import BRepAlgoAPI_Common # noqa: PLC0415 + from OCP.BRepAlgoAPI import BRepAlgoAPI_Common parts: list[Any] = [] for solid in solids: diff --git a/microgen/operations.py b/microgen/operations.py index e131c74a..00061a44 100644 --- a/microgen/operations.py +++ b/microgen/operations.py @@ -150,9 +150,9 @@ def _unify_solids(shape: Any) -> CadShape: upgrader = ShapeUpgrade_UnifySameDomain( shape, - True, # unify edges - True, # unify faces - True, # concat bsplines + True, # unify edges + True, # unify faces + True, # concat bsplines ) upgrader.Build() return CadShape(upgrader.Shape()) diff --git a/microgen/periodic.py b/microgen/periodic.py index f59fb756..cf404465 100644 --- a/microgen/periodic.py +++ b/microgen/periodic.py @@ -145,7 +145,9 @@ def _intersect_face( base = _base_pnt(face, rve) inside_solids = select_solids_on_side(partitions[face], base, _SIDE_DIR[face]) outside_solids = select_solids_on_side( - partitions[face], base, tuple(-d for d in _SIDE_DIR[face]), + partitions[face], + base, + tuple(-d for d in _SIDE_DIR[face]), ) tr = _translate(face, rve) translated = [translate_solid(s, tr) for s in outside_solids] @@ -172,29 +174,37 @@ def _intersect_edge( # (++) — inside both half-spaces, no translation p0_inside = select_solids_on_side(partitions[f_0], base_0, _SIDE_DIR[f_0]) split_pp = split_shape( - make_compound_from_solids(p0_inside), rve_planes[f_1], + make_compound_from_solids(p0_inside), + rve_planes[f_1], ) pp = select_solids_on_side(split_pp, base_1, _SIDE_DIR[f_1]) # (+−) — inside first, outside second: translate by tr_1 pm = select_solids_on_side( - split_pp, base_1, tuple(-d for d in _SIDE_DIR[f_1]), + split_pp, + base_1, + tuple(-d for d in _SIDE_DIR[f_1]), ) pm_t = [translate_solid(s, tr_1) for s in pm] # Split the "outside first" half by the second plane to get (-+) and (--) p0_outside = select_solids_on_side( - partitions[f_0], base_0, tuple(-d for d in _SIDE_DIR[f_0]), + partitions[f_0], + base_0, + tuple(-d for d in _SIDE_DIR[f_0]), ) split_m = split_shape( - make_compound_from_solids(p0_outside), rve_planes[f_1], + make_compound_from_solids(p0_outside), + rve_planes[f_1], ) # (-+) — outside first, inside second: translate by tr_0 mp = select_solids_on_side(split_m, base_1, _SIDE_DIR[f_1]) mp_t = [translate_solid(s, tr_0) for s in mp] # (--) — outside both: translate by tr_0 + tr_1 mm = select_solids_on_side( - split_m, base_1, tuple(-d for d in _SIDE_DIR[f_1]), + split_m, + base_1, + tuple(-d for d in _SIDE_DIR[f_1]), ) tslt = (tr_0[0] + tr_1[0], tr_0[1] + tr_1[1], tr_0[2] + tr_1[2]) mm_t = [translate_solid(s, tslt) for s in mm] @@ -235,17 +245,26 @@ def neg(d: tuple[int, int, int]) -> tuple[int, int, int]: results: list[CadShape] = [] # Branch by sign of f_0 side - for sign_0, tr_x in ((_SIDE_DIR[f_0], (0.0, 0.0, 0.0)), (neg(_SIDE_DIR[f_0]), tr_0)): + for sign_0, tr_x in ( + (_SIDE_DIR[f_0], (0.0, 0.0, 0.0)), + (neg(_SIDE_DIR[f_0]), tr_0), + ): p0 = select_solids_on_side(partitions[f_0], base_0, sign_0) split_1 = split_shape(make_compound_from_solids(p0), rve_planes[f_1]) # Branch by sign of f_1 side - for sign_1, tr_y in ((_SIDE_DIR[f_1], (0.0, 0.0, 0.0)), (neg(_SIDE_DIR[f_1]), tr_1)): + for sign_1, tr_y in ( + (_SIDE_DIR[f_1], (0.0, 0.0, 0.0)), + (neg(_SIDE_DIR[f_1]), tr_1), + ): p1 = select_solids_on_side(split_1, base_1, sign_1) split_2 = split_shape(make_compound_from_solids(p1), rve_planes[f_2]) # Branch by sign of f_2 side - for sign_2, tr_z in ((_SIDE_DIR[f_2], (0.0, 0.0, 0.0)), (neg(_SIDE_DIR[f_2]), tr_2)): + for sign_2, tr_z in ( + (_SIDE_DIR[f_2], (0.0, 0.0, 0.0)), + (neg(_SIDE_DIR[f_2]), tr_2), + ): p2 = select_solids_on_side(split_2, base_2, sign_2) shift = add(tr_x, tr_y, tr_z) if shift == (0.0, 0.0, 0.0): diff --git a/microgen/shape/box.py b/microgen/shape/box.py index 37dcddca..1b154605 100644 --- a/microgen/shape/box.py +++ b/microgen/shape/box.py @@ -63,7 +63,7 @@ def __init__( def generate(self: Box, **_: KwargsGenerateType) -> CadShape: """Generate a box CAD shape (OCCT). Requires the ``[cad]`` extra.""" - from microgen.cad import make_box # noqa: PLC0415 + from microgen.cad import make_box shape = make_box(self.dim, self.center) return rotate(shape, self.center, self.orientation) diff --git a/microgen/shape/capsule.py b/microgen/shape/capsule.py index 82ca015f..fdd22913 100644 --- a/microgen/shape/capsule.py +++ b/microgen/shape/capsule.py @@ -47,7 +47,7 @@ def __init__( def generate(self: Capsule, **_: KwargsGenerateType) -> CadShape: """Generate a capsule CAD shape (OCCT). Requires the ``[cad]`` extra.""" - from microgen.cad import make_capsule # noqa: PLC0415 + from microgen.cad import make_capsule shape = make_capsule( radius=self.radius, diff --git a/microgen/shape/cylinder.py b/microgen/shape/cylinder.py index 8a2972dd..753eed7b 100644 --- a/microgen/shape/cylinder.py +++ b/microgen/shape/cylinder.py @@ -47,7 +47,7 @@ def __init__( def generate(self: Cylinder, **_: KwargsGenerateType) -> CadShape: """Generate a cylinder CAD shape (OCCT). Requires the ``[cad]`` extra.""" - from microgen.cad import make_cylinder # noqa: PLC0415 + from microgen.cad import make_cylinder shape = make_cylinder( radius=self.radius, diff --git a/microgen/shape/ellipsoid.py b/microgen/shape/ellipsoid.py index 6cf49086..29f7e500 100644 --- a/microgen/shape/ellipsoid.py +++ b/microgen/shape/ellipsoid.py @@ -65,7 +65,7 @@ def __init__( def generate(self: Ellipsoid, **_: KwargsGenerateType) -> CadShape: """Generate an ellipsoid CAD shape (OCCT). Requires the ``[cad]`` extra.""" - from microgen.cad import make_ellipsoid # noqa: PLC0415 + from microgen.cad import make_ellipsoid shape = make_ellipsoid(radii=self.radii, center=self.center) return rotate(shape, self.center, self.orientation) diff --git a/microgen/shape/extruded_polygon.py b/microgen/shape/extruded_polygon.py index 2c667adc..15274195 100644 --- a/microgen/shape/extruded_polygon.py +++ b/microgen/shape/extruded_polygon.py @@ -68,7 +68,7 @@ def __init__( def generate(self: ExtrudedPolygon, **_: KwargsGenerateType) -> CadShape: """Generate an extruded polygon CAD shape (OCCT). Requires the ``[cad]`` extra.""" - from microgen.cad import make_extruded_polygon # noqa: PLC0415 + from microgen.cad import make_extruded_polygon shape = make_extruded_polygon( list_corners=self.list_corners, diff --git a/microgen/shape/polyhedron.py b/microgen/shape/polyhedron.py index c819e694..aafcccb4 100644 --- a/microgen/shape/polyhedron.py +++ b/microgen/shape/polyhedron.py @@ -77,7 +77,7 @@ def __init__( def generate(self: Polyhedron, **_: KwargsGenerateType) -> CadShape: """Generate a polyhedron CAD shape (OCCT). Requires the ``[cad]`` extra.""" - from microgen.cad import make_polyhedron # noqa: PLC0415 + from microgen.cad import make_polyhedron shape = make_polyhedron( vertices=self.dic["vertices"], diff --git a/microgen/shape/shape.py b/microgen/shape/shape.py index 36906b41..e863a314 100644 --- a/microgen/shape/shape.py +++ b/microgen/shape/shape.py @@ -200,7 +200,7 @@ def generate( err_msg = "No implicit field defined — subclasses must override generate()" raise NotImplementedError(err_msg) - from microgen.cad import mesh_to_shape # noqa: PLC0415 + from microgen.cad import mesh_to_shape mesh = self.generate_vtk(bounds=bounds, resolution=resolution) if mesh.n_cells == 0: diff --git a/microgen/shape/sphere.py b/microgen/shape/sphere.py index a130f9a7..aaa351b0 100644 --- a/microgen/shape/sphere.py +++ b/microgen/shape/sphere.py @@ -43,7 +43,7 @@ def __init__( def generate(self: Sphere, **_: KwargsGenerateType) -> CadShape: """Generate a sphere CAD shape (OCCT). Requires the ``[cad]`` extra.""" - from microgen.cad import make_sphere # noqa: PLC0415 + from microgen.cad import make_sphere return make_sphere(self.radius, self.center) diff --git a/microgen/shape/strut_lattice/abstract_lattice.py b/microgen/shape/strut_lattice/abstract_lattice.py index 61fbb70d..1bbce309 100644 --- a/microgen/shape/strut_lattice/abstract_lattice.py +++ b/microgen/shape/strut_lattice/abstract_lattice.py @@ -9,7 +9,7 @@ from abc import abstractmethod from pathlib import Path from tempfile import NamedTemporaryFile -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import numpy as np import numpy.typing as npt diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 9c0aba4d..c789c145 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -676,8 +676,8 @@ def _mesh_to_shell(self: Tpms, mesh: pv.PolyData) -> CadShape: with real surface geometry — required because downstream callers may use the result as a cutting tool in boolean ops. """ - from microgen.cad import ShellCreationError as _CadShellError # noqa: PLC0415 - from microgen.cad import mesh_to_shell_brep # noqa: PLC0415 + from microgen.cad import ShellCreationError as _CadShellError + from microgen.cad import mesh_to_shell_brep if not mesh.is_all_triangles: mesh.triangulate(inplace=True) @@ -869,11 +869,12 @@ def _try_make_solid(shape: CadShape) -> CadShape: (multiple disjoint shells, can't be a single solid) or if OCCT refuses the conversion. """ - from microgen.cad import CadShape as _CadShape # noqa: PLC0415 - from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid # noqa: PLC0415 - from OCP.TopAbs import TopAbs_SHELL # noqa: PLC0415 - from OCP.TopExp import TopExp_Explorer # noqa: PLC0415 - from OCP.TopoDS import TopoDS # noqa: PLC0415 + from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid + from OCP.TopAbs import TopAbs_SHELL + from OCP.TopExp import TopExp_Explorer + from OCP.TopoDS import TopoDS + + from microgen.cad import CadShape as _CadShape wrapped = shape.wrapped # Already a Shell? Try to make a Solid directly. diff --git a/pyproject.toml b/pyproject.toml index bf485ea0..d24976d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,6 +110,7 @@ ignore = [ "ANN002", # missing type hint on *args "ANN003", # missing **kwargs annotation "ANN202", # missing return-type on private function + "ANN401", # typing.Any — needed for raw OCCT TopoDS_Shape passthrough "PLC0415", # late imports needed to avoid circular deps "N806", # capitalised local variables (T, N, B for tangent/normal/binormal) "PLR2004", # magic-value comparison — too noisy in scientific code @@ -127,6 +128,10 @@ ignore = [ "TID252", # relative imports are the project convention "FBT001", # boolean positional argument "FBT002", # boolean default-value + "FBT003", # boolean positional value — third-party OCP/CadQuery API + "COM812", # trailing comma — conflicts with ruff-format + "PYI034", # __new__ return type — _Centre/_BBox use tuple subclassing + "D301", # raw docstring with backslashes — needed for LaTeX in docs "TC003", # typing-only imports — minor "RET504", # unnecessary assignment before return "PERF401", # list comprehension — readability tradeoff From 1aabe3d25e14d8e70ea4e6f8f2a4ebea32b9b6cd Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Mon, 27 Apr 2026 10:40:30 +0200 Subject: [PATCH 14/15] Replace cadquery usage with microgen.cad helpers Refactor examples to use microgen.cad helpers instead of importing cadquery directly: replace imports and usage with make_compound, make_compound_from_solids, import_step, make_box, and use shape.export_step / export_stl methods. Add import_step implementation to microgen.cad to load STEP files (merging multi-root STEP into a compound via OCP). Update docs workflow to install the [cad] extra (cadquery-ocp) so Sphinx-executed examples can render, and remove Python 3.14 from the build-and-test matrix due to missing vtk PyPI wheels (3.14 is covered via the conda-based integration job). These changes consolidate CAD operations behind microgen.cad and remove direct CadQuery usage across example scripts. --- .github/workflows/build-and-test.yml | 4 +++- .github/workflows/build-docs.yml | 6 ++++-- .../rasterEllipsoid/rasterEllipsoid.py | 7 +++---- .../3Doperations/repeatShape/repeatShape.py | 9 +++------ examples/3Doperations/voronoi/voronoi.py | 7 +++---- .../voronoiGyroid/voronoiGyroid.py | 7 +++---- examples/BasicShapes/platon/platon.py | 7 +++---- examples/BasicShapes/shapes/shapes.py | 10 +++++----- examples/Fibers/fibers.py | 13 ++++--------- examples/Lattices/custom_lattice.py | 3 +-- examples/Lattices/honeycomb/honeycomb.py | 5 ++--- examples/Lattices/octetTruss/octetTruss.py | 8 ++++---- examples/Mesh/gyroid/gyroid_step_remesh.py | 6 ++---- examples/TPMS/tpmsShell/tpmsShell.py | 16 ++++++++++------ microgen/cad.py | 18 ++++++++++++++++++ 15 files changed, 68 insertions(+), 58 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 6bb47298..06c5af69 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -24,7 +24,9 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest", "macos-latest"] - python-version: ["3.10", "3.12", "3.13", "3.14"] + # vtk has no PyPI wheel for 3.14 yet (only conda-forge); the + # integration job below covers 3.14 via the conda route. + python-version: ["3.10", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 67d91613..2b5e8abe 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -35,8 +35,10 @@ jobs: - name: Install libGLU run: sudo apt-get install -y libglu1-mesa - - name: Install package with docs dependencies - run: pip install .[docs] + - name: Install package with docs and CAD dependencies + # Sphinx jupyter-execute cells render Shape.generate() / Tpms.generate() + # examples, which require the OCP backend (``cadquery-ocp``). + run: pip install .[docs,cad] - name: Build docs run: make -C docs html diff --git a/examples/3Doperations/rasterEllipsoid/rasterEllipsoid.py b/examples/3Doperations/rasterEllipsoid/rasterEllipsoid.py index 57123234..c2298b52 100644 --- a/examples/3Doperations/rasterEllipsoid/rasterEllipsoid.py +++ b/examples/3Doperations/rasterEllipsoid/rasterEllipsoid.py @@ -1,8 +1,7 @@ from pathlib import Path -import cadquery as cq - from microgen import Ellipsoid, Phase, Rve, mesh, rasterPhase +from microgen.cad import make_compound_from_solids rve = Rve(dim=1) @@ -11,11 +10,11 @@ raster = rasterPhase(phase=Phase(shape=elli), rve=rve, grid=[5, 5, 5]) -compound = cq.Compound.makeCompound( +compound = make_compound_from_solids( [solid for phase in raster for solid in phase.solids] ) step_file = str(Path(__file__).parent / "compound.step") -cq.exporters.export(compound, step_file) +compound.export_step(step_file) vtk_file = str(Path(__file__).parent / "rasterEllipsoid.vtk") mesh( diff --git a/examples/3Doperations/repeatShape/repeatShape.py b/examples/3Doperations/repeatShape/repeatShape.py index fffe0250..476f6c54 100644 --- a/examples/3Doperations/repeatShape/repeatShape.py +++ b/examples/3Doperations/repeatShape/repeatShape.py @@ -1,17 +1,14 @@ from pathlib import Path -import cadquery as cq - from microgen import Rve, repeatShape +from microgen.cad import import_step rve = Rve(dim=1) step_file = str(Path(__file__).parent / "octettruss.step") -unit_geom = cq.importers.importStep(step_file) - -unit_geom = cq.Shape(unit_geom.val().wrapped) +unit_geom = import_step(step_file) shape = repeatShape(unit_geom, rve, grid=(5, 5, 5)) stl_file = str(Path(__file__).parent / "repeated_shape.stl") -cq.exporters.export(shape, stl_file) +shape.export_stl(stl_file) diff --git a/examples/3Doperations/voronoi/voronoi.py b/examples/3Doperations/voronoi/voronoi.py index 966fced1..082752e5 100644 --- a/examples/3Doperations/voronoi/voronoi.py +++ b/examples/3Doperations/voronoi/voronoi.py @@ -1,8 +1,7 @@ from pathlib import Path -import cadquery as cq - from microgen import Neper, Phase, mesh +from microgen.cad import make_compound # # We import the Polyhedra from Neper tessellation file # listPolyhedra, seed, vertices, edges, faces, polys = parseNeper("test1") @@ -37,9 +36,9 @@ shapes = [poly.generate() for poly in polyhedra] -compound = cq.Compound.makeCompound(shapes) +compound = make_compound(shapes) step_file = str(Path(__file__).parent / "compound.step") -cq.exporters.export(compound, step_file) +compound.export_step(step_file) phases = [Phase(shape=shape) for shape in shapes] diff --git a/examples/3Doperations/voronoiGyroid/voronoiGyroid.py b/examples/3Doperations/voronoiGyroid/voronoiGyroid.py index 4eb33a8d..4ad7136a 100644 --- a/examples/3Doperations/voronoiGyroid/voronoiGyroid.py +++ b/examples/3Doperations/voronoiGyroid/voronoiGyroid.py @@ -1,8 +1,7 @@ from pathlib import Path -import cadquery as cq - from microgen import Neper, Phase, Tpms, mesh +from microgen.cad import make_compound from microgen.shape import surface_functions # We import the Polyhedra from Neper tessellation file @@ -21,9 +20,9 @@ shape = polyhedron.generate() phases.append(Phase(shape=shape.intersect(gyroid))) -compound = cq.Compound.makeCompound([phase.shape for phase in phases]) +compound = make_compound([phase.shape for phase in phases]) step_file = str(Path(__file__).parent / "compound.step") -cq.exporters.export(compound, step_file) +compound.export_step(step_file) vtk_file = str(Path(__file__).parent / "Gyroid-voro.vtk") mesh( diff --git a/examples/BasicShapes/platon/platon.py b/examples/BasicShapes/platon/platon.py index 6d2fbc60..279a1c00 100644 --- a/examples/BasicShapes/platon/platon.py +++ b/examples/BasicShapes/platon/platon.py @@ -1,8 +1,7 @@ from pathlib import Path -import cadquery as cq - import microgen +from microgen.cad import make_compound filenames = [ "tetrahedron.obj", @@ -19,9 +18,9 @@ dic = microgen.shape.polyhedron.read_obj(filename) solid = microgen.shape.polyhedron.Polyhedron(dic=dic) shape = solid.generate() - shape = shape.translate(cq.Vector(-6 + 3 * i, 0, 0)) + shape = shape.translate((-6 + 3 * i, 0, 0)) platon_solids.append(shape) i += 1 stl_file = str(Path(__file__).parent / "platon.stl") -cq.exporters.export(cq.Compound.makeCompound(platon_solids), stl_file) +make_compound(platon_solids).export_stl(stl_file) diff --git a/examples/BasicShapes/shapes/shapes.py b/examples/BasicShapes/shapes/shapes.py index 01f051ce..7135ce66 100644 --- a/examples/BasicShapes/shapes/shapes.py +++ b/examples/BasicShapes/shapes/shapes.py @@ -1,8 +1,8 @@ from pathlib import Path -import cadquery as cq import numpy as np +from microgen.cad import make_compound from microgen.shape import newGeometry shapes = { @@ -26,7 +26,7 @@ } -assembly = cq.Assembly() +parts = [] n_col = 3 n_row = np.ceil(len(shapes) / n_col) @@ -40,9 +40,9 @@ orientation=(90, 90, 90), param_geom=param_geom, ) - assembly.add(elem.generate()) + parts.append(elem.generate()) i = i + 1 -compound = assembly.toCompound() +compound = make_compound(parts) stl_file = str(Path(__file__).parent / "shapes.stl") -cq.exporters.export(compound, stl_file) +compound.export_stl(stl_file) diff --git a/examples/Fibers/fibers.py b/examples/Fibers/fibers.py index 492f36de..87bb8e16 100644 --- a/examples/Fibers/fibers.py +++ b/examples/Fibers/fibers.py @@ -1,9 +1,9 @@ from pathlib import Path -import cadquery as cq import progressbar # pip install progressbar2 from microgen import Cylinder +from microgen.cad import make_compound def read_csv(filename): @@ -58,17 +58,12 @@ def convert_angles(phi, theta): shapes.append(elem.generate()) bar.update(i) -assemb = cq.Assembly() -for shape in shapes: - assemb.add(shape) - - -compound = assemb.toCompound() +compound = make_compound(shapes) step_file = str(Path(__file__).parent / "compound.step") stl_file = str(Path(__file__).parent / "compound.stl") -cq.exporters.export(compound, step_file) -cq.exporters.export(compound, stl_file) +compound.export_step(step_file) +compound.export_stl(stl_file) # mesh(mesh_file='compound.step', listPhases=raster[1], size=0.03, order=1, output_file='Mesh.msh') diff --git a/examples/Lattices/custom_lattice.py b/examples/Lattices/custom_lattice.py index fc468f1b..8a2525c6 100644 --- a/examples/Lattices/custom_lattice.py +++ b/examples/Lattices/custom_lattice.py @@ -1,7 +1,6 @@ from itertools import product from pathlib import Path -import cadquery as cq import numpy as np from microgen import CustomLattice @@ -95,4 +94,4 @@ shape = auxetic_lattice.generate() stl_file = Path(__file__).parent / "auxetic_custom_lattice.stl" -cq.exporters.export(shape, stl_file.name) +shape.export_stl(stl_file.name) diff --git a/examples/Lattices/honeycomb/honeycomb.py b/examples/Lattices/honeycomb/honeycomb.py index fda05314..e4a146e7 100644 --- a/examples/Lattices/honeycomb/honeycomb.py +++ b/examples/Lattices/honeycomb/honeycomb.py @@ -1,6 +1,5 @@ from pathlib import Path -import cadquery as cq import numpy as np from microgen import Box, ExtrudedPolygon, Phase, cutPhaseByShapeList, mesh @@ -45,8 +44,8 @@ step_file = str(Path(__file__).parent / "honeycomb.step") stl_file = str(Path(__file__).parent / "honeycomb.stl") -cq.exporters.export(honeycomb.shape, step_file) -cq.exporters.export(honeycomb.shape, stl_file) +honeycomb.shape.export_step(step_file) +honeycomb.shape.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 f36857b0..a1744008 100644 --- a/examples/Lattices/octetTruss/octetTruss.py +++ b/examples/Lattices/octetTruss/octetTruss.py @@ -1,9 +1,9 @@ from pathlib import Path -import cadquery as cq import numpy as np from microgen import Cylinder, Phase, Rve, cutPhases, meshPeriodic, periodic +from microgen.cad import make_compound # ----------LOADTXT------------------------------------------------------------------------------------------# @@ -71,12 +71,12 @@ listPeriodicPhases.append(periodicPhase) phases_cut = cutPhases(phaseList=listPeriodicPhases, reverseOrder=False) -compound = cq.Compound.makeCompound([phase.shape for phase in phases_cut]) +compound = make_compound([phase.shape for phase in phases_cut]) step_file = str(Path(__file__).parent / "octettruss.step") stl_file = str(Path(__file__).parent / "octettruss.stl") -cq.exporters.export(compound, step_file) -cq.exporters.export(compound, stl_file) +compound.export_step(step_file) +compound.export_stl(stl_file) vtk_file = str(Path(__file__).parent / "octettruss.vtk") meshPeriodic( diff --git a/examples/Mesh/gyroid/gyroid_step_remesh.py b/examples/Mesh/gyroid/gyroid_step_remesh.py index 109771ee..d3f6ad9b 100755 --- a/examples/Mesh/gyroid/gyroid_step_remesh.py +++ b/examples/Mesh/gyroid/gyroid_step_remesh.py @@ -16,8 +16,7 @@ - remeshed_gyroid_mesh.vtk (optional): Quality-improved periodic mesh for FEM. Dependencies: -- microgen -- cadquery +- microgen (with the [cad] extra: cadquery-ocp via microgen.cad) - pyvista """ @@ -25,7 +24,6 @@ from microgen import Tpms, Phase, meshPeriodic, Rve from microgen.remesh import remesh_keeping_periodicity_for_fem from microgen.shape.surface_functions import gyroid -import cadquery as cq import pyvista as pv # 1. Generate a TPMS geometry using the gyroid surface function. @@ -42,7 +40,7 @@ # 3. Export the geometry as a STEP file. step_file = str(Path(__file__).parent / "gyroid.step") -cq.exporters.export(phases[0].shape, step_file) +phases[0].shape.export_step(step_file) # 4. Import the STEP file and create a mesh with periodic constraints and export as VTK. diff --git a/examples/TPMS/tpmsShell/tpmsShell.py b/examples/TPMS/tpmsShell/tpmsShell.py index b7a2a7d5..89d4ad3b 100755 --- a/examples/TPMS/tpmsShell/tpmsShell.py +++ b/examples/TPMS/tpmsShell/tpmsShell.py @@ -1,11 +1,15 @@ from pathlib import Path -import cadquery as cq - from microgen import Tpms, surface_functions +from microgen.cad import make_box, make_compound -shell = cq.Workplane("front").box(3, 3, 3).faces("+Z or -X or +X").shell(0.1) -shell = shell.val() +# Outer 3x3x3 box, hollowed by subtracting an inner 2.8x2.8x2.8 cavity — +# replaces the CadQuery ``Workplane.shell`` API. The face-selective version +# ("open on +Z, -X, +X") could be reproduced with ``BRepOffsetAPI_MakeThickSolid`` +# via OCP, but a plain symmetric shell is enough for this demo. +outer = make_box((3.0, 3.0, 3.0), (0.0, 0.0, 0.0)) +inner = make_box((2.8, 2.8, 2.8), (0.0, 0.0, 0.0)) +shell = outer.cut(inner) geometry = Tpms( surface_function=surface_functions.gyroid, @@ -15,6 +19,6 @@ ) shape = geometry.generate(type_part="sheet", smoothing=0) -compound = cq.Compound.makeCompound([shell, shape]) +compound = make_compound([shell, shape]) stl_file = str(Path(__file__).parent / "tpms_shell.stl") -cq.exporters.export(compound, stl_file) +compound.export_stl(stl_file) diff --git a/microgen/cad.py b/microgen/cad.py index 5eabe55c..95e69f7e 100644 --- a/microgen/cad.py +++ b/microgen/cad.py @@ -363,6 +363,24 @@ def export_brep(self, path: str | Path) -> None: 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) # --------------------------------------------------------------------------- From 477fc3cdbb246fcd41e5f450d422336b18d81cde Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Mon, 27 Apr 2026 10:54:20 +0200 Subject: [PATCH 15/15] Add mmgpy support and remesher refactor Introduce mmgpy-based remeshing support: add mmgpy to environment.yml and pyproject extras and include it in the `all` extras. Refactor microgen.external.Mmg to call mmgpy bindings instead of spawning mmg CLI binaries, adding a lazy importer (_require_mmgpy), option translation (_build_mmg_options), and a wrapper to invoke mmgpy remesh functions (_run_mmg). Preserve backward-compatible CLI signatures and keep the legacy subprocess hook for compatibility; update docs and error messages with installation hints for mmgpy. --- environment.yml | 2 +- microgen/external.py | 488 +++++++++++++++++++++++-------------------- pyproject.toml | 6 +- 3 files changed, 272 insertions(+), 224 deletions(-) diff --git a/environment.yml b/environment.yml index 8fec1c0e..7ffc082e 100755 --- a/environment.yml +++ b/environment.yml @@ -16,7 +16,7 @@ dependencies: - python-gmsh - meshio - ocp # OCP (OCCT Python binding) — enables the CAD path + - mmgpy # MMG remeshing (Python bindings, bundles MMG natives) - pip: - autograd - - mmg # - neper diff --git a/microgen/external.py b/microgen/external.py index f3acf619..8d316c15 100644 --- a/microgen/external.py +++ b/microgen/external.py @@ -2,6 +2,8 @@ Functions related to external software - `Neper - Polycrystal Generation and Meshing`_ - `mmg - Robust, Open-source & Multidisciplinary Software for Remeshing`_ + (via the ``mmgpy`` Python bindings — install with + ``pip install mmgpy`` or ``conda install -c conda-forge mmgpy``) .. _Neper - Polycrystal Generation and Meshing: https://neper.info/ .. _mmg - Robust, Open-source & Multidisciplinary Software for Remeshing: https://www.mmgtools.org/ @@ -10,11 +12,27 @@ from __future__ import annotations import subprocess +from typing import Any import numpy as np from microgen.shape import Polyhedron +_MMGPY_INSTALL_HINT = ( + "microgen's MMG remeshing path requires mmgpy. " + "Install it with: pip install mmgpy " + "or: conda install -c conda-forge mmgpy" +) + + +def _require_mmgpy() -> Any: + """Import :mod:`mmgpy` lazily and surface a helpful error otherwise.""" + try: + import mmgpy # noqa: PLC0415 + except ImportError as err: + raise ImportError(_MMGPY_INSTALL_HINT) from err + return mmgpy + class Neper: @staticmethod @@ -485,14 +503,143 @@ def parseNeper(filename: str) -> tuple: class MmgError(Exception): - """Raised when Mmg command fails""" + """Raised when an Mmg invocation fails.""" ... +# CLI flag → mmgpy option-key translation table. The CLI flag names below +# match those of the historical subprocess ``Mmg`` wrapper so the public +# Python API of this module is unchanged: callers keep passing the same +# keyword arguments, and we forward them as a typed options dict to mmgpy. +# +# A handful of CLI-only flags have **no library equivalent** and are dropped: +# * ``-d`` / ``-h`` / ``-default`` / ``-val`` — debug/help switches +# * ``-3dMedit`` — mmg2d Medit format toggle +# +# Two flags map to **non-1:1** option keys: +# * ``-nr`` → ``angle=0`` (disable ridge detection) +# * ``-A`` → ``anisosize=1`` (request anisotropic adaptation) + + +def _build_mmg_options( + flag_kwargs: dict[str, Any], + *, + cli_unsupported: tuple[str, ...] = (), +) -> dict[str, Any]: + """Translate Mmg CLI-style kwargs into an ``mmgpy`` options dict. + + Boolean flags map to ``1`` when truthy. Numeric flags pass through. + Unsupported flags are silently ignored (matching the historical + behaviour where unused CLI switches were simply omitted from argv). + """ + opts: dict[str, Any] = {} + + # Numeric / value-carrying flags. + for key in ("hausd", "hgrad", "hmax", "hmin", "hsiz", "rmc", "ar"): + v = flag_kwargs.get(key) + if v is not None and v is not False: + opts[key] = float(v) + + for key in ("nreg", "nsd", "octree", "v", "m"): + v = flag_kwargs.get(key) + if v is not None and v is not False: + # ``v`` and ``m`` were typed as bool in the old wrapper too — + # default them to 1 when explicitly enabled. + opts["verbose" if key == "v" else "mem" if key == "m" else key] = ( + 1 if v is True else int(v) + ) + + # ``lag`` and ``ls`` accept 0 as a meaningful value, so guard truthiness. + lag = flag_kwargs.get("lag") + if lag is not None and lag is not False: + opts["lag"] = 0 if lag is True else int(lag) + ls = flag_kwargs.get("ls") + if ls is not None and ls is not False: + opts["ls"] = 0.0 if ls is True else float(ls) + + # Boolean / presence-only flags. + for key in ( + "noinsert", + "nomove", + "nosurf", + "noswap", + "optim", + "optimLES", + "opnbdy", + "nofem", + ): + if flag_kwargs.get(key): + opts[key] = 1 + + # CLI synonyms with renamed option keys. + if flag_kwargs.get("A"): + opts["anisosize"] = 1 + if flag_kwargs.get("rn"): + opts["renum"] = 1 + if flag_kwargs.get("nr"): + # ``-nr`` disables ridge detection: in the library this is + # exposed as ``angle = 0`` (set the ridge-detection angle to 0°). + opts["angle"] = 0 + + for unsupported in cli_unsupported: + if flag_kwargs.get(unsupported): + # Silent skip — the flag was never used inside microgen and + # mmgpy has no equivalent option key. + pass + + return opts + + +def _run_mmg( + binding_name: str, + *, + input_mesh: str | None, + output_mesh: str | None, + solution: str | None, + metric: str | None, + options: dict[str, Any], +) -> None: + """Invoke ``mmgpy.{mmg2d,mmgs,mmg3d}.remesh`` and re-raise as MmgError. + + The ``input_mesh is None`` guard runs *before* :func:`_require_mmgpy` so + the historical "no-args raises MmgError" contract is preserved even on + machines without mmgpy installed. + """ + if input_mesh is None: + err_msg = f"Mmg.{binding_name} requires an input mesh path (input=...)" + raise MmgError(err_msg) + + mmgpy = _require_mmgpy() + binding = getattr(mmgpy, binding_name) + try: + binding.remesh( + input_mesh=input_mesh, + input_sol=solution, + input_met=metric, + output_mesh=output_mesh, + options=options, + ) + except Exception as err: # noqa: BLE001 + raise MmgError(f"mmg invocation failed: {err}") from err + + class Mmg: + """Thin wrapper around :mod:`mmgpy` with a CLI-style keyword interface. + + Each static method preserves the keyword-argument names of the original + subprocess wrapper (``input``, ``output``, ``hausd``, ``hgrad``, …) so + existing call sites keep working. Internally we translate to an + ``mmgpy`` options dict and call :func:`mmgpy.mmg{2d,s,3d}.remesh`. + """ + @staticmethod - def _run_mmg_command(cmd: list[str]): + def _run_mmg_command(cmd: list[str]) -> None: + """Legacy hook — kept for backwards compatibility with old tests. + + Wraps :mod:`subprocess` exactly as the original implementation did, + but is no longer used by :meth:`mmg2d` / :meth:`mmgs` / :meth:`mmg3d`. + """ try: subprocess.check_output(cmd, stderr=subprocess.STDOUT) except (subprocess.CalledProcessError, FileNotFoundError) as error: @@ -532,82 +679,46 @@ def mmg2d( opnbdy=None, rmc=None, ): + """Run mmg2d on ``input`` writing to ``output``. + + Forwards to :func:`mmgpy.mmg2d.remesh`. CLI-only flags + (``d``, ``h``, ``val``, ``default``, ``_3dMedit``) are accepted for + signature compatibility but have no effect. """ - Runs mmg2d_O3 from the command line with given arguments - """ - cmd = ["mmg2d_O3"] - if d: - cmd.append("-d") - if h: - cmd.append("-h") - if m: - if isinstance(m, bool): - m = "" - cmd.extend(["-m", str(m)]) - if v: - if isinstance(v, bool): - v = 1 - cmd.extend(["-v", str(v)]) - if val: - cmd.append("-val") - if default: - cmd.append("-default") - if input: - cmd.extend(["-in", input]) - if output: - cmd.extend(["-out", output]) - if solution: - cmd.extend(["-sol", solution]) - if metric: - cmd.extend(["-met", metric]) - if A: - cmd.append("-A") - if ar: - cmd.extend(["-ar", str(ar)]) - if hausd: - cmd.extend(["-hausd", str(hausd)]) - if hgrad: - cmd.extend(["-hgrad", str(hgrad)]) - if hmax: - cmd.extend(["-hmax", str(hmax)]) - if hmin: - cmd.extend(["-hmin", str(hmin)]) - if hsiz: - cmd.extend(["-hsiz", str(hsiz)]) - if lag or lag == 0: - if isinstance(lag, bool): - lag = 0 - cmd.extend(["-lag", str(lag)]) - if ls or ls == 0: - ls_value = ls - if isinstance(ls_value, bool): - ls_value = 0 - cmd.extend(["-ls", str(ls_value)]) - if _3dMedit: - cmd.extend(["-3dMedit", str(_3dMedit)]) - - if noinsert: - cmd.append("-noinsert") - if nomove: - cmd.append("-nomove") - if nosurf: - cmd.append("-nosurf") - if noswap: - cmd.append("-noswap") - if nr: - cmd.append("-nr") - if nreg: - cmd.extend(["-nreg", str(nreg)]) - if nsd: - cmd.extend(["-nsd", str(nsd)]) - if optim: - cmd.append("-optim") - if opnbdy: - cmd.append("-opnbdy") - if rmc: - cmd.extend(["-rmc", str(rmc)]) - - Mmg._run_mmg_command(cmd) + opts = _build_mmg_options( + { + "m": m, + "v": v, + "A": A, + "ar": ar, + "hausd": hausd, + "hgrad": hgrad, + "hmax": hmax, + "hmin": hmin, + "hsiz": hsiz, + "lag": lag, + "ls": ls, + "noinsert": noinsert, + "nomove": nomove, + "nosurf": nosurf, + "noswap": noswap, + "nr": nr, + "nreg": nreg, + "nsd": nsd, + "optim": optim, + "opnbdy": opnbdy, + "rmc": rmc, + }, + cli_unsupported=("d", "h", "val", "default", "_3dMedit"), + ) + _run_mmg( + "mmg2d", + input_mesh=input, + output_mesh=output, + solution=solution, + metric=metric, + options=opts, + ) @staticmethod def mmgs( @@ -639,72 +750,43 @@ def mmgs( optim=None, rn=None, ): + """Run mmgs (surface remesher) on ``input`` writing to ``output``. + + Forwards to :func:`mmgpy.mmgs.remesh`. CLI-only flags + (``d``, ``h``, ``val``, ``default``) are accepted but have no effect. """ - Runs mmgs_O3 from the command line with given arguments - """ - cmd = ["mmgs_O3"] - if d: - cmd.append("-d") - if h: - cmd.append("-h") - if m: - if isinstance(m, bool): - m = "" - cmd.extend(["-m", str(m)]) - if v: - if isinstance(v, bool): - v = 1 - cmd.extend(["-v", str(v)]) - if val: - cmd.append("-val") - if default: - cmd.append("-default") - if input: - cmd.extend(["-in", input]) - if output: - cmd.extend(["-out", output]) - if solution: - cmd.extend(["-sol", solution]) - if metric: - cmd.extend(["-met", metric]) - if A: - cmd.append("-A") - if ar: - cmd.extend(["-ar", str(ar)]) - if hausd: - cmd.extend(["-hausd", str(hausd)]) - if hgrad: - cmd.extend(["-hgrad", str(hgrad)]) - if hmax: - cmd.extend(["-hmax", str(hmax)]) - if hmin: - cmd.extend(["-hmin", str(hmin)]) - if hsiz: - cmd.extend(["-hsiz", str(hsiz)]) - if ls or ls == 0: - ls_value = ls - if isinstance(ls_value, bool): - ls_value = 0 - cmd.extend(["-ls", str(ls_value)]) - if noinsert: - cmd.append("-noinsert") - if nomove: - cmd.append("-nomove") - if nosurf: - cmd.append("-nosurf") - if noswap: - cmd.append("-noswap") - if nr: - cmd.append("-nr") - if nreg: - cmd.extend(["-nreg", str(nreg)]) - if nsd: - cmd.extend(["-nsd", str(nsd)]) - if optim: - cmd.append("-optim") - if rn: - cmd.append("-rn") - Mmg._run_mmg_command(cmd) + opts = _build_mmg_options( + { + "m": m, + "v": v, + "A": A, + "ar": ar, + "hausd": hausd, + "hgrad": hgrad, + "hmax": hmax, + "hmin": hmin, + "hsiz": hsiz, + "ls": ls, + "noinsert": noinsert, + "nomove": nomove, + "nosurf": nosurf, + "noswap": noswap, + "nr": nr, + "nreg": nreg, + "nsd": nsd, + "optim": optim, + "rn": rn, + }, + cli_unsupported=("d", "h", "val", "default"), + ) + _run_mmg( + "mmgs", + input_mesh=input, + output_mesh=output, + solution=solution, + metric=metric, + options=opts, + ) @staticmethod def mmg3d( @@ -742,84 +824,46 @@ def mmg3d( rmc=None, rn=None, ): + """Run mmg3d (volume remesher) on ``input`` writing to ``output``. + + Forwards to :func:`mmgpy.mmg3d.remesh`. CLI-only flags + (``d``, ``h``, ``val``, ``default``) are accepted but have no effect. """ - Runs mmg3d_O3 from the command line with given arguments - """ - cmd = ["mmg3d_O3"] - if d: - cmd.append("-d") - if h: - cmd.append("-h") - if m: - if isinstance(m, bool): - m = "" - cmd.extend(["-m", str(m)]) - if v: - if isinstance(v, bool): - v = 1 - cmd.extend(["-v", str(v)]) - if val: - cmd.append("-val") - if default: - cmd.append("-default") - if input: - cmd.extend(["-in", input]) - if output: - cmd.extend(["-out", output]) - if solution: - cmd.extend(["-sol", solution]) - if metric: - cmd.extend(["-met", metric]) - if A: - cmd.append("-A") - if ar: - cmd.extend(["-ar", str(ar)]) - if octree: - cmd.extend(["-octree", str(octree)]) - if hausd: - cmd.extend(["-hausd", str(hausd)]) - if hgrad: - cmd.extend(["-hgrad", str(hgrad)]) - if hmax: - cmd.extend(["-hmax", str(hmax)]) - if hmin: - cmd.extend(["-hmin", str(hmin)]) - if hsiz: - cmd.extend(["-hsiz", str(hsiz)]) - if lag or lag == 0: - if isinstance(lag, bool): - lag = 0 - cmd.extend(["-lag", str(lag)]) - if ls or ls == 0: - ls_value = ls - if isinstance(ls_value, bool): - ls_value = 0 - cmd.extend(["-ls", str(ls_value)]) - if nofem: - cmd.append("-nofem") - if noinsert: - cmd.append("-noinsert") - if nomove: - cmd.append("-nomove") - if nosurf: - cmd.append("-nosurf") - if noswap: - cmd.append("-noswap") - if nr: - cmd.append("-nr") - if nreg: - cmd.extend(["-nreg", str(nreg)]) - if nsd: - cmd.extend(["-nsd", str(nsd)]) - if optim: - cmd.append("-optim") - if optimLES: - cmd.append("-optimLES") - if opnbdy: - cmd.append("-opnbdy") - if rmc: - cmd.extend(["-rmc", str(rmc)]) - if rn: - cmd.append("-rn") - - Mmg._run_mmg_command(cmd) + opts = _build_mmg_options( + { + "m": m, + "v": v, + "A": A, + "ar": ar, + "octree": octree, + "hausd": hausd, + "hgrad": hgrad, + "hmax": hmax, + "hmin": hmin, + "hsiz": hsiz, + "lag": lag, + "ls": ls, + "nofem": nofem, + "noinsert": noinsert, + "nomove": nomove, + "nosurf": nosurf, + "noswap": noswap, + "nr": nr, + "nreg": nreg, + "nsd": nsd, + "optim": optim, + "optimLES": optimLES, + "opnbdy": opnbdy, + "rmc": rmc, + "rn": rn, + }, + cli_unsupported=("d", "h", "val", "default"), + ) + _run_mmg( + "mmg3d", + input_mesh=input, + output_mesh=output, + solution=solution, + metric=metric, + options=opts, + ) diff --git a/pyproject.toml b/pyproject.toml index d24976d7..ed558de7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,10 @@ dependencies = [ # CAD backend: OCCT via cadquery-ocp (no cadquery itself). Installing this # extra unlocks Shape.generate(), Phase, fuse_shapes, lattice CAD, etc. cad = ["cadquery-ocp"] +# MMG remeshing via the kmarchais/mmgpy Python bindings. Bundles the MMG +# native library, so no system MMG install is needed. Used by +# ``microgen.external.Mmg`` and ``microgen.remesh``. +mmg = ["mmgpy"] dev = [ 'pre-commit>=3.5.0', "ruff>=0.9", @@ -60,7 +64,7 @@ jupyter = [ "sidecar>=0.5.2", "jupyterview>=0.7.0", ] -all = ["microgen[cad, dev, docs, jupyter, ray]"] +all = ["microgen[cad, dev, docs, jupyter, mmg, ray]"] [project.urls] Documentation = 'https://3mah.github.io/microgen-docs/'