diff --git a/src/build123d/topology/__init__.py b/src/build123d/topology/__init__.py index abee547..2bf01d3 100644 --- a/src/build123d/topology/__init__.py +++ b/src/build123d/topology/__init__.py @@ -51,8 +51,16 @@ from .utils import ( find_max_dimension, ) from .zero_d import Vertex, topo_explore_common_vertex -from .one_d import Edge, Wire, Mixin1D, edges_to_wires, topo_explore_connected_edges -from .two_d import Face, Shell, Mixin2D,sort_wires_by_build_order +from .one_d import ( + Edge, + Wire, + Mixin1D, + edges_to_wires, + topo_explore_connected_edges, + offset_topods_face, + topo_explore_connected_faces, +) +from .two_d import Face, Shell, Mixin2D, sort_wires_by_build_order from .three_d import Solid, Mixin3D from .composite import Compound, Curve, Sketch, Part @@ -79,7 +87,9 @@ __all__ = [ "Edge", "Wire", "edges_to_wires", + "offset_topods_face", "topo_explore_connected_edges", + "topo_explore_connected_faces", "Face", "Shell", "sort_wires_by_build_order", diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index b5a3acf..366cf51 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -66,7 +66,11 @@ from scipy.spatial import ConvexHull import OCP.TopAbs as ta from OCP.BRep import BRep_Tool from OCP.BRepAdaptor import BRepAdaptor_CompCurve, BRepAdaptor_Curve -from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Splitter +from OCP.BRepAlgoAPI import ( + BRepAlgoAPI_Common, + BRepAlgoAPI_Section, + BRepAlgoAPI_Splitter, +) from OCP.BRepBuilderAPI import ( BRepBuilderAPI_DisconnectedWire, BRepBuilderAPI_EmptyWire, @@ -80,6 +84,7 @@ from OCP.BRepExtrema import BRepExtrema_DistShapeShape from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet2d from OCP.BRepGProp import BRepGProp, BRepGProp_Face from OCP.BRepLib import BRepLib, BRepLib_FindSurface +from OCP.BRepOffset import BRepOffset_MakeOffset from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace from OCP.BRepProj import BRepProj_Projection @@ -134,6 +139,7 @@ from OCP.TopoDS import ( TopoDS, TopoDS_Compound, TopoDS_Edge, + TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Wire, @@ -223,6 +229,34 @@ class Mixin1D(Shape): raise ValueError("Can't determine direction of empty Edge or Wire") return self.wrapped.Orientation() == TopAbs_Orientation.TopAbs_FORWARD + @property + def is_interior(self) -> bool: + """ + Check if the edge is an interior edge. + + An interior edge lies between surfaces that are part of the body (internal + to the geometry) and does not form part of the exterior boundary. + + Returns: + bool: True if the edge is an interior edge, False otherwise. + """ + # Find the faces connected to this edge and offset them + topods_face_pair = topo_explore_connected_faces(self) + offset_face_pair = [ + offset_topods_face(f, self.length / 100) for f in topods_face_pair + ] + + # Intersect the offset faces + sectionor = BRepAlgoAPI_Section( + offset_face_pair[0], offset_face_pair[1], PerformNow=False + ) + sectionor.Build() + face_intersection_result = sectionor.Shape() + + # If an edge was created the faces intersect and the edge is interior + explorer = TopExp_Explorer(face_intersection_result, ta.TopAbs_EDGE) + return explorer.More() + @property def length(self) -> float: """Edge or Wire length""" @@ -3004,6 +3038,15 @@ def edges_to_wires(edges: Iterable[Edge], tol: float = 1e-6) -> ShapeList[Wire]: return wires +def offset_topods_face(face: TopoDS_Face, amount: float) -> TopoDS_Shape: + """Offset a topods_face""" + offsetor = BRepOffset_MakeOffset() + offsetor.Initialize(face, Offset=amount, Tol=TOLERANCE) + offsetor.MakeOffsetShape() + + return offsetor.Shape() + + def topo_explore_connected_edges( edge: Edge, parent: Shape | None = None ) -> ShapeList[Edge]: @@ -3029,3 +3072,31 @@ def topo_explore_connected_edges( connected_edges.add(topods_edge) return ShapeList(Edge(e) for e in connected_edges) + + +def topo_explore_connected_faces( + edge: Edge, parent: Shape | None = None +) -> list[TopoDS_Face]: + """Given an edge extracted from a Shape, return the topods_faces connected to it""" + + parent = parent if parent is not None else edge.topo_parent + if parent is None: + raise ValueError("edge has no valid parent") + + # make a edge --> faces mapping + edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape() + TopExp.MapShapesAndAncestors_s( + parent.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, edge_face_map + ) + + # Query the map + faces = [] + if edge_face_map.Contains(edge.wrapped): + face_list = edge_face_map.FindFromKey(edge.wrapped) + for face in face_list: + faces.append(TopoDS.Face_s(face)) + + if len(faces) != 2: + raise RuntimeError("Invalid # of faces connected to this edge") + + return faces diff --git a/tests/test_direct_api/test_edge.py b/tests/test_direct_api/test_edge.py index 3589ec0..3eb2da5 100644 --- a/tests/test_direct_api/test_edge.py +++ b/tests/test_direct_api/test_edge.py @@ -29,9 +29,11 @@ license: import math import unittest -from build123d.build_enums import AngularDirection +from build123d.build_enums import AngularDirection, GeomType, Transition from build123d.geometry import Axis, Plane, Vector from build123d.objects_curve import CenterArc, EllipticalCenterArc +from build123d.objects_sketch import Circle, Rectangle, RegularPolygon +from build123d.operations_generic import sweep from build123d.topology import Edge @@ -284,6 +286,14 @@ class TestEdge(unittest.TestCase): with self.assertRaises(TypeError): Edge(direction=(1, 0, 0)) + def test_is_interior(self): + path = RegularPolygon(5, 5).face().outer_wire() + profile = path.location_at(0) * (Circle(0.6) & Rectangle(2, 1)) + target = sweep(profile, path, transition=Transition.RIGHT) + inside_edges = target.edges().filter_by(lambda e: e.is_interior) + self.assertEqual(len(inside_edges), 5) + self.assertTrue(all(e.geom_type == GeomType.ELLIPSE for e in inside_edges)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_topo_explore.py b/tests/test_topo_explore.py index 75b4e3a..8524795 100644 --- a/tests/test_topo_explore.py +++ b/tests/test_topo_explore.py @@ -1,6 +1,11 @@ from typing import Optional import unittest +from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace +from OCP.GProp import GProp_GProps +from OCP.BRepGProp import BRepGProp +from OCP.gp import gp_Pnt, gp_Pln +from OCP.TopoDS import TopoDS_Face, TopoDS_Shape from build123d.build_enums import SortBy from build123d.objects_part import Box @@ -12,8 +17,11 @@ from build123d.geometry import ( from build123d.topology import ( Edge, Face, + Shell, Wire, + offset_topods_face, topo_explore_connected_edges, + topo_explore_connected_faces, topo_explore_common_vertex, ) @@ -78,6 +86,17 @@ class TestTopoExplore(DirectApiTestCase): connected_edges = topo_explore_connected_edges(face.edges()[0]) self.assertEqual(len(connected_edges), 1) + def test_topo_explore_connected_edges_errors(self): + # No parent case + with self.assertRaises(ValueError): + topo_explore_connected_edges(Edge()) + + # Null edge case + null_edge = Wire.make_rect(1, 1).edges()[0] + null_edge.wrapped = None + with self.assertRaises(ValueError): + topo_explore_connected_edges(null_edge) + def test_topo_explore_common_vertex(self): triangle = Face( Wire( @@ -98,5 +117,70 @@ class TestTopoExplore(DirectApiTestCase): ) +class TestOffsetTopodsFace(unittest.TestCase): + def setUp(self): + # Create a simple planar face for testing + self.face = Face.make_rect(1, 1).wrapped + + def get_face_center(self, face: TopoDS_Face) -> tuple: + """Calculate the center of a face""" + props = GProp_GProps() + BRepGProp.SurfaceProperties_s(face, props) + center = props.CentreOfMass() + return (center.X(), center.Y(), center.Z()) + + def test_offset_topods_face(self): + # Offset the face by a positive amount + offset_amount = 1.0 + original_center = self.get_face_center(self.face) + offset_shape = offset_topods_face(self.face, offset_amount) + offset_center = self.get_face_center(offset_shape) + self.assertIsInstance(offset_shape, TopoDS_Shape) + self.assertAlmostEqual(Vector(0, 0, 1), offset_center) + + # Offset the face by a negative amount + offset_amount = -1.0 + offset_shape = offset_topods_face(self.face, offset_amount) + offset_center = self.get_face_center(offset_shape) + self.assertIsInstance(offset_shape, TopoDS_Shape) + self.assertAlmostEqual(Vector(0, 0, -1), offset_center) + + def test_offset_topods_face_zero(self): + # Offset the face by zero amount + offset_amount = 0.0 + original_center = self.get_face_center(self.face) + offset_shape = offset_topods_face(self.face, offset_amount) + offset_center = self.get_face_center(offset_shape) + self.assertIsInstance(offset_shape, TopoDS_Shape) + self.assertAlmostEqual(Vector(original_center), offset_center) + + +class TestTopoExploreConnectedFaces(unittest.TestCase): + def setUp(self): + # Create a shell with 4 faces + walls = Shell.extrude(Wire.make_rect(1, 1), (0, 0, 1)) + diagonal = Axis((0, 0, 0), (1, 1, 0)) + + # Extract the edge that is connected to two faces + self.connected_edge = walls.edges().filter_by(Axis.Z).sort_by(diagonal)[-1] + + # Create an edge that is only connected to one face + self.unconnected_edge = Face.make_rect(1, 1).edges()[0] + + def test_topo_explore_connected_faces(self): + # Add the edge to the faces + faces = topo_explore_connected_faces(self.connected_edge) + self.assertEqual(len(faces), 2) + + def test_topo_explore_connected_faces_invalid(self): + # Test with an edge that is not connected to two faces + with self.assertRaises(RuntimeError): + topo_explore_connected_faces(self.unconnected_edge) + + # No parent case + with self.assertRaises(ValueError): + topo_explore_connected_faces(Edge()) + + if __name__ == "__main__": unittest.main()