mirror of
https://github.com/gumyr/build123d.git
synced 2025-12-06 02:30:55 -08:00
Added OrientedBoundBox to geometry and Shape.oriented_bounding_box
This commit is contained in:
parent
c728124b3b
commit
05ed5fd8e1
4 changed files with 350 additions and 1 deletions
|
|
@ -127,6 +127,7 @@ __all__ = [
|
|||
"Wedge",
|
||||
# Direct API Classes
|
||||
"BoundBox",
|
||||
"OrientedBoundBox",
|
||||
"Rotation",
|
||||
"Rot",
|
||||
"Pos",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
179
tests/test_direct_api/test_oriented_bound_box.py
Normal file
179
tests/test_direct_api/test_oriented_bound_box.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue