From dc90a4b15a291b2584bd4917f9bd71eef7889769 Mon Sep 17 00:00:00 2001 From: Alex Verschoot Date: Sun, 16 Nov 2025 15:48:30 +0100 Subject: [PATCH 1/3] Changed the FilletPolyLine to be compatible with 0-radius fillets, where it should behave like a normal Polyline --- src/build123d/objects_curve.py | 109 ++++++++++++++++++++++++--------- 1 file changed, 80 insertions(+), 29 deletions(-) diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 71d788b..61731bd 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -787,20 +787,22 @@ class Helix(BaseEdgeObject): class FilletPolyline(BaseLineObject): """Line Object: Fillet Polyline - Create a sequence of straight lines defined by successive points that are filleted to a given radius. Args: pts (VectorLike | Iterable[VectorLike]): sequence of two or more points - radius (float | Iterable[float]): radius to fillet at each vertex or a single value for all vertices + radius (float | Iterable[float]): radius to fillet at each vertex or a single value for all vertices. + A radius of 0 will create a sharp corner (vertex without fillet). + close (bool, optional): close end points with extra Edge and corner fillets. Defaults to False + mode (Mode, optional): combination mode. Defaults to Mode.ADD Raises: ValueError: Two or more points not provided - ValueError: radius must be positive + ValueError: radius must be non-negative """ _applies_to = [BuildLine._tag] @@ -812,9 +814,9 @@ class FilletPolyline(BaseLineObject): close: bool = False, mode: Mode = Mode.ADD, ): + context: BuildLine | None = BuildLine._get_context(self) validate_inputs(context, self) - points = flatten_sequence(*pts) if len(points) < 2: @@ -822,30 +824,35 @@ class FilletPolyline(BaseLineObject): if isinstance(radius, (int, float)): radius_list = [radius] * len(points) # Single radius for all points + else: radius_list = list(radius) if len(radius_list) != len(points) - int(not close) * 2: raise ValueError( f"radius list length ({len(radius_list)}) must match angle count ({ len(points) - int(not close) * 2})" ) + for r in radius_list: - if r <= 0: - raise ValueError(f"radius {r} must be positive") + if r < 0: + raise ValueError(f"radius {r} must be non-negative") lines_pts = WorkplaneList.localize(*points) - # Create the polyline + new_edges = [ Edge.make_line(lines_pts[i], lines_pts[i + 1]) for i in range(len(lines_pts) - 1) ] + if close and (new_edges[0] @ 0 - new_edges[-1] @ 1).length > 1e-5: new_edges.append(Edge.make_line(new_edges[-1] @ 1, new_edges[0] @ 0)) + wire_of_lines = Wire(new_edges) # Create a list of vertices from wire_of_lines in the same order as # the original points so the resulting fillet edges are ordered ordered_vertices = [] + for pnts in lines_pts: distance = { v: (Vector(pnts) - Vector(*v)).length for v in wire_of_lines.vertices() @@ -853,46 +860,90 @@ class FilletPolyline(BaseLineObject): ordered_vertices.append(sorted(distance.items(), key=lambda x: x[1])[0][0]) # Fillet the corners - # Create a map of vertices to edges containing that vertex vertex_to_edges = { v: [e for e in wire_of_lines.edges() if v in e.vertices()] for v in ordered_vertices } - # For each corner vertex create a new fillet Edge + # For each corner vertex create a new fillet Edge (or keep as vertex if radius is 0) fillets = [] + for i, (vertex, edges) in enumerate(vertex_to_edges.items()): if len(edges) != 2: continue - other_vertices = {ve for e in edges for ve in e.vertices() if ve != vertex} - third_edge = Edge.make_line(*[v for v in other_vertices]) - fillet_face = Face(Wire(edges + [third_edge])).fillet_2d( - radius_list[i - int(not close)], [vertex] - ) - fillets.append(fillet_face.edges().filter_by(GeomType.CIRCLE)[0]) + current_radius = radius_list[i - int(not close)] + + if current_radius == 0: + # For 0 radius, store the vertex as a marker for a sharp corner + fillets.append(None) + + else: + other_vertices = {ve for e in edges for ve in e.vertices() if ve != vertex} + third_edge = Edge.make_line(*[v for v in other_vertices]) + fillet_face = Face(Wire(edges + [third_edge])).fillet_2d( + current_radius, [vertex] + ) + fillets.append(fillet_face.edges().filter_by(GeomType.CIRCLE)[0]) # Create the Edges that join the fillets if close: - interior_edges = [ - Edge.make_line(fillets[i - 1] @ 1, fillets[i] @ 0) - for i in range(len(fillets)) - ] - end_edges = [] - else: - interior_edges = [ - Edge.make_line(fillets[i] @ 1, f @ 0) for i, f in enumerate(fillets[1:]) - ] - end_edges = [ - Edge.make_line(wire_of_lines @ 0, fillets[0] @ 0), - Edge.make_line(fillets[-1] @ 1, wire_of_lines @ 1), - ] + interior_edges = [] - new_wire = Wire(end_edges + interior_edges + fillets) + for i in range(len(fillets)): + prev_idx = i - 1 + curr_idx = i + # Determine start and end points + if fillets[prev_idx] is None: + start_pt = ordered_vertices[prev_idx] + else: + start_pt = fillets[prev_idx] @ 1 + + if fillets[curr_idx] is None: + end_pt = ordered_vertices[curr_idx] + else: + end_pt = fillets[curr_idx] @ 0 + interior_edges.append(Edge.make_line(start_pt, end_pt)) + + end_edges = [] + + else: + interior_edges = [] + for i in range(len(fillets) - 1): + curr_idx = i + next_idx = i + 1 + # Determine start and end points + if fillets[curr_idx] is None: + start_pt = ordered_vertices[curr_idx + 1] # +1 because first vertex has no fillet + else: + start_pt = fillets[curr_idx] @ 1 + + if fillets[next_idx] is None: + end_pt = ordered_vertices[next_idx + 1] + else: + end_pt = fillets[next_idx] @ 0 + interior_edges.append(Edge.make_line(start_pt, end_pt)) + + # Handle end edges + if fillets[0] is None: + start_edge = Edge.make_line(wire_of_lines @ 0, ordered_vertices[1]) + else: + start_edge = Edge.make_line(wire_of_lines @ 0, fillets[0] @ 0) + + if fillets[-1] is None: + end_edge = Edge.make_line(ordered_vertices[-2], wire_of_lines @ 1) + else: + end_edge = Edge.make_line(fillets[-1] @ 1, wire_of_lines @ 1) + end_edges = [start_edge, end_edge] + + # Filter out None values from fillets (these are 0-radius corners) + actual_fillets = [f for f in fillets if f is not None] + new_wire = Wire(end_edges + interior_edges + actual_fillets) super().__init__(new_wire, mode=mode) + class JernArc(BaseEdgeObject): """Line Object: Jern Arc From c7034202f31b909703a2b310aa4ff2886df6cdee Mon Sep 17 00:00:00 2001 From: Alex Verschoot Date: Sun, 16 Nov 2025 16:15:13 +0100 Subject: [PATCH 2/3] Changed the tests to not expect a valueorrer when having a 0 radius, but add two assertEquals so the number of Circles and Lines should be correct --- tests/test_build_line.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_build_line.py b/tests/test_build_line.py index be4cd8d..16f89ce 100644 --- a/tests/test_build_line.py +++ b/tests/test_build_line.py @@ -183,7 +183,7 @@ class BuildLineTests(unittest.TestCase): self.assertEqual(len(p.edges().filter_by(GeomType.CIRCLE)), 2) self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 3) - with self.assertRaises(ValueError): + with BuildLine(Plane.YZ): p = FilletPolyline( (0, 0), (10, 0), @@ -192,6 +192,9 @@ class BuildLineTests(unittest.TestCase): radius=(1, 2, 3, 0), close=True, ) + self.assertEqual(len(p.edges().filter_by(GeomType.CIRCLE)), 3) + self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 4) + with self.assertRaises(ValueError): p = FilletPolyline( From 0bedc9c9add0f13d990cef1db17b4273f902f403 Mon Sep 17 00:00:00 2001 From: gumyr Date: Sat, 29 Nov 2025 11:43:27 -0500 Subject: [PATCH 3/3] Fixed typing problems and increased coverage to 100% --- src/build123d/objects_curve.py | 39 ++++++++++++++++++++-------------- tests/test_build_line.py | 34 +++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 61731bd..ad2a17a 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -50,7 +50,7 @@ from build123d.build_enums import ( ) from build123d.build_line import BuildLine from build123d.geometry import Axis, Plane, Vector, VectorLike, TOLERANCE -from build123d.topology import Edge, Face, Wire, Curve +from build123d.topology import Curve, Edge, Face, Vertex, Wire from build123d.topology.shape_core import ShapeList @@ -851,7 +851,7 @@ class FilletPolyline(BaseLineObject): # Create a list of vertices from wire_of_lines in the same order as # the original points so the resulting fillet edges are ordered - ordered_vertices = [] + ordered_vertices: list[Vertex] = [] for pnts in lines_pts: distance = { @@ -867,7 +867,7 @@ class FilletPolyline(BaseLineObject): } # For each corner vertex create a new fillet Edge (or keep as vertex if radius is 0) - fillets = [] + fillets: list[None | Edge] = [] for i, (vertex, edges) in enumerate(vertex_to_edges.items()): if len(edges) != 2: @@ -879,7 +879,9 @@ class FilletPolyline(BaseLineObject): fillets.append(None) else: - other_vertices = {ve for e in edges for ve in e.vertices() if ve != vertex} + other_vertices = { + ve for e in edges for ve in e.vertices() if ve != vertex + } third_edge = Edge.make_line(*[v for v in other_vertices]) fillet_face = Face(Wire(edges + [third_edge])).fillet_2d( current_radius, [vertex] @@ -891,18 +893,20 @@ class FilletPolyline(BaseLineObject): interior_edges = [] for i in range(len(fillets)): + prev_fillet = fillets[i - 1] + curr_fillet = fillets[i] prev_idx = i - 1 curr_idx = i # Determine start and end points - if fillets[prev_idx] is None: - start_pt = ordered_vertices[prev_idx] + if prev_fillet is None: + start_pt: Vertex | Vector = ordered_vertices[prev_idx] else: - start_pt = fillets[prev_idx] @ 1 + start_pt = prev_fillet @ 1 - if fillets[curr_idx] is None: - end_pt = ordered_vertices[curr_idx] + if curr_fillet is None: + end_pt: Vertex | Vector = ordered_vertices[curr_idx] else: - end_pt = fillets[curr_idx] @ 0 + end_pt = curr_fillet @ 0 interior_edges.append(Edge.make_line(start_pt, end_pt)) end_edges = [] @@ -910,18 +914,22 @@ class FilletPolyline(BaseLineObject): else: interior_edges = [] for i in range(len(fillets) - 1): + next_fillet = fillets[i + 1] + curr_fillet = fillets[i] curr_idx = i next_idx = i + 1 # Determine start and end points - if fillets[curr_idx] is None: - start_pt = ordered_vertices[curr_idx + 1] # +1 because first vertex has no fillet + if curr_fillet is None: + start_pt = ordered_vertices[ + curr_idx + 1 + ] # +1 because first vertex has no fillet else: - start_pt = fillets[curr_idx] @ 1 + start_pt = curr_fillet @ 1 - if fillets[next_idx] is None: + if next_fillet is None: end_pt = ordered_vertices[next_idx + 1] else: - end_pt = fillets[next_idx] @ 0 + end_pt = next_fillet @ 0 interior_edges.append(Edge.make_line(start_pt, end_pt)) # Handle end edges @@ -943,7 +951,6 @@ class FilletPolyline(BaseLineObject): super().__init__(new_wire, mode=mode) - class JernArc(BaseEdgeObject): """Line Object: Jern Arc diff --git a/tests/test_build_line.py b/tests/test_build_line.py index 16f89ce..2696432 100644 --- a/tests/test_build_line.py +++ b/tests/test_build_line.py @@ -195,7 +195,6 @@ class BuildLineTests(unittest.TestCase): self.assertEqual(len(p.edges().filter_by(GeomType.CIRCLE)), 3) self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 4) - with self.assertRaises(ValueError): p = FilletPolyline( (0, 0), @@ -253,6 +252,33 @@ class BuildLineTests(unittest.TestCase): with self.assertRaises(ValueError): FilletPolyline((0, 0), (1, 0), (1, 1), radius=-1) + # test filletpolyline curr_fillet None + # Middle corner radius = 0 → curr_fillet is None + with BuildLine(): + p = FilletPolyline( + (0, 0), + (10, 0), + (10, 10), + (20, 10), + radius=(0, 1), # middle corner is sharp + close=False, + ) + # 1 circular fillet, 3 line fillets + assert len(p.edges().filter_by(GeomType.CIRCLE)) == 1 + + # test filletpolyline next_fillet None: + # Second corner is sharp (radius 0) → next_fillet is None + with BuildLine(): + p = FilletPolyline( + (0, 0), + (10, 0), + (10, 10), + (0, 10), + radius=(1, 0), # next_fillet is None at last interior corner + close=False, + ) + assert len(p.edges()) > 0 + def test_intersecting_line(self): with BuildLine(): l1 = Line((0, 0), (10, 0)) @@ -861,9 +887,9 @@ class BuildLineTests(unittest.TestCase): min_r = 0 if case[2][0] is None else (flip_min * case[0] + case[2][0]) / 2 max_r = 1e6 if case[2][1] is None else (flip_max * case[0] + case[2][1]) / 2 - print(case[1], min_r, max_r, case[0]) - print(min_r + 0.01, min_r * 0.99, max_r - 0.01, max_r + 0.01) - print((case[0] - 1 * (r1 + r2)) / 2) + # print(case[1], min_r, max_r, case[0]) + # print(min_r + 0.01, min_r * 0.99, max_r - 0.01, max_r + 0.01) + # print((case[0] - 1 * (r1 + r2)) / 2) # Greater than min l1 = ArcArcTangentArc(start_arc, end_arc, min_r + 0.01, keep=case[1])