Merge pull request #1158 from gumyr/pr1140
Some checks failed
benchmarks / benchmarks (macos-14, 3.12) (push) Has been cancelled
benchmarks / benchmarks (macos-15-intel, 3.12) (push) Has been cancelled
benchmarks / benchmarks (ubuntu-latest, 3.12) (push) Has been cancelled
benchmarks / benchmarks (windows-latest, 3.12) (push) Has been cancelled
Upload coverage reports to Codecov / run (push) Has been cancelled
pylint / lint (3.10) (push) Has been cancelled
Run type checker / typecheck (3.10) (push) Has been cancelled
Run type checker / typecheck (3.13) (push) Has been cancelled
Wheel building and publishing / Build wheel on ubuntu-latest (push) Has been cancelled
tests / tests (macos-14, 3.10) (push) Has been cancelled
tests / tests (macos-14, 3.13) (push) Has been cancelled
tests / tests (macos-15-intel, 3.10) (push) Has been cancelled
tests / tests (macos-15-intel, 3.13) (push) Has been cancelled
tests / tests (ubuntu-latest, 3.10) (push) Has been cancelled
tests / tests (ubuntu-latest, 3.13) (push) Has been cancelled
tests / tests (windows-latest, 3.10) (push) Has been cancelled
tests / tests (windows-latest, 3.13) (push) Has been cancelled
Wheel building and publishing / upload_pypi (push) Has been cancelled

Pr1140 with typing and test coverage improvements
This commit is contained in:
Roger Maitland 2025-11-29 12:01:43 -05:00 committed by GitHub
commit ad77bf5f7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 123 additions and 36 deletions

View file

@ -50,7 +50,7 @@ from build123d.build_enums import (
) )
from build123d.build_line import BuildLine from build123d.build_line import BuildLine
from build123d.geometry import Axis, Plane, Vector, VectorLike, TOLERANCE 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 from build123d.topology.shape_core import ShapeList
@ -787,20 +787,22 @@ class Helix(BaseEdgeObject):
class FilletPolyline(BaseLineObject): class FilletPolyline(BaseLineObject):
"""Line Object: Fillet Polyline """Line Object: Fillet Polyline
Create a sequence of straight lines defined by successive points that are filleted Create a sequence of straight lines defined by successive points that are filleted
to a given radius. to a given radius.
Args: Args:
pts (VectorLike | Iterable[VectorLike]): sequence of two or more points 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. close (bool, optional): close end points with extra Edge and corner fillets.
Defaults to False Defaults to False
mode (Mode, optional): combination mode. Defaults to Mode.ADD mode (Mode, optional): combination mode. Defaults to Mode.ADD
Raises: Raises:
ValueError: Two or more points not provided ValueError: Two or more points not provided
ValueError: radius must be positive ValueError: radius must be non-negative
""" """
_applies_to = [BuildLine._tag] _applies_to = [BuildLine._tag]
@ -812,9 +814,9 @@ class FilletPolyline(BaseLineObject):
close: bool = False, close: bool = False,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context: BuildLine | None = BuildLine._get_context(self) context: BuildLine | None = BuildLine._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
points = flatten_sequence(*pts) points = flatten_sequence(*pts)
if len(points) < 2: if len(points) < 2:
@ -822,30 +824,35 @@ class FilletPolyline(BaseLineObject):
if isinstance(radius, (int, float)): if isinstance(radius, (int, float)):
radius_list = [radius] * len(points) # Single radius for all points radius_list = [radius] * len(points) # Single radius for all points
else: else:
radius_list = list(radius) radius_list = list(radius)
if len(radius_list) != len(points) - int(not close) * 2: if len(radius_list) != len(points) - int(not close) * 2:
raise ValueError( raise ValueError(
f"radius list length ({len(radius_list)}) must match angle count ({ len(points) - int(not close) * 2})" f"radius list length ({len(radius_list)}) must match angle count ({ len(points) - int(not close) * 2})"
) )
for r in radius_list: for r in radius_list:
if r <= 0: if r < 0:
raise ValueError(f"radius {r} must be positive") raise ValueError(f"radius {r} must be non-negative")
lines_pts = WorkplaneList.localize(*points) lines_pts = WorkplaneList.localize(*points)
# Create the polyline # Create the polyline
new_edges = [ new_edges = [
Edge.make_line(lines_pts[i], lines_pts[i + 1]) Edge.make_line(lines_pts[i], lines_pts[i + 1])
for i in range(len(lines_pts) - 1) for i in range(len(lines_pts) - 1)
] ]
if close and (new_edges[0] @ 0 - new_edges[-1] @ 1).length > 1e-5: 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)) new_edges.append(Edge.make_line(new_edges[-1] @ 1, new_edges[0] @ 0))
wire_of_lines = Wire(new_edges) wire_of_lines = Wire(new_edges)
# Create a list of vertices from wire_of_lines in the same order as # Create a list of vertices from wire_of_lines in the same order as
# the original points so the resulting fillet edges are ordered # the original points so the resulting fillet edges are ordered
ordered_vertices = [] ordered_vertices: list[Vertex] = []
for pnts in lines_pts: for pnts in lines_pts:
distance = { distance = {
v: (Vector(pnts) - Vector(*v)).length for v in wire_of_lines.vertices() 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]) ordered_vertices.append(sorted(distance.items(), key=lambda x: x[1])[0][0])
# Fillet the corners # Fillet the corners
# Create a map of vertices to edges containing that vertex # Create a map of vertices to edges containing that vertex
vertex_to_edges = { vertex_to_edges = {
v: [e for e in wire_of_lines.edges() if v in e.vertices()] v: [e for e in wire_of_lines.edges() if v in e.vertices()]
for v in ordered_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 = [] fillets: list[None | Edge] = []
for i, (vertex, edges) in enumerate(vertex_to_edges.items()): for i, (vertex, edges) in enumerate(vertex_to_edges.items()):
if len(edges) != 2: if len(edges) != 2:
continue continue
other_vertices = {ve for e in edges for ve in e.vertices() if ve != vertex} current_radius = radius_list[i - int(not close)]
third_edge = Edge.make_line(*[v for v in other_vertices])
fillet_face = Face(Wire(edges + [third_edge])).fillet_2d( if current_radius == 0:
radius_list[i - int(not close)], [vertex] # For 0 radius, store the vertex as a marker for a sharp corner
) fillets.append(None)
fillets.append(fillet_face.edges().filter_by(GeomType.CIRCLE)[0])
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 # Create the Edges that join the fillets
if close: if close:
interior_edges = [ 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),
]
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) super().__init__(new_wire, mode=mode)

View file

@ -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.CIRCLE)), 2)
self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 3) self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 3)
with self.assertRaises(ValueError): with BuildLine(Plane.YZ):
p = FilletPolyline( p = FilletPolyline(
(0, 0), (0, 0),
(10, 0), (10, 0),
@ -192,6 +192,8 @@ class BuildLineTests(unittest.TestCase):
radius=(1, 2, 3, 0), radius=(1, 2, 3, 0),
close=True, 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): with self.assertRaises(ValueError):
p = FilletPolyline( p = FilletPolyline(
@ -250,6 +252,33 @@ class BuildLineTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
FilletPolyline((0, 0), (1, 0), (1, 1), radius=-1) 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): def test_intersecting_line(self):
with BuildLine(): with BuildLine():
l1 = Line((0, 0), (10, 0)) 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 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 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(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(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[0] - 1 * (r1 + r2)) / 2)
# Greater than min # Greater than min
l1 = ArcArcTangentArc(start_arc, end_arc, min_r + 0.01, keep=case[1]) l1 = ArcArcTangentArc(start_arc, end_arc, min_r + 0.01, keep=case[1])