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

471 lines
15 KiB
Python

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