From 93f0ecedec1d0177b3824a16811245cf7a8c542e Mon Sep 17 00:00:00 2001 From: mozman Date: Sat, 6 Jan 2024 09:33:02 +0100 Subject: [PATCH] add clipping shape MultiClip --- src/ezdxf/tools/clipping_portal.py | 75 +++++++++++++++++++ .../test_05_tools/test_541_clipping_portal.py | 55 +++++++++++++- 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/src/ezdxf/tools/clipping_portal.py b/src/ezdxf/tools/clipping_portal.py index 3ee1295b3..effcfe4f1 100644 --- a/src/ezdxf/tools/clipping_portal.py +++ b/src/ezdxf/tools/clipping_portal.py @@ -12,6 +12,7 @@ "ClippingShape", "ClippingPortal", "ClippingRect", + "MultiClip", "find_best_clipping_shape", ] @@ -48,6 +49,10 @@ class ClippingShape(abc.ABC): # - True: remove geometry outside the clipping shape # - False: remove geometry inside the clipping shape + @abc.abstractmethod + def bbox(self) -> BoundingBox2d: + ... + @abc.abstractmethod def clip_point(self, point: Vec2) -> Optional[Vec2]: ... @@ -221,8 +226,12 @@ def __init__(self, vertices: Iterable[UVec], remove_outside=True) -> None: self.remove_all = True else: # remove inside self.remove_none = True + self._bbox = bbox self.clipper = _ClippingRect2d(bbox.extmin, bbox.extmax) + def bbox(self) -> BoundingBox2d: + return self._bbox + def clip_point(self, point: Vec2) -> Optional[Vec2]: if self.remove_all: return None @@ -326,6 +335,72 @@ def clip_filled_paths( ) +class MultiClip(ClippingShape): + """The MultiClip combines multiple clipping shapes into a single clipping shape. + + Overlapping clipping shapes and clipping shapes that "remove_inside" will yield + strange results but are not actively prevented. + + """ + + def __init__(self, shapes: Iterable[ClippingShape]) -> None: + self._clipping_ranges: list[tuple[ClippingShape, BoundingBox2d]] = [ + (shape, shape.bbox()) for shape in shapes if not shape.bbox().is_empty + ] + + def bbox(self) -> BoundingBox2d: + bbox = BoundingBox2d() + for _, extents in self._clipping_ranges: + bbox.extend(extents) + return bbox + + def shapes_in_range(self, bbox: BoundingBox2d) -> list[ClippingShape]: + return [ + shape + for shape, extents in self._clipping_ranges + if bbox.has_intersection(extents) + ] + + def clip_point(self, point: Vec2) -> Optional[Vec2]: + for shape, _ in self._clipping_ranges: + clipped_point = shape.clip_point(point) + if clipped_point is not None: + return clipped_point + return None + + def clip_line(self, start: Vec2, end: Vec2) -> Sequence[tuple[Vec2, Vec2]]: + result: list[tuple[Vec2, Vec2]] = [] + for clipper in self.shapes_in_range(BoundingBox2d((start, end))): + result.extend(clipper.clip_line(start, end)) + return result + + def clip_polyline(self, points: NumpyPoints2d) -> Sequence[NumpyPoints2d]: + result: list[NumpyPoints2d] = [] + for shape in self.shapes_in_range(points.bbox()): + result.extend(shape.clip_polyline(points)) + return result + + def clip_polygon(self, points: NumpyPoints2d) -> Sequence[NumpyPoints2d]: + result: list[NumpyPoints2d] = [] + for shape in self.shapes_in_range(points.bbox()): + result.extend(shape.clip_polygon(points)) + return result + + def clip_paths( + self, paths: Iterable[NumpyPath2d], max_sagitta: float + ) -> Iterator[NumpyPath2d]: + for path in paths: + for shape in self.shapes_in_range(path.bbox()): + yield from shape.clip_paths((path,), max_sagitta) + + def clip_filled_paths( + self, paths: Iterable[NumpyPath2d], max_sagitta: float + ) -> Iterator[NumpyPath2d]: + for path in paths: + for shape in self.shapes_in_range(path.bbox()): + yield from shape.clip_filled_paths((path,), max_sagitta) + + def find_best_clipping_shape( polygon: Iterable[UVec], remove_outside=True ) -> ClippingShape: diff --git a/tests/test_05_tools/test_541_clipping_portal.py b/tests/test_05_tools/test_541_clipping_portal.py index 171a9ea13..0d7f7d361 100644 --- a/tests/test_05_tools/test_541_clipping_portal.py +++ b/tests/test_05_tools/test_541_clipping_portal.py @@ -1,10 +1,9 @@ # Copyright (c) 2023-2024, Manfred Moitzi # License: MIT License - import pytest +import math - -from ezdxf.tools.clipping_portal import ClippingRect +from ezdxf.tools.clipping_portal import ClippingRect, MultiClip from ezdxf.math import Vec2 from ezdxf import npshapes @@ -19,7 +18,7 @@ def test_clipping_lines(self): clipper = ClippingRect([(-1, -1), (1, 1)]) cropped_segments = clipper.clip_line(Vec2(-3, 0), Vec2(3, 0)) assert len(cropped_segments) == 1 - + points = cropped_segments[0] assert len(points) == 2 start, end = points @@ -34,5 +33,53 @@ def test_clip_polyline(self): assert len(polygons[0]) == 4 +class TestMultiClip: + @pytest.fixture + def multi_clip(self) -> MultiClip: + return MultiClip( + [ + ClippingRect([(0, 0), (1, 1)]), + ClippingRect([(2, 0), (3, 1)]), + ] + ) + + def test_extents(self, multi_clip: MultiClip) -> None: + bbox = multi_clip.bbox() + assert bbox.extmin.isclose((0, 0)) + assert bbox.extmax.isclose((3, 1)) + + def test_remove_empty_clipping_shapes(self): + r0 = ClippingRect([(0.5, 0.5), (0.5, 0.5)]) + r1 = ClippingRect([(0, 0), (3, 1)]) + multi_clip = MultiClip([r0, r1]) + shapes = multi_clip.shapes_in_range(multi_clip.bbox()) + assert len(shapes) == 1 + assert shapes[0] is r1 + + @pytest.mark.parametrize( + "point", + Vec2.list([(0.5, 0.5), (2.5, 0.5), (0, 0), (1, 1), (2, 0), (3, 1)]), + ) + def test_point_inside(self, multi_clip: MultiClip, point: Vec2) -> None: + assert point.isclose(multi_clip.clip_point(point)) + + @pytest.mark.parametrize( + "point", + Vec2.list([(-1, 0.5), (1.5, 0.5), (3.5, 0)]), + ) + def test_point_outside(self, multi_clip: MultiClip, point: Vec2) -> None: + assert multi_clip.clip_point(point) is None + + def test_clip_line_1(self, multi_clip: MultiClip) -> None: + parts = list(multi_clip.clip_line(Vec2(0.5, 0.5), Vec2(2.5, 0.5))) + assert len(parts) == 2 + assert math.isclose(sum(v0.distance(v1) for v0, v1 in parts), 1.0) + + def test_clip_line_2(self, multi_clip: MultiClip) -> None: + parts = list(multi_clip.clip_line(Vec2(-0.5, 0.5), Vec2(3.5, 0.5))) + assert len(parts) == 2 + assert math.isclose(sum(v0.distance(v1) for v0, v1 in parts), 2.0) + + if __name__ == "__main__": pytest.main([__file__])