"""Differentiable triangular-mesh magnet source."""
from __future__ import annotations
import warnings
import jax.numpy as jnp
import numpy as np
from magpylib_jax._types import ArrayLike
from magpylib_jax.constants import MU0
from magpylib_jax.core.base import BaseSource, MagpylibMissingInput
[docs]
class TriangularMesh(BaseSource):
"""Uniformly polarized magnet defined by mesh vertices and triangular faces."""
_source_type = "triangularmesh"
def __init__(
self,
vertices: ArrayLike | None = None,
faces: ArrayLike | None = None,
polarization: ArrayLike | None = None,
magnetization: ArrayLike | None = None,
position: ArrayLike = (0.0, 0.0, 0.0),
orientation: ArrayLike | None = None,
reorient_faces: bool = True,
check_open: bool | str = "warn",
check_disconnected: bool | str = "warn",
check_selfintersecting: bool | str = "warn",
in_out: str = "auto",
style=None,
style_label: str | None = None,
**kwargs,
) -> None:
self.vertices = vertices
self.faces = faces
self.polarization = polarization
self.magnetization = magnetization
self.reorient_faces = reorient_faces
self.check_open = check_open
self.check_disconnected = check_disconnected
self.check_selfintersecting = check_selfintersecting
self.in_out = self._validate_in_out(in_out)
self.status_open: bool | None = None
self.status_open_data: list[tuple[int, int]] | None = None
self.status_disconnected: bool | None = None
self.status_disconnected_data: list[np.ndarray] | None = None
self.status_reoriented: bool | None = bool(reorient_faces)
self.status_selfintersecting: bool | None = None
self.status_selfintersecting_data: object | None = None
if self.vertices is not None and self.faces is not None:
verts = jnp.asarray(self.vertices, dtype=float)
facs = jnp.asarray(self.faces, dtype=jnp.int32)
if verts.ndim != 2 or verts.shape[1] != 3:
raise ValueError("TriangularMesh `vertices` must have shape (n,3).")
if facs.ndim != 2 or facs.shape[1] != 3:
raise ValueError("TriangularMesh `faces` must have shape (m,3).")
if jnp.any(facs < 0) or jnp.any(facs >= verts.shape[0]):
raise ValueError("TriangularMesh `faces` contain indices outside `vertices`.")
verts_np = np.asarray(self.vertices)
faces_np = np.asarray(self.faces)
mode = self._validate_mode_arg(self.check_open, arg_name="check_open")
if mode != "skip":
open_edges = self._get_open_edges(verts_np, faces_np)
self.status_open = len(open_edges) > 0
self.status_open_data = [tuple(e) for e in open_edges.tolist()]
if self.status_open:
msg = (
"Open mesh detected in TriangularMesh. Inside-outside checks and "
"reorient_faces may yield unexpected results for open meshes. "
"This check can be disabled at initialization with check_open='skip'."
)
if mode == "warn":
warnings.warn(msg, stacklevel=2)
elif mode == "raise":
raise ValueError(msg)
mode = self._validate_mode_arg(self.check_disconnected, arg_name="check_disconnected")
if mode != "skip":
subsets = self._get_faces_subsets(faces_np)
self.status_disconnected = len(subsets) > 1
self.status_disconnected_data = subsets
if self.status_disconnected:
msg = (
"Disconnected mesh detected in TriangularMesh. The magnet consists of "
"multiple individual parts. This check can be disabled at initialization "
"with check_disconnected='skip'. Parts are stored in the "
"status_disconnected_data property."
)
if mode == "warn":
warnings.warn(msg, stacklevel=2)
elif mode == "raise":
raise ValueError(msg)
# Lightweight self-intersecting check: the mode argument is validated
# and accepted, but no full triangle-triangle intersection test is
# performed, so ``status_selfintersecting`` stays ``None`` (unchecked)
# rather than falsely claiming the mesh is intersection-free.
self._validate_mode_arg(self.check_selfintersecting, arg_name="check_selfintersecting")
super().__init__(
position=position,
orientation=orientation,
style=style,
style_label=style_label,
**kwargs,
)
self._mesh_cache_key: tuple[object, ...] | None = None
self._faces_oriented_cache = jnp.zeros((0, 3), dtype=jnp.int32)
self._mesh_cache = jnp.zeros((0, 3, 3), dtype=float)
def _geometry_cache_key(self) -> tuple[object, ...]:
return (
id(self.vertices),
id(self.faces),
bool(self.reorient_faces),
)
def _geometry_cache(self) -> tuple[jnp.ndarray, jnp.ndarray]:
if self.vertices is None or self.faces is None:
empty_faces = jnp.zeros((0, 3), dtype=jnp.int32)
empty_mesh = jnp.zeros((0, 3, 3), dtype=float)
self._mesh_cache_key = None
self._faces_oriented_cache = empty_faces
self._mesh_cache = empty_mesh
return empty_faces, empty_mesh
cache_key = self._geometry_cache_key()
if self._mesh_cache_key == cache_key:
return self._faces_oriented_cache, self._mesh_cache
faces = jnp.asarray(self.faces, dtype=jnp.int32)
if self.reorient_faces:
verts = jnp.asarray(self.vertices, dtype=float)
tri = verts[faces]
center = jnp.mean(verts, axis=0)
ctri = jnp.mean(tri, axis=1)
nvec = jnp.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
inward = jnp.sum(nvec * (ctri - center), axis=1) < 0
faces = jnp.where(inward[:, None], faces[:, (0, 2, 1)], faces)
verts = jnp.asarray(self.vertices, dtype=float)
mesh = verts[faces]
self._mesh_cache_key = cache_key
self._faces_oriented_cache = faces
self._mesh_cache = mesh
return faces, mesh
@staticmethod
def _validate_mode_arg(arg: bool | str, *, arg_name: str = "mode") -> str:
accepted = (True, False, "warn", "raise", "ignore", "skip")
if arg not in accepted:
msg = (
"Input "
f"{arg_name} must be one of {{'warn', 'raise', 'ignore', 'skip', True, False}}; "
f"instead received {arg!r}."
)
raise ValueError(msg)
return "warn" if arg is True else "skip" if arg is False else arg
@staticmethod
def _validate_in_out(value: str) -> str:
if value not in ("auto", "inside", "outside"):
raise ValueError(
"TriangularMesh `in_out` must be one of {'auto', 'inside', 'outside'}."
)
return value
@staticmethod
def _get_open_edges(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray:
edges = np.concatenate([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]], axis=0)
edges = np.sort(edges, axis=1)
uniq, counts = np.unique(edges, axis=0, return_counts=True)
return uniq[counts == 1]
@staticmethod
def _get_faces_subsets(faces: np.ndarray) -> list[np.ndarray]:
"""Split faces into connected components via shared vertices (union-find).
Two faces belong to the same subset if they can be linked by a chain of
faces sharing at least one vertex. Mirrors magpylib's
``get_faces_subsets`` so a disconnected magnet yields more than one part.
"""
faces_arr = np.asarray(faces, dtype=int)
parent: dict[int, int] = {}
def find(x: int) -> int:
parent.setdefault(x, x)
root = x
while parent[root] != root:
root = parent[root]
while parent[x] != root:
parent[x], x = root, parent[x]
return root
def union(a: int, b: int) -> None:
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
for face in faces_arr:
union(int(face[0]), int(face[1]))
union(int(face[1]), int(face[2]))
groups: dict[int, list[np.ndarray]] = {}
for face in faces_arr:
groups.setdefault(find(int(face[0])), []).append(face)
return [np.asarray(g, dtype=int) for g in groups.values()]
@classmethod
def from_ConvexHull(cls, points: ArrayLike, **kwargs) -> TriangularMesh:
from scipy.spatial import ConvexHull
opts = dict(kwargs)
pts = np.asarray(points, dtype=float)
hull = ConvexHull(pts)
return cls(vertices=hull.points, faces=hull.simplices, **opts)
@property
def _polarization(self) -> jnp.ndarray:
if self.polarization is not None:
return jnp.asarray(self.polarization, dtype=float)
if self.magnetization is not None:
return MU0 * jnp.asarray(self.magnetization, dtype=float)
raise MagpylibMissingInput("Input polarization of TriangularMesh must be set.")
@property
def _faces_oriented(self) -> jnp.ndarray:
faces, _ = self._geometry_cache()
return faces
@property
def mesh(self) -> jnp.ndarray:
_, mesh = self._geometry_cache()
return mesh
@property
def barycenter(self) -> jnp.ndarray:
if self.vertices is None or self.faces is None:
return jnp.zeros((3,), dtype=float)
tri = self.mesh
ctri = jnp.mean(tri, axis=1)
area = 0.5 * jnp.linalg.norm(
jnp.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]),
axis=1,
)
total = jnp.maximum(jnp.sum(area), 1e-30)
return jnp.sum(ctri * area[:, None], axis=0) / total
@property
def centroid(self) -> jnp.ndarray:
return self.barycenter + jnp.asarray(self.position, dtype=float)
@property
def volume(self) -> float:
if self.vertices is None or self.faces is None:
return 0.0
tri = self.mesh
signed = jnp.sum(jnp.cross(tri[:, 0], tri[:, 1]) * tri[:, 2], axis=1) / 6.0
return float(jnp.abs(jnp.sum(signed)))
def _require_inputs(self) -> None:
if self.vertices is None:
raise MagpylibMissingInput("Input vertices of TriangularMesh must be set.")
if self.faces is None:
raise MagpylibMissingInput("Input faces of TriangularMesh must be set.")
if self.polarization is None and self.magnetization is None:
raise MagpylibMissingInput("Input polarization of TriangularMesh must be set.")
def _field_kwargs(self) -> dict:
"""Geometry + excitation arguments for the field engine."""
return {"mesh": self.mesh, "polarization": self._polarization}
def _resolve_in_out(self, in_out: str | None) -> str:
"""Default ``in_out`` to the mesh's configured value and validate it."""
return self._validate_in_out(self.in_out if in_out is None else in_out)
[docs]
def getB(self, *observers, in_out=None, squeeze=True, sumup=False,
output="ndarray", pixel_agg=None):
return self._get_field("B", observers, in_out=in_out, squeeze=squeeze,
sumup=sumup, output=output, pixel_agg=pixel_agg)
[docs]
def getH(self, *observers, in_out=None, squeeze=True, sumup=False,
output="ndarray", pixel_agg=None):
return self._get_field("H", observers, in_out=in_out, squeeze=squeeze,
sumup=sumup, output=output, pixel_agg=pixel_agg)
[docs]
def getJ(self, *observers, in_out=None, squeeze=True, sumup=False):
return self._get_field("J", observers, in_out=in_out, squeeze=squeeze, sumup=sumup)
[docs]
def getM(self, *observers, in_out=None, squeeze=True, sumup=False):
return self._get_field("M", observers, in_out=in_out, squeeze=squeeze, sumup=sumup)