Transformations and Projections#

Geometric Transformations#

Linear and affine transformations on mesh geometry. Each function returns a new Mesh with transformed point coordinates and appropriately invalidated caches. Cached quantities such as normals and areas are automatically recomputed on next access.

All transformations are also available as methods on Mesh.

import numpy as np
from physicsnemo.mesh.primitives.surfaces import sphere_icosahedral

mesh = sphere_icosahedral.load(subdivisions=3)

# Via Mesh methods
translated = mesh.translate([1.0, 0.0, 0.0])
rotated = mesh.rotate(axis=[0, 0, 1], angle=np.pi / 4)
scaled = mesh.scale(2.0)
scaled_aniso = mesh.scale([2.0, 1.0, 0.5])

# Arbitrary linear transform
import torch
matrix = torch.eye(3) * 2
transformed = mesh.transform(matrix)

Linear and affine transformations for simplicial meshes.

This module implements geometric point transformations with intelligent cache handling. By default, all caches are invalidated; transformations explicitly opt in to preserve or update valid cache fields.

Cached fields handled: - areas: point_data and cell_data - normals: point_data and cell_data - centroids: cell_data only

physicsnemo.mesh.transformations.geometric.rotate(
mesh: Mesh,
angle: float,
axis: Float[Tensor, 'n_spatial_dims'] | Sequence[float] | Literal['x', 'y', 'z'] | None = None,
center: Float[Tensor, 'n_spatial_dims'] | Sequence[float] | None = None,
transform_point_data: bool | TensorDict = False,
transform_cell_data: bool | TensorDict = False,
transform_global_data: bool | TensorDict = False,
) Mesh[source]#

Rotate the mesh about an axis by a specified angle.

Call it as rotate(mesh, ...) or as mesh.rotate(...). The bound method supplies mesh automatically.

Parameters:
  • mesh (Mesh) – Input mesh to rotate.

  • angle (float) – Rotation angle in radians (counterclockwise, right-hand rule).

  • axis (Float[torch.Tensor, " n_spatial_dims"] or Sequence[float] or {"x", "y", "z"} or None) – Rotation axis vector. None for 2D, shape \((3,)\) for 3D. String literals "x", "y", "z" are converted to unit vectors (1,0,0), (0,1,0), (0,0,1) respectively.

  • center (Float[torch.Tensor, " n_spatial_dims"] or Sequence[float] or None) – Center point for rotation. If None, rotates about the origin.

  • transform_point_data (bool or TensorDict) – Controls transformation of point_data fields. See transform() for full semantics.

  • transform_cell_data (bool or TensorDict) – Same semantics as transform_point_data, for cell_data.

  • transform_global_data (bool or TensorDict) – Same semantics as transform_point_data, for global_data.

Returns:

New Mesh with rotated geometry.

Return type:

Mesh

Notes

Cache Handling:

  • areas: Unchanged (rotation preserves volumes)

  • centroids: Rotated

  • normals: Rotated

physicsnemo.mesh.transformations.geometric.rotation_matrix(
angle: float,
axis: Float[Tensor, 'n_spatial_dims'] | Sequence[float] | Literal['x', 'y', 'z'] | None,
n_spatial_dims: int,
device: device,
dtype: dtype,
) Float[Tensor, 'n_spatial_dims n_spatial_dims'][source]#

Build a rotation matrix from angle and axis.

Parameters:
  • angle (float) – Rotation angle in radians (counterclockwise, right-hand rule).

  • axis (Float[torch.Tensor, " n_spatial_dims"] or Sequence[float] or {"x", "y", "z"} or None) – Rotation axis. None for 2D, tensor/sequence/string for 3D.

  • n_spatial_dims (int) – Number of spatial dimensions.

  • device (torch.device) – Target device for the output matrix.

  • dtype (torch.dtype) – Target dtype for the output matrix.

Returns:

Rotation matrix, shape \((S, S)\).

Return type:

Float[torch.Tensor, “n_spatial_dims n_spatial_dims”]

physicsnemo.mesh.transformations.geometric.scale(
mesh: Mesh,
factor: float | Float[Tensor, 'n_spatial_dims'] | Sequence[float],
center: Float[Tensor, 'n_spatial_dims'] | Sequence[float] | None = None,
transform_point_data: bool | TensorDict = False,
transform_cell_data: bool | TensorDict = False,
transform_global_data: bool | TensorDict = False,
assume_invertible: bool | None = None,
) Mesh[source]#

Scale the mesh by specified factor(s).

Call it as scale(mesh, ...) or as mesh.scale(...). The bound method supplies mesh automatically.

Parameters:
  • mesh (Mesh) – Input mesh to scale.

  • factor (float or Float[torch.Tensor, " n_spatial_dims"] or Sequence[float]) – Scale factor(s). Scalar for uniform, vector for non-uniform.

  • center (Float[torch.Tensor, " n_spatial_dims"] or Sequence[float] or None) – Center point for scaling. If None, scales about the origin.

  • transform_point_data (bool or TensorDict) – Controls transformation of point_data fields. See transform() for full semantics.

  • transform_cell_data (bool or TensorDict) – Same semantics as transform_point_data, for cell_data.

  • transform_global_data (bool or TensorDict) – Same semantics as transform_point_data, for global_data.

  • assume_invertible (bool or None) –

    Controls cache propagation:

    • True: Assume all factors are non-zero, propagate caches (compile-safe)

    • False: Assume some factor is zero, skip cache propagation (compile-safe)

    • None: Check determinant at runtime (may cause graph breaks under torch.compile)

Returns:

New Mesh with scaled geometry.

Return type:

Mesh

Notes

Cache Handling:

  • areas: Scaled correctly. For non-isotropic transforms of codimension-1

    embedded manifolds, per-element scaling is computed using normals.

  • centroids: Scaled

  • normals: Transformed by inverse-transpose (direction adjusted, magnitude normalized)

physicsnemo.mesh.transformations.geometric.scale_matrix(
factor: float | Float[Tensor, 'n_spatial_dims'] | Sequence[float],
n_spatial_dims: int,
device: device,
dtype: dtype,
) Float[Tensor, 'n_spatial_dims n_spatial_dims'][source]#

Build a diagonal scale matrix from a factor specification.

Parameters:
  • factor (float or Float[torch.Tensor, " n_spatial_dims"] or Sequence[float]) – Scale factor(s). Scalar for uniform, vector for non-uniform.

  • n_spatial_dims (int) – Number of spatial dimensions.

  • device (torch.device) – Target device for the output matrix.

  • dtype (torch.dtype) – Target dtype for the output matrix.

Returns:

Diagonal scale matrix, shape \((S, S)\).

Return type:

Float[torch.Tensor, “n_spatial_dims n_spatial_dims”]

Raises:

ValueError – If factor is a vector whose length does not match n_spatial_dims.

physicsnemo.mesh.transformations.geometric.transform(
mesh: Mesh,
matrix: Float[Tensor, 'new_n_spatial_dims n_spatial_dims'],
transform_point_data: bool | TensorDict = False,
transform_cell_data: bool | TensorDict = False,
transform_global_data: bool | TensorDict = False,
assume_invertible: bool | None = None,
) Mesh[source]#

Apply a linear transformation to the mesh.

Call it as transform(mesh, ...) or as mesh.transform(...). The bound method supplies mesh automatically.

Parameters:
  • mesh (Mesh) – Input mesh to transform.

  • matrix (Float[torch.Tensor, "new_n_spatial_dims n_spatial_dims"]) – Transformation matrix, shape \((S', S)\).

  • transform_point_data (bool or TensorDict) – Controls transformation of point_data fields. True transforms all compatible fields; False transforms none; a TensorDict (or dict) with scalar bool leaves selectively transforms only the named fields.

  • transform_cell_data (bool or TensorDict) – Same semantics as transform_point_data, for cell_data.

  • transform_global_data (bool or TensorDict) – Same semantics as transform_point_data, for global_data.

  • assume_invertible (bool or None) –

    Controls cache propagation for square matrices:

    • True: assume matrix is invertible and propagate caches (compile-safe). This is a promise, not a check. If matrix is in fact singular, the inverse-transpose step silently yields non-finite values instead of raising, so the propagated normals and areas caches – and anything derived from them, such as the sum of cell_areas – come back as NaN. Use False or None unless you know the matrix is non-singular.

    • False: assume matrix is singular and skip cache propagation (compile-safe). Caches are dropped and recomputed lazily on demand, which is always correct, just slower.

    • None (default): test abs(det(matrix)) > 1e-10 at runtime and take one of the branches above. Safe for singular input, but the test reads a device scalar back to the host, which synchronizes on CUDA and may cause graph breaks under torch.compile.

Returns:

New Mesh with transformed geometry and appropriately updated caches.

Return type:

Mesh

Notes

Cache Handling:

  • areas: For square invertible matrices:

    • Full-dimensional meshes: scaled by |det|

    • Codimension-1 manifolds: per-element scaling using |det| * ||M^{-T} n||

    • Higher codimension: invalidated

  • centroids: Always transformed

  • normals: For square invertible matrices, transformed by inverse-transpose

physicsnemo.mesh.transformations.geometric.translate(
mesh: Mesh,
offset: Float[Tensor, 'n_spatial_dims'] | Sequence[float],
) Mesh[source]#

Apply a translation to the mesh.

Translation only affects point positions and centroids. Vector/tensor fields are unchanged by translation (they represent directions, not positions).

Call it as translate(mesh, ...) or as mesh.translate(...). The bound method supplies mesh automatically.

Parameters:
  • mesh (Mesh) – Input mesh to translate.

  • offset (Float[torch.Tensor, " n_spatial_dims"] or Sequence[float]) – Translation vector, shape \((S,)\).

Returns:

New Mesh with translated geometry.

Return type:

Mesh

Notes

Cache Handling:

  • areas: Unchanged

  • centroids: Translated

  • normals: Unchanged

Deformations#

The deform namespace provides four deformation families:

Each operation is also available as a method on Mesh.

Dense displacement accepts a tensor or a point-data key (including a nested tuple key). The operation returns a new mesh without changing mesh.points. Assigning a point-data key, as in the second example below, is a separate, explicit mutation of the source mesh’s attached data.

displacement = torch.zeros_like(mesh.points)
displacement[:, 2] = 0.05
displaced = mesh.displace(0.5 * displacement)

# Point-data fields can drive the same operation.
mesh.point_data["design_displacement"] = displacement
displaced_from_data = mesh.displace("design_displacement")

Sparse controls are useful when only a small set of design handles is known. A control point is a location in world coordinates, and its control displacement is a vector rather than a destination coordinate. Control points do not need to be mesh vertices, although selecting vertices makes their prescribed movement directly visible in the result.

Single-Control Morphing#

Indexing one vertex produces a coordinate vector with shape (3,). The morph API instead expects (n_controls, n_spatial_dims), so unsqueeze(0) adds the control dimension and gives shape (1, 3). It is not a batch dimension.

top_index = mesh.points[:, 2].argmax()
control_points = mesh.points[top_index].unsqueeze(0)  # (1, 3)
control_displacements = mesh.points.new_tensor(
    [[0.0, 0.0, 0.5]], requires_grad=True
)
single_morph = mesh.morph(
    control_points,
    control_displacements,
    radius=1.0,
)

# Autograd continues through the returned point coordinates.
objective = single_morph.points.square().mean()
objective.backward()

control_displacements is differentiable. An optimizer can learn it from any differentiable loss computed from single_morph.points. A model can also predict the displacements.

Without point weights, a mesh vertex exactly at a unique control moves by its prescribed displacement. point_weights can scale or mask the final movement. Duplicate controls at the same coordinate contribute their mean displacement.

Multiple-Control Morphing#

Advanced indexing retains the control dimension when several vertices are selected. Each row of control_points pairs with the same row of control_displacements and, when supplied, one entry of radius.

bottom_index = mesh.points[:, 2].argmin()
control_indices = torch.stack((top_index, bottom_index))
control_points = mesh.points[control_indices]  # (2, 3)
control_displacements = mesh.points.new_tensor(
    [[0.0, 0.0, 0.5], [0.0, 0.0, -0.5]]
)
radii = mesh.points.new_tensor([1.0, 1.0])

multiple_morph = mesh.morph(
    control_points,
    control_displacements,
    radius=radii,
)

The radius is a Euclidean support distance in the same coordinate units as the mesh. A control’s influence vanishes smoothly at its support boundary. Where supports overlap, all active controls are evaluated together using a stationary zero-displacement background. The result is not a simple sum or average. Points outside every support remain unchanged. Put simultaneous controls in one call, because applying several morphs sequentially evaluates later fields on already modified coordinates and is therefore order-dependent.

The kernel keyword names the compact radial kernel used by the field. "wendland_c2" is currently the supported value and the default.

Every tensor-valued radius must remain finite and strictly positive. Its values are not validated at runtime. When a model learns the radius, use a positive parameterization such as torch.nn.functional.softplus(raw_radius) + radius_epsilon rather than optimizing an unconstrained radius directly. Floating point_weights are used as supplied and may be signed or greater than one.

Visualization

The panels compare the original sphere with the single-control and multiple-control examples above. Green markers identify the displaced handle locations, while arrows and labels show the prescribed displacement directions and magnitudes.

Original sphere and single-control and multiple-control sphere morphing

Global Radial-Basis Deformation#

Radial-basis deformation fits one global displacement field through sparse handles. With zero smoothing and a nonsingular control layout, the fitted field interpolates each prescribed control displacement up to solver precision. Optional point weights are applied after interpolation. Unlike compact Shepard morphing, every control generally influences every point. Fixed controls are therefore useful as anchors.

The standard affine polynomial tail reproduces affine displacement fields. The controls must affinely span the coordinate space, and the augmented system must be nonsingular. This formulation follows the thin-plate-spline interpolant described by Bookstein [1].

[1] F. L. Bookstein, “Principal warps: thin-plate splines and the decomposition of deformations,” IEEE Transactions on Pattern Analysis and Machine Intelligence, vol. 11, no. 6, pp. 567-585, 1989. https://doi.org/10.1109/34.24792

Two-Dimensional Example

A mesh with n_spatial_dims=2 requires at least three non-collinear controls when the affine tail is enabled. This example fixes the four corners of a triangulated square and moves a fifth handle at the midpoint of its upper edge.

import torch
from physicsnemo.mesh.primitives.planar import unit_square

mesh_2d = unit_square.load(subdivisions=4)
controls_2d = mesh_2d.points.new_tensor(
    [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.5, 1.0]]
)
displacements_2d = torch.zeros_like(controls_2d)
displacements_2d[-1] = controls_2d.new_tensor([0.15, 0.35])

deformed_2d = mesh_2d.radial_basis_function_deform(
    controls_2d,
    displacements_2d,
    kernel="thin_plate_spline",
    polynomial=True,
    smoothing=0.0,
)

handle_index = torch.linalg.vector_norm(
    mesh_2d.points - controls_2d[-1], dim=1
).argmin()
torch.testing.assert_close(
    deformed_2d.points[handle_index],
    controls_2d[-1] + displacements_2d[-1],
    atol=2.0e-5,
    rtol=2.0e-5,
)

The corner controls remain fixed while the upper edge and interior deform smoothly. The hollow green marker in the output panel shows the handle’s source position.

Two-dimensional global RBF deformation of a triangulated square

Three-Dimensional Example

In three dimensions, the affine tail requires at least four controls spanning the coordinate space. Practical layouts usually use more. The sphere example uses its six axis-extreme vertices as controls.

control_indices = torch.stack(
    (
        mesh.points[:, 2].argmax(),
        mesh.points[:, 2].argmin(),
        mesh.points[:, 0].argmax(),
        mesh.points[:, 0].argmin(),
        mesh.points[:, 1].argmax(),
        mesh.points[:, 1].argmin(),
    )
)
rbf_controls = mesh.points[control_indices]
rbf_displacements = torch.zeros_like(rbf_controls)
rbf_displacements[0, 2] = 0.55

exact_rbf = mesh.radial_basis_function_deform(
    rbf_controls,
    rbf_displacements,
    kernel="thin_plate_spline",
    polynomial=True,
    smoothing=0.0,
)

torch.testing.assert_close(
    exact_rbf.points[control_indices],
    rbf_controls + rbf_displacements,
    atol=2.0e-5,
    rtol=2.0e-5,
)

smoothing=0.0 interpolates the controls up to solver precision. A positive smoothing value adds diagonal regularization. This deliberately relaxes interpolation accuracy.

Both evaluation backends use PyTorch for the dense coefficient solve. The Warp backend fuses evaluation over the mesh points without materializing its full point/control kernel matrix.

Orange controls are fixed anchors. Green controls mark moved handles, and the arrows show their displacement directions. The labels give the prescribed magnitudes. With zero smoothing, the deformed surfaces interpolate all six control displacements up to solver precision.

Original sphere with one-handle and two-handle RBF deformations

Lattice Free-Form Deformation#

free_form_deform() defines a regular array of control displacements over an axis-aligned evaluation box and deforms every point inside the box by tensor-product basis interpolation. Compared with sparse morphing, the design parameters form a structured grid of fixed size, which suits parametric shape optimization. A lattice of zeros is exactly the identity, and the same lattice deforms any geometry embedded in the box.

When origin and extent are omitted, the box spans the mesh bounds. Each coordinate axis must have positive range. Planar or linear geometry embedded in a higher-dimensional space therefore needs an explicit positive extent. Validating an automatically derived extent synchronizes with the device and is not CUDA Graph capture-safe. For capture, pass both origin and extent as device tensors.

basis="bernstein" provides classic global-support free-form deformation for coarse lattices. basis="bspline" provides local four-node-per-axis support and scales to fine lattices for local sculpting. Its first and last coefficient planes lie one knot spacing outside the evaluation box. basis="linear", "cubic_hermite", and "quintic_hermite" instead use the two neighboring nodes per axis and reproduce every control displacement at its lattice node. For a local cell coordinate \(t\), their upper-node weights are \(t\), \(3t^2-2t^3\) (cubic Hermite), and \(6t^5-15t^4+10t^3\) (quintic Hermite), respectively. The lower-node weight is one minus the upper-node weight. The resulting fields are C0, C1, and C2 across cell boundaries, respectively. Perlin introduced the quintic blend in Improving Noise to eliminate the cubic blend’s second-derivative discontinuities.

# A 4x4x4 Bernstein lattice spans the mesh bounds.
# Zero displacements start at the identity.
control_displacements = torch.zeros(4, 4, 4, 3, requires_grad=True)
deformed = mesh.free_form_deform(control_displacements)

# Autograd continues through the returned point coordinates.
objective = deformed.points.square().mean()
objective.backward()

Points outside the lattice box are unchanged. The deformation is generally not continuous across the box boundary. A sufficient condition for a fixed exterior is to zero the outermost coefficient plane on every Bernstein or node-interpolating face. For cubic B-splines, zero the first and last three coefficient planes on every axis. origin and extent are non-differentiable lattice parameters. Optimize control_displacements instead.

Visualization

The panels compare the original sphere with two lattice deformations. A coarse Bernstein lattice tapers the whole sphere because every node acts globally, while three independently displaced regions in a finer cubic B-spline lattice produce two local bulges and a local indentation.

Sphere with control lattice, Bernstein taper, and local B-spline sculpt

Domain Meshes#

morph() evaluates one world-coordinate control field on the interior and every named boundary. With point_weights=None, coincident component points receive identical motion. Domain point weights must instead be a point-data key (or nested tuple key) present in every component. Raw weight tensors are rejected because component point counts can differ. Every resolved field must use one common dtype across the domain: bool for a hard mask, or the same floating dtype as the mesh points. Coincident points remain coincident under a point-weight key only when their resolved values also match.

radial_basis_function_deform() follows the same component and point-weight rules while fitting one global RBF field. The coefficient system is solved once, and the combined interior and boundary points are evaluated together before the component meshes are rebuilt.

import torch
from physicsnemo.mesh import DomainMesh, Mesh

interior = Mesh(
    points=torch.tensor([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]),
    cells=torch.tensor([[0, 1, 2]]),
    point_data={"design_weight": torch.tensor([1.0, 0.8, 0.5])},
)
wall = Mesh(
    points=interior.points[:2],
    cells=torch.tensor([[0, 1]]),
    point_data={"design_weight": torch.tensor([1.0, 0.8])},
)
domain = DomainMesh(interior=interior, boundaries={"wall": wall})

domain_controls = interior.points[[0]]  # (1, 2)
domain_displacements = interior.points.new_tensor([[0.0, 0.25]])
morphed_domain = domain.morph(
    domain_controls,
    domain_displacements,
    radius=1.25,
    point_weights="design_weight",
    implementation="torch",
)

# Equal point weights keep the shared wall vertices coincident.
assert torch.allclose(
    morphed_domain.interior.points[:2],
    morphed_domain.boundaries["wall"].points,
)

free_form_deform() follows the same pattern for lattice free-form deformation:

  • The operation evaluates one lattice field over the combined interior and boundary points.

  • The default box spans the combined component bounds.

The combined bounds must have positive range on every coordinate axis unless an explicit extent is supplied.

Every deformation preserves connectivity and attached point, cell, global, and domain data. These operations treat attached vector and tensor fields as Lagrangian data and do not push them forward. They discard geometry-dependent caches and recompute them lazily. They retain topology caches.

Warning

Deformations do not detect or repair inverted, degenerate, or self-intersecting output cells. Call validate() or validate() explicitly when a deformation could compromise validity.

For optimization-time geometric penalties on a fixed topology, see Differentiable Deformation Energies.

physicsnemo.mesh.transformations.deform.displace(
mesh: Mesh,
displacement: str | tuple[str, ...] | Tensor,
*,
point_weights: str | tuple[str, ...] | Tensor | None = None,
implementation: Literal['torch'] | None = None,
) Mesh[source]#

Displace every mesh point by a dense vector field.

Computes points + displacement without changing connectivity, optionally multiplying the displacement by point_weights[..., None]. displacement and point_weights may be raw tensors or keys (including nested tuple keys) in point_data.

Call it as displace(mesh, ...) or as mesh.displace(...). The bound method supplies mesh automatically.

Parameters:
  • mesh (Mesh) – Mesh whose points are displaced. The source mesh is not modified.

  • displacement (str, tuple[str, ...], or torch.Tensor) – Dense displacement vectors with shape (mesh.n_points, mesh.n_spatial_dims), or a point-data key resolving to such a tensor. The tensor and mesh.points must have the same float32 or float64 dtype and device.

  • point_weights (str, tuple[str, ...], torch.Tensor, or None, optional) – Optional bool or floating-point weights with shape (mesh.n_points,), or a point-data key resolving to those point weights. Floating-point weights may be signed or greater than one. Default is None.

  • implementation ({"torch"} or None, optional) – Backend override. None selects Torch for dense displacement.

Returns:

New mesh with displaced points and unchanged connectivity and attached fields.

Return type:

Mesh

Notes

Attached fields are treated as Lagrangian data and are not pushed forward. Geometry-dependent caches are invalidated and topology caches are retained. The operation does not detect or repair inverted, degenerate, or self-intersecting cells; call validate() explicitly when needed.

physicsnemo.mesh.transformations.deform.free_form_deform(
mesh: Mesh,
control_displacements: Float[Tensor, '*lattice_resolution n_spatial_dims'],
*,
origin: Float[Tensor, 'n_spatial_dims'] | Sequence[float] | None = None,
extent: Float[Tensor, 'n_spatial_dims'] | Sequence[float] | None = None,
basis: Literal['bernstein', 'bspline', 'linear', 'cubic_hermite', 'quintic_hermite'] = 'bernstein',
point_weights: str | tuple[str, ...] | Bool[Tensor, 'n_points'] | Float[Tensor, 'n_points'] | None = None,
implementation: Literal['torch', 'warp'] | None = None,
) Mesh[source]#

Deform a mesh with a control-point lattice by free-form deformation.

An n_1 x ... x n_D array of control displacements defines a field over the axis-aligned box [origin, origin + extent]. Mesh points inside the box move with the tensor-product basis interpolation of those values. Points outside the box are unchanged. A lattice of zero displacements is exactly the identity, and a constant lattice translates every point inside the box.

Call it as free_form_deform(mesh, ...) or as mesh.free_form_deform(...). The bound method supplies mesh automatically.

Parameters:
  • mesh (Mesh) – Mesh whose points are deformed. The source mesh is not modified.

  • control_displacements (torch.Tensor) – Displacement vectors, not destination coordinates, for every lattice node, with shape (n_1, ..., n_D, mesh.n_spatial_dims) and the same float32 or float64 dtype and device as mesh.points. Each axis needs at least two nodes for "bernstein" and the node-interpolating bases, and four for "bspline".

  • origin (torch.Tensor, sequence of float, or None, optional) – Minimum corner of the lattice box with shape (mesh.n_spatial_dims,). None uses the minimum corner of the mesh bounds. For repeated GPU calls with an explicit box, create origin and extent once as device tensors. Reuse them to avoid recreating and transferring sequence values. Default is None.

  • extent (torch.Tensor, sequence of float, or None, optional) – Edge lengths of the lattice box with the same accepted shapes as origin. Every value must be finite and strictly positive. The operation does not validate tensor values at runtime. None sizes the box from origin to the maximum corner of the mesh bounds. Validating a derived extent synchronizes with the device and is not CUDA Graph capture-safe. For capture, pass both origin and extent as device tensors. Every point-coordinate axis must have positive range when the extent is derived. Supply an explicit extent for lower-dimensional geometry embedded in a higher-dimensional space. Default is None.

  • basis ({"bernstein", "bspline", "linear", "cubic_hermite", "quintic_hermite"}, optional) –

    Per-axis basis family:

    • "bernstein" provides classic global-support FFD. Every lattice node influences every point inside the box.

    • "bspline" uses a uniform cubic B-spline with local four-node-per-axis support and C2 continuity between knot spans. Coefficient index i corresponds to local coordinate (i - 1) / (n - 3). The first and last coefficient planes lie outside the evaluation box.

    • "linear" uses upper-node weight \(s(t)=t\) within each lattice cell. It interpolates every node. It is continuous (C0) across cell boundaries, but its slope can jump.

    • "cubic_hermite" uses the cubic Hermite blend \(s(t)=3t^2-2t^3\). Its first derivative vanishes at both cell endpoints. This gives C1 continuity across cell boundaries.

    • "quintic_hermite" uses the quintic Hermite blend \(s(t)=6t^5-15t^4+10t^3\). Its first and second derivatives vanish at both endpoints. This gives C2 continuity across cell boundaries. Perlin introduced this improved interpolant in “Improving Noise” [1].

    The node-interpolating bases use only the two neighboring nodes per axis. Here, t is the local cell coordinate in [0, 1]. The upper-node weight is \(s(t)\), and the lower-node weight is \(1-s(t)\). Default is "bernstein".

  • point_weights (str, tuple[str, ...], torch.Tensor, or None, optional) – Optional bool or floating mesh-point weights with shape (mesh.n_points,), or a point_data key resolving to those point weights. All weights must match the point device. Floating weights must also match the point dtype. Default is None.

  • implementation ({"torch", "warp"} or None, optional) – Backend override. None selects Torch on CPU. On CUDA, it selects Warp when available and otherwise Torch.

Returns:

New mesh with deformed points and unchanged connectivity and fields.

Return type:

Mesh

Raises:
  • TypeError – If tensors, lattice values, or point weights have unsupported types or dtypes.

  • ValueError – If shapes, devices, lattice parameters, point weights, or basis are invalid.

  • KeyError – If a point-data key or implementation name is not found.

  • ImportError – If an explicitly requested backend is unavailable.

Notes

The operation treats attached fields as Lagrangian data and does not push them forward. It invalidates geometry-dependent caches and retains topology caches. The deformation is generally not continuous across the lattice box boundary. To keep the exterior fixed, zero the outermost coefficient plane on every Bernstein or node-interpolating face. For cubic B-splines, zero the first and last three coefficient planes on every axis. origin and extent are non-differentiable lattice parameters. Optimize control_displacements instead. The operation does not detect or repair inverted, degenerate, or self-intersecting cells. Call validate() explicitly when needed.

References

[1] Perlin, K. (2002). “Improving Noise.” ACM Transactions on Graphics, 21(3), 681-682. https://doi.org/10.1145/566654.566636

physicsnemo.mesh.transformations.deform.morph(
mesh: Mesh,
control_points: Tensor,
control_displacements: Tensor,
*,
radius: float | Tensor,
point_weights: str | tuple[str, ...] | Tensor | None = None,
kernel: Literal['wendland_c2'] = 'wendland_c2',
implementation: Literal['torch', 'warp'] | None = None,
) Mesh[source]#

Morph a mesh from sparse, compactly supported control displacements.

Control influence uses a Wendland-C2 compact Shepard field with a stationary zero-displacement background. Each control’s influence vanishes smoothly at its support boundary. Where supports overlap, all active controls and the background are blended together; the result is not a simple sum or average. The field is zero outside the union of all supports.

With no point weights, the field at a unique control coordinate is exactly that control’s displacement. Duplicate controls at one coordinate contribute their mean displacement. A control may be anywhere in world coordinates and need not coincide with a mesh point.

Call it as morph(mesh, ...) or as mesh.morph(...). The bound method supplies mesh automatically.

Parameters:
  • mesh (Mesh) – Mesh whose points are morphed. The source mesh is not modified.

  • control_points (torch.Tensor) – World-coordinate controls with shape (n_controls, mesh.n_spatial_dims) and the same dtype and device as mesh.points.

  • control_displacements (torch.Tensor) – Displacement vectors, not destination coordinates, with exactly the same shape, dtype, and device as control_points.

  • radius (float or torch.Tensor) – Support distance in mesh coordinate units. Supply one scalar for every control or a tensor with shape (n_controls,) that matches the control dtype and device. Every tensor value must remain positive and finite; values are not validated at runtime.

  • point_weights (str, tuple[str, ...], torch.Tensor, or None, optional) – Optional bool or floating mesh-point weights with shape (mesh.n_points,), or a point_data key resolving to those point weights. These are query-point weights, not per-control values. Default is None.

  • kernel ({"wendland_c2"}, optional) – Compact radial kernel used to blend control displacements. Default is "wendland_c2".

  • implementation ({"torch", "warp"} or None, optional) – Backend override. None selects Torch on CPU and Warp on CUDA when Warp is available, otherwise Torch.

Returns:

New mesh with morphed points and unchanged connectivity and attached fields.

Return type:

Mesh

Notes

Attached fields are treated as Lagrangian data and are not pushed forward. Geometry-dependent caches are invalidated and topology caches are retained. Parameterize learned radii to remain positive, for example as torch.nn.functional.softplus(raw_radius) + eps. The operation does not detect or repair inverted, degenerate, or self-intersecting cells; call validate() explicitly when needed.

physicsnemo.mesh.transformations.deform.radial_basis_function_deform(
mesh: Mesh,
control_points: Float[Tensor, 'n_controls n_spatial_dims'],
control_displacements: Float[Tensor, 'n_controls n_spatial_dims'],
*,
kernel: Literal['thin_plate_spline'] = 'thin_plate_spline',
polynomial: bool = True,
smoothing: float = 0.0,
point_weights: str | tuple[str, ...] | Bool[Tensor, 'n_points'] | Float[Tensor, 'n_points'] | None = None,
implementation: Literal['torch', 'warp'] | None = None,
) Mesh[source]#

Deform a mesh with a global thin-plate-spline RBF field.

A thin-plate-spline radial field is fitted to the prescribed sparse control displacements and evaluated at every mesh point. With the default affine polynomial tail, zero smoothing, and a nonsingular control layout, the unweighted field interpolates every control displacement up to solver precision.

Call it as radial_basis_function_deform(mesh, ...) or as mesh.radial_basis_function_deform(...). The bound method supplies mesh automatically.

Parameters:
  • mesh (Mesh) – Mesh whose points are deformed. The source mesh is not modified.

  • control_points (torch.Tensor) – World-coordinate controls with shape (n_controls, mesh.n_spatial_dims) and the same dtype and device as mesh.points.

  • control_displacements (torch.Tensor) – Displacement vectors, not destination coordinates, with exactly the same shape, dtype, and device as control_points.

  • kernel ({"thin_plate_spline"}, optional) – Radial kernel used by the interpolant. Default is "thin_plate_spline".

  • polynomial (bool, optional) – Add the standard affine polynomial tail and side constraints. This reproduces affine displacement fields. The controls must affinely span the coordinate space, and the augmented system must be nonsingular. Default is True.

  • smoothing (float, optional) – Nonnegative diagonal regularization added to the radial system. Zero gives exact interpolation for a nonsingular control layout up to solver precision. Positive values relax interpolation accuracy. Default is 0.0.

  • point_weights (str, tuple[str, ...], torch.Tensor, or None, optional) – Optional bool or floating mesh-point weights with shape (mesh.n_points,), or a point_data key resolving to those weights. Values scale or mask the fitted field after interpolation. Bool weights must be on the same device as the mesh points. Floating weights must have the same dtype and device as the mesh points.

  • implementation ({"torch", "warp"} or None, optional) – Evaluation-backend override. Both backends use PyTorch for the dense coefficient solve. None selects Torch on CPU and Warp on CUDA when Warp is available, otherwise Torch.

Returns:

New mesh with deformed points and unchanged connectivity and attached fields.

Return type:

Mesh

Raises:
  • TypeError – If control tensors or Python arguments have unsupported types, or if tensor dtypes are unsupported or mismatched.

  • ValueError – If tensor shapes, devices, control layout, point weights, or RBF options are invalid.

  • KeyError – If a point-data key is missing or implementation does not name a registered backend.

  • ImportError – If an explicitly requested backend is unavailable.

  • RuntimeError – If runtime validation or coefficient fitting fails, including for a singular system or during CUDA Graph capture.

Notes

The field has global support. Unlike compact Shepard morphing, every control generally influences every mesh point. Attached fields are treated as Lagrangian data and are not pushed forward. Geometry-dependent caches are invalidated and topology caches are retained. The operation does not detect or repair inverted, degenerate, or self-intersecting cells. Call validate() explicitly when needed. Coefficient fitting is not supported inside CUDA Graph capture because the singular-system check requires host interaction.

Projections#

Spatial dimension manipulation – changing the embedding dimension of a mesh without altering its manifold dimension.

  • embed() – add spatial dimensions (non-destructive; for example, 2D mesh to 3D by appending zero coordinates)

  • extrude() – sweep a manifold to create a mesh one dimension higher (for example, a triangle mesh extruded to a prism mesh)

  • project() – reduce spatial dimensions (lossy; drops coordinate axes)

Projection operations for mesh extrusion, embedding, and spatial dimension manipulation.

This module provides functionality for: - Embedding meshes in higher-dimensional spaces (non-destructive) - Projecting meshes to lower-dimensional spaces (lossy) - Extruding manifolds to higher dimensions