Repository: openmc-dev/openmc

Issue:
Polygon can't handle colinear points.

## Bug Description
When multiple points for `openmc.model.Polygon` are colinear the polygon decomposes in part into a line and breaks the underlying geometry engine. The question though is why would you ever specify colinear points? I was working on hackily creating polygons from an SVG file. In this case SVG interpreters don't care about colinearity, and this can happen. 

I was attempting to create this: 
![letter_h](https://github.com/openmc-dev/openmc/assets/12405772/d5168d37-febb-4a63-943e-8f646a0938da)

The problematic polygon looks like:
![letter_h2](https://github.com/openmc-dev/openmc/assets/12405772/83944b00-8e2c-47af-9bfe-879d13864b68)



## Steps to Reproduce
MWE:

``` python
import openmc
openmc.model.Polygon([
(43.08936, -13.206429),
(43.08936, -22.855452),
(44.236576, -22.855452),
(44.236576, -18.930111),
(44.236576, -16.989144),
(44.236576, -13.206429),
(43.08936, -13.206429),
])

```

This leads to: 

``` python
QhullError                                Traceback (most recent call last)
Cell In[1], line 2
      1 import openmc
----> 2 openmc.model.Polygon([
      3 (43.08936, -13.206429),
      4 (43.08936, -22.855452),
      5 (44.236576, -22.855452),
      6 (44.236576, -18.930111),
      7 (44.236576, -16.989144),
      8 (44.236576, -13.206429),
      9 (43.08936, -13.206429),
     10 ])

File ~/mambaforge/lib/python3.10/site-packages/openmc/model/surface_composite.py:818, in Polygon.__init__(self, points, basis)
    814 self._constrain_triangulation(self._validate_points(points))
    816 # Decompose the polygon into groups of simplices forming convex subsets
    817 # and get the sets of (surface, operator) pairs defining the polygon
--> 818 self._surfsets = self._decompose_polygon_into_convex_sets()
    820 # Set surface names as required by CompositeSurface protocol
    821 surfnames = []

File ~/mambaforge/lib/python3.10/site-packages/openmc/model/surface_composite.py:1190, in Polygon._decompose_polygon_into_convex_sets(self)
   1188 groups = []
   1189 while neighbor_map:
-> 1190     groups.append(self._group_simplices(neighbor_map))
   1191 self._groups = groups
   1193 # Generate lists of (surface, operator) pairs for each convex
   1194 # sub-region.

File ~/mambaforge/lib/python3.10/site-packages/openmc/model/surface_composite.py:1075, in Polygon._group_simplices(self, neighbor_map, group)
   1073 if group is None:
   1074     sidx = next(iter(neighbor_map))
-> 1075     return self._group_simplices(neighbor_map, group=[sidx])
   1076 # Otherwise use the last simplex in the group
   1077 else:
   1078     sidx = group[-1]

File ~/mambaforge/lib/python3.10/site-packages/openmc/model/surface_composite.py:1090, in Polygon._group_simplices(self, neighbor_map, group)
   1088     test_points = self.points[test_point_idx]
   1089     # If test_points are convex keep adding to this group
-> 1090     if len(test_points) == len(ConvexHull(test_points).vertices):
   1091         group = self._group_simplices(neighbor_map, group=test_group)
   1092 return group

File _qhull.pyx:2431, in scipy.spatial._qhull.ConvexHull.__init__()

File _qhull.pyx:353, in scipy.spatial._qhull._Qhull.__init__()

QhullError: QH6013 qhull input error: input is less than 2-dimensional since all points have the same x coordinate 44.24

While executing:  | qhull i Qt
Options selected for Qhull 2019.1.r 2019/06/21:
  run-id 179452238  incidence  Qtriangulate  _pre-merge  _zero-centrum
  _max-width 9.6  Error-roundoff 3.8e-14  _one-merge 1.9e-13
  _near-inside 9.5e-13  Visible-distance 7.6e-14  U-max-coplanar 7.6e-14
  Width-outside 1.5e-13  _wide-facet 4.5e-13  _maxoutside 2.3e-13
```




## Environment
Openmc 0.14. See #2930 for details. 



Discussion:

Resolving patch:
diff --git a/openmc/model/surface_composite.py b/openmc/model/surface_composite.py
--- a/openmc/model/surface_composite.py
+++ b/openmc/model/surface_composite.py
@@ -635,7 +635,7 @@ class XConeOneSided(CompositeSurface):
         z-coordinate of the apex. Defaults to 0.
     r2 : float, optional
         Parameter related to the aperture [:math:`\\rm cm^2`].
-        It can be interpreted as the increase in the radius squared per cm along 
+        It can be interpreted as the increase in the radius squared per cm along
         the cone's axis of revolution.
     up : bool
         Whether to select the side of the cone that extends to infinity in the
@@ -695,7 +695,7 @@ class YConeOneSided(CompositeSurface):
         z-coordinate of the apex. Defaults to 0.
     r2 : float, optional
         Parameter related to the aperture [:math:`\\rm cm^2`].
-        It can be interpreted as the increase in the radius squared per cm along 
+        It can be interpreted as the increase in the radius squared per cm along
         the cone's axis of revolution.
     up : bool
         Whether to select the side of the cone that extends to infinity in the
@@ -749,7 +749,7 @@ class ZConeOneSided(CompositeSurface):
         z-coordinate of the apex. Defaults to 0.
     r2 : float, optional
         Parameter related to the aperture [:math:`\\rm cm^2`].
-        It can be interpreted as the increase in the radius squared per cm along 
+        It can be interpreted as the increase in the radius squared per cm along
         the cone's axis of revolution.
     up : bool
         Whether to select the side of the cone that extends to infinity in the
@@ -1029,9 +1029,9 @@ def _constrain_triangulation(self, points, depth=0):
         -------
         None
         """
-        # Only attempt the triangulation up to 3 times.
-        if depth > 2:
-            raise RuntimeError('Could not create a valid triangulation after 3'
+        # Only attempt the triangulation up to 5 times.
+        if depth > 4:
+            raise RuntimeError('Could not create a valid triangulation after 5'
                                ' attempts')
 
         tri = Delaunay(points, qhull_options='QJ')
@@ -1039,7 +1039,7 @@ def _constrain_triangulation(self, points, depth=0):
         # included in the triangulation, break it into two line segments.
         n = len(points)
         new_pts = []
-        for i, j in zip(range(n), range(1, n +1)):
+        for i, j in zip(range(n), range(1, n + 1)):
             # If both vertices of any edge are not found in any simplex, insert
             # a new point between them.
             if not any([i in s and j % n in s for s in tri.simplices]):
@@ -1077,7 +1077,8 @@ def _group_simplices(self, neighbor_map, group=None):
             return group
         # If group is empty, grab the next simplex in the dictionary and recurse
         if group is None:
-            sidx = next(iter(neighbor_map))
+            # Start with smallest neighbor lists
+            sidx = sorted(neighbor_map.items(), key=lambda item: len(item[1]))[0][0]
             return self._group_simplices(neighbor_map, group=[sidx])
         # Otherwise use the last simplex in the group
         else:
@@ -1087,13 +1088,16 @@ def _group_simplices(self, neighbor_map, group=None):
             # For each neighbor check if it is part of the same convex
             # hull as the rest of the group. If yes, recurse. If no, continue on.
             for n in neighbors:
-                if n in group or neighbor_map.get(n, None) is None:
+                if n in group or neighbor_map.get(n) is None:
                     continue
                 test_group = group + [n]
                 test_point_idx = np.unique(self._tri.simplices[test_group, :])
                 test_points = self.points[test_point_idx]
-                # If test_points are convex keep adding to this group
-                if len(test_points) == len(ConvexHull(test_points).vertices):
+                test_hull = ConvexHull(test_points, qhull_options='Qc')
+                pts_on_hull = len(test_hull.vertices) + len(test_hull.coplanar)
+                # If test_points are convex (including coplanar) keep adding to
+                # this group
+                if len(test_points) == pts_on_hull:
                     group = self._group_simplices(neighbor_map, group=test_group)
             return group
 


Test patch:
diff --git a/tests/unit_tests/test_surface_composite.py b/tests/unit_tests/test_surface_composite.py
--- a/tests/unit_tests/test_surface_composite.py
+++ b/tests/unit_tests/test_surface_composite.py
@@ -399,6 +399,32 @@ def test_polygon():
     with pytest.raises(ValueError):
         openmc.model.Polygon(rz_points)
 
+    # Test "M" shaped polygon 
+    points = np.array([[8.5151581, -17.988337],
+                       [10.381711000000001, -17.988337],
+                       [12.744357, -24.288728000000003],
+                       [15.119406000000001, -17.988337],
+                       [16.985959, -17.988337],
+                       [16.985959, -27.246687],
+                       [15.764328, -27.246687],
+                       [15.764328, -19.116951],
+                       [13.376877, -25.466951],
+                       [12.118039, -25.466951],
+                       [9.7305877, -19.116951],
+                       [9.7305877, -27.246687],
+                       [8.5151581, -27.246687]])
+
+    # Test points inside and outside by using offset method
+    m_polygon = openmc.model.Polygon(points, basis='xz')
+    inner_pts = m_polygon.offset(-0.1).points
+    assert all([(pt[0], 0, pt[1]) in -m_polygon for pt in inner_pts])
+    outer_pts = m_polygon.offset(0.1).points
+    assert all([(pt[0], 0, pt[1]) in +m_polygon for pt in outer_pts])
+
+    # Offset of -0.2 will cause self-intersection
+    with pytest.raises(ValueError):
+        m_polygon.offset(-0.2)
+
 
 @pytest.mark.parametrize("axis", ["x", "y", "z"])
 def test_cruciform_prism(axis):