# Copyright (c) 2024, Manfred Moitzi # License: MIT License from __future__ import annotations from typing import ( Optional, Iterable, Iterator, Sequence, NamedTuple, Callable, TYPE_CHECKING, ) import abc from ezdxf.math import ( Matrix44, Vec2, BoundingBox2d, UVec, is_convex_polygon_2d, is_axes_aligned_rectangle_2d, ) from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d if TYPE_CHECKING: from ezdxf.math.clipping import Clipping __all__ = [ "ClippingShape", "ClippingPortal", "ClippingRect", "ConvexClippingPolygon", "InvertedClippingPolygon", "MultiClip", "find_best_clipping_shape", "make_inverted_clipping_shape", ] class ClippingShape(abc.ABC): """The ClippingShape defines a single clipping path and executes the clipping on basic geometries: - point: a single point - line: a line between two vertices - polyline: open polyline with one or more straight line segments - polygon: closed shape with straight line as edges - path: open shape with straight lines and Bezier-curves as segments - filled-path: closed shape with straight lines and Bezier-curves as edges Difference between open and closed shapes: - an open shape is treated as a linear shape without a filling - clipping an open shape returns one or more open shapes - a closed shape is treated as a filled shape, where the first vertex is coincident to the last vertex. - clipping a closed shape returns one or more closed shapes Notes: An arbitrary clipping polygon can split any basic geometry (except point) into multiple parts. All current implemented clipping algorithms flatten Bezier-curves into polylines. """ @abc.abstractmethod def bbox(self) -> BoundingBox2d: ... @abc.abstractmethod def is_completely_inside(self, other: BoundingBox2d) -> bool: ... # returning False means: I don't know! @abc.abstractmethod def is_completely_outside(self, other: BoundingBox2d) -> bool: ... @abc.abstractmethod def clip_point(self, point: Vec2) -> Optional[Vec2]: ... @abc.abstractmethod def clip_line(self, start: Vec2, end: Vec2) -> Sequence[tuple[Vec2, Vec2]]: ... @abc.abstractmethod def clip_polyline(self, points: NumpyPoints2d) -> Sequence[NumpyPoints2d]: ... @abc.abstractmethod def clip_polygon(self, points: NumpyPoints2d) -> Sequence[NumpyPoints2d]: ... @abc.abstractmethod def clip_paths( self, paths: Iterable[NumpyPath2d], max_sagitta: float ) -> Iterator[NumpyPath2d]: ... @abc.abstractmethod def clip_filled_paths( self, paths: Iterable[NumpyPath2d], max_sagitta: float ) -> Iterator[NumpyPath2d]: ... class ClippingStage(NamedTuple): portal: ClippingShape transform: Matrix44 | None class ClippingPortal: """The ClippingPortal manages a clipping path stack.""" def __init__(self) -> None: self._stages: list[ClippingStage] = [] @property def is_active(self) -> bool: return bool(self._stages) def push(self, portal: ClippingShape, transform: Matrix44 | None) -> None: self._stages.append(ClippingStage(portal, transform)) def pop(self) -> None: if self._stages: self._stages.pop() def foreach_stage(self, command: Callable[[ClippingStage], bool]) -> None: for stage in self._stages[::-1]: if not command(stage): return def clip_point(self, point: Vec2) -> Optional[Vec2]: result: Vec2 | None = point def do(stage: ClippingStage) -> bool: nonlocal result assert result is not None if stage.transform: result = Vec2(stage.transform.transform(result)) result = stage.portal.clip_point(result) return result is not None self.foreach_stage(do) return result def clip_line(self, start: Vec2, end: Vec2) -> list[tuple[Vec2, Vec2]]: def do(stage: ClippingStage) -> bool: lines = list(result) result.clear() for s, e in lines: if stage.transform: s, e = stage.transform.fast_2d_transform((s, e)) result.extend(stage.portal.clip_line(s, e)) return bool(result) result = [(start, end)] self.foreach_stage(do) return result def clip_polyline(self, points: NumpyPoints2d) -> list[NumpyPoints2d]: def do(stage: ClippingStage) -> bool: polylines = list(result) result.clear() for polyline in polylines: if stage.transform: polyline.transform_inplace(stage.transform) result.extend(stage.portal.clip_polyline(polyline)) return bool(result) result = [points] self.foreach_stage(do) return result def clip_polygon(self, points: NumpyPoints2d) -> list[NumpyPoints2d]: def do(stage: ClippingStage) -> bool: polygons = list(result) result.clear() for polygon in polygons: if stage.transform: polygon.transform_inplace(stage.transform) result.extend(stage.portal.clip_polygon(polygon)) return bool(result) result = [points] self.foreach_stage(do) return result def clip_paths( self, paths: Iterable[NumpyPath2d], max_sagitta: float ) -> list[NumpyPath2d]: def do(stage: ClippingStage) -> bool: paths = list(result) result.clear() for path in paths: if stage.transform: path.transform_inplace(stage.transform) result.extend(stage.portal.clip_paths(paths, max_sagitta)) return bool(result) result = list(paths) self.foreach_stage(do) return result def clip_filled_paths( self, paths: Iterable[NumpyPath2d], max_sagitta: float ) -> list[NumpyPath2d]: def do(stage: ClippingStage) -> bool: paths = list(result) result.clear() for path in paths: if stage.transform: path.transform_inplace(stage.transform) result.extend(stage.portal.clip_filled_paths(paths, max_sagitta)) return bool(result) result = list(paths) self.foreach_stage(do) return result def transform_matrix(self, m: Matrix44) -> Matrix44: for _, transform in self._stages[::-1]: if transform is not None: m @= transform return m class ClippingPolygon(ClippingShape): """Represents an arbitrary polygon as clipping shape. Removes the geometry outside the clipping polygon. """ def __init__(self, bbox: BoundingBox2d, clipper: Clipping) -> None: if not bbox.has_data: raise ValueError("clipping box not detectable") self._bbox = bbox self.clipper = clipper def bbox(self) -> BoundingBox2d: return self._bbox def clip_point(self, point: Vec2) -> Optional[Vec2]: is_inside = self.clipper.is_inside(Vec2(point)) if not is_inside: return None return point def clip_line(self, start: Vec2, end: Vec2) -> Sequence[tuple[Vec2, Vec2]]: return self.clipper.clip_line(start, end) def clip_polyline(self, points: NumpyPoints2d) -> Sequence[NumpyPoints2d]: clipper = self.clipper if len(points) == 0: return tuple() polyline_bbox = BoundingBox2d(points.extents()) if self.is_completely_outside(polyline_bbox): return tuple() if self.is_completely_inside(polyline_bbox): return (points,) return [ NumpyPoints2d(part) for part in clipper.clip_polyline(points.vertices()) if len(part) > 0 ] def clip_polygon(self, points: NumpyPoints2d) -> Sequence[NumpyPoints2d]: clipper = self.clipper if len(points) < 2: return tuple() polygon_bbox = BoundingBox2d(points.extents()) if self.is_completely_outside(polygon_bbox): return tuple() if self.is_completely_inside(polygon_bbox): return (points,) return [ NumpyPoints2d(part) for part in clipper.clip_polygon(points.vertices()) if len(part) > 0 ] def clip_paths( self, paths: Iterable[NumpyPath2d], max_sagitta: float ) -> Iterator[NumpyPath2d]: clipper = self.clipper for path in paths: for sub_path in path.sub_paths(): path_bbox = BoundingBox2d(sub_path.control_vertices()) if not path_bbox.has_data: continue if self.is_completely_inside(path_bbox): yield sub_path continue if self.is_completely_outside(path_bbox): continue polyline = Vec2.list(sub_path.flattening(max_sagitta, segments=4)) for part in clipper.clip_polyline(polyline): if len(part) > 0: yield NumpyPath2d.from_vertices(part, close=False) def clip_filled_paths( self, paths: Iterable[NumpyPath2d], max_sagitta: float ) -> Iterator[NumpyPath2d]: clipper = self.clipper for path in paths: for sub_path in path.sub_paths(): if len(sub_path) < 2: continue path_bbox = BoundingBox2d(sub_path.control_vertices()) if self.is_completely_inside(path_bbox): yield sub_path continue if self.is_completely_outside(path_bbox): continue for part in clipper.clip_polygon( Vec2.list(sub_path.flattening(max_sagitta, segments=4)) ): if len(part) > 0: yield NumpyPath2d.from_vertices(part, close=True) class ClippingRect(ClippingPolygon): """Represents a rectangle as clipping shape where the edges are parallel to the x- and y-axis of the coordinate system. Removes the geometry outside the clipping rectangle. """ def __init__(self, vertices: Iterable[UVec]) -> None: from ezdxf.math.clipping import ClippingRect2d polygon = Vec2.list(vertices) bbox = BoundingBox2d(polygon) if not bbox.has_data: raise ValueError("clipping box not detectable") size: Vec2 = bbox.size self.remove_all = size.x * size.y < 1e-9 super().__init__(bbox, ClippingRect2d(bbox.extmin, bbox.extmax)) def is_completely_inside(self, other: BoundingBox2d) -> bool: return self._bbox.contains(other) def is_completely_outside(self, other: BoundingBox2d) -> bool: return not self._bbox.has_intersection(other) def clip_point(self, point: Vec2) -> Optional[Vec2]: if self.remove_all: return None return super().clip_point(point) def clip_line(self, start: Vec2, end: Vec2) -> Sequence[tuple[Vec2, Vec2]]: if self.remove_all: return tuple() return self.clipper.clip_line(start, end) def clip_polyline(self, points: NumpyPoints2d) -> Sequence[NumpyPoints2d]: if self.remove_all: return (NumpyPoints2d(tuple()),) return super().clip_polyline(points) def clip_polygon(self, points: NumpyPoints2d) -> Sequence[NumpyPoints2d]: if self.remove_all: return (NumpyPoints2d(tuple()),) return super().clip_polygon(points) def clip_paths( self, paths: Iterable[NumpyPath2d], max_sagitta: float ) -> Iterator[NumpyPath2d]: if self.remove_all: return iter(tuple()) return super().clip_paths(paths, max_sagitta) def clip_filled_paths( self, paths: Iterable[NumpyPath2d], max_sagitta: float ) -> Iterator[NumpyPath2d]: if self.remove_all: return iter(tuple()) return super().clip_filled_paths(paths, max_sagitta) class ConvexClippingPolygon(ClippingPolygon): """Represents an arbitrary convex polygon as clipping shape. Removes the geometry outside the clipping polygon. """ def __init__(self, vertices: Iterable[UVec]) -> None: from ezdxf.math.clipping import ConvexClippingPolygon2d polygon = Vec2.list(vertices) super().__init__(BoundingBox2d(polygon), ConvexClippingPolygon2d(polygon)) def is_completely_inside(self, other: BoundingBox2d) -> bool: return False # I don't know! def is_completely_outside(self, other: BoundingBox2d) -> bool: return not self._bbox.has_intersection(other) class ConcaveClippingPolygon(ClippingPolygon): """Represents an arbitrary concave polygon as clipping shape. Removes the geometry outside the clipping polygon. """ def __init__(self, vertices: Iterable[UVec]) -> None: from ezdxf.math.clipping import ConcaveClippingPolygon2d polygon = Vec2.list(vertices) super().__init__(BoundingBox2d(polygon), ConcaveClippingPolygon2d(polygon)) def is_completely_inside(self, other: BoundingBox2d) -> bool: return False # I don't know! def is_completely_outside(self, other: BoundingBox2d) -> bool: return not self._bbox.has_intersection(other) class InvertedClippingPolygon(ClippingPolygon): """Represents an arbitrary inverted clipping polygon. Removes the geometry inside the clipping polygon. .. Important:: The `outer_bounds` must be larger than the content to clip to work correctly. """ def __init__(self, vertices: Iterable[UVec], outer_bounds: BoundingBox2d) -> None: from ezdxf.math.clipping import InvertedClippingPolygon2d polygon = Vec2.list(vertices) super().__init__(outer_bounds, InvertedClippingPolygon2d(polygon, outer_bounds)) def is_completely_inside(self, other: BoundingBox2d) -> bool: # returning False means: I don't know! return False # not easy to detect def is_completely_outside(self, other: BoundingBox2d) -> bool: return not self._bbox.has_intersection(other) def find_best_clipping_shape(polygon: Iterable[UVec]) -> ClippingShape: """Returns the best clipping shape for the given clipping polygon. The function analyses the given polygon (rectangular, convex or concave polygon, ...) and returns the optimized (fastest) clipping shape. Args: polygon: clipping polygon as iterable vertices """ points = Vec2.list(polygon) if is_axes_aligned_rectangle_2d(points): return ClippingRect(points) elif is_convex_polygon_2d(points, strict=False): return ConvexClippingPolygon(points) return ConcaveClippingPolygon(points) def make_inverted_clipping_shape( polygon: Iterable[UVec], outer_bounds: BoundingBox2d ) -> ClippingShape: """Returns an inverted clipping shape that removes the geometry inside the clipping polygon and beyond the outer bounds. Args: polygon: clipping polygon as iterable vertices outer_bounds: outer bounds of the clipping shape """ return InvertedClippingPolygon(polygon, outer_bounds)