# 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.
"""Public Mesh API for Warp-accelerated surface remeshing."""
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, TypeAlias
import torch
from tensordict import TensorDict
from physicsnemo.nn.functional.geometry.remeshing.remeshing import (
_remeshing_with_mapping,
)
if TYPE_CHECKING:
from physicsnemo.mesh.mesh import Mesh
PointDataKey: TypeAlias = str | tuple[str, ...]
PointDataSelection: TypeAlias = bool | PointDataKey | list[PointDataKey] | None
ResolutionField: TypeAlias = PointDataKey | torch.Tensor | None
# A linear-resolution multiplier is an inverse target edge length. For the
# squared-distance CVT objective on a 2D surface, its integration density is
# the fourth power of that multiplier.
_LINEAR_RESOLUTION_DENSITY_EXPONENT = 4.0
def _canonical_point_data_key(key: PointDataKey) -> PointDataKey:
"""Match TensorDict's canonical spelling for one-component key paths."""
if isinstance(key, tuple) and len(key) == 1:
return key[0]
return key
def _point_data_keys(
mesh: Mesh,
selection: PointDataSelection,
) -> list[PointDataKey]:
"""Resolve a point-data transfer selection into unique leaf paths."""
if selection is None or selection is False:
return []
available = list(mesh.point_data.keys(include_nested=True, leaves_only=True))
if selection is True:
return available
if isinstance(selection, str):
requested: list[PointDataKey] = [selection]
elif isinstance(selection, tuple) and all(
isinstance(part, str) for part in selection
):
if not selection:
raise ValueError("point_data key paths cannot be empty")
requested = [selection]
elif isinstance(selection, list):
requested = list(selection)
else:
raise TypeError(
"transfer_point_data must be a bool, point_data key/path, "
"list of keys/paths, or None"
)
resolved: list[PointDataKey] = []
for key in requested:
if isinstance(key, str):
normalized_key: PointDataKey = key
elif (
isinstance(key, tuple)
and key
and all(isinstance(part, str) for part in key)
):
normalized_key = _canonical_point_data_key(key)
else:
raise TypeError(
"each transfer_point_data entry must be a string or a "
"nonempty tuple of strings"
)
if normalized_key not in available:
raise KeyError(
f"point_data field {normalized_key!r} was not found. "
f"Available keys: {available}"
)
if normalized_key not in resolved:
resolved.append(normalized_key)
return resolved
def _resolve_resolution_field(
mesh: Mesh,
field: ResolutionField,
) -> torch.Tensor | None:
"""Resolve and validate a direct or attached linear-resolution field."""
if field is None:
return None
if isinstance(field, torch.Tensor):
resolution = field
description = "resolution_field"
else:
if not isinstance(field, (str, tuple)):
raise TypeError(
"resolution_field must be a torch.Tensor, point_data key/path, or None"
)
if isinstance(field, tuple) and (
not field or not all(isinstance(part, str) for part in field)
):
raise TypeError("resolution_field paths must be nonempty tuples of strings")
key = _canonical_point_data_key(field)
available = list(mesh.point_data.keys(include_nested=True, leaves_only=True))
if key not in available:
raise KeyError(
f"resolution_field {key!r} was not found in point_data. "
f"Available keys: {available}"
)
resolution = mesh.point_data[key]
description = f"resolution_field {key!r}"
if not isinstance(resolution, torch.Tensor):
raise TypeError(f"{description} must resolve to a torch.Tensor")
if resolution.shape != (mesh.n_points,):
raise ValueError(
f"{description} must have shape ({mesh.n_points},), "
f"got {tuple(resolution.shape)}"
)
if not torch.is_floating_point(resolution):
raise TypeError(
f"{description} must use a real floating-point dtype, "
f"got {resolution.dtype}"
)
if resolution.device != mesh.points.device:
raise ValueError(f"{description} and mesh points must be on the same device")
return resolution
def _validate_transfer_fields(
mesh: Mesh,
keys: Sequence[PointDataKey],
) -> None:
"""Validate selected fields before starting the remeshing operation."""
for key in keys:
values = mesh.point_data[key]
if not isinstance(values, torch.Tensor):
raise TypeError(f"point_data field {key!r} must be a torch.Tensor")
if not torch.is_floating_point(values):
raise TypeError(
f"point_data field {key!r} must use a real floating-point "
"dtype for barycentric interpolation, got "
f"{values.dtype}"
)
if values.shape[0] != mesh.n_points:
raise ValueError(
f"point_data field {key!r} must start with "
f"n_points={mesh.n_points}, got shape {tuple(values.shape)}"
)
if values.device != mesh.points.device:
raise ValueError(
f"point_data field {key!r} and mesh points must be on the same device"
)
def _interpolate_point_data(
mesh: Mesh,
keys: Sequence[PointDataKey],
source_faces: torch.Tensor,
barycentric_coordinates: torch.Tensor,
) -> TensorDict:
"""Interpolate selected source fields at projected output vertices."""
output = TensorDict(
{},
batch_size=[source_faces.shape[0]],
device=mesh.points.device,
)
if not keys:
return output
if bool((source_faces < 0).any()):
raise RuntimeError(
"Warp could not locate a source triangle for every remeshed "
"vertex, so point data cannot be transferred"
)
source_vertices = mesh.cells.to(torch.int64)[source_faces]
weights_by_dtype: dict[torch.dtype, torch.Tensor] = {}
for key in keys:
values = mesh.point_data[key]
accumulation_dtype = (
torch.float32 if values.element_size() < 4 else values.dtype
)
gathered = values[source_vertices].to(dtype=accumulation_dtype)
weights = weights_by_dtype.get(accumulation_dtype)
if weights is None:
weights = barycentric_coordinates.to(dtype=accumulation_dtype)
weights_by_dtype[accumulation_dtype] = weights
weight_shape = (source_faces.shape[0], 3) + (1,) * (values.ndim - 1)
weights = weights.reshape(weight_shape)
interpolated = (gathered * weights).sum(dim=1).to(dtype=values.dtype)
output.set(key, interpolated)
return output
[docs]
def remesh(
mesh: Mesh,
n_clusters: int,
*,
max_iterations: int = 4,
transfer_point_data: PointDataSelection = False,
resolution_field: ResolutionField = None,
) -> Mesh:
"""Remesh a triangle surface with point-data and resolution controls.
Warp performs integration-mass-weighted centroidal clustering, projects
cluster centers back to the source surface with a bounding volume
hierarchy, and reconstructs compact triangle connectivity. A direct
positive tensor or an attached point-data field can specify relative local
linear resolution.
Parameters
----------
mesh : Mesh
Input triangle surface. Only 2D triangle manifolds embedded in 3D are
supported.
n_clusters : int
Target output vertex count. Cleanup can produce slightly fewer vertices.
Must be between 3 and the input point count, inclusive.
max_iterations : int, optional
Maximum centroid-relaxation iterations. Default is ``4``. Values must
be non-negative.
transfer_point_data : bool, str, tuple, list, or None, optional
Point-data fields to interpolate onto the output vertices. ``False``
or ``None`` transfers no fields. ``True`` transfers every point-data
leaf. A string or tuple selects one key or nested key path. A list
selects several keys or paths. Selected fields must contain real
floating-point tensors. Default is ``False``.
resolution_field : str, tuple, torch.Tensor, or None, optional
Positive scalar tensor with shape ``(n_points,)``, or a key or nested
key path resolving to one in ``mesh.point_data``. Values specify
relative linear resolution. A value twice another requests
approximately half the local edge spacing. The fixed ``n_clusters``
budget and source geometry limit the realized spacing. The field must
use a real floating-point dtype on the mesh device. Direct tensor
entries correspond to ``mesh.points`` order and are not attached to or
transferred with the output mesh. Only relative values matter. Default
is ``None`` for uniform remeshing.
Returns
-------
Mesh
Remeshed surface on the input device. Selected point data is
barycentrically interpolated from the original source surface. Cell
data and unselected point data are discarded. Global data is
preserved.
Raises
------
TypeError
If counts, tuning parameters, point coordinates, a field selection, or
a selected field has an invalid type.
ValueError
If a count is out of range or geometry, connectivity, or a selected
field is invalid.
KeyError
If a requested point-data key or path does not exist.
NotImplementedError
If ``mesh`` is not a 2D triangle surface embedded in 3D.
ImportError
If Warp is unavailable.
RuntimeError
If cleanup cannot reconstruct a nonempty manifold triangle surface or
point-data transfer provenance is unavailable.
Notes
-----
Remeshing, topology, projection choices, and resolution control are
intentionally non-differentiable. Transferred fields remain differentiable
with respect to their source values because the final barycentric
interpolation uses PyTorch. Warp computes geometry in centered and scaled
coordinates in float32, then restores the input point dtype and coordinate
frame. For the 2D squared-distance CVT objective, the implementation
converts linear resolution ``r`` to integration density ``r**4``. Ideal
local point density therefore scales approximately as ``r**2``. These
relationships guide allocation but do not guarantee exact edge lengths or
local point counts. Because clustering uses spatial distance rather than
mesh connectivity, sheets or thin features separated by less than the mean
cluster spacing can be assigned to a common cluster and welded together.
Projection can map distinct cluster centroids to the same surface position.
Output vertices are compacted by connectivity but are not welded by
position. Backend-specific tuning remains available through
:func:`physicsnemo.nn.functional.remeshing`. These advanced parameters may
change as the implementation evolves.
"""
if mesh.n_manifold_dims != 2 or mesh.n_spatial_dims != 3:
raise NotImplementedError(
"remesh only supports 2D triangle surfaces embedded in 3D. Got "
f"n_manifold_dims={mesh.n_manifold_dims} and "
f"n_spatial_dims={mesh.n_spatial_dims}"
)
transfer_keys = _point_data_keys(mesh, transfer_point_data)
_validate_transfer_fields(mesh, transfer_keys)
linear_resolution = _resolve_resolution_field(mesh, resolution_field)
(
output_points,
output_cells,
source_faces,
barycentric_coordinates,
) = _remeshing_with_mapping(
mesh.points,
mesh.cells,
n_clusters,
max_iterations=max_iterations,
vertex_density=linear_resolution,
vertex_density_exponent=_LINEAR_RESOLUTION_DENSITY_EXPONENT,
)
output_point_data = (
_interpolate_point_data(
mesh,
transfer_keys,
source_faces,
barycentric_coordinates,
)
if transfer_keys
else None
)
from physicsnemo.mesh.mesh import Mesh
return Mesh(
points=output_points,
cells=output_cells,
point_data=output_point_data,
global_data=mesh.global_data.clone(),
)
__all__ = ["remesh"]