refactor: excel parse
This commit is contained in:
@@ -0,0 +1,938 @@
|
||||
# Copyright (c) 2021-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence, Optional, Iterator, Callable
|
||||
from typing_extensions import Protocol
|
||||
import math
|
||||
import enum
|
||||
|
||||
from ezdxf.math import (
|
||||
Vec2,
|
||||
UVec,
|
||||
intersection_line_line_2d,
|
||||
is_point_in_polygon_2d,
|
||||
has_clockwise_orientation,
|
||||
point_to_line_relation,
|
||||
TOLERANCE,
|
||||
BoundingBox2d,
|
||||
)
|
||||
from ezdxf.tools import take2, pairwise
|
||||
|
||||
|
||||
__all__ = [
|
||||
"greiner_hormann_union",
|
||||
"greiner_hormann_difference",
|
||||
"greiner_hormann_intersection",
|
||||
"Clipping",
|
||||
"ConvexClippingPolygon2d",
|
||||
"ConcaveClippingPolygon2d",
|
||||
"ClippingRect2d",
|
||||
"InvertedClippingPolygon2d",
|
||||
"CohenSutherlandLineClipping2d",
|
||||
]
|
||||
|
||||
|
||||
class Clipping(Protocol):
|
||||
def clip_polygon(self, polygon: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polygon."""
|
||||
|
||||
def clip_polyline(self, polyline: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polyline."""
|
||||
|
||||
def clip_line(self, start: Vec2, end: Vec2) -> Sequence[tuple[Vec2, Vec2]]:
|
||||
"""Returns the parts of the clipped line."""
|
||||
|
||||
def is_inside(self, point: Vec2) -> bool:
|
||||
"""Returns ``True`` if `point` is inside the clipping path."""
|
||||
|
||||
|
||||
def _clip_polyline(
|
||||
polyline: Sequence[Vec2],
|
||||
line_clipper: Callable[[Vec2, Vec2], Sequence[tuple[Vec2, Vec2]]],
|
||||
abs_tol: float,
|
||||
) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polyline."""
|
||||
if len(polyline) < 2:
|
||||
return []
|
||||
result: list[Vec2] = []
|
||||
parts: list[list[Vec2]] = []
|
||||
next_start = polyline[0]
|
||||
for end in polyline[1:]:
|
||||
start = next_start
|
||||
next_start = end
|
||||
for clipped_line in line_clipper(start, end):
|
||||
if len(clipped_line) != 2:
|
||||
continue
|
||||
if result:
|
||||
clip_start, clip_end = clipped_line
|
||||
if result[-1].isclose(clip_start, abs_tol=abs_tol):
|
||||
result.append(clip_end)
|
||||
continue
|
||||
parts.append(result)
|
||||
result = list(clipped_line)
|
||||
if result:
|
||||
parts.append(result)
|
||||
return parts
|
||||
|
||||
|
||||
class ConvexClippingPolygon2d:
|
||||
"""The clipping path is an arbitrary convex 2D polygon."""
|
||||
|
||||
def __init__(self, vertices: Iterable[Vec2], ccw_check=True, abs_tol=TOLERANCE):
|
||||
self.abs_tol = abs_tol
|
||||
clip = list(vertices)
|
||||
if len(clip) > 1:
|
||||
if clip[0].isclose(clip[-1], abs_tol=self.abs_tol):
|
||||
clip.pop()
|
||||
if len(clip) < 3:
|
||||
raise ValueError("more than 3 vertices as clipping polygon required")
|
||||
if ccw_check and has_clockwise_orientation(clip):
|
||||
clip.reverse()
|
||||
self._clipping_polygon: list[Vec2] = clip
|
||||
|
||||
def clip_polyline(self, polyline: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polyline."""
|
||||
return _clip_polyline(polyline, self.clip_line, abs_tol=self.abs_tol)
|
||||
|
||||
def clip_line(self, start: Vec2, end: Vec2) -> Sequence[tuple[Vec2, Vec2]]:
|
||||
"""Returns the parts of the clipped line."""
|
||||
|
||||
def is_inside(point: Vec2) -> bool:
|
||||
# is point left of line:
|
||||
return (clip_end.x - clip_start.x) * (point.y - clip_start.y) - (
|
||||
clip_end.y - clip_start.y
|
||||
) * (point.x - clip_start.x) >= 0.0
|
||||
|
||||
def edge_intersection(default: Vec2) -> Vec2:
|
||||
ip = intersection_line_line_2d(
|
||||
(edge_start, edge_end), (clip_start, clip_end), abs_tol=self.abs_tol
|
||||
)
|
||||
if ip is None:
|
||||
return default
|
||||
return ip
|
||||
|
||||
# The clipping polygon is always treated as a closed polyline!
|
||||
clip_start = self._clipping_polygon[-1]
|
||||
edge_start = start
|
||||
edge_end = end
|
||||
for clip_end in self._clipping_polygon:
|
||||
if is_inside(edge_start):
|
||||
if not is_inside(edge_end):
|
||||
edge_end = edge_intersection(edge_end)
|
||||
elif is_inside(edge_end):
|
||||
if not is_inside(edge_start):
|
||||
edge_start = edge_intersection(edge_start)
|
||||
else:
|
||||
return tuple()
|
||||
clip_start = clip_end
|
||||
return ((edge_start, edge_end),)
|
||||
|
||||
def clip_polygon(self, polygon: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polygon. A polygon is a closed polyline."""
|
||||
|
||||
def is_inside(point: Vec2) -> bool:
|
||||
# is point left of line:
|
||||
return (clip_end.x - clip_start.x) * (point.y - clip_start.y) - (
|
||||
clip_end.y - clip_start.y
|
||||
) * (point.x - clip_start.x) > 0.0
|
||||
|
||||
def edge_intersection() -> None:
|
||||
ip = intersection_line_line_2d(
|
||||
(edge_start, edge_end), (clip_start, clip_end), abs_tol=self.abs_tol
|
||||
)
|
||||
if ip is not None:
|
||||
clipped.append(ip)
|
||||
|
||||
# The clipping polygon is always treated as a closed polyline!
|
||||
clip_start = self._clipping_polygon[-1]
|
||||
clipped = list(polygon)
|
||||
for clip_end in self._clipping_polygon:
|
||||
# next clipping edge to test: clip_start -> clip_end
|
||||
if not clipped: # no subject vertices left to test
|
||||
break
|
||||
|
||||
vertices = clipped.copy()
|
||||
if len(vertices) > 1 and vertices[0].isclose(
|
||||
vertices[-1], abs_tol=self.abs_tol
|
||||
):
|
||||
vertices.pop()
|
||||
|
||||
clipped.clear()
|
||||
edge_start = vertices[-1]
|
||||
for edge_end in vertices:
|
||||
# next polygon edge to test: edge_start -> edge_end
|
||||
if is_inside(edge_end):
|
||||
if not is_inside(edge_start):
|
||||
edge_intersection()
|
||||
clipped.append(edge_end)
|
||||
elif is_inside(edge_start):
|
||||
edge_intersection()
|
||||
edge_start = edge_end
|
||||
clip_start = clip_end
|
||||
return (clipped,)
|
||||
|
||||
def is_inside(self, point: Vec2) -> bool:
|
||||
"""Returns ``True`` if `point` is inside the clipping polygon."""
|
||||
return is_point_in_polygon_2d(point, self._clipping_polygon) >= 0
|
||||
|
||||
|
||||
class ClippingRect2d:
|
||||
"""The clipping path is an axis-aligned rectangle, where all sides are parallel to
|
||||
the x- and y-axis.
|
||||
"""
|
||||
|
||||
def __init__(self, bottom_left: Vec2, top_right: Vec2, abs_tol=TOLERANCE):
|
||||
self.abs_tol = abs_tol
|
||||
self._bbox = BoundingBox2d((bottom_left, top_right))
|
||||
bottom_left = self._bbox.extmin
|
||||
top_right = self._bbox.extmax
|
||||
self._clipping_polygon = ConvexClippingPolygon2d(
|
||||
[
|
||||
bottom_left,
|
||||
Vec2(top_right.x, bottom_left.y),
|
||||
top_right,
|
||||
Vec2(bottom_left.x, top_right.y),
|
||||
],
|
||||
ccw_check=False,
|
||||
abs_tol=self.abs_tol,
|
||||
)
|
||||
self._line_clipper = CohenSutherlandLineClipping2d(
|
||||
self._bbox.extmin, self._bbox.extmax
|
||||
)
|
||||
|
||||
def clip_polygon(self, polygon: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polygon. A polygon is a closed polyline."""
|
||||
return self._clipping_polygon.clip_polygon(polygon)
|
||||
|
||||
def clip_polyline(self, polyline: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polyline."""
|
||||
return _clip_polyline(polyline, self.clip_line, self.abs_tol)
|
||||
|
||||
def clip_line(self, start: Vec2, end: Vec2) -> Sequence[tuple[Vec2, Vec2]]:
|
||||
"""Returns the clipped line."""
|
||||
result = self._line_clipper.clip_line(start, end)
|
||||
if len(result) == 2:
|
||||
return (result,) # type: ignore
|
||||
return tuple()
|
||||
|
||||
def is_inside(self, point: Vec2) -> bool:
|
||||
"""Returns ``True`` if `point` is inside the clipping rectangle."""
|
||||
return self._bbox.inside(point)
|
||||
|
||||
def has_intersection(self, other: BoundingBox2d) -> bool:
|
||||
"""Returns ``True`` if `other` bounding box intersects the clipping rectangle."""
|
||||
return self._bbox.has_intersection(other)
|
||||
|
||||
|
||||
class ConcaveClippingPolygon2d:
|
||||
"""The clipping path is an arbitrary concave 2D polygon."""
|
||||
|
||||
def __init__(self, vertices: Iterable[Vec2], abs_tol=TOLERANCE):
|
||||
self.abs_tol = abs_tol
|
||||
clip = list(vertices)
|
||||
if len(clip) > 1:
|
||||
if clip[0].isclose(clip[-1], abs_tol=self.abs_tol):
|
||||
clip.pop()
|
||||
if len(clip) < 3:
|
||||
raise ValueError("more than 3 vertices as clipping polygon required")
|
||||
# open polygon; clockwise or counter-clockwise oriented vertices
|
||||
self._clipping_polygon = clip
|
||||
self._bbox = BoundingBox2d(clip)
|
||||
|
||||
def is_inside(self, point: Vec2) -> bool:
|
||||
"""Returns ``True`` if `point` is inside the clipping polygon."""
|
||||
if not self._bbox.inside(point):
|
||||
return False
|
||||
return (
|
||||
is_point_in_polygon_2d(point, self._clipping_polygon, abs_tol=self.abs_tol)
|
||||
>= 0
|
||||
)
|
||||
|
||||
def clip_line(self, start: Vec2, end: Vec2) -> Sequence[tuple[Vec2, Vec2]]:
|
||||
"""Returns the clipped line."""
|
||||
abs_tol = self.abs_tol
|
||||
line = (start, end)
|
||||
if not self._bbox.has_overlap(BoundingBox2d(line)):
|
||||
return tuple()
|
||||
|
||||
intersections = polygon_line_intersections_2d(self._clipping_polygon, line)
|
||||
start_is_inside = is_point_in_polygon_2d(start, self._clipping_polygon) >= 0
|
||||
if len(intersections) == 0:
|
||||
if start_is_inside:
|
||||
return (line,)
|
||||
return tuple()
|
||||
end_is_inside = (
|
||||
is_point_in_polygon_2d(end, self._clipping_polygon, abs_tol=abs_tol) >= 0
|
||||
)
|
||||
if end_is_inside and not intersections[-1].isclose(end, abs_tol=abs_tol):
|
||||
# last inside-segment ends at end
|
||||
intersections.append(end)
|
||||
if start_is_inside and not intersections[0].isclose(start, abs_tol=abs_tol):
|
||||
# first inside-segment begins at start
|
||||
intersections.insert(0, start)
|
||||
|
||||
# REMOVE duplicate intersection points at the beginning and the end -
|
||||
# these are caused by clipping at the connection point of two edges.
|
||||
# KEEP duplicate intersection points in between - these are caused by the
|
||||
# coincident edges of inverted clipping polygons. These intersections points
|
||||
# are required for the inside/outside rule to work properly!
|
||||
if len(intersections) > 1 and intersections[0].isclose(
|
||||
intersections[1], abs_tol=abs_tol
|
||||
):
|
||||
intersections.pop(0)
|
||||
if len(intersections) > 1 and intersections[-1].isclose(
|
||||
intersections[-2], abs_tol=abs_tol
|
||||
):
|
||||
intersections.pop()
|
||||
|
||||
if has_collinear_edge(self._clipping_polygon, start, end):
|
||||
# slow detection: doesn't work with inside/outside rule!
|
||||
# test if mid-point of intersection-segment is inside the polygon.
|
||||
# intersection-segment collinear with a polygon edge is inside!
|
||||
segments: list[tuple[Vec2, Vec2]] = []
|
||||
for a, b in pairwise(intersections):
|
||||
if a.isclose(b, abs_tol=abs_tol): # ignore zero-length segments
|
||||
continue
|
||||
if (
|
||||
is_point_in_polygon_2d(
|
||||
a.lerp(b), self._clipping_polygon, abs_tol=abs_tol
|
||||
)
|
||||
>= 0
|
||||
):
|
||||
segments.append((a, b))
|
||||
return segments
|
||||
|
||||
# inside/outside rule
|
||||
# intersection segments:
|
||||
# (0, 1) outside (2, 3) outside (4, 5) ...
|
||||
return list(take2(intersections))
|
||||
|
||||
def clip_polyline(self, polyline: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polyline."""
|
||||
abs_tol = self.abs_tol
|
||||
segments: list[list[Vec2]] = []
|
||||
for start, end in pairwise(polyline):
|
||||
for a, b in self.clip_line(start, end):
|
||||
if segments:
|
||||
last_seg = segments[-1]
|
||||
if last_seg[-1].isclose(a, abs_tol=abs_tol):
|
||||
last_seg.append(b)
|
||||
continue
|
||||
segments.append([a, b])
|
||||
return segments
|
||||
|
||||
def clip_polygon(self, polygon: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polygon. A polygon is a closed polyline."""
|
||||
vertices = list(polygon)
|
||||
abs_tol = self.abs_tol
|
||||
if len(vertices) > 1:
|
||||
if vertices[0].isclose(vertices[-1], abs_tol=abs_tol):
|
||||
vertices.pop()
|
||||
if len(vertices) < 3:
|
||||
return tuple()
|
||||
polygon_box = BoundingBox2d(vertices)
|
||||
if not self._bbox.has_intersection(polygon_box):
|
||||
return tuple() # polygons do not overlap
|
||||
result = clip_arbitrary_polygons(self._clipping_polygon, vertices)
|
||||
if len(result) == 0:
|
||||
is_outside = any(
|
||||
is_point_in_polygon_2d(v, self._clipping_polygon, abs_tol=abs_tol) < 0
|
||||
for v in vertices
|
||||
)
|
||||
if is_outside:
|
||||
return tuple()
|
||||
return (vertices,)
|
||||
# return (self._clipping_polygon.copy(),)
|
||||
return result
|
||||
|
||||
|
||||
def clip_arbitrary_polygons(
|
||||
clipper: list[Vec2], subject: list[Vec2]
|
||||
) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped subject. Both polygons can be concave
|
||||
|
||||
Args:
|
||||
clipper: clipping window closed polygon
|
||||
subject: closed polygon to clip
|
||||
|
||||
"""
|
||||
# Caching of gh_clipper is not possible, because both GHPolygons get modified!
|
||||
gh_clipper = GHPolygon.from_vec2(clipper)
|
||||
gh_subject = GHPolygon.from_vec2(subject)
|
||||
return gh_clipper.intersection(gh_subject)
|
||||
|
||||
|
||||
def has_collinear_edge(polygon: list[Vec2], start: Vec2, end: Vec2) -> bool:
|
||||
"""Returns ``True`` if `polygon` has any collinear edge to line `start->end`."""
|
||||
a = polygon[-1]
|
||||
rel_a = point_to_line_relation(a, start, end)
|
||||
for b in polygon:
|
||||
rel_b = point_to_line_relation(b, start, end)
|
||||
if rel_a == 0 and rel_b == 0:
|
||||
return True
|
||||
a = b
|
||||
rel_a = rel_b
|
||||
return False
|
||||
|
||||
|
||||
def polygon_line_intersections_2d(
|
||||
polygon: list[Vec2], line: tuple[Vec2, Vec2], abs_tol: float = TOLERANCE
|
||||
) -> list[Vec2]:
|
||||
"""Returns all intersections of polygon with line.
|
||||
All intersections points are ordered from start to end of line.
|
||||
Start and end points are not included if not explicit intersection points.
|
||||
|
||||
.. Note::
|
||||
|
||||
Returns duplicate intersections points when the line intersects at
|
||||
the connection point of two polygon edges!
|
||||
|
||||
"""
|
||||
intersection_points: list[Vec2] = []
|
||||
start, end = line
|
||||
size = len(polygon)
|
||||
for index in range(size):
|
||||
a = polygon[index - 1]
|
||||
b = polygon[index]
|
||||
ip = intersection_line_line_2d((a, b), line, virtual=False, abs_tol=abs_tol)
|
||||
if ip is None:
|
||||
continue
|
||||
# Note: do not remove duplicate vertices, because inverted clipping polygons
|
||||
# have coincident clipping edges inside the clipping polygon! #1101
|
||||
if ip.isclose(a, abs_tol=abs_tol):
|
||||
a_prev = polygon[index - 2]
|
||||
rel_prev = point_to_line_relation(a_prev, start, end, abs_tol=abs_tol)
|
||||
rel_next = point_to_line_relation(b, start, end, abs_tol=abs_tol)
|
||||
if rel_prev == rel_next:
|
||||
continue
|
||||
# edge case: line intersects "exact" in point b
|
||||
elif ip.isclose(b, abs_tol=abs_tol):
|
||||
b_next = polygon[(index + 1) % size]
|
||||
rel_prev = point_to_line_relation(a, start, end, abs_tol=abs_tol)
|
||||
rel_next = point_to_line_relation(b_next, start, end, abs_tol=abs_tol)
|
||||
if rel_prev == rel_next:
|
||||
continue
|
||||
intersection_points.append(ip)
|
||||
|
||||
intersection_points.sort(key=lambda ip: ip.distance(start))
|
||||
return intersection_points
|
||||
|
||||
|
||||
class InvertedClippingPolygon2d(ConcaveClippingPolygon2d):
|
||||
"""This class represents an inverted clipping path. Everything between the inner
|
||||
polygon and the outer extents is considered as inside. The inner clipping path is
|
||||
an arbitrary 2D polygon.
|
||||
|
||||
.. Important::
|
||||
|
||||
The `outer_bounds` must be larger than the content to clip to work correctly.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inner_polygon: Iterable[Vec2],
|
||||
outer_bounds: BoundingBox2d,
|
||||
abs_tol=TOLERANCE,
|
||||
):
|
||||
self.abs_tol = abs_tol
|
||||
clip = list(inner_polygon)
|
||||
if len(clip) > 1:
|
||||
if not clip[0].isclose(clip[-1], abs_tol=abs_tol): # close inner_polygon
|
||||
clip.append(clip[0])
|
||||
if len(clip) < 4:
|
||||
raise ValueError("more than 3 vertices as clipping polygon required")
|
||||
# requirements for inner_polygon:
|
||||
# arbitrary polygon (convex or concave)
|
||||
# closed polygon (first vertex == last vertex)
|
||||
# clockwise or counter-clockwise oriented vertices
|
||||
self._clipping_polygon = make_inverted_clipping_polygon(
|
||||
clip, outer_bounds, abs_tol
|
||||
)
|
||||
self._bbox = outer_bounds
|
||||
|
||||
|
||||
def make_inverted_clipping_polygon(
|
||||
inner_polygon: list[Vec2], outer_bounds: BoundingBox2d, abs_tol=TOLERANCE
|
||||
) -> list[Vec2]:
|
||||
"""Creates a closed inverted clipping polygon by connecting the inner polygon with
|
||||
the surrounding rectangle at their closest vertices.
|
||||
"""
|
||||
assert outer_bounds.has_data is True
|
||||
inner_polygon = inner_polygon.copy()
|
||||
if inner_polygon[0].isclose(inner_polygon[-1], abs_tol=abs_tol):
|
||||
inner_polygon.pop()
|
||||
assert len(inner_polygon) > 2
|
||||
outer_rect = list(outer_bounds.rect_vertices()) # counter-clockwise
|
||||
outer_rect.reverse() # clockwise
|
||||
ci, co = find_closest_vertices(inner_polygon, outer_rect)
|
||||
result = inner_polygon[ci:]
|
||||
result.extend(inner_polygon[: ci + 1])
|
||||
result.extend(outer_rect[co:])
|
||||
result.extend(outer_rect[: co + 1])
|
||||
result.append(result[0])
|
||||
return result
|
||||
|
||||
|
||||
def find_closest_vertices(
|
||||
vertices0: list[Vec2], vertices1: list[Vec2]
|
||||
) -> tuple[int, int]:
|
||||
"""Returns the indices of the closest vertices of both lists."""
|
||||
min_dist = math.inf
|
||||
result: tuple[int, int] = 0, 0
|
||||
for i0, v0 in enumerate(vertices0):
|
||||
for i1, v1 in enumerate(vertices1):
|
||||
distance = v0.distance(v1)
|
||||
if distance < min_dist:
|
||||
min_dist = distance
|
||||
result = i0, i1
|
||||
return result
|
||||
|
||||
|
||||
# Based on the paper "Efficient Clipping of Arbitrary Polygons" by
|
||||
# Günther Greiner and Kai Hormann,
|
||||
# ACM Transactions on Graphics 1998;17(2):71-83
|
||||
# Available at: http://www.inf.usi.ch/hormann/papers/Greiner.1998.ECO.pdf
|
||||
|
||||
|
||||
class _Node:
|
||||
def __init__(
|
||||
self,
|
||||
vtx: Vec2,
|
||||
alpha: float = 0.0,
|
||||
intersect=False,
|
||||
entry=True,
|
||||
checked=False,
|
||||
):
|
||||
self.vtx = vtx
|
||||
|
||||
# Reference to the next vertex of the polygon
|
||||
self.next: _Node = None # type: ignore
|
||||
|
||||
# Reference to the previous vertex of the polygon
|
||||
self.prev: _Node = None # type: ignore
|
||||
|
||||
# Reference to the corresponding intersection vertex in the other polygon
|
||||
self.neighbor: _Node = None # type: ignore
|
||||
|
||||
# True if intersection is an entry point, False if exit
|
||||
self.entry: bool = entry
|
||||
|
||||
# Intersection point's relative distance from previous vertex
|
||||
self.alpha: float = alpha
|
||||
|
||||
# True if vertex is an intersection
|
||||
self.intersect: bool = intersect
|
||||
|
||||
# True if the vertex has been checked (last phase)
|
||||
self.checked: bool = checked
|
||||
|
||||
def set_checked(self):
|
||||
self.checked = True
|
||||
if self.neighbor and not self.neighbor.checked:
|
||||
self.neighbor.set_checked()
|
||||
|
||||
|
||||
class IntersectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GHPolygon:
|
||||
first: _Node = None # type: ignore
|
||||
max_x: float = 1e6
|
||||
|
||||
def add(self, node: _Node):
|
||||
"""Add a polygon vertex node."""
|
||||
|
||||
self.max_x = max(self.max_x, node.vtx.x)
|
||||
if self.first is None:
|
||||
self.first = node
|
||||
self.first.next = node
|
||||
self.first.prev = node
|
||||
else: # insert as last node
|
||||
first = self.first
|
||||
last = first.prev
|
||||
first.prev = node
|
||||
node.next = first
|
||||
node.prev = last
|
||||
last.next = node
|
||||
|
||||
@staticmethod
|
||||
def build(vertices: Iterable[UVec]) -> GHPolygon:
|
||||
"""Build a new GHPolygon from an iterable of vertices."""
|
||||
return GHPolygon.from_vec2(Vec2.list(vertices))
|
||||
|
||||
@staticmethod
|
||||
def from_vec2(vertices: Sequence[Vec2]) -> GHPolygon:
|
||||
"""Build a new GHPolygon from an iterable of vertices."""
|
||||
polygon = GHPolygon()
|
||||
for v in vertices:
|
||||
polygon.add(_Node(v))
|
||||
return polygon
|
||||
|
||||
@staticmethod
|
||||
def insert(vertex: _Node, start: _Node, end: _Node):
|
||||
"""Insert and sort an intersection node.
|
||||
|
||||
This function inserts an intersection node between two other
|
||||
start- and end node of an edge. The start and end node cannot be
|
||||
an intersection node (that is, they must be actual vertex nodes of
|
||||
the original polygon). If there are multiple intersection nodes
|
||||
between the start- and end node, then the new node is inserted
|
||||
based on its alpha value.
|
||||
"""
|
||||
curr = start
|
||||
while curr != end and curr.alpha < vertex.alpha:
|
||||
curr = curr.next
|
||||
|
||||
vertex.next = curr
|
||||
prev = curr.prev
|
||||
vertex.prev = prev
|
||||
prev.next = vertex
|
||||
curr.prev = vertex
|
||||
|
||||
def __iter__(self) -> Iterator[_Node]:
|
||||
assert self.first is not None
|
||||
s = self.first
|
||||
while True:
|
||||
yield s
|
||||
s = s.next
|
||||
if s is self.first:
|
||||
return
|
||||
|
||||
@property
|
||||
def first_intersect(self) -> Optional[_Node]:
|
||||
for v in self:
|
||||
if v.intersect and not v.checked:
|
||||
return v
|
||||
return None
|
||||
|
||||
@property
|
||||
def points(self) -> list[Vec2]:
|
||||
points = [v.vtx for v in self]
|
||||
if not points[0].isclose(points[-1]):
|
||||
points.append(points[0])
|
||||
return points
|
||||
|
||||
def unprocessed(self):
|
||||
for v in self:
|
||||
if v.intersect and not v.checked:
|
||||
return True
|
||||
return False
|
||||
|
||||
def union(self, clip: GHPolygon) -> list[list[Vec2]]:
|
||||
return self.clip(clip, False, False)
|
||||
|
||||
def intersection(self, clip: GHPolygon) -> list[list[Vec2]]:
|
||||
return self.clip(clip, True, True)
|
||||
|
||||
def difference(self, clip: GHPolygon) -> list[list[Vec2]]:
|
||||
return self.clip(clip, False, True)
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
def clip(self, clip: GHPolygon, s_entry, c_entry) -> list[list[Vec2]]:
|
||||
"""Clip this polygon using another one as a clipper.
|
||||
|
||||
This is where the algorithm is executed. It allows you to make
|
||||
a UNION, INTERSECT or DIFFERENCE operation between two polygons.
|
||||
|
||||
Given two polygons A, B the following operations may be performed:
|
||||
|
||||
A|B ... A OR B (Union of A and B)
|
||||
A&B ... A AND B (Intersection of A and B)
|
||||
A\\B ... A - B
|
||||
B\\A ... B - A
|
||||
|
||||
The entry records store the direction the algorithm should take when
|
||||
it arrives at that entry point in an intersection. Depending on the
|
||||
operation requested, the direction is set as follows for entry points
|
||||
(f=forward, b=backward; exit points are always set to the opposite):
|
||||
|
||||
Entry
|
||||
A B
|
||||
-----
|
||||
A|B b b
|
||||
A&B f f
|
||||
A\\B b f
|
||||
B\\A f b
|
||||
|
||||
f = True, b = False when stored in the entry record
|
||||
"""
|
||||
# Phase 1: Find intersections
|
||||
for subject_vertex in self:
|
||||
if not subject_vertex.intersect:
|
||||
for clipper_vertex in clip:
|
||||
if not clipper_vertex.intersect:
|
||||
ip, us, uc = line_intersection(
|
||||
subject_vertex.vtx,
|
||||
next_vertex_node(subject_vertex.next).vtx,
|
||||
clipper_vertex.vtx,
|
||||
next_vertex_node(clipper_vertex.next).vtx,
|
||||
)
|
||||
if ip is None:
|
||||
continue
|
||||
subject_node = _Node(ip, us, intersect=True, entry=False)
|
||||
clipper_node = _Node(ip, uc, intersect=True, entry=False)
|
||||
subject_node.neighbor = clipper_node
|
||||
clipper_node.neighbor = subject_node
|
||||
|
||||
self.insert(
|
||||
subject_node,
|
||||
subject_vertex,
|
||||
next_vertex_node(subject_vertex.next),
|
||||
)
|
||||
clip.insert(
|
||||
clipper_node,
|
||||
clipper_vertex,
|
||||
next_vertex_node(clipper_vertex.next),
|
||||
)
|
||||
|
||||
# Phase 2: Identify entry/exit points
|
||||
s_entry ^= is_inside_polygon(self.first.vtx, clip)
|
||||
for subject_vertex in self:
|
||||
if subject_vertex.intersect:
|
||||
subject_vertex.entry = s_entry
|
||||
s_entry = not s_entry
|
||||
|
||||
c_entry ^= is_inside_polygon(clip.first.vtx, self)
|
||||
for clipper_vertex in clip:
|
||||
if clipper_vertex.intersect:
|
||||
clipper_vertex.entry = c_entry
|
||||
c_entry = not c_entry
|
||||
|
||||
# Phase 3: Construct clipped polygons
|
||||
clipped_polygons: list[list[Vec2]] = []
|
||||
while self.unprocessed():
|
||||
current: _Node = self.first_intersect # type: ignore
|
||||
clipped: list[Vec2] = [current.vtx]
|
||||
while True:
|
||||
current.set_checked()
|
||||
if current.entry:
|
||||
while True:
|
||||
current = current.next
|
||||
clipped.append(current.vtx)
|
||||
if current.intersect:
|
||||
break
|
||||
else:
|
||||
while True:
|
||||
current = current.prev
|
||||
clipped.append(current.vtx)
|
||||
if current.intersect:
|
||||
break
|
||||
|
||||
current = current.neighbor
|
||||
if current.checked:
|
||||
break
|
||||
clipped_polygons.append(clipped)
|
||||
return clipped_polygons
|
||||
|
||||
|
||||
def next_vertex_node(v: _Node) -> _Node:
|
||||
"""Return the next non-intersecting vertex after the one specified."""
|
||||
c = v
|
||||
while c.intersect:
|
||||
c = c.next
|
||||
return c
|
||||
|
||||
|
||||
def is_inside_polygon(vertex: Vec2, polygon: GHPolygon) -> bool:
|
||||
"""Returns ``True`` if `vertex` is inside `polygon`."""
|
||||
# Possible issue: are points on the boundary inside or outside the polygon?
|
||||
# this version: inside
|
||||
return is_point_in_polygon_2d(vertex, polygon.points, abs_tol=TOLERANCE) >= 0
|
||||
|
||||
|
||||
_ERROR = None, 0, 0
|
||||
|
||||
|
||||
def line_intersection(
|
||||
s1: Vec2, s2: Vec2, c1: Vec2, c2: Vec2, tol: float = TOLERANCE
|
||||
) -> tuple[Optional[Vec2], float, float]:
|
||||
"""Returns the intersection point between two lines.
|
||||
|
||||
This special implementation excludes the line end points as intersection
|
||||
points!
|
||||
|
||||
Algorithm based on: http://paulbourke.net/geometry/lineline2d/
|
||||
"""
|
||||
den = (c2.y - c1.y) * (s2.x - s1.x) - (c2.x - c1.x) * (s2.y - s1.y)
|
||||
if abs(den) < tol:
|
||||
return _ERROR
|
||||
us = ((c2.x - c1.x) * (s1.y - c1.y) - (c2.y - c1.y) * (s1.x - c1.x)) / den
|
||||
lwr = 0.0 + tol
|
||||
upr = 1.0 - tol
|
||||
# Line end points are excluded as intersection points:
|
||||
# us =~ 0.0; us =~ 1.0
|
||||
if not (lwr < us < upr):
|
||||
return _ERROR
|
||||
# uc =~ 0.0; uc =~ 1.0
|
||||
uc = ((s2.x - s1.x) * (s1.y - c1.y) - (s2.y - s1.y) * (s1.x - c1.x)) / den
|
||||
if lwr < uc < upr:
|
||||
return (
|
||||
Vec2(s1.x + us * (s2.x - s1.x), s1.y + us * (s2.y - s1.y)),
|
||||
us,
|
||||
uc,
|
||||
)
|
||||
return _ERROR
|
||||
|
||||
|
||||
class BooleanOperation(enum.Enum):
|
||||
UNION = "union"
|
||||
DIFFERENCE = "difference"
|
||||
INTERSECTION = "intersection"
|
||||
|
||||
|
||||
def greiner_hormann_intersection(
|
||||
p1: Iterable[UVec], p2: Iterable[UVec]
|
||||
) -> list[list[Vec2]]:
|
||||
"""Returns the INTERSECTION of polygon `p1` & polygon `p2`.
|
||||
This algorithm works only for polygons with real intersection points
|
||||
and line end points on face edges are not considered as such intersection
|
||||
points!
|
||||
|
||||
"""
|
||||
return greiner_hormann(p1, p2, BooleanOperation.INTERSECTION)
|
||||
|
||||
|
||||
def greiner_hormann_difference(
|
||||
p1: Iterable[UVec], p2: Iterable[UVec]
|
||||
) -> list[list[Vec2]]:
|
||||
"""Returns the DIFFERENCE of polygon `p1` - polygon `p2`.
|
||||
This algorithm works only for polygons with real intersection points
|
||||
and line end points on face edges are not considered as such intersection
|
||||
points!
|
||||
|
||||
"""
|
||||
return greiner_hormann(p1, p2, BooleanOperation.DIFFERENCE)
|
||||
|
||||
|
||||
def greiner_hormann_union(p1: Iterable[UVec], p2: Iterable[UVec]) -> list[list[Vec2]]:
|
||||
"""Returns the UNION of polygon `p1` | polygon `p2`.
|
||||
This algorithm works only for polygons with real intersection points
|
||||
and line end points on face edges are not considered as such intersection
|
||||
points!
|
||||
|
||||
"""
|
||||
return greiner_hormann(p1, p2, BooleanOperation.UNION)
|
||||
|
||||
|
||||
def greiner_hormann(
|
||||
p1: Iterable[UVec], p2: Iterable[UVec], op: BooleanOperation
|
||||
) -> list[list[Vec2]]:
|
||||
"""Implements a 2d clipping function to perform 3 boolean operations:
|
||||
|
||||
- UNION: p1 | p2 ... p1 OR p2
|
||||
- INTERSECTION: p1 & p2 ... p1 AND p2
|
||||
- DIFFERENCE: p1 \\ p2 ... p1 - p2
|
||||
|
||||
Based on the paper "Efficient Clipping of Arbitrary Polygons" by
|
||||
Günther Greiner and Kai Hormann.
|
||||
This algorithm works only for polygons with real intersection points
|
||||
and line end points on face edges are not considered as such intersection
|
||||
points!
|
||||
|
||||
"""
|
||||
polygon1 = GHPolygon.build(p1)
|
||||
polygon2 = GHPolygon.build(p2)
|
||||
|
||||
if op == BooleanOperation.UNION:
|
||||
return polygon1.union(polygon2)
|
||||
elif op == BooleanOperation.DIFFERENCE:
|
||||
return polygon1.difference(polygon2)
|
||||
elif op == BooleanOperation.INTERSECTION:
|
||||
return polygon1.intersection(polygon2)
|
||||
raise ValueError(f"unknown or unsupported boolean operation: {op}")
|
||||
|
||||
|
||||
LEFT = 0x1
|
||||
RIGHT = 0x2
|
||||
BOTTOM = 0x4
|
||||
TOP = 0x8
|
||||
|
||||
|
||||
class CohenSutherlandLineClipping2d:
|
||||
"""Cohen-Sutherland 2D line clipping algorithm, source:
|
||||
https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm
|
||||
|
||||
Args:
|
||||
w_min: bottom-left corner of the clipping rectangle
|
||||
w_max: top-right corner of the clipping rectangle
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ("x_min", "x_max", "y_min", "y_max")
|
||||
|
||||
def __init__(self, w_min: Vec2, w_max: Vec2) -> None:
|
||||
self.x_min, self.y_min = w_min
|
||||
self.x_max, self.y_max = w_max
|
||||
|
||||
def encode(self, x: float, y: float) -> int:
|
||||
code: int = 0
|
||||
if x < self.x_min:
|
||||
code |= LEFT
|
||||
elif x > self.x_max:
|
||||
code |= RIGHT
|
||||
if y < self.y_min:
|
||||
code |= BOTTOM
|
||||
elif y > self.y_max:
|
||||
code |= TOP
|
||||
return code
|
||||
|
||||
def clip_line(self, p0: Vec2, p1: Vec2) -> Sequence[Vec2]:
|
||||
"""Returns the clipped line part as tuple[Vec2, Vec2] or an empty tuple.
|
||||
|
||||
Args:
|
||||
p0: start-point of the line to clip
|
||||
p1: end-point of the line to clip
|
||||
|
||||
"""
|
||||
x0, y0 = p0
|
||||
x1, y1 = p1
|
||||
code0 = self.encode(x0, y0)
|
||||
code1 = self.encode(x1, y1)
|
||||
x = x0
|
||||
y = y0
|
||||
while True:
|
||||
if not code0 | code1: # ACCEPT
|
||||
# bitwise OR is 0: both points inside window; trivially accept and
|
||||
# exit loop:
|
||||
return Vec2(x0, y0), Vec2(x1, y1)
|
||||
if code0 & code1: # REJECT
|
||||
# bitwise AND is not 0: both points share an outside zone (LEFT,
|
||||
# RIGHT, TOP, or BOTTOM), so both must be outside window;
|
||||
# exit loop
|
||||
return tuple()
|
||||
|
||||
# failed both tests, so calculate the line segment to clip
|
||||
# from an outside point to an intersection with clip edge
|
||||
# At least one endpoint is outside the clip rectangle; pick it
|
||||
code = code1 if code1 > code0 else code0
|
||||
|
||||
# Now find the intersection point;
|
||||
# use formulas:
|
||||
# slope = (y1 - y0) / (x1 - x0)
|
||||
# x = x0 + (1 / slope) * (ym - y0), where ym is y_min or y_max
|
||||
# y = y0 + slope * (xm - x0), where xm is x_min or x_max
|
||||
# No need to worry about divide-by-zero because, in each case, the
|
||||
# code bit being tested guarantees the denominator is non-zero
|
||||
if code & TOP: # point is above the clip window
|
||||
x = x0 + (x1 - x0) * (self.y_max - y0) / (y1 - y0)
|
||||
y = self.y_max
|
||||
elif code & BOTTOM: # point is below the clip window
|
||||
x = x0 + (x1 - x0) * (self.y_min - y0) / (y1 - y0)
|
||||
y = self.y_min
|
||||
elif code & RIGHT: # point is to the right of clip window
|
||||
y = y0 + (y1 - y0) * (self.x_max - x0) / (x1 - x0)
|
||||
x = self.x_max
|
||||
elif code & LEFT: # point is to the left of clip window
|
||||
y = y0 + (y1 - y0) * (self.x_min - x0) / (x1 - x0)
|
||||
x = self.x_min
|
||||
|
||||
if code == code0:
|
||||
x0 = x
|
||||
y0 = y
|
||||
code0 = self.encode(x0, y0)
|
||||
else:
|
||||
x1 = x
|
||||
y1 = y
|
||||
code1 = self.encode(x1, y1)
|
||||
Reference in New Issue
Block a user