# Source: https://github.com/mapbox/earcut # License: ISC License (MIT compatible) # # Copyright (c) 2016, Mapbox # # Permission to use, copy, modify, and/or distribute this software for any purpose # with or without fee is hereby granted, provided that the above copyright notice # and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND # FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS # OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER # TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF # THIS SOFTWARE. # # The Algorithm # ------------- # The library implements a modified ear slicing algorithm, optimized by z-order # curve hashing and extended to handle holes, twisted polygons, degeneracies and # self-intersections in a way that doesn't guarantee correctness of triangulation, # but attempts to always produce acceptable results for practical data. # # Translation to Python: # Copyright (c) 2022, Manfred Moitzi # License: MIT License # # Notes # ----- # Exterior path (outer path) vertices are stored in counter-clockwise order # Hole vertices are stored in clockwise order # Vertex order will be maintained by the algorithm automatically. # Boundary behavior for holes: # - holes outside the exterior path are ignored # - invalid result for holes partially extending beyond the exterior path # - invalid result for overlapping holes # - invalid result for holes in holes # Very stable in all circumstances - DOES NOT CRASH! # # Steiner Point # ------------- # https://en.wikipedia.org/wiki/Steiner_point_(computational_geometry) # A Steiner point is a point that is not part of the input to a geometric # optimization problem but is added during the solution of the problem, to # create a better solution than would be possible from the original points # alone. # A Steiner point is defined as a hole with a single point! # from __future__ import annotations from typing import Sequence, Optional, Protocol, TypeVar import math class Point(Protocol): x: float y: float class Node: def __init__(self, i: int, point: Point) -> None: self.i: int = i # store source point for output self.point = point # vertex coordinates self.x: float = point.x self.y: float = point.y # previous and next vertex nodes in a polygon ring self.prev: Node = None # type: ignore self.next: Node = None # type: ignore # z-order curve value self.z: int = 0 # previous and next nodes in z-order self.prev_z: Node = None # type: ignore self.next_z: Node = None # type: ignore # indicates whether this is a steiner point self.steiner: bool = False def __eq__(self, other): return self.x == other.x and self.y == other.y T = TypeVar("T", bound=Point) def earcut(exterior: list[T], holes: list[list[T]]) -> list[Sequence[T]]: """Implements a modified ear slicing algorithm, optimized by z-order curve hashing and extended to handle holes, twisted polygons, degeneracies and self-intersections in a way that doesn't guarantee correctness of triangulation, but attempts to always produce acceptable results for practical data. Source: https://github.com/mapbox/earcut Args: exterior: outer path as list of points as objects which provide a `x`- and a `y`-attribute holes: list of holes, each hole is list of points, a hole with a single points is a Steiner point Returns: Returns a list of triangles, each triangle is a tuple of three points, the output points are the same objects as the input points. """ # exterior points in counter-clockwise order outer_node: Node = linked_list(exterior, 0, ccw=True) triangles: list[Sequence[T]] = [] if outer_node is None or outer_node.next is outer_node.prev: return triangles if len(holes) > 0: outer_node = eliminate_holes(holes, len(exterior), outer_node) min_x: float = 0.0 min_y: float = 0.0 inv_size: float = 0.0 # if the shape is not too simple, we'll use z-order curve hash later # calculate polygon bbox if len(exterior) > 80: min_x = max_x = exterior[0].x min_y = max_y = exterior[0].y for point in exterior: x = point.x y = point.y min_x = min(min_x, x) min_y = min(min_y, y) max_x = max(max_x, x) max_y = max(max_y, y) # min_x, min_y and inv_size are later used to transform coords into # integers for z-order calculation inv_size = max(max_x - min_x, max_y - min_y) inv_size = 32767 / inv_size if inv_size != 0 else 0 earcut_linked(outer_node, triangles, min_x, min_y, inv_size, 0) # type: ignore return triangles def linked_list(points: Sequence[Point], start: int, ccw: bool) -> Node: """Create a circular doubly linked list from polygon points in the specified winding order """ last: Node = None # type: ignore if ccw is (signed_area(points) < 0): for point in points: last = insert_node(start, point, last) start += 1 else: end = start + len(points) for point in reversed(points): last = insert_node(end, point, last) end -= 1 # open polygon: where the 1st vertex is not coincident with the last vertex if last and last == last.next: # true equals remove_node(last) last = last.next return last def signed_area(points: Sequence[Point]) -> float: s: float = 0.0 if not len(points): return s prev = points[-1] for point in points: s += (point.x - prev.x) * (point.y + prev.y) prev = point # s < 0 is counter-clockwise # s > 0 is clockwise return s def area(p: Node, q: Node, r: Node) -> float: """Returns signed area of a triangle""" return (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y) def is_valid_diagonal(a: Node, b: Node): """Check if a diagonal between two polygon nodes is valid (lies in polygon interior) """ return ( a.next.i != b.i and a.prev.i != b.i and not intersects_polygon(a, b) # doesn't intersect other edges and ( locally_inside(a, b) and locally_inside(b, a) and middle_inside(a, b) and ( area(a.prev, a, b.prev) or area(a, b.prev, b) ) # does not create opposite-facing sectors or a == b # true equals and area(a.prev, a, a.next) > 0 and area(b.prev, b, b.next) > 0 ) # special zero-length case ) def intersects_polygon(a: Node, b: Node) -> bool: """Check if a polygon diagonal intersects any polygon segments""" p = a while True: if ( p.i != a.i and p.next.i != a.i and p.i != b.i and p.next.i != b.i and intersects(p, p.next, a, b) ): return True p = p.next if p is a: break return False def sign(num: float) -> int: if num < 0.0: return -1 if num > 0.0: return 1 return 0 def on_segment(p: Node, q: Node, r: Node) -> bool: return max(p.x, r.x) >= q.x >= min(p.x, r.x) and max(p.y, r.y) >= q.y >= min( p.y, r.y ) def intersects(p1: Node, q1: Node, p2: Node, q2: Node) -> bool: """check if two segments intersect""" o1 = sign(area(p1, q1, p2)) o2 = sign(area(p1, q1, q2)) o3 = sign(area(p2, q2, p1)) o4 = sign(area(p2, q2, q1)) if o1 != o2 and o3 != o4: return True # general case if o1 == 0 and on_segment(p1, p2, q1): return True # p1, q1 and p2 are collinear and p2 lies on p1q1 if o2 == 0 and on_segment(p1, q2, q1): return True # p1, q1 and q2 are collinear and q2 lies on p1q1 if o3 == 0 and on_segment(p2, p1, q2): return True # p2, q2 and p1 are collinear and p1 lies on p2q2 if o4 == 0 and on_segment(p2, q1, q2): return True # p2, q2 and q1 are collinear and q1 lies on p2q2 return False def insert_node(i: int, point: Point, last: Node) -> Node: """create a node and optionally link it with previous one (in a circular doubly linked list) """ p = Node(i, point) if last is None: p.prev = p p.next = p else: p.next = last.next p.prev = last last.next.prev = p last.next = p return p def remove_node(p: Node) -> None: p.next.prev = p.prev p.prev.next = p.next if p.prev_z: p.prev_z.next_z = p.next_z if p.next_z: p.next_z.prev_z = p.prev_z def eliminate_holes( holes: Sequence[Sequence[Point]], start: int, outer_node: Node ) -> Node: """link every hole into the outer loop, producing a single-ring polygon without holes """ queue: list[Node] = [] for hole in holes: if len(hole) < 1: # skip empty holes continue # hole vertices in clockwise order _list = linked_list(hole, start, ccw=False) if _list is _list.next: _list.steiner = True start += len(hole) queue.append(get_leftmost(_list)) queue.sort(key=lambda node: (node.x, node.y)) # process holes from left to right for hole_ in queue: outer_node = eliminate_hole(hole_, outer_node) return outer_node def eliminate_hole(hole: Node, outer_node: Node) -> Node: """Find a bridge between vertices that connects hole with an outer ring and link it """ bridge = find_hole_bridge(hole, outer_node) if bridge is None: return outer_node bridge_reverse = split_polygon(bridge, hole) # filter collinear points around the cuts filter_points(bridge_reverse, bridge_reverse.next) return filter_points(bridge, bridge.next) def filter_points(start: Node, end: Optional[Node] = None) -> Node: """eliminate colinear or duplicate points""" if start is None: return start if end is None: end = start p = start while True: again = False if not p.steiner and ( p == p.next or area(p.prev, p, p.next) == 0 # true equals ): remove_node(p) p = end = p.prev if p is p.next: break again = True else: p = p.next if not (again or p is not end): break return end # main ear slicing loop which triangulates a polygon (given as a linked list) def earcut_linked( ear: Node, triangles: list[Sequence[Point]], min_x: float, min_y: float, inv_size: float, pass_: int, ) -> None: if ear is None: return # interlink polygon nodes in z-order if not pass_ and inv_size: index_curve(ear, min_x, min_y, inv_size) stop = ear # iterate through ears, slicing them one by one while ear.prev is not ear.next: prev = ear.prev next = ear.next _is_ear = ( is_ear_hashed(ear, min_x, min_y, inv_size) if inv_size else is_ear(ear) ) if _is_ear: # cut off the triangle triangles.append((prev.point, ear.point, next.point)) remove_node(ear) # skipping the next vertex leads to less sliver triangles ear = next.next stop = next.next continue ear = next # if we looped through the whole remaining polygon and can't find any more ears if ear is stop: # try filtering points and slicing again if not pass_: earcut_linked( filter_points(ear), triangles, min_x, min_y, inv_size, 1, ) # if this didn't work, try curing all small self-intersections locally elif pass_ == 1: ear = cure_local_intersections(filter_points(ear), triangles) earcut_linked(ear, triangles, min_x, min_y, inv_size, 2) # as a last resort, try splitting the remaining polygon into two elif pass_ == 2: split_ear_cut(ear, triangles, min_x, min_y, inv_size) break def is_ear(ear: Node) -> bool: """check whether a polygon node forms a valid ear with adjacent nodes""" a: Node = ear.prev b: Node = ear c: Node = ear.next if area(a, b, c) >= 0: return False # reflex, can't be an ear # now make sure we don't have other points inside the potential ear ax = a.x bx = b.x cx = c.x ay = a.y by = b.y cy = c.y # triangle bbox; min & max are calculated like this for speed x0 = min(ax, bx, cx) x1 = max(ax, bx, cx) y0 = min(ay, by, cy) y1 = max(ay, by, cy) p: Node = c.next while p is not a: if ( x0 <= p.x <= x1 and y0 <= p.y <= y1 and point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y) and area(p.prev, p, p.next) >= 0 ): return False p = p.next return True def is_ear_hashed(ear: Node, min_x: float, min_y: float, inv_size: float): a: Node = ear.prev b: Node = ear c: Node = ear.next if area(a, b, c) >= 0: return False # reflex, can't be an ear ax = a.x bx = b.x cx = c.x ay = a.y by = b.y cy = c.y # triangle bbox; min & max are calculated like this for speed x0 = min(ax, bx, cx) x1 = max(ax, bx, cx) y0 = min(ay, by, cy) y1 = max(ay, by, cy) # z-order range for the current triangle bbox; min_z = z_order(x0, y0, min_x, min_y, inv_size) max_z = z_order(x1, y1, min_x, min_y, inv_size) p: Node = ear.prev_z n: Node = ear.next_z # look for points inside the triangle in both directions while p and p.z >= min_z and n and n.z <= max_z: if ( x0 <= p.x <= x1 and y0 <= p.y <= y1 and p is not a and p is not c and point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y) and area(p.prev, p, p.next) >= 0 ): return False p = p.prev_z if ( x0 <= n.x <= x1 and y0 <= n.y <= y1 and n is not a and n is not c and point_in_triangle(ax, ay, bx, by, cx, cy, n.x, n.y) and area(n.prev, n, n.next) >= 0 ): return False n = n.next_z # look for remaining points in decreasing z-order while p and p.z >= min_z: if ( x0 <= p.x <= x1 and y0 <= p.y <= y1 and p is not a and p is not c and point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y) and area(p.prev, p, p.next) >= 0 ): return False p = p.prev_z # look for remaining points in increasing z-order while n and n.z <= max_z: if ( x0 <= n.x <= x1 and y0 <= n.y <= y1 and n is not a and n is not c and point_in_triangle(ax, ay, bx, by, cx, cy, n.x, n.y) and area(n.prev, n, n.next) >= 0 ): return False n = n.next_z return True def get_leftmost(start: Node) -> Node: """Find the leftmost node of a polygon ring""" p = start leftmost = start while True: if p.x < leftmost.x or (p.x == leftmost.x and p.y < leftmost.y): leftmost = p p = p.next if p is start: break return leftmost def point_in_triangle( ax: float, ay: float, bx: float, by: float, cx: float, cy: float, px: float, py: float, ) -> bool: """Check if a point lies within a convex triangle""" return ( (cx - px) * (ay - py) >= (ax - px) * (cy - py) and (ax - px) * (by - py) >= (bx - px) * (ay - py) and (bx - px) * (cy - py) >= (cx - px) * (by - py) ) def sector_contains_sector(m: Node, p: Node): """Whether sector in vertex m contains sector in vertex p in the same coordinates. """ return area(m.prev, m, p.prev) < 0 and area(p.next, m, m.next) < 0 def index_curve(start: Node, min_x: float, min_y: float, inv_size: float): """Interlink polygon nodes in z-order""" p = start while True: if p.z == 0: p.z = z_order(p.x, p.y, min_x, min_y, inv_size) p.prev_z = p.prev p.next_z = p.next p = p.next if p is start: break p.prev_z.next_z = None # type: ignore p.prev_z = None # type: ignore sort_linked(p) def z_order(x0: float, y0: float, min_x: float, min_y: float, inv_size: float) -> int: """Z-order of a point given coords and inverse of the longer side of data bbox. """ # coords are transformed into non-negative 15-bit integer range x = int((x0 - min_x) * inv_size) y = int((y0 - min_y) * inv_size) x = (x | (x << 8)) & 0x00FF00FF x = (x | (x << 4)) & 0x0F0F0F0F x = (x | (x << 2)) & 0x33333333 x = (x | (x << 1)) & 0x55555555 y = (y | (y << 8)) & 0x00FF00FF y = (y | (y << 4)) & 0x0F0F0F0F y = (y | (y << 2)) & 0x33333333 y = (y | (y << 1)) & 0x55555555 return x | (y << 1) # Simon Tatham's linked list merge sort algorithm # http://www.chiark.greenend.org.uk/~sgtatham/algorithms/listsort.html def sort_linked(head: Node) -> Node: in_size = 1 tail: Node while True: p = head head = None # type: ignore tail = None # type: ignore num_merges = 0 while p: num_merges += 1 q = p p_size = 0 for i in range(in_size): p_size += 1 q = q.next_z if not q: break q_size = in_size while p_size > 0 or (q_size > 0 and q): if p_size != 0 and (q_size == 0 or not q or p.z <= q.z): e = p p = p.next_z p_size -= 1 else: e = q q = q.next_z q_size -= 1 if tail: tail.next_z = e else: head = e e.prev_z = tail tail = e p = q tail.next_z = None # type: ignore in_size *= 2 if num_merges <= 1: break return head def split_polygon(a: Node, b: Node) -> Node: """Link two polygon vertices with a bridge. If the vertices belong to the same ring, it splits polygon into two. If one belongs to the outer ring and another to a hole, it merges it into a single ring. """ a2 = Node(a.i, a.point) b2 = Node(b.i, b.point) an = a.next bp = b.prev a.next = b b.prev = a a2.next = an an.prev = a2 b2.next = a2 a2.prev = b2 bp.next = b2 b2.prev = bp return b2 # go through all polygon nodes and cure small local self-intersections def cure_local_intersections(start: Node, triangles: list[Sequence[Point]]) -> Node: p = start while True: a = p.prev b = p.next.next if ( not a == b # true equals and intersects(a, p, p.next, b) and locally_inside(a, b) and locally_inside(b, a) ): triangles.append((a.point, p.point, b.point)) # remove two nodes involved remove_node(p) remove_node(p.next) p = start = b p = p.next if p is start: break return filter_points(p) def split_ear_cut( start: Node, triangles: list[Sequence[Point]], min_x: float, min_y: float, inv_size: float, ) -> None: """Try splitting polygon into two and triangulate them independently""" # look for a valid diagonal that divides the polygon into two a = start while True: b = a.next.next while b is not a.prev: if a.i != b.i and is_valid_diagonal(a, b): # split the polygon in two by the diagonal c = split_polygon(a, b) # filter colinear points around the cuts a = filter_points(a, a.next) c = filter_points(c, c.next) # run earcut on each half earcut_linked(a, triangles, min_x, min_y, inv_size, 0) earcut_linked(c, triangles, min_x, min_y, inv_size, 0) return b = b.next a = a.next if a is start: break # David Eberly's algorithm for finding a bridge between hole and outer polygon def find_hole_bridge(hole: Node, outer_node: Node) -> Node: p = outer_node hx = hole.x hy = hole.y qx = -math.inf m: Node = None # type: ignore # find a segment intersected by a ray from the hole's leftmost point to the left; # segment's endpoint with lesser x will be potential connection point while True: if p.y >= hy >= p.next.y != p.y: x = p.x + (hy - p.y) * (p.next.x - p.x) / (p.next.y - p.y) if hx >= x > qx: qx = x m = p if p.x < p.next.x else p.next if x == hx: # ??? use math.isclose # hole touches outer segment; pick leftmost endpoint return m p = p.next if p is outer_node: break if m is None: return None # look for points inside the triangle of hole point, segment intersection and endpoint; # if there are no points found, we have a valid connection; # otherwise choose the point of the minimum angle with the ray as connection point stop = m mx = m.x my = m.y tan_min = math.inf p = m while True: if ( hx >= p.x >= mx and hx != p.x and point_in_triangle( hx if hy < my else qx, hy, mx, my, qx if hy < my else hx, hy, p.x, p.y, ) ): tan = abs(hy - p.y) / (hx - p.x) # tangential if locally_inside(p, hole) and ( tan < tan_min or ( tan == tan_min and (p.x > m.x or (p.x == m.x and sector_contains_sector(m, p))) ) ): m = p tan_min = tan p = p.next if p is stop: break return m def locally_inside(a: Node, b: Node) -> bool: """Check if a polygon diagonal is locally inside the polygon""" return ( area(a, b, a.next) >= 0 and area(a, a.prev, b) >= 0 if area(a.prev, a, a.next) < 0 else area(a, b, a.prev) < 0 or area(a, a.next, b) < 0 ) def middle_inside(a: Node, b: Node) -> bool: """Check if the middle point of a polygon diagonal is inside the polygon""" p = a inside = False px = (a.x + b.x) / 2 py = (a.y + b.y) / 2 while True: if ( ((p.y > py) != (p.next.y > py)) and p.next.y != p.y and (px < (p.next.x - p.x) * (py - p.y) / (p.next.y - p.y) + p.x) ): inside = not inside p = p.next if p is a: break return inside