[remove_colinear_vertices / Path] add preserve_uturns and use it for paths

This commit is contained in:
jan 2026-03-09 03:28:31 -07:00
commit ea93a7ef37
3 changed files with 56 additions and 19 deletions

View file

@ -323,9 +323,30 @@ class Path(Shape):
) -> list['Polygon']: ) -> list['Polygon']:
extensions = self._calculate_cap_extensions() 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) 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: if self.width == 0:
verts = numpy.vstack((v, v[::-1])) verts = numpy.vstack((v, v[::-1]))
@ -448,16 +469,11 @@ class Path(Shape):
rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v) rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v)
for v in normed_vertices]) for v in normed_vertices])
# Reorder the vertices so that the one with lowest x, then y, comes first. # Canonical ordering for open paths: pick whichever of (v) or (v[::-1]) is smaller
x_min_val = rotated_vertices[:, 0].min() if tuple(rotated_vertices.flat) > tuple(rotated_vertices[::-1].flat):
x_min_inds = numpy.where(rotated_vertices[:, 0] == x_min_val)[0] reordered_vertices = rotated_vertices[::-1]
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]
else: else:
start_ind = x_min_inds[0] reordered_vertices = rotated_vertices
reordered_vertices = numpy.roll(rotated_vertices, -start_ind, axis=0)
width0 = self.width / norm_value width0 = self.width / norm_value
@ -496,7 +512,7 @@ class Path(Shape):
Returns: Returns:
self 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 return self
def _calculate_cap_extensions(self) -> NDArray[numpy.float64]: def _calculate_cap_extensions(self) -> NDArray[numpy.float64]:

View file

@ -29,14 +29,19 @@ def test_remove_colinear_vertices() -> None:
def test_remove_colinear_vertices_exhaustive() -> None: def test_remove_colinear_vertices_exhaustive() -> None:
# U-turn # U-turn
v = [[0, 0], [10, 0], [0, 0]] 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]? # Open path should keep ends. [10,0] is between [0,0] and [0,0]?
# Yes, they are all on the same line. # They are colinear, but it's a 180 degree turn.
assert len(v_clean) == 2 # 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 # 180 degree U-turn in closed path
v = [[0, 0], [10, 0], [5, 0]] 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 assert len(v_clean) == 2

View file

@ -30,7 +30,11 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) ->
return result 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. Given a list of vertices, remove any superflous vertices (i.e.
those which lie along the line formed by their neighbors) 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 vertices: Nx2 ndarray of vertices
closed_path: If `True`, the vertices are assumed to represent an implicitly 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`. 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: Returns:
`vertices` with colinear (superflous) vertices removed. May be a view into the original array. `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) vertices = remove_duplicate_vertices(vertices, closed_path=closed_path)
# Check for dx0/dy0 == dx1/dy1 # 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[i] is based on dv[i] and dv[i-1]
dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dx0]] # 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] dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0]
err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40 err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40
slopes_equal = (dxdy_diff / err_mult) < 1e-15 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: if not closed_path:
slopes_equal[[0, -1]] = False slopes_equal[[0, -1]] = False