From 0208621fd770e9b95917c71e14fd4659aeb7912b Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 19 Feb 2025 11:20:06 -0500 Subject: [PATCH] Adding Face.radii, Face.is_circular_convex, Face.is_circular_concave, rename Face.rotational_axis to Face.axis_of_rotation --- src/build123d/topology/two_d.py | 99 ++++++++++++++++++++++++++---- tests/test_direct_api/test_face.py | 90 +++++++++++++++++++++++++-- 2 files changed, 172 insertions(+), 17 deletions(-) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 6536b523..353fc395 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -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. @@ -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""" @@ -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""" @@ -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""" diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index e3a9f6b9..4b0557ca 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -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 @@ -41,7 +43,7 @@ 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, @@ -49,7 +51,7 @@ 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 @@ -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):