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:: Until
**********
Workplanes
**********
.. py:module:: build_common
.. autoclass:: Workplanes
*********
Locations
*********

View file

@ -8,7 +8,6 @@ Cheat Sheet
| :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.Workplanes`
.. card:: Objects

View file

@ -271,7 +271,7 @@ provide multiple output formats, support for multiple languages and can be
integrated with code management tools.
****************************************
Key Concepts (context mode)
Key Concepts (builder mode)
****************************************
.. 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.
Builders
@ -59,39 +67,36 @@ information - as follows:
In this example, ``Box`` is in the scope of ``part_builder`` while ``Circle``
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
frequently will work in the same plane for a sequence of operations, a workplane is used
to aid in the location of features. The default workplane in most cases is the XY plane
frequently will work in the same plane for a sequence of operations, the first parameter(s)
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
be generated on any plane which allows users to put a workplane where they are working
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
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() as side:
with BuildSketch(Plane.XZ) as vertical:
...
with Workplanes(example.faces().sort_by(SortBy.Z)[-1]):
with BuildSketch() as top:
with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[-1]) as top:
...
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
the user has created the ``vertical`` plane (XZ) on which the ``side`` sketch is created. All
objects or operations within the scope of a workplane will automatically be orientated with
default of the ``Plane.XY``). The ``bottom`` sketch is therefore created on the ``Plane.XY`` but with the
normal reversed to point down. Subsequently the user has created the ``vertical`` (``Plane.XZ```) sketch.
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.
Workplanes can be created from faces as well. The ``top`` sketch is positioned on top
of ``example`` by selecting its faces and finding the one with the greatest z value.
As shown above, workplanes can be created from faces as well. The ``top`` sketch is
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
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:
bd.Box(3, 3, 3)
with bd.Workplanes(*bp.faces()):
bd.Box(1, 2, 0.1, rotation=(0, 0, 45))
with bd.BuildSketch(*bp.faces()):
bd.Rectangle(1, 2, rotation=45)
bd.extrude(amount=0.1)
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
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
to retrieve the global locations relative to the current workplane(s) as follows:
Also note that the locations are local to the current location(s) - i.e. ``Locations`` can be
nested. It's easy for a user to retrieve the global locations:
.. code-block:: python
with Workplanes(Plane.XY, Plane.XZ):
with Locations(Plane.XY, Plane.XZ):
locs = GridLocations(1, 1, 2, 2)
for l in locs:
print(l)

View file

@ -119,7 +119,7 @@ class Hinge(Compound):
add(hinge_profile.part, rotation=(90, 0, 0), mode=Mode.INTERSECT)
# 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):
holes = CounterSinkHole(3 * MM, 5 * MM)
# Add the hinge pin to the external leaf
@ -194,7 +194,7 @@ with BuildPart() as box_builder:
with Locations((-15 * CM, 0, 5 * CM)):
Box(2 * CM, 12 * CM, 4 * MM, mode=Mode.SUBTRACT)
bbox = box.bounding_box()
with Workplanes(
with Locations(
Plane(origin=(bbox.min.X, 0, bbox.max.Z - 30 * MM), z_dir=(-1, 0, 0))
):
with GridLocations(0, 40 * MM, 1, 3):

View file

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

View file

@ -186,9 +186,9 @@ class Builder(ABC):
# If there are no workplanes, create a default XY plane
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:
self.workplanes_context = Workplanes(*self.workplanes).__enter__()
self.workplanes_context = WorkplaneList(*self.workplanes).__enter__()
return self
@ -541,27 +541,25 @@ class Builder(ABC):
and self.__class__.__name__ not in operations_apply_to[validating_class]
):
raise RuntimeError(
f"{self.__class__.__name__} doesn't have a "
f"{validating_class} object or operation "
f"({validating_class} applies to {operations_apply_to[validating_class]})"
f"({validating_class} doesn't apply to {operations_apply_to[validating_class]})"
)
# Check for valid object inputs
for obj in objects:
operation = (
validating_class
if isinstance(validating_class, str)
else validating_class.__class__.__name__
)
if obj is None:
pass
elif isinstance(obj, Builder):
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}?"
)
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):
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}?"
)
@ -915,7 +913,7 @@ class WorkplaneList:
at all time.
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"
)
def __init__(self, planes: list[Plane]):
def __init__(self, *workplanes: Union[Face, Plane, Location]):
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.plane_index = 0
@ -998,35 +1003,6 @@ class WorkplaneList:
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
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]
validate_inputs(context, None, object_iter)
validate_inputs(context, "add", object_iter)
if isinstance(context, BuildPart):
if rotation is None:

View file

@ -26,6 +26,7 @@ license:
"""
import unittest
from math import pi
from build123d import *
from build123d import Builder, WorkplaneList, LocationList
@ -233,18 +234,30 @@ class TestShapeList(unittest.TestCase):
Box(1, 1, 1)
self.assertEqual(len(test.part.vertices()), 8)
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):
with BuildPart() as test:
Box(1, 1, 1)
self.assertEqual(len(test.part.edges()), 12)
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):
with BuildPart() as test:
Box(1, 1, 1)
self.assertEqual(len(test.wires()), 6)
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):
with BuildPart() as test:
@ -258,12 +271,20 @@ class TestShapeList(unittest.TestCase):
Box(1, 1, 1)
self.assertEqual(len(test.part.faces()), 6)
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):
with BuildPart() as test:
Box(1, 1, 1)
self.assertEqual(len(test.part.solids()), 1)
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):
with BuildPart() as test:
@ -271,6 +292,11 @@ class TestShapeList(unittest.TestCase):
self.assertEqual(len(test.part.compounds()), 1)
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):
"""Test the Builder base class"""
@ -287,73 +313,23 @@ class TestBuilder(unittest.TestCase):
make_face()
self.assertEqual(len(outer.pending_faces), 2)
class TestWorkplanes(unittest.TestCase):
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,
)
def test_plane_with_no_x(self):
with BuildPart() as p:
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):
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:
self.assertTrue(plane == Plane.XY)
elif i == 1:
@ -365,35 +341,47 @@ class TestWorkplaneList(unittest.TestCase):
self.assertTupleAlmostEquals(pnts[0].to_tuple(), (0, 1, 2), 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):
# def test_no_builder(self):
# with self.assertRaises(RuntimeError):
# Circle(1)
def test_wrong_builder(self):
with self.assertRaises(RuntimeError):
with self.assertRaises(RuntimeError) as rte:
with BuildPart():
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):
with self.assertRaises(RuntimeError):
with self.assertRaises(RuntimeError) as rte:
with BuildPart() as p:
Box(1, 1, 1)
with BuildSketch():
add(p)
self.assertEqual(
"add doesn't accept Builders as input, did you intend <BuildPart>.part?",
str(rte.exception),
)
def test_no_sequence(self):
with self.assertRaises(ValueError):
with self.assertRaises(ValueError) as rte:
with BuildPart() as p:
Box(1, 1, 1)
fillet([None, None], radius=1)
self.assertEqual("3D fillet operation takes only Edges", str(rte.exception))
def test_wrong_type(self):
with self.assertRaises(RuntimeError):
with self.assertRaises(RuntimeError) as rte:
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 TestBuilderExit(unittest.TestCase):
@ -522,6 +510,23 @@ class TestLocations(unittest.TestCase):
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):
def test_vector_localization(self):