diff --git a/masque/shapes/path.py b/masque/shapes/path.py index b4ac650..542cf11 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -323,9 +323,30 @@ class Path(Shape): ) -> list['Polygon']: extensions = self._calculate_cap_extensions() - v = remove_colinear_vertices(self.vertices, closed_path=False) + v = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) dv = numpy.diff(v, axis=0) - dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None] + norms = numpy.sqrt((dv * dv).sum(axis=1)) + + # Filter out zero-length segments if any remained after remove_colinear_vertices + valid = (norms > 1e-18) + if not numpy.all(valid): + # This shouldn't happen much if remove_colinear_vertices is working + v = v[numpy.append(valid, True)] + dv = numpy.diff(v, axis=0) + norms = norms[valid] + + if dv.shape[0] == 0: + # All vertices were the same. It's a point. + if self.width == 0: + return [Polygon(vertices=numpy.zeros((3, 2)))] # Area-less degenerate + if self.cap == PathCap.Circle: + return Circle(radius=self.width / 2, offset=v[0]).to_polygons(num_vertices=num_vertices, max_arclen=max_arclen) + if self.cap == PathCap.Square: + return [Polygon.square(side_length=self.width, offset=v[0])] + # Flush or CustomSquare + return [Polygon(vertices=numpy.zeros((3, 2)))] + + dvdir = dv / norms[:, None] if self.width == 0: verts = numpy.vstack((v, v[::-1])) @@ -448,16 +469,11 @@ class Path(Shape): rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v) for v in normed_vertices]) - # Reorder the vertices so that the one with lowest x, then y, comes first. - x_min_val = rotated_vertices[:, 0].min() - x_min_inds = numpy.where(rotated_vertices[:, 0] == x_min_val)[0] - if x_min_inds.size > 1: - y_min_val = rotated_vertices[x_min_inds, 1].min() - tie_breaker = numpy.where(rotated_vertices[x_min_inds, 1] == y_min_val)[0][0] - start_ind = x_min_inds[tie_breaker] + # Canonical ordering for open paths: pick whichever of (v) or (v[::-1]) is smaller + if tuple(rotated_vertices.flat) > tuple(rotated_vertices[::-1].flat): + reordered_vertices = rotated_vertices[::-1] else: - start_ind = x_min_inds[0] - reordered_vertices = numpy.roll(rotated_vertices, -start_ind, axis=0) + reordered_vertices = rotated_vertices width0 = self.width / norm_value @@ -496,7 +512,7 @@ class Path(Shape): Returns: self """ - self.vertices = remove_colinear_vertices(self.vertices, closed_path=False) + self.vertices = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) return self def _calculate_cap_extensions(self) -> NDArray[numpy.float64]: diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py index ffa3c63..f495285 100644 --- a/masque/test/test_utils.py +++ b/masque/test/test_utils.py @@ -29,14 +29,19 @@ def test_remove_colinear_vertices() -> None: def test_remove_colinear_vertices_exhaustive() -> None: # U-turn v = [[0, 0], [10, 0], [0, 0]] - v_clean = remove_colinear_vertices(v, closed_path=False) + v_clean = remove_colinear_vertices(v, closed_path=False, preserve_uturns=True) # Open path should keep ends. [10,0] is between [0,0] and [0,0]? - # Yes, they are all on the same line. - assert len(v_clean) == 2 + # They are colinear, but it's a 180 degree turn. + # We preserve 180 degree turns if preserve_uturns is True. + assert len(v_clean) == 3 + + v_collapsed = remove_colinear_vertices(v, closed_path=False, preserve_uturns=False) + # If not preserving u-turns, it should collapse to just the endpoints + assert len(v_collapsed) == 2 # 180 degree U-turn in closed path v = [[0, 0], [10, 0], [5, 0]] - v_clean = remove_colinear_vertices(v, closed_path=True) + v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False) assert len(v_clean) == 2 diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index f830696..5a5df9f 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -30,7 +30,11 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> return result -def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: +def remove_colinear_vertices( + vertices: ArrayLike, + closed_path: bool = True, + preserve_uturns: bool = False, + ) -> NDArray[numpy.float64]: """ Given a list of vertices, remove any superflous vertices (i.e. those which lie along the line formed by their neighbors) @@ -39,6 +43,8 @@ def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> N vertices: Nx2 ndarray of vertices closed_path: If `True`, the vertices are assumed to represent an implicitly closed path. If `False`, the path is assumed to be open. Default `True`. + preserve_uturns: If `True`, colinear vertices that correspond to a 180 degree + turn (a "spike") are preserved. Default `False`. Returns: `vertices` with colinear (superflous) vertices removed. May be a view into the original array. @@ -46,14 +52,24 @@ def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> N vertices = remove_duplicate_vertices(vertices, closed_path=closed_path) # Check for dx0/dy0 == dx1/dy1 + dv = numpy.roll(vertices, -1, axis=0) - vertices + if not closed_path: + dv[-1] = 0 - dv = numpy.roll(vertices, -1, axis=0) - vertices # [y1-y0, y2-y1, ...] - dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dx0]] + # dxdy[i] is based on dv[i] and dv[i-1] + # slopes_equal[i] refers to vertex i + dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0] err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40 slopes_equal = (dxdy_diff / err_mult) < 1e-15 + + if preserve_uturns: + # Only merge if segments are in the same direction (avoid collapsing u-turns) + dot_prod = (dv * numpy.roll(dv, 1, axis=0)).sum(axis=1) + slopes_equal &= (dot_prod > 0) + if not closed_path: slopes_equal[[0, -1]] = False