mirror of
https://github.com/gumyr/build123d.git
synced 2026-01-03 15:53:56 -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",
|
"Wedge",
|
||||||
# Direct API Classes
|
# Direct API Classes
|
||||||
"BoundBox",
|
"BoundBox",
|
||||||
|
"OrientedBoundBox",
|
||||||
"Rotation",
|
"Rotation",
|
||||||
"Rot",
|
"Rot",
|
||||||
"Pos",
|
"Pos",
|
||||||
|
|
|
||||||
|
|
@ -1724,6 +1724,164 @@ class LocationEncoder(json.JSONEncoder):
|
||||||
return obj
|
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):
|
class Rotation(Location):
|
||||||
"""Subclass of Location used only for object rotation
|
"""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.BRepIntCurveSurface import BRepIntCurveSurface_Inter
|
||||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
from OCP.BRepTools import BRepTools
|
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.GProp import GProp_GProps
|
||||||
from OCP.Geom import Geom_Line
|
from OCP.Geom import Geom_Line
|
||||||
from OCP.GeomAPI import GeomAPI_ProjectPointOnSurf
|
from OCP.GeomAPI import GeomAPI_ProjectPointOnSurf
|
||||||
|
|
@ -139,6 +139,7 @@ from build123d.geometry import (
|
||||||
Color,
|
Color,
|
||||||
Location,
|
Location,
|
||||||
Matrix,
|
Matrix,
|
||||||
|
OrientedBoundBox,
|
||||||
Plane,
|
Plane,
|
||||||
Vector,
|
Vector,
|
||||||
VectorLike,
|
VectorLike,
|
||||||
|
|
@ -1503,6 +1504,16 @@ class Shape(NodeMixin, Generic[TOPODS]):
|
||||||
shape_copy.wrapped = tcast(TOPODS, downcast(self.wrapped.Moved(loc.wrapped)))
|
shape_copy.wrapped = tcast(TOPODS, downcast(self.wrapped.Moved(loc.wrapped)))
|
||||||
return shape_copy
|
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(
|
def project_faces(
|
||||||
self,
|
self,
|
||||||
faces: list[Face] | Compound,
|
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