Source code for physicsnemo.mesh.validation.quality

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

"""Quality metrics for mesh cells.

Computes geometric quality metrics for simplicial cells including aspect ratio,
skewness, and angles. Higher quality = better shaped cells.
"""

import math
from typing import TYPE_CHECKING

import torch
from jaxtyping import Float
from tensordict import TensorDict

from physicsnemo.mesh.geometry._angles import compute_vertex_angles
from physicsnemo.mesh.geometry._cell_areas import compute_cell_areas
from physicsnemo.mesh.utilities._tolerances import safe_eps

if TYPE_CHECKING:
    from physicsnemo.mesh.mesh import Mesh


def compute_cell_edge_lengths(
    mesh: "Mesh",
) -> Float[torch.Tensor, "n_cells n_edges_per_cell"]:
    """Compute all pairwise edge lengths within each cell.

    For an n-simplex with (n+1) vertices, there are C(n+1, 2) edges per cell.
    Returns a tensor of all edge lengths, vectorized across all cells.

    Parameters
    ----------
    mesh : Mesh
        Mesh whose cells to measure.

    Returns
    -------
    torch.Tensor
        Edge lengths, shape ``(n_cells, n_edges_per_cell)`` where
        ``n_edges_per_cell = C(n_manifold_dims + 1, 2)``.
        Returns an empty ``(0, 0)`` tensor if the mesh has no cells.

    Examples
    --------
    >>> import torch
    >>> from physicsnemo.mesh import Mesh
    >>> points = torch.tensor([[0., 0.], [1., 0.], [0., 1.]])
    >>> cells = torch.tensor([[0, 1, 2]])
    >>> mesh = Mesh(points=points, cells=cells)
    >>> lengths = compute_cell_edge_lengths(mesh)
    >>> lengths.shape
    torch.Size([1, 3])
    """
    if mesh.n_cells == 0:
        return torch.zeros((0, 0), dtype=mesh.points.dtype, device=mesh.points.device)

    cell_vertices = mesh.points[mesh.cells]  # (n_cells, n_verts, n_dims)
    n_verts_per_cell = mesh.n_manifold_dims + 1

    # All (i, j) pairs with i < j via upper-triangular indices
    i_indices, j_indices = torch.triu_indices(
        n_verts_per_cell,
        n_verts_per_cell,
        offset=1,
        device=mesh.points.device,
    )
    # Edge vectors and their lengths: (n_cells, n_edges_per_cell)
    edge_vectors = cell_vertices[:, j_indices] - cell_vertices[:, i_indices]
    return torch.linalg.vector_norm(edge_vectors, dim=-1)


def _compute_simplex_altitudes(
    mesh: "Mesh",
    cell_measures: Float[torch.Tensor, " n_cells"],
) -> Float[torch.Tensor, "n_cells n_vertices_per_cell"]:
    """Compute the altitude opposite every simplex vertex."""
    n_manifold_dims = mesh.n_manifold_dims
    n_vertices_per_cell = n_manifold_dims + 1

    if n_manifold_dims == 1:
        # A segment's two opposing facets are points with unit 0-volume.
        return cell_measures[:, None].expand(-1, n_vertices_per_cell)

    cell_vertices = mesh.points[mesh.cells]
    vertex_indices = torch.arange(n_vertices_per_cell, device=mesh.cells.device)
    facet_mask = ~torch.eye(
        n_vertices_per_cell,
        dtype=torch.bool,
        device=mesh.cells.device,
    )
    facet_indices = vertex_indices.expand(n_vertices_per_cell, -1)[facet_mask].reshape(
        n_vertices_per_cell, n_manifold_dims
    )
    facet_vertices = cell_vertices[:, facet_indices]
    facet_relative_vectors = facet_vertices[..., 1:, :] - facet_vertices[..., :1, :]
    facet_measures = compute_cell_areas(facet_relative_vectors.flatten(0, 1)).unflatten(
        0, (mesh.n_cells, n_vertices_per_cell)
    )

    # An absolute epsilon is not dimensionally valid here: facet measures have
    # units of length ** (d - 1), while the common tolerance is length-valued.
    # Guard exact zeros without changing any positive, representable measure.
    facet_denominators = torch.where(
        facet_measures > 0,
        facet_measures,
        torch.ones_like(facet_measures),
    )
    return n_manifold_dims * cell_measures[:, None] / facet_denominators


[docs] def compute_quality_metrics(mesh: "Mesh") -> TensorDict: """Compute geometric quality metrics for all cells. Returns TensorDict with per-cell quality metrics: - aspect_ratio: normalized max_edge / min_altitude (lower is better, 1.0 is a regular simplex) - min_angle: Minimum interior angle in radians - max_angle: Maximum interior angle in radians - edge_length_ratio: max_edge / min_edge (1.0 is a regular simplex) - quality_score: Combined metric in [0,1] (1.0 is a regular simplex) Parameters ---------- mesh : Mesh Mesh to analyze Returns ------- TensorDict TensorDict of shape (n_cells,) with quality metrics Notes ----- A 0-simplex has no shape to distort, so its aspect ratio, edge-length ratio, and quality score are 1. Its edge lengths and angles are undefined and reported as ``NaN``. Examples -------- >>> from physicsnemo.mesh.primitives.basic import two_triangles_2d >>> mesh = two_triangles_2d.load() >>> metrics = compute_quality_metrics(mesh) >>> assert "quality_score" in metrics.keys() """ if mesh.n_cells == 0: return TensorDict( {}, batch_size=torch.Size([0]), device=mesh.points.device, ) device = mesh.points.device dtype = mesh.points.dtype n_cells = mesh.n_cells if mesh.n_manifold_dims == 0: # A point has no shape to distort, so its dimensionless quality values # are ideal. Edge lengths and angles are undefined for a 0-simplex. ideal = torch.ones((n_cells,), dtype=dtype, device=device) undefined = torch.full( (n_cells,), float("nan"), dtype=dtype, device=device, ) return TensorDict( { "aspect_ratio": ideal.clone(), "edge_length_ratio": ideal.clone(), "min_angle": undefined.clone(), "max_angle": undefined.clone(), "min_edge_length": undefined.clone(), "max_edge_length": undefined.clone(), "quality_score": ideal, }, batch_size=torch.Size([n_cells]), device=device, ) ### Compute edge lengths for each cell edge_lengths = compute_cell_edge_lengths(mesh) # (n_cells, n_edges_per_cell) max_edge = edge_lengths.max(dim=1).values min_edge = edge_lengths.min(dim=1).values eps = safe_eps(dtype) min_edge_denominator = torch.where( min_edge > 0, min_edge, torch.ones_like(min_edge), ) edge_length_ratio = max_edge / min_edge_denominator edge_length_ratio = torch.where( min_edge > 0, edge_length_ratio, torch.full_like(edge_length_ratio, float("inf")), ) ### Compute a dimensionless, scale-invariant simplex aspect ratio cell_measures = mesh.cell_areas min_altitude = _compute_simplex_altitudes(mesh, cell_measures).min(dim=1).values min_altitude_denominator = torch.where( min_altitude > 0, min_altitude, torch.ones_like(min_altitude), ) raw_aspect_ratio = max_edge / min_altitude_denominator # A regular d-simplex has max_edge / min_altitude = sqrt(2d / (d + 1)). # Normalize by that value so 1.0 is ideal in every manifold dimension. regular_simplex_ratio = math.sqrt( 2 * mesh.n_manifold_dims / (mesh.n_manifold_dims + 1) ) aspect_ratio = (raw_aspect_ratio / regular_simplex_ratio).clamp(min=1.0) aspect_ratio = torch.where( (cell_measures > 0) & (min_altitude > 0), aspect_ratio, torch.full_like(aspect_ratio, float("inf")), ) ### Compute interior angles at each vertex of each cell if mesh.n_manifold_dims >= 2: # Unified formula works for triangles, tetrahedra, and higher simplices all_angles = compute_vertex_angles(mesh) # (n_cells, n_verts_per_cell) min_angle = all_angles.min(dim=1).values max_angle = all_angles.max(dim=1).values else: # For 1D manifolds (edges), interior angles are not meaningful min_angle = torch.full((n_cells,), float("nan"), dtype=dtype, device=device) max_angle = torch.full((n_cells,), float("nan"), dtype=dtype, device=device) ### Compute combined quality score # Perfect simplex has: # - edge_length_ratio = 1.0 (all edges equal) # - all vertex angles are equal # - aspect_ratio = 1.0 # Quality score combines multiple metrics # Each component in [0, 1] where 1 is perfect # Edge uniformity: 1 / edge_length_ratio (clamped) edge_uniformity = 1.0 / torch.clamp(edge_length_ratio, min=1.0, max=10.0) # Aspect ratio quality: 1 / aspect_ratio (clamped) aspect_quality = 1.0 / torch.clamp(aspect_ratio, min=1.0, max=10.0) # Angle quality: measure how uniform the vertex angles are within each cell if mesh.n_manifold_dims == 2: # For triangles: compare against equilateral ideal (pi/3) ideal_angle = torch.pi / 3 min_angle_quality = torch.clamp(min_angle / ideal_angle, max=1.0) max_angle_quality = torch.clamp(ideal_angle / max_angle, max=1.0) angle_quality = (min_angle_quality + max_angle_quality) / 2 elif mesh.n_manifold_dims >= 3: # For tets and higher: use min/max ratio (1.0 for regular simplex) angle_quality = torch.clamp(min_angle / max_angle.clamp(min=eps), max=1.0) else: angle_quality = torch.ones((n_cells,), dtype=dtype, device=device) # Combined score (geometric mean) quality_score = (edge_uniformity * aspect_quality * angle_quality) ** (1 / 3) return TensorDict( { "aspect_ratio": aspect_ratio, "edge_length_ratio": edge_length_ratio, "min_angle": min_angle, "max_angle": max_angle, "min_edge_length": min_edge, "max_edge_length": max_edge, "quality_score": quality_score, }, batch_size=torch.Size([n_cells]), device=device, )