458 lines
15 KiB
Python
458 lines
15 KiB
Python
# Copyright (c) 2019-2024, Manfred Moitzi
|
|
# License: MIT License
|
|
from __future__ import annotations
|
|
from typing import Iterable, Optional, Iterator, Sequence, TypeVar, Generic
|
|
import abc
|
|
import math
|
|
import numpy as np
|
|
|
|
from ezdxf.math import Vec3, Vec2, UVec
|
|
|
|
T = TypeVar("T", Vec2, Vec3)
|
|
|
|
__all__ = ["BoundingBox2d", "BoundingBox", "AbstractBoundingBox"]
|
|
|
|
|
|
class AbstractBoundingBox(Generic[T], abc.ABC):
|
|
extmin: T
|
|
extmax: T
|
|
|
|
@abc.abstractmethod
|
|
def __init__(self, vertices: Optional[Iterable[UVec]] = None):
|
|
...
|
|
|
|
def copy(self):
|
|
box = self.__class__()
|
|
box.extmin = self.extmin
|
|
box.extmax = self.extmax
|
|
return box
|
|
|
|
def __str__(self) -> str:
|
|
return f"[{self.extmin}, {self.extmax}]"
|
|
|
|
def __repr__(self) -> str:
|
|
name = self.__class__.__name__
|
|
if self.has_data:
|
|
return f"{name}({self.__str__()})"
|
|
else:
|
|
return f"{name}()"
|
|
|
|
def __iter__(self) -> Iterator[T]:
|
|
if self.has_data:
|
|
yield self.extmin
|
|
yield self.extmax
|
|
|
|
@abc.abstractmethod
|
|
def extend(self, vertices: Iterable[UVec]) -> None:
|
|
...
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def is_empty(self) -> bool:
|
|
...
|
|
|
|
@abc.abstractmethod
|
|
def inside(self, vertex: UVec) -> bool:
|
|
...
|
|
|
|
@abc.abstractmethod
|
|
def has_intersection(self, other: AbstractBoundingBox[T]) -> bool:
|
|
...
|
|
|
|
@abc.abstractmethod
|
|
def has_overlap(self, other: AbstractBoundingBox[T]) -> bool:
|
|
...
|
|
|
|
@abc.abstractmethod
|
|
def intersection(self, other: AbstractBoundingBox[T]) -> AbstractBoundingBox[T]:
|
|
...
|
|
|
|
def contains(self, other: AbstractBoundingBox[T]) -> bool:
|
|
"""Returns ``True`` if the `other` bounding box is completely inside
|
|
this bounding box.
|
|
|
|
"""
|
|
return self.inside(other.extmin) and self.inside(other.extmax)
|
|
|
|
def any_inside(self, vertices: Iterable[UVec]) -> bool:
|
|
"""Returns ``True`` if any vertex is inside this bounding box.
|
|
|
|
Vertices at the box border are inside!
|
|
"""
|
|
if self.has_data:
|
|
return any(self.inside(v) for v in vertices)
|
|
return False
|
|
|
|
def all_inside(self, vertices: Iterable[UVec]) -> bool:
|
|
"""Returns ``True`` if all vertices are inside this bounding box.
|
|
|
|
Vertices at the box border are inside!
|
|
"""
|
|
if self.has_data:
|
|
# all() returns True for an empty set of vertices
|
|
has_any = False
|
|
for v in vertices:
|
|
has_any = True
|
|
if not self.inside(v):
|
|
return False
|
|
return has_any
|
|
return False
|
|
|
|
@property
|
|
def has_data(self) -> bool:
|
|
"""Returns ``True`` if the bonding box has known limits."""
|
|
return math.isfinite(self.extmin.x)
|
|
|
|
@property
|
|
def size(self) -> T:
|
|
"""Returns size of bounding box."""
|
|
return self.extmax - self.extmin
|
|
|
|
@property
|
|
def center(self) -> T:
|
|
"""Returns center of bounding box."""
|
|
return self.extmin.lerp(self.extmax)
|
|
|
|
def union(self, other: AbstractBoundingBox[T]) -> AbstractBoundingBox[T]:
|
|
"""Returns a new bounding box as union of this and `other` bounding
|
|
box.
|
|
"""
|
|
vertices: list[T] = []
|
|
if self.has_data:
|
|
vertices.extend(self)
|
|
if other.has_data:
|
|
vertices.extend(other)
|
|
return self.__class__(vertices)
|
|
|
|
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:
|
|
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")
|
|
|
|
def grow(self, value: float) -> None:
|
|
"""Grow or shrink the bounding box by an uniform value in x, y and
|
|
z-axis. A negative value shrinks the bounding box.
|
|
Raises :class:`ValueError` for shrinking the size of the bounding box to
|
|
zero or below in any dimension.
|
|
"""
|
|
if self.has_data:
|
|
if value < 0.0:
|
|
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)
|
|
self.extmin += Vec3(-value, -value, -value)
|
|
|
|
|
|
class BoundingBox(AbstractBoundingBox[Vec3]):
|
|
"""3D bounding box.
|
|
|
|
Args:
|
|
vertices: iterable of ``(x, y, z)`` tuples or :class:`Vec3` objects
|
|
|
|
"""
|
|
|
|
__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
|
|
has a size of 0 in any or all dimensions or is undefined.
|
|
|
|
"""
|
|
if self.has_data:
|
|
sx, sy, sz = self.size
|
|
return sx * sy * sz == 0.0
|
|
return True
|
|
|
|
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 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[T]) -> bool:
|
|
"""Returns ``True`` if this bounding box intersects with `other` but does
|
|
not include touching bounding boxes, see also :meth:`has_overlap`::
|
|
|
|
bbox1 = BoundingBox([(0, 0, 0), (1, 1, 1)])
|
|
bbox2 = BoundingBox([(1, 1, 1), (2, 2, 2)])
|
|
assert bbox1.has_intersection(bbox2) is False
|
|
|
|
"""
|
|
# Source: https://gamemath.com/book/geomtests.html#intersection_two_aabbs
|
|
# Check for a separating axis:
|
|
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
|
|
|
|
# Check for a separating axis:
|
|
if self.extmin.x >= o_max.x:
|
|
return False
|
|
if self.extmax.x <= o_min.x:
|
|
return False
|
|
if self.extmin.y >= o_max.y:
|
|
return False
|
|
if self.extmax.y <= o_min.y:
|
|
return False
|
|
if self.extmin.z >= o_max.z:
|
|
return False
|
|
if self.extmax.z <= o_min.z:
|
|
return False
|
|
return True
|
|
|
|
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::
|
|
|
|
bbox1 = BoundingBox([(0, 0, 0), (1, 1, 1)])
|
|
bbox2 = BoundingBox([(1, 1, 1), (2, 2, 2)])
|
|
assert bbox1.has_overlap(bbox2) is True
|
|
|
|
"""
|
|
# Source: https://gamemath.com/book/geomtests.html#intersection_two_aabbs
|
|
# Check for a separating axis:
|
|
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
|
|
# Check for a separating axis:
|
|
if self.extmin.x > o_max.x:
|
|
return False
|
|
if self.extmax.x < o_min.x:
|
|
return False
|
|
if self.extmin.y > o_max.y:
|
|
return False
|
|
if self.extmax.y < o_min.y:
|
|
return False
|
|
if self.extmin.z > o_max.z:
|
|
return False
|
|
if self.extmax.z < o_min.z:
|
|
return False
|
|
return True
|
|
|
|
def cube_vertices(self) -> Sequence[Vec3]:
|
|
"""Returns the 3D corners of the bounding box as :class:`Vec3` objects."""
|
|
if self.has_data:
|
|
x0, y0, z0 = self.extmin
|
|
x1, y1, z1 = self.extmax
|
|
return (
|
|
Vec3(x0, y0, z0),
|
|
Vec3(x1, y0, z0),
|
|
Vec3(x1, y1, z0),
|
|
Vec3(x0, y1, z0),
|
|
Vec3(x0, y0, z1),
|
|
Vec3(x1, y0, z1),
|
|
Vec3(x1, y1, z1),
|
|
Vec3(x0, y1, z1),
|
|
)
|
|
else:
|
|
raise ValueError("empty bounding box")
|
|
|
|
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.
|
|
|
|
"""
|
|
new_bbox = self.__class__()
|
|
if not self.has_intersection(other):
|
|
return new_bbox
|
|
s_min_x, s_min_y, s_min_z = Vec3(self.extmin)
|
|
o_min_x, o_min_y, o_min_z = Vec3(other.extmin)
|
|
s_max_x, s_max_y, s_max_z = Vec3(self.extmax)
|
|
o_max_x, o_max_y, o_max_z = Vec3(other.extmax)
|
|
new_bbox.extend(
|
|
[
|
|
(
|
|
max(s_min_x, o_min_x),
|
|
max(s_min_y, o_min_y),
|
|
max(s_min_z, o_min_z),
|
|
),
|
|
(
|
|
min(s_max_x, o_max_x),
|
|
min(s_max_y, o_max_y),
|
|
min(s_max_z, o_max_z),
|
|
),
|
|
]
|
|
)
|
|
return new_bbox
|
|
|
|
|
|
class BoundingBox2d(AbstractBoundingBox[Vec2]):
|
|
"""2D bounding box.
|
|
|
|
Args:
|
|
vertices: iterable of ``(x, y[, z])`` tuples or :class:`Vec3` objects
|
|
|
|
"""
|
|
|
|
__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
|
|
size of 0 in any or all dimensions or is undefined.
|
|
"""
|
|
if self.has_data:
|
|
sx, sy = self.size
|
|
return sx * sy == 0.0
|
|
return True
|
|
|
|
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 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[T]) -> bool:
|
|
"""Returns ``True`` if this bounding box intersects with `other` but does
|
|
not include touching bounding boxes, see also :meth:`has_overlap`::
|
|
|
|
bbox1 = BoundingBox2d([(0, 0), (1, 1)])
|
|
bbox2 = BoundingBox2d([(1, 1), (2, 2)])
|
|
assert bbox1.has_intersection(bbox2) is False
|
|
|
|
"""
|
|
# Source: https://gamemath.com/book/geomtests.html#intersection_two_aabbs
|
|
if not self.has_data or not other.has_data:
|
|
return False
|
|
# Check for a separating axis:
|
|
if self.extmin.x >= other.extmax.x:
|
|
return False
|
|
if self.extmax.x <= other.extmin.x:
|
|
return False
|
|
if self.extmin.y >= other.extmax.y:
|
|
return False
|
|
if self.extmax.y <= other.extmin.y:
|
|
return False
|
|
return True
|
|
|
|
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.
|
|
"""
|
|
new_bbox = self.__class__()
|
|
if not self.has_intersection(other):
|
|
return new_bbox
|
|
s_min_x, s_min_y = Vec2(self.extmin)
|
|
o_min_x, o_min_y = Vec2(other.extmin)
|
|
s_max_x, s_max_y = Vec2(self.extmax)
|
|
o_max_x, o_max_y = Vec2(other.extmax)
|
|
new_bbox.extend(
|
|
[
|
|
(max(s_min_x, o_min_x), max(s_min_y, o_min_y)),
|
|
(min(s_max_x, o_max_x), min(s_max_y, o_max_y)),
|
|
]
|
|
)
|
|
return new_bbox
|
|
|
|
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::
|
|
|
|
bbox1 = BoundingBox2d([(0, 0), (1, 1)])
|
|
bbox2 = BoundingBox2d([(1, 1), (2, 2)])
|
|
assert bbox1.has_overlap(bbox2) is True
|
|
|
|
"""
|
|
# Source: https://gamemath.com/book/geomtests.html#intersection_two_aabbs
|
|
if not self.has_data or not other.has_data:
|
|
return False
|
|
# Check for a separating axis:
|
|
if self.extmin.x > other.extmax.x:
|
|
return False
|
|
if self.extmax.x < other.extmin.x:
|
|
return False
|
|
if self.extmin.y > other.extmax.y:
|
|
return False
|
|
if self.extmax.y < other.extmin.y:
|
|
return False
|
|
return True
|
|
|
|
|
|
def extents3d(vertices: Iterable[UVec]) -> tuple[Vec3, Vec3]:
|
|
"""Returns the extents of the bounding box as tuple (extmin, extmax)."""
|
|
vertices = np.array([Vec3(v).xyz for v in vertices], dtype=np.float64)
|
|
if len(vertices):
|
|
return Vec3(vertices.min(0)), Vec3(vertices.max(0))
|
|
else:
|
|
raise ValueError("no vertices given")
|
|
|
|
|
|
def extents2d(vertices: Iterable[UVec]) -> tuple[Vec2, Vec2]:
|
|
"""Returns the extents of the bounding box as tuple (extmin, extmax)."""
|
|
vertices = np.array([(x, y) for x, y, *_ in vertices], dtype=np.float64)
|
|
if len(vertices):
|
|
return Vec2(vertices.min(0)), Vec2(vertices.max(0))
|
|
else:
|
|
raise ValueError("no vertices given")
|