Added OrientedBoundBox to geometry and Shape.oriented_bounding_box

This commit is contained in:
gumyr 2025-02-04 13:51:56 -05:00
parent c728124b3b
commit 05ed5fd8e1
4 changed files with 350 additions and 1 deletions

View file

@ -127,6 +127,7 @@ __all__ = [
"Wedge",
# Direct API Classes
"BoundBox",
"OrientedBoundBox",
"Rotation",
"Rot",
"Pos",

View file

@ -1724,6 +1724,164 @@ class LocationEncoder(json.JSONEncoder):
return obj
class OrientedBoundBox:
"""
An Oriented Bounding Box
This class computes the oriented bounding box for a given build123d shape.
It exposes properties such as the center, principal axis directions, the
extents along these axes, and the full diagonal length of the box.
"""
def __init__(self, shape: Bnd_OBB | Shape):
"""
Create an oriented bounding box from either a precomputed Bnd_OBB or
a build123d Shape (which wraps a TopoDS_Shape).
Args:
shape (Bnd_OBB | Shape): Either a precomputed Bnd_OBB or a build123d shape
from which to compute the oriented bounding box.
"""
if isinstance(shape, Bnd_OBB):
obb = shape
else:
obb = Bnd_OBB()
# Compute the oriented bounding box for the shape.
BRepBndLib.AddOBB_s(shape.wrapped, obb, True)
self.wrapped = obb
@property
def diagonal(self) -> float:
"""
The full length of the body diagonal of the oriented bounding box,
which represents the maximum size of the object.
Returns:
float: The diagonal length.
"""
if self.wrapped is None:
return 0.0
return self.wrapped.SquareExtent() ** 0.5
@property
def plane(self) -> Plane:
"""
The oriented coordinate system of the bounding box.
Returns:
Plane: The coordinate system defined by the center and primary
(X) and tertiary (Z) directions of the bounding box.
"""
return Plane(
origin=self.center(), x_dir=self.x_direction, z_dir=self.z_direction
)
@property
def size(self) -> Vector:
"""
The full extents of the bounding box along its primary axes.
Returns:
Vector: The oriented size (full dimensions) of the box.
"""
return (
Vector(self.wrapped.XHSize(), self.wrapped.YHSize(), self.wrapped.ZHSize())
* 2.0
)
@property
def x_direction(self) -> Vector:
"""
The primary (X) direction of the oriented bounding box.
Returns:
Vector: The X direction as a unit vector.
"""
x_direction_xyz = self.wrapped.XDirection()
coords = [getattr(x_direction_xyz, attr)() for attr in ("X", "Y", "Z")]
return Vector(*coords)
@property
def y_direction(self) -> Vector:
"""
The secondary (Y) direction of the oriented bounding box.
Returns:
Vector: The Y direction as a unit vector.
"""
y_direction_xyz = self.wrapped.YDirection()
coords = [getattr(y_direction_xyz, attr)() for attr in ("X", "Y", "Z")]
return Vector(*coords)
@property
def z_direction(self) -> Vector:
"""
The tertiary (Z) direction of the oriented bounding box.
Returns:
Vector: The Z direction as a unit vector.
"""
z_direction_xyz = self.wrapped.ZDirection()
coords = [getattr(z_direction_xyz, attr)() for attr in ("X", "Y", "Z")]
return Vector(*coords)
def center(self) -> Vector:
"""
Compute and return the center point of the oriented bounding box.
Returns:
Vector: The center point of the box.
"""
center_xyz = self.wrapped.Center()
coords = [getattr(center_xyz, attr)() for attr in ("X", "Y", "Z")]
return Vector(*coords)
def is_completely_inside(self, other: OrientedBoundBox) -> bool:
"""
Determine whether the given oriented bounding box is entirely contained
within this bounding box.
This method checks that every point of 'other' lies strictly within the
boundaries of this box, according to the tolerance criteria inherent to the
underlying OCCT implementation.
Args:
other (OrientedBoundBox): The bounding box to test for containment.
Raises:
ValueError: If the 'other' bounding box has an uninitialized (null) underlying geometry.
Returns:
bool: True if 'other' is completely inside this bounding box; otherwise, False.
"""
if other.wrapped is None:
raise ValueError("Can't compare to a null obb")
return self.wrapped.IsCompletelyInside(other.wrapped)
def is_outside(self, point: Vector) -> bool:
"""
Determine whether a given point lies entirely outside this oriented bounding box.
A point is considered outside if it is neither inside the box nor on its surface,
based on the criteria defined by the OCCT implementation.
Args:
point (Vector): The point to test.
Raises:
ValueError: If the point's underlying geometry is not set (null).
Returns:
bool: True if the point is completely outside the bounding box; otherwise, False.
"""
if point.wrapped is None:
raise ValueError("Can't compare to a null point")
return self.wrapped.IsOut(point.to_pnt())
def __repr__(self) -> str:
return f"OrientedBoundBox(center={self.center()}, size={self.size}, plane={self.plane})"
class Rotation(Location):
"""Subclass of Location used only for object rotation

View file

@ -98,7 +98,7 @@ from OCP.BRepGProp import BRepGProp, BRepGProp_Face
from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.BRepTools import BRepTools
from OCP.Bnd import Bnd_Box
from OCP.Bnd import Bnd_Box, Bnd_OBB
from OCP.GProp import GProp_GProps
from OCP.Geom import Geom_Line
from OCP.GeomAPI import GeomAPI_ProjectPointOnSurf
@ -139,6 +139,7 @@ from build123d.geometry import (
Color,
Location,
Matrix,
OrientedBoundBox,
Plane,
Vector,
VectorLike,
@ -1503,6 +1504,16 @@ class Shape(NodeMixin, Generic[TOPODS]):
shape_copy.wrapped = tcast(TOPODS, downcast(self.wrapped.Moved(loc.wrapped)))
return shape_copy
def oriented_bounding_box(self) -> OrientedBoundBox:
"""Create an oriented bounding box for this Shape.
Returns:
OrientedBoundBox: A box oriented and sized to contain this Shape
"""
if self.wrapped is None:
return OrientedBoundBox(Bnd_OBB())
return OrientedBoundBox(self)
def project_faces(
self,
faces: list[Face] | Compound,

View file

@ -0,0 +1,179 @@
"""
build123d tests
name: test_oriented_bound_box.py
by: Gumyr
date: February 4, 2025
desc:
This python module contains tests for the build123d project.
license:
Copyright 2025 Gumyr
Licensed 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 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.
"""
import math
import re
import unittest
from build123d.geometry import Axis, OrientedBoundBox, Pos, Rot, Vector
from build123d.topology import Face, Solid
class TestOrientedBoundBox(unittest.TestCase):
def test_size_and_diagonal(self):
# Create a unit cube (with one corner at the origin).
cube = Solid.make_box(1, 1, 1)
obb = OrientedBoundBox(cube)
# The size property multiplies half-sizes by 2. For a unit cube, expect (1, 1, 1).
size = obb.size
self.assertAlmostEqual(size.X, 1.0, places=6)
self.assertAlmostEqual(size.Y, 1.0, places=6)
self.assertAlmostEqual(size.Z, 1.0, places=6)
# The full body diagonal should be sqrt(1^2+1^2+1^2) = sqrt(3).
expected_diag = math.sqrt(3)
self.assertAlmostEqual(obb.diagonal, expected_diag, places=6)
obb.wrapped = None
self.assertAlmostEqual(obb.diagonal, 0.0, places=6)
def test_center(self):
# For a cube made at the origin, the center should be at (0.5, 0.5, 0.5)
cube = Solid.make_box(1, 1, 1)
obb = OrientedBoundBox(cube)
center = obb.center()
self.assertAlmostEqual(center.X, 0.5, places=6)
self.assertAlmostEqual(center.Y, 0.5, places=6)
self.assertAlmostEqual(center.Z, 0.5, places=6)
def test_directions_are_unit_vectors(self):
# Create a rotated cube so the direction vectors are non-trivial.
cube = Rot(45, 45, 0) * Solid.make_box(1, 1, 1)
obb = OrientedBoundBox(cube)
# Check that each primary direction is a unit vector.
for direction in (obb.x_direction, obb.y_direction, obb.z_direction):
self.assertAlmostEqual(direction.length, 1.0, places=6)
def test_is_outside(self):
# For a unit cube, test a point inside and a point clearly outside.
cube = Solid.make_box(1, 1, 1)
obb = OrientedBoundBox(cube)
# Use the cube's center as an "inside" test point.
center = obb.center()
self.assertFalse(obb.is_outside(center))
# A point far away should be outside.
outside_point = Vector(10, 10, 10)
self.assertTrue(obb.is_outside(outside_point))
outside_point._wrapped = None
with self.assertRaises(ValueError):
obb.is_outside(outside_point)
def test_is_completely_inside(self):
# Create a larger cube and a smaller cube that is centered within it.
large_cube = Solid.make_box(2, 2, 2)
small_cube = Solid.make_box(1, 1, 1)
# Translate the small cube by (0.5, 0.5, 0.5) so its center is at (1,1,1),
# which centers it within the 2x2x2 cube (whose center is also at (1,1,1)).
small_cube = Pos(0.5, 0.5, 0.5) * small_cube
large_obb = OrientedBoundBox(large_cube)
small_obb = OrientedBoundBox(small_cube)
# The small box should be completely inside the larger box.
self.assertTrue(large_obb.is_completely_inside(small_obb))
# Conversely, the larger box cannot be completely inside the smaller one.
self.assertFalse(small_obb.is_completely_inside(large_obb))
large_obb.wrapped = None
with self.assertRaises(ValueError):
small_obb.is_completely_inside(large_obb)
def test_init_from_bnd_obb(self):
# Test that constructing from an already computed Bnd_OBB works as expected.
cube = Solid.make_box(1, 1, 1)
obb1 = OrientedBoundBox(cube)
# Create a new instance by passing the underlying wrapped object.
obb2 = OrientedBoundBox(obb1.wrapped)
# Compare diagonal, size, and center.
self.assertAlmostEqual(obb1.diagonal, obb2.diagonal, places=6)
size1 = obb1.size
size2 = obb2.size
self.assertAlmostEqual(size1.X, size2.X, places=6)
self.assertAlmostEqual(size1.Y, size2.Y, places=6)
self.assertAlmostEqual(size1.Z, size2.Z, places=6)
center1 = obb1.center()
center2 = obb2.center()
self.assertAlmostEqual(center1.X, center2.X, places=6)
self.assertAlmostEqual(center1.Y, center2.Y, places=6)
self.assertAlmostEqual(center1.Z, center2.Z, places=6)
def test_plane(self):
rect = Rot(Z=10) * Face.make_rect(1, 2)
obb = rect.oriented_bounding_box()
pln = obb.plane
self.assertAlmostEqual(
abs(pln.x_dir.dot(Vector(0, 1, 0).rotate(Axis.Z, 10))), 1.0, places=6
)
self.assertAlmostEqual(abs(pln.z_dir.dot(Vector(0, 0, 1))), 1.0, places=6)
def test_repr(self):
# Create a simple unit cube OBB.
obb = OrientedBoundBox(Solid.make_box(1, 1, 1))
rep = repr(obb)
# Check that the repr string contains expected substrings.
self.assertIn("OrientedBoundBox(center=Vector(", rep)
self.assertIn("size=Vector(", rep)
self.assertIn("plane=Plane(", rep)
# Use a regular expression to extract numbers.
pattern = (
r"OrientedBoundBox\(center=Vector\((?P<c0>[-\d\.]+), (?P<c1>[-\d\.]+), (?P<c2>[-\d\.]+)\), "
r"size=Vector\((?P<s0>[-\d\.]+), (?P<s1>[-\d\.]+), (?P<s2>[-\d\.]+)\), "
r"plane=Plane\(o=\((?P<o0>[-\d\.]+), (?P<o1>[-\d\.]+), (?P<o2>[-\d\.]+)\), "
r"x=\((?P<x0>[-\d\.]+), (?P<x1>[-\d\.]+), (?P<x2>[-\d\.]+)\), "
r"z=\((?P<z0>[-\d\.]+), (?P<z1>[-\d\.]+), (?P<z2>[-\d\.]+)\)\)\)"
)
m = re.match(pattern, rep)
self.assertIsNotNone(
m, "The __repr__ string did not match the expected format."
)
# Convert extracted strings to floats.
center = Vector(
float(m.group("c0")), float(m.group("c1")), float(m.group("c2"))
)
size = Vector(float(m.group("s0")), float(m.group("s1")), float(m.group("s2")))
# For a unit cube, we expect the center to be (0.5, 0.5, 0.5)
self.assertAlmostEqual(center.X, 0.5, places=6)
self.assertAlmostEqual(center.Y, 0.5, places=6)
self.assertAlmostEqual(center.Z, 0.5, places=6)
# And the full size to be approximately (1, 1, 1) (floating-point values may vary slightly).
self.assertAlmostEqual(size.X, 1.0, places=6)
self.assertAlmostEqual(size.Y, 1.0, places=6)
self.assertAlmostEqual(size.Z, 1.0, places=6)
if __name__ == "__main__":
unittest.main()