Improved fillet_2d Issue #720 #958 #379 #314

This commit is contained in:
Roger Maitland 2026-04-12 15:04:07 -04:00
parent 11a017ead7
commit 7dea02e814
5 changed files with 390 additions and 36 deletions

View file

@ -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

View file

@ -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"""

View file

@ -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

View file

@ -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)

View file

@ -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: