Adding OrientedBoundBox.corners and Face.axes_of_symmetry

This commit is contained in:
gumyr 2025-02-06 10:29:31 -05:00
parent 72bbc433f0
commit 4e42ccb196
4 changed files with 334 additions and 6 deletions

View file

@ -35,11 +35,12 @@ from __future__ import annotations
# too-many-arguments, too-many-locals, too-many-public-methods,
# too-many-statements, too-many-instance-attributes, too-many-branches
import copy as copy_module
import itertools
import json
import logging
import numpy as np
from math import degrees, pi, radians
from math import degrees, pi, radians, isclose
from typing import Any, overload, TypeAlias, TYPE_CHECKING
from collections.abc import Iterable, Sequence
@ -1753,6 +1754,44 @@ class OrientedBoundBox:
BRepBndLib.AddOBB_s(shape.wrapped, obb, True)
self.wrapped = obb
@property
def corners(self) -> list[Vector]:
"""
Compute and return the unique corner points of the oriented bounding box
in the coordinate system defined by the OBB's plane.
For degenerate shapes (e.g. a line or a planar face), only the unique
points are returned. For 2D shapes the corners are returned in an order
that allows a polygon to be directly created from them.
Returns:
list[Vector]: The unique corner points.
"""
# Build a dictionary keyed by a tuple indicating if each axis is degenerate.
orders = {
# Straight line cases
(True, True, False): [(1, 1, 1), (1, 1, -1)],
(True, False, True): [(1, 1, 1), (1, -1, 1)],
(False, True, True): [(1, 1, 1), (-1, 1, 1)],
# Planar face cases
(True, False, False): [(1, 1, 1), (1, 1, -1), (1, -1, -1), (1, -1, 1)],
(False, True, False): [(1, 1, 1), (1, 1, -1), (-1, 1, -1), (-1, 1, 1)],
(False, False, True): [(1, 1, 1), (1, -1, 1), (-1, -1, 1), (-1, 1, 1)],
# 3D object case
(False, False, False): [
(x, y, z) for x, y, z in itertools.product((-1, 1), (-1, 1), (-1, 1))
],
}
hs = self.size * 0.5
order = orders[(hs.X < TOLERANCE, hs.Y < TOLERANCE, hs.Z < TOLERANCE)]
local_corners = [
Vector(sx * hs.X, sy * hs.Y, sz * hs.Z) for sx, sy, sz in order
]
corners = [self.plane.from_local_coords(c) for c in local_corners]
return corners
@property
def diagonal(self) -> float:
"""
@ -1766,6 +1805,16 @@ class OrientedBoundBox:
return 0.0
return self.wrapped.SquareExtent() ** 0.5
@property
def location(self) -> Location:
"""
The Location of the center of the oriented bounding box.
Returns:
Location: center location
"""
return Location(self.plane)
@property
def plane(self) -> Plane:
"""

View file

@ -93,12 +93,13 @@ from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Solid
from OCP.gce import gce_MakeLin
from OCP.gp import gp_Pnt, gp_Vec
from build123d.build_enums import CenterOf, GeomType, SortBy, Transition
from build123d.build_enums import CenterOf, GeomType, Keep, SortBy, Transition
from build123d.geometry import (
TOLERANCE,
Axis,
Color,
Location,
OrientedBoundBox,
Plane,
Vector,
VectorLike,
@ -354,6 +355,104 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
# ---- Properties ----
@property
def axes_of_symmetry(self) -> list[Axis]:
"""Computes and returns the axes of symmetry for a planar face.
The method determines potential symmetry axes by analyzing the faces
geometry:
- It first validates that the face is non-empty and planar.
- For faces with inner wires (holes), it computes the centroid of the
holes and the face's overall center (COG).
If the holes' centroid significantly deviates from the COG (beyond
a specified tolerance), the symmetry axis is taken along the line
connecting these points; otherwise, each holes center is used to
generate a candidate axis.
- For faces without holes, candidate directions are derived by sampling
midpoints along the outer wire's edges.
If curved edges are present, additional candidate directions are
obtained from an oriented bounding box (OBB) constructed around the
face.
For each candidate direction, the face is split by a plane (defined
using the candidate direction and the faces normal). The top half of the face
is then mirrored across this plane, and if the area of the intersection between
the mirrored half and the bottom half matches the bottom halfs area within a
small tolerance, the direction is accepted as an axis of symmetry.
Returns:
list[Axis]: A list of Axis objects, each defined by the face's
center and a direction vector, representing the symmetry axes of
the face.
Raises:
ValueError: If the face or its underlying representation is empty.
ValueError: If the face is not planar.
"""
if self.wrapped is None:
raise ValueError("Can't determine axes_of_symmetry of empty face")
if not self.is_planar_face:
raise ValueError("axes_of_symmetry only supports for planar faces")
cog = self.center()
normal = self.normal_at()
shape_inner_wires = self.inner_wires()
if shape_inner_wires:
hole_faces = [Face(w) for w in shape_inner_wires]
holes_centroid = Face.combined_center(hole_faces)
# If the holes aren't centered on the cog the axis of symmetry must be
# through the cog and hole centroid
if abs(holes_centroid - cog) > TOLERANCE:
cross_dirs = [(holes_centroid - cog).normalized()]
else:
# There may be an axis of symmetry through the center of the holes
cross_dirs = [(f.center() - cog).normalized() for f in hole_faces]
else:
curved_edges = (
self.outer_wire().edges().filter_by(GeomType.LINE, reverse=True)
)
shape_edges = self.outer_wire().edges()
if curved_edges:
obb = OrientedBoundBox(self)
corners = obb.corners
obb_edges = ShapeList(
[Edge.make_line(corners[i], corners[(i + 1) % 4]) for i in range(4)]
)
mid_points = [
e @ p for e in shape_edges + obb_edges for p in [0.0, 0.5, 1.0]
]
else:
mid_points = [e @ p for e in shape_edges for p in [0.0, 0.5, 1.0]]
cross_dirs = [(mid_point - cog).normalized() for mid_point in mid_points]
symmetry_dirs: set[Vector] = set()
for cross_dir in cross_dirs:
# Split the face by the potential axis and flip the top
split_plane = Plane(
origin=cog,
x_dir=cross_dir,
z_dir=cross_dir.cross(normal),
)
top, bottom = self.split(split_plane, keep=Keep.BOTH)
top_flipped = top.mirror(split_plane)
# Are the top/bottom the same?
if abs(bottom.intersect(top_flipped).area - bottom.area) < TOLERANCE:
# If this axis isn't in the set already add it
if not symmetry_dirs:
symmetry_dirs.add(cross_dir)
else:
opposite = any(
d.dot(cross_dir) < -1 + TOLERANCE for d in symmetry_dirs
)
if not opposite:
symmetry_dirs.add(cross_dir)
symmetry_axes = [Axis(cog, d) for d in symmetry_dirs]
return symmetry_axes
@property
def center_location(self) -> Location:
"""Location at the center of face"""

View file

@ -32,7 +32,7 @@ import platform
import random
import unittest
from build123d.build_common import Locations
from build123d.build_common import Locations, PolarLocations
from build123d.build_enums import Align, CenterOf, GeomType
from build123d.build_line import BuildLine
from build123d.build_part import BuildPart
@ -42,7 +42,13 @@ from build123d.geometry import Axis, Location, Plane, Pos, Vector
from build123d.importers import import_stl
from build123d.objects_curve import Polyline
from build123d.objects_part import Box, Cylinder
from build123d.objects_sketch import Rectangle, RegularPolygon
from build123d.objects_sketch import (
Circle,
Ellipse,
Rectangle,
RegularPolygon,
Triangle,
)
from build123d.operations_generic import fillet
from build123d.operations_part import extrude
from build123d.operations_sketch import make_face
@ -482,6 +488,65 @@ class TestFace(unittest.TestCase):
frame.wrapped = None
self.assertAlmostEqual(frame.total_area, 0.0, 5)
def test_axes_of_symmetry(self):
# Empty shape
shape = Face.make_rect(1, 1)
shape.wrapped = None
with self.assertRaises(ValueError):
shape.axes_of_symmetry
# Non planar
shape = Solid.make_cylinder(1, 2).faces().filter_by(GeomType.CYLINDER)[0]
with self.assertRaises(ValueError):
shape.axes_of_symmetry
# Test a variety of shapes
shapes = [
Rectangle(1, 1),
Rectangle(1, 2, align=Align.MIN),
Rectangle(1, 2, rotation=10),
Rectangle(1, 2, align=Align.MIN) - Pos(0.5, 0.75) * Circle(0.2),
(Rectangle(1, 2, align=Align.MIN) - Pos(0.5, 0.75) * Circle(0.2)).rotate(
Axis.Z, 10
),
Triangle(a=1, b=0.5, C=90),
Circle(2) - Pos(0.1) * Rectangle(0.5, 0.5),
Circle(2) - Pos(0.1, 0.1) * Rectangle(0.5, 0.5),
Circle(2) - (Pos(0.1, 0.1) * PolarLocations(1, 3)) * Circle(0.3),
Circle(2) - (Pos(0.5) * PolarLocations(1, 3)) * Circle(0.3),
Circle(2) - PolarLocations(1, 3) * Circle(0.3),
Ellipse(1, 2, rotation=10),
]
shape_dir = [
[(-1, 1), (-1, 0), (-1, -1), (0, -1)],
[(-1, 0), (0, -1)],
[Vector(-1, 0).rotate(Axis.Z, 10), Vector(0, -1).rotate(Axis.Z, 10)],
[(0, -1)],
[Vector(0, -1).rotate(Axis.Z, 10)],
[],
[(1, 0)],
[(1, 1)],
[],
[(1, 0)],
[
(1, 0),
Vector(1, 0).rotate(Axis.Z, 120),
Vector(1, 0).rotate(Axis.Z, 240),
],
[Vector(1, 0).rotate(Axis.Z, 10), Vector(0, 1).rotate(Axis.Z, 10)],
]
for i, shape in enumerate(shapes):
test_face: Face = shape.face()
cog = test_face.center()
axes = test_face.axes_of_symmetry
target_axes = [Axis(cog, d) for d in shape_dir[i]]
self.assertEqual(len(target_axes), len(axes))
axes_dirs = sorted(tuple(a.direction) for a in axes)
target_dirs = sorted(tuple(a.direction) for a in target_axes)
self.assertTrue(all(a == t) for a, t in zip(axes_dirs, target_dirs))
self.assertTrue(all(a.position == cog) for a in axes)
if __name__ == "__main__":
unittest.main()

View file

@ -30,8 +30,10 @@ import math
import re
import unittest
from build123d.geometry import Axis, OrientedBoundBox, Pos, Rot, Vector
from build123d.topology import Face, Solid
from build123d.geometry import Location, OrientedBoundBox, Plane, Pos, Rot, Vector
from build123d.topology import Edge, Face, Solid
from build123d.objects_part import Box
from build123d.objects_sketch import Polygon
class TestOrientedBoundBox(unittest.TestCase):
@ -175,6 +177,119 @@ class TestOrientedBoundBox(unittest.TestCase):
self.assertAlmostEqual(size.Y, 1.0, places=6)
self.assertAlmostEqual(size.Z, 1.0, places=6)
def test_rotated_cube_corners(self):
# Create a cube of size 2x2x2 rotated by 45 degrees around each axis.
rotated_cube = Rot(45, 45, 45) * Box(2, 2, 2)
# Compute the oriented bounding box.
obb = OrientedBoundBox(rotated_cube)
corners = obb.corners
# There should be eight unique corners.
self.assertEqual(len(corners), 8)
# The center of the cube should be at or near the origin.
center = obb.center()
# For a cube with full side lengths 2, the half-size is 1,
# so the distance from the center to any corner is sqrt(1^2 + 1^2 + 1^2) = sqrt(3).
expected_distance = math.sqrt(3)
# Verify that each corner is at the expected distance from the center.
for corner in corners:
distance = (corner - center).length
self.assertAlmostEqual(distance, expected_distance, places=6)
def test_planar_face_corners(self):
"""
Test that a planar face returns four unique corner points.
"""
# Create a square face of size 2x2 (centered at the origin).
face = Face.make_rect(2, 2)
# Compute the oriented bounding box from the face.
obb = OrientedBoundBox(face)
corners = obb.corners
# Convert each Vector to a tuple (rounded for tolerance reasons)
unique_points = {
(round(pt.X, 6), round(pt.Y, 6), round(pt.Z, 6)) for pt in corners
}
# For a planar (2D) face, we expect 4 unique corners.
self.assertEqual(
len(unique_points),
4,
f"Expected 4 unique corners for a planar face but got {len(unique_points)}",
)
# Check orientation
for pln in [Plane.XY, Plane.XZ, Plane.YZ]:
rect = Face.make_rect(1, 2, pln)
obb = OrientedBoundBox(rect)
corners = obb.corners
poly = Polygon(*corners, align=None)
self.assertAlmostEqual(rect.intersect(poly).area, rect.area, 5)
for face in Box(1, 2, 3).faces():
obb = OrientedBoundBox(face)
corners = obb.corners
poly = Polygon(*corners, align=None)
self.assertAlmostEqual(face.intersect(poly).area, face.area, 5)
def test_line_corners(self):
"""
Test that a straight line returns two unique endpoints.
"""
# Create a straight line from (0, 0, 0) to (1, 0, 0).
line = Edge.make_line(Vector(0, 0, 0), Vector(1, 0, 0))
# Compute the oriented bounding box from the line.
obb = OrientedBoundBox(line)
corners = obb.corners
# Convert each Vector to a tuple (rounded for tolerance)
unique_points = {
(round(pt.X, 6), round(pt.Y, 6), round(pt.Z, 6)) for pt in corners
}
# For a line, we expect only 2 unique endpoints.
self.assertEqual(
len(unique_points),
2,
f"Expected 2 unique corners for a line but got {len(unique_points)}",
)
# Check orientation
for end in [(1, 0, 0), (0, 1, 0), (0, 0, 1)]:
line = Edge.make_line((0, 0, 0), end)
obb = OrientedBoundBox(line)
corners = obb.corners
self.assertEqual(len(corners), 2)
self.assertTrue(Vector(end) in corners)
def test_location(self):
# Create a unit cube.
cube = Solid.make_box(1, 1, 1)
obb = OrientedBoundBox(cube)
# Get the location property (constructed from the plane).
loc = obb.location
# Check that loc is a Location instance.
self.assertIsInstance(loc, Location)
# Compare the location's origin with the oriented bounding box center.
center = obb.center()
self.assertAlmostEqual(loc.position.X, center.X, places=6)
self.assertAlmostEqual(loc.position.Y, center.Y, places=6)
self.assertAlmostEqual(loc.position.Z, center.Z, places=6)
# Optionally, if the Location preserves the plane's orientation,
# check that the x and z directions match those of the obb's plane.
plane = obb.plane
self.assertAlmostEqual(loc.x_axis.direction.X, plane.x_dir.X, places=6)
self.assertAlmostEqual(loc.x_axis.direction.Y, plane.x_dir.Y, places=6)
self.assertAlmostEqual(loc.x_axis.direction.Z, plane.x_dir.Z, places=6)
self.assertAlmostEqual(loc.z_axis.direction.X, plane.z_dir.X, places=6)
self.assertAlmostEqual(loc.z_axis.direction.Y, plane.z_dir.Y, places=6)
self.assertAlmostEqual(loc.z_axis.direction.Z, plane.z_dir.Z, places=6)
if __name__ == "__main__":
unittest.main()