Skip to content

Commit 2630dbf

Browse files
authored
Merge pull request #2362 from eepeterson/polygon_fix
try to constrain triangulation
2 parents ec6f15d + 2f5428f commit 2630dbf

File tree

2 files changed

+200
-26
lines changed

2 files changed

+200
-26
lines changed

openmc/model/surface_composite.py

Lines changed: 146 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -659,21 +659,10 @@ class Polygon(CompositeSurface):
659659
def __init__(self, points, basis='rz'):
660660
check_value('basis', basis, ('xy', 'yz', 'xz', 'rz'))
661661
self._basis = basis
662-
points = np.asarray(points, dtype=float)
663-
check_iterable_type('points', points, float, min_depth=2, max_depth=2)
664-
check_length('points', points[0, :], 2, 2)
665-
666-
# If the last point is the same as the first, remove it and make sure
667-
# there are still at least 3 points for a valid polygon.
668-
if np.allclose(points[0, :], points[-1, :]):
669-
points = points[:-1, :]
670-
check_length('points', points, 3)
671-
672-
# Order the points counter-clockwise (necessary for offset method)
673-
self._points = self._make_ccw(points)
674662

675-
# Create a triangulation of the points.
676-
self._tri = Delaunay(self._points, qhull_options='QJ')
663+
# Create a constrained triangulation of the validated points.
664+
# The constrained triangulation is set to the _tri attribute
665+
self._constrain_triangulation(self._validate_points(points))
677666

678667
# Decompose the polygon into groups of simplices forming convex subsets
679668
# and get the sets of (surface, operator) pairs defining the polygon
@@ -723,7 +712,7 @@ def boundary_type(self, boundary_type):
723712

724713
@property
725714
def points(self):
726-
return self._points
715+
return self._tri.points
727716

728717
@property
729718
def basis(self):
@@ -738,7 +727,7 @@ def _normals(self):
738727
# Get the unit vectors that point from one point in the polygon to the
739728
# next given that they are ordered counterclockwise and that the final
740729
# point is connected to the first point
741-
tangents = np.diff(self._points, axis=0, append=[self._points[0, :]])
730+
tangents = np.diff(self.points, axis=0, append=[self.points[0, :]])
742731
tangents /= np.linalg.norm(tangents, axis=-1, keepdims=True)
743732
# Rotate the tangent vectors clockwise by 90 degrees, which for a
744733
# counter-clockwise ordered polygon will produce the outward normal
@@ -761,8 +750,9 @@ def regions(self):
761750
def region(self):
762751
return self._region
763752

764-
def _make_ccw(self, points):
765-
"""Order a set of points counter-clockwise.
753+
def _validate_points(self, points):
754+
"""Ensure the closed path defined by points does not intersect and is
755+
oriented counter-clockwise.
766756
767757
Parameters
768758
----------
@@ -773,15 +763,145 @@ def _make_ccw(self, points):
773763
-------
774764
ordered_points : the input points ordered counter-clockwise
775765
"""
766+
points = np.asarray(points, dtype=float)
767+
check_iterable_type('points', points, float, min_depth=2, max_depth=2)
768+
check_length('points', points[0, :], 2, 2)
769+
770+
# If the last point is the same as the first, remove it and make sure
771+
# there are still at least 3 points for a valid polygon.
772+
if np.allclose(points[0, :], points[-1, :]):
773+
points = points[:-1, :]
774+
check_length('points', points, 3)
775+
776+
if len(points) != len(np.unique(points, axis=0)):
777+
raise ValueError('Duplicate points were detected in the Polygon input')
778+
779+
# Order the points counter-clockwise (necessary for offset method)
776780
# Calculates twice the signed area of the polygon using the "Shoelace
777781
# Formula" https://en.wikipedia.org/wiki/Shoelace_formula
782+
# If signed area is positive the curve is oriented counter-clockwise.
783+
# If the signed area is negative the curve is oriented clockwise.
778784
xpts, ypts = points.T
785+
if np.sum(ypts*(np.roll(xpts, 1) - np.roll(xpts, -1))) < 0:
786+
points = points[::-1, :]
787+
788+
# Check if polygon is self-intersecting by comparing edges pairwise
789+
n = len(points)
790+
for i in range(n):
791+
p0 = points[i, :]
792+
p1 = points[(i + 1) % n, :]
793+
for j in range(i + 1, n):
794+
p2 = points[j, :]
795+
p3 = points[(j + 1) % n, :]
796+
# Compute orientation of p0 wrt p2->p3 line segment
797+
cp0 = np.cross(p3-p0, p2-p0)
798+
# Compute orientation of p1 wrt p2->p3 line segment
799+
cp1 = np.cross(p3-p1, p2-p1)
800+
# Compute orientation of p2 wrt p0->p1 line segment
801+
cp2 = np.cross(p1-p2, p0-p2)
802+
# Compute orientation of p3 wrt p0->p1 line segment
803+
cp3 = np.cross(p1-p3, p0-p3)
804+
805+
# Group cross products in an array and find out how many are 0
806+
cross_products = np.array([[cp0, cp1], [cp2, cp3]])
807+
cps_near_zero = np.isclose(cross_products, 0).astype(int)
808+
num_zeros = np.sum(cps_near_zero)
809+
810+
# Topologies of 2 finite line segments categorized by the number
811+
# of zero-valued cross products:
812+
#
813+
# 0: No 3 points lie on the same line
814+
# 1: 1 point lies on the same line defined by the other line
815+
# segment, but is not coincident with either of the points
816+
# 2: 2 points are coincident, but the line segments are not
817+
# collinear which guarantees no intersection
818+
# 3: not possible, except maybe floating point issues?
819+
# 4: Both line segments are collinear, simply need to check if
820+
# they overlap or not
821+
# adapted from algorithm linked below and modified to only
822+
# consider intersections on the interior of line segments as
823+
# proper intersections: i.e. segments sharing end points do not
824+
# count as intersections.
825+
# https://www.geeksforgeeks.org/check-if-two-given-line-segments-intersect/
826+
827+
if num_zeros == 0:
828+
# If the orientations of p0 and p1 have opposite signs
829+
# and the orientations of p2 and p3 have opposite signs
830+
# then there is an intersection.
831+
if all(np.prod(cross_products, axis=-1) < 0):
832+
raise ValueError('Polygon cannot be self-intersecting')
833+
continue
779834

780-
# If signed area is positive the curve is oriented counter-clockwise
781-
if np.sum(ypts*(np.roll(xpts, 1) - np.roll(xpts, -1))) > 0:
782-
return points
835+
elif num_zeros == 1:
836+
# determine which line segment has 2 out of the 3 collinear
837+
# points
838+
idx = np.argwhere(np.sum(cps_near_zero, axis=-1) == 0)
839+
if np.prod(cross_products[idx, :]) < 0:
840+
raise ValueError('Polygon cannot be self-intersecting')
841+
continue
842+
843+
elif num_zeros == 2:
844+
continue
845+
846+
elif num_zeros == 3:
847+
warnings.warn('Unclear if Polygon is self-intersecting')
848+
continue
849+
850+
else:
851+
# All 4 cross products are zero
852+
# Determine number of unique points, x span and y span for
853+
# both line segments
854+
xmin1, xmax1 = min(p0[0], p1[0]), max(p0[0], p1[0])
855+
ymin1, ymax1 = min(p0[1], p1[1]), max(p0[1], p1[1])
856+
xmin2, xmax2 = min(p2[0], p3[0]), max(p2[0], p3[0])
857+
ymin2, ymax2 = min(p2[1], p3[1]), max(p2[1], p3[1])
858+
xlap = xmin1 < xmax2 and xmin2 < xmax1
859+
ylap = ymin1 < ymax2 and ymin2 < ymax1
860+
if xlap or ylap:
861+
raise ValueError('Polygon cannot be self-intersecting')
862+
continue
783863

784-
return points[::-1, :]
864+
return points
865+
866+
def _constrain_triangulation(self, points, depth=0):
867+
"""Generate a constrained triangulation by ensuring all edges of the
868+
Polygon are contained within the simplices.
869+
870+
Parameters
871+
----------
872+
points : np.ndarray (Nx2)
873+
An Nx2 array of coordinate pairs describing the vertices. These
874+
points represent a planar straight line graph.
875+
876+
Returns
877+
-------
878+
None
879+
"""
880+
# Only attempt the triangulation up to 3 times.
881+
if depth > 2:
882+
raise RuntimeError('Could not create a valid triangulation after 3'
883+
' attempts')
884+
885+
tri = Delaunay(points, qhull_options='QJ')
886+
# Loop through the boundary edges of the polygon. If an edge is not
887+
# included in the triangulation, break it into two line segments.
888+
n = len(points)
889+
new_pts = []
890+
for i, j in zip(range(n), range(1, n +1)):
891+
# If both vertices of any edge are not found in any simplex, insert
892+
# a new point between them.
893+
if not any([i in s and j % n in s for s in tri.simplices]):
894+
newpt = (points[i, :] + points[j % n, :]) / 2
895+
new_pts.append((j, newpt))
896+
897+
# If all the edges are included in the triangulation set it, otherwise
898+
# try again with additional points inserted on offending edges.
899+
if not new_pts:
900+
self._tri = tri
901+
else:
902+
for i, pt in new_pts[::-1]:
903+
points = np.insert(points, i, pt, axis=0)
904+
self._constrain_triangulation(points, depth=depth + 1)
785905

786906
def _group_simplices(self, neighbor_map, group=None):
787907
"""Generate a convex grouping of simplices.
@@ -819,7 +939,7 @@ def _group_simplices(self, neighbor_map, group=None):
819939
continue
820940
test_group = group + [n]
821941
test_point_idx = np.unique(self._tri.simplices[test_group, :])
822-
test_points = self._tri.points[test_point_idx]
942+
test_points = self.points[test_point_idx]
823943
# If test_points are convex keep adding to this group
824944
if len(test_points) == len(ConvexHull(test_points).vertices):
825945
group = self._group_simplices(neighbor_map, group=test_group)
@@ -902,8 +1022,8 @@ def _decompose_polygon_into_convex_sets(self):
9021022

9031023
# Get centroids of all the simplices and determine if they are inside
9041024
# the polygon defined by input vertices or not.
905-
centroids = np.mean(self._points[self._tri.simplices], axis=1)
906-
in_polygon = Path(self._points).contains_points(centroids)
1025+
centroids = np.mean(self.points[self._tri.simplices], axis=1)
1026+
in_polygon = Path(self.points).contains_points(centroids)
9071027
self._in_polygon = in_polygon
9081028

9091029
# Build a map with keys of simplex indices inside the polygon whose
@@ -931,7 +1051,7 @@ def _decompose_polygon_into_convex_sets(self):
9311051
# generate the convex hull and find the resulting surfaces and
9321052
# unary operators that represent this convex subset of the polygon.
9331053
idx = np.unique(self._tri.simplices[group, :])
934-
qhull = ConvexHull(self._tri.points[idx, :])
1054+
qhull = ConvexHull(self.points[idx, :])
9351055
surf_ops = self._get_convex_hull_surfs(qhull)
9361056
surfsets.append(surf_ops)
9371057
return surfsets

tests/unit_tests/test_surface_composite.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,57 @@ def test_polygon():
339339
offset_star = star_poly.offset(.6)
340340
assert (0, 0, 0) in -offset_star
341341
assert any([(0, 0, 0) in reg for reg in offset_star.regions])
342+
343+
# check invalid Polygon input points
344+
# duplicate points not just at start and end
345+
rz_points = np.array([[6.88, 3.02],
346+
[6.88, 2.72],
347+
[6.88, 3.02],
348+
[7.63, 0.0],
349+
[5.75, 0.0],
350+
[5.75, 1.22],
351+
[7.63, 0.0],
352+
[6.30, 1.22],
353+
[6.30, 3.02],
354+
[6.88, 3.02]])
355+
with pytest.raises(ValueError):
356+
openmc.model.Polygon(rz_points)
357+
358+
# segment traces back on previous segment
359+
rz_points = np.array([[6.88, 3.02],
360+
[6.88, 2.72],
361+
[6.88, 2.32],
362+
[6.88, 2.52],
363+
[7.63, 0.0],
364+
[5.75, 0.0],
365+
[6.75, 0.0],
366+
[5.75, 1.22],
367+
[6.30, 1.22],
368+
[6.30, 3.02],
369+
[6.88, 3.02]])
370+
with pytest.raises(ValueError):
371+
openmc.model.Polygon(rz_points)
372+
373+
# segments intersect (line-line)
374+
rz_points = np.array([[6.88, 3.02],
375+
[5.88, 2.32],
376+
[7.63, 0.0],
377+
[5.75, 0.0],
378+
[5.75, 1.22],
379+
[6.30, 1.22],
380+
[6.30, 3.02],
381+
[6.88, 3.02]])
382+
with pytest.raises(ValueError):
383+
openmc.model.Polygon(rz_points)
384+
385+
# segments intersect (line-point)
386+
rz_points = np.array([[6.88, 3.02],
387+
[6.3, 2.32],
388+
[7.63, 0.0],
389+
[5.75, 0.0],
390+
[5.75, 1.22],
391+
[6.30, 1.22],
392+
[6.30, 3.02],
393+
[6.88, 3.02]])
394+
with pytest.raises(ValueError):
395+
openmc.model.Polygon(rz_points)

0 commit comments

Comments
 (0)