diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index ed41ce8e..1d1f4f1e 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -55,6 +55,7 @@ import copy import warnings from bisect import bisect_right from collections.abc import Iterable, Sequence +from dataclasses import dataclass from itertools import combinations from math import atan2, ceil, copysign, cos, floor, inf, isclose, pi, radians from typing import TYPE_CHECKING, Literal, overload @@ -62,6 +63,7 @@ from typing import cast as tcast import numpy as np import OCP.TopAbs as ta +from OCP.BOPAlgo import BOPAlgo_Splitter from OCP.BRep import BRep_Tool from OCP.BRepAdaptor import BRepAdaptor_CompCurve, BRepAdaptor_Curve from OCP.BRepAlgoAPI import ( @@ -69,6 +71,7 @@ from OCP.BRepAlgoAPI import ( BRepAlgoAPI_Section, BRepAlgoAPI_Splitter, ) + from OCP.BRepBuilderAPI import ( BRepBuilderAPI_DisconnectedWire, BRepBuilderAPI_EmptyWire, @@ -78,8 +81,10 @@ from OCP.BRepBuilderAPI import ( BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeWire, BRepBuilderAPI_NonManifoldWire, + BRepBuilderAPI_MakeVertex, ) from OCP.BRepExtrema import BRepExtrema_DistShapeShape, BRepExtrema_SupportType +from OCP.BRepFeat import BRepFeat_SplitShape from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet2d from OCP.BRepGProp import BRepGProp, BRepGProp_Face from OCP.BRepLib import BRepLib, BRepLib_FindSurface @@ -89,6 +94,7 @@ from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace from OCP.BRepProj import BRepProj_Projection from OCP.BRepTools import BRepTools, BRepTools_WireExplorer +from OCP.ChFi2d import ChFi2d_FilletAlgo from OCP.Extrema import Extrema_ExtPC from OCP.GC import ( GC_MakeArcOfCircle, @@ -267,6 +273,222 @@ if TYPE_CHECKING: # pragma: no cover from .two_d import Face, Shell # pylint: disable=R0801 +@dataclass(frozen=True) +class _WireFilletCorner: + """Context needed to fillet a single planar wire corner.""" + + wire: Wire + vertex: Vertex + surface: Geom_Plane + all_edges: ShapeList[Edge] + connected_edges: ShapeList[Edge] + connected_edge_indices: list[int] + + +@dataclass(frozen=True) +class _WireFilletSolution: + """Replacement edges for a filleted wire corner.""" + + trimmed_topods_edges: list[TopoDS_Edge] + fillet_topods_edge: TopoDS_Edge + + +def _analyze_wire_fillet_corner(wire: Wire, vertex: Vertex) -> _WireFilletCorner: + """Collect the topology needed to fillet a single wire corner.""" + + find_surface = BRepLib_FindSurface(wire.wrapped, OnlyPlane=True) + surf = find_surface.Surface() + if not isinstance(surf, Geom_Plane): + raise ValueError(f"Wire is not planar {wire}") + + all_edges = wire.edges() + connected_edges = all_edges.filter_by( + lambda edge: any( + edge_vertex.wrapped.IsSame(vertex.wrapped) + for edge_vertex in edge.vertices() + ) + ) + vertex_label = str(vertex) + if not connected_edges: + raise ValueError(f"Could not find shared vertex on wire: {vertex_label}") + if len(connected_edges) != 2: + raise ValueError(f"Vertex must connect exactly two edges: {vertex_label}") + + connected_edge_indices = [all_edges.index(e) for e in connected_edges] + + return _WireFilletCorner( + wire=wire, + vertex=vertex, + surface=surf, + all_edges=all_edges, + connected_edges=connected_edges, + connected_edge_indices=connected_edge_indices, + ) + + +def _solve_wire_fillet_corner_chfi2d( + corner: _WireFilletCorner, radius: float +) -> _WireFilletSolution | None: + """Try to fillet a planar wire corner with ``ChFi2d_FilletAlgo``.""" + + fillet_builder = ChFi2d_FilletAlgo() + fillet_builder.Init( + corner.connected_edges[0].wrapped, + corner.connected_edges[1].wrapped, + corner.surface.Pln(), + ) + + vertex_point = BRep_Tool.Pnt_s(corner.vertex.wrapped) + if ( + not fillet_builder.Perform(radius) + or fillet_builder.NbResults(vertex_point) == 0 + ): + return None + + trimmed_topods_edge0, trimmed_topods_edge1 = TopoDS_Edge(), TopoDS_Edge() + fillet_topods_edge = fillet_builder.Result( + vertex_point, trimmed_topods_edge0, trimmed_topods_edge1 + ) + + return _WireFilletSolution( + trimmed_topods_edges=[trimmed_topods_edge0, trimmed_topods_edge1], + fillet_topods_edge=fillet_topods_edge, + ) + + +def _topods_edge_contains_vertex( + topods_edge: TopoDS_Edge, topods_vertex: TopoDS_Vertex +) -> bool: + """Check whether an edge has the given vertex as one of its endpoints.""" + + edge_vertex0 = TopExp.FirstVertex_s(topods_edge) + edge_vertex1 = TopExp.LastVertex_s(topods_edge) + vertex_point = BRep_Tool.Pnt_s(topods_vertex) + return ( + BRep_Tool.Pnt_s(edge_vertex0).Distance(vertex_point) < TOLERANCE + or BRep_Tool.Pnt_s(edge_vertex1).Distance(vertex_point) < TOLERANCE + ) + + +def _split_edge_at_vertex(edge: Edge, split_vertex: Vertex) -> list[TopoDS_Edge]: + """Split an edge at a vertex and return the resulting edge segments.""" + + splitter = BOPAlgo_Splitter() + splitter.AddArgument(edge.wrapped) + splitter.AddTool(split_vertex.wrapped) + splitter.Perform() + + split_shape = splitter.Shape() + split_edges = [] + explorer = TopExp_Explorer(split_shape, ta.TopAbs_EDGE) + while explorer.More(): + split_edges.append(TopoDS.Edge(explorer.Current())) + explorer.Next() + + return split_edges + + +def _wire_fillet_corner_is_tangent_continuous(corner: _WireFilletCorner) -> bool: + """Determine if two incident edges are tangent-continuous at a corner.""" + + tangent0 = corner.connected_edges[0].tangent_at(corner.vertex) + tangent1 = corner.connected_edges[1].tangent_at(corner.vertex) + return tangent0.cross(tangent1).length <= TOLERANCE + + +def _solve_wire_fillet_corner_geom2dgcc_circ2d2tanrad( + corner: _WireFilletCorner, radius: float +) -> _WireFilletSolution | None: + """Fallback fillet using the ``Geom2dGcc_Circ2d2TanRad`` tangent-arc solver.""" + + fillet_arcs = _make_2tan_rad_arcs( + *corner.connected_edges, + radius=radius, + sagitta=Sagitta.BOTH, + edge_factory=Edge, + ) + if not fillet_arcs: + return None + + fillet_arc = fillet_arcs.sort_by_distance(corner.vertex)[0] + + trimmed_topods_edges = [] + for connected_edge in corner.connected_edges: + other_vertex = [v for v in connected_edge.vertices() if v != corner.vertex][0] + fillet_vertex = fillet_arc.vertices().sort_by_distance(connected_edge)[0] + split_vertex = Vertex( + BRepBuilderAPI_MakeVertex(Vector(fillet_vertex).to_pnt()).Vertex() + ) + split_edges = _split_edge_at_vertex(copy.deepcopy(connected_edge), split_vertex) + trimmed_topods_edges.append( + next( + edge + for edge in split_edges + if _topods_edge_contains_vertex(edge, other_vertex.wrapped) + ) + ) + + return _WireFilletSolution( + trimmed_topods_edges=trimmed_topods_edges, + fillet_topods_edge=fillet_arc.wrapped, + ) + + +def _splice_wire_fillet_corner( + corner: _WireFilletCorner, solution: _WireFilletSolution +) -> Wire: + """Replace two connected edges with a fillet and rebuild the wire.""" + + all_topods_edges = [edge.wrapped for edge in corner.all_edges] + + # Flip any edges that were reversed during trimming + for i in range(2): + if ( + solution.trimmed_topods_edges[i].Orientation() + != corner.connected_edges[i].wrapped.Orientation() + ): + solution.trimmed_topods_edges[i].Reverse() + + for i in range(2): + all_topods_edges[corner.connected_edge_indices[i]] = ( + solution.trimmed_topods_edges[i] + ) + + n = len(all_topods_edges) + if corner.connected_edge_indices[1] == (corner.connected_edge_indices[0] + 1) % n: + insert_index = corner.connected_edge_indices[0] + 1 + else: + insert_index = corner.connected_edge_indices[1] + 1 + + all_topods_edges.insert(insert_index, solution.fillet_topods_edge) + + combined_edges = TopTools_ListOfShape() + for topods_edge in all_topods_edges: + combined_edges.Append(topods_edge) + wire_builder = BRepBuilderAPI_MakeWire() + wire_builder.Add(combined_edges) + wire_builder.Build() + + return Wire(wire_builder.Wire()) + + +def _fillet_wire_corner(wire: Wire, vertex: Vertex, radius: float) -> Wire: + """Fillet a single planar wire corner with the available 2D fillet solvers.""" + + corner = _analyze_wire_fillet_corner(wire, vertex) + if _wire_fillet_corner_is_tangent_continuous(corner): + return wire + vertex_label = str(vertex) + solution = _solve_wire_fillet_corner_chfi2d(corner, radius) + if solution is None: + solution = _solve_wire_fillet_corner_geom2dgcc_circ2d2tanrad(corner, radius) + if solution is None: + raise ValueError( + f"Fillet algorithm failed for {vertex_label} with radius {radius}" + ) + return _splice_wire_fillet_corner(corner, solution) + + class Mixin1D(Shape[TOPODS]): """Methods to add to the Edge and Wire classes""" @@ -3785,20 +4007,18 @@ class Wire(Mixin1D[TopoDS_Wire]): """ if self._wrapped is None: raise ValueError("Can't fillet an empty wire") - - # Create a face to fillet - unfilleted_face = _make_topods_face_from_wires(self.wrapped) - # Fillet the face - fillet_builder = BRepFilletAPI_MakeFillet2d(unfilleted_face) + filleted_wire = self for vertex in vertices: - if vertex.wrapped is not None: - fillet_builder.AddFillet(vertex.wrapped, radius) - fillet_builder.Build() - filleted_face = downcast(fillet_builder.Shape()) - if not isinstance(filleted_face, TopoDS_Face): - raise RuntimeError("An internal error occured creating the fillet") - # Return the outer wire - return Wire(BRepTools.OuterWire_s(filleted_face)) + if vertex.wrapped is None: + continue + current_vertices = filleted_wire.vertices().sort_by_distance(vertex) + if not current_vertices: + raise ValueError(f"Could not find fillet vertex on wire: {vertex}") + current_vertex = current_vertices[0] + if not isclose_b((Vector(current_vertex) - Vector(vertex)).length, 0.0): + raise ValueError(f"Could not find fillet vertex on wire: {vertex}") + filleted_wire = _fillet_wire_corner(filleted_wire, current_vertex, radius) + return filleted_wire def fix_degenerate_edges(self, precision: float) -> Wire: """fix_degenerate_edges diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 4eb05c2a..da702d62 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -1836,15 +1836,32 @@ class Face(Mixin2D[TopoDS_Face]): Returns: """ + vertices = [vertex for vertex in vertices if vertex.wrapped is not None] + if not vertices: + return self - fillet_builder = BRepFilletAPI_MakeFillet2d(self.wrapped) + outer_wire = self.outer_wire() + inner_wires = self.inner_wires() + filleted_wires: list[Wire] = [] - for vertex in vertices: - fillet_builder.AddFillet(vertex.wrapped, radius) + for wire in [outer_wire, *inner_wires]: + vertices_in_wire = [ + vertex + for vertex in vertices + if any( + wire_vertex.wrapped.IsSame(vertex.wrapped) + for wire_vertex in wire.vertices() + ) + ] + filleted_wires.append( + wire.fillet_2d(radius, vertices_in_wire) if vertices_in_wire else wire + ) - fillet_builder.Build() + filleted_face = self.__class__(filleted_wires[0], filleted_wires[1:]) + if self.normal_at() != filleted_face.normal_at(): + filleted_face = -filleted_face - return self.__class__.cast(fillet_builder.Shape()) + return filleted_face def geom_adaptor(self) -> Geom_Surface: """Return the Geom Surface for this Face""" diff --git a/tests/test_build_generic.py b/tests/test_build_generic.py index ac02ca56..6f1139a8 100644 --- a/tests/test_build_generic.py +++ b/tests/test_build_generic.py @@ -594,8 +594,8 @@ class OffsetTests(unittest.TestCase): ] line = FilletPolyline(*pts, radius=3.177) self.assertEqual(len(line.edges()), 11) - o_line = offset(line, amount=3.177) - self.assertEqual(len(o_line.edges()), 19) + o_line = offset(line, amount=2) + self.assertEqual(len(o_line.edges()), 26) def test_offset_face_with_inner_wire(self): # offset amount causes the inner wire to have zero length diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index 92b78d88..3ee4137a 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -39,8 +39,8 @@ from OCP.Geom import Geom_RectangularTrimmedSurface from OCP.GeomAPI import GeomAPI_ExtremaCurveCurve from OCP.Geom import Geom_CylindricalSurface, Geom_OffsetSurface -from build123d.build_common import Locations, PolarLocations -from build123d.build_enums import Align, CenterOf, ContinuityLevel, GeomType, Keep +from build123d.build_common import GridLocations, Locations, PolarLocations +from build123d.build_enums import Align, CenterOf, ContinuityLevel, GeomType, Keep, Mode from build123d.build_line import BuildLine from build123d.build_part import BuildPart from build123d.build_sketch import BuildSketch @@ -61,7 +61,7 @@ from build123d.objects_sketch import ( 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, Shell, Solid, Wire +from build123d.topology import Edge, Face, Shell, Sketch, Solid, Wire class TestFace(unittest.TestCase): @@ -133,6 +133,45 @@ class TestFace(unittest.TestCase): distance=1, distance2=2, vertices=[vertex], edge=other_edge ) + def test_fillet_2d_mixed_profile_case(self): + sketch = Sketch() + Rectangle(10, 20) + Ellipse(20, 5) + vertex = sketch.vertices().group_by(Axis.X)[0].sort_by(Axis.Y)[0] + + filleted = sketch.faces()[0].fillet_2d(1.0, [vertex]) + + self.assertTrue(filleted.is_valid) + self.assertGreater(filleted.area, 0) + self.assertGreaterEqual(len(filleted.edges().filter_by(GeomType.CIRCLE)), 1) + + def test_fillet_2d_mixed_profile_regression(self): + sketch = Sketch() + Rectangle(10, 20) + Ellipse(20, 5) + vertex = sketch.vertices().group_by(Axis.X)[1].sort_by(Axis.Y)[1] + + filleted = sketch.faces()[0].fillet_2d(1.0, [vertex]) + + self.assertTrue(filleted.is_valid) + self.assertGreater(filleted.area, 0) + self.assertGreaterEqual(len(filleted.edges().filter_by(GeomType.CIRCLE)), 1) + + def test_fillet_2d_holes_regression(self): + with BuildSketch() as sketch_builder: + Ellipse(x_radius=74 / 2, y_radius=54 / 2) + with GridLocations(49, 32, 2, 2): + Circle(12 / 2, mode=Mode.SUBTRACT) + vertex = sketch_builder.vertices().sort_by_distance((30, 20))[0] + original = sketch_builder.face() + + filleted = original.fillet_2d(2.0, [vertex]) + + self.assertTrue(filleted.is_valid) + self.assertGreater(filleted.area, 0) + self.assertLess(filleted.area, original.area) + + def test_fillet_geom2dgcc_circ2d2tanrad_algorithm(self): + r = Rectangle(6, 6) - Pos(1, 1) * Circle(2) - Pos(3, 3) * Rectangle(4, 4) + filleted = r.face().fillet_2d(0.2, r.vertices()) + self.assertEqual(len(filleted.edges().filter_by(GeomType.CIRCLE)), 6) + def test_plane_as_face(self): test_face = Face(Plane.XY) self.assertAlmostEqual(test_face.normal_at(), (0, 0, 1), 5) diff --git a/tests/test_direct_api/test_wire.py b/tests/test_direct_api/test_wire.py index b9f069ba..6d6bfc8c 100644 --- a/tests/test_direct_api/test_wire.py +++ b/tests/test_direct_api/test_wire.py @@ -33,14 +33,15 @@ from unittest.mock import patch, MagicMock import numpy as np from build123d.topology.shape_core import TOLERANCE +import build123d.topology.one_d as one_d from build123d.build_enums import GeomType, PositionMode, Side from build123d.build_line import BuildLine from build123d.geometry import Axis, Color, Location, Plane, Vector -from build123d.objects_curve import Curve, Line, PolarLine, Polyline, Spline +from build123d.objects_curve import Curve, Line, JernArc, PolarLine, Polyline, Spline from build123d.objects_sketch import Circle, Rectangle, RegularPolygon from build123d.operations_generic import fillet -from build123d.topology import Edge, Face, Wire +from build123d.topology import Edge, Face, Vertex, Wire from OCP.BRepAdaptor import BRepAdaptor_CompCurve from OCP.gp import gp_Pnt @@ -69,6 +70,16 @@ class TestWire(unittest.TestCase): self.assertAlmostEqual( squaroid.length, 4 * (1 - 2 * 0.1) + 2 * math.pi * 0.1, 5 ) + straight_wire = Wire( + [ + Edge.make_line((0, 0), (1, 0)), + Edge.make_line((1, 0), (2, 0)), + ] + ) + straight_vertex = straight_wire.vertices().sort_by_distance((1, 0, 0))[0] + unmodified_wire = straight_wire.fillet_2d(0.1, [straight_vertex]) + self.assertAlmostEqual(unmodified_wire.length, straight_wire.length, 5) + self.assertEqual(len(unmodified_wire.edges()), 2) square.wrapped = None with self.assertRaises(ValueError): square.fillet_2d(0.1, square.vertices()) @@ -224,18 +235,14 @@ class TestWire(unittest.TestCase): self.assertAlmostEqual(param, i / 20, 6) def test_tangent_at_reversed_edges(self): - with BuildLine(Plane.YZ) as wing_line: - l1 = Line((0, 65), (80 / 2 + 1.526 * 4, 65)) - PolarLine( - l1 @ 1, 20.371288916, direction=Vector(0, 1, 0).rotate(Axis.X, -75) - ) - fillet(wing_line.vertices(), 7) - - w = wing_line.wire() - self.assertAlmostEqual( - w.tangent_at(0), (0, -0.2588190451025, 0.9659258262891), 6 + w = Wire( + [ + Line((0, 0), (0, 1)), + JernArc((0, 1), (0, 1), 1, -90).reversed(reconstruct=True), + ] ) - self.assertAlmostEqual(w.tangent_at(1), (0, -1, 0), 6) + self.assertAlmostEqual(w.tangent_at(0), (0, 1, 0), 6) + self.assertAlmostEqual(w.tangent_at(1), (1, 0, 0), 6) def test_order_edges(self): w1 = Wire( @@ -288,6 +295,77 @@ class TestWire(unittest.TestCase): Wire(bob="fred") +class TestWireFilletHelpers(unittest.TestCase): + def test_analyze_wire_fillet_corner_non_planar(self): + wire = Wire( + [ + Edge.make_line((0, 0, 0), (1, 0, 0)), + Edge.make_line((1, 0, 0), (1, 1, 1)), + ] + ) + vertex = wire.vertices().sort_by_distance((1, 0, 0))[0] + + mock_find_surface = MagicMock() + mock_find_surface.Surface.return_value = object() + + with ( + patch( + "build123d.topology.one_d.BRepLib_FindSurface", + return_value=mock_find_surface, + ), + self.assertRaises(ValueError) as ctx, + ): + one_d._analyze_wire_fillet_corner(wire, vertex) + self.assertIn("Wire is not planar", str(ctx.exception)) + + def test_analyze_wire_fillet_corner_missing_vertex(self): + wire = Wire.make_rect(1, 1) + vertex = Vertex((10, 10, 0)) + + with self.assertRaises(ValueError) as ctx: + one_d._analyze_wire_fillet_corner(wire, vertex) + self.assertIn("Could not find shared vertex on wire", str(ctx.exception)) + + def test_analyze_wire_fillet_corner_vertex_not_degree_two(self): + wire = Wire.make_rect(1, 1) + shared_vertex = wire.vertices()[0] + mock_edges = MagicMock() + mock_edges.filter_by.return_value = [MagicMock(), MagicMock(), MagicMock()] + + with ( + patch.object(wire, "edges", return_value=mock_edges), + self.assertRaises(ValueError) as ctx, + ): + one_d._analyze_wire_fillet_corner(wire, shared_vertex) + self.assertIn("Vertex must connect exactly two edges", str(ctx.exception)) + + def test_solve_wire_fillet_corner_geom2dgcc_circ2d2tanrad_no_solution(self): + wire = Wire.make_rect(1, 1) + corner = one_d._analyze_wire_fillet_corner(wire, wire.vertices()[0]) + self.assertIsNone( + one_d._solve_wire_fillet_corner_geom2dgcc_circ2d2tanrad(corner, 10) + ) + + def test_fillet_wire_corner_failure_when_all_solvers_fail(self): + wire = Wire.make_rect(1, 1) + vertex = wire.vertices()[0] + + with ( + patch( + "build123d.topology.one_d._solve_wire_fillet_corner_chfi2d", + return_value=None, + ), + patch( + "build123d.topology.one_d._solve_wire_fillet_corner_geom2dgcc_circ2d2tanrad", + return_value=None, + ), + ): + with self.assertRaises(ValueError) as ctx: + one_d._fillet_wire_corner(wire, vertex, 0.1) + + self.assertIn("Fillet algorithm failed", str(ctx.exception)) + + class TestWireToBSpline(unittest.TestCase): def setUp(self): # A simple rectilinear, multi-segment wire: