diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 71d788b..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 @@ -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 = [] + ordered_vertices: list[Vertex] = [] + for pnts in lines_pts: distance = { v: (Vector(pnts) - Vector(*v)).length for v in wire_of_lines.vertices() @@ -853,42 +860,93 @@ 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 - fillets = [] + # For each corner vertex create a new fillet Edge (or keep as vertex if radius is 0) + fillets: list[None | Edge] = [] + 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_fillet = fillets[i - 1] + curr_fillet = fillets[i] + prev_idx = i - 1 + curr_idx = i + # Determine start and end points + if prev_fillet is None: + start_pt: Vertex | Vector = ordered_vertices[prev_idx] + else: + start_pt = prev_fillet @ 1 + + if curr_fillet is None: + end_pt: Vertex | Vector = ordered_vertices[curr_idx] + else: + end_pt = curr_fillet @ 0 + interior_edges.append(Edge.make_line(start_pt, end_pt)) + + end_edges = [] + + 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 curr_fillet is None: + start_pt = ordered_vertices[ + curr_idx + 1 + ] # +1 because first vertex has no fillet + else: + start_pt = curr_fillet @ 1 + + if next_fillet is None: + end_pt = ordered_vertices[next_idx + 1] + else: + end_pt = next_fillet @ 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) diff --git a/tests/test_build_line.py b/tests/test_build_line.py index ae7364a..2255793 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,8 @@ 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( @@ -250,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)) @@ -858,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])