filter/map/apply/sort/[]/invoke (#1514)

* PoC commit for discussion

* Rework

Remove group
Rename
More generic []
Add apply / change map

* Iterable check fix

* typing fix

* Add docstrings

* Add tests

* Black fix

* fix test

* Another fix

* Test fix

* Add invoke

* Support builtins

* Add test for invoke

* Better coverage

* Add some docs on ad-hoc selection

* Mention special methods for extending

* Typo fix

* Better docstring and typos

Co-authored-by: Matti Eiden <snaipperi@gmail.com>

* Typo fix

Co-authored-by: Matti Eiden <snaipperi@gmail.com>

---------

Co-authored-by: AU <adam-urbanczyk@users.noreply.github.com>
This commit is contained in:
Matti Eiden
2024-07-03 19:48:31 +03:00
committed by GitHub
parent d9ccd25891
commit 9ee703da34
4 changed files with 200 additions and 4 deletions

View File

@ -35,7 +35,7 @@ from typing import (
Dict,
)
from typing_extensions import Literal
from inspect import Parameter, Signature
from inspect import Parameter, Signature, isbuiltin
from .occ_impl.geom import Vector, Plane, Location
@ -4430,6 +4430,82 @@ class Workplane(object):
_selectShapes(self.objects)
)._repr_javascript_()
def __getitem__(self: T, item: Union[int, Sequence[int], slice]) -> T:
if isinstance(item, Iterable):
rv = self.newObject(self.objects[i] for i in item)
elif isinstance(item, slice):
rv = self.newObject(self.objects[item])
else:
rv = self.newObject([self.objects[item]])
return rv
def filter(self: T, f: Callable[[CQObject], bool]) -> T:
"""
Filter items using a boolean predicate.
:param f: Callable to be used for filtering.
:return: Workplane object with filtered items.
"""
return self.newObject(filter(f, self.objects))
def map(self: T, f: Callable[[CQObject], CQObject]):
"""
Apply a callable to every item separately.
:param f: Callable to be applied to every item separately.
:return: Workplane object with f applied to all items.
"""
return self.newObject(map(f, self.objects))
def apply(self: T, f: Callable[[Iterable[CQObject]], Iterable[CQObject]]):
"""
Apply a callable to all items at once.
:param f: Callable to be applied.
:return: Workplane object with f applied to all items.
"""
return self.newObject(f(self.objects))
def sort(self: T, key: Callable[[CQObject], Any]) -> T:
"""
Sort items using a callable.
:param key: Callable to be used for sorting.
:return: Workplane object with items sorted.
"""
return self.newObject(sorted(self.objects, key=key))
def invoke(
self: T, f: Union[Callable[[T], T], Callable[[T], None], Callable[[], None]]
):
"""
Invoke a callable mapping Workplane to Workplane or None. Supports also
callables that take no arguments such as breakpoint. Returns self if callable
returns None.
:param f: Callable to be invoked.
:return: Workplane object.
"""
if isbuiltin(f):
arity = 0 # assume 0 arity for builtins; they cannot be introspected
else:
arity = f.__code__.co_argcount # NB: this is not understood by mypy
rv = self
if arity == 0:
f() # type: ignore
elif arity == 1:
res = f(self) # type: ignore
if res is not None:
rv = res
else:
raise ValueError("Provided function {f} accepts too many arguments")
return rv
# alias for backward compatibility
CQ = Workplane

View File

@ -163,7 +163,9 @@ This ultra simple plugin makes cubes of the specified size for each stack point.
(The cubes are off-center because the boxes have their lower left corner at the reference points.)
.. code-block:: python
.. cadquery::
from cadquery.occ_impl.shapes import box
def makeCubes(self, length):
# self refers to the CQ or Workplane object
@ -172,7 +174,7 @@ This ultra simple plugin makes cubes of the specified size for each stack point.
def _singleCube(loc):
# loc is a location in local coordinates
# since we're using eachpoint with useLocalCoordinates=True
return cq.Solid.makeBox(length, length, length, pnt).locate(loc)
return box(length, length, length).locate(loc)
# use CQ utility method to iterate over the stack, call our
# method, and convert to/from local coordinates.
@ -193,3 +195,42 @@ This ultra simple plugin makes cubes of the specified size for each stack point.
.combineSolids()
)
Extending CadQuery: Special Methods
-----------------------------------
The above-mentioned approach has one drawback, it requires monkey-patching or subclassing. To avoid this
one can also use the following special methods of :py:class:`cadquery.Workplane`
and write plugins in a more functional style.
* :py:meth:`cadquery.Workplane.map`
* :py:meth:`cadquery.Workplane.apply`
* :py:meth:`cadquery.Workplane.invoke`
Here is the same plugin rewritten using one of those methods.
.. cadquery::
from cadquery.occ_impl.shapes import box
def makeCubes(length):
# inner method that creates the cubes
def callback(wp):
return wp.eachpoint(box(length, length, length), True)
return callback
# use the plugin
result = (
cq.Workplane("XY")
.box(6.0, 8.0, 0.5)
.faces(">Z")
.rect(4.0, 4.0, forConstruction=True)
.vertices()
.invoke(makeCubes(1.0))
.combineSolids()
)
Such an approach is more friendly for auto-completion and static analysis tools.

View File

@ -185,3 +185,45 @@ objects. This includes chaining and combining.
# select top and bottom wires
result = box.faces(">Z or <Z").wires()
Additional special methods
--------------------------
:py:class:`cadquery.Workplane` provides the following special methods that can be used
for quick prototyping of selectors when implementing a complete selector via subclassing of
:py:class:`cadquery.Selector` is not desirable.
* :py:meth:`cadquery.Workplane.filter`
* :py:meth:`cadquery.Workplane.sort`
* :py:meth:`cadquery.Workplane.__getitem__`
For example, one could use those methods for selecting objects within a certain range of volumes.
.. cadquery::
from cadquery.occ_impl.shapes import box
result = (
cq.Workplane()
.add([box(1,1,i+1).moved(x=2*i) for i in range(5)])
)
# select boxes with volume <= 3
result = result.filter(lambda s: s.Volume() <= 3)
The same can be achieved using sorting.
.. cadquery::
from cadquery.occ_impl.shapes import box
result = (
cq.Workplane()
.add([box(1,1,i+1).moved(x=2*i) for i in range(5)])
)
# select boxes with volume <= 3
result = result.sort(lambda s: s.Volume())[:3]

View File

@ -1786,7 +1786,7 @@ class TestCadQuery(BaseTest):
def testBoundBoxEnlarge(self):
"""
Tests BoundBox.enlarge(). Confirms that the
Tests BoundBox.enlarge(). Confirms that the
bounding box lengths are all enlarged by the
correct amount.
"""
@ -5741,6 +5741,43 @@ class TestCadQuery(BaseTest):
res7 = list(fs.siblings(c, "Edge", 2))
assert len(res7) == 2
def test_map_apply_filter_sort(self):
w = Workplane().box(1, 1, 1).moveTo(3, 0).box(1, 1, 3).solids()
assert w.filter(lambda s: s.Volume() > 2).size() == 1
assert w.filter(lambda s: s.Volume() > 5).size() == 0
assert w.sort(lambda s: -s.Volume())[-1].val().Volume() == approx(1)
assert w.apply(lambda obj: []).size() == 0
assert w.map(lambda s: s.faces(">Z")).faces().size() == 2
def test_getitem(self):
w = Workplane().rarray(2, 1, 5, 1).box(1, 1, 1, combine=False)
assert w[0].solids().size() == 1
assert w[-2:].solids().size() == 2
assert w[[0, 1]].solids().size() == 2
def test_invoke(self):
w = Workplane().rarray(2, 1, 5, 1).box(1, 1, 1, combine=False)
# builtin
assert w.invoke(print).size() == 5
# arity 0
assert w.invoke(lambda: 1).size() == 5
# arity 1 and no return
assert w.invoke(lambda x: None).size() == 5
# arity 1
assert w.invoke(lambda x: x.newObject([x.val()])).size() == 1
# test exception with wrong arity
with raises(ValueError):
w.invoke(lambda x, y: 1)
def test_tessellate(self):
# happy flow