Source code for physicsnemo.mesh.tessellation.triangulate

# 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.

"""Decompose non-simplicial cells into a simplex connectivity.

The public entry point :func:`triangulate` branches on the manifold dimension,
mirroring how the rest of the mesh package handles dimension-generic ops (e.g.
``from_pyvista`` branches on ``manifold_dim``, ``compute_cell_areas`` on
``n_manifold_dims``). Only ``manifold_dim == 2`` (polygon ring -> triangles) is
implemented; higher dimensions raise ``NotImplementedError`` because a
polyhedron -> tetrahedron decomposition needs an explicit face hierarchy and a
different non-convex fallback (a non-convex polyhedron may require Steiner
points; cf. Schoenhardt's polyhedron).

The polygon path is pure PyTorch and vectorized:

- Convex polygons (the overwhelming majority of CFD surface cells) are
  fan-triangulated from vertex 0 in a single ``_ragged_arange`` pass, which is
  ``torch.compile``-traceable with no graph break.
- Non-convex polygons (rare) are ear-clipped so the unsigned per-triangle areas
  sum to the true polygon area -- a bare fan would emit overlapping triangles
  and over-count viscous / scalar-area integrals. Ear clipping is vectorized by
  grouping polygons of equal valence and clipping the whole group in lockstep.

Both paths emit exactly ``k - 2`` triangles per ``k``-gon, so per-polygon data
is broadcast to the output identically via the returned ``parent_index``
(``cell_data[parent_index]``).
"""

import torch
from jaxtyping import Bool, Float, Int

from physicsnemo.mesh.neighbors._adjacency import Adjacency
from physicsnemo.mesh.spatial._ragged import _ragged_arange
from physicsnemo.mesh.utilities._tolerances import safe_eps

#: Absolute tolerance on the (dimensionless) sine of a vertex turn below which
#: the turn is treated as straight rather than reflex, so near-collinear
#: vertices stay on the cheap convex fan path.
_REFLEX_SIN_TOL: float = 1e-6

#: ``(poly_id, prev_pos, next_pos)`` from :func:`_ring_neighbors`: for each flat
#: connectivity slot, its owning polygon and the slots of its cyclic neighbors.
_RingNeighbors = tuple[
    Int[torch.Tensor, " n_ring_positions"],
    Int[torch.Tensor, " n_ring_positions"],
    Int[torch.Tensor, " n_ring_positions"],
]


[docs] def triangulate( points: Float[torch.Tensor, "n_points n_spatial"], polygons: Adjacency, *, manifold_dim: int = 2, assume_convex: bool = False, ) -> tuple[ Int[torch.Tensor, "n_simplices d_plus_one"], Int[torch.Tensor, " n_simplices"] ]: r"""Decompose cells into simplices, branching on manifold dimension. Parameters ---------- points : torch.Tensor Vertex coordinates, shape :math:`(N_\text{points}, D)` with :math:`D \in \{2, 3\}`. polygons : Adjacency Cell-to-vertex incidence in CSR form: cell ``c`` is the vertex ring ``polygons.indices[polygons.offsets[c] : polygons.offsets[c + 1]]``. Build one from a flat VTK-style soup with ``Adjacency(offsets=..., indices=connectivity)``. manifold_dim : int, default 2 Dimension of the cells to decompose. Only ``2`` (polygon -> triangle) is implemented. assume_convex : bool, default False If ``True``, skip the convexity test and ear-clip fallback and fan-triangulate every cell. Correct only when all cells are convex; this is the fully ``torch.compile``-traceable fast path. Returns ------- cells : torch.Tensor Simplex connectivity, shape :math:`(N_\text{simplices}, \text{manifold\_dim} + 1)`, dtype int64. parent_index : torch.Tensor Source cell of each simplex, shape :math:`(N_\text{simplices},)`. Broadcast per-cell data to the simplices with ``data[parent_index]``. Raises ------ NotImplementedError If ``manifold_dim != 2``. ValueError If any polygon has fewer than three vertices, or if ``polygons.indices`` contains a negative index or one ``>= n_points`` (i.e. outside the valid :math:`[0, N_\text{points})` range; both checked off the ``torch.compile`` path). Notes ----- Each polygon ring must be a *simple* polygon (no self-intersections) with no repeated consecutive vertices or zero-length edges. Degenerate or self-intersecting rings are not detected and produce undefined results. The non-convex (ear-clip) path additionally assumes each ring is approximately planar: a badly non-planar non-convex ring can project to a self-intersecting 2-D polygon and triangulate incorrectly (convex rings are unaffected by planarity). Examples -------- >>> import torch >>> from physicsnemo.mesh.neighbors import Adjacency >>> from physicsnemo.mesh.tessellation import triangulate >>> points = torch.tensor([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], ... [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]) >>> polygons = Adjacency(offsets=torch.tensor([0, 4]), # one quad ... indices=torch.tensor([0, 1, 2, 3])) >>> cells, parent_index = triangulate(points, polygons) >>> cells.tolist() [[0, 1, 2], [0, 2, 3]] >>> parent_index.tolist() [0, 0] """ if manifold_dim != 2: raise NotImplementedError( f"triangulate supports manifold_dim=2 (polygon -> triangle) only; got " f"{manifold_dim=}. Higher-dimensional decomposition (e.g. polyhedron -> " f"tetrahedron) needs an explicit face hierarchy and is not yet implemented." ) return _triangulate_polygons(points, polygons, assume_convex=assume_convex)
# --------------------------------------------------------------------------- # Polygon triangulation (manifold_dim == 2) # --------------------------------------------------------------------------- def _triangulate_polygons( points: Float[torch.Tensor, "n_points n_spatial"], polygons: Adjacency, *, assume_convex: bool, ) -> tuple[Int[torch.Tensor, "n_triangles 3"], Int[torch.Tensor, " n_triangles"]]: """Triangulate a 2D polygon soup: fan the convex cells, ear-clip the rest. Runs in two passes so the common all-convex case stays on a single vectorized, ``torch.compile``-traceable kernel: 1. Fan every polygon from its first vertex (:func:`_fan`). This is correct for convex cells and emits ``k - 2`` contiguous triangles per ``k``-gon. 2. Unless ``assume_convex``, classify cells from their Newell normal and per-vertex turn signs (:func:`_polygon_normals`, :func:`_convex_mask`) and overwrite only the non-convex triangle blocks with a true ear-clip triangulation (``reclip_nonconvex``). A bare fan over a non-convex cell emits overlapping triangles whose unsigned areas over-count the polygon, which would corrupt any area-weighted integral. Both passes emit the same ``k - 2`` triangles per polygon in the same order, so ``parent_index`` is path-independent and per-polygon data broadcasts to the triangles identically via ``data[parent_index]``. Parameters ---------- points : torch.Tensor Vertex coordinates, shape ``(n_points, n_spatial)`` with ``n_spatial`` in ``{2, 3}``. Read only on the ear-clip path; the fan is purely combinatorial. polygons : Adjacency Cell-to-vertex rings in CSR form. assume_convex : bool If ``True``, return the bare fan with no convexity test or ear-clip fallback. Correct only when every cell is convex, and the only fully ``torch.compile``-traceable path. Returns ------- cells : torch.Tensor Triangle connectivity, shape ``(n_triangles, 3)``. parent_index : torch.Tensor Source polygon of each triangle, shape ``(n_triangles,)``. Raises ------ ValueError If any polygon has fewer than three vertices, or references a vertex index outside ``[0, n_points)`` (negative or too large; both checked only off the ``torch.compile`` path, where a host sync would force a graph break). """ counts = polygons.counts # Cheap structural validation, off the torch.compile path (each check is a # host sync that would otherwise force a graph break). if not torch.compiler.is_compiling() and counts.numel() > 0: if bool((counts < 3).any()): raise ValueError( f"Every polygon needs >= 3 vertices to triangulate; got a " f"polygon with {int(counts.min())} vertices." ) if bool((polygons.indices < 0).any()): raise ValueError( f"polygons.indices must be non-negative, but the minimum is " f"{int(polygons.indices.min())}. A negative index would silently " f"wrap around the points array under PyTorch gather semantics and " f"triangulate the wrong vertex rather than raising." ) if bool((polygons.indices >= points.shape[0]).any()): raise ValueError( f"polygons.indices reference vertex " f"{int(polygons.indices.max())}, but points has only " f"{points.shape[0]} vertices." ) # Fan every polygon (correct for convex cells; non-convex blocks are # overwritten below). This is the only path under ``assume_convex``. cells, parent_index = _fan(polygons) if assume_convex: return cells.long(), parent_index points = _to_3d(points) # normals / projection need a 3D embedding ring = _ring_neighbors(polygons) # shared by normals + convexity, computed once normals = _polygon_normals(points, polygons, ring) nonconvex = ~_convex_mask(points, polygons, normals, ring) if bool(nonconvex.any()): # the only host sync on the all-convex common path from physicsnemo.mesh.tessellation._ear_clipping import reclip_nonconvex cells = reclip_nonconvex(points, polygons, normals, cells, nonconvex) return cells.long(), parent_index def _fan( polygons: Adjacency, ) -> tuple[Int[torch.Tensor, "n_triangles 3"], Int[torch.Tensor, " n_triangles"]]: """Fan-triangulate every polygon from its first vertex, fully vectorized. A ``k``-gon with vertices ``(v_0, ..., v_{k-1})`` becomes the ``k - 2`` triangles ``(v_0, v_{j+1}, v_{j+2})`` for ``j = 0 .. k - 3``. The whole soup is expanded with one :func:`_ragged_arange` (no Python loop, no host sync), so the fan is fully ``torch.compile``-traceable. A polygon's triangles are emitted contiguously and in order, so ``parent_index`` is simply each polygon id repeated ``k - 2`` times. That contiguous, ordered layout is the contract ``reclip_nonconvex`` relies on to patch non-convex cells in place. The fan is geometrically valid only for convex polygons; for a non-convex cell its triangles overlap and spill outside the ring. Parameters ---------- polygons : Adjacency Cell-to-vertex rings in CSR form. Returns ------- cells : torch.Tensor Triangle connectivity, shape ``(n_triangles, 3)``. parent_index : torch.Tensor Source polygon of each triangle, shape ``(n_triangles,)``. """ conn = polygons.indices poly_starts = polygons.offsets[:-1] # One entry per output triangle: ``parent_index`` is its polygon and # ``positions`` walks the polygon's connectivity (poly_start + fan index j). positions, parent_index = _ragged_arange(poly_starts, polygons.counts - 2) cells = torch.stack( [conn[poly_starts[parent_index]], conn[positions + 1], conn[positions + 2]], dim=-1, ) return cells, parent_index # --------------------------------------------------------------------------- # Convexity (Newell normal + per-vertex turn sign) # --------------------------------------------------------------------------- def _polygon_normals( points: Float[torch.Tensor, "n_points 3"], polygons: Adjacency, ring: _RingNeighbors | None = None, ) -> Float[torch.Tensor, "n_polygons 3"]: """Per-polygon (unnormalized) Newell normal ``sum_i v_i x v_{i+1}``. The Newell formula sums the cross products of consecutive edges around each ring, so it stays robust for slightly non-planar or non-convex polygons rather than trusting a single corner. The result points along the vertex winding by the right-hand rule, and its length is twice the polygon's area: callers reuse it both to orient the projection plane (:func:`_convex_mask`, ear clipping) and to detect degenerate (near-zero-area) cells. Each ring is centered on its own first vertex before the cross-sum. This is translation-invariant yet keeps the summands small, avoiding catastrophic cancellation for meshes far from the origin in float32. Parameters ---------- points : torch.Tensor Vertex coordinates embedded in 3D, shape ``(n_points, 3)``. polygons : Adjacency Cell-to-vertex rings in CSR form. ring : tuple of torch.Tensor, optional Precomputed ``(poly_id, prev_pos, next_pos)`` from :func:`_ring_neighbors`. Both this function and :func:`_convex_mask` need the same ring decomposition, so :func:`_triangulate_polygons` computes it once and passes it to both, avoiding a redundant pass on the common convex path. Defaults to ``None``, in which case it is computed internally so the function stays correct when called on its own. Returns ------- torch.Tensor Unnormalized per-polygon normals, shape ``(n_polygons, 3)``. """ if ring is None: ring = _ring_neighbors(polygons) poly_id, _, next_pos = ring conn = polygons.indices ref = points[conn[polygons.offsets[:-1]]][poly_id] # this position's polygon v0 edge_cross = torch.linalg.cross(points[conn] - ref, points[conn[next_pos]] - ref) normals = points.new_zeros((polygons.n_sources, 3)) normals.index_add_(0, poly_id, edge_cross) return normals def _convex_mask( points: Float[torch.Tensor, "n_points 3"], polygons: Adjacency, normals: Float[torch.Tensor, "n_polygons 3"], ring: _RingNeighbors | None = None, ) -> Bool[torch.Tensor, " n_polygons"]: """Flag polygons that are convex, and therefore safe to fan-triangulate. A simple polygon is convex iff it has no reflex vertex. At each vertex the turn from the incoming to the outgoing edge is measured as the signed sine ``(edge_in x edge_out) . n_hat / (|edge_in| |edge_out|)``, which is scale-free and lies in ``[-1, 1]``. Taken against the polygon's own normal a convex (left) turn is positive and a reflex (right) turn negative, so a vertex counts as reflex when its signed sine falls below ``-_REFLEX_SIN_TOL``; that small tolerance keeps near-collinear vertices on the cheap fan path. A polygon is convex when its reflex count is zero. Degenerate (near-zero-area) polygons have an ill-defined normal and are reported convex so they, too, stay on the fan path instead of entering ear clipping. Parameters ---------- points : torch.Tensor Vertex coordinates embedded in 3D, shape ``(n_points, 3)``. polygons : Adjacency Cell-to-vertex rings in CSR form. normals : torch.Tensor Per-polygon Newell normals from :func:`_polygon_normals`, shape ``(n_polygons, 3)``. ring : tuple of torch.Tensor, optional Precomputed ``(poly_id, prev_pos, next_pos)`` from :func:`_ring_neighbors`, shared with :func:`_polygon_normals` to avoid recomputing it on the common convex path (see that function). Defaults to ``None``, in which case it is computed internally. Returns ------- torch.Tensor Boolean mask, ``True`` where a polygon is convex (or degenerate), shape ``(n_polygons,)``. """ if ring is None: ring = _ring_neighbors(polygons) poly_id, prev_pos, next_pos = ring conn = polygons.indices eps = safe_eps(points.dtype) v_cur = points[conn] edge_in = v_cur - points[conn[prev_pos]] edge_out = points[conn[next_pos]] - v_cur normal_hat = normals / normals.norm(dim=-1, keepdim=True).clamp_min(eps) sin_turn = (torch.linalg.cross(edge_in, edge_out) * normal_hat[poly_id]).sum(-1) / ( edge_in.norm(dim=-1) * edge_out.norm(dim=-1) ).clamp_min(eps) reflex_count = torch.zeros( polygons.n_sources, dtype=torch.int64, device=points.device ) reflex_count.index_add_(0, poly_id, (sin_turn < -_REFLEX_SIN_TOL).to(torch.int64)) degenerate = normals.norm(dim=-1) < eps return (reflex_count == 0) | degenerate def _ring_neighbors(polygons: Adjacency) -> _RingNeighbors: """Cyclic previous/next neighbors of every vertex in the flattened rings. Walks the flat connectivity (one slot per polygon-vertex incidence) and, for each slot, returns the owning polygon together with the connectivity indices of the cyclically previous and next vertices of the *same* polygon. The successor of a ring's last vertex wraps to its first, via modular arithmetic confined to that polygon's ``[start, start + valence)`` block. Both :func:`_polygon_normals` and :func:`_convex_mask` use these to gather each vertex's two incident edges (``v - v_prev`` and ``v_next - v``) in one vectorized pass over the whole soup. Parameters ---------- polygons : Adjacency Cell-to-vertex rings in CSR form. Returns ------- poly_id : torch.Tensor Owning polygon of each connectivity slot, shape ``(n_ring_positions,)``. prev_pos : torch.Tensor Connectivity index of the cyclically previous vertex, same shape. next_pos : torch.Tensor Connectivity index of the cyclically next vertex, same shape. """ poly_id, _ = polygons.expand_to_pairs() # owning polygon of each ring position starts = polygons.offsets[:-1][poly_id] valence = polygons.counts[poly_id] local = torch.arange(polygons.indices.shape[0], device=poly_id.device) - starts prev_pos = starts + (local - 1 + valence) % valence next_pos = starts + (local + 1) % valence return poly_id, prev_pos, next_pos def _to_3d( points: Float[torch.Tensor, "n_points n_spatial"], ) -> Float[torch.Tensor, "n_points 3"]: """Lift points onto the ``z = 0`` plane so 3D cross products are defined. The normal and convexity machinery is written with :func:`torch.linalg.cross`, which is defined only for 3-vectors, so planar (``n_spatial == 2``) inputs are padded with a zero z-column. Already-3D inputs pass through untouched. Any other dimensionality is rejected, making the ``n_spatial in {2, 3}`` contract of :func:`triangulate` explicit instead of letting, say, a 1D point cloud silently produce meaningless normals. Parameters ---------- points : torch.Tensor Vertex coordinates, shape ``(n_points, n_spatial)``. Returns ------- torch.Tensor Coordinates embedded in 3D, shape ``(n_points, 3)``. Raises ------ ValueError If ``n_spatial`` is neither 2 nor 3. """ if points.shape[-1] == 3: return points if points.shape[-1] != 2: raise ValueError( f"triangulate supports 2-D or 3-D point coordinates; got " f"{points.shape[-1]}-D points." ) pad = points.new_zeros((points.shape[0], 1)) return torch.cat([points, pad], dim=-1)