Added ability to accept iterables to: Builders, Locations, Bezier,

FilletPolyline, Line, Polyline, Spline, TangentArc, ThreePointArc,
Polygon, Issue #269
This commit is contained in:
gumyr 2023-11-29 13:53:53 -05:00
parent b18af27e13
commit 34db0aae78
9 changed files with 194 additions and 66 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = []

View file

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

View file

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

View file

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

View file

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