From f20501ed0ccdd7a38038e2a4a1ee6ce005c4c86f Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Sat, 25 Nov 2023 20:59:52 -0600 Subject: [PATCH 1/6] add vertex support to make_loft --- src/build123d/topology.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/build123d/topology.py b/src/build123d/topology.py index 94a240e..74cab0a 100644 --- a/src/build123d/topology.py +++ b/src/build123d/topology.py @@ -5935,13 +5935,13 @@ class Solid(Mixin3D, Shape): ) @classmethod - def make_loft(cls, wires: list[Wire], ruled: bool = False) -> Solid: + def make_loft(objs: list[Vertex, Wire], ruled: bool = False) -> Solid: """make loft - Makes a loft from a list of wires. + Makes a loft from a list of wires and vertices, where vertices can be the first, last, or first and last elements. Args: - wires (list[Wire]): section perimeters + objs (list[Vertex, Wire]): wire perimeters or vertices ruled (bool, optional): stepped or smooth. Defaults to False (smooth). Raises: @@ -5950,13 +5950,18 @@ class Solid(Mixin3D, Shape): Returns: Solid: Lofted object """ + + if len(objs) < 2: + raise ValueError("More than one wire, or a wire and a vertex is required") + # the True flag requests building a solid instead of a shell. - if len(wires) < 2: - raise ValueError("More than one wire is required") loft_builder = BRepOffsetAPI_ThruSections(True, ruled) - for wire in wires: - loft_builder.AddWire(wire.wrapped) + for obj in objs: + if isinstance(obj, Vertex): + loft_builder.AddVertex(obj.wrapped) + elif isinstance(obj, Wire): + loft_builder.AddWire(obj.wrapped) loft_builder.Build() From b4b3b2b0e8e3cbe96ff43a634a0a6e7302f040db Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Sat, 25 Nov 2023 21:02:33 -0600 Subject: [PATCH 2/6] add vertex, sketch support to loft() and input handling --- src/build123d/operations_part.py | 50 ++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/src/build123d/operations_part.py b/src/build123d/operations_part.py index 6040025..58adf68 100644 --- a/src/build123d/operations_part.py +++ b/src/build123d/operations_part.py @@ -175,7 +175,7 @@ def extrude( def loft( - sections: Union[Face, Iterable[Face]] = None, + sections: Union[Face, Sketch, Iterable[Union[Vertex, Face, Sketch]]] = None, ruled: bool = False, clean: bool = True, mode: Mode = Mode.ADD, @@ -185,8 +185,9 @@ def loft( Loft the pending sketches/faces, across all workplanes, into a solid. Args: - sections (Face): slices to loft into object. If not provided, pending_faces - will be used. + sections (Vertex, Face, Sketch): slices to loft into object. If not provided, pending_faces + will be used. If vertices are to be used, a vertex can be the first, last, or + first and last elements. ruled (bool, optional): discontiguous layer tangents. Defaults to False. clean (bool, optional): Remove extraneous internal structure. Defaults to True. mode (Mode, optional): combination mode. Defaults to Mode.ADD. @@ -205,9 +206,46 @@ def loft( context.pending_faces = [] context.pending_face_planes = [] else: - loft_wires = [ - face.outer_wire() for section in section_list for face in section.faces() - ] + if all(isinstance(s, (Face, Sketch)) for s in section_list): + loft_wires = [ + face.outer_wire() + for section in section_list + for face in section.faces() + ] + elif any(isinstance(s, Vertex) for s in section_list) and any( + isinstance(s, (Face, Sketch)) for s in section_list + ): + if len(section_list) == 2: + pass + elif isinstance(section_list[0], Vertex) and isinstance( + section_list[-1], Vertex + ): + pass + elif isinstance(section_list[0], Vertex) and isinstance( + section_list[-1], (Face, Sketch) + ): + pass + elif isinstance(section_list[0], (Face, Sketch)) and isinstance( + section_list[-1], Vertex + ): + pass + else: + raise ValueError( + "Vertices must be the first, last, or first and last elements" + ) + loft_wires = [] + for s in section_list: + if isinstance(s, Vertex): + loft_wires.append(s) + elif isinstance(s, Face): + loft_wires.append(s.outer_wire()) + elif isinstance(s, Sketch): + loft_wires.append(s.face().outer_wire()) + elif all(isinstance(s, Vertex) for s in section_list): + raise ValueError( + "At least one face/sketch is required if vertices are the first, last, or first and last elements" + ) + new_solid = Solid.make_loft(loft_wires, ruled) # Try to recover an invalid loft From b947152216a5c76313863e16f5bbff7d4f6b6ff5 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Sat, 25 Nov 2023 21:23:15 -0600 Subject: [PATCH 3/6] make_loft accept iterable of vertex/wire --- src/build123d/topology.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build123d/topology.py b/src/build123d/topology.py index 74cab0a..b4b36b4 100644 --- a/src/build123d/topology.py +++ b/src/build123d/topology.py @@ -5935,7 +5935,7 @@ class Solid(Mixin3D, Shape): ) @classmethod - def make_loft(objs: list[Vertex, Wire], ruled: bool = False) -> Solid: + def make_loft(objs: Iterable[Union[Vertex, Wire]], ruled: bool = False) -> Solid: """make loft Makes a loft from a list of wires and vertices, where vertices can be the first, last, or first and last elements. From 594deefb70bfbb4df5b9076e78dbcce1390372ee Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Sun, 26 Nov 2023 20:50:06 -0600 Subject: [PATCH 4/6] fix missing cls in make_loft --- src/build123d/topology.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/build123d/topology.py b/src/build123d/topology.py index b4b36b4..476a586 100644 --- a/src/build123d/topology.py +++ b/src/build123d/topology.py @@ -1390,6 +1390,7 @@ class Shape(NodeMixin): topo_parent (Shape): assembly parent of this object """ + # pylint: disable=too-many-instance-attributes, too-many-public-methods _dim = None @@ -1763,7 +1764,7 @@ class Shape(NodeMixin): try: upgrader.Build() self.wrapped = downcast(upgrader.Shape()) - except: # pylint: disable=bare-except + except: # pylint: disable=bare-except warnings.warn(f"Unable to clean {self}") return self @@ -3256,6 +3257,7 @@ class ShapePredicate(Protocol): class ShapeList(list[T]): """Subclass of list with custom filter and sort methods appropriate to CAD""" + # pylint: disable=too-many-public-methods @property @@ -4244,6 +4246,7 @@ class Curve(Compound): class Edge(Mixin1D, Shape): """A trimmed curve that represents the border of a face""" + # pylint: disable=too-many-public-methods _dim = 1 @@ -4999,6 +5002,7 @@ class Edge(Mixin1D, Shape): class Face(Shape): """a bounded surface that represents part of the boundary of a solid""" + # pylint: disable=too-many-public-methods _dim = 2 @@ -5935,7 +5939,9 @@ class Solid(Mixin3D, Shape): ) @classmethod - def make_loft(objs: Iterable[Union[Vertex, Wire]], ruled: bool = False) -> Solid: + def make_loft( + cls, objs: Iterable[Union[Vertex, Wire]], ruled: bool = False + ) -> Solid: """make loft Makes a loft from a list of wires and vertices, where vertices can be the first, last, or first and last elements. @@ -6279,7 +6285,7 @@ class Solid(Mixin3D, Shape): .solids() .sort_by(direction_axis)[0] ) - except: # pylint: disable=bare-except + except: # pylint: disable=bare-except warnings.warn("clipping error - extrusion may be incorrect") else: extrusion_parts = [extrusion.intersect(target_object)] @@ -6290,7 +6296,7 @@ class Solid(Mixin3D, Shape): .solids() .sort_by(direction_axis)[0] ) - except: # pylint: disable=bare-except + except: # pylint: disable=bare-except warnings.warn("clipping error - extrusion may be incorrect") extrusion = Shape.fuse(*extrusion_parts) From b7a68a87c334394e26b609edcbae8efc79a9f729 Mon Sep 17 00:00:00 2001 From: jdegenstein Date: Mon, 27 Nov 2023 10:07:24 -0600 Subject: [PATCH 5/6] add tests, simplify and improve loft logic --- src/build123d/operations_part.py | 18 ++----------- tests/test_build_part.py | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/build123d/operations_part.py b/src/build123d/operations_part.py index 58adf68..3fd9fe7 100644 --- a/src/build123d/operations_part.py +++ b/src/build123d/operations_part.py @@ -50,7 +50,7 @@ from build123d.build_common import logger, WorkplaneList, validate_inputs def extrude( to_extrude: Union[Face, Sketch] = None, amount: float = None, - dir: VectorLike = None, # pylint: disable=redefined-builtin + dir: VectorLike = None, # pylint: disable=redefined-builtin until: Until = None, target: Union[Compound, Solid] = None, both: bool = False, @@ -215,21 +215,7 @@ def loft( elif any(isinstance(s, Vertex) for s in section_list) and any( isinstance(s, (Face, Sketch)) for s in section_list ): - if len(section_list) == 2: - pass - elif isinstance(section_list[0], Vertex) and isinstance( - section_list[-1], Vertex - ): - pass - elif isinstance(section_list[0], Vertex) and isinstance( - section_list[-1], (Face, Sketch) - ): - pass - elif isinstance(section_list[0], (Face, Sketch)) and isinstance( - section_list[-1], Vertex - ): - pass - else: + if any(isinstance(s, Vertex) for s in section_list[1:-1]): raise ValueError( "Vertices must be the first, last, or first and last elements" ) diff --git a/tests/test_build_part.py b/tests/test_build_part.py index 6ad2d8f..71e38b5 100644 --- a/tests/test_build_part.py +++ b/tests/test_build_part.py @@ -325,6 +325,50 @@ class TestLoft(unittest.TestCase): self.assertLess(test.part.volume, 225 * pi * 30, 5) self.assertGreater(test.part.volume, 25 * pi * 30, 5) + def test_loft_vertex(self): + with BuildPart() as test: + v1 = Vertex(0, 0, 3) + with BuildSketch() as s: + Rectangle(1, 1) + loft(sections=[s.sketch, v1], ruled=True) + self.assertAlmostEqual(test.part.volume, 1, 5) + + def test_loft_vertices(self): + with BuildPart() as test: + v1 = Vertex(0, 0, 3) + v2 = Vertex(0, 0, -3) + with BuildSketch() as s: + Rectangle(1, 1) + loft(sections=[v2, s.sketch, v1], ruled=True) + self.assertAlmostEqual(test.part.volume, 2, 5) + + def test_loft_vertex_face(self): + v1 = Vertex(0, 0, 3) + r = Rectangle(1, 1) + test = loft(sections=[r.face(), v1], ruled=True) + self.assertAlmostEqual(test.volume, 1, 5) + + def test_loft_no_sections_assert(self): + with BuildPart() as test: + with self.assertRaises(ValueError): + loft(sections=[None]) + + def test_loft_all_vertices_assert(self): + with BuildPart() as test: + v1 = Vertex(0, 0, -1) + v2 = Vertex(0, 0, 2) + with self.assertRaises(ValueError): + loft(sections=[v1, v2]) + + def test_loft_vertex_middle_assert(self): + with BuildPart() as test: + v1 = Vertex(0, 0, -1) + v2 = Vertex(0, 0, 2) + with BuildSketch() as s: + Circle(1) + with self.assertRaises(ValueError): + loft(sections=[v1, v2, s.sketch]) + class TestRevolve(unittest.TestCase): def test_simple_revolve(self): From 34db0aae78d76cf1ccab51e5de04582f87ce039d Mon Sep 17 00:00:00 2001 From: gumyr Date: Wed, 29 Nov 2023 13:53:53 -0500 Subject: [PATCH 6/6] Added ability to accept iterables to: Builders, Locations, Bezier, FilletPolyline, Line, Polyline, Spline, TangentArc, ThreePointArc, Polygon, Issue #269 --- src/build123d/build_common.py | 44 ++++++++++++++++-- src/build123d/build_line.py | 2 + src/build123d/objects_curve.py | 43 +++++++++++------ src/build123d/objects_sketch.py | 13 ++++-- src/build123d/operations_generic.py | 46 +++++++++---------- src/build123d/operations_part.py | 18 ++++---- src/build123d/operations_sketch.py | 8 ++-- tests/test_build_common.py | 71 ++++++++++++++++++++++++++--- tests/test_build_line.py | 15 ++++++ 9 files changed, 194 insertions(+), 66 deletions(-) diff --git a/src/build123d/build_common.py b/src/build123d/build_common.py index 4f1e470..c5c4f87 100644 --- a/src/build123d/build_common.py +++ b/src/build123d/build_common.py @@ -103,6 +103,30 @@ G = 1 KG = 1000 * G LB = 453.59237 * G +T = TypeVar("T") + + +def flatten_sequence(*obj: T) -> list[T]: + """Convert a sequence of object potentially containing iterables into a flat list""" + + def is_point(obj): + """Identify points as tuples of numbers""" + return isinstance(obj, tuple) and all( + isinstance(item, (int, float)) for item in obj + ) + + flat_list = [] + for item in obj: + # Note: an Iterable can't be used here as it will match with Vector & Vertex + # and break them into a list of floats. + if isinstance(item, (list, tuple, filter, set)) and not is_point(item): + flat_list.extend(item) + else: + flat_list.append(item) + + return flat_list + + operations_apply_to = { "add": ["BuildPart", "BuildSketch", "BuildLine"], "bounding_box": ["BuildPart", "BuildSketch", "BuildLine"], @@ -942,16 +966,28 @@ class Locations(LocationList): Creates a context of locations for Part or Sketch Args: - pts (Union[VectorLike, Vertex, Location]): sequence of points to push + pts (Union[VectorLike, Vertex, Location, Face, Plane, Axis] or iterable of same): + sequence of points to push Attributes: local_locations (list{Location}): locations relative to workplane """ - def __init__(self, *pts: Union[VectorLike, Vertex, Location, Face, Plane, Axis]): + def __init__( + self, + *pts: Union[ + VectorLike, + Vertex, + Location, + Face, + Plane, + Axis, + Iterable[VectorLike, Vertex, Location, Face, Plane, Axis], + ], + ): local_locations = [] - for point in pts: + for point in flatten_sequence(*pts): if isinstance(point, Location): local_locations.append(point) elif isinstance(point, Vector): @@ -1108,6 +1144,7 @@ class WorkplaneList: @staticmethod def _convert_to_planes(objs: Iterable[Union[Face, Plane, Location]]) -> list[Plane]: """Translate objects to planes""" + objs = flatten_sequence(*objs) planes = [] for obj in objs: if isinstance(obj, Plane): @@ -1186,7 +1223,6 @@ class WorkplaneList: return result -T = TypeVar("T") P = ParamSpec("P") diff --git a/src/build123d/build_line.py b/src/build123d/build_line.py index 4cdc724..75d66b7 100644 --- a/src/build123d/build_line.py +++ b/src/build123d/build_line.py @@ -83,6 +83,8 @@ class BuildLine(Builder): ): self.line: Curve = None super().__init__(workplane, mode=mode) + if len(self.workplanes) > 1: + raise ValueError("BuildLine only accepts one workplane") def __exit__(self, exception_type, exception_value, traceback): """Upon exiting restore context and send object to parent""" diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 62a4c5d..e5a98df 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -31,7 +31,7 @@ import copy from math import copysign, cos, radians, sin, sqrt from typing import Iterable, Union -from build123d.build_common import WorkplaneList, validate_inputs +from build123d.build_common import WorkplaneList, flatten_sequence, validate_inputs from build123d.build_enums import AngularDirection, GeomType, LengthMode, Mode from build123d.build_line import BuildLine from build123d.geometry import Axis, Plane, Vector, VectorLike @@ -90,6 +90,7 @@ class Bezier(BaseLineObject): context: BuildLine = BuildLine._get_context(self) validate_inputs(context, self) + cntl_pnts = flatten_sequence(*cntl_pnts) polls = WorkplaneList.localize(*cntl_pnts) curve = Edge.make_bezier(*polls, weights=weights) @@ -353,7 +354,7 @@ class FilletPolyline(BaseLineObject): are filleted to a given radius. Args: - pts (VectorLike): sequence of three or more points + pts (Union[VectorLike, Iterable[VectorLike]]): sequence of three or more points radius (float): radius of filleted corners close (bool, optional): close by generating an extra Edge. Defaults to False. mode (Mode, optional): combination mode. Defaults to Mode.ADD. @@ -367,7 +368,7 @@ class FilletPolyline(BaseLineObject): def __init__( self, - *pts: VectorLike, + *pts: Union[VectorLike, Iterable[VectorLike]], radius: float, close: bool = False, mode: Mode = Mode.ADD, @@ -375,6 +376,8 @@ class FilletPolyline(BaseLineObject): context: BuildLine = BuildLine._get_context(self) validate_inputs(context, self) + pts = flatten_sequence(*pts) + if len(pts) < 3: raise ValueError("filletpolyline requires three or more pts") if radius <= 0: @@ -506,7 +509,7 @@ class Line(BaseLineObject): Add a straight line defined by two end points. Args: - pts (VectorLike): sequence of two points + pts (Union[VectorLike, Iterable[VectorLike]]): sequence of two points mode (Mode, optional): combination mode. Defaults to Mode.ADD. Raises: @@ -515,7 +518,10 @@ class Line(BaseLineObject): _applies_to = [BuildLine._tag] - def __init__(self, *pts: VectorLike, mode: Mode = Mode.ADD): + def __init__( + self, *pts: Union[VectorLike, Iterable[VectorLike]], mode: Mode = Mode.ADD + ): + pts = flatten_sequence(*pts) if len(pts) != 2: raise ValueError("Line requires two pts") @@ -637,7 +643,7 @@ class Polyline(BaseLineObject): Add a sequence of straight lines defined by successive point pairs. Args: - pts (VectorLike): sequence of three or more points + pts (Union[VectorLike, Iterable[VectorLike]]): sequence of three or more points close (bool, optional): close by generating an extra Edge. Defaults to False. mode (Mode, optional): combination mode. Defaults to Mode.ADD. @@ -647,10 +653,16 @@ class Polyline(BaseLineObject): _applies_to = [BuildLine._tag] - def __init__(self, *pts: VectorLike, close: bool = False, mode: Mode = Mode.ADD): + def __init__( + self, + *pts: Union[VectorLike, Iterable[VectorLike]], + close: bool = False, + mode: Mode = Mode.ADD, + ): context: BuildLine = BuildLine._get_context(self) validate_inputs(context, self) + pts = flatten_sequence(*pts) if len(pts) < 3: raise ValueError("polyline requires three or more pts") @@ -766,7 +778,7 @@ class Spline(BaseLineObject): Add a spline through the provided points optionally constrained by tangents. Args: - pts (VectorLike): sequence of two or more points + pts (Union[VectorLike, Iterable[VectorLike]]): sequence of two or more points tangents (Iterable[VectorLike], optional): tangents at end points. Defaults to None. tangent_scalars (Iterable[float], optional): change shape by amplifying tangent. Defaults to None. @@ -778,12 +790,13 @@ class Spline(BaseLineObject): def __init__( self, - *pts: VectorLike, + *pts: Union[VectorLike, Iterable[VectorLike]], tangents: Iterable[VectorLike] = None, tangent_scalars: Iterable[float] = None, periodic: bool = False, mode: Mode = Mode.ADD, ): + pts = flatten_sequence(*pts) context: BuildLine = BuildLine._get_context(self) validate_inputs(context, self) @@ -821,7 +834,7 @@ class TangentArc(BaseLineObject): Add an arc defined by two points and a tangent. Args: - pts (VectorLike): sequence of two points + pts (Union[VectorLike, Iterable[VectorLike]]): sequence of two points tangent (VectorLike): tangent to constrain arc tangent_from_first (bool, optional): apply tangent to first point. Note, applying tangent to end point will flip the orientation of the arc. Defaults to True. @@ -835,11 +848,12 @@ class TangentArc(BaseLineObject): def __init__( self, - *pts: VectorLike, + *pts: Union[VectorLike, Iterable[VectorLike]], tangent: VectorLike, tangent_from_first: bool = True, mode: Mode = Mode.ADD, ): + pts = flatten_sequence(*pts) context: BuildLine = BuildLine._get_context(self) validate_inputs(context, self) @@ -862,7 +876,7 @@ class ThreePointArc(BaseLineObject): Add an arc generated by three points. Args: - pts (VectorLike): sequence of three points + pts (Union[VectorLike, Iterable[VectorLike]]): sequence of three points mode (Mode, optional): combination mode. Defaults to Mode.ADD. Raises: @@ -871,10 +885,13 @@ class ThreePointArc(BaseLineObject): _applies_to = [BuildLine._tag] - def __init__(self, *pts: VectorLike, mode: Mode = Mode.ADD): + def __init__( + self, *pts: Union[VectorLike, Iterable[VectorLike]], mode: Mode = Mode.ADD + ): context: BuildLine = BuildLine._get_context(self) validate_inputs(context, self) + pts = flatten_sequence(*pts) if len(pts) != 3: raise ValueError("ThreePointArc requires three points") points = WorkplaneList.localize(*pts) diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py index 56d63ff..b906909 100644 --- a/src/build123d/objects_sketch.py +++ b/src/build123d/objects_sketch.py @@ -28,9 +28,9 @@ license: from __future__ import annotations from math import cos, pi, radians, sin, tan -from typing import Union +from typing import Iterable, Union -from build123d.build_common import LocationList, validate_inputs +from build123d.build_common import LocationList, flatten_sequence, validate_inputs from build123d.build_enums import Align, FontStyle, Mode from build123d.build_sketch import BuildSketch from build123d.geometry import Axis, Location, Rotation, Vector, VectorLike @@ -155,7 +155,8 @@ class Polygon(BaseSketchObject): Add polygon(s) defined by given sequence of points to sketch. Args: - pts (VectorLike): sequence of points defining the vertices of polygon + pts (Union[VectorLike, Iterable[VectorLike]]): sequence of points defining the + vertices of polygon rotation (float, optional): angles to rotate objects. Defaults to 0. align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. Defaults to (Align.CENTER, Align.CENTER). @@ -166,7 +167,7 @@ class Polygon(BaseSketchObject): def __init__( self, - *pts: VectorLike, + *pts: Union[VectorLike, Iterable[VectorLike]], rotation: float = 0, align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, @@ -174,6 +175,7 @@ class Polygon(BaseSketchObject): context = BuildSketch._get_context(self) validate_inputs(context, self) + pts = flatten_sequence(*pts) self.pts = pts self.align = tuplify(align, 2) @@ -495,7 +497,7 @@ class SlotOverall(BaseSketchObject): ).offset_2d(height / 2) ) else: - face = Circle(width/2, mode=mode).face() + face = Circle(width / 2, mode=mode).face() super().__init__(face, rotation, align, mode) @@ -518,6 +520,7 @@ class Text(BaseSketchObject): rotation (float, optional): angles to rotate objects. Defaults to 0. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ + # pylint: disable=too-many-instance-attributes _applies_to = [BuildSketch._tag] diff --git a/src/build123d/operations_generic.py b/src/build123d/operations_generic.py index 6f7d359..5d00e21 100644 --- a/src/build123d/operations_generic.py +++ b/src/build123d/operations_generic.py @@ -31,7 +31,13 @@ import logging from math import radians, tan from typing import Union, Iterable -from build123d.build_common import Builder, LocationList, WorkplaneList, validate_inputs +from build123d.build_common import ( + Builder, + LocationList, + WorkplaneList, + flatten_sequence, + validate_inputs, +) from build123d.build_enums import Keep, Kind, Mode, Side, Transition from build123d.build_line import BuildLine from build123d.build_part import BuildPart @@ -206,9 +212,8 @@ def bounding_box( raise ValueError("objects must be provided") object_list = [context._obj] else: - object_list = ( - [*objects] if isinstance(objects, (list, tuple, filter)) else [objects] - ) + object_list = flatten_sequence(objects) + validate_inputs(context, "bounding_box", object_list) if all([obj._dim == 2 for obj in object_list]): @@ -300,9 +305,7 @@ def chamfer( ): raise ValueError("No objects provided") - object_list = ( - [*objects] if isinstance(objects, (list, tuple, filter)) else [objects] - ) + object_list = flatten_sequence(objects) validate_inputs(context, "chamfer", object_list) @@ -397,9 +400,8 @@ def fillet( ): raise ValueError("No objects provided") - object_list = ( - [*objects] if isinstance(objects, (list, tuple, filter)) else [objects] - ) + object_list = flatten_sequence(objects) + validate_inputs(context, "fillet", object_list) if context is not None: target = context._obj @@ -495,9 +497,8 @@ def mirror( raise ValueError("objects must be provided") object_list = [context._obj] else: - object_list = ( - [*objects] if isinstance(objects, (list, tuple, filter)) else [objects] - ) + object_list = flatten_sequence(objects) + validate_inputs(context, "mirror", object_list) mirrored = [copy.deepcopy(o).mirror(about) for o in object_list] @@ -562,9 +563,8 @@ def offset( raise ValueError("objects must be provided") object_list = [context._obj] else: - object_list = ( - [*objects] if isinstance(objects, (list, tuple, filter)) else [objects] - ) + object_list = flatten_sequence(objects) + validate_inputs(context, "offset", object_list) edges: list[Edge] = [] @@ -701,9 +701,7 @@ def project( else: workplane = context.exit_workplanes[0] else: - object_list = ( - [*objects] if isinstance(objects, (list, tuple, filter)) else [objects] - ) + object_list = flatten_sequence(objects) # The size of the object determines the size of the target projection screen # as the screen is normal to the direction of parallel projection @@ -826,9 +824,8 @@ def scale( raise ValueError("objects must be provided") object_list = [context._obj] else: - object_list = ( - [*objects] if isinstance(objects, (list, tuple, filter)) else [objects] - ) + object_list = flatten_sequence(objects) + validate_inputs(context, "scale", object_list) if isinstance(by, (int, float)): @@ -907,9 +904,8 @@ def split( raise ValueError("objects must be provided") object_list = [context._obj] else: - object_list = ( - [*objects] if isinstance(objects, (list, tuple, filter)) else [objects] - ) + object_list = flatten_sequence(objects) + validate_inputs(context, "split", object_list) new_objects = [] diff --git a/src/build123d/operations_part.py b/src/build123d/operations_part.py index 6040025..c743d4e 100644 --- a/src/build123d/operations_part.py +++ b/src/build123d/operations_part.py @@ -44,13 +44,18 @@ from build123d.topology import ( Vertex, ) -from build123d.build_common import logger, WorkplaneList, validate_inputs +from build123d.build_common import ( + logger, + WorkplaneList, + flatten_sequence, + validate_inputs, +) def extrude( to_extrude: Union[Face, Sketch] = None, amount: float = None, - dir: VectorLike = None, # pylint: disable=redefined-builtin + dir: VectorLike = None, # pylint: disable=redefined-builtin until: Until = None, target: Union[Compound, Solid] = None, both: bool = False, @@ -193,9 +198,7 @@ def loft( """ context: BuildPart = BuildPart._get_context("loft") - section_list = ( - [*sections] if isinstance(sections, (list, tuple, filter)) else [sections] - ) + section_list = flatten_sequence(sections) validate_inputs(context, "loft", section_list) if all([s is None for s in section_list]): @@ -411,9 +414,8 @@ def revolve( """ context: BuildPart = BuildPart._get_context("revolve") - profile_list = ( - [*profiles] if isinstance(profiles, (list, tuple, filter)) else [profiles] - ) + profile_list = flatten_sequence(profiles) + validate_inputs(context, "revolve", profile_list) # Make sure we account for users specifying angles larger than 360 degrees, and diff --git a/src/build123d/operations_sketch.py b/src/build123d/operations_sketch.py index 79a6197..c2b7f6c 100644 --- a/src/build123d/operations_sketch.py +++ b/src/build123d/operations_sketch.py @@ -30,7 +30,7 @@ from __future__ import annotations from typing import Iterable, Union from build123d.build_enums import Mode from build123d.topology import Compound, Curve, Edge, Face, ShapeList, Wire, Sketch -from build123d.build_common import validate_inputs +from build123d.build_common import flatten_sequence, validate_inputs from build123d.build_sketch import BuildSketch @@ -49,7 +49,7 @@ def make_face( context: BuildSketch = BuildSketch._get_context("make_face") if edges is not None: - outer_edges = [*edges] if isinstance(edges, (list, tuple, filter)) else [edges] + outer_edges = flatten_sequence(edges) elif context is not None: outer_edges = context.pending_edges else: @@ -84,7 +84,7 @@ def make_hull( context: BuildSketch = BuildSketch._get_context("make_hull") if edges is not None: - hull_edges = [*edges] if isinstance(edges, (list, tuple, filter)) else [edges] + hull_edges = flatten_sequence(edges) elif context is not None: hull_edges = context.pending_edges if context.sketch_local is not None: @@ -131,7 +131,7 @@ def trace( context: BuildSketch = BuildSketch._get_context("trace") if lines is not None: - trace_lines = [*lines] if isinstance(lines, (list, tuple, filter)) else [lines] + trace_lines = flatten_sequence(lines) trace_edges = [e for l in trace_lines for e in l.edges()] elif context is not None: trace_edges = context.pending_edges diff --git a/tests/test_build_common.py b/tests/test_build_common.py index 4c9863f..668fef1 100644 --- a/tests/test_build_common.py +++ b/tests/test_build_common.py @@ -28,7 +28,7 @@ license: import unittest from math import pi from build123d import * -from build123d import Builder, WorkplaneList, LocationList +from build123d import WorkplaneList, flatten_sequence def _assertTupleAlmostEquals(self, expected, actual, places, msg=None): @@ -40,6 +40,38 @@ def _assertTupleAlmostEquals(self, expected, actual, places, msg=None): unittest.TestCase.assertTupleAlmostEquals = _assertTupleAlmostEquals +class TestFlattenSequence(unittest.TestCase): + """Test the flatten_sequence helper function""" + + def test_single_object(self): + self.assertListEqual(flatten_sequence("a"), ["a"]) + + def test_sequence(self): + self.assertListEqual(flatten_sequence("a", "b", "c"), ["a", "b", "c"]) + + def test_list(self): + self.assertListEqual(flatten_sequence(["a", "b", "c"]), ["a", "b", "c"]) + + def test_list_sequence(self): + self.assertListEqual( + flatten_sequence(["a", "b", "c"], "d"), ["a", "b", "c", "d"] + ) + + def test_sequence_tuple(self): + self.assertListEqual( + flatten_sequence("a", ("b", "c", "d"), "e"), ["a", "b", "c", "d", "e"] + ) + + def test_points(self): + self.assertListEqual( + flatten_sequence("a", (1, 2, 3), "e"), ["a", (1, 2, 3), "e"] + ) + + self.assertListEqual( + flatten_sequence("a", (1.0, 2.0, 3.0), "e"), ["a", (1.0, 2.0, 3.0), "e"] + ) + + class TestBuilder(unittest.TestCase): """Test the Builder base class""" @@ -123,6 +155,18 @@ class TestBuilder(unittest.TestCase): with self.assertWarns(UserWarning): p.solid() + def test_workplanes_as_list(self): + with BuildPart() as p: + Box(1, 1, 1) + with BuildSketch(p.faces() >> Axis.Z): + Rectangle(0.25, 0.25) + extrude(amount=0.25) + self.assertAlmostEqual(p.part.volume, 1**3 + 0.25**3, 5) + + with self.assertRaises(ValueError): + with BuildLine([Plane.XY, Plane.XZ]): + Line((0, 0), (1, 1)) + class TestBuilderExit(unittest.TestCase): def test_multiple(self): @@ -306,6 +350,22 @@ class TestLocations(unittest.TestCase): self.assertTupleAlmostEquals(grid.min.to_tuple(), (-5, -15, 0), 5) self.assertTupleAlmostEquals(grid.max.to_tuple(), (5, 15, 0), 5) + def test_mixed_sequence_list(self): + locs = Locations((0, 1), [(2, 3), (4, 5)], (6, 7)) + self.assertEqual(len(locs.locations), 4) + self.assertTupleAlmostEquals( + locs.locations[0].position.to_tuple(), (0, 1, 0), 5 + ) + self.assertTupleAlmostEquals( + locs.locations[1].position.to_tuple(), (2, 3, 0), 5 + ) + self.assertTupleAlmostEquals( + locs.locations[2].position.to_tuple(), (4, 5, 0), 5 + ) + self.assertTupleAlmostEquals( + locs.locations[3].position.to_tuple(), (6, 7, 0), 5 + ) + class TestProperties(unittest.TestCase): def test_vector_properties(self): @@ -609,9 +669,6 @@ class TestValidateInputs(unittest.TestCase): with BuildPart() as p: Box(1, 1, 1) fillet(4, radius=1) - self.assertEqual( - "fillet doesn't accept int, did you intend =4?", str(rte.exception) - ) class TestVectorExtensions(unittest.TestCase): @@ -695,20 +752,20 @@ class TestWorkplaneStorage(unittest.TestCase): class TestContextAwareSelectors(unittest.TestCase): def test_context_aware_selectors(self): with BuildPart() as p: - Box(1,1,1) + Box(1, 1, 1) self.assertEqual(solids(), p.solids()) self.assertEqual(faces(), p.faces()) self.assertEqual(wires(), p.wires()) self.assertEqual(edges(), p.edges()) self.assertEqual(vertices(), p.vertices()) with BuildSketch() as p: - Rectangle(1,1) + Rectangle(1, 1) self.assertEqual(faces(), p.faces()) self.assertEqual(wires(), p.wires()) self.assertEqual(edges(), p.edges()) self.assertEqual(vertices(), p.vertices()) with BuildLine() as p: - Line((0,0), (1,0)) + Line((0, 0), (1, 0)) self.assertEqual(edges(), p.edges()) self.assertEqual(vertices(), p.vertices()) with BuildSketch() as p: diff --git a/tests/test_build_line.py b/tests/test_build_line.py index 2e52e75..b715813 100644 --- a/tests/test_build_line.py +++ b/tests/test_build_line.py @@ -280,6 +280,21 @@ class BuildLineTests(unittest.TestCase): self.assertEqual(len(test.edges()), 4) self.assertAlmostEqual(test.wires()[0].length, 4) + def test_polyline_with_list(self): + """Test edge generation and close""" + with BuildLine() as test: + Polyline((0, 0), [(1, 0), (1, 1)], (0, 1), close=True) + self.assertAlmostEqual( + (test.edges()[0] @ 0 - test.edges()[-1] @ 1).length, 0, 5 + ) + self.assertEqual(len(test.edges()), 4) + self.assertAlmostEqual(test.wires()[0].length, 4) + + def test_line_with_list(self): + """Test line with a list of points""" + l = Line([(0, 0), (10, 0)]) + self.assertAlmostEqual(l.length, 10, 5) + def test_wires_select_last(self): with BuildLine() as test: Line((0, 0), (0, 1))