Skip to content

Commit

Permalink
Adding Face.radii, Face.is_circular_convex, Face.is_circular_concave,…
Browse files Browse the repository at this point in the history
… rename Face.rotational_axis to Face.axis_of_rotation
  • Loading branch information
gumyr committed Feb 19, 2025
1 parent ffc3eba commit 0208621
Show file tree
Hide file tree
Showing 2 changed files with 172 additions and 17 deletions.
99 changes: 88 additions & 11 deletions src/build123d/topology/two_d.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,20 @@ def area_without_holes(self) -> float:

return self.without_holes().area

@property
def axis_of_rotation(self) -> None | Axis:
"""Get the rotational axis of a cylinder or torus"""
if type(self.geom_adaptor()) == Geom_RectangularTrimmedSurface:
return None

if self.geom_type == GeomType.CYLINDER:
return Axis(self.geom_adaptor().Cylinder().Axis())

if self.geom_type == GeomType.TORUS:
return Axis(self.geom_adaptor().Torus().Axis())

return None

@property
def axes_of_symmetry(self) -> list[Axis]:
"""Computes and returns the axes of symmetry for a planar face.
Expand Down Expand Up @@ -535,6 +549,69 @@ def geometry(self) -> None | str:

return result

@property
def _curvature_sign(self) -> float:
"""
Compute the signed dot product between the face normal and the vector from the
underlying geometry's reference point to the face center.
For a cylinder, the reference is the cylinder’s axis position.
For a sphere, it is the sphere’s center.
For a torus, we derive a reference point on the central circle.
Returns:
float: The signed value; positive indicates convexity, negative indicates concavity.
Returns 0 if the geometry type is unsupported.
"""
if self.geom_type == GeomType.CYLINDER:
axis = self.axis_of_rotation
if axis is None:
raise ValueError("Can't find curvature of empty object")
return self.normal_at().dot(self.center() - axis.position)

elif self.geom_type == GeomType.SPHERE:
loc = self.location # The sphere's center
if loc is None:
raise ValueError("Can't find curvature of empty object")
return self.normal_at().dot(self.center() - loc.position)

elif self.geom_type == GeomType.TORUS:
# Here we assume that for a torus the rotational axis can be converted to a plane,
# and we then define the central (or core) circle using the first value of self.radii.
axis = self.axis_of_rotation
if axis is None or self.radii is None:
raise ValueError("Can't find curvature of empty object")
loc = Location(axis.to_plane())
axis_circle = Edge.make_circle(self.radii[0]).locate(loc)
_, pnt_on_axis_circle, _ = axis_circle.distance_to_with_closest_points(
self.center()
)
return self.normal_at().dot(self.center() - pnt_on_axis_circle)

return 0.0

@property
def is_circular_convex(self) -> bool:
"""
Determine whether a given face is convex relative to its underlying geometry
for supported geometries: cylinder, sphere, torus.
Returns:
bool: True if convex; otherwise, False.
"""
return self._curvature_sign > TOLERANCE

@property
def is_circular_concave(self) -> bool:
"""
Determine whether a given face is concave relative to its underlying geometry
for supported geometries: cylinder, sphere, torus.
Returns:
bool: True if concave; otherwise, False.
"""
return self._curvature_sign < -TOLERANCE

@property
def is_planar(self) -> bool:
"""Is the face planar even though its geom_type may not be PLANE"""
Expand All @@ -551,6 +628,17 @@ def length(self) -> None | float:
result = face_vertices[-1].X - face_vertices[0].X
return result

@property
def radii(self) -> None | tuple[float, float]:
"""Return the major and minor radii of a torus otherwise None"""
if self.geom_type == GeomType.TORUS:
return (
self.geom_adaptor().MajorRadius(),
self.geom_adaptor().MinorRadius(),
)

return None

@property
def radius(self) -> None | float:
"""Return the radius of a cylinder or sphere, otherwise None"""
Expand All @@ -562,17 +650,6 @@ def radius(self) -> None | float:
else:
return None

@property
def rotational_axis(self) -> None | Axis:
"""Get the rotational axis of a cylinder"""
if (
self.geom_type == GeomType.CYLINDER
and type(self.geom_adaptor()) != Geom_RectangularTrimmedSurface
):
return Axis(self.geom_adaptor().Cylinder().Axis())
else:
return None

@property
def volume(self) -> float:
"""volume - the volume of this Face, which is always zero"""
Expand Down
90 changes: 84 additions & 6 deletions tests/test_direct_api/test_face.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
import random
import unittest

from unittest.mock import patch, PropertyMock
from OCP.Geom import Geom_RectangularTrimmedSurface
from build123d.build_common import Locations, PolarLocations
from build123d.build_enums import Align, CenterOf, GeomType
from build123d.build_line import BuildLine
Expand All @@ -41,15 +43,15 @@
from build123d.geometry import Axis, Location, Plane, Pos, Vector
from build123d.importers import import_stl
from build123d.objects_curve import Line, Polyline, Spline, ThreePointArc
from build123d.objects_part import Box, Cylinder, Sphere
from build123d.objects_part import Box, Cylinder, Sphere, Torus
from build123d.objects_sketch import (
Circle,
Ellipse,
Rectangle,
RegularPolygon,
Triangle,
)
from build123d.operations_generic import fillet
from build123d.operations_generic import fillet, offset
from build123d.operations_part import extrude
from build123d.operations_sketch import make_face
from build123d.topology import Edge, Face, Solid, Wire
Expand Down Expand Up @@ -741,16 +743,92 @@ def test_radius_property(self):
self.assertAlmostEqual(s.radius, 3, 5)
self.assertIsNone(b.radius)

def test_rotational_axis_property(self):
def test_axis_of_rotation_property(self):
c = (
Cylinder(1.5, 2, rotation=(90, 0, 0))
.faces()
.filter_by(GeomType.CYLINDER)[0]
)
s = Sphere(3).faces().filter_by(GeomType.SPHERE)[0]
self.assertAlmostEqual(c.rotational_axis.direction, (0, -1, 0), 5)
self.assertAlmostEqual(c.rotational_axis.position, (0, 1, 0), 5)
self.assertIsNone(s.rotational_axis)
self.assertAlmostEqual(c.axis_of_rotation.direction, (0, -1, 0), 5)
self.assertAlmostEqual(c.axis_of_rotation.position, (0, 1, 0), 5)
self.assertIsNone(s.axis_of_rotation)

@patch.object(
Face,
"geom_adaptor",
return_value=Geom_RectangularTrimmedSurface(
Face.make_rect(1, 1).geom_adaptor(), 0.0, 1.0, True
),
)
def test_axis_of_rotation_property_error(self, mock_is_valid):
c = (
Cylinder(1.5, 2, rotation=(90, 0, 0))
.faces()
.filter_by(GeomType.CYLINDER)[0]
)
self.assertIsNone(c.axis_of_rotation)
# Verify is_valid was called
mock_is_valid.assert_called_once()

def test_is_convex_concave(self):

with BuildPart() as open_box:
Box(20, 20, 5)
offset(amount=-2, openings=open_box.faces().sort_by(Axis.Z)[-1])
fillet(open_box.edges(), 0.5)

outside_fillets = open_box.faces().filter_by(Face.is_circular_convex)
inside_fillets = open_box.faces().filter_by(Face.is_circular_concave)
self.assertEqual(len(outside_fillets), 28)
self.assertEqual(len(inside_fillets), 12)

@patch.object(
Face, "axis_of_rotation", new_callable=PropertyMock, return_value=None
)
def test_is_convex_concave_error0(self, mock_is_valid):
with BuildPart() as open_box:
Box(20, 20, 5)
offset(amount=-2, openings=open_box.faces().sort_by(Axis.Z)[-1])
fillet(open_box.edges(), 0.5)

with self.assertRaises(ValueError):
open_box.faces().filter_by(Face.is_circular_convex)

# Verify is_valid was called
mock_is_valid.assert_called_once()

@patch.object(Face, "radii", new_callable=PropertyMock, return_value=None)
def test_is_convex_concave_error1(self, mock_is_valid):
with BuildPart() as open_box:
Box(20, 20, 5)
offset(amount=-2, openings=open_box.faces().sort_by(Axis.Z)[-1])
fillet(open_box.edges(), 0.5)

with self.assertRaises(ValueError):
open_box.faces().filter_by(Face.is_circular_convex)

# Verify is_valid was called
mock_is_valid.assert_called_once()

@patch.object(Face, "location", new_callable=PropertyMock, return_value=None)
def test_is_convex_concave_error2(self, mock_is_valid):
with BuildPart() as open_box:
Box(20, 20, 5)
offset(amount=-2, openings=open_box.faces().sort_by(Axis.Z)[-1])
fillet(open_box.edges(), 0.5)

with self.assertRaises(ValueError):
open_box.faces().filter_by(Face.is_circular_convex)

# Verify is_valid was called
mock_is_valid.assert_called_once()

def test_radii(self):
t = Torus(5, 1).face()
self.assertAlmostEqual(t.radii, (5, 1), 5)
s = Sphere(1).face()
self.assertIsNone(s.radii)


class TestAxesOfSymmetrySplitNone(unittest.TestCase):
Expand Down

0 comments on commit 0208621

Please sign in to comment.