# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Basic 2-dimensional geometric constructs: points, rectangles, etc. """
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterator, Sequence, Tuple, Union
[docs]@dataclass(order=True, frozen=True)
class Point:
"""Represents a point in 2-dimensional plane (e.g. image)."""
x: float
y: float
def __add__(self, other: Point) -> Point:
return Point(self.x + other.x, self.y + other.y)
def __sub__(self, other: Point) -> Point:
return Point(self.x - other.x, self.y - other.y)
def __mul__(self, scalar: float) -> Point:
return Point(self.x * scalar, self.y * scalar)
def __iter__(self) -> Iterator[float]:
return (data for data in (self.x, self.y))
@staticmethod
def zero() -> Point:
return Point(0, 0)
[docs]@dataclass(frozen=True)
class Rectangle:
"""Represents a rectangle in a 2-dimensional plane."""
minima: Point
maxima: Point
def __iter__(self) -> Iterator[float]:
return (data for data in (self.x, self.y, self.width, self.height))
def __contains__(self, other: Union[Point, Rectangle]) -> bool:
return self.contains(other)
[docs] def contains(self, other: Union[Point, Rectangle]) -> bool:
"""
Tests whether the rectangle contains ``other``.
:return: ``True`` if other contained in the rectangle, ``False`` otherwise.
"""
if isinstance(other, Point):
return self.minima.x <= other.x <= self.maxima.x and self.minima.y <= other.y <= self.maxima.y
if isinstance(other, Rectangle):
return (
self.maxima.x >= other.maxima.x
and self.maxima.y >= other.maxima.y
and self.minima.x <= other.minima.x
and self.minima.y <= other.minima.y
)
raise TypeError(f"Expected a Rectangle or a Point, not {type(other)}")
[docs] def clip(self, other: Rectangle) -> Rectangle:
"""
Return a new rectangle generated by clipping this one by the bounds of ``other``.
Similar to intersection, but clipping non-intersecting rectangles will result in a degenerate rectangle
located on one of the edges of ``other``
:return: New, clipped ``Rectangle`` (possibly degenerate)
"""
return Rectangle.from_list(
min(max(self.minima.x, other.minima.x), other.maxima.x),
min(max(self.minima.y, other.minima.y), other.maxima.y),
min(max(self.maxima.x, other.minima.x), other.maxima.x),
min(max(self.maxima.y, other.minima.y), other.maxima.y),
)
@property
def x(self) -> float:
"""The x-coordinate of the lower left vertex"""
return self.minima.x
@property
def y(self) -> float:
"""The y-coordinate of the lower-left vertex"""
return self.minima.y
@property
def bounds(self) -> Tuple[float, float, float, float]:
"""Returns min x, min y, max x, max y"""
return self.minima.x, self.minima.y, self.maxima.x, self.maxima.y
@property
def center(self) -> Point:
"""Return the midpoint of the rectangle"""
return (self.minima + self.maxima) * 0.5
@property
def width(self) -> float:
return self.maxima.x - self.minima.x
@property
def height(self) -> float:
return self.maxima.y - self.minima.y
@property
def area(self) -> float:
return self.width * self.height
[docs] @staticmethod
def empty() -> Rectangle:
"""Returns a rectangle of zero area at origo."""
return Rectangle.from_list(0, 0, 0, 0)
[docs] def resized(self, delta: float) -> Rectangle:
"""
Returns a resized rectangle, shrinked (inflated for ``delta<0``) by ``2*delta`` in width and in height.
"""
assert 2 * delta >= -min(self.width, self.height), "Operation only defined for delta >= 2*min(width, height)"
delta_point = Point(delta, delta)
return Rectangle(self.minima - delta_point, self.maxima + delta_point)
def __add__(self, vector: Point) -> Rectangle:
"""Returns a copy of the rectangle translated by ``vector``"""
return Rectangle(self.minima + vector, self.maxima + vector)
def __sub__(self, vector: Point) -> Rectangle:
"""Returns a copy of the rectangle inversly translated by ``vector``"""
return Rectangle(self.minima - vector, self.maxima - vector)
def __repr__(self):
return f"({self.minima}), ({self.maxima})"
[docs] @staticmethod
def bounding_box(rectangles: Sequence[Rectangle]) -> Rectangle:
"""Computes the bounding box of ``rectangles``"""
if not rectangles:
raise ValueError("Expected a non-empty sequence of rectangles")
max_x = max(rect.maxima.x for rect in rectangles)
max_y = max(rect.maxima.y for rect in rectangles)
min_x = min(rect.minima.x for rect in rectangles)
min_y = min(rect.minima.y for rect in rectangles)
return Rectangle.from_list(max_x, max_y, min_x, min_y)
[docs] @staticmethod
def intersection(rectangles: Sequence[Rectangle]) -> Rectangle:
"""
Computes the rectangle which is the intersection of a sequence of ``rectangles``. In case the
intersection is empty, it returns an empty rectangle.
"""
if not rectangles or len(rectangles) < 2:
raise ValueError("Expected a sequence of at least two rectangles")
if Rectangle.empty() in rectangles:
return Rectangle.empty()
max_x = min(rect.maxima.x for rect in rectangles)
max_y = min(rect.maxima.y for rect in rectangles)
min_x = max(rect.minima.x for rect in rectangles)
min_y = max(rect.minima.y for rect in rectangles)
if max_x < min_x or max_y < min_y:
return Rectangle.empty()
return Rectangle.from_list(max_x, max_y, min_x, min_y)
[docs] @staticmethod
def centered_at(center: Point, radius: float) -> Rectangle:
"""
A new square centered at ``center`` with side length ``radius``. (The technically correct term here is
`Apothem <https://en.wikipedia.org/wiki/Apothem>`_.)
"""
if radius <= 0:
raise ValueError(f"Cannot instantiate rectangle with non-positive side {2 * radius} centered at {center}")
radius_point = Point(radius, radius)
return Rectangle(center - radius_point, center + radius_point)
[docs] @staticmethod
def from_list(*args) -> Rectangle:
"""Converts tuple/list (x1, y1, x2, y2) to a Rectangle"""
if len(args) == 1:
args = args[0]
if len(args) == 4 and all(isinstance(el, (int, float)) for el in args):
first_x, first_y, second_x, second_y = args
else:
raise ValueError("Invalid argument(s) to create rectangle from!")
minima = Point(min(first_x, second_x), min(first_y, second_y))
maxima = Point(max(first_x, second_x), max(first_y, second_y))
return Rectangle(minima, maxima)