Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/api/lapy.modules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ Modules
heat
diffgeo
conformal
intersect
plot
233 changes: 233 additions & 0 deletions lapy/intersect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
"""Self-intersection tests for triangle meshes.

Candidate pairs of triangles come from a KD-tree on the triangle centroids,
and each candidate pair is tested exactly with the interval test of Moeller
(1997). Everything is vectorized over the candidate pairs.
"""

from typing import TYPE_CHECKING

import numpy as np
from scipy import spatial

if TYPE_CHECKING:
from .tria_mesh import TriaMesh

_EDGES = ((0, 1), (1, 2), (2, 0))


def _plane_distances(p: np.ndarray, n: np.ndarray, q: np.ndarray) -> np.ndarray:
"""Signed distances of the corners p (k, 3, 3) to the planes through q (k, 3) with normals n."""
return np.einsum("kij,kj->ki", p - q[:, None, :], n)


def _interval(x: np.ndarray, s: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Interval on the intersection line covered by a triangle.

``x`` holds the corner coordinates along the line and ``s`` the signed
corner distances to the other triangle's plane. The interval spans corners
on the plane and edge crossings.
"""
lo = np.full(len(x), np.inf)
hi = np.full(len(x), -np.inf)
for i, j in _EDGES:
cross = s[:, i] * s[:, j] < 0
with np.errstate(divide="ignore", invalid="ignore"):
val = x[:, i] + (x[:, j] - x[:, i]) * s[:, i] / (s[:, i] - s[:, j])
lo = np.where(cross, np.minimum(lo, val), lo)
hi = np.where(cross, np.maximum(hi, val), hi)
for i in range(3):
on = s[:, i] == 0
lo = np.where(on, np.minimum(lo, x[:, i]), lo)
hi = np.where(on, np.maximum(hi, x[:, i]), hi)
return lo, hi


def triangles_intersect(a: np.ndarray, b: np.ndarray, eps: float = 1e-10) -> np.ndarray:
"""Test pairs of triangles for intersection.

Uses the interval test of Moeller (1997). Pairs that only touch in a
single point and coplanar pairs are reported as not intersecting.

Parameters
----------
a : np.ndarray
Corner coordinates of the first triangle of each pair, shape (k, 3, 3).
b : np.ndarray
Corner coordinates of the second triangle of each pair, shape (k, 3, 3).
eps : float, default=1e-10
Distances to the other triangle's plane below ``eps`` times the
longest edge of the pair count as zero.

Returns
-------
np.ndarray
Boolean array of shape (k,), True where the two triangles intersect.
"""
a = np.asarray(a, dtype=float)
b = np.asarray(b, dtype=float)
na = np.cross(a[:, 1] - a[:, 0], a[:, 2] - a[:, 0])
nb = np.cross(b[:, 1] - b[:, 0], b[:, 2] - b[:, 0])
# unit normals make the plane values true distances; a degenerate
# triangle gets a zero normal and is treated as coplanar, so never hit
with np.errstate(divide="ignore", invalid="ignore"):
ua = np.nan_to_num(na / np.linalg.norm(na, axis=1, keepdims=True))
ub = np.nan_to_num(nb / np.linalg.norm(nb, axis=1, keepdims=True))
sb = _plane_distances(b, ua, a[:, 0])
sa = _plane_distances(a, ub, b[:, 0])
edges = np.concatenate([a - np.roll(a, 1, axis=1), b - np.roll(b, 1, axis=1)], axis=1)
tol = eps * np.linalg.norm(edges, axis=2).max(axis=1)
sa[np.abs(sa) <= tol[:, None]] = 0
sb[np.abs(sb) <= tol[:, None]] = 0
separated = (
np.all(sb > 0, axis=1)
| np.all(sb < 0, axis=1)
| np.all(sa > 0, axis=1)
| np.all(sa < 0, axis=1)
| np.all(sa == 0, axis=1)
| np.all(sb == 0, axis=1)
)
hit = ~separated
idx = np.flatnonzero(hit)
if len(idx) == 0:
return hit
axis = np.argmax(np.abs(np.cross(na[idx], nb[idx])), axis=1)
k = np.arange(len(idx))[:, None]
lo_a, hi_a = _interval(a[idx][k, np.arange(3), axis[:, None]], sa[idx])
lo_b, hi_b = _interval(b[idx][k, np.arange(3), axis[:, None]], sb[idx])
hit[idx] = (lo_a < hi_b) & (lo_b < hi_a)
return hit


def _test_pairs(v: np.ndarray, t: np.ndarray, pairs: np.ndarray, centre: np.ndarray, radius: np.ndarray) -> np.ndarray:
"""Keep the candidate pairs whose triangles intersect and share no vertex."""
pairs = pairs[pairs[:, 0] != pairs[:, 1]]
if len(pairs) == 0:
return np.empty((0, 2), dtype=np.int64)
dist = np.linalg.norm(centre[pairs[:, 0]] - centre[pairs[:, 1]], axis=1)
pairs = pairs[dist <= radius[pairs[:, 0]] + radius[pairs[:, 1]]]
shared = (t[pairs[:, 0], :, None] == t[pairs[:, 1], None, :]).any(axis=(1, 2))
pairs = pairs[~shared]
p = v[t]
return pairs[triangles_intersect(p[pairs[:, 0]], p[pairs[:, 1]])]


def _intersections(v: np.ndarray, t: np.ndarray, subset: np.ndarray | None = None) -> np.ndarray:
"""Intersecting triangle pairs of the mesh (v, t), optionally only those involving ``subset``."""
p = v[t]
centre = p.mean(axis=1)
radius = np.linalg.norm(p - centre[:, None], axis=2).max(axis=1)
if subset is None:
# the few much larger triangles are searched one by one, so that they
# do not inflate the search radius for all others
big_r = np.percentile(radius, 99.9)
tree = spatial.KDTree(centre)
pairs = [tree.query_pairs(2 * big_r, output_type="ndarray")]
for i in np.flatnonzero(radius > big_r):
near = np.array(tree.query_ball_point(centre[i], radius[i] + radius.max()), dtype=np.int64)
pairs.append(np.column_stack([np.minimum(i, near), np.maximum(i, near)]))
else:
subset = np.unique(np.asarray(subset, dtype=np.int64))
if len(subset) == 0:
return np.empty((0, 2), dtype=np.int64)
# only triangles in the bounding box of the subset, widened by the
# largest possible reach, can intersect it
reach = radius[subset].max() + radius.max()
lo = centre[subset].min(axis=0) - reach
hi = centre[subset].max(axis=0) + reach
box = np.flatnonzero(np.all((centre >= lo) & (centre <= hi), axis=1))
near = spatial.KDTree(centre[box]).query_ball_point(centre[subset], radius[subset] + radius.max())
a = np.repeat(subset, [len(n) for n in near])
b = box[np.concatenate([np.asarray(n, dtype=np.int64) for n in near])]
pairs = [np.column_stack([np.minimum(a, b), np.maximum(a, b)])]
# every pair is ordered within its row
pairs = np.unique(np.concatenate(pairs).reshape(-1, 2), axis=0)
return _test_pairs(v, t, pairs, centre, radius)


def self_intersections(tria: "TriaMesh", subset: np.ndarray | None = None) -> np.ndarray:
"""Find pairs of triangles that intersect and do not share a vertex.

Triangles that share a vertex or an edge are never reported, and
neither are triangles that only touch in a point or lie in a common
plane. For a 2D mesh, which is planar, nothing is reported.

Parameters
----------
tria : TriaMesh
Triangle mesh.
subset : np.ndarray, default=None
Triangle indices. If given, only pairs that involve at least one of
these triangles are reported, which is much faster when only a few
triangles have moved.

Returns
-------
np.ndarray
Array of shape (n_pairs, 2) with the indices of intersecting
triangles, sorted within each row and lexicographically.
"""
return _intersections(np.asarray(tria.v, dtype=float), np.asarray(tria.t, dtype=np.int64), subset)


def undo_intersections(
tria: "TriaMesh", v_new: np.ndarray, rings: int = 2, max_rounds: int = 10
) -> tuple[np.ndarray, int]:
"""Move vertices back where new positions make the surface intersect itself.

Useful after smoothing or another change of vertex positions on a
surface that did not intersect itself before. Starting from the
triangles that intersect with the new positions, their vertices and
``rings`` rings of neighbors return to their positions in ``tria``. This
repeats, testing only the triangles that changed, until no triangle
intersects or ``max_rounds`` is reached. The mesh itself is not
modified.

Parameters
----------
tria : TriaMesh
Triangle mesh with the old vertex positions.
v_new : np.ndarray
New vertex positions of shape (n_vertices, 3), or (n_vertices, 2)
for a 2D mesh.
rings : int, default=2
Rings of neighbors that move back together with each intersecting
triangle.
max_rounds : int, default=10
Maximum number of rounds.

Returns
-------
v : np.ndarray
Vertex positions, of the same shape as ``v_new``.
n_reverted : int
Number of vertices moved back to their old positions.

Raises
------
ValueError
If ``v_new`` does not have the shape of the mesh vertices.
"""
v_old = tria.get_vertices(original_dim=True)
v = np.array(v_new, dtype=float)
if v.shape != v_old.shape:
raise ValueError(f"v_new has shape {v.shape}, the mesh has vertices of shape {v_old.shape}.")
t = np.asarray(tria.t, dtype=np.int64)
adj = (tria.adj_sym > 0).astype(float)

def positions_3d(x: np.ndarray) -> np.ndarray:
return x if x.shape[1] == 3 else np.column_stack([x, np.zeros(len(x))])

reverted = np.zeros(len(v), dtype=bool)
pairs = _intersections(positions_3d(v), t)
for _ in range(max_rounds):
if len(pairs) == 0:
break
mask = np.zeros(len(v), dtype=bool)
mask[t[pairs].ravel()] = True
for _ in range(rings):
mask |= adj @ mask.astype(float) > 0
v[mask] = v_old[mask]
reverted |= mask
pairs = _intersections(positions_3d(v), t, np.flatnonzero(np.any(mask[t], axis=1)))
return v, int(reverted.sum())
96 changes: 96 additions & 0 deletions lapy/utils/tests/test_intersect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import numpy as np

from ...intersect import self_intersections, triangles_intersect, undo_intersections
from ...tria_mesh import TriaMesh
from .test_tria_mesh import _torus


def _pushed_through_torus():
"""Torus with a vertex of the inner equator moved across the hole into the opposite tube."""
v, t = _torus(30, 12, 3.0, 1.0)
i = int(np.argmin(np.linalg.norm(v - [2.0, 0.0, 0.0], axis=1)))
moved = v.copy()
moved[i] = [-3.0, 0.0, 0.0]
return v, moved, t, i


def test_triangle_pairs():
"""
A triangle against one that pierces it, lies above it, touches a corner,
lies beside it and lies in the same plane.
"""
a = np.array([[0, 0, 0], [2, 0, 0], [0, 2, 0]], dtype=float)
piercing = np.array([[0.5, 0.5, -1], [0.5, 0.5, 1], [3, 3, 0]], dtype=float)
above = piercing + [0, 0, 5]
corner_touch = np.array([[2, 0, 0], [3, 0, 1], [3, 0, -1]], dtype=float)
beside = np.array([[3, 0, -1], [3, 0, 1], [4, 1, 0]], dtype=float)
coplanar = a + [0.5, 0.5, 0]
others = np.stack([piercing, above, corner_touch, beside, coplanar])
hit = triangles_intersect(np.repeat(a[None], len(others), axis=0), others)
assert hit.tolist() == [True, False, False, False, False]


def test_thin_sliver_through_large_triangle():
"""
A sliver piercing a much larger triangle is found in either argument
order, and random pairs give the same answer in both orders.
"""
a = np.array([[-5, -5, 0], [5, -5, 0], [0, 5, 0]], dtype=float)
for width in (1e-1, 1e-5, 1e-9):
b = np.array([[0, 0, -1], [0, 0, 1], [width, 0, 1]], dtype=float)
assert triangles_intersect(a[None], b[None])[0]
assert triangles_intersect(b[None], a[None])[0]
rng = np.random.default_rng(0)
p = rng.normal(size=(2000, 3, 3))
q = rng.normal(size=(2000, 3, 3)) * [1.0, 1.0, 1e-6]
hit = triangles_intersect(p, q)
assert 0 < hit.sum() < len(hit)
np.testing.assert_array_equal(hit, triangles_intersect(q, p))


def test_overlapping_copies_intersect():
"""
Two copies of a torus intersect each other where they overlap, never
within one copy, and not at all when they are far apart.
"""
v, t = _torus(30, 12, 3.0, 1.0)
assert len(self_intersections(TriaMesh(v, t))) == 0
both = np.vstack([t, t + len(v)])
pairs = self_intersections(TriaMesh(np.vstack([v, v + [1.3, 0.4, 0.7]]), both))
assert len(pairs) > 0
assert np.all((pairs[:, 0] < len(t)) != (pairs[:, 1] < len(t)))
assert len(self_intersections(TriaMesh(np.vstack([v, v + [20.0, 0, 0]]), both))) == 0


def test_subset_finds_the_same_pairs():
"""
Restricting the search to the triangles around a moved vertex finds the
same pairs as the full search.
"""
_, moved, t, i = _pushed_through_torus()
mesh = TriaMesh(moved, t)
full = self_intersections(mesh)
assert len(full) > 0
around = np.flatnonzero(np.any(t == i, axis=1))
np.testing.assert_array_equal(self_intersections(mesh, subset=around), full)
far = np.setdiff1d(np.arange(len(t)), np.unique(full))[:10]
assert len(self_intersections(mesh, subset=far)) == 0


def test_undo_intersections_moves_back_only_near_the_crossing():
"""
After a vertex is pushed through the opposite tube, only the vertices
near the crossing return to their old positions.
"""
v, moved, t, i = _pushed_through_torus()
# far from where the vertex started and from the tube it now pierces
far = (np.linalg.norm(v - v[i], axis=1) > 3.0) & (np.linalg.norm(v - moved[i], axis=1) > 3.0)
moved[far] += 0.01 # a small change elsewhere that keeps the surface clean
mesh = TriaMesh(v, t)
fixed, n_back = undo_intersections(mesh, moved)
assert len(self_intersections(TriaMesh(fixed, t))) == 0
np.testing.assert_allclose(fixed[i], v[i])
np.testing.assert_allclose(fixed[far], moved[far])
assert 0 < n_back < len(v) // 4
# the mesh itself keeps its old positions
np.testing.assert_allclose(mesh.v, v)
Loading