Source code for physicsnemo.mesh.spatial.sdf

# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Signed distance field over a triangle surface mesh.

This is a :class:`~physicsnemo.mesh.Mesh`-typed wrapper around the Warp-backed
:func:`physicsnemo.nn.functional.signed_distance_field` custom op, which runs
NVIDIA Warp mesh queries (``wp.mesh_query_point_sign_normal`` /
``wp.mesh_query_point_sign_winding_number``) on CPU and CUDA. Warp launches are
made stream- and allocator-safe by
:meth:`physicsnemo.core.function_spec.FunctionSpec.warp_launch_context`, so the
op composes with torch streams (e.g. datapipe prefetch) without host
synchronization.

:func:`signed_distance_field` returns the signed distance, the closest surface
point, and the nearest face index for each query.
"""

from __future__ import annotations

import torch
from jaxtyping import Float, Int

from physicsnemo.mesh.mesh import Mesh
from physicsnemo.nn.functional.geometry.sdf import (
    signed_distance_field as _warp_signed_distance_field,
)

# Relative-height threshold below which a face is treated as degenerate and
# repaired at call time (see ``_repair_degenerate_faces``): a triangle whose
# height is less than this fraction of its longest edge has its off-edge vertex
# displaced perpendicular to that edge by the same fraction of the edge length.
_DEGENERATE_TRI_REL_HEIGHT = 1e-4


def _repair_degenerate_faces(
    face_vertices: Float[torch.Tensor, "n_faces 3 3"],
) -> Float[torch.Tensor, "n_faces 3 3"]:
    r"""Displace the off-edge vertex of (near-)degenerate faces off their edge.

    Warp's mesh closest-point query skips (near-)zero-area triangles --
    repeated vertices or collinear points, which real surface meshes do
    contain -- so a query nearest to such a face would silently report the
    distance to some farther valid face instead. This repairs the geometry
    once per call: any face whose height is below
    :data:`_DEGENERATE_TRI_REL_HEIGHT` times its longest edge has its off-edge
    vertex moved to the edge midpoint plus a perpendicular offset ``h``, giving
    an equivalent thin-but-valid triangle over the same edge.

    ``h`` is ``max(rel * L, 8 * eps_f32 * max|coord|)`` with ``L`` the longest
    edge: the first term keeps the repaired face far above the float32 regime
    where closest-point arithmetic misclassifies, and the second keeps the
    offset representable for small faces far from the origin (where ``rel * L``
    would round away against the coordinate magnitude). The closest point, and
    hence the SDF and hit point, move by at most ``h`` -- and only for queries
    whose nearest face was degenerate. Point-like faces (all vertices
    coincident, zero longest edge) are left untouched.

    Everything is a fixed-shape tensor pass over the faces -- no host
    readbacks, so the SDF prep stream stays sync-free.

    Parameters
    ----------
    face_vertices : torch.Tensor
        Per-face vertex positions, shape ``(n_faces, 3, 3)`` (float32).

    Returns
    -------
    torch.Tensor
        Repaired per-face vertex positions, shape ``(n_faces, 3, 3)``. Faces
        above the degeneracy threshold are bit-identical to the input.
    """
    a = face_vertices[:, 0, :]
    b = face_vertices[:, 1, :]
    c = face_vertices[:, 2, :]
    ab = b - a
    ac = c - a
    bc = c - b
    ab_sq = (ab * ab).sum(-1)
    ac_sq = (ac * ac).sum(-1)
    bc_sq = (bc * bc).sum(-1)

    # degenerate <=> height <= rel * longest edge <=> |ab x ac|^2 <= (rel * L^2)^2
    area_sq = (torch.linalg.cross(ab, ac, dim=-1) ** 2).sum(-1)
    scale_sq = torch.maximum(ab_sq, torch.maximum(ac_sq, bc_sq))
    degenerate = area_sq <= (_DEGENERATE_TRI_REL_HEIGHT * scale_sq) ** 2

    # The longest edge of a (near-)collinear face spans its extreme points, so
    # the face is (within its height) the segment (e0, e1); the remaining
    # "off-edge" vertex is the one displaced. 0 -> ab (off c), 1 -> ac (off b),
    # 2 -> bc (off a). Ties pick either longest edge; both are valid.
    longest = torch.stack([ab_sq, ac_sq, bc_sq], dim=-1).argmax(dim=-1)
    is_ab = (longest == 0).unsqueeze(-1)
    is_ac = (longest == 1).unsqueeze(-1)
    e0 = torch.where(is_ab | is_ac, a, b)
    e1 = torch.where(is_ab, b, c)

    # Unit perpendicular to the edge: cross against whichever of x-hat / y-hat
    # is less aligned with it (at least one of the two always works).
    edge = e1 - e0
    x_hat = torch.zeros_like(edge)
    x_hat[:, 0] = 1.0
    y_hat = torch.zeros_like(edge)
    y_hat[:, 1] = 1.0
    perp = torch.linalg.cross(edge, x_hat, dim=-1)
    perp_alt = torch.linalg.cross(edge, y_hat, dim=-1)
    edge_sq = (edge * edge).sum(-1)
    use_alt = (perp * perp).sum(-1) < 0.5 * edge_sq
    perp = torch.where(use_alt.unsqueeze(-1), perp_alt, perp)
    tiny = torch.finfo(face_vertices.dtype).tiny
    perp = perp / perp.norm(dim=-1, keepdim=True).clamp(min=tiny)

    eps = torch.finfo(face_vertices.dtype).eps
    coord_scale = face_vertices.abs().amax(dim=(1, 2))
    h = torch.maximum(
        _DEGENERATE_TRI_REL_HEIGHT * edge_sq.sqrt(), 8.0 * eps * coord_scale
    )
    # Point-like faces (zero longest edge) keep h = 0, i.e. stay untouched.
    h = torch.where(edge_sq > 0, h, torch.zeros_like(h))
    off_vertex = 0.5 * (e0 + e1) + perp * h.unsqueeze(-1)

    move_a = (degenerate & (longest == 2)).unsqueeze(-1)
    move_b = (degenerate & (longest == 1)).unsqueeze(-1)
    move_c = (degenerate & (longest == 0)).unsqueeze(-1)
    return torch.stack(
        [
            torch.where(move_a, off_vertex, a),
            torch.where(move_b, off_vertex, b),
            torch.where(move_c, off_vertex, c),
        ],
        dim=1,
    )


def _splice_repaired_faces(
    points: Float[torch.Tensor, "n_vertices 3"],
    cells: Int[torch.Tensor, "n_faces 3"],
) -> tuple[Float[torch.Tensor, "n_vertices_out 3"], Int[torch.Tensor, "n_faces 3"]]:
    """Splice repaired degenerate faces into the mesh, preserving welded topology.

    Applies :func:`_repair_degenerate_faces` and rewires only the faces it
    changed: each face's (single) moved vertex is appended as a new private
    vertex and that face's cell slot is remapped to it. Valid faces keep their
    original, shared vertices bit-identical, so Warp's angle-weighted
    pseudo-normal sign (which consumes the welded topology) is unaffected away
    from the repaired faces. Appended vertices of untouched faces are simply
    never referenced.

    Fixed-shape tensor ops throughout -- no host readbacks, so the repair is
    safe on a sync-free prefetch stream.
    """
    n_vertices = points.shape[0]
    n_faces = cells.shape[0]

    face_vertices = points[cells]
    repaired = _repair_degenerate_faces(face_vertices)

    # At most one vertex slot moves per face; argmax picks it (slot 0 for
    # untouched faces, where the remap below is a no-op).
    moved = (repaired != face_vertices).any(dim=-1)  # (n_faces, 3)
    face_ids = torch.arange(n_faces, device=cells.device)
    slot = moved.long().argmax(dim=1)
    new_vertices = repaired[face_ids, slot]  # (n_faces, 3)

    new_cells = cells.clone()
    new_cells[face_ids, slot] = torch.where(
        moved.any(dim=1), n_vertices + face_ids, cells[face_ids, slot]
    )
    return torch.cat([points, new_vertices]), new_cells


[docs] def signed_distance_field( mesh: Mesh, query_points: Float[torch.Tensor, "... 3"], max_dist: float | None = None, use_sign_winding_number: bool = False, ) -> tuple[ Float[torch.Tensor, "..."], Float[torch.Tensor, "... 3"], Int[torch.Tensor, "..."], ]: r"""Compute the signed distance to a triangle surface mesh. Returns the signed distance, the closest surface point, and the nearest face index for each query. Delegates to the Warp-backed :func:`physicsnemo.nn.functional.signed_distance_field` op, which runs on CPU and CUDA. Parameters ---------- mesh : Mesh Triangle surface mesh embedded in 3D: ``mesh.points`` has shape ``(n_vertices, 3)`` and ``mesh.cells`` has shape ``(n_faces, 3)``. query_points : torch.Tensor Query points, shape ``(..., 3)``. max_dist : float or None, optional Maximum search radius for the nearest-triangle query. ``None`` (default) searches without bound, so the true nearest triangle is always found; a finite value restricts the search to a band and reports queries farther than it as ``NaN`` (both ``sdf`` and ``hit_points``) with a hit face of ``-1``. use_sign_winding_number : bool, optional If ``True``, sign via the generalized winding number (``wp.mesh_query_point_sign_winding_number``), robust for non-watertight meshes. If ``False`` (default), sign via the angle-weighted pseudo-normal of the closest mesh feature (``wp.mesh_query_point_sign_normal``), which stays correct at sharp/non-convex edges where a single face normal would flip the sign. The mesh should be watertight for reliable signs in the ``False`` case. Returns ------- tuple[torch.Tensor, torch.Tensor, torch.Tensor] ``(sdf, hit_points, hit_faces)``: signed distance per query (shape ``query_points.shape[:-1]``; negative inside, positive outside), the closest point on the mesh per query (shape ``query_points.shape``), and the index into ``mesh.cells`` of the nearest face per query (int64, shape ``query_points.shape[:-1]``). Queries beyond a finite ``max_dist`` return ``NaN`` for the distance and hit point and ``-1`` for the hit face. Raises ------ ValueError If ``mesh`` is not a triangle surface in 3D (``n_spatial_dims == 3`` and ``n_manifold_dims == 2``), if ``query_points`` does not have a trailing dimension of size 3, if the mesh has no faces (there is no surface to measure distance to), if ``mesh`` and ``query_points`` are on different devices, or if ``max_dist`` is negative. Notes ----- A finite ``max_dist`` is an opt-in optimization/narrow-band mode: it prunes the search to the given radius and marks out-of-band queries as ``NaN`` so a far query is never silently reported as on-surface (``sdf == 0``). The unbounded default never produces ``NaN`` for a non-empty mesh. Distances are computed in float32 (inputs are cast as needed) and results are cast back to the ``query_points`` dtype. (Near-)degenerate faces -- repeated vertices or collinear points, which Warp's mesh query would otherwise skip -- are repaired into equivalent thin-but-valid triangles over the same longest edge before the query, so a query nearest to such a face reports the distance to its segment (to within the repair offset) rather than the distance to some farther valid face. See :func:`_repair_degenerate_faces`. """ if query_points.shape[-1] != 3: raise ValueError("query_points must have last dimension of size 3") # A triangle surface in 3D is required: the Warp mesh queries assume # 3-vertex cells with 3D coordinates. Validate here so a mis-typed mesh # fails loudly rather than deep inside the Warp op. if mesh.n_spatial_dims != 3: raise ValueError( "signed_distance_field requires a 3D mesh " f"(n_spatial_dims == 3), but got {mesh.n_spatial_dims=}." ) if mesh.n_manifold_dims != 2: raise ValueError( "signed_distance_field requires a triangle mesh " f"(n_manifold_dims == 2), but got {mesh.n_manifold_dims=}." ) if mesh.n_cells == 0: raise ValueError( "mesh has no faces; there is no surface to measure distance to" ) if mesh.points.device != query_points.device: raise ValueError( "mesh and query_points must be on the same device, but got " f"{mesh.points.device=} and {query_points.device=}." ) if max_dist is not None and max_dist < 0: raise ValueError(f"max_dist must be None or non-negative, got {max_dist}") # None -> unbounded exact search; a finite value is a narrow band. max_dist_eff = float("inf") if max_dist is None else float(max_dist) # Warp's mesh query skips (near-)zero-area triangles, so repair them into # equivalent thin-but-valid faces first (welded topology preserved; see # :func:`_splice_repaired_faces`). float32 up front: the Warp op computes # in float32 anyway, and the repair thresholds are float32-calibrated. points, cells = _splice_repaired_faces( mesh.points.to(torch.float32), mesh.cells.to(torch.long) ) return _warp_signed_distance_field( points, cells, query_points, max_dist=max_dist_eff, use_sign_winding_number=use_sign_winding_number, )