Transformations and Projections#

These functions move the points of a mesh and keep its connectivity. They cover rigid and linear transforms, six families of deformation, and projections between spatial dimensions, and each returns a new Mesh.

CPU and GPU autograd any dimension rotate in 2D and 3D shrinkwrap in 3D only

Six panels of the same flat square sheet, deformed six ways. They show a noisy dense displacement, the same displacement smoothed with a fixed boundary, and one orange handle that lifts a round patch. Then a sheet with one lifted handle and five green anchors, four at the corners and one below the front edge. Last, a sheet bent by a gray control lattice, and a sheet wrapped onto a curved surface.

Fig. 41 Six ways to deform one sheet. Each keeps the connectivity and moves only the points.#

The most-used functions are the following:

rotate

Rotate the points, and optionally the vector and tensor fields.

physicsnemo.mesh.transformations.geometric.rotate
morph

Move a few handles, and the rest of the mesh follows.

Single-Control Morphing
sobolev_deform

Smooth a raw displacement before it moves the mesh.

Sobolev-Filtered Deformation
extrude

Sweep a mesh into one more manifold dimension.

physicsnemo.mesh.projections.extrude

Example#

Each transformation is both a Mesh method and a function. The two forms give the same result:

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

mesh = sphere_icosahedral.load(subdivisions=3)

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])
transformed = mesh.transform(torch.eye(3) * 2)  # any linear map
import numpy as np
import torch
from physicsnemo.mesh.primitives.surfaces import sphere_icosahedral
from physicsnemo.mesh.transformations import rotate, scale, transform, translate

mesh = sphere_icosahedral.load(subdivisions=3)

translated = translate(mesh, [1.0, 0.0, 0.0])
rotated = rotate(mesh, axis=[0, 0, 1], angle=np.pi / 4)
scaled = scale(mesh, 2.0)
scaled_aniso = scale(mesh, [2.0, 1.0, 0.5])
transformed = transform(mesh, torch.eye(3) * 2)  # any linear map

The projections and the two matrix builders are functions only. Refer to Differentiable Deformation Energies for penalty energies that keep a deformation well shaped.

Geometric Transformations#

These functions apply linear and affine transformations to mesh geometry. Each function returns a new Mesh with transformed point coordinates and the same connectivity.

By default, rotate(), scale(), and transform() leave point_data, cell_data, and global_data unchanged. To transform vector and tensor fields with the mesh, pass transform_point_data, transform_cell_data, or transform_global_data. True transforms every field. Scalar fields stay unchanged, and a field whose trailing sizes do not all equal the spatial dimension raises ValueError. When the data also holds other channels, such as a (n_points, 5) feature array on a 3D mesh, pass a dict of booleans that selects fields by name. For more information, refer to Fields and Schemas. translate() does not change data.

Topology caches carry over to the new mesh. Cached cell areas, normals, and centroids are updated from the matrix where possible. Other cached geometry, such as curvature, is recomputed on next access.

Deformations#

The deform namespace provides six deformation families. Each one is also a Mesh method, and each wraps a tensor functional in physicsnemo.nn.functional, such as morph_points() for morph().

Pick a family by what you control:

Dense Displacement

One vector for each point, applied as given, with displace().

Dense Displacement
Sobolev-Filtered

One vector for each point, smoothed first, with sobolev_deform().

Sobolev-Filtered Deformation
Morph

A few handles with compact support, with morph().

Single-Control Morphing
Radial Basis Function

A few handles, one global field that is exact at each handle, with radial_basis_function_deform().

Global Radial-Basis Deformation
Free-Form Lattice

A regular grid of control displacements, with free_form_deform().

Lattice Free-Form Deformation
Shrinkwrap

Points moved onto a target surface, with shrinkwrap().

Nearest-Surface Shrinkwrap

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.

Dense Displacement#

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. The following example shows both forms:

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")

Sobolev-Filtered Deformation#

sobolev_deform() turns a raw displacement \(d\) into a spatially smooth displacement \(u\) with a discrete Helmholtz filter. length_scale sets how far the smoothing reaches, in the same units as mesh.points. A zero length scale applies the raw displacement exactly at unfixed vertices.

The Filter Equation

The filter solves

\[(M + \ell^2 K)u = M d.\]

Here \(M=\bar m I\) is a uniform vertex mass matrix scaled by the mean positive lumped P1 mass, and \(K\) is the P1 stiffness matrix. The uniform mass makes the filter self-adjoint in standard Euclidean vertex coordinates, so the same operator smooths the displacement and its adjoint. length_scale is \(\ell\). Larger values suppress variation over longer distances. This discrete Helmholtz filter is motivated by the implicit formulation for node-based shape optimization studied by Najian Asl and Bletzinger.

reference_points = mesh.points.detach()
candidate_vertices = reference_points.clone().requires_grad_()
raw_displacement = candidate_vertices - reference_points
smooth = mesh.sobolev_deform(
    raw_displacement,
    length_scale=0.2,
)

objective = smooth.points.square().mean()
objective.backward()
smooth_vertex_adjoint = candidate_vertices.grad

The forward deformation is the uniform-mass discrete Helmholtz solve itself. Its first-order implicit backward solves the matching adjoint system. Optimize candidate vertex coordinates by subtracting fixed reference points and passing that offset as raw_displacement. The adjoint with respect to those candidate vertices receives the same Sobolev filter. Gradients with respect to the reference coordinates also include the geometric dependence of the P1 stiffness and mass scale. Higher-order derivatives are not supported for a positive length scale. Each ambient displacement component is filtered independently.

fixed_points accepts a boolean point mask or a point-data key. True entries receive zero displacement, which imposes a homogeneous Dirichlet condition. Other mesh boundaries use the natural homogeneous Neumann condition. With no fixed points, constant displacements pass through to solver precision.

The Torch and Warp implementations use matrix-free preconditioned conjugate gradients. The Warp implementation runs on CUDA and supplies an explicit implicit-adjoint backward with an analytic geometry vector-Jacobian product. By default, CUDA segments, triangles, and tetrahedra select Warp when it is available. CPU meshes and higher-dimensional simplices select Torch. max_iterations and tolerance control the iterative solve. Keep the following behavior in mind:

  • The operation raises an error if either the forward or adjoint solve does not reach the requested tolerance.

  • At positive length scales, cells must be finite, nondegenerate simplices.

  • Isolated points receive their raw displacement.

  • CUDA Graph capture is not supported because P1 operator assembly and solver diagnostics are not capture-safe.

  • Both backends support torch.compile.

  • Warp CUDA results and point gradients can vary at roundoff between runs.

Before and After

The panels show the initial objective adjoint with respect to candidate vertex coordinates. The raw field contains checkerboard-scale oscillation. Applying the Sobolev deformation filters that adjoint over the mesh while retaining the fixed boundary. Both panels use the same color and arrow scale.

Raw noisy vertex adjoint and smoother Sobolev-filtered adjoint

Three-Dimensional Surface Example

The 3D figure applies the same workflow to a triangulated sheet with points shaped (N, 3). Its objective pulls the center upward while the boundary remains fixed. The reproducible figure source is docs/img/mesh/sobolev_adjoint_field_3d.py.

The arrows show the normalized negative adjoint, which is the gradient-descent update direction. Each panel applies that field to the sheet geometry for visibility. The raw panel is corrugated by vertex-scale oscillations. The Sobolev panel gives the same broad upward pull with a smooth surface. Green points mark the fixed boundary, and both panels use one shared scale.

Raw and Sobolev-filtered upward updates on a fixed-boundary sheet

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 can be signed or greater than one.

Visualization

The panels compare the original sphere with the single-control and multiple-control examples above. Orange markers identify the displaced handle locations. Arrows show the prescribed displacement directions, and the panel titles give their magnitudes.

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

Global Radial-Basis Deformation#

Radial basis function (RBF) 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. In 3D, for example, they must not all lie in one plane. The augmented system must also 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.

Two-Dimensional Example

A mesh with n_spatial_dims=2 requires at least three non-collinear controls when the affine tail is enabled. The following 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 orange 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 following 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 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 kernel matrix between points and controls.

Green controls are fixed anchors. Orange controls mark moved handles, and the arrows show their displacement directions. The panel titles 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.

The basis argument accepts the following options:

  • basis="bernstein" provides classic global-support free-form deformation for coarse lattices.

  • basis="bspline" provides local support of four nodes along each axis 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 for each axis and reproduce every control displacement at its lattice node.

For a local cell coordinate \(t\), the upper-node weights of these three node-interpolating bases 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. In a finer cubic B-spline lattice, three independently displaced regions produce two local bulges and a local indentation.

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

Nearest-Surface Shrinkwrap#

shrinkwrap() projects source vertices to their closest points on a triangle target. An optional signed offset keeps the result a specified distance from the target along its oriented face normals. point_weights can fix selected vertices or apply a partial projection. max_distance leaves vertices unchanged when no target surface is close enough.

The following example projects a lifted sheet onto a flat target and keeps one edge of the sheet fixed:

from physicsnemo.mesh.primitives.surfaces import plane

panel_target = plane.load(size=2.0, subdivisions=10)  # the z = 0 plane
source = plane.load(size=2.0, subdivisions=8)
lift = 0.2 * (1.0 - source.points[:, 0].square())
source = source.with_points(source.points + lift[:, None] * source.points.new_tensor([0.0, 0.0, 1.0]))

movable = source.points[:, 0] > -0.9  # the edge at x = -1 stays fixed
conformed = source.shrinkwrap(
    panel_target,
    point_weights=movable,
    max_distance=0.34,
)
print(conformed.points[movable, 2].abs().max())  # tensor(0.): on the target

The figure shows the same call on a curved panel. The figure’s example constructs a swept triangle panel and adds a smooth springback error to form the source sheet. The boolean movable mask keeps its green root attachment strip fixed. The movable vertices lie on the target surface. Color shows how far each source vertex moved.

Curved triangle panel, lifted source sheet, and shrinkwrapped result

Nearest-face selection and closest-feature changes are discrete. With those choices fixed, gradients propagate through the following inputs:

  • Source points

  • Selected target vertices

  • Floating-point weights

  • A tensor-valued offset

At a shared edge or vertex, adjacent faces can provide different normals. Use consistently oriented target faces and avoid placing offset-sensitive samples exactly on shared features.

The source points and the target must be in 3D. Torch provides the reference search. By default (implementation=None), shrinkwrap searches with Torch on CPU and with Warp on CUDA when Warp is available. implementation="warp" also runs on CPU. Both backends replay the selected point-to-triangle projection with PyTorch in the input dtype. Shrinkwrap is available on Mesh, not DomainMesh, because one source mesh is projected onto one target surface.

float64 targets use the Torch search because Warp searches in float32. Safe float32 coordinates are searched unchanged. Warp falls back to Torch for unsafe coordinate magnitudes or face geometry.

Shrinkwrap performs data-dependent validation and nearest-face search setup. CUDA executions with either backend are not supported inside CUDA Graph capture.

Shape-Optimization Constraint Example

In shape optimization, an otherwise acceptable design iterate can locally cross an admissible clearance surface. This example constructs a closed triangulated low-profile enclosure with an inset lid and chamfered walls. A boolean mask selects only the vertices above a horizontal clearance plane. The central call has this form, here with the sheet and plane from the previous example:

candidate, admissible_envelope = source, panel_target
design_region = candidate.points[:, 2] > 0.0  # vertices above the z = 0 limit
feasible = candidate.shrinkwrap(
    admissible_envelope,
    point_weights=design_region,
)

Before repair, a smooth orange dome protrudes through the blue clearance plane while the broad gold lid deformation remains admissible. Shrinkwrap projects only the violating vertices back to the limit. After repair, the cap is green, the gold optimized surface is retained, and an orange wire outline marks the former excess.

All connectivity is triangular. The example checks the following properties:

  • The repaired-cap residual

  • Exact preservation of admissible vertices

  • Closed two-manifold source and result connectivity

  • Consistent winding

  • Positive enclosed volume

  • Adjoints

  • Agreement between Torch and Warp

Shrinkwrap restores this geometric constraint. It does not run the optimizer or guarantee valid cells for arbitrary inputs.

Selective repair of an optimized enclosure crossing a clearance plane

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 a nested tuple key, present in every component. The method rejects raw weight tensors 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. It solves the coefficient system once and evaluates the combined interior and boundary points together before it rebuilds the component meshes. The following example morphs a domain with a point-weight key:

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 you supply an explicit extent.

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

Projections#

These functions change the spatial dimension of a mesh, or extrude it into a mesh one manifold dimension higher:

  • embed(): add spatial dimensions without loss, for example from 2D to 3D by appending zero coordinates.

  • extrude(): sweep each cell along a vector into a prism, and split the prism into simplices. A triangle mesh becomes a tetrahedral mesh.

  • project(): remove spatial dimensions by dropping coordinate axes. This loses information.

The following example embeds a square in 3D, extrudes it into tetrahedra, and projects it back to 2D:

from physicsnemo.mesh.primitives.planar import unit_square
from physicsnemo.mesh.projections import embed, extrude, project

square = unit_square.load(subdivisions=2)  # triangles in 2D
in_3d = embed(square, target_n_spatial_dims=3)  # z = 0
prism = extrude(in_3d, vector=[0.0, 0.0, 1.0])  # tetrahedra in 3D
flat = project(in_3d, target_n_spatial_dims=2)  # drops z
print(in_3d.n_spatial_dims, prism.n_manifold_dims, flat.n_spatial_dims)  # 3 3 2
A grid of eight small panels joined by arrows. The top row shows a flat square of eight triangles, Mesh[2, 2], then the same square tilted in 3D, Mesh[2, 3], then a slab of 24 separated tetrahedra, Mesh[3, 3]. The bottom row shows a circle of 24 edges, Mesh[1, 2], and the same circle tilted in 3D, Mesh[1, 3]. Last comes an open cylinder of 48 triangles, Mesh[2, 3]. The right column sits behind a thin divider. In it, a bumpy sheet colored by height, Mesh[2, 3], points down to a flat uncolored grid of the same 512 triangles, Mesh[2, 2].

Fig. 42 embed, extrude, and project change the two numbers of Mesh[m, s]. embed raises s, extrude raises m by one, and project lowers s. Only project loses information.#

Summary#

Geometric Transformations

translate

Apply a translation to the mesh.

rotate

Rotate the mesh about an axis by a specified angle.

scale

Scale the mesh by specified factor(s).

transform

Apply a linear transformation to the mesh.

rotation_matrix

Build a rotation matrix from angle and axis.

scale_matrix

Build a diagonal scale matrix from a factor specification.

Deformations

displace

Displace every mesh point by a dense vector field.

sobolev_deform

Deform a mesh with a uniform-mass P1 Sobolev displacement.

morph

Morph a mesh from sparse, compactly supported control displacements.

radial_basis_function_deform

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

free_form_deform

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

shrinkwrap

Project mesh vertices onto the nearest locations of a target surface.

Projections

embed

Embed a mesh into a higher-dimensional ambient space.

extrude

Extrude an N-dimensional mesh into an (N+1)-dimensional mesh.

project

Project a mesh to a lower-dimensional ambient space.

API Reference#

Geometric Transformations#

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

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,
assume_valid_axis: bool = 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.

  • assume_valid_axis (bool) – Skip the check that axis has non-zero length (compile-safe). Only an axis given as a device tensor benefits: checking it reads a device scalar back to the host, which synchronizes on CUDA, whereas an axis given as Python values or a string is checked on the host. This is a promise, not a check: a zero-length axis then gives a NaN rotation.

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.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 the determinant; on the host for Python factors, else 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.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,
assume_similarity: 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.

  • assume_similarity (bool or None) –

    Whether matrix is a similarity, orthogonal up to a uniform scale (rotations, reflections, isotropic scales, and their compositions). Similarities preserve angles, so they exactly carry over the cached angle-weighted point normals of 2+ manifolds and the point measures of lower-dimensional quadrature:

    • True: assume a similarity (compile-safe). This is a promise, not a check: for any other matrix the propagated point normals are silently inexact, and point measures scale incorrectly.

    • False: assume not a similarity (compile-safe). The point-normal cache is dropped and recomputed lazily on demand.

    • None (default): test M.T @ M == c * I at runtime when the answer is needed. The test reads a device scalar back to the host, which synchronizes on CUDA; under torch.compile it is skipped and the point-normal cache is dropped.

Returns:

New Mesh with transformed geometry and appropriately updated caches.

Return type:

Mesh

Notes

Explicit effective measures follow geometry independently of the ordinary field-transformation flags. Cell measures follow geometric measure ratios. Point measures use their represented dimension for similarities and the determinant for full-dimensional square maps. Other point-measure maps require support geometry and raise ValueError. To retain reference measures explicitly, use mesh.with_points(new_points, preserve_measures=True).

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.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,
assume_valid_axis: bool = False,
) → 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.

  • assume_valid_axis (bool) – Skip the check that axis has non-zero length. See rotate().

Returns:

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

Return type:

Float[torch.Tensor, “n_spatial_dims n_spatial_dims”]

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.

Deformations#

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.sobolev_deform(
mesh: Mesh,
displacement: str | tuple[str, ...] | Float[torch.Tensor, 'n_points n_spatial_dims'],
*,
length_scale: float,
fixed_points: str | tuple[str, ...] | Bool[torch.Tensor, ' n_points'] | None = None,
max_iterations: int = 128,
tolerance: float | None = None,
implementation: Literal['torch', 'warp'] | None = None,
) → Mesh[source]#

Deform a mesh with a uniform-mass P1 Sobolev displacement.

Filters a dense per-vertex displacement by solving

\[(M + \ell^2 K)u = M d\]

and returns a new mesh with points mesh.points + u. Here \(M=\bar m I\) is a uniform vertex mass matrix scaled by the mean positive lumped P1 mass. \(K\) is the P1 stiffness matrix, and \(\ell\) is length_scale in mesh coordinate units. The uniform mass makes the filter self-adjoint for PyTorch vertex tensors. Connectivity and attached fields are unchanged.

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

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

  • displacement (str, tuple[str, ...], or torch.Tensor) – Raw displacement with shape (mesh.n_points, mesh.n_spatial_dims), or a point_data key resolving to one. It must match the point dtype and device.

  • length_scale (float) – Nonnegative physical smoothing length. Zero applies the raw displacement directly at unfixed points.

  • fixed_points (str, tuple[str, ...], torch.Tensor, or None, optional) – Optional bool mask with shape (mesh.n_points,), or a point-data key resolving to one. True entries receive zero displacement. Default is None.

  • max_iterations (int, optional) – Maximum PCG iterations. Default is 128.

  • tolerance (float or None, optional) – Positive relative residual tolerance. None selects a dtype-dependent default. Default is None.

  • implementation ({"torch", "warp"} or None, optional) – Backend override. None selects Torch on CPU. On CUDA, it selects Warp for segments, triangles, and tetrahedra when available. It otherwise selects Torch, with a one-time RuntimeWarning when Warp is unavailable. The Warp backend requires CUDA tensors.

Returns:

New mesh with Sobolev-filtered points, unchanged connectivity, and unchanged attached fields.

Return type:

Mesh

Notes

Unfixed mesh boundaries use the natural homogeneous Neumann condition. Constant displacements are retained when no points are fixed. Isolated points receive their raw displacement.

Both backends participate in autograd through the source points and the raw displacement. Their reverse-mode derivatives solve the adjoint Helmholtz system, which makes the operation suitable for smooth vertex-based optimization. The Warp backend evaluates the geometry vector-Jacobian product analytically. A forward or adjoint solve that does not reach tolerance within max_iterations raises a RuntimeError. Warp supports segment, triangle, and tetrahedron cells. Higher-dimensional simplices use Torch by default. Warp CUDA results and point gradients may vary at roundoff between runs. CUDA Graph capture is not supported because P1 operator assembly and solver diagnostics are not capture-safe.

Geometry caches are invalidated and topology caches are retained. At positive length scales, cells must be finite, nondegenerate simplices. The operation does not detect inverted or self-intersecting output cells.

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.

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.shrinkwrap(
mesh: Mesh,
target: Mesh,
*,
offset: float | Float[torch.Tensor, ''] = 0.0,
max_distance: float | None = None,
point_weights: str | tuple[str, ...] | Bool[torch.Tensor, ' n_points'] | Float[torch.Tensor, ' n_points'] | None = None,
implementation: Literal['torch', 'warp'] | None = None,
) → Mesh[source]#

Project mesh vertices onto the nearest locations of a target surface.

For each source vertex \(x_i\), Shrinkwrap selects the closest point \(p_i\) on target and returns

\[x'_i = x_i + w_i\left(p_i + \delta n_i - x_i\right),\]

where \(w_i\) is an optional point weight, \(\delta\) is offset, and \(n_i\) is the oriented unit normal of the selected target face. Source connectivity and attached data are retained.

Call it as shrinkwrap(mesh, target, ...) or as mesh.shrinkwrap(target, ...). Neither source nor target is modified.

Parameters:
  • mesh (Mesh) – Source mesh whose 3D point coordinates are projected. Its connectivity may have any supported topology.

  • target (Mesh) – Nonempty triangle surface embedded in 3D. Source and target points must share their float32 or float64 dtype and device. Target connectivity may use any non-bool integer dtype supported by Mesh and is normalized to int64 for this operation.

  • offset (float or torch.Tensor, optional) – Signed scalar distance from the target along the selected face normal. A scalar tensor must match the mesh point dtype and device and may require gradients. The value must be finite. Positive values follow target face winding. Default is 0.0.

  • max_distance (float or None, optional) – Positive finite nearest-surface search radius in mesh coordinate units. Vertices without a target strictly closer than this distance remain unchanged. Values that round to zero in the mesh point dtype are rejected. None performs an unbounded search. Default is None.

  • point_weights (str, tuple[str, ...], torch.Tensor, or None, optional) – Optional bool or floating per-source-point weights, or a key/path in point_data resolving to one. Zero leaves a vertex unchanged and one applies the full projection. Floating values are not clamped. Default is None.

  • implementation ({"torch", "warp"} or None, optional) – Nearest-face backend. None selects Torch on CPU and Warp on CUDA when available.

Returns:

New mesh with projected points, preserved connectivity and data, invalidated geometry caches, and retained topology caches.

Return type:

Mesh

Notes

Nearest-face selection is discrete. Away from target face, edge, vertex, and distance-cutoff transitions, gradients propagate through source points, selected target vertices, floating point weights, and tensor-valued offset. Degenerate target faces are ignored. Target orientation must be consistent when using a nonzero offset.

Shrinkwrap does not detect or prevent inverted or self-intersecting source cells. Partial point weights interpolate toward the projected surface and generally do not place the resulting vertex exactly on it. Shrinkwrap is not supported inside CUDA Graph capture with either backend. Float64 targets use the Torch search because Warp searches in float32. Safe float32 coordinates are searched unchanged. Warp falls back to Torch for unsafe coordinate magnitudes or face geometry.

Projections#

physicsnemo.mesh.projections.embed(
mesh: Mesh,
target_n_spatial_dims: int,
*,
insert_at: int | None = None,
) → Mesh[source]#

Embed a mesh into a higher-dimensional ambient space.

Increases the spatial dimensionality of a mesh by inserting new zero-valued coordinate dimensions, while preserving the manifold structure and topology. This operation is non-destructive: the original coordinate values are retained in their respective dimensions.

Key behaviors:

  • Manifold dimension (n_manifold_dims) is preserved

  • Topology (cell connectivity) is preserved

  • Point/cell/global data are preserved as-is

  • Cached geometric properties are cleared (they depend on spatial embedding)

Use cases:

  • [2, 2] -> [2, 3]: Embed a flat 2D surface into 3D space (e.g., to enable surface normal computation via codimension-1)

  • [1, 2] -> [1, 3]: Embed a 2D curve into 3D space

  • [2, 3] -> [2, 4]: Embed a 3D surface into 4D space

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

  • target_n_spatial_dims (int) – Target number of spatial dimensions. Must be >= current n_spatial_dims. If equal to current, returns mesh unchanged (no-op).

  • insert_at (int | None, optional) –

    Index at which to insert the new zero-valued dimensions. The new dimensions form a contiguous block starting at this position, with semantics matching list.insert. Valid range is 0 <= insert_at <= n_current_spatial_dims.

    • None (default): append new dimensions at the end. [x, y] -> [x, y, 0]

    • 0: prepend new dimensions at the start. [x, y] -> [0, x, y]

    • 1: insert new dimensions after the first coordinate. [x, y] -> [x, 0, y]

Returns:

New mesh with increased spatial dimensions:

  • points shape: (n_points, target_n_spatial_dims)

  • n_manifold_dims: unchanged

  • cells: unchanged

  • point_data, cell_data: preserved (non-cached fields only)

  • Cached geometric properties: cleared

Return type:

Mesh

Raises:
  • ValueError – If target_n_spatial_dims < 1.

  • ValueError – If target_n_spatial_dims < current n_spatial_dims (use project() to reduce dimensions).

  • ValueError – If insert_at is out of the valid range [0, n_spatial_dims].

Examples

>>> import torch
>>> from physicsnemo.mesh import Mesh
>>> from physicsnemo.mesh.projections import embed
>>> points_2d = torch.tensor([[0., 0.], [1., 0.], [0., 1.]])
>>> cells = torch.tensor([[0, 1, 2]])
>>> mesh_2d = Mesh(points=points_2d, cells=cells)
>>>
>>> # Embed in 3D (default: append z=0)
>>> mesh_3d = embed(mesh_2d, target_n_spatial_dims=3)
>>> assert mesh_3d.points.shape == (3, 3)
>>> assert torch.allclose(mesh_3d.points[:, 2], torch.zeros(3))
>>>
>>> # Embed with new dimension inserted at position 1: [x, y] -> [x, 0, y]
>>> mesh_3d_mid = embed(mesh_2d, target_n_spatial_dims=3, insert_at=1)
>>> assert torch.allclose(mesh_3d_mid.points[:, 0], points_2d[:, 0])
>>> assert torch.allclose(mesh_3d_mid.points[:, 1], torch.zeros(3))
>>> assert torch.allclose(mesh_3d_mid.points[:, 2], points_2d[:, 1])
>>>
>>> # Codimension changes affect normal computation
>>> assert mesh_2d.codimension == 0  # no normals defined
>>> assert mesh_3d.codimension == 1  # normals now defined!
>>> assert mesh_3d.cell_normals.shape == (1, 3)

Notes

When spatial dimensions change, all cached geometric properties are cleared because they depend on the spatial embedding. This includes:

  • Cell/point normals (codimension changes)

  • Cell centroids (coordinate count changes)

  • Cell areas (intrinsically unchanged but cache is cleared for consistency)

  • Curvature values (depend on embedding)

User data in point_data and cell_data is preserved as-is. If you have vector fields that should also be padded, you must handle this manually.

See also

project

The inverse operation - reduce spatial dimensions.

physicsnemo.mesh.projections.extrude(
mesh: Mesh,
vector: Float[Tensor, 'n_spatial_dims'] | Sequence[float] | None = None,
capping: bool = False,
allow_new_spatial_dims: bool = False,
) → Mesh[source]#

Extrude an N-dimensional mesh into an (N+1)-dimensional mesh.

Extrusion sweeps a mesh along a direction to produce a mesh one manifold dimension higher - analogous to pushing a 2D cookie-cutter shape through space to create a solid 3D object. Each N-simplex in the input is swept into a prism and then tessellated into (N+1) child (N+1)-simplices.

Common use cases:

  • 3D text and shapes: extrude a 2D triangulated outline to create a solid tetrahedral volume (used by text_3d_3d in this library).

  • Boundary layer meshing: sweep a surface mesh along its normals to create a thin volumetric layer for near-wall CFD resolution.

  • Space-time meshes: extrude a spatial mesh along a time axis for space-time finite element discretizations.

  • Structured mesh generation: create 3D meshes from 2D cross-sections (e.g., a pipe from a circle, a channel from a rectangle).

Dimensional behavior:

  • [N, M] -> [N+1, M]: Default case where M >= N+1 (e.g., 2D surface in 3D -> 3D volume in 3D)

  • [N, M] -> [N+1, N+1]: When M < N+1 and allow_new_spatial_dims=True, spatial dimensions are extended automatically.

Parameters:
  • mesh (Mesh) – Input mesh to extrude. Can be any manifold dimension N in any spatial dimension M >= N.

  • vector (torch.Tensor | list | tuple | None, optional) – Extrusion direction and magnitude, shape (n_spatial_dims,) or broadcastable. If None, defaults to a unit vector along the last spatial dimension: [0, 0, ..., 0, 1]. For meshes where N+1 > M and allow_new_spatial_dims=True, the default vector has shape (N+1,) with the last component set to 1.

  • capping (bool, optional) – If True, cap the top and bottom of the extrusion to create a closed volume. Currently not implemented.

  • allow_new_spatial_dims (bool, optional) – If True, allows extrusion to add new spatial dimensions when n_manifold_dims + 1 > n_spatial_dims. The point coordinates are padded with zeros in the new dimensions (via embed()). If False (default), raises ValueError when spatial dimensions are insufficient.

Returns:

Extruded mesh with:

  • n_manifold_dims = original_n_manifold_dims + 1

  • n_spatial_dims = max(original_n_spatial_dims, n_manifold_dims) if allow_new_spatial_dims=True, else original_n_spatial_dims

  • n_points = 2 * original_n_points (original + extruded copies)

  • n_cells = (original_n_manifold_dims + 1) * original_n_cells

Return type:

Mesh

Raises:
  • ValueError – If n_manifold_dims + 1 > n_spatial_dims and allow_new_spatial_dims=False, or if the mesh has dimensional point measures, which cannot be copied to the extruded points.

  • NotImplementedError – If capping=True (not yet implemented).

Examples

>>> import torch
>>> from physicsnemo.mesh import Mesh
>>> # Extrude a triangle (2D) in 3D space to create a triangular prism
>>> # tessellated into 3 tetrahedra
>>> points = torch.tensor([[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]])
>>> cells = torch.tensor([[0, 1, 2]])
>>> mesh = Mesh(points=points, cells=cells)
>>> extruded = extrude(mesh, vector=[0., 0., 1.])
>>> assert extruded.n_manifold_dims == 3  # tetrahedra
>>> assert extruded.n_cells == 3  # one triangle -> three tetrahedra
>>>
>>> # Extrude an edge (1D) in 2D space to create triangles
>>> points = torch.tensor([[0., 0.], [1., 0.]])
>>> cells = torch.tensor([[0, 1]])
>>> mesh = Mesh(points=points, cells=cells)
>>> extruded = extrude(mesh, vector=[0., 1.])
>>> assert extruded.n_manifold_dims == 2  # triangles
>>> assert extruded.n_cells == 2  # one edge -> two triangles
>>>
>>> # Extrude a 2D surface in 2D space into 3D (auto-extending spatial dims)
>>> points_2d = torch.tensor([[0., 0.], [1., 0.], [0., 1.]])
>>> triangles = torch.tensor([[0, 1, 2]])
>>> mesh_2d = Mesh(points_2d, triangles)  # [2, 2] mesh
>>> extruded = extrude(mesh_2d, allow_new_spatial_dims=True)
>>> assert extruded.n_spatial_dims == 3  # new dimension added

Notes

Before tessellating, the vertices of each parent simplex are sorted into a consistent global (ascending-index) order. With that ordering, the tessellation pattern for an N-simplex with (sorted) vertices [v0, v1, …, vN] creates (N+1) child (N+1)-simplices:

  • Child i has vertices: [v0’, v1’, …, vi’, vi, vi+1, …, vN]

where primed vertices (v’) are the extruded copies (offset by the extrusion vector).

Sorting the vertices is the Freudenthal-Kuhn subdivision: prisms that share a face split it along the same diagonal, so the output is a conforming simplicial complex (no cracks / non-manifold facets).

Orientation depends on the codimension of the output:

  • Codimension 0 (target_manifold_dims == target_spatial_dims): the raw Kuhn children alternate in orientation, so the last two vertices of any inverted cell are swapped to give every cell a positive signed volume. The result is a consistently oriented complex.

  • A 2D surface in 3D (e.g. extruding a curve): orientation is not enforced here; pass the result through physicsnemo.mesh.repair.fix_orientation(), which handles exactly this case.

  • Any other positive codimension: signed volume is undefined, so no orientation is enforced.

physicsnemo.mesh.projections.project(
mesh: Mesh,
target_n_spatial_dims: int | None = None,
*,
keep_dims: Sequence[int] | None = None,
transform_point_data: bool = False,
transform_cell_data: bool = False,
transform_global_data: bool = False,
) → Mesh[source]#

Project a mesh to a lower-dimensional ambient space.

Reduces the spatial dimensionality of a mesh by discarding coordinate dimensions. The manifold structure and topology are preserved, but coordinate information in the removed dimensions is permanently lost.

Two calling conventions are supported:

  • project(mesh, target_n_spatial_dims=N): keep the first N dimensions.

  • project(mesh, keep_dims=[i, j, ...]): keep specific dimensions by index, in the order they should appear in the output.

Exactly one of target_n_spatial_dims or keep_dims must be specified.

Key behaviors:

  • Manifold dimension (n_manifold_dims) is preserved

  • Topology (cell connectivity) is preserved

  • By default, point/cell/global data are preserved as-is

  • Optionally, vector and tensor fields can be projected alongside the geometry (see transform_*_data parameters)

  • Cached geometric properties are cleared

  • Information in removed dimensions is discarded

Use cases:

  • [2, 3] -> [2, 2]: Project a 3D surface to the xy-plane

  • [1, 3] -> [1, 2]: Project a 3D curve down to 2D

  • [2, 3] -> [2, 2] via keep_dims=[0, 2]: Project to the xz-plane

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

  • target_n_spatial_dims (int | None, optional) – Target number of spatial dimensions. Keeps the first N dimensions. Must be <= current n_spatial_dims and >= n_manifold_dims. If equal to current, returns mesh unchanged (no-op).

  • keep_dims (Sequence[int] | None, optional) – Indices of spatial dimensions to retain, in the order they should appear in the output. For example, keep_dims=[0, 2] on a 3D mesh produces a 2D mesh with coordinates [x, z].

  • transform_point_data (bool, optional, default=False) – If True, vector and tensor fields in point_data are projected using the same dimension selection applied to the coordinates. Scalar fields are left unchanged. See transform() for the field-type discrimination rules.

  • transform_cell_data (bool, optional, default=False) – If True, vector and tensor fields in cell_data are projected.

  • transform_global_data (bool, optional, default=False) – If True, vector and tensor fields in global_data are projected.

Returns:

New mesh with reduced spatial dimensions:

  • points shape: (n_points, len(keep_dims)) or (n_points, target_n_spatial_dims)

  • n_manifold_dims: unchanged

  • cells: unchanged

  • point_data, cell_data: preserved or projected (depending on flags)

  • Cached geometric properties: cleared

Return type:

Mesh

Raises:
  • ValueError – If both or neither of target_n_spatial_dims and keep_dims are specified.

  • ValueError – If target_n_spatial_dims > current n_spatial_dims (use embed() to add dimensions).

  • ValueError – If any index in keep_dims is out of range.

  • ValueError – If the resulting number of spatial dimensions would be less than n_manifold_dims.

Examples

>>> import torch
>>> from physicsnemo.mesh import Mesh
>>> from physicsnemo.mesh.projections import project
>>> points = torch.tensor([[0., 1., 2.], [3., 4., 5.]])
>>> cells = torch.tensor([[0, 1]])
>>> mesh = Mesh(points=points, cells=cells)
>>>
>>> # Keep first 2 dimensions (drop z)
>>> mesh_xy = project(mesh, target_n_spatial_dims=2)
>>> assert torch.allclose(mesh_xy.points, torch.tensor([[0., 1.], [3., 4.]]))
>>>
>>> # Keep x and z (drop y) using keep_dims
>>> mesh_xz = project(mesh, keep_dims=[0, 2])
>>> assert torch.allclose(mesh_xz.points, torch.tensor([[0., 2.], [3., 5.]]))
>>>
>>> # Project with vector cell data transformation
>>> mesh_with_vel = Mesh(
...     points=points, cells=cells,
...     cell_data={"velocity": torch.tensor([[1., 2., 3.]])}
... )
>>> projected = project(mesh_with_vel, target_n_spatial_dims=2, transform_cell_data=True)
>>> assert projected.cell_data["velocity"].shape == (1, 2)

Notes

This operation is lossy: coordinate values in removed dimensions cannot be recovered. If you need a reversible dimensionality change, use embed() followed by project(), ensuring the projected dimensions contain only values you added (e.g., zeros from embedding).

When spatial dimensions change, all cached geometric properties are cleared because they depend on the spatial embedding (normals, centroids, areas, curvature). When transform_*_data flags are False (the default), user data in point_data and cell_data is preserved as-is. When a flag is True, fields whose first non-batch dimension equals n_spatial_dims are projected via matrix multiplication; scalar fields are left unchanged. See _transform_tensordict() for the exact field-type discrimination logic.

See also

embed

The inverse operation - add spatial dimensions.

Mesh.transform

General linear transformation with data transformation.