Removed Workplanes

This commit is contained in:
Roger Maitland 2023-04-10 11:17:23 -04:00
parent 205a7b2aea
commit c52ccd9174
9 changed files with 128 additions and 151 deletions

View file

@ -32,14 +32,6 @@ Enums
.. autoclass:: Transition .. autoclass:: Transition
.. autoclass:: Until .. autoclass:: Until
**********
Workplanes
**********
.. py:module:: build_common
.. autoclass:: Workplanes
********* *********
Locations Locations
********* *********

View file

@ -8,7 +8,6 @@ Cheat Sheet
| :class:`~build_line.BuildLine` :class:`~build_part.BuildPart` :class:`~build_sketch.BuildSketch` | :class:`~build_line.BuildLine` :class:`~build_part.BuildPart` :class:`~build_sketch.BuildSketch`
| :class:`~build_common.GridLocations` :class:`~build_common.HexLocations` :class:`~build_common.Locations` :class:`~build_common.PolarLocations` | :class:`~build_common.GridLocations` :class:`~build_common.HexLocations` :class:`~build_common.Locations` :class:`~build_common.PolarLocations`
| :class:`~build_common.Workplanes`
.. card:: Objects .. card:: Objects

View file

@ -271,7 +271,7 @@ provide multiple output formats, support for multiple languages and can be
integrated with code management tools. integrated with code management tools.
**************************************** ****************************************
Key Concepts (context mode) Key Concepts (builder mode)
**************************************** ****************************************
.. include:: key_concepts.rst .. include:: key_concepts.rst

View file

@ -1,3 +1,11 @@
There are two primary APIs provided by build123d: builder and algebra. The builder
api may be easier for new users as it provides some assistance and shortcuts; however,
if you know what a Quaternion is you might prefer the algebra API which allows
CAD objects to be created in the style of mathematical equations. Both API can
be mixed in the same model with the exception that the algebra API can't be used
from within a builder context. As with music, there is no "best" genre or API,
use the one you prefer or both if you like.
The following key concepts will help new users understand build123d quickly. The following key concepts will help new users understand build123d quickly.
Builders Builders
@ -59,39 +67,36 @@ information - as follows:
In this example, ``Box`` is in the scope of ``part_builder`` while ``Circle`` In this example, ``Box`` is in the scope of ``part_builder`` while ``Circle``
is in the scope of ``sketch_builder``. is in the scope of ``sketch_builder``.
Workplane Contexts Workplanes
================== ==========
As build123d is a 3D CAD package one must be able to position objects anywhere. As one As build123d is a 3D CAD package one must be able to position objects anywhere. As one
frequently will work in the same plane for a sequence of operations, a workplane is used frequently will work in the same plane for a sequence of operations, the first parameter(s)
to aid in the location of features. The default workplane in most cases is the XY plane of the builders is a (sequence of) workplane(s) which is (are) used
to aid in the location of features. The default workplane in most cases is the ``Plane.XY``
where a tuple of numbers represent positions on the x and y axes. However workplanes can where a tuple of numbers represent positions on the x and y axes. However workplanes can
be generated on any plane which allows users to put a workplane where they are working be generated on any plane which allows users to put a workplane where they are working
and then work in local 2D coordinate space. and then work in local 2D coordinate space.
To facilitate this a ``Workplanes`` stateful context is used to create a scope where a given
workplane will apply. For example:
.. code-block:: python .. code-block:: python
with BuildPart(Plane.XY) as example: with BuildPart(Plane.XY) as example:
with BuildSketch() as bottom: with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[0]) as bottom:
... ...
with Workplanes(Plane.XZ) as vertical: with BuildSketch(Plane.XZ) as vertical:
with BuildSketch() as side:
... ...
with Workplanes(example.faces().sort_by(SortBy.Z)[-1]): with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[-1]) as top:
with BuildSketch() as top:
... ...
When ``BuildPart`` is invoked it creates the workplane provided as a parameter (which has a When ``BuildPart`` is invoked it creates the workplane provided as a parameter (which has a
default of the XY plane). The ``bottom`` sketch is therefore created on the XY plane. Subsequently default of the ``Plane.XY``). The ``bottom`` sketch is therefore created on the ``Plane.XY`` but with the
the user has created the ``vertical`` plane (XZ) on which the ``side`` sketch is created. All normal reversed to point down. Subsequently the user has created the ``vertical`` (``Plane.XZ```) sketch.
objects or operations within the scope of a workplane will automatically be orientated with All objects or operations within the scope of a workplane will automatically be orientated with
respect to this plane so the user only has to work with local coordinates. respect to this plane so the user only has to work with local coordinates.
Workplanes can be created from faces as well. The ``top`` sketch is positioned on top As shown above, workplanes can be created from faces as well. The ``top`` sketch is
of ``example`` by selecting its faces and finding the one with the greatest z value. positioned on top of ``example`` by selecting its faces and finding the one with the greatest z value.
One is not limited to a single workplane at a time. In the following example all six One is not limited to a single workplane at a time. In the following example all six
faces of the first box is used to define workplanes which are then used to position faces of the first box is used to define workplanes which are then used to position
@ -103,8 +108,9 @@ rotated boxes.
with bd.BuildPart() as bp: with bd.BuildPart() as bp:
bd.Box(3, 3, 3) bd.Box(3, 3, 3)
with bd.Workplanes(*bp.faces()): with bd.BuildSketch(*bp.faces()):
bd.Box(1, 2, 0.1, rotation=(0, 0, 45)) bd.Rectangle(1, 2, rotation=45)
bd.extrude(amount=0.1)
This is the result: This is the result:
@ -136,12 +142,12 @@ Note that these contexts are creating Location objects not just simple points. T
isn't obvious until the ``PolarLocations`` context is used which can also rotate objects within isn't obvious until the ``PolarLocations`` context is used which can also rotate objects within
its scope - much as the hour and minute indicator on an analogue clock. its scope - much as the hour and minute indicator on an analogue clock.
Also note that the locations are local to the current workplane(s). However, it's easy for a user Also note that the locations are local to the current location(s) - i.e. ``Locations`` can be
to retrieve the global locations relative to the current workplane(s) as follows: nested. It's easy for a user to retrieve the global locations:
.. code-block:: python .. code-block:: python
with Workplanes(Plane.XY, Plane.XZ): with Locations(Plane.XY, Plane.XZ):
locs = GridLocations(1, 1, 2, 2) locs = GridLocations(1, 1, 2, 2)
for l in locs: for l in locs:
print(l) print(l)

View file

@ -119,7 +119,7 @@ class Hinge(Compound):
add(hinge_profile.part, rotation=(90, 0, 0), mode=Mode.INTERSECT) add(hinge_profile.part, rotation=(90, 0, 0), mode=Mode.INTERSECT)
# Create holes for fasteners # Create holes for fasteners
with Workplanes(leaf_builder.part.faces().filter_by(Axis.Y)[-1]): with Locations(leaf_builder.part.faces().filter_by(Axis.Y)[-1]):
with GridLocations(0, length / 3, 1, 3): with GridLocations(0, length / 3, 1, 3):
holes = CounterSinkHole(3 * MM, 5 * MM) holes = CounterSinkHole(3 * MM, 5 * MM)
# Add the hinge pin to the external leaf # Add the hinge pin to the external leaf
@ -194,7 +194,7 @@ with BuildPart() as box_builder:
with Locations((-15 * CM, 0, 5 * CM)): with Locations((-15 * CM, 0, 5 * CM)):
Box(2 * CM, 12 * CM, 4 * MM, mode=Mode.SUBTRACT) Box(2 * CM, 12 * CM, 4 * MM, mode=Mode.SUBTRACT)
bbox = box.bounding_box() bbox = box.bounding_box()
with Workplanes( with Locations(
Plane(origin=(bbox.min.X, 0, bbox.max.Z - 30 * MM), z_dir=(-1, 0, 0)) Plane(origin=(bbox.min.X, 0, bbox.max.Z - 30 * MM), z_dir=(-1, 0, 0))
): ):
with GridLocations(0, 40 * MM, 1, 3): with GridLocations(0, 40 * MM, 1, 3):

View file

@ -44,7 +44,6 @@ __all__ = [
"HexLocations", "HexLocations",
"PolarLocations", "PolarLocations",
"Locations", "Locations",
"Workplanes",
"GridLocations", "GridLocations",
"BuildLine", "BuildLine",
"BuildPart", "BuildPart",

View file

@ -186,9 +186,9 @@ class Builder(ABC):
# If there are no workplanes, create a default XY plane # If there are no workplanes, create a default XY plane
if not self.workplanes and not WorkplaneList._get_context(): if not self.workplanes and not WorkplaneList._get_context():
self.workplanes_context = Workplanes(Plane.XY).__enter__() self.workplanes_context = WorkplaneList(Plane.XY).__enter__()
elif self.workplanes: elif self.workplanes:
self.workplanes_context = Workplanes(*self.workplanes).__enter__() self.workplanes_context = WorkplaneList(*self.workplanes).__enter__()
return self return self
@ -541,27 +541,25 @@ class Builder(ABC):
and self.__class__.__name__ not in operations_apply_to[validating_class] and self.__class__.__name__ not in operations_apply_to[validating_class]
): ):
raise RuntimeError( raise RuntimeError(
f"{self.__class__.__name__} doesn't have a " f"({validating_class} doesn't apply to {operations_apply_to[validating_class]})"
f"{validating_class} object or operation "
f"({validating_class} applies to {operations_apply_to[validating_class]})"
) )
# Check for valid object inputs # Check for valid object inputs
for obj in objects: for obj in objects:
operation = (
validating_class
if isinstance(validating_class, str)
else validating_class.__class__.__name__
)
if obj is None: if obj is None:
pass pass
elif isinstance(obj, Builder): elif isinstance(obj, Builder):
raise RuntimeError( raise RuntimeError(
f"{validating_class.__class__.__name__} doesn't accept Builders as input," f"{operation} doesn't accept Builders as input,"
f" did you intend <{obj.__class__.__name__}>.{obj._obj_name}?" f" did you intend <{obj.__class__.__name__}>.{obj._obj_name}?"
) )
elif isinstance(obj, list):
raise RuntimeError(
f"{validating_class.__class__.__name__} doesn't accept {type(obj).__name__},"
f" did you intend *{obj}?"
)
elif not isinstance(obj, Shape): elif not isinstance(obj, Shape):
raise RuntimeError( raise RuntimeError(
f"{validating_class.__class__.__name__} doesn't accept {type(obj).__name__}," f"{operation} doesn't accept {type(obj).__name__},"
f" did you intend <keyword>={obj}?" f" did you intend <keyword>={obj}?"
) )
@ -915,7 +913,7 @@ class WorkplaneList:
at all time. at all time.
Args: Args:
planes (list[Plane]): list of planes workplanes (sequence of Union[Face, Plane, Location]): objects to become planes
""" """
@ -924,9 +922,16 @@ class WorkplaneList:
"WorkplaneList._current" "WorkplaneList._current"
) )
def __init__(self, planes: list[Plane]): def __init__(self, *workplanes: Union[Face, Plane, Location]):
self._reset_tok = None self._reset_tok = None
self.workplanes = planes self.workplanes = []
for plane in workplanes:
if isinstance(plane, Plane):
self.workplanes.append(plane)
elif isinstance(plane, (Location, Face)):
self.workplanes.append(Plane(plane))
else:
raise ValueError(f"WorkplaneList does not accept {type(plane)}")
self.locations_context = None self.locations_context = None
self.plane_index = 0 self.plane_index = 0
@ -998,35 +1003,6 @@ class WorkplaneList:
return result return result
class Workplanes(WorkplaneList):
"""Workplane Context: Workplanes
Create workplanes from the given sequence of planes.
Args:
objs (Union[Face, Plane, Location]): sequence of faces, planes, or
locations to use to define workplanes.
Raises:
ValueError: invalid input
"""
def __init__(self, *objs: Union[Face, Plane, Location]):
# warnings.warn(
# "Workplanes may be deprecated - Post on Discord to save it",
# DeprecationWarning,
# stacklevel=2,
# )
self.workplanes = []
for obj in objs:
if isinstance(obj, Plane):
self.workplanes.append(obj)
elif isinstance(obj, (Location, Face)):
self.workplanes.append(Plane(obj))
else:
raise ValueError(f"Workplanes does not accept {type(obj)}")
super().__init__(self.workplanes)
# #
# To avoid import loops, Vector add & sub are monkey-patched # To avoid import loops, Vector add & sub are monkey-patched
def _vector_add(self: Vector, vec: VectorLike) -> Vector: def _vector_add(self: Vector, vec: VectorLike) -> Vector:

View file

@ -97,7 +97,7 @@ def add(
object_iter = objects if isinstance(objects, Iterable) else [objects] object_iter = objects if isinstance(objects, Iterable) else [objects]
validate_inputs(context, None, object_iter) validate_inputs(context, "add", object_iter)
if isinstance(context, BuildPart): if isinstance(context, BuildPart):
if rotation is None: if rotation is None:

View file

@ -26,6 +26,7 @@ license:
""" """
import unittest import unittest
from math import pi
from build123d import * from build123d import *
from build123d import Builder, WorkplaneList, LocationList from build123d import Builder, WorkplaneList, LocationList
@ -233,18 +234,30 @@ class TestShapeList(unittest.TestCase):
Box(1, 1, 1) Box(1, 1, 1)
self.assertEqual(len(test.part.vertices()), 8) self.assertEqual(len(test.part.vertices()), 8)
self.assertTrue(isinstance(test.part.vertices(), ShapeList)) self.assertTrue(isinstance(test.part.vertices(), ShapeList))
with self.assertRaises(ValueError):
with BuildPart() as test:
Box(1, 1, 1)
v = test.vertices("ALL")
def test_edges(self): def test_edges(self):
with BuildPart() as test: with BuildPart() as test:
Box(1, 1, 1) Box(1, 1, 1)
self.assertEqual(len(test.part.edges()), 12) self.assertEqual(len(test.part.edges()), 12)
self.assertTrue(isinstance(test.part.edges(), ShapeList)) self.assertTrue(isinstance(test.part.edges(), ShapeList))
with self.assertRaises(ValueError):
with BuildPart() as test:
Box(1, 1, 1)
v = test.edges("ALL")
def test_wires(self): def test_wires(self):
with BuildPart() as test: with BuildPart() as test:
Box(1, 1, 1) Box(1, 1, 1)
self.assertEqual(len(test.wires()), 6) self.assertEqual(len(test.wires()), 6)
self.assertTrue(isinstance(test.wires(), ShapeList)) self.assertTrue(isinstance(test.wires(), ShapeList))
with self.assertRaises(ValueError):
with BuildPart() as test:
Box(1, 1, 1)
v = test.wires("ALL")
def test_wires_last(self): def test_wires_last(self):
with BuildPart() as test: with BuildPart() as test:
@ -258,12 +271,20 @@ class TestShapeList(unittest.TestCase):
Box(1, 1, 1) Box(1, 1, 1)
self.assertEqual(len(test.part.faces()), 6) self.assertEqual(len(test.part.faces()), 6)
self.assertTrue(isinstance(test.part.faces(), ShapeList)) self.assertTrue(isinstance(test.part.faces(), ShapeList))
with self.assertRaises(ValueError):
with BuildPart() as test:
Box(1, 1, 1)
v = test.faces("ALL")
def test_solids(self): def test_solids(self):
with BuildPart() as test: with BuildPart() as test:
Box(1, 1, 1) Box(1, 1, 1)
self.assertEqual(len(test.part.solids()), 1) self.assertEqual(len(test.part.solids()), 1)
self.assertTrue(isinstance(test.part.solids(), ShapeList)) self.assertTrue(isinstance(test.part.solids(), ShapeList))
with self.assertRaises(ValueError):
with BuildPart() as test:
Box(1, 1, 1)
v = test.solids("ALL")
def test_compounds(self): def test_compounds(self):
with BuildPart() as test: with BuildPart() as test:
@ -271,6 +292,11 @@ class TestShapeList(unittest.TestCase):
self.assertEqual(len(test.part.compounds()), 1) self.assertEqual(len(test.part.compounds()), 1)
self.assertTrue(isinstance(test.part.compounds(), ShapeList)) self.assertTrue(isinstance(test.part.compounds(), ShapeList))
def test_shapes(self):
with BuildPart() as test:
Box(1, 1, 1)
self.assertIsNone(test._shapes(Compound))
class TestBuilder(unittest.TestCase): class TestBuilder(unittest.TestCase):
"""Test the Builder base class""" """Test the Builder base class"""
@ -287,73 +313,23 @@ class TestBuilder(unittest.TestCase):
make_face() make_face()
self.assertEqual(len(outer.pending_faces), 2) self.assertEqual(len(outer.pending_faces), 2)
def test_plane_with_no_x(self):
class TestWorkplanes(unittest.TestCase): with BuildPart() as p:
def test_named(self):
with Workplanes(Plane.XY) as test:
self.assertTupleAlmostEquals(
test.workplanes[0].origin.to_tuple(), (0, 0, 0), 5
)
self.assertTupleAlmostEquals(
test.workplanes[0].z_dir.to_tuple(), (0, 0, 1), 5
)
def test_locations(self):
with Workplanes(Plane.XY):
with Locations((0, 0, 1), (0, 0, 2)) as l:
with Workplanes(*l.locations) as w:
origins = [p.origin.to_tuple() for p in w.workplanes]
self.assertTupleAlmostEquals(origins[0], (0, 0, 1), 5)
self.assertTupleAlmostEquals(origins[1], (0, 0, 2), 5)
self.assertEqual(len(origins), 2)
def test_grid_locations(self):
with Workplanes(Plane(origin=(1, 2, 3))):
locs = GridLocations(4, 6, 2, 2).locations
self.assertTupleAlmostEquals(locs[0].position.to_tuple(), (-1, -1, 3), 5)
self.assertTupleAlmostEquals(locs[1].position.to_tuple(), (-1, 5, 3), 5)
self.assertTupleAlmostEquals(locs[2].position.to_tuple(), (3, -1, 3), 5)
self.assertTupleAlmostEquals(locs[3].position.to_tuple(), (3, 5, 3), 5)
def test_conversions(self):
loc = Location((1, 2, 3), (23, 45, 67))
loc2 = Workplanes(loc).workplanes[0].to_location()
self.assertTupleAlmostEquals(loc.to_tuple()[0], loc2.to_tuple()[0], 6)
self.assertTupleAlmostEquals(loc.to_tuple()[1], loc2.to_tuple()[1], 6)
loc = Location((-10, -2, 30), (-123, 145, 267))
face = Face.make_rect(1, 1).move(loc)
loc2 = Workplanes(face).workplanes[0].to_location()
face2 = Face.make_rect(1, 1).move(loc2)
self.assertTupleAlmostEquals(
face.center().to_tuple(), face2.center().to_tuple(), 6
)
self.assertTupleAlmostEquals(
face.normal_at(face.center()).to_tuple(),
face2.normal_at(face2.center()).to_tuple(),
6,
)
def test_bad_plane(self):
with self.assertRaises(ValueError):
with BuildPart(4):
pass
def test_locations_after_new_workplane(self):
with BuildPart(Plane.XY):
with Locations((0, 1, 2), (3, 4, 5)):
with BuildPart(Plane.XY.offset(2)):
self.assertTupleAlmostEquals(
LocationList._get_context().locations[0].position.to_tuple(),
(0, 0, 2),
5,
)
Box(1, 1, 1) Box(1, 1, 1)
front = p.faces().sort_by(Axis.X)[-1]
with BuildSketch(front):
offset(front, amount=-0.1)
extrude(amount=0.1)
self.assertAlmostEqual(p.part.volume, 1**3 + 0.1 * (1 - 2 * 0.1) ** 2, 4)
def test_no_workplane(self):
with BuildSketch() as s:
Circle(1)
class TestWorkplaneList(unittest.TestCase): class TestWorkplaneList(unittest.TestCase):
def test_iter(self): def test_iter(self):
for i, plane in enumerate(WorkplaneList([Plane.XY, Plane.YZ])): for i, plane in enumerate(WorkplaneList(Plane.XY, Plane.YZ)):
if i == 0: if i == 0:
self.assertTrue(plane == Plane.XY) self.assertTrue(plane == Plane.XY)
elif i == 1: elif i == 1:
@ -365,35 +341,47 @@ class TestWorkplaneList(unittest.TestCase):
self.assertTupleAlmostEquals(pnts[0].to_tuple(), (0, 1, 2), 5) self.assertTupleAlmostEquals(pnts[0].to_tuple(), (0, 1, 2), 5)
self.assertTupleAlmostEquals(pnts[1].to_tuple(), (0, 2, 3), 5) self.assertTupleAlmostEquals(pnts[1].to_tuple(), (0, 2, 3), 5)
def test_invalid_workplane(self):
with self.assertRaises(ValueError):
WorkplaneList(Vector(1, 1, 1))
class TestValidateInputs(unittest.TestCase): class TestValidateInputs(unittest.TestCase):
# def test_no_builder(self):
# with self.assertRaises(RuntimeError):
# Circle(1)
def test_wrong_builder(self): def test_wrong_builder(self):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError) as rte:
with BuildPart(): with BuildPart():
Circle(1) Circle(1)
self.assertEqual(
"BuildPart doesn't have a Circle object or operation (Circle applies to ['BuildSketch'])",
str(rte.exception),
)
def test_bad_builder_input(self): def test_bad_builder_input(self):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError) as rte:
with BuildPart() as p: with BuildPart() as p:
Box(1, 1, 1) Box(1, 1, 1)
with BuildSketch(): with BuildSketch():
add(p) add(p)
self.assertEqual(
"add doesn't accept Builders as input, did you intend <BuildPart>.part?",
str(rte.exception),
)
def test_no_sequence(self): def test_no_sequence(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError) as rte:
with BuildPart() as p: with BuildPart() as p:
Box(1, 1, 1) Box(1, 1, 1)
fillet([None, None], radius=1) fillet([None, None], radius=1)
self.assertEqual("3D fillet operation takes only Edges", str(rte.exception))
def test_wrong_type(self): def test_wrong_type(self):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError) as rte:
with BuildPart() as p: with BuildPart() as p:
Box(1, 1, 1) Box(1, 1, 1)
fillet(4, radius=1) fillet(4, radius=1)
self.assertEqual(
"fillet doesn't accept int, did you intend <keyword>=4?", str(rte.exception)
)
class TestBuilderExit(unittest.TestCase): class TestBuilderExit(unittest.TestCase):
@ -522,6 +510,23 @@ class TestLocations(unittest.TestCase):
loc.orientation.to_tuple(), Location(Plane.XZ).orientation.to_tuple(), 5 loc.orientation.to_tuple(), Location(Plane.XZ).orientation.to_tuple(), 5
) )
def test_from_plane(self):
with BuildPart():
loc = Locations(Plane.XY.offset(1)).locations[0]
self.assertTupleAlmostEquals(loc.position.to_tuple(), (0, 0, 1), 5)
def test_from_axis(self):
with BuildPart():
loc = Locations(Axis((1, 1, 1), (0, 0, 1))).locations[0]
self.assertTupleAlmostEquals(loc.position.to_tuple(), (1, 1, 1), 5)
def test_multiplication(self):
circles = GridLocations(2, 2, 2, 2) * Circle(1)
self.assertEqual(len(circles), 4)
with self.assertRaises(ValueError):
GridLocations(2, 2, 2, 2) * "error"
class TestVectorExtensions(unittest.TestCase): class TestVectorExtensions(unittest.TestCase):
def test_vector_localization(self): def test_vector_localization(self):