# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause
# LibFile: pybosl2/bounds.py
# FileSummary: Axis-aligned bounding boxes (Bounds2D / Bounds3D).
# DocCategory: Math & geometry
# FileGroup: BOSL2
"""Axis-aligned bounding boxes for 2-D and 3-D geometry.
:class:`Bounds2D` and :class:`Bounds3D` are what **every** ``bounds()`` in pybosl2 returns
(SPEC S-2b) -- shapes on either backend, :class:`~pybosl2.path2d.Path2D`,
:class:`~pybosl2.path3d.Path3D`, :class:`~pybosl2.regions.Region` and :class:`~pybosl2.vnf.VNF`.
One name, one meaning: ``bounds()`` answers a box, and the box carries every spelling of itself
so no caller has to do the arithmetic and no implementation has to pick a winner::
box = cuboid([40, 30, 20]).bounds()
box.min, box.max # corners, as Points
box.center, box.size # centre and extent
box.width # or .length / .height
They used to disagree -- shapes answered a bare ``(centre, size)`` pair, paths and meshes a
dataclass, regions a NumPy array -- so ``lo, hi = solid.bounds()``, the obvious reading of the
name, silently bound a *centre* to ``lo``. These types are the single answer.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from pybosl2.points import Point
if TYPE_CHECKING:
from collections.abc import Sequence
__all__ = ["Bounds2D", "Bounds3D"]
[docs]
@dataclass(frozen=True)
class Bounds2D:
"""Axis-aligned bounding box of a 2-D path.
Returned by :meth:`~pybosl2.paths.Path2D.bounds` with the min/max
corners and pre-computed width and length.
"""
min_x: float
min_y: float
max_x: float
max_y: float
width: float
length: float
@property
def center(self) -> Point:
"""The (x, y) centre of the bounding box as a 2‑D :class:`Point`."""
return Point((self.min_x + self.max_x) / 2, (self.min_y + self.max_y) / 2)
@property
def size(self) -> tuple[float, float]:
"""The (width, length) of the bounding box."""
return (self.width, self.length)
@property
def min(self) -> Point:
"""The lower corner as a 2-D :class:`~pybosl2.points.Point`."""
return Point(self.min_x, self.min_y)
@property
def max(self) -> Point:
"""The upper corner as a 2-D :class:`~pybosl2.points.Point`."""
return Point(self.max_x, self.max_y)
[docs]
@classmethod
def from_min_max(cls, lo: "Sequence[float]", hi: "Sequence[float]") -> "Bounds2D":
"""Build from the two opposite corners.
Args:
lo: the lower corner, ``[x, y]``.
hi: the upper corner, ``[x, y]``.
Returns:
The bounding box, with width and length derived.
Examples:
>>> Bounds2D.from_min_max([0, 0], [10, 5]).size
(10.0, 5.0)
"""
return cls(
min_x=float(lo[0]),
min_y=float(lo[1]),
max_x=float(hi[0]),
max_y=float(hi[1]),
width=float(hi[0]) - float(lo[0]),
length=float(hi[1]) - float(lo[1]),
)
[docs]
@classmethod
def from_center_size(cls, center: "Sequence[float]", size: "Sequence[float]") -> "Bounds2D":
"""Build from a centre point and an extent.
This is the form the native backends report, so it is the conversion every shape's
``bounds()`` goes through rather than doing the halving inline.
Args:
center: the box centre, ``[x, y]``.
size: the box extent, ``[width, length]``.
Returns:
The bounding box, with the corners derived.
Examples:
>>> Bounds2D.from_center_size([0, 0], [10, 5]).min_x
-5.0
"""
half = [float(size[0]) / 2, float(size[1]) / 2]
return cls.from_min_max(
[float(center[0]) - half[0], float(center[1]) - half[1]],
[float(center[0]) + half[0], float(center[1]) + half[1]],
)
[docs]
@dataclass(frozen=True)
class Bounds3D:
"""Axis-aligned bounding box of a 3-D path or solid.
Returned by :meth:`~pybosl2.paths.Path3D.bounds` and solid bounding-box
methods with the min/max corners and pre-computed width, length, and
height.
"""
min_x: float
min_y: float
min_z: float
max_x: float
max_y: float
max_z: float
width: float
length: float
height: float
@property
def center(self) -> Point:
"""The (x, y, z) centre of the bounding box as a 3‑D :class:`Point`."""
return Point(
(self.min_x + self.max_x) / 2,
(self.min_y + self.max_y) / 2,
(self.min_z + self.max_z) / 2,
)
@property
def size(self) -> tuple[float, float, float]:
"""The (width, length, height) of the bounding box."""
return (self.width, self.length, self.height)
@property
def min(self) -> Point:
"""The lower corner as a 3-D :class:`~pybosl2.points.Point`."""
return Point(self.min_x, self.min_y, self.min_z)
@property
def max(self) -> Point:
"""The upper corner as a 3-D :class:`~pybosl2.points.Point`."""
return Point(self.max_x, self.max_y, self.max_z)
[docs]
@classmethod
def from_min_max(cls, lo: "Sequence[float]", hi: "Sequence[float]") -> "Bounds3D":
"""Build from the two opposite corners.
Args:
lo: the lower corner, ``[x, y, z]``.
hi: the upper corner, ``[x, y, z]``.
Returns:
The bounding box, with width, length and height derived.
Examples:
>>> Bounds3D.from_min_max([0, 0, 0], [10, 5, 2]).size
(10.0, 5.0, 2.0)
"""
return cls(
min_x=float(lo[0]),
min_y=float(lo[1]),
min_z=float(lo[2]),
max_x=float(hi[0]),
max_y=float(hi[1]),
max_z=float(hi[2]),
width=float(hi[0]) - float(lo[0]),
length=float(hi[1]) - float(lo[1]),
height=float(hi[2]) - float(lo[2]),
)
[docs]
@classmethod
def from_center_size(cls, center: "Sequence[float]", size: "Sequence[float]") -> "Bounds3D":
"""Build from a centre point and an extent.
This is the form the native backends report, so it is the conversion every solid's
``bounds()`` goes through rather than doing the halving inline.
Args:
center: the box centre, ``[x, y, z]``.
size: the box extent, ``[width, length, height]``.
Returns:
The bounding box, with the corners derived.
Examples:
>>> Bounds3D.from_center_size([0, 0, 0], [40, 30, 20]).min_z
-10.0
"""
half = [float(size[i]) / 2 for i in range(3)]
return cls.from_min_max(
[float(center[i]) - half[i] for i in range(3)],
[float(center[i]) + half[i] for i in range(3)],
)