mirror of
https://github.com/gumyr/build123d.git
synced 2025-12-06 02:30:55 -08:00
Adding OrientedBoundBox.corners and Face.axes_of_symmetry
This commit is contained in:
parent
72bbc433f0
commit
4e42ccb196
4 changed files with 334 additions and 6 deletions
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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 face’s
|
||||
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 hole’s 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 face’s 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 half’s 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"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue