diff --git a/src/ezdxf/math/bbox.py b/src/ezdxf/math/bbox.py index 2e1e97faf..f600131d3 100644 --- a/src/ezdxf/math/bbox.py +++ b/src/ezdxf/math/bbox.py @@ -1,29 +1,25 @@ -# Copyright (c) 2019-2023, Manfred Moitzi +# Copyright (c) 2019-2024, Manfred Moitzi # License: MIT License from __future__ import annotations -from typing import Iterable, Optional, Iterator, Sequence, Any +from typing import Iterable, Optional, Iterator, Sequence, TypeVar, Generic import abc - +import math import numpy as np -from ezdxf.math import Vec3, Vec2, UVec, AnyVec +from ezdxf.math import Vec3, Vec2, UVec +T = TypeVar("T", Vec2, Vec3) __all__ = ["BoundingBox2d", "BoundingBox", "AbstractBoundingBox"] -class AbstractBoundingBox: - __slots__ = ("extmin", "extmax") +class AbstractBoundingBox(Generic[T], abc.ABC): + extmin: T + extmax: T + @abc.abstractmethod def __init__(self, vertices: Optional[Iterable[UVec]] = None): - self.extmax: Any = None - self.extmin: Any = None - if vertices is not None: - try: - self.extmin, self.extmax = self.extends_detector(vertices) - except ValueError: - # No or invalid data creates an empty BoundingBox - pass + ... def copy(self): box = self.__class__() @@ -41,14 +37,14 @@ def __repr__(self) -> str: else: return f"{name}()" - def __iter__(self) -> Iterator[AnyVec]: + def __iter__(self) -> Iterator[T]: if self.has_data: yield self.extmin yield self.extmax @abc.abstractmethod - def extends_detector(self, vertices: Iterable[UVec]) -> tuple[AnyVec, AnyVec]: - pass + def extend(self, vertices: Iterable[UVec]) -> None: + ... @property @abc.abstractmethod @@ -60,18 +56,18 @@ def inside(self, vertex: UVec) -> bool: ... @abc.abstractmethod - def has_intersection(self, other: AbstractBoundingBox) -> bool: + def has_intersection(self, other: AbstractBoundingBox[T]) -> bool: ... @abc.abstractmethod - def has_overlap(self, other: AbstractBoundingBox) -> bool: + def has_overlap(self, other: AbstractBoundingBox[T]) -> bool: ... @abc.abstractmethod - def intersection(self, other: AbstractBoundingBox) -> AbstractBoundingBox: + def intersection(self, other: AbstractBoundingBox[T]) -> AbstractBoundingBox[T]: ... - def contains(self, other: AbstractBoundingBox) -> bool: + def contains(self, other: AbstractBoundingBox[T]) -> bool: """Returns ``True`` if the `other` bounding box is completely inside this bounding box. @@ -105,37 +101,23 @@ def all_inside(self, vertices: Iterable[UVec]) -> bool: @property def has_data(self) -> bool: """Returns ``True`` if the bonding box has known limits.""" - return self.extmin is not None + return math.isfinite(self.extmin.x) @property - def size(self): + def size(self) -> T: """Returns size of bounding box.""" return self.extmax - self.extmin @property - def center(self): + def center(self) -> T: """Returns center of bounding box.""" return self.extmin.lerp(self.extmax) - def extend(self, vertices: Iterable[UVec]) -> None: - """Extend bounds by `vertices`. - - Args: - vertices: iterable of vertices - - """ - v = list(vertices) - if not v: - return - if self.has_data: - v.extend([self.extmin, self.extmax]) - self.extmin, self.extmax = self.extends_detector(v) - - def union(self, other: AbstractBoundingBox): + def union(self, other: AbstractBoundingBox[T]) -> AbstractBoundingBox[T]: """Returns a new bounding box as union of this and `other` bounding box. """ - vertices: list[AnyVec] = [] + vertices: list[T] = [] if self.has_data: vertices.extend(self) if other.has_data: @@ -146,9 +128,9 @@ def rect_vertices(self) -> Sequence[Vec2]: """Returns the corners of the bounding box in the xy-plane as :class:`Vec2` objects. """ - if self.has_data: # extmin is not None! - x0, y0, *_ = self.extmin # type: ignore - x1, y1, *_ = self.extmax # type: ignore + if self.has_data: + x0, y0, *_ = self.extmin + x1, y1, *_ = self.extmax return Vec2(x0, y0), Vec2(x1, y0), Vec2(x1, y1), Vec2(x0, y1) else: raise ValueError("empty bounding box") @@ -164,11 +146,11 @@ def grow(self, value: float) -> None: min_ext = min(self.size) if -value >= min_ext / 2.0: raise ValueError("shrinking one or more dimensions <= 0") - self.extmax += Vec3(value, value, value) # type: ignore - self.extmin += Vec3(-value, -value, -value) # type: ignore + self.extmax += Vec3(value, value, value) + self.extmin += Vec3(-value, -value, -value) -class BoundingBox(AbstractBoundingBox): +class BoundingBox(AbstractBoundingBox[Vec3]): """3D bounding box. Args: @@ -178,6 +160,16 @@ class BoundingBox(AbstractBoundingBox): __slots__ = ("extmin", "extmax") + def __init__(self, vertices: Optional[Iterable[UVec]] = None): + self.extmin = Vec3(math.inf, math.inf, math.inf) + self.extmax = self.extmin + if vertices is not None: + try: + self.extmin, self.extmax = extents3d(vertices) + except ValueError: + # No or invalid data creates an empty BoundingBox + pass + @property def is_empty(self) -> bool: """Returns ``True`` if the bounding box is empty or the bounding box @@ -189,22 +181,33 @@ def is_empty(self) -> bool: return sx * sy * sz == 0.0 return True - def extends_detector(self, vertices: Iterable[UVec]) -> tuple[Vec3, Vec3]: - return extents3d(vertices) + def extend(self, vertices: Iterable[UVec]) -> None: + """Extend bounds by `vertices`. + + Args: + vertices: iterable of vertices + + """ + v = list(vertices) + if not v: + return + if self.has_data: + v.extend([self.extmin, self.extmax]) + self.extmin, self.extmax = extents3d(v) def inside(self, vertex: UVec) -> bool: """Returns ``True`` if `vertex` is inside this bounding box. Vertices at the box border are inside! """ - if self.extmin is None or self.extmax is None: + if not self.has_data: return False x, y, z = Vec3(vertex).xyz xmin, ymin, zmin = self.extmin.xyz xmax, ymax, zmax = self.extmax.xyz return (xmin <= x <= xmax) and (ymin <= y <= ymax) and (zmin <= z <= zmax) - def has_intersection(self, other: AbstractBoundingBox) -> bool: + def has_intersection(self, other: AbstractBoundingBox[T]) -> bool: """Returns ``True`` if this bounding box intersects with `other` but does not include touching bounding boxes, see also :meth:`has_overlap`:: @@ -215,12 +218,7 @@ def has_intersection(self, other: AbstractBoundingBox) -> bool: """ # Source: https://gamemath.com/book/geomtests.html#intersection_two_aabbs # Check for a separating axis: - if ( - self.extmin is None - or self.extmax is None - or other.extmin is None - or other.extmax is None - ): + if not self.has_data or not other.has_data: return False o_min = Vec3(other.extmin) # could be a 2D bounding box o_max = Vec3(other.extmax) # could be a 2D bounding box @@ -240,7 +238,7 @@ def has_intersection(self, other: AbstractBoundingBox) -> bool: return False return True - def has_overlap(self, other: AbstractBoundingBox) -> bool: + def has_overlap(self, other: AbstractBoundingBox[T]) -> bool: """Returns ``True`` if this bounding box intersects with `other` but in contrast to :meth:`has_intersection` includes touching bounding boxes too:: @@ -251,12 +249,7 @@ def has_overlap(self, other: AbstractBoundingBox) -> bool: """ # Source: https://gamemath.com/book/geomtests.html#intersection_two_aabbs # Check for a separating axis: - if ( - self.extmin is None - or self.extmax is None - or other.extmin is None - or other.extmax is None - ): + if not self.has_data or not other.has_data: return False o_min = Vec3(other.extmin) # could be a 2D bounding box o_max = Vec3(other.extmax) # could be a 2D bounding box @@ -277,7 +270,7 @@ def has_overlap(self, other: AbstractBoundingBox) -> bool: def cube_vertices(self) -> Sequence[Vec3]: """Returns the 3D corners of the bounding box as :class:`Vec3` objects.""" - if self.extmin is not None and self.extmax is not None: + if self.has_data: x0, y0, z0 = self.extmin x1, y1, z1 = self.extmax return ( @@ -293,7 +286,7 @@ def cube_vertices(self) -> Sequence[Vec3]: else: raise ValueError("empty bounding box") - def intersection(self, other: AbstractBoundingBox) -> BoundingBox: + def intersection(self, other: AbstractBoundingBox[T]) -> BoundingBox: """Returns the bounding box of the intersection cube of both 3D bounding boxes. Returns an empty bounding box if the intersection volume is 0. @@ -323,7 +316,7 @@ def intersection(self, other: AbstractBoundingBox) -> BoundingBox: return new_bbox -class BoundingBox2d(AbstractBoundingBox): +class BoundingBox2d(AbstractBoundingBox[Vec2]): """2D bounding box. Args: @@ -333,6 +326,16 @@ class BoundingBox2d(AbstractBoundingBox): __slots__ = ("extmin", "extmax") + def __init__(self, vertices: Optional[Iterable[UVec]] = None): + self.extmin = Vec2(math.inf, math.inf) + self.extmax = self.extmin + if vertices is not None: + try: + self.extmin, self.extmax = extents2d(vertices) + except ValueError: + # No or invalid data creates an empty BoundingBox + pass + @property def is_empty(self) -> bool: """Returns ``True`` if the bounding box is empty. The bounding box has a @@ -343,22 +346,33 @@ def is_empty(self) -> bool: return sx * sy == 0.0 return True - def extends_detector(self, vertices: Iterable[UVec]) -> tuple[Vec2, Vec2]: - return extents2d(vertices) + def extend(self, vertices: Iterable[UVec]) -> None: + """Extend bounds by `vertices`. + + Args: + vertices: iterable of vertices + + """ + v = list(vertices) + if not v: + return + if self.has_data: + v.extend([self.extmin, self.extmax]) + self.extmin, self.extmax = extents2d(v) def inside(self, vertex: UVec) -> bool: """Returns ``True`` if `vertex` is inside this bounding box. Vertices at the box border are inside! """ - if self.extmin is None or self.extmax is None: + if not self.has_data: return False v = Vec2(vertex) min_ = self.extmin max_ = self.extmax return (min_.x <= v.x <= max_.x) and (min_.y <= v.y <= max_.y) - def has_intersection(self, other: AbstractBoundingBox) -> bool: + def has_intersection(self, other: AbstractBoundingBox[T]) -> bool: """Returns ``True`` if this bounding box intersects with `other` but does not include touching bounding boxes, see also :meth:`has_overlap`:: @@ -368,12 +382,7 @@ def has_intersection(self, other: AbstractBoundingBox) -> bool: """ # Source: https://gamemath.com/book/geomtests.html#intersection_two_aabbs - if ( - self.extmin is None - or self.extmax is None - or other.extmin is None - or other.extmax is None - ): + if not self.has_data or not other.has_data: return False # Check for a separating axis: if self.extmin.x >= other.extmax.x: @@ -386,7 +395,7 @@ def has_intersection(self, other: AbstractBoundingBox) -> bool: return False return True - def intersection(self, other: AbstractBoundingBox) -> BoundingBox2d: + def intersection(self, other: AbstractBoundingBox[T]) -> BoundingBox2d: """Returns the bounding box of the intersection rectangle of both 2D bounding boxes. Returns an empty bounding box if the intersection area is 0. @@ -406,7 +415,7 @@ def intersection(self, other: AbstractBoundingBox) -> BoundingBox2d: ) return new_bbox - def has_overlap(self, other: AbstractBoundingBox) -> bool: + def has_overlap(self, other: AbstractBoundingBox[T]) -> bool: """Returns ``True`` if this bounding box intersects with `other` but in contrast to :meth:`has_intersection` includes touching bounding boxes too:: @@ -416,12 +425,7 @@ def has_overlap(self, other: AbstractBoundingBox) -> bool: """ # Source: https://gamemath.com/book/geomtests.html#intersection_two_aabbs - if ( - self.extmin is None - or self.extmax is None - or other.extmin is None - or other.extmax is None - ): + if not self.has_data or not other.has_data: return False # Check for a separating axis: if self.extmin.x > other.extmax.x: diff --git a/src/ezdxf/tools/clipping_portal.py b/src/ezdxf/tools/clipping_portal.py index effcfe4f1..6009489ff 100644 --- a/src/ezdxf/tools/clipping_portal.py +++ b/src/ezdxf/tools/clipping_portal.py @@ -218,7 +218,7 @@ def __init__(self, vertices: Iterable[UVec], remove_outside=True) -> None: self.remove_all = False self.remove_none = False bbox = BoundingBox2d(vertices) - if bbox.extmin is None or bbox.extmax is None: + if not bbox.has_data: raise ValueError("clipping box not detectable") size: Vec2 = bbox.size if size.x * size.y < 1e-9: diff --git a/src/ezdxf/xclip.py b/src/ezdxf/xclip.py index c00703ba1..977f8d3de 100644 --- a/src/ezdxf/xclip.py +++ b/src/ezdxf/xclip.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023, Manfred Moitzi +# Copyright (c) 2024, Manfred Moitzi # License: MIT License from __future__ import annotations from typing import Iterable, Sequence @@ -231,7 +231,7 @@ def invert_clipping_path(self, extents: Iterable[UVec] | None = None) -> None: grow_factor = 0.1 bbox = BoundingBox2d(extents) - if bbox.extmin is None or bbox.extmax is None: + if not bbox.has_data: raise const.DXFValueError("extents not detectable") if grow_factor: @@ -254,7 +254,7 @@ def _detect_block_extents(self) -> Sequence[Vec2]: return no_vertices _bbox = bbox.extents(block, fast=True) - if _bbox.extmin is None or _bbox.extmax is None: + if not _bbox.has_data: return no_vertices return Vec2.tuple([_bbox.extmin, _bbox.extmax]) diff --git a/tests/test_06_math/test_640_bbox.py b/tests/test_06_math/test_640_bbox.py index a9e6c8c10..972150360 100644 --- a/tests/test_06_math/test_640_bbox.py +++ b/tests/test_06_math/test_640_bbox.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019-2021, Manfred Moitzi +# Copyright (c) 2019-2024, Manfred Moitzi # License: MIT License import pytest @@ -34,13 +34,16 @@ def test_init_empty_list(self): bbox = BoundingBox([]) assert bbox.has_data is False - @pytest.mark.parametrize("v", [ - [], - [(0, 0, 0)], - [(1, 0, 0), (2, 0, 0)], - [(1, 1, 0), (2, 2, 0)], - [(1, 1, 1), (1, 1, 1)], - ]) + @pytest.mark.parametrize( + "v", + [ + [], + [(0, 0, 0)], + [(1, 0, 0), (2, 0, 0)], + [(1, 1, 0), (2, 2, 0)], + [(1, 1, 1), (1, 1, 1)], + ], + ) def test_is_empty(self, v): """Volume is 0.""" assert BoundingBox(v).is_empty is True @@ -202,7 +205,7 @@ def test_union_of_two_bounding_boxes(self): assert bbox.extmin == (0, 0, 0) assert bbox.extmax == (15, 16, 17) - def test_union_bbox_with_emtpy_bbox(self): + def test_union_bbox_with_empty_bbox(self): bbox1 = BoundingBox([(0, 0, 0), (10, 10, 10)]) bbox = bbox1.union(BoundingBox()) assert bbox.extmin == (0, 0, 0) @@ -219,6 +222,13 @@ def test_union_empty_bounding_boxes(self): assert bbox.has_data is False assert bbox.is_empty is True + def test_union_different_bounding_boxes(self): + bbox1 = BoundingBox([(0, 0, 0), (10, 10, 10)]) + bbox2 = BoundingBox2d([(5, 5), (15, 16)]) + bbox = bbox1.union(BoundingBox(bbox2)) + assert bbox.extmin == (0, 0, 0) + assert bbox.extmax == (15, 16, 10) + def test_rect_vertices_for_empty_bbox_raises_value_error(self): with pytest.raises(ValueError): BoundingBox().rect_vertices() @@ -229,9 +239,7 @@ def test_cube_vertices_for_empty_bbox_raises_value_error(self): def test_rect_vertices_returns_vertices_in_counter_clockwise_order(self): bbox = BoundingBox([(0, 0, 0), (1, 2, 3)]) - assert bbox.rect_vertices() == Vec2.tuple( - [(0, 0), (1, 0), (1, 2), (0, 2)] - ) + assert bbox.rect_vertices() == Vec2.tuple([(0, 0), (1, 0), (1, 2), (0, 2)]) def test_cube_vertices_returns_vertices_in_counter_clockwise_order(self): bbox = BoundingBox([(0, 0, 0), (1, 2, 3)]) @@ -294,13 +302,16 @@ def test_init_none(self): assert bbox.size == (10, 10) assert bbox.has_data is True - @pytest.mark.parametrize("v", [ - [], - [(0, 0)], - [(1, 0), (2, 0)], - [(0, 1), (0, 2)], - [(1, 1), (1, 1)], - ]) + @pytest.mark.parametrize( + "v", + [ + [], + [(0, 0)], + [(1, 0), (2, 0)], + [(0, 1), (0, 2)], + [(1, 1), (1, 1)], + ], + ) def test_is_empty(self, v): """Area is 0.""" assert BoundingBox2d(v).is_empty is True @@ -349,9 +360,7 @@ def test_rect_vertices_for_empty_bbox_raises_value_error(self): def test_rect_vertices_returns_vertices_in_counter_clockwise_order(self): bbox = BoundingBox2d([(0, 0, 0), (1, 2, 3)]) - assert bbox.rect_vertices() == Vec2.tuple( - [(0, 0), (1, 0), (1, 2), (0, 2)] - ) + assert bbox.rect_vertices() == Vec2.tuple([(0, 0), (1, 0), (1, 2), (0, 2)]) def test_contains_other_bounding_box(self): box_a = BoundingBox2d([(0, 0), (10, 10)]) @@ -363,20 +372,20 @@ def test_contains_other_bounding_box(self): assert box_a.contains(box_c) is False def test_2d_box_contains_3d_box(self): + # casting is required since v1.2 for valid type-checking box_a = BoundingBox2d( [(0, 0), (10, 10)] ) # this is flat-land, z-axis does not exist box_b = BoundingBox([(1, 1, 1), (9, 9, 9)]) - assert box_a.contains(box_b) is True, "z-axis should be ignored" + assert box_a.contains(BoundingBox2d(box_b)) is True, "z-axis should be ignored" def test_3d_box_contains_2d_box(self): - box_a = BoundingBox2d( - [(1, 1), (9, 9)] - ) # lives in the xy-plane, z-axis is 0 + # casting is required since v1.2 for valid type-checking + box_a = BoundingBox2d([(1, 1), (9, 9)]) # lives in the xy-plane, z-axis is 0 box_b = BoundingBox([(0, 0, 0), (10, 10, 10)]) - assert box_b.contains(box_a) is True, "xy-plane is included" + assert box_b.contains(BoundingBox(box_a)) is True, "xy-plane is included" box_c = BoundingBox([(0, 0, 1), (10, 10, 10)]) - assert box_c.contains(box_a) is False, "xy-plane is not included" + assert box_c.contains(BoundingBox(box_a)) is False, "xy-plane is not included" def test_grow_bounding_box(self): box = BoundingBox2d([(0, 0), (1, 1)]) @@ -426,11 +435,14 @@ def test_half_intersection(self): assert b.extmin.isclose((1, 1)) assert b.extmax.isclose((2, 2)) - @pytest.mark.parametrize("v1, v2", [ - ([(0, 0), (2, 2)], [(1, 1), (3, 3)]), - ([(-1, -1), (1, 1)], [(0, 0), (2, 2)]), - ([(-2, -2), (0, 0)], [(-3, -3), (-1, -1)]), - ]) + @pytest.mark.parametrize( + "v1, v2", + [ + ([(0, 0), (2, 2)], [(1, 1), (3, 3)]), + ([(-1, -1), (1, 1)], [(0, 0), (2, 2)]), + ([(-2, -2), (0, 0)], [(-3, -3), (-1, -1)]), + ], + ) def test_multiple_intersections(self, v1, v2): b1 = BoundingBox2d(v1) b2 = BoundingBox2d(v2) @@ -491,11 +503,14 @@ def test_half_intersection(self): assert b.extmin.isclose((1, 1, 1)) assert b.extmax.isclose((2, 2, 2)) - @pytest.mark.parametrize("v1, v2", [ - ([(0, 0, 0), (2, 2, 2)], [(1, 1, 1), (3, 3, 3)]), - ([(-1, -1, -1), (1, 1, 1)], [(0, 0, 0), (2, 2, 2)]), - ([(-2, -2, -2), (0, 0, 0)], [(-3, -3, -3), (-1, -1, -1)]), - ]) + @pytest.mark.parametrize( + "v1, v2", + [ + ([(0, 0, 0), (2, 2, 2)], [(1, 1, 1), (3, 3, 3)]), + ([(-1, -1, -1), (1, 1, 1)], [(0, 0, 0), (2, 2, 2)]), + ([(-2, -2, -2), (0, 0, 0)], [(-3, -3, -3), (-1, -1, -1)]), + ], + ) def test_multiple_intersections(self, v1, v2): b1 = BoundingBox(v1) b2 = BoundingBox(v2)