From ab42caed64538f428cf4ddb7be19b520392a22d3 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Mon, 1 Jun 2026 16:30:44 +0200 Subject: [PATCH 1/3] Rename Shape.func to field and extend Phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Shape internal implicit field attribute from `_func`/func to `_field`/field across the codebase and update all callers (require_func → require_field, evaluate, mesh paths, implicit ops, primitives). Rename immutable transform APIs on Shape (translate/rotate/scale → translated/rotated/scaled) and update tests accordingly. Enhance Phase with new constructors and features: Phase.from_implicit, Phase.from_grid (grid caching and nearest-neighbour wrapper), a cached grid shortcut, and an immutable rotated() transform (CAD- and field-backed handling) plus an internal _with_cad helper. Minor fix in meshbridge error check to use the new field property. Tests updated and a new test file added to cover Phase constructors and rotations. These changes standardize naming and add Tier-2 Phase functionality while updating tests to match the new API. --- microgen/cad/meshbridge.py | 2 +- microgen/phase.py | 222 +++++++++++++++++++++- microgen/shape/box.py | 2 +- microgen/shape/capsule.py | 2 +- microgen/shape/cylinder.py | 2 +- microgen/shape/ellipsoid.py | 2 +- microgen/shape/implicit_ops.py | 60 +++--- microgen/shape/shape.py | 40 ++-- microgen/shape/sphere.py | 2 +- microgen/shape/spinodoid.py | 2 +- microgen/shape/tpms.py | 16 +- tests/shapes/test_default_mesh_methods.py | 32 ++-- tests/shapes/test_implicit_ops.py | 40 ++-- tests/shapes/test_shape_period.py | 4 +- tests/shapes/test_tpms_frep.py | 16 +- tests/test_phase_constructors.py | 110 +++++++++++ tests/test_spinodoid.py | 14 +- 17 files changed, 446 insertions(+), 122 deletions(-) create mode 100644 tests/test_phase_constructors.py diff --git a/microgen/cad/meshbridge.py b/microgen/cad/meshbridge.py index ceab2d43..caf412c5 100644 --- a/microgen/cad/meshbridge.py +++ b/microgen/cad/meshbridge.py @@ -108,7 +108,7 @@ def shape_to_cad( :return: :class:`CadShape` wrapping the tessellated ``TopoDS_Shell`` """ require_cad() - if shape.func is None: + if shape.field is None: err_msg = "No implicit field defined — cannot build BREP from an empty Shape" raise NotImplementedError(err_msg) diff --git a/microgen/phase.py b/microgen/phase.py index 4965d1fd..f8ce4463 100644 --- a/microgen/phase.py +++ b/microgen/phase.py @@ -137,6 +137,10 @@ def __init__( # ``_surface_mesh`` at construction. self._cad: Any | None = None self._surface_mesh: pv.PolyData | None = None + # Set by ``from_grid`` to short-circuit lazy field-sampling in + # :meth:`grid`; ``None`` for shapes where the field is the source of + # truth and the grid is regenerated per (bounds, resolution) call. + self._cached_grid: pv.StructuredGrid | None = None self.name: str = ( name if name is not None else f"Phase_{next(_PHASE_AUTONAME_COUNTER)}" @@ -168,7 +172,7 @@ def from_shape( :param name: phase name (auto-generated if omitted). :param resolution: default sampling resolution. """ - if shape.func is None: + if shape.field is None: err_msg = "Cannot build Phase from a Shape without an implicit field" raise ValueError(err_msg) actual_bounds = bounds if bounds is not None else shape.bounds @@ -179,7 +183,7 @@ def from_shape( ) raise ValueError(err_msg) return cls( - field=shape.func, + field=shape.field, bounds=actual_bounds, iso=iso, period=shape.period, @@ -229,6 +233,125 @@ def from_mesh( instance._surface_mesh = mesh # noqa: SLF001 return instance + @classmethod + def from_implicit( + cls: type[Phase], + func: Field, + rve: Rve, + *, + iso: float = 0.0, + period: PeriodType | None = None, + name: str | None = None, + resolution: int = 50, + ) -> Phase: + """Construct a field-backed :class:`Phase` from a callable + :class:`Rve`. + + Sugar over the field-first constructor that derives ``bounds`` + from the RVE bounding box. + + :param func: implicit scalar field ``(x, y, z) -> array`` + (negative inside). + :param rve: domain whose AABB becomes the Phase ``bounds``. + :param iso: iso-value (default ``0.0``). + :param period: ``(Lx, Ly, Lz)`` if ``func`` is intrinsically periodic. + :param name: phase name (auto-generated if omitted). + :param resolution: default sampling resolution. + """ + bounds = ( + float(rve.min_point[0]), + float(rve.max_point[0]), + float(rve.min_point[1]), + float(rve.max_point[1]), + float(rve.min_point[2]), + float(rve.max_point[2]), + ) + return cls( + field=func, + bounds=bounds, + iso=iso, + period=period, + name=name, + resolution=resolution, + ) + + @classmethod + def from_grid( + cls: type[Phase], + grid: pv.StructuredGrid, + *, + scalars: str = "implicit", + iso: float = 0.0, + name: str | None = None, + ) -> Phase: + """Construct a field-backed :class:`Phase` from a pre-sampled grid. + + Useful when the implicit field is expensive to evaluate (GRF, + FFT-based fields) and the caller already has a + :class:`pyvista.StructuredGrid` whose points carry the scalar + sample. The Phase wraps the grid as its native representation; + :attr:`grid` returns it untouched, and downstream operations + (``pieces``, ``surface_mesh``, ``volume_mesh``, + ``center_of_mass``) read from it directly. + + The Phase's ``field`` is a nearest-neighbour lookup against the + grid samples — usable for ``Phase.from_shape``-style composition, + but inexact between sample points. + + :param grid: structured grid whose point data contains the scalar + field samples. + :param scalars: name of the scalar array on the grid. + :param iso: iso-value (default ``0.0``). + :param name: phase name (auto-generated if omitted). + """ + if scalars not in grid.point_data: + err_msg = ( + f"Grid has no point scalar named {scalars!r}. " + f"Available: {list(grid.point_data.keys())}" + ) + raise ValueError(err_msg) + + nx, ny, nz = grid.dimensions + pts = np.asarray(grid.points).reshape((nx, ny, nz, 3), order="F") + xmin, ymin, zmin = pts[0, 0, 0] + xmax, ymax, zmax = pts[-1, -1, -1] + bounds = ( + float(xmin), + float(xmax), + float(ymin), + float(ymax), + float(zmin), + float(zmax), + ) + scalar = np.asarray(grid[scalars]).reshape((nx, ny, nz), order="F") + dx = (xmax - xmin) / (nx - 1) if nx > 1 else 1.0 + dy = (ymax - ymin) / (ny - 1) if ny > 1 else 1.0 + dz = (zmax - zmin) / (nz - 1) if nz > 1 else 1.0 + + def _nearest_field( + x: np.ndarray, + y: np.ndarray, + z: np.ndarray, + ) -> np.ndarray: + xa = np.asarray(x) + ya = np.asarray(y) + za = np.asarray(z) + ix = np.clip(np.round((xa - xmin) / dx).astype(int), 0, nx - 1) + iy = np.clip(np.round((ya - ymin) / dy).astype(int), 0, ny - 1) + iz = np.clip(np.round((za - zmin) / dz).astype(int), 0, nz - 1) + return scalar[ix, iy, iz] + + instance = cls( + field=_nearest_field, + bounds=bounds, + iso=iso, + name=name, + resolution=max(nx, ny, nz), + ) + # Seed the grid cache so subsequent .grid() returns the original + # (avoids resampling the field via nearest-neighbour). + instance._cached_grid = grid # noqa: SLF001 + return instance + # ------------------------------------------------------------------ # Read-only accessors # ------------------------------------------------------------------ @@ -300,7 +423,7 @@ def _materialise_cad(self: Phase) -> Any: from .cad import shape_to_cad # noqa: PLC0415 from .shape.shape import Shape # noqa: PLC0415 - transient = Shape(func=self._field, bounds=self._bounds) + transient = Shape(field=self._field, bounds=self._bounds) return shape_to_cad( transient, bounds=self._bounds, resolution=self._resolution ) @@ -338,8 +461,12 @@ def cad(self: Phase) -> Any: def grid(self: Phase, resolution: int | None = None) -> pv.StructuredGrid: """Return a structured grid sampling of the field. - Only meaningful for field-backed phases. + Only meaningful for field-backed phases. When the Phase was built + via :meth:`from_grid`, the cached input grid is returned untouched + (regardless of the ``resolution`` argument). """ + if self._cached_grid is not None: + return self._cached_grid if self._field is None or self._bounds is None: err_msg = "grid() requires a field-backed Phase" raise ValueError(err_msg) @@ -698,6 +825,93 @@ def scaled(self: Phase, factor: float | tuple[float, float, float]) -> Phase: err_msg = "Cannot scale an empty Phase" raise ValueError(err_msg) + def rotated( + self: Phase, + angles: tuple[float, float, float], + convention: str = "ZXZ", + ) -> Phase: + """Return a new :class:`Phase` rotated by Euler ``angles`` (degrees). + + Rotation is applied about the world origin. + + - CAD-backed: delegates to ``CadShape.rotate`` with axis-angle form. + - Field-backed: composes the field as + ``f'(p) = f(R^{-1} p)`` so the iso-surface rotates with the rest; + ``bounds`` is the AABB of the rotated original AABB. + + :param angles: Euler angles in degrees. + :param convention: rotation order (default ``"ZXZ"``); accepted by + ``scipy.spatial.transform.Rotation.from_euler``. + """ + from scipy.spatial.transform import Rotation # noqa: PLC0415 + + rot = Rotation.from_euler(convention, angles, degrees=True) + + if self._cad is not None: + rotvec = rot.as_rotvec(degrees=True) + angle = float(np.linalg.norm(rotvec)) + if angle == 0.0: + return Phase(name=self.name, resolution=self._resolution)._with_cad( + self._cad + ) + axis = rotvec / angle + new = Phase(name=self.name, resolution=self._resolution) + new._cad = self._cad.rotate((0.0, 0.0, 0.0), tuple(axis), angle) # noqa: SLF001 + return new + + if self._field is not None and self._bounds is not None: + inv = rot.inv().as_matrix() + rot_matrix = rot.as_matrix() + f = self._field + # World-frame AABB of the rotated bbox: take all 8 corners, + # apply the rotation, and re-AABB. + b = self._bounds + corners = np.array( + [ + [x, y, z] + for x in (b[0], b[1]) + for y in (b[2], b[3]) + for z in (b[4], b[5]) + ] + ) + rotated_corners = corners @ rot_matrix.T + new_bounds = ( + float(rotated_corners[:, 0].min()), + float(rotated_corners[:, 0].max()), + float(rotated_corners[:, 1].min()), + float(rotated_corners[:, 1].max()), + float(rotated_corners[:, 2].min()), + float(rotated_corners[:, 2].max()), + ) + + def _rotated_field( + x: np.ndarray, + y: np.ndarray, + z: np.ndarray, + _f: Field = f, + _m: np.ndarray = inv, + ) -> np.ndarray: + lx = _m[0, 0] * x + _m[0, 1] * y + _m[0, 2] * z + ly = _m[1, 0] * x + _m[1, 1] * y + _m[1, 2] * z + lz = _m[2, 0] * x + _m[2, 1] * y + _m[2, 2] * z + return _f(lx, ly, lz) + + return Phase( + field=_rotated_field, + bounds=new_bounds, + iso=self._iso, + period=None, # rotation breaks axis-aligned periodicity + name=self.name, + resolution=self._resolution, + ) + err_msg = "Cannot rotate an empty Phase" + raise ValueError(err_msg) + + def _with_cad(self: Phase, cad: Any) -> Phase: + """Internal: return self with ``_cad`` set (used by transforms).""" + self._cad = cad + return self + def tiled(self: Phase, rve: Rve, grid: tuple[int, int, int]) -> Phase: """Return a new :class:`Phase` periodically tiled on the RVE. diff --git a/microgen/shape/box.py b/microgen/shape/box.py index 863447ef..123885d4 100644 --- a/microgen/shape/box.py +++ b/microgen/shape/box.py @@ -87,7 +87,7 @@ def _field( corners = np.array(list(itertools.product([-hx, hx], [-hy, hy], [-hz, hz]))) rotated = corners @ rot.T margin = max(hx, hy, hz) * 0.1 - self._func = _field + self._field = _field self._bounds = ( cx + float(rotated[:, 0].min()) - margin, cx + float(rotated[:, 0].max()) + margin, diff --git a/microgen/shape/capsule.py b/microgen/shape/capsule.py index f10b191b..ef82f0b8 100644 --- a/microgen/shape/capsule.py +++ b/microgen/shape/capsule.py @@ -86,7 +86,7 @@ def _field( ) rotated = corners @ rot.T margin = (half_h + r) * 0.1 - self._func = _field + self._field = _field self._bounds = ( cx + float(rotated[:, 0].min()) - margin, cx + float(rotated[:, 0].max()) + margin, diff --git a/microgen/shape/cylinder.py b/microgen/shape/cylinder.py index 5ca870e3..861947e8 100644 --- a/microgen/shape/cylinder.py +++ b/microgen/shape/cylinder.py @@ -89,7 +89,7 @@ def _field( ) rotated = corners @ rot.T margin = max(r, half_h) * 0.1 - self._func = _field + self._field = _field self._bounds = ( cx + float(rotated[:, 0].min()) - margin, cx + float(rotated[:, 0].max()) + margin, diff --git a/microgen/shape/ellipsoid.py b/microgen/shape/ellipsoid.py index 87446553..7a97347c 100644 --- a/microgen/shape/ellipsoid.py +++ b/microgen/shape/ellipsoid.py @@ -76,7 +76,7 @@ def _field( corners = np.array(list(itertools.product([-rx, rx], [-ry, ry], [-rz, rz]))) rotated = corners @ rot.T margin = max(rx, ry, rz) * 0.1 - self._func = _field + self._field = _field self._bounds = ( cx + float(rotated[:, 0].min()) - margin, cx + float(rotated[:, 0].max()) + margin, diff --git a/microgen/shape/implicit_ops.py b/microgen/shape/implicit_ops.py index 1aa0fcd2..83e74e7b 100644 --- a/microgen/shape/implicit_ops.py +++ b/microgen/shape/implicit_ops.py @@ -27,9 +27,9 @@ # --------------------------------------------------------------------------- -def _make_shape(func: Field, bounds: BoundsType | None) -> Shape: +def _make_shape(field: Field, bounds: BoundsType | None) -> Shape: """Create a bare Shape with only an implicit scalar field.""" - return _shape.Shape(func=func, bounds=bounds) + return _shape.Shape(field=field, bounds=bounds) def _smooth_min( @@ -92,9 +92,9 @@ def _merge_bounds( def complement(a: Shape) -> Shape: """Complement (negate the field): inside becomes outside and vice versa.""" - f = a.require_func() + f = a.require_field() return _make_shape( - func=lambda x, y, z, _f=f: -_f(x, y, z), + field=lambda x, y, z, _f=f: -_f(x, y, z), bounds=a.bounds, ) @@ -106,27 +106,27 @@ def complement(a: Shape) -> Shape: def union(a: Shape, b: Shape) -> Shape: """Union of two shapes (hard boolean).""" - fa, fb = a.require_func(), b.require_func() + fa, fb = a.require_field(), b.require_field() return _make_shape( - func=lambda x, y, z, _fa=fa, _fb=fb: np.minimum(_fa(x, y, z), _fb(x, y, z)), + field=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() + fa, fb = a.require_field(), b.require_field() return _make_shape( - func=lambda x, y, z, _fa=fa, _fb=fb: np.maximum(_fa(x, y, z), _fb(x, y, z)), + field=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() + fa, fb = a.require_field(), b.require_field() return _make_shape( - func=lambda x, y, z, _fa=fa, _fb=fb: np.maximum( + field=lambda x, y, z, _fa=fa, _fb=fb: np.maximum( _fa(x, y, z), -_fb(x, y, z), ), @@ -141,9 +141,9 @@ def difference(a: Shape, b: Shape) -> Shape: def smooth_union(a: Shape, b: Shape, k: float) -> Shape: """Smooth union with blending radius *k*.""" - fa, fb = a.require_func(), b.require_func() + fa, fb = a.require_field(), b.require_field() return _make_shape( - func=lambda x, y, z, _fa=fa, _fb=fb, _k=k: _smooth_min( + field=lambda x, y, z, _fa=fa, _fb=fb, _k=k: _smooth_min( _fa(x, y, z), _fb(x, y, z), _k, @@ -154,9 +154,9 @@ def smooth_union(a: Shape, b: Shape, k: float) -> Shape: def smooth_intersection(a: Shape, b: Shape, k: float) -> Shape: """Smooth intersection with blending radius *k*.""" - fa, fb = a.require_func(), b.require_func() + fa, fb = a.require_field(), b.require_field() return _make_shape( - func=lambda x, y, z, _fa=fa, _fb=fb, _k=k: _smooth_max( + field=lambda x, y, z, _fa=fa, _fb=fb, _k=k: _smooth_max( _fa(x, y, z), _fb(x, y, z), _k, @@ -167,9 +167,9 @@ def smooth_intersection(a: Shape, b: Shape, k: float) -> Shape: 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() + fa, fb = a.require_field(), b.require_field() return _make_shape( - func=lambda x, y, z, _fa=fa, _fb=fb, _k=k: _smooth_max( + field=lambda x, y, z, _fa=fa, _fb=fb, _k=k: _smooth_max( _fa(x, y, z), -_fb(x, y, z), _k, @@ -196,7 +196,7 @@ def batch_smooth_union( msg = "batch_smooth_union requires at least one shape" raise ValueError(msg) - funcs = [s.require_func() for s in shapes] + funcs = [s.require_field() for s in shapes] merged = shapes[0].bounds for s in shapes[1:]: @@ -216,7 +216,7 @@ def _batched( 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) + return _make_shape(field=_batched, bounds=merged) # --------------------------------------------------------------------------- @@ -231,18 +231,18 @@ def shell(shape: Shape, thickness: float | Field) -> Shape: ``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() + f = shape.require_field() if callable(thickness): t_func = thickness 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) + return _make_shape(field=_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, + field=lambda x, y, z, _f=f, _ht=half_t: np.abs(_f(x, y, z)) - _ht, bounds=shape.bounds, ) @@ -264,7 +264,7 @@ def repeat( coordinate-modulo repetition is used (hard tiling). """ sx, sy, sz = spacing - f = shape.require_func() + f = shape.require_field() if k <= 0: @@ -299,7 +299,7 @@ def _repeated( result = _smooth_min(result, f(cx + ox, cy + oy, cz + oz), k) return result - return _make_shape(func=_repeated, bounds=None) + return _make_shape(field=_repeated, bounds=None) def blend( @@ -308,10 +308,10 @@ def blend( factor: float = 0.5, ) -> Shape: """Linear interpolation between two fields: ``(1-t)*a + t*b``.""" - fa, fb = a.require_func(), b.require_func() + fa, fb = a.require_field(), b.require_field() t = factor return _make_shape( - func=lambda x, y, z, _fa=fa, _fb=fb, _t=t: ( + field=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"), @@ -319,11 +319,11 @@ def blend( def from_field( - func: Field, + field: 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) + return _make_shape(field=field, bounds=bounds) def box( @@ -354,7 +354,7 @@ def _box_sdf( ) bounds: BoundsType = (cx - hx, cx + hx, cy - hy, cy + hy, cz - hz, cz + hz) - return _make_shape(func=_box_sdf, bounds=bounds) + return _make_shape(field=_box_sdf, bounds=bounds) def _fd_sdf( @@ -394,7 +394,7 @@ def normalize_to_sdf(shape: Shape, epsilon: float = 1e-10) -> Shape: :param epsilon: floor for gradient magnitude (avoids division by zero at saddle points) """ - f = shape.require_func() + f = shape.require_field() # Try autograd first; fall back to FD if it fails at construction # OR at first evaluation (autograd may succeed at construction but @@ -435,4 +435,4 @@ def sdf( except Exception: # noqa: BLE001 sdf = _fd_sdf(f, epsilon) - return _make_shape(func=sdf, bounds=shape.bounds) + return _make_shape(field=sdf, bounds=shape.bounds) diff --git a/microgen/shape/shape.py b/microgen/shape/shape.py index fa074a39..d1718d54 100644 --- a/microgen/shape/shape.py +++ b/microgen/shape/shape.py @@ -82,10 +82,10 @@ 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 field: implicit scalar field ``(x, y, z) -> array``, or ``None`` :param bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)`` or ``None`` :param period: ``(Lx, Ly, Lz)`` if the field is intrinsically periodic - (``func(p + L) == func(p)`` along each axis), or ``None``. + (``field(p + L) == field(p)`` along each axis), or ``None``. Set by ``Tpms`` and ``Spinodoid`` from ``cell_size * repeat_cell``. """ @@ -93,7 +93,7 @@ def __init__( self: Shape, center: Vector3DType = (0, 0, 0), orientation: Vector3DType | Rotation = (0, 0, 0), - func: Field | None = None, + field: Field | None = None, bounds: BoundsType | None = None, period: PeriodType | None = None, ) -> None: @@ -104,7 +104,7 @@ def __init__( if isinstance(orientation, Rotation) else Rotation.from_euler("ZXZ", orientation, degrees=True) ) - self._func = func + self._field = field self._bounds = bounds self._period: PeriodType | None = period # Cache of sampled structured grids keyed on (bounds, resolution). @@ -141,9 +141,9 @@ def orientation(self: Shape) -> Rotation: return self._orientation @property - def func(self: Shape) -> Field | None: + def field(self: Shape) -> Field | None: """The implicit scalar field, or ``None``.""" - return self._func + return self._field @property def bounds(self: Shape) -> BoundsType | None: @@ -161,12 +161,12 @@ def period(self: Shape) -> PeriodType | None: """ return self._period - def require_func(self: Shape) -> Field: + def require_field(self: Shape) -> Field: """Return ``_func`` or raise if not set.""" - if self._func is None: + if self._field is None: err_msg = "No implicit scalar field defined on this shape" raise ValueError(err_msg) - return self._func + return self._field # ------------------------------------------------------------------ # Implicit field evaluation @@ -190,7 +190,7 @@ def evaluate( :param z: z coordinates :return: scalar field values (negative = inside) """ - return self.require_func()(x, y, z) + return self.require_field()(x, y, z) # ------------------------------------------------------------------ # Mesh generation (defaults use the implicit field) @@ -210,7 +210,7 @@ def _sample_implicit_grid( ``NotImplementedError`` (with a caller-specific message) when ``_func`` is unset, and ``ValueError`` when bounds can't be resolved. """ - if self._func is None: + if self._field is None: err_msg = f"No implicit field defined — subclasses must override {caller}()" raise NotImplementedError(err_msg) @@ -363,14 +363,14 @@ def smooth_difference(self: Shape, other: Shape, k: float) -> Shape: # Implicit field transforms # ------------------------------------------------------------------ - def translate(self: Shape, offset: tuple[float, float, float]) -> Shape: + def translated(self: Shape, offset: tuple[float, float, float]) -> Shape: """Return a new shape translated by *offset*. The returned :class:`Shape` has its ``center`` shifted by *offset* and its ``bounds`` updated; ``orientation`` is preserved. The implicit field is composed so ``evaluate(p) == old.evaluate(p - offset)``. """ - f = self.require_func() + f = self.require_field() dx, dy, dz = offset new_bounds = None if self._bounds is not None: @@ -385,7 +385,7 @@ def translate(self: Shape, offset: tuple[float, float, float]) -> Shape: ) cx, cy, cz = self._center return Shape( - func=lambda x, y, z, _f=f, _dx=dx, _dy=dy, _dz=dz: _f( + field=lambda x, y, z, _f=f, _dx=dx, _dy=dy, _dz=dz: _f( x - _dx, y - _dy, z - _dz, @@ -395,7 +395,7 @@ def translate(self: Shape, offset: tuple[float, float, float]) -> Shape: orientation=self._orientation, ) - def rotate( + def rotated( self: Shape, angles: tuple[float, float, float], convention: str = "ZXZ", @@ -407,7 +407,7 @@ def rotate( composes left with the rotation, and ``bounds`` is the AABB of the rotated original AABB. """ - f = self.require_func() + f = self.require_field() rot = Rotation.from_euler(convention, angles, degrees=True) rot_matrix = rot.as_matrix() inv_matrix = rot.inv().as_matrix() @@ -428,7 +428,7 @@ def rotate( ) rotated_center = rot_matrix @ np.asarray(self._center, dtype=np.float64) return Shape( - func=lambda x, y, z, _f=f, _m=inv_matrix: _f( + field=lambda x, y, z, _f=f, _m=inv_matrix: _f( *(_m @ np.array([x, y, z])), ), bounds=new_bounds, @@ -436,14 +436,14 @@ def rotate( orientation=rot * self._orientation, ) - def scale(self: Shape, factor: float) -> Shape: + def scaled(self: Shape, factor: float) -> Shape: """Return a new shape uniformly scaled by *factor* about the world origin. ``center`` is scaled by the same factor; ``orientation`` is preserved; ``bounds`` is scaled (with axis-pair swap for negative factors). """ - f = self.require_func() + f = self.require_field() new_bounds = None if self._bounds is not None: b = self._bounds @@ -466,7 +466,7 @@ def scale(self: Shape, factor: float) -> Shape: ) cx, cy, cz = self._center return Shape( - func=lambda x, y, z, _f=f, _s=factor: _f(x / _s, y / _s, z / _s) * _s, + field=lambda x, y, z, _f=f, _s=factor: _f(x / _s, y / _s, z / _s) * _s, bounds=new_bounds, center=(cx * factor, cy * factor, cz * factor), orientation=self._orientation, diff --git a/microgen/shape/sphere.py b/microgen/shape/sphere.py index afb1cc85..01b64061 100644 --- a/microgen/shape/sphere.py +++ b/microgen/shape/sphere.py @@ -61,7 +61,7 @@ def _field( ) -> npt.NDArray[np.float64]: return np.sqrt((x - cx) ** 2 + (y - cy) ** 2 + (z - cz) ** 2) - r - self._func = _field + self._field = _field self._bounds = ( cx - margin, cx + margin, diff --git a/microgen/shape/spinodoid.py b/microgen/shape/spinodoid.py index 06b29143..ce346410 100644 --- a/microgen/shape/spinodoid.py +++ b/microgen/shape/spinodoid.py @@ -180,7 +180,7 @@ def _signed_field( ) -> npt.NDArray[np.float64]: return -frep.evaluate(x, y, z) - self._func = _signed_field + self._field = _signed_field lx, ly, lz = (self.cell_size * self.repeat_cell).tolist() self._bounds = (0.0, float(lx), 0.0, float(ly), 0.0, float(lz)) # Spinodoid's field is *bit-exact* periodic on ``cell_size`` along each diff --git a/microgen/shape/tpms.py b/microgen/shape/tpms.py index 8efd2a66..c72dcc9f 100644 --- a/microgen/shape/tpms.py +++ b/microgen/shape/tpms.py @@ -482,7 +482,7 @@ def _finalize_frep( self._raw_field_func = raw_field sdf_shape = normalize_to_sdf(from_field(raw_field)) - self._func = sdf_shape.func + self._field = sdf_shape.field self._bounds = bounds # The TPMS field is intrinsically periodic on ``cell_size`` along each # axis (the 2π/cell_size wavenumbers in ``_setup_frep_field`` make @@ -535,7 +535,7 @@ def as_sheet(self: Tpms, thickness: float | None = None) -> Shape: t = self._offset_func else: t = self._offset - return shell(Shape(func=self._func, bounds=self._bounds), t) + return shell(Shape(field=self._field, bounds=self._bounds), t) def _half_offset_field(self: Tpms) -> Field | float: """ @@ -569,7 +569,7 @@ def as_upper_skeletal(self: Tpms) -> Shape: """ from .implicit_ops import from_field - f = self._func + f = self._field h = self._half_offset_field() if callable(h): @@ -581,13 +581,13 @@ def _upper(x, y, z, _f=f, _h=h): def _upper(x, y, z, _f=f, _h=h): return -_f(x, y, z) + _h - return from_field(func=_upper, bounds=self._bounds) + return from_field(field=_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 - f = self._func + f = self._field h = self._half_offset_field() if callable(h): @@ -599,7 +599,7 @@ def _lower(x, y, z, _f=f, _h=h): def _lower(x, y, z, _f=f, _h=h): return _f(x, y, z) + _h - return from_field(func=_lower, bounds=self._bounds) + return from_field(field=_lower, bounds=self._bounds) def as_surface(self: Tpms) -> Shape: """ @@ -610,7 +610,7 @@ def as_surface(self: Tpms) -> Shape: """ from .implicit_ops import from_field - return from_field(func=self._func, bounds=self._bounds) + return from_field(field=self._field, bounds=self._bounds) def _cell_box(self: Tpms) -> Shape: """SDF Shape of this TPMS' cell (cell_size × repeat_cell, centered origin).""" @@ -1821,7 +1821,7 @@ def _raw_field( # 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._field = _raw_field self._bounds = bounds # -- Cell-box (tube SDF) ----------------------------------------------- diff --git a/tests/shapes/test_default_mesh_methods.py b/tests/shapes/test_default_mesh_methods.py index 69cba284..9d42475f 100644 --- a/tests/shapes/test_default_mesh_methods.py +++ b/tests/shapes/test_default_mesh_methods.py @@ -71,7 +71,7 @@ def test_primitive_volume_mesh_is_unstructured_grid(): def test_from_field_volume_mesh(): """A shape built via ``from_field`` exposes the default volume-mesh path.""" s = from_field( - func=lambda x, y, z: np.sqrt(x**2 + y**2 + z**2) - 1.0, + field=lambda x, y, z: np.sqrt(x**2 + y**2 + z**2) - 1.0, bounds=(-1.2, 1.2, -1.2, 1.2, -1.2, 1.2), ) mesh = s.generate_volume_mesh(resolution=20) @@ -96,7 +96,7 @@ def test_composed_shape_volume_mesh(): def test_grid_cache_avoids_resampling(): """Two calls on the same instance hit the cache the second time.""" s = from_field( - func=lambda x, y, z: x**2 + y**2 + z**2 - 1.0, + field=lambda x, y, z: x**2 + y**2 + z**2 - 1.0, bounds=(-1.2, 1.2, -1.2, 1.2, -1.2, 1.2), ) s.generate_surface_mesh(resolution=20) @@ -111,7 +111,7 @@ def test_grid_cache_avoids_resampling(): def test_grid_cache_keyed_on_resolution(): """A different ``resolution`` produces a fresh cache entry.""" s = from_field( - func=lambda x, y, z: x**2 + y**2 + z**2 - 1.0, + field=lambda x, y, z: x**2 + y**2 + z**2 - 1.0, bounds=(-1.2, 1.2, -1.2, 1.2, -1.2, 1.2), ) s.generate_surface_mesh(resolution=20) @@ -143,35 +143,35 @@ def test_orientation_is_read_only_on_shape(): # --------------------------------------------------------------------------- -def test_translate_propagates_center(): - """``translate`` shifts the reported center by the offset.""" +def test_translated_propagates_center(): + """``translated`` shifts the reported center by the offset.""" s = Sphere(radius=1.0, center=(1.0, 2.0, 3.0)) - shifted = s.translate((4.0, -1.0, 2.0)) + shifted = s.translated((4.0, -1.0, 2.0)) assert tuple(shifted.center) == pytest.approx((5.0, 1.0, 5.0)) -def test_translate_preserves_orientation(): - """``translate`` does not touch orientation.""" +def test_translated_preserves_orientation(): + """``translated`` does not touch orientation.""" s = Sphere(radius=1.0, orientation=Rotation.from_euler("z", 30, degrees=True)) - shifted = s.translate((1.0, 0.0, 0.0)) + shifted = s.translated((1.0, 0.0, 0.0)) np.testing.assert_allclose( shifted.orientation.as_matrix(), s.orientation.as_matrix(), ) -def test_rotate_propagates_center_and_orientation(): - """``rotate`` rotates the reported center about the world origin.""" +def test_rotated_propagates_center_and_orientation(): + """``rotated`` rotates the reported center about the world origin.""" s = Sphere(radius=1.0, center=(1.0, 0.0, 0.0)) - rotated = s.rotate((0.0, 0.0, 90.0), convention="xyz") + rotated = s.rotated((0.0, 0.0, 90.0), convention="xyz") # Rotation of (1, 0, 0) by 90° about z lands at (0, 1, 0). np.testing.assert_allclose(rotated.center, (0.0, 1.0, 0.0), atol=1e-9) -def test_scale_propagates_center(): - """``scale`` scales the reported center by the same factor.""" +def test_scaled_propagates_center(): + """``scaled`` scales the reported center by the same factor.""" s = Sphere(radius=1.0, center=(2.0, 0.0, 0.0)) - scaled = s.scale(3.0) + scaled = s.scaled(3.0) assert tuple(scaled.center) == pytest.approx((6.0, 0.0, 0.0)) @@ -189,6 +189,6 @@ def test_volume_mesh_without_func_raises(): def test_volume_mesh_without_bounds_raises(): """A shape with a func but no bounds raises when bounds aren't passed.""" - s = Shape(func=lambda x, y, z: x**2 + y**2 + z**2 - 1.0) + s = Shape(field=lambda x, y, z: x**2 + y**2 + z**2 - 1.0) with pytest.raises(ValueError, match="Bounds must be provided"): s.generate_volume_mesh() diff --git a/tests/shapes/test_implicit_ops.py b/tests/shapes/test_implicit_ops.py index 3c0fe17d..8804ac77 100644 --- a/tests/shapes/test_implicit_ops.py +++ b/tests/shapes/test_implicit_ops.py @@ -67,12 +67,12 @@ def sdf(x, y, z): 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) + return Shape(field=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) + return Shape(field=func, bounds=bounds) # --------------------------------------------------------------------------- @@ -220,20 +220,20 @@ def test_smooth_methods(self): class TestTransforms: """Test implicit field transform operations.""" - def test_translate(self): + def test_translated(self): s = _make_sphere() - st = s.translate((2, 0, 0)) + st = s.translated((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 + def test_rotated_90(self): + # Elongated box along x, rotated 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") + box = Shape(field=func, bounds=bounds) + rotated = box.rotated((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 @@ -243,22 +243,22 @@ def test_rotate_90(self): rotated.evaluate(np.array([0.5]), np.array([0.0]), np.array([0.0]))[0] > 0 ) - def test_scale(self): + def test_scaled(self): s = _make_sphere() - ss = s.scale(2.0) + ss = s.scaled(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): + def test_translated_bounds(self): s = _make_sphere() - st = s.translate((5, 0, 0)) + st = s.translated((5, 0, 0)) assert st.bounds is not None assert st.bounds[0] > 3.0 - def test_scale_bounds(self): + def test_scaled_bounds(self): s = _make_sphere() - ss = s.scale(3.0) + ss = s.scaled(3.0) assert ss.bounds is not None assert ss.bounds[1] > 3.0 @@ -297,7 +297,7 @@ def test_no_func_raises(self): s.generate_surface_mesh() def test_no_bounds_raises(self): - s = Shape(func=lambda x, y, z: x**2 + y**2 + z**2 - 1) + s = Shape(field=lambda x, y, z: x**2 + y**2 + z**2 - 1) with pytest.raises(ValueError, match="Bounds must be provided"): s.generate_surface_mesh() @@ -331,7 +331,7 @@ def test_intersection_shrinks(self): def test_none_bounds(self): f = lambda x, y, z: x # noqa: E731 - p = Shape(func=f, bounds=None) + p = Shape(field=f, bounds=None) s = _make_sphere() u = p | s assert u.bounds == s.bounds @@ -370,7 +370,7 @@ def test_blend(self): def test_from_field(self): shape = from_field( - func=lambda x, y, z: x**2 + y**2 + z**2 - 1, + field=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 @@ -404,11 +404,11 @@ class TestErrorHandling: def test_transform_without_func_raises(self): s = Shape() with pytest.raises(ValueError, match="No implicit scalar field"): - s.translate((1, 0, 0)) + s.translated((1, 0, 0)) with pytest.raises(ValueError, match="No implicit scalar field"): - s.rotate((0, 0, 45)) + s.rotated((0, 0, 45)) with pytest.raises(ValueError, match="No implicit scalar field"): - s.scale(2.0) + s.scaled(2.0) def test_boolean_without_func_raises(self): s = Shape() diff --git a/tests/shapes/test_shape_period.py b/tests/shapes/test_shape_period.py index 72ad52ef..e80d66de 100644 --- a/tests/shapes/test_shape_period.py +++ b/tests/shapes/test_shape_period.py @@ -19,8 +19,8 @@ def test_bare_shape_period_is_none() -> None: - """A free ``Shape(func=...)`` has no intrinsic period.""" - s = Shape(func=lambda x, y, z: x * x + y * y + z * z - 1.0) + """A free ``Shape(field=...)`` has no intrinsic period.""" + s = Shape(field=lambda x, y, z: x * x + y * y + z * z - 1.0) assert s.period is None diff --git a/tests/shapes/test_tpms_frep.py b/tests/shapes/test_tpms_frep.py index edac7e5f..50815c78 100644 --- a/tests/shapes/test_tpms_frep.py +++ b/tests/shapes/test_tpms_frep.py @@ -23,7 +23,7 @@ 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 + sdf = sdf_shape.field rng = np.random.default_rng(42) x = rng.uniform(-2, 2, 500) @@ -64,7 +64,7 @@ def non_autograd_field(x, y, z): 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])) + vals = sdf_shape.field(np.array([0.0]), np.array([0.0]), np.array([0.0])) assert np.isfinite(vals[0]) @@ -78,7 +78,7 @@ class TestTpmsFrepField: def test_tpms_has_func(self): tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) - assert tpms.func is not None + assert tpms.field is not None def test_tpms_has_bounds(self): tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3) @@ -113,7 +113,7 @@ 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 + assert sheet.field is not None def test_as_sheet_mesh(self): tpms = Tpms( @@ -130,13 +130,13 @@ 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 + assert skel.field 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 + assert skel.field is not None # --------------------------------------------------------------------------- @@ -235,7 +235,7 @@ def test_cylindrical_has_func(self): offset=0.3, resolution=10, ) - assert tpms.func is not None + assert tpms.field is not None assert tpms.bounds is not None def test_cylindrical_evaluate(self): @@ -262,7 +262,7 @@ def test_spherical_has_func(self): offset=0.3, resolution=10, ) - assert tpms.func is not None + assert tpms.field is not None assert tpms.bounds is not None def test_spherical_evaluate(self): diff --git a/tests/test_phase_constructors.py b/tests/test_phase_constructors.py new file mode 100644 index 00000000..b9b65369 --- /dev/null +++ b/tests/test_phase_constructors.py @@ -0,0 +1,110 @@ +"""Tests for the new Tier 2 :class:`Phase` constructors. + +- :meth:`Phase.from_implicit` — sugar over the field-first constructor that + derives ``bounds`` from a :class:`Rve`. +- :meth:`Phase.from_grid` — wraps a pre-sampled + :class:`pyvista.StructuredGrid` (useful for expensive-to-evaluate fields). +- :meth:`Phase.rotated` — the missing immutable transform paired with + ``translated`` / ``scaled`` / ``tiled``. +""" + +import numpy as np +import pyvista as pv + +from microgen import Box, Phase, Rve, Sphere + +# ruff: noqa: S101 + + +def _sphere_sdf(x, y, z, r=0.5): + return np.sqrt(x * x + y * y + z * z) - r + + +def test_from_implicit_derives_bounds_from_rve() -> None: + """``Phase.from_implicit(f, rve)`` derives bounds from ``rve.min/max_point``.""" + rve = Rve(center=(0.0, 0.0, 0.0), dim=2.0) + phase = Phase.from_implicit(_sphere_sdf, rve, resolution=30) + assert phase.bounds == (-1.0, 1.0, -1.0, 1.0, -1.0, 1.0) + assert phase.field is not None + assert phase.period is None + # Centred sphere → COM at origin. + assert np.allclose(phase.center_of_mass, (0.0, 0.0, 0.0), atol=0.05) + + +def test_from_implicit_propagates_period_and_name() -> None: + """Period and name kwargs flow through unchanged.""" + rve = Rve(dim=1.0) + phase = Phase.from_implicit( + _sphere_sdf, rve, period=(1.0, 1.0, 1.0), name="my_phase" + ) + assert phase.period == (1.0, 1.0, 1.0) + assert phase.name == "my_phase" + + +def test_from_grid_returns_input_grid_untouched() -> None: + """``Phase.from_grid`` reuses the provided grid as its source of truth.""" + nx = 30 + xi = np.linspace(-1, 1, nx) + x, y, z = np.meshgrid(xi, xi, xi, indexing="ij") + sg = pv.StructuredGrid(x, y, z) + sg["implicit"] = _sphere_sdf(x, y, z).ravel(order="F") + + phase = Phase.from_grid(sg, scalars="implicit") + # Cached grid is the same object (no resampling). + assert phase.grid() is sg + assert phase.bounds == (-1.0, 1.0, -1.0, 1.0, -1.0, 1.0) + # COM of the centred sphere ≈ origin. + assert np.allclose(phase.center_of_mass, (0.0, 0.0, 0.0), atol=0.05) + + +def test_from_grid_rejects_missing_scalar() -> None: + """Asking for a scalar name that isn't on the grid raises ``ValueError``.""" + import pytest + + sg = pv.StructuredGrid( + *np.meshgrid([-1, 0, 1], [-1, 0, 1], [-1, 0, 1], indexing="ij") + ) + sg["other"] = np.zeros(sg.n_points) + with pytest.raises(ValueError, match="no point scalar named 'implicit'"): + Phase.from_grid(sg) + + +def test_phase_rotated_field_backed_swaps_axes() -> None: + """A box along x rotated 90° about z lands along y (field-backed).""" + box = Box(dim=(2.0, 0.4, 0.4)) + phase = Phase.from_shape(box, resolution=40) + # f(0.5, 0, 0) < 0 before, > 0 after; f(0, 0.5, 0) < 0 after. + f_before = phase.field(np.array([0.5]), np.array([0.0]), np.array([0.0]))[0] + rotated = phase.rotated((0, 0, 90), convention="xyz") + f_after = rotated.field(np.array([0.5]), np.array([0.0]), np.array([0.0]))[0] + f_y = rotated.field(np.array([0.0]), np.array([0.5]), np.array([0.0]))[0] + assert f_before < 0 + assert f_after > 0 + assert f_y < 0 + + +def test_phase_rotated_field_backed_invalidates_period() -> None: + """Rotation breaks axis-aligned periodicity; ``period`` resets to ``None``.""" + from microgen import Tpms, surface_functions + + tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3, cell_size=1.0) + phase = Phase.from_shape(tpms) + assert phase.period == (1.0, 1.0, 1.0) + rotated = phase.rotated((30, 0, 0)) + assert rotated.period is None + + +def test_phase_rotated_cad_backed_preserves_volume() -> None: + """CAD-backed rotation produces a Phase with the same volume.""" + sph = Phase.from_cad(Sphere(radius=0.5).generate_cad()) + rotated = sph.rotated((0, 0, 45)) + # CAD-backed: same volume (rigid transform). + assert np.isclose(rotated.cad.volume(), sph.cad.volume(), rtol=1e-6) + + +def test_phase_rotated_empty_raises() -> None: + """An empty Phase cannot be rotated.""" + import pytest + + with pytest.raises(ValueError, match="Cannot rotate an empty Phase"): + Phase().rotated((10, 0, 0)) diff --git a/tests/test_spinodoid.py b/tests/test_spinodoid.py index 988eac9e..1305b0e1 100644 --- a/tests/test_spinodoid.py +++ b/tests/test_spinodoid.py @@ -56,11 +56,11 @@ def test_spinodoid_offset_used_directly() -> None: def test_spinodoid_func_evaluable_and_sign_matches_solid() -> None: - """`_func` is evaluable; sign convention matches grid_solid membership.""" + """`_field` is evaluable; sign convention matches grid_solid membership.""" sp = Spinodoid(k0=15.0, bandwidth=3.0, resolution=16, density=0.5, seed=0) rng = np.random.default_rng(0) pts = rng.uniform(0.0, sp.cell_size[0], size=(100, 3)) - values = sp._func(pts[:, 0], pts[:, 1], pts[:, 2]) + values = sp._field(pts[:, 0], pts[:, 1], pts[:, 2]) assert values.shape == (100,) # Negative ↔ inside the solid (Shape F-rep convention) inside_count = int((values < 0).sum()) @@ -76,10 +76,10 @@ def test_spinodoid_field_is_bit_exact_periodic() -> None: x = rng.uniform(0.0, Lx, size=10) y = rng.uniform(0.0, Ly, size=10) z = rng.uniform(0.0, Lz, size=10) - v0 = sp._func(x, y, z) - vx = sp._func(x + Lx, y, z) - vy = sp._func(x, y + Ly, z) - vz = sp._func(x, y, z + Lz) + v0 = sp._field(x, y, z) + vx = sp._field(x + Lx, y, z) + vy = sp._field(x, y + Ly, z) + vz = sp._field(x, y, z + Lz) assert np.max(np.abs(vx - v0)) < 1e-10 assert np.max(np.abs(vy - v0)) < 1e-10 assert np.max(np.abs(vz - v0)) < 1e-10 @@ -200,5 +200,5 @@ def sphere_sdf( assert isinstance(intersected, Shape) # Smoke test: callable evaluable on arbitrary points. pts = np.array([[0.5, 0.5, 0.5]]) - val = intersected._func(pts[:, 0], pts[:, 1], pts[:, 2]) + val = intersected._field(pts[:, 0], pts[:, 1], pts[:, 2]) assert val.shape == (1,) From 9a39b972a8b89fdbee15e29d436b35b84b9dd75a Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Mon, 1 Jun 2026 17:31:00 +0200 Subject: [PATCH 2/3] Hoist Rotation import and tidy tests Move scipy.spatial.transform.Rotation import to module scope in microgen/phase.py (removing the local import inside Phase) and update tests to import pytest, Tpms, and surface_functions at the top. Also remove several in-test imports and minor whitespace cleanups. These are refactors to satisfy style/static checks and keep imports consistent; behavior is unchanged. --- microgen/phase.py | 3 +-- tests/test_phase_constructors.py | 9 ++------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/microgen/phase.py b/microgen/phase.py index f8ce4463..f5ae3c57 100644 --- a/microgen/phase.py +++ b/microgen/phase.py @@ -37,6 +37,7 @@ from typing import TYPE_CHECKING, Any import numpy as np +from scipy.spatial.transform import Rotation if TYPE_CHECKING: from collections.abc import Sequence @@ -843,8 +844,6 @@ def rotated( :param convention: rotation order (default ``"ZXZ"``); accepted by ``scipy.spatial.transform.Rotation.from_euler``. """ - from scipy.spatial.transform import Rotation # noqa: PLC0415 - rot = Rotation.from_euler(convention, angles, degrees=True) if self._cad is not None: diff --git a/tests/test_phase_constructors.py b/tests/test_phase_constructors.py index b9b65369..1afac2cb 100644 --- a/tests/test_phase_constructors.py +++ b/tests/test_phase_constructors.py @@ -9,9 +9,10 @@ """ import numpy as np +import pytest import pyvista as pv -from microgen import Box, Phase, Rve, Sphere +from microgen import Box, Phase, Rve, Sphere, Tpms, surface_functions # ruff: noqa: S101 @@ -59,8 +60,6 @@ def test_from_grid_returns_input_grid_untouched() -> None: def test_from_grid_rejects_missing_scalar() -> None: """Asking for a scalar name that isn't on the grid raises ``ValueError``.""" - import pytest - sg = pv.StructuredGrid( *np.meshgrid([-1, 0, 1], [-1, 0, 1], [-1, 0, 1], indexing="ij") ) @@ -85,8 +84,6 @@ def test_phase_rotated_field_backed_swaps_axes() -> None: def test_phase_rotated_field_backed_invalidates_period() -> None: """Rotation breaks axis-aligned periodicity; ``period`` resets to ``None``.""" - from microgen import Tpms, surface_functions - tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3, cell_size=1.0) phase = Phase.from_shape(tpms) assert phase.period == (1.0, 1.0, 1.0) @@ -104,7 +101,5 @@ def test_phase_rotated_cad_backed_preserves_volume() -> None: def test_phase_rotated_empty_raises() -> None: """An empty Phase cannot be rotated.""" - import pytest - with pytest.raises(ValueError, match="Cannot rotate an empty Phase"): Phase().rotated((10, 0, 0)) From 1ae1c42b1381ee5748e78bb1a6bba39ec7db5a54 Mon Sep 17 00:00:00 2001 From: Yves Chemisky Date: Wed, 24 Jun 2026 15:47:57 +0200 Subject: [PATCH 3/3] Adopt PyVista convention for transforms Rename immutable transform APIs to PyVista-style verbs and add optional inplace behavior. translated/scaled/rotated/tiled -> translate/scale/rotate/tile across Phase and Shape; transforms now accept inplace=False (default) to return a new Phase/Shape or inplace=True to mutate. Implement Phase.copy(), _invalidate_derived() and internal _apply_* workers to keep field, CAD, mesh and cached grid coherent. Rotation now accepts scipy.spatial.transform.Rotation (or a 3x3 matrix) and supports a pivot point; scaling uses a computed pivot (center_of_mass/mesh center). Tests and example updated to use the new names and Rotation imports. Notes: tile remains a producer (always returns new), rotations invalidate axis-aligned periodicity, and cached grids are cleared when necessary. Breaking change: public transform method names and signatures changed. --- .../ImplicitShapes/gyroid_implicit_phase.py | 4 +- microgen/phase.py | 390 +++++++++++------- microgen/shape/shape.py | 126 ++++-- tests/shapes/test_default_mesh_methods.py | 8 +- tests/shapes/test_implicit_ops.py | 17 +- tests/test_phase.py | 8 +- tests/test_phase_constructors.py | 13 +- tests/test_phase_pieces.py | 2 +- 8 files changed, 364 insertions(+), 204 deletions(-) diff --git a/examples/ImplicitShapes/gyroid_implicit_phase.py b/examples/ImplicitShapes/gyroid_implicit_phase.py index 56a058e0..084e1bfc 100644 --- a/examples/ImplicitShapes/gyroid_implicit_phase.py +++ b/examples/ImplicitShapes/gyroid_implicit_phase.py @@ -54,10 +54,10 @@ print(f" piece[{i}]: volume={piece.volume:.4f}, com={piece.com}") # %% Immutable transforms return new Phase objects. -moved = phase.translated((1.0, 0.0, 0.0)) +moved = phase.translate((1.0, 0.0, 0.0)) print(f"\nTranslated phase COM: {moved.center_of_mass} (shifted by +1 in x)") -scaled = phase.scaled(2.0) +scaled = phase.scale(2.0) print(f"Scaled phase bounds: {scaled.bounds} (cell doubled)") # %% Compose with another implicit Shape via F-rep boolean ops. diff --git a/microgen/phase.py b/microgen/phase.py index f5ae3c57..8ae32f46 100644 --- a/microgen/phase.py +++ b/microgen/phase.py @@ -24,9 +24,12 @@ ``microgen.cad`` to ``pyvista-cad`` later means swapping that one method; no other code in microgen needs to change. -A :class:`Phase` is **immutable**: transforms (:meth:`translated`, -:meth:`scaled`, :meth:`rotated`, :meth:`tiled`) return a new instance. -This makes cache invalidation impossible by construction. +Transforms follow the PyVista convention — :meth:`translate`, +:meth:`rotate` and :meth:`scale` take ``inplace=False`` (default; returns +a new :class:`Phase`) or ``inplace=True`` (mutates ``self``). :meth:`tile` +is a producer (always a new instance). Every live representation (field, +CAD, surface mesh, cached grid) is transformed together so the result +stays coherent; derived caches are invalidated. """ from __future__ import annotations @@ -728,142 +731,243 @@ def inertia_matrix(self: Phase) -> npt.NDArray[np.float64]: raise ValueError(err_msg) # ------------------------------------------------------------------ - # Immutable transforms + # Copy # ------------------------------------------------------------------ - def translated(self: Phase, offset: Sequence[float]) -> Phase: - """Return a new :class:`Phase` translated by ``offset``.""" + def copy(self: Phase) -> Phase: + """Return an independent copy with every live representation duplicated. + + The field callable and (immutable) bounds tuple are shared; the CAD + (:meth:`CadShape.copy` → ``BRepBuilderAPI_Copy``), the surface mesh + and any cached grid are deep-copied, so an ``inplace=False`` + transform on the copy never touches the original. + """ + new = Phase( + field=self._field, + bounds=self._bounds, + iso=self._iso, + period=self._period, + name=self.name, + resolution=self._resolution, + ) + if self._cad is not None: + new._cad = self._cad.copy() # noqa: SLF001 + if self._surface_mesh is not None: + new._surface_mesh = self._surface_mesh.copy() # noqa: SLF001 + if self._cached_grid is not None: + new._cached_grid = self._cached_grid.copy() # noqa: SLF001 + return new + + def _invalidate_derived(self: Phase) -> None: + """Drop cached ``@cached_property`` results after a mutation.""" + for key in ("cad", "pieces", "center_of_mass", "inertia_matrix"): + self.__dict__.pop(key, None) + + # ------------------------------------------------------------------ + # Transforms — PyVista convention: imperative verb + ``inplace=False``. + # + # ``inplace=False`` (default) returns a new Phase (operating on a + # :meth:`copy`); ``inplace=True`` mutates ``self``. Both return a + # Phase. The ``_apply_*`` workers mutate every live representation + # (field, CAD, surface mesh, cached grid) via ``self`` so the result + # stays coherent regardless of which mode the caller picked. + # ------------------------------------------------------------------ + + def translate( + self: Phase, offset: Sequence[float], *, inplace: bool = False + ) -> Phase: + """Translate the phase by ``offset``. + + :param offset: ``(dx, dy, dz)`` world-space shift. + :param inplace: mutate ``self`` when ``True``; otherwise (default) + return a new translated :class:`Phase`. + """ + if self.is_empty: + err_msg = "Cannot translate an empty Phase" + raise ValueError(err_msg) dx, dy, dz = float(offset[0]), float(offset[1]), float(offset[2]) + target = self if inplace else self.copy() + target._apply_translate(dx, dy, dz) # noqa: SLF001 + return target + def _apply_translate(self: Phase, dx: float, dy: float, dz: float) -> None: if self._field is not None and self._bounds is not None: f = self._field - new_bounds = ( - self._bounds[0] + dx, - self._bounds[1] + dx, - self._bounds[2] + dy, - self._bounds[3] + dy, - self._bounds[4] + dz, - self._bounds[5] + dz, - ) - return Phase( - field=lambda x, y, z, _f=f, _dx=dx, _dy=dy, _dz=dz: _f( - x - _dx, y - _dy, z - _dz - ), - bounds=new_bounds, - iso=self._iso, - period=self._period, - name=self.name, - resolution=self._resolution, + + def _translated_field(x, y, z, _f=f, _dx=dx, _dy=dy, _dz=dz): # noqa: ANN001, ANN202 + return _f(x - _dx, y - _dy, z - _dz) + + b = self._bounds + self._field = _translated_field + self._bounds = ( + b[0] + dx, + b[1] + dx, + b[2] + dy, + b[3] + dy, + b[4] + dz, + b[5] + dz, ) if self._cad is not None: - new = Phase(name=self.name, resolution=self._resolution) - new._cad = self._cad.translate((dx, dy, dz)) # noqa: SLF001 - return new - err_msg = "Cannot translate an empty Phase" - raise ValueError(err_msg) + self._cad = self._cad.translate((dx, dy, dz)) + if self._surface_mesh is not None: + self._surface_mesh = self._surface_mesh.translate( + (dx, dy, dz), inplace=False + ) + if self._cached_grid is not None: + self._cached_grid = self._cached_grid.translate((dx, dy, dz), inplace=False) + self._invalidate_derived() - def scaled(self: Phase, factor: float | tuple[float, float, float]) -> Phase: - """Return a new :class:`Phase` scaled by ``factor`` about its center of mass. + def scale( + self: Phase, + factor: float | tuple[float, float, float], + *, + inplace: bool = False, + ) -> Phase: + """Scale the phase about its center. - CAD-backed phases use OCCT ``BRepBuilderAPI_GTransform`` about - the BRep center. Field-backed phases compose the field via - ``f'(p) = f((p - c) / s + c) * s_min`` (uniform scale only; a - per-axis scale is not exact for an SDF, so non-uniform factors - rescale the bbox only and leave the field's iso-distance - approximate — caller's responsibility). + Field-backed phases scale about their volumetric + :attr:`center_of_mass`; CAD/mesh-backed phases about the BRep/mesh + center. Uniform ``factor`` is exact; a per-axis tuple rescales the + bbox and (for an SDF) leaves the iso-distance approximate. + + :param factor: uniform scalar or ``(sx, sy, sz)``. + :param inplace: mutate ``self`` when ``True``; otherwise (default) + return a new scaled :class:`Phase`. """ + if self.is_empty: + err_msg = "Cannot scale an empty Phase" + raise ValueError(err_msg) + if isinstance(factor, (int, float)): + sx = sy = sz = float(factor) + else: + sx, sy, sz = (float(s) for s in factor) + # One pivot shared by every representation so they stay coherent. + pivot = self._scale_pivot() + target = self if inplace else self.copy() + target._apply_scale(sx, sy, sz, pivot) # noqa: SLF001 + return target + + def _scale_pivot(self: Phase) -> tuple[float, float, float]: + if self._field is not None: + return tuple(float(v) for v in self.center_of_mass) # type: ignore[return-value] if self._cad is not None: - from .cad import transform_geometry # noqa: PLC0415 - - if isinstance(factor, (int, float)): - sx = sy = sz = float(factor) - else: - sx, sy, sz = (float(s) for s in factor) c = self._cad.center() - cx, cy, cz = c.x, c.y, c.z - matrix = np.array( - [ - [sx, 0.0, 0.0, cx - sx * cx], - [0.0, sy, 0.0, cy - sy * cy], - [0.0, 0.0, sz, cz - sz * cz], - ], - dtype=np.float64, - ) - new = Phase(name=self.name, resolution=self._resolution) - new._cad = transform_geometry(self._cad, matrix) # noqa: SLF001 - return new + return (float(c.x), float(c.y), float(c.z)) + c = self._surface_mesh.center # type: ignore[union-attr] + return (float(c[0]), float(c[1]), float(c[2])) + + def _apply_scale( + self: Phase, + sx: float, + sy: float, + sz: float, + pivot: tuple[float, float, float], + ) -> None: + px, py, pz = pivot if self._field is not None and self._bounds is not None: - if isinstance(factor, (int, float)): - sx = sy = sz = float(factor) - else: - sx, sy, sz = (float(s) for s in factor) - cx, cy, cz = self.center_of_mass.tolist() f = self._field - new_bounds = ( - cx + (self._bounds[0] - cx) * sx, - cx + (self._bounds[1] - cx) * sx, - cy + (self._bounds[2] - cy) * sy, - cy + (self._bounds[3] - cy) * sy, - cz + (self._bounds[4] - cz) * sz, - cz + (self._bounds[5] - cz) * sz, - ) s_min = min(sx, sy, sz) - return Phase( - field=lambda x, y, z, _f=f, _sx=sx, _sy=sy, _sz=sz, _cx=cx, _cy=cy, _cz=cz, _sm=s_min: ( + + def _scaled_field( # noqa: ANN001, ANN202, PLR0913 + x, y, z, _f=f, _sx=sx, _sy=sy, _sz=sz, _px=px, _py=py, _pz=pz, _sm=s_min + ): + return ( _f( - (x - _cx) / _sx + _cx, - (y - _cy) / _sy + _cy, - (z - _cz) / _sz + _cz, + (x - _px) / _sx + _px, + (y - _py) / _sy + _py, + (z - _pz) / _sz + _pz, ) * _sm - ), - bounds=new_bounds, - iso=self._iso, - period=self._period, - name=self.name, - resolution=self._resolution, + ) + + b = self._bounds + self._field = _scaled_field + self._bounds = ( + px + (b[0] - px) * sx, + px + (b[1] - px) * sx, + py + (b[2] - py) * sy, + py + (b[3] - py) * sy, + pz + (b[4] - pz) * sz, + pz + (b[5] - pz) * sz, ) - err_msg = "Cannot scale an empty Phase" - raise ValueError(err_msg) + if self._cad is not None: + from .cad import transform_geometry # noqa: PLC0415 - def rotated( + matrix = np.array( + [ + [sx, 0.0, 0.0, px - sx * px], + [0.0, sy, 0.0, py - sy * py], + [0.0, 0.0, sz, pz - sz * pz], + ], + dtype=np.float64, + ) + self._cad = transform_geometry(self._cad, matrix) + if self._surface_mesh is not None: + self._surface_mesh = self._surface_mesh.scale( + (sx, sy, sz), point=(px, py, pz), inplace=False + ) + # A cached input grid can't be rescaled in lockstep with the SDF + # pivot; drop it so .grid() re-samples the scaled field. + self._cached_grid = None + self._invalidate_derived() + + def rotate( self: Phase, - angles: tuple[float, float, float], - convention: str = "ZXZ", + rotation: Rotation | np.ndarray, + point: Sequence[float] | None = None, + *, + inplace: bool = False, ) -> Phase: - """Return a new :class:`Phase` rotated by Euler ``angles`` (degrees). - - Rotation is applied about the world origin. - - - CAD-backed: delegates to ``CadShape.rotate`` with axis-angle form. - - Field-backed: composes the field as - ``f'(p) = f(R^{-1} p)`` so the iso-surface rotates with the rest; - ``bounds`` is the AABB of the rotated original AABB. - - :param angles: Euler angles in degrees. - :param convention: rotation order (default ``"ZXZ"``); accepted by - ``scipy.spatial.transform.Rotation.from_euler``. + """Rotate the phase by a SciPy :class:`~scipy.spatial.transform.Rotation`. + + - CAD-backed: delegates to ``CadShape.rotate`` (axis-angle form). + - Field-backed: composes ``f'(p) = f(R^{-1}(p - point) + point)`` so + the iso-surface rotates with the rest; ``bounds`` becomes the AABB + of the rotated original AABB and ``period`` resets to ``None``. + + :param rotation: a ``Rotation`` object (or a 3x3 matrix), following + PyVista's ``RotationLike``. Build it with ``Rotation.from_euler`` + / ``from_matrix`` / ``from_quat`` / ``from_rotvec``. + :param point: pivot point; defaults to the world origin. + :param inplace: mutate ``self`` when ``True``; otherwise (default) + return a new rotated :class:`Phase`. """ - rot = Rotation.from_euler(convention, angles, degrees=True) - - if self._cad is not None: - rotvec = rot.as_rotvec(degrees=True) - angle = float(np.linalg.norm(rotvec)) - if angle == 0.0: - return Phase(name=self.name, resolution=self._resolution)._with_cad( - self._cad - ) - axis = rotvec / angle - new = Phase(name=self.name, resolution=self._resolution) - new._cad = self._cad.rotate((0.0, 0.0, 0.0), tuple(axis), angle) # noqa: SLF001 - return new + if self.is_empty: + err_msg = "Cannot rotate an empty Phase" + raise ValueError(err_msg) + rot = ( + rotation + if isinstance(rotation, Rotation) + else Rotation.from_matrix(np.asarray(rotation, dtype=np.float64)) + ) + pivot = ( + (0.0, 0.0, 0.0) + if point is None + else (float(point[0]), float(point[1]), float(point[2])) + ) + target = self if inplace else self.copy() + target._apply_rotate(rot, pivot) # noqa: SLF001 + return target + def _apply_rotate( + self: Phase, rot: Rotation, pivot: tuple[float, float, float] + ) -> None: + px, py, pz = pivot + p = np.array([px, py, pz], dtype=np.float64) if self._field is not None and self._bounds is not None: inv = rot.inv().as_matrix() rot_matrix = rot.as_matrix() f = self._field - # World-frame AABB of the rotated bbox: take all 8 corners, - # apply the rotation, and re-AABB. + + def _rotated_field(x, y, z, _f=f, _m=inv, _px=px, _py=py, _pz=pz): # noqa: ANN001, ANN202 + xx = x - _px + yy = y - _py + zz = z - _pz + lx = _m[0, 0] * xx + _m[0, 1] * yy + _m[0, 2] * zz + _px + ly = _m[1, 0] * xx + _m[1, 1] * yy + _m[1, 2] * zz + _py + lz = _m[2, 0] * xx + _m[2, 1] * yy + _m[2, 2] * zz + _pz + return _f(lx, ly, lz) + b = self._bounds corners = np.array( [ @@ -873,47 +977,37 @@ def rotated( for z in (b[4], b[5]) ] ) - rotated_corners = corners @ rot_matrix.T - new_bounds = ( - float(rotated_corners[:, 0].min()), - float(rotated_corners[:, 0].max()), - float(rotated_corners[:, 1].min()), - float(rotated_corners[:, 1].max()), - float(rotated_corners[:, 2].min()), - float(rotated_corners[:, 2].max()), + rc = (corners - p) @ rot_matrix.T + p + self._field = _rotated_field + self._bounds = ( + float(rc[:, 0].min()), + float(rc[:, 0].max()), + float(rc[:, 1].min()), + float(rc[:, 1].max()), + float(rc[:, 2].min()), + float(rc[:, 2].max()), ) - - def _rotated_field( - x: np.ndarray, - y: np.ndarray, - z: np.ndarray, - _f: Field = f, - _m: np.ndarray = inv, - ) -> np.ndarray: - lx = _m[0, 0] * x + _m[0, 1] * y + _m[0, 2] * z - ly = _m[1, 0] * x + _m[1, 1] * y + _m[1, 2] * z - lz = _m[2, 0] * x + _m[2, 1] * y + _m[2, 2] * z - return _f(lx, ly, lz) - - return Phase( - field=_rotated_field, - bounds=new_bounds, - iso=self._iso, - period=None, # rotation breaks axis-aligned periodicity - name=self.name, - resolution=self._resolution, + self._period = None # rotation breaks axis-aligned periodicity + if self._cad is not None: + rotvec = rot.as_rotvec(degrees=True) + angle = float(np.linalg.norm(rotvec)) + if angle != 0.0: + axis = rotvec / angle + self._cad = self._cad.rotate((px, py, pz), tuple(axis), angle) + if self._surface_mesh is not None: + self._surface_mesh = self._surface_mesh.rotate( + rot, point=(px, py, pz), inplace=False ) - err_msg = "Cannot rotate an empty Phase" - raise ValueError(err_msg) - - def _with_cad(self: Phase, cad: Any) -> Phase: - """Internal: return self with ``_cad`` set (used by transforms).""" - self._cad = cad - return self + if self._cached_grid is not None: + self._cached_grid = self._cached_grid.rotate( + rot, point=(px, py, pz), inplace=False + ) + self._invalidate_derived() - def tiled(self: Phase, rve: Rve, grid: tuple[int, int, int]) -> Phase: - """Return a new :class:`Phase` periodically tiled on the RVE. + def tile(self: Phase, rve: Rve, grid: tuple[int, int, int]) -> Phase: + """Periodically tile the phase on the RVE over ``grid`` copies. + A **producer**: always returns a new :class:`Phase` (no ``inplace``). Builds ``∏ grid`` translated copies of the current phase and fuses them. Only implemented for CAD-backed phases today (the field-backed equivalent — domain folding via ``mod`` — lands in @@ -923,7 +1017,7 @@ def tiled(self: Phase, rve: Rve, grid: tuple[int, int, int]) -> Phase: """ if self._cad is None: err_msg = ( - "Phase.tiled is implemented for CAD-backed phases today; " + "Phase.tile is implemented for CAD-backed phases today; " "field-backed tiling lives on the source Shape (use " "microgen.shape.implicit_ops.repeat there)." ) diff --git a/microgen/shape/shape.py b/microgen/shape/shape.py index d1718d54..2f8af42c 100644 --- a/microgen/shape/shape.py +++ b/microgen/shape/shape.py @@ -363,15 +363,30 @@ def smooth_difference(self: Shape, other: Shape, k: float) -> Shape: # Implicit field transforms # ------------------------------------------------------------------ - def translated(self: Shape, offset: tuple[float, float, float]) -> Shape: - """Return a new shape translated by *offset*. + def translate( + self: Shape, offset: tuple[float, float, float], *, inplace: bool = False + ) -> Shape: + """Translate the shape by *offset* (PyVista convention). - The returned :class:`Shape` has its ``center`` shifted by *offset* - and its ``bounds`` updated; ``orientation`` is preserved. The - implicit field is composed so ``evaluate(p) == old.evaluate(p - offset)``. + ``inplace=False`` (default) returns a new :class:`Shape`; + ``inplace=True`` mutates ``self``. The implicit field is composed so + ``evaluate(p) == old.evaluate(p - offset)``; ``center`` shifts by + *offset*, ``bounds`` updates, ``orientation`` is preserved. """ f = self.require_field() dx, dy, dz = offset + + def new_field( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + _f: Field = f, + _dx: float = dx, + _dy: float = dy, + _dz: float = dz, + ) -> npt.NDArray[np.float64]: + return _f(x - _dx, y - _dy, z - _dz) + new_bounds = None if self._bounds is not None: b = self._bounds @@ -384,31 +399,43 @@ def translated(self: Shape, offset: tuple[float, float, float]) -> Shape: b[5] + dz, ) cx, cy, cz = self._center + new_center = (cx + dx, cy + dy, cz + dz) + if inplace: + self._field = new_field + self._bounds = new_bounds + self._center = new_center + self._grid_cache.clear() + return self return Shape( - field=lambda x, y, z, _f=f, _dx=dx, _dy=dy, _dz=dz: _f( - x - _dx, - y - _dy, - z - _dz, - ), + field=new_field, bounds=new_bounds, - center=(cx + dx, cy + dy, cz + dz), + center=new_center, orientation=self._orientation, ) - def rotated( + def rotate( self: Shape, - angles: tuple[float, float, float], - convention: str = "ZXZ", + rotation: Rotation | npt.NDArray[np.float64], + *, + inplace: bool = False, ) -> Shape: - """Return a new shape rotated by Euler *angles* (degrees). + """Rotate the shape by *rotation* about the world origin (PyVista convention). - The rotation is applied **about the world origin**. The returned - shape's ``center`` is the rotated original center, ``orientation`` - composes left with the rotation, and ``bounds`` is the AABB of - the rotated original AABB. + :param rotation: a SciPy ``Rotation`` (or a 3x3 matrix). Build it via + ``Rotation.from_euler`` / ``from_matrix`` / ``from_quat`` / etc. + :param inplace: mutate ``self`` when ``True``; otherwise (default) + return a new rotated :class:`Shape`. + + The returned shape's ``center`` is the rotated original center, + ``orientation`` composes left with the rotation, and ``bounds`` is + the AABB of the rotated original AABB. """ f = self.require_field() - rot = Rotation.from_euler(convention, angles, degrees=True) + rot = ( + rotation + if isinstance(rotation, Rotation) + else Rotation.from_matrix(np.asarray(rotation, dtype=np.float64)) + ) rot_matrix = rot.as_matrix() inv_matrix = rot.inv().as_matrix() new_bounds = None @@ -427,21 +454,41 @@ def rotated( float(rotated[:, 2].max()), ) rotated_center = rot_matrix @ np.asarray(self._center, dtype=np.float64) + + def new_field( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + _f: Field = f, + _m: npt.NDArray[np.float64] = inv_matrix, + ) -> npt.NDArray[np.float64]: + return _f(*(_m @ np.array([x, y, z]))) + + new_center = tuple(rotated_center.tolist()) + new_orientation = rot * self._orientation + if inplace: + self._field = new_field + self._bounds = new_bounds + self._center = new_center + self._orientation = new_orientation + self._grid_cache.clear() + return self return Shape( - field=lambda x, y, z, _f=f, _m=inv_matrix: _f( - *(_m @ np.array([x, y, z])), - ), + field=new_field, bounds=new_bounds, - center=tuple(rotated_center.tolist()), - orientation=rot * self._orientation, + center=new_center, + orientation=new_orientation, ) - def scaled(self: Shape, factor: float) -> Shape: - """Return a new shape uniformly scaled by *factor* about the world origin. + def scale(self: Shape, factor: float, *, inplace: bool = False) -> Shape: + """Uniformly scale by *factor* about the world origin (PyVista convention). ``center`` is scaled by the same factor; ``orientation`` is - preserved; ``bounds`` is scaled (with axis-pair swap for - negative factors). + preserved; ``bounds`` is scaled (with axis-pair swap for negative + factors). + + :param inplace: mutate ``self`` when ``True``; otherwise (default) + return a new scaled :class:`Shape`. """ f = self.require_field() new_bounds = None @@ -464,10 +511,27 @@ def scaled(self: Shape, factor: float) -> Shape: new_bounds[5], new_bounds[4], ) + + def new_field( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + z: npt.NDArray[np.float64], + _f: Field = f, + _s: float = factor, + ) -> npt.NDArray[np.float64]: + return _f(x / _s, y / _s, z / _s) * _s + cx, cy, cz = self._center + new_center = (cx * factor, cy * factor, cz * factor) + if inplace: + self._field = new_field + self._bounds = new_bounds + self._center = new_center + self._grid_cache.clear() + return self return Shape( - field=lambda x, y, z, _f=f, _s=factor: _f(x / _s, y / _s, z / _s) * _s, + field=new_field, bounds=new_bounds, - center=(cx * factor, cy * factor, cz * factor), + center=new_center, orientation=self._orientation, ) diff --git a/tests/shapes/test_default_mesh_methods.py b/tests/shapes/test_default_mesh_methods.py index 9d42475f..5266a33d 100644 --- a/tests/shapes/test_default_mesh_methods.py +++ b/tests/shapes/test_default_mesh_methods.py @@ -146,14 +146,14 @@ def test_orientation_is_read_only_on_shape(): def test_translated_propagates_center(): """``translated`` shifts the reported center by the offset.""" s = Sphere(radius=1.0, center=(1.0, 2.0, 3.0)) - shifted = s.translated((4.0, -1.0, 2.0)) + shifted = s.translate((4.0, -1.0, 2.0)) assert tuple(shifted.center) == pytest.approx((5.0, 1.0, 5.0)) def test_translated_preserves_orientation(): """``translated`` does not touch orientation.""" s = Sphere(radius=1.0, orientation=Rotation.from_euler("z", 30, degrees=True)) - shifted = s.translated((1.0, 0.0, 0.0)) + shifted = s.translate((1.0, 0.0, 0.0)) np.testing.assert_allclose( shifted.orientation.as_matrix(), s.orientation.as_matrix(), @@ -163,7 +163,7 @@ def test_translated_preserves_orientation(): def test_rotated_propagates_center_and_orientation(): """``rotated`` rotates the reported center about the world origin.""" s = Sphere(radius=1.0, center=(1.0, 0.0, 0.0)) - rotated = s.rotated((0.0, 0.0, 90.0), convention="xyz") + rotated = s.rotate(Rotation.from_euler("xyz", (0.0, 0.0, 90.0), degrees=True)) # Rotation of (1, 0, 0) by 90° about z lands at (0, 1, 0). np.testing.assert_allclose(rotated.center, (0.0, 1.0, 0.0), atol=1e-9) @@ -171,7 +171,7 @@ def test_rotated_propagates_center_and_orientation(): def test_scaled_propagates_center(): """``scaled`` scales the reported center by the same factor.""" s = Sphere(radius=1.0, center=(2.0, 0.0, 0.0)) - scaled = s.scaled(3.0) + scaled = s.scale(3.0) assert tuple(scaled.center) == pytest.approx((6.0, 0.0, 0.0)) diff --git a/tests/shapes/test_implicit_ops.py b/tests/shapes/test_implicit_ops.py index 8804ac77..291f93b4 100644 --- a/tests/shapes/test_implicit_ops.py +++ b/tests/shapes/test_implicit_ops.py @@ -5,6 +5,7 @@ import numpy as np import pytest import pyvista as pv +from scipy.spatial.transform import Rotation from microgen.shape.implicit_ops import ( batch_smooth_union, @@ -222,7 +223,7 @@ class TestTransforms: def test_translated(self): s = _make_sphere() - st = s.translated((2, 0, 0)) + 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 @@ -233,7 +234,7 @@ def test_rotated_90(self): # Elongated box along x, rotated 90° around z -> elongated along y func, bounds = _box_field(hx=1.0, hy=0.1, hz=0.1) box = Shape(field=func, bounds=bounds) - rotated = box.rotated((0, 0, 90), convention="xyz") + rotated = box.rotate(Rotation.from_euler("xyz", (0, 0, 90), degrees=True)) # Point along y axis should be inside assert ( rotated.evaluate(np.array([0.0]), np.array([0.5]), np.array([0.0]))[0] < 0 @@ -245,20 +246,20 @@ def test_rotated_90(self): def test_scaled(self): s = _make_sphere() - ss = s.scaled(2.0) + 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_translated_bounds(self): s = _make_sphere() - st = s.translated((5, 0, 0)) + st = s.translate((5, 0, 0)) assert st.bounds is not None assert st.bounds[0] > 3.0 def test_scaled_bounds(self): s = _make_sphere() - ss = s.scaled(3.0) + ss = s.scale(3.0) assert ss.bounds is not None assert ss.bounds[1] > 3.0 @@ -404,11 +405,11 @@ class TestErrorHandling: def test_transform_without_func_raises(self): s = Shape() with pytest.raises(ValueError, match="No implicit scalar field"): - s.translated((1, 0, 0)) + s.translate((1, 0, 0)) with pytest.raises(ValueError, match="No implicit scalar field"): - s.rotated((0, 0, 45)) + s.rotate(Rotation.from_euler("ZXZ", (0, 0, 45), degrees=True)) with pytest.raises(ValueError, match="No implicit scalar field"): - s.scaled(2.0) + s.scale(2.0) def test_boolean_without_func_raises(self): s = Shape() diff --git a/tests/test_phase.py b/tests/test_phase.py index a9657f6c..94677bf2 100644 --- a/tests/test_phase.py +++ b/tests/test_phase.py @@ -53,7 +53,7 @@ def test_phase_tiled_should_repeat_the_shape_in_the_rve() -> None: phase = Phase.from_cad(box) repeat = (1, 2, 1) - tiled = phase.tiled(rve, grid=repeat) + tiled = phase.tile(rve, grid=repeat) assert len(tiled.cad.solids()) == np.prod(repeat) assert np.isclose(tiled.cad.volume(), 2.0 * volume_before) @@ -64,7 +64,7 @@ def test_phase_scaled_should_change_the_size_of_the_shape() -> None: scale = 1.5 phase = Phase.from_cad(Sphere(radius=radius).generate_cad()) volume_before = phase.cad.volume() - scaled = phase.scaled(scale) + scaled = phase.scale(scale) assert np.isclose(scaled.cad.volume(), volume_before * scale**3, rtol=1e-2) @@ -74,9 +74,9 @@ def test_phase_translated_should_shift_centers() -> None: phase = Phase.from_cad( Ellipsoid(center=center, radii=(0.15, 0.31, 0.4)).generate_cad() ) - moved = phase.translated((1, 0, 0)) + moved = phase.translate((1, 0, 0)) assert np.allclose(moved.center_of_mass, (2.0, 0.5, -0.5), rtol=1e-4) - moved2 = moved.translated(np.array([0, 1, 1])) + moved2 = moved.translate(np.array([0, 1, 1])) assert np.allclose(moved2.center_of_mass, (2.0, 1.5, 0.5), rtol=1e-4) diff --git a/tests/test_phase_constructors.py b/tests/test_phase_constructors.py index 1afac2cb..9db8cbc1 100644 --- a/tests/test_phase_constructors.py +++ b/tests/test_phase_constructors.py @@ -4,13 +4,14 @@ derives ``bounds`` from a :class:`Rve`. - :meth:`Phase.from_grid` — wraps a pre-sampled :class:`pyvista.StructuredGrid` (useful for expensive-to-evaluate fields). -- :meth:`Phase.rotated` — the missing immutable transform paired with - ``translated`` / ``scaled`` / ``tiled``. +- :meth:`Phase.rotate` — the rotation transform paired with + ``translate`` / ``scale`` / ``tile``. """ import numpy as np import pytest import pyvista as pv +from scipy.spatial.transform import Rotation from microgen import Box, Phase, Rve, Sphere, Tpms, surface_functions @@ -74,7 +75,7 @@ def test_phase_rotated_field_backed_swaps_axes() -> None: phase = Phase.from_shape(box, resolution=40) # f(0.5, 0, 0) < 0 before, > 0 after; f(0, 0.5, 0) < 0 after. f_before = phase.field(np.array([0.5]), np.array([0.0]), np.array([0.0]))[0] - rotated = phase.rotated((0, 0, 90), convention="xyz") + rotated = phase.rotate(Rotation.from_euler("xyz", (0, 0, 90), degrees=True)) f_after = rotated.field(np.array([0.5]), np.array([0.0]), np.array([0.0]))[0] f_y = rotated.field(np.array([0.0]), np.array([0.5]), np.array([0.0]))[0] assert f_before < 0 @@ -87,14 +88,14 @@ def test_phase_rotated_field_backed_invalidates_period() -> None: tpms = Tpms(surface_function=surface_functions.gyroid, offset=0.3, cell_size=1.0) phase = Phase.from_shape(tpms) assert phase.period == (1.0, 1.0, 1.0) - rotated = phase.rotated((30, 0, 0)) + rotated = phase.rotate(Rotation.from_euler("ZXZ", (30, 0, 0), degrees=True)) assert rotated.period is None def test_phase_rotated_cad_backed_preserves_volume() -> None: """CAD-backed rotation produces a Phase with the same volume.""" sph = Phase.from_cad(Sphere(radius=0.5).generate_cad()) - rotated = sph.rotated((0, 0, 45)) + rotated = sph.rotate(Rotation.from_euler("ZXZ", (0, 0, 45), degrees=True)) # CAD-backed: same volume (rigid transform). assert np.isclose(rotated.cad.volume(), sph.cad.volume(), rtol=1e-6) @@ -102,4 +103,4 @@ def test_phase_rotated_cad_backed_preserves_volume() -> None: def test_phase_rotated_empty_raises() -> None: """An empty Phase cannot be rotated.""" with pytest.raises(ValueError, match="Cannot rotate an empty Phase"): - Phase().rotated((10, 0, 0)) + Phase().rotate(Rotation.from_euler("ZXZ", (10, 0, 0), degrees=True)) diff --git a/tests/test_phase_pieces.py b/tests/test_phase_pieces.py index cbc96347..d354f050 100644 --- a/tests/test_phase_pieces.py +++ b/tests/test_phase_pieces.py @@ -74,7 +74,7 @@ def test_phase_translated_preserves_period_and_invalidates_cache() -> None: b = Sphere(center=(0.6, 0.0, 0.0), radius=0.2) merged = union(a, b) phase = Phase.from_shape(merged, bounds=(-1.0, 1.0, -0.4, 0.4, -0.4, 0.4)) - moved = phase.translated((1.0, 0.0, 0.0)) + moved = phase.translate((1.0, 0.0, 0.0)) # New Phase => caches independent; both still report 2 pieces. assert len(moved.pieces) == 2 shifts = sorted(p.com[0] for p in moved.pieces)