Skip to content

Commit

Permalink
add clipping shape MultiClip
Browse files Browse the repository at this point in the history
  • Loading branch information
mozman committed Jan 6, 2024
1 parent aca3d9b commit 93f0ece
Show file tree
Hide file tree
Showing 2 changed files with 126 additions and 4 deletions.
75 changes: 75 additions & 0 deletions src/ezdxf/tools/clipping_portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"ClippingShape",
"ClippingPortal",
"ClippingRect",
"MultiClip",
"find_best_clipping_shape",
]

Expand Down Expand Up @@ -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]:
...
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
55 changes: 51 additions & 4 deletions tests/test_05_tools/test_541_clipping_portal.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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__])

0 comments on commit 93f0ece

Please sign in to comment.