Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/math/clipping.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

939 lines
33 KiB
Python

# 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)