From 51932b1cb60f6f200706dba5bb3a841d7ecce528 Mon Sep 17 00:00:00 2001 From: Fedor Kotov Date: Sun, 14 Mar 2021 21:55:39 +0300 Subject: [PATCH 1/8] Added AreaNthSelector Added AreaNthSelector that is useful for nested features selection. Especially to select one of coplanar nested wires for subsequent extrusion, cutting or filleting. --- cadquery/occ_impl/shapes.py | 9 +- cadquery/selectors.py | 58 +++++++++++++ examples/Ex026_Case_Seam_Lip.py | 46 ++++++++++ tests/__init__.py | 4 +- tests/test_selectors.py | 146 ++++++++++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 examples/Ex026_Case_Seam_Lip.py diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index b33b2ee66..716bd50cb 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -290,7 +290,14 @@ } Shapes = Literal[ - "Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "CompSolid", "Compound" + "Vertex", + "Edge", + "Wire", + "Face", + "Shell", + "Solid", + "CompSolid", + "Compound" ] Geoms = Literal[ "Vertex", diff --git a/cadquery/selectors.py b/cadquery/selectors.py index 9a0be8144..3bae5474b 100644 --- a/cadquery/selectors.py +++ b/cadquery/selectors.py @@ -453,6 +453,64 @@ def filter(self, objectlist: Sequence[Shape]) -> List[Shape]: objectlist = _NthSelector.filter(self, objectlist) return objectlist +class AreaNthSelector(_NthSelector): + """ + Selects object(s) with Nth area. + + Applicability: + Faces, Shells, Solids - Shape.Area() is used to compute area + planar Wires - a temporary face is created to compute area + + Among other things can be used to select one of + the nested coplanar wires or faces. + + For example to create a fillet on a shank: + + cq.Workplane("XY")\ + .circle(5)\ + .extrude(2)\ + .circle(2)\ + .extrude(10)\ + .faces(">Z[-2]")\ + .wires(AreaNthSelector(0))\ + .fillet(2) + + Or to create a lip on a case seam: + + cq.Workplane("XY")\ + .rect(20, 20)\ + .extrude(10)\ + .edges("|Z or Z")\ + .shell(2)\ + .faces(">Z")\ + .wires(AreaNthSelector(-1))\ + .toPending()\ + .workplane()\ + .offset2D(-1)\ + .extrude(1)\ + .faces(">Z[-2]")\ + .wires(AreaNthSelector(0))\ + .toPending()\ + .workplane()\ + .cutBlind(2) + """ + def key(self, obj: Shape) -> float: + shape_type = obj.ShapeType() + + if shape_type in ("Face", + "Shell", + "Solid"): + return obj.Area() + elif shape_type == "Wire": + return Face.makeFromWires(obj).Area() + else: + raise TypeError( + "AreaNthSelector supports only Wires, "\ + "Faces, Shells and Solids, not {}"\ + .format(shape_type)) + class BinarySelector(Selector): """ diff --git a/examples/Ex026_Case_Seam_Lip.py b/examples/Ex026_Case_Seam_Lip.py new file mode 100644 index 000000000..04f11faf1 --- /dev/null +++ b/examples/Ex026_Case_Seam_Lip.py @@ -0,0 +1,46 @@ +import cadquery as cq + +case_bottom = ( + cq.Workplane("XY") + .rect(20, 20) + .extrude(10) # solid 20x20x10 box + .edges("|Z or Z") + .shell(2) # shell of thickness 2 with top face open + .faces(">Z") + .wires(AreaNthSelector(-1)) # selecting top outer wire + .toPending() + .workplane() + .offset2D(-1) # creating centerline wire of case seam face + .extrude(1) # covering the sell with temporary "lid" + .faces(">Z[-2]")\ + .wires(AreaNthSelector(0)) # selecting case crossection wire + .toPending() + .workplane() + .cutBlind(2) # cutting through the "lid" leaving a lip on case seam surface +) + +# similar process repeated for the top part +# but instead of "growing" an inner lip +# material is removed inside case seam centerline +# to create an outer lip +case_top = ( + cq.Workplane("XY") + .move(25) + .rect(20, 20) + .extrude(5) + .edges("|Z or >Z") + .fillet(2) + .faces("Z")\ + .shell(-5, "intersection")\ + .faces(">Z")\ + .wires(selectors.AreaNthSelector(-1)) + + self.assertEqual( + len(wp.vals()), + 1, + msg="Failed to select top outermost wire "\ + "of the box: wrong N wires") + self.assertAlmostEqual( + Face.makeFromWires(wp.val()).Area(), + 50 * 50, + msg="Failed to select top outermost wire "\ + "of the box: wrong wire area") + + # preparing to add an inside lip to the box + wp = \ + wp.toPending()\ + .workplane()\ + .offset2D(-2)\ + .extrude(1)\ + .faces(">Z[-2]") + # workplane now has 2 faces selected: + # a square and a thin rectangular frame + + wp_outer_wire = \ + wp.wires(selectors.AreaNthSelector(-1)) + self.assertEqual( + len(wp_outer_wire.vals()), + 1, + msg="Failed to select outermost wire "\ + "of 2 faces: wrong N wires") + self.assertAlmostEqual( + Face.makeFromWires(wp_outer_wire.val()).Area(), + 50 * 50, + msg="Failed to select outermost wire "\ + "of 2 faces: wrong area") + + wp_mid_wire = \ + wp.wires(selectors.AreaNthSelector(1)) + self.assertEqual( + len(wp_mid_wire.vals()), + 1, + msg="Failed to select middle wire "\ + "of 2 faces: wrong N wires") + self.assertAlmostEqual( + Face.makeFromWires(wp_mid_wire.val()).Area(), + (50-2*2) * (50-2*2), + msg="Failed to select middle wire "\ + "of 2 faces: wrong area") + + wp_inner_wire = \ + wp.wires(selectors.AreaNthSelector(0)) + self.assertEqual( + len(wp_inner_wire.vals()), + 1, + msg="Failed to select inner wire "\ + "of 2 faces: wrong N wires") + self.assertAlmostEqual( + Face.makeFromWires(wp_inner_wire.val()).Area(), + (50-5*2) * (50-5*2), + msg="Failed to select inner wire "\ + "of 2 faces: wrong area") + + def testAreaNthSelector_Faces(self): + wp = \ + Workplane("XY")\ + .box(10, 20, 30)\ + .faces(selectors.AreaNthSelector(1)) + + self.assertEqual( + len(wp.vals()), + 2, + msg="Failed to select two faces of 10-20-30 box "\ + "with intermediate area: wrong N faces") + self.assertTupleAlmostEquals( + (wp.vals()[0].Area(), wp.vals()[1].Area()), + (10*30, 10*30), + 7, + msg="Failed to select two faces of 10-20-30 box "\ + "with intermediate area: wrong area") + + def testAreaNthSelector_Shells(self): + shells = \ + [Shell.makeShell( + Workplane("XY").box(20, 20, 20).faces().vals()), + Shell.makeShell( + Workplane("XY").box(10, 10, 10).faces().vals()), + Shell.makeShell( + Workplane("XY").box(30, 30, 30).faces().vals())] + + selected_shells = \ + selectors.AreaNthSelector(0).filter(shells) + + self.assertEqual( + len(selected_shells), + 1, + msg="Failed to select the smallest shell "\ + ": wrong N shells") + self.assertAlmostEqual( + selected_shells[0].Area(), + 10*10*6, + msg="Failed to select the smallest shell "\ + ": wrong area") + + def testAreaNthSelector_Solids(self): + shells = \ + [Workplane("XY").box(20, 20, 20).solids().val(), + Workplane("XY").box(10, 10, 10).solids().val(), + Workplane("XY").box(30, 30, 30).solids().val()] + + selected_shells = \ + selectors.AreaNthSelector(0).filter(shells) + + self.assertEqual( + len(selected_shells), + 1, + msg="Failed to select the smallest solid "\ + ": wrong N shells") + self.assertAlmostEqual( + selected_shells[0].Area(), + 10*10*6, + msg="Failed to select the smallest solid "\ + ": wrong area") + def testAndSelector(self): c = CQ(makeUnitCube()) From 109c07de434ff75559277170342dd3154de0792a Mon Sep 17 00:00:00 2001 From: Fedor Kotov Date: Sun, 14 Mar 2021 22:19:12 +0300 Subject: [PATCH 2/8] Fixed error: AreaNthSelector not imported in example 26 --- examples/Ex026_Case_Seam_Lip.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/Ex026_Case_Seam_Lip.py b/examples/Ex026_Case_Seam_Lip.py index 04f11faf1..a133ea2d4 100644 --- a/examples/Ex026_Case_Seam_Lip.py +++ b/examples/Ex026_Case_Seam_Lip.py @@ -1,4 +1,5 @@ import cadquery as cq +from cadquery.selectors import AreaNthSelector case_bottom = ( cq.Workplane("XY") From fe58219248a2276b03f2c5cfe167365ab42b95bf Mon Sep 17 00:00:00 2001 From: Fedor Kotov Date: Sun, 14 Mar 2021 22:57:05 +0300 Subject: [PATCH 3/8] Using cast to appease mypy type checker --- cadquery/selectors.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cadquery/selectors.py b/cadquery/selectors.py index 3bae5474b..8bd6da3a7 100644 --- a/cadquery/selectors.py +++ b/cadquery/selectors.py @@ -37,7 +37,7 @@ Keyword, ) from functools import reduce -from typing import List, Union, Sequence +from typing import List, Union, Sequence, cast class Selector(object): @@ -504,7 +504,9 @@ def key(self, obj: Shape) -> float: "Solid"): return obj.Area() elif shape_type == "Wire": - return Face.makeFromWires(obj).Area() + return Face.makeFromWires( + cast(Wire, obj))\ + .Area() else: raise TypeError( "AreaNthSelector supports only Wires, "\ From bb7a465091c5dccefdbd54d3dd7a6559aaa2e13a Mon Sep 17 00:00:00 2001 From: Fedor Kotov Date: Mon, 15 Mar 2021 09:34:09 +0300 Subject: [PATCH 4/8] Sacrificing some readability to Black Formatter --- cadquery/occ_impl/shapes.py | 9 +- cadquery/selectors.py | 20 ++-- examples/Ex026_Case_Seam_Lip.py | 22 ++-- tests/test_selectors.py | 193 +++++++++++++++----------------- 4 files changed, 109 insertions(+), 135 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 716bd50cb..b33b2ee66 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -290,14 +290,7 @@ } Shapes = Literal[ - "Vertex", - "Edge", - "Wire", - "Face", - "Shell", - "Solid", - "CompSolid", - "Compound" + "Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "CompSolid", "Compound" ] Geoms = Literal[ "Vertex", diff --git a/cadquery/selectors.py b/cadquery/selectors.py index 8bd6da3a7..a5723458c 100644 --- a/cadquery/selectors.py +++ b/cadquery/selectors.py @@ -453,6 +453,7 @@ def filter(self, objectlist: Sequence[Shape]) -> List[Shape]: objectlist = _NthSelector.filter(self, objectlist) return objectlist + class AreaNthSelector(_NthSelector): """ Selects object(s) with Nth area. @@ -495,23 +496,20 @@ class AreaNthSelector(_NthSelector): .toPending()\ .workplane()\ .cutBlind(2) - """ - def key(self, obj: Shape) -> float: + """ + + def key(self, obj: Shape) -> float: shape_type = obj.ShapeType() - if shape_type in ("Face", - "Shell", - "Solid"): + if shape_type in ("Face", "Shell", "Solid"): return obj.Area() elif shape_type == "Wire": - return Face.makeFromWires( - cast(Wire, obj))\ - .Area() + return Face.makeFromWires(cast(Wire, obj)).Area() else: raise TypeError( - "AreaNthSelector supports only Wires, "\ - "Faces, Shells and Solids, not {}"\ - .format(shape_type)) + "AreaNthSelector supports only Wires, " + "Faces, Shells and Solids, not {}".format(shape_type) + ) class BinarySelector(Selector): diff --git a/examples/Ex026_Case_Seam_Lip.py b/examples/Ex026_Case_Seam_Lip.py index a133ea2d4..13777968f 100644 --- a/examples/Ex026_Case_Seam_Lip.py +++ b/examples/Ex026_Case_Seam_Lip.py @@ -4,19 +4,19 @@ case_bottom = ( cq.Workplane("XY") .rect(20, 20) - .extrude(10) # solid 20x20x10 box + .extrude(10) # solid 20x20x10 box .edges("|Z or Z") - .shell(2) # shell of thickness 2 with top face open + .shell(2) # shell of thickness 2 with top face open .faces(">Z") - .wires(AreaNthSelector(-1)) # selecting top outer wire + .wires(AreaNthSelector(-1)) # selecting top outer wire .toPending() .workplane() - .offset2D(-1) # creating centerline wire of case seam face - .extrude(1) # covering the sell with temporary "lid" - .faces(">Z[-2]")\ - .wires(AreaNthSelector(0)) # selecting case crossection wire + .offset2D(-1) # creating centerline wire of case seam face + .extrude(1) # covering the sell with temporary "lid" + .faces(">Z[-2]") + .wires(AreaNthSelector(0)) # selecting case crossection wire .toPending() .workplane() .cutBlind(2) # cutting through the "lid" leaving a lip on case seam surface @@ -24,7 +24,7 @@ # similar process repeated for the top part # but instead of "growing" an inner lip -# material is removed inside case seam centerline +# material is removed inside case seam centerline # to create an outer lip case_top = ( cq.Workplane("XY") @@ -42,6 +42,6 @@ .offset2D(-1) .cutBlind(-1) ) - + show_object(case_bottom) -show_object(case_top, options={"alpha":0.5}) \ No newline at end of file +show_object(case_top, options={"alpha": 0.5}) diff --git a/tests/test_selectors.py b/tests/test_selectors.py index 768e1206a..e2387254b 100644 --- a/tests/test_selectors.py +++ b/tests/test_selectors.py @@ -702,149 +702,132 @@ def testRadiusNthSelector(self): def testAreaNthSelector_Vertices(self): with self.assertRaises(TypeError): - wp = \ - Workplane("XY")\ - .box(10,10,10)\ - .vertices(selectors.AreaNthSelector(0)) + wp = Workplane("XY").box(10, 10, 10).vertices(selectors.AreaNthSelector(0)) def testAreaNthSelector_Edges(self): with self.assertRaises(TypeError): - wp = \ - Workplane("XY")\ - .box(10,10,10)\ - .edges(selectors.AreaNthSelector(0)) + wp = Workplane("XY").box(10, 10, 10).edges(selectors.AreaNthSelector(0)) def testAreaNthSelector_Wires(self): # selecting top outermost wire of square box - wp = \ - Workplane("XY")\ - .rect(50,50)\ - .extrude(50)\ - .faces(">Z")\ - .shell(-5, "intersection")\ - .faces(">Z")\ + wp = ( + Workplane("XY") + .rect(50, 50) + .extrude(50) + .faces(">Z") + .shell(-5, "intersection") + .faces(">Z") .wires(selectors.AreaNthSelector(-1)) - + ) + self.assertEqual( - len(wp.vals()), + len(wp.vals()), 1, - msg="Failed to select top outermost wire "\ - "of the box: wrong N wires") + msg="Failed to select top outermost wire " "of the box: wrong N wires", + ) self.assertAlmostEqual( - Face.makeFromWires(wp.val()).Area(), + Face.makeFromWires(wp.val()).Area(), 50 * 50, - msg="Failed to select top outermost wire "\ - "of the box: wrong wire area") - - # preparing to add an inside lip to the box - wp = \ - wp.toPending()\ - .workplane()\ - .offset2D(-2)\ - .extrude(1)\ - .faces(">Z[-2]") - # workplane now has 2 faces selected: + msg="Failed to select top outermost wire " "of the box: wrong wire area", + ) + + # preparing to add an inside lip to the box + wp = wp.toPending().workplane().offset2D(-2).extrude(1).faces(">Z[-2]") + # workplane now has 2 faces selected: # a square and a thin rectangular frame - wp_outer_wire = \ - wp.wires(selectors.AreaNthSelector(-1)) + wp_outer_wire = wp.wires(selectors.AreaNthSelector(-1)) self.assertEqual( - len(wp_outer_wire.vals()), + len(wp_outer_wire.vals()), 1, - msg="Failed to select outermost wire "\ - "of 2 faces: wrong N wires") + msg="Failed to select outermost wire " "of 2 faces: wrong N wires", + ) self.assertAlmostEqual( - Face.makeFromWires(wp_outer_wire.val()).Area(), + Face.makeFromWires(wp_outer_wire.val()).Area(), 50 * 50, - msg="Failed to select outermost wire "\ - "of 2 faces: wrong area") + msg="Failed to select outermost wire " "of 2 faces: wrong area", + ) - wp_mid_wire = \ - wp.wires(selectors.AreaNthSelector(1)) + wp_mid_wire = wp.wires(selectors.AreaNthSelector(1)) self.assertEqual( - len(wp_mid_wire.vals()), + len(wp_mid_wire.vals()), 1, - msg="Failed to select middle wire "\ - "of 2 faces: wrong N wires") + msg="Failed to select middle wire " "of 2 faces: wrong N wires", + ) self.assertAlmostEqual( - Face.makeFromWires(wp_mid_wire.val()).Area(), - (50-2*2) * (50-2*2), - msg="Failed to select middle wire "\ - "of 2 faces: wrong area") + Face.makeFromWires(wp_mid_wire.val()).Area(), + (50 - 2 * 2) * (50 - 2 * 2), + msg="Failed to select middle wire " "of 2 faces: wrong area", + ) - wp_inner_wire = \ - wp.wires(selectors.AreaNthSelector(0)) + wp_inner_wire = wp.wires(selectors.AreaNthSelector(0)) self.assertEqual( - len(wp_inner_wire.vals()), + len(wp_inner_wire.vals()), 1, - msg="Failed to select inner wire "\ - "of 2 faces: wrong N wires") + msg="Failed to select inner wire " "of 2 faces: wrong N wires", + ) self.assertAlmostEqual( - Face.makeFromWires(wp_inner_wire.val()).Area(), - (50-5*2) * (50-5*2), - msg="Failed to select inner wire "\ - "of 2 faces: wrong area") + Face.makeFromWires(wp_inner_wire.val()).Area(), + (50 - 5 * 2) * (50 - 5 * 2), + msg="Failed to select inner wire " "of 2 faces: wrong area", + ) def testAreaNthSelector_Faces(self): - wp = \ - Workplane("XY")\ - .box(10, 20, 30)\ - .faces(selectors.AreaNthSelector(1)) - + wp = Workplane("XY").box(10, 20, 30).faces(selectors.AreaNthSelector(1)) + self.assertEqual( - len(wp.vals()), + len(wp.vals()), 2, - msg="Failed to select two faces of 10-20-30 box "\ - "with intermediate area: wrong N faces") - self.assertTupleAlmostEquals( - (wp.vals()[0].Area(), wp.vals()[1].Area()), - (10*30, 10*30), + msg="Failed to select two faces of 10-20-30 box " + "with intermediate area: wrong N faces", + ) + self.assertTupleAlmostEquals( + (wp.vals()[0].Area(), wp.vals()[1].Area()), + (10 * 30, 10 * 30), 7, - msg="Failed to select two faces of 10-20-30 box "\ - "with intermediate area: wrong area") + msg="Failed to select two faces of 10-20-30 box " + "with intermediate area: wrong area", + ) def testAreaNthSelector_Shells(self): - shells = \ - [Shell.makeShell( - Workplane("XY").box(20, 20, 20).faces().vals()), - Shell.makeShell( - Workplane("XY").box(10, 10, 10).faces().vals()), - Shell.makeShell( - Workplane("XY").box(30, 30, 30).faces().vals())] - - selected_shells = \ - selectors.AreaNthSelector(0).filter(shells) - + shells = [ + Shell.makeShell(Workplane("XY").box(20, 20, 20).faces().vals()), + Shell.makeShell(Workplane("XY").box(10, 10, 10).faces().vals()), + Shell.makeShell(Workplane("XY").box(30, 30, 30).faces().vals()), + ] + + selected_shells = selectors.AreaNthSelector(0).filter(shells) + self.assertEqual( - len(selected_shells), + len(selected_shells), 1, - msg="Failed to select the smallest shell "\ - ": wrong N shells") - self.assertAlmostEqual( - selected_shells[0].Area(), - 10*10*6, - msg="Failed to select the smallest shell "\ - ": wrong area") + msg="Failed to select the smallest shell: wrong N shells", + ) + self.assertAlmostEqual( + selected_shells[0].Area(), + 10 * 10 * 6, + msg="Failed to select the smallest shell: wrong area", + ) def testAreaNthSelector_Solids(self): - shells = \ - [Workplane("XY").box(20, 20, 20).solids().val(), - Workplane("XY").box(10, 10, 10).solids().val(), - Workplane("XY").box(30, 30, 30).solids().val()] - - selected_shells = \ - selectors.AreaNthSelector(0).filter(shells) - + shells = [ + Workplane("XY").box(20, 20, 20).solids().val(), + Workplane("XY").box(10, 10, 10).solids().val(), + Workplane("XY").box(30, 30, 30).solids().val(), + ] + + selected_shells = selectors.AreaNthSelector(0).filter(shells) + self.assertEqual( - len(selected_shells), + len(selected_shells), 1, - msg="Failed to select the smallest solid "\ - ": wrong N shells") - self.assertAlmostEqual( - selected_shells[0].Area(), - 10*10*6, - msg="Failed to select the smallest solid "\ - ": wrong area") + msg="Failed to select the smallest solid: wrong N shells", + ) + self.assertAlmostEqual( + selected_shells[0].Area(), + 10 * 10 * 6, + msg="Failed to select the smallest solid: wrong area", + ) def testAndSelector(self): c = CQ(makeUnitCube()) From cf0286d38795c04ba2d2d4210bfda33fe088e7f6 Mon Sep 17 00:00:00 2001 From: Fedor Kotov Date: Mon, 15 Mar 2021 10:07:18 +0300 Subject: [PATCH 5/8] AreaNthSelector test docstrings, minor formatting --- tests/test_selectors.py | 46 ++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/tests/test_selectors.py b/tests/test_selectors.py index e2387254b..33ad737fb 100644 --- a/tests/test_selectors.py +++ b/tests/test_selectors.py @@ -701,14 +701,31 @@ def testRadiusNthSelector(self): ) def testAreaNthSelector_Vertices(self): + """ + Raising exception when AreaNthSelector is used + on unsupported Shapes (Vertices) + """ with self.assertRaises(TypeError): - wp = Workplane("XY").box(10, 10, 10).vertices(selectors.AreaNthSelector(0)) + Workplane("XY").box(10, 10, 10).vertices(selectors.AreaNthSelector(0)) def testAreaNthSelector_Edges(self): + """ + Raising exception when AreaNthSelector is used + on unsupported Shapes (Edges) + """ with self.assertRaises(TypeError): - wp = Workplane("XY").box(10, 10, 10).edges(selectors.AreaNthSelector(0)) + Workplane("XY").box(10, 10, 10).edges(selectors.AreaNthSelector(0)) def testAreaNthSelector_Wires(self): + """ + Tests key parts of case seam leap creation algorithm + (see example 26) + + - Selecting top outer wire + - Applying Offset2D and extruding a "lid" + - Selecting the innermost of three wires in preparation to + cut through the lid and leave a lip on the case seam + """ # selecting top outermost wire of square box wp = ( Workplane("XY") @@ -723,12 +740,12 @@ def testAreaNthSelector_Wires(self): self.assertEqual( len(wp.vals()), 1, - msg="Failed to select top outermost wire " "of the box: wrong N wires", + msg="Failed to select top outermost wire of the box: wrong N wires", ) self.assertAlmostEqual( Face.makeFromWires(wp.val()).Area(), 50 * 50, - msg="Failed to select top outermost wire " "of the box: wrong wire area", + msg="Failed to select top outermost wire of the box: wrong wire area", ) # preparing to add an inside lip to the box @@ -740,39 +757,42 @@ def testAreaNthSelector_Wires(self): self.assertEqual( len(wp_outer_wire.vals()), 1, - msg="Failed to select outermost wire " "of 2 faces: wrong N wires", + msg="Failed to select outermost wire of 2 faces: wrong N wires", ) self.assertAlmostEqual( Face.makeFromWires(wp_outer_wire.val()).Area(), 50 * 50, - msg="Failed to select outermost wire " "of 2 faces: wrong area", + msg="Failed to select outermost wire of 2 faces: wrong area", ) wp_mid_wire = wp.wires(selectors.AreaNthSelector(1)) self.assertEqual( len(wp_mid_wire.vals()), 1, - msg="Failed to select middle wire " "of 2 faces: wrong N wires", + msg="Failed to select middle wire of 2 faces: wrong N wires", ) self.assertAlmostEqual( Face.makeFromWires(wp_mid_wire.val()).Area(), (50 - 2 * 2) * (50 - 2 * 2), - msg="Failed to select middle wire " "of 2 faces: wrong area", + msg="Failed to select middle wire of 2 faces: wrong area", ) wp_inner_wire = wp.wires(selectors.AreaNthSelector(0)) self.assertEqual( len(wp_inner_wire.vals()), 1, - msg="Failed to select inner wire " "of 2 faces: wrong N wires", + msg="Failed to select inner wire of 2 faces: wrong N wires", ) self.assertAlmostEqual( Face.makeFromWires(wp_inner_wire.val()).Area(), (50 - 5 * 2) * (50 - 5 * 2), - msg="Failed to select inner wire " "of 2 faces: wrong area", + msg="Failed to select inner wire of 2 faces: wrong area", ) def testAreaNthSelector_Faces(self): + """ + Selecting two faces of 10x20x30 box with intermediate area. + """ wp = Workplane("XY").box(10, 20, 30).faces(selectors.AreaNthSelector(1)) self.assertEqual( @@ -790,6 +810,9 @@ def testAreaNthSelector_Faces(self): ) def testAreaNthSelector_Shells(self): + """ + Selecting one of three shells with the smallest surface area + """ shells = [ Shell.makeShell(Workplane("XY").box(20, 20, 20).faces().vals()), Shell.makeShell(Workplane("XY").box(10, 10, 10).faces().vals()), @@ -810,6 +833,9 @@ def testAreaNthSelector_Shells(self): ) def testAreaNthSelector_Solids(self): + """ + Selecting one of three solids with the smallest surface area + """ shells = [ Workplane("XY").box(20, 20, 20).solids().val(), Workplane("XY").box(10, 10, 10).solids().val(), From 981d58b19421794e41f9dbbfe6f1a3fd0fec28b9 Mon Sep 17 00:00:00 2001 From: Fedor Kotov Date: Mon, 15 Mar 2021 22:10:24 +0300 Subject: [PATCH 6/8] LengthNthSelector, implementing reviewer changes, abstract _NthSelector - Switched Shape.ShapeType() to isinstance to determine what kind of Shape was passed into _NthSelector.key(..) implementation in AreaNthSelector for consistency with other selectors based on _NthSelector - Added LengthNthSelector developed by @marcus7070 in #690 with somewhat modified unit tests - Added new selector references to documentation - Black compatible code examples in AreaNthSelector docstring - Explicitly marked _NthSelector class and _NthSelector.key(..) method as abstract using standard abc package --- cadquery/selectors.py | 101 +++++++++++++++-------- doc/apireference.rst | 2 + doc/classreference.rst | 2 + tests/test_selectors.py | 177 +++++++++++++++++++++++++++++++++------- 4 files changed, 216 insertions(+), 66 deletions(-) diff --git a/cadquery/selectors.py b/cadquery/selectors.py index a5723458c..9bc045d57 100644 --- a/cadquery/selectors.py +++ b/cadquery/selectors.py @@ -17,9 +17,19 @@ License along with this library; If not, see """ +from abc import abstractmethod, ABC import math from .occ_impl.geom import Vector -from .occ_impl.shapes import Shape, Edge, Face, Wire, geom_LUT_EDGE, geom_LUT_FACE +from .occ_impl.shapes import ( + Shape, + Edge, + Face, + Wire, + Shell, + Solid, + geom_LUT_EDGE, + geom_LUT_FACE, +) from pyparsing import ( Literal, Word, @@ -294,7 +304,7 @@ def filter(self, objectList: Sequence[Shape]) -> List[Shape]: return r -class _NthSelector(Selector): +class _NthSelector(Selector, ABC): """ An abstract class that provides the methods to select the Nth object/objects of an ordered list. """ @@ -324,6 +334,7 @@ def filter(self, objectlist: Sequence[Shape]) -> List[Shape]: return out + @abstractmethod def key(self, obj: Shape) -> float: """ Return the key for ordering. Can raise a ValueError if obj can not be @@ -454,9 +465,26 @@ def filter(self, objectlist: Sequence[Shape]) -> List[Shape]: return objectlist +class LengthNthSelector(_NthSelector): + """ + Select the object(s) with the Nth length + + Applicability: + All Edge and Wire objects + """ + + def key(self, obj: Shape) -> float: + if isinstance(obj, (Edge, Wire)): + return obj.Length() + else: + raise ValueError( + f"LengthNthSelector supports only Edges and Wires, not {type(obj).__name__}" + ) + + class AreaNthSelector(_NthSelector): """ - Selects object(s) with Nth area. + Selects the object(s) with Nth area Applicability: Faces, Shells, Solids - Shape.Area() is used to compute area @@ -467,48 +495,49 @@ class AreaNthSelector(_NthSelector): For example to create a fillet on a shank: - cq.Workplane("XY")\ - .circle(5)\ - .extrude(2)\ - .circle(2)\ - .extrude(10)\ - .faces(">Z[-2]")\ - .wires(AreaNthSelector(0))\ + result = ( + cq.Workplane("XY") + .circle(5) + .extrude(2) + .circle(2) + .extrude(10) + .faces(">Z[-2]") + .wires(AreaNthSelector(0)) .fillet(2) + ) Or to create a lip on a case seam: - cq.Workplane("XY")\ - .rect(20, 20)\ - .extrude(10)\ - .edges("|Z or Z")\ - .shell(2)\ - .faces(">Z")\ - .wires(AreaNthSelector(-1))\ - .toPending()\ - .workplane()\ - .offset2D(-1)\ - .extrude(1)\ - .faces(">Z[-2]")\ - .wires(AreaNthSelector(0))\ - .toPending()\ - .workplane()\ - .cutBlind(2) + result = ( + cq.Workplane("XY") + .rect(20, 20) + .extrude(10) + .edges("|Z or Z") + .shell(2) + .faces(">Z") + .wires(AreaNthSelector(-1)) + .toPending() + .workplane() + .offset2D(-1) + .extrude(1) + .faces(">Z[-2]") + .wires(AreaNthSelector(0)) + .toPending() + .workplane() + .cutBlind(2) + ) """ def key(self, obj: Shape) -> float: - shape_type = obj.ShapeType() - - if shape_type in ("Face", "Shell", "Solid"): + if isinstance(obj, (Face, Shell, Solid)): return obj.Area() - elif shape_type == "Wire": - return Face.makeFromWires(cast(Wire, obj)).Area() + elif isinstance(obj, Wire): + return Face.makeFromWires(obj).Area() else: - raise TypeError( - "AreaNthSelector supports only Wires, " - "Faces, Shells and Solids, not {}".format(shape_type) + raise ValueError( + f"AreaNthSelector supports only Wires, Faces, Shells and Solids, not {type(obj).__name__}" ) diff --git a/doc/apireference.rst b/doc/apireference.rst index 568cc6c5f..351821992 100644 --- a/doc/apireference.rst +++ b/doc/apireference.rst @@ -177,6 +177,8 @@ as a basis for futher operations. ParallelDirSelector DirectionSelector DirectionNthSelector + LengthNthSelector + AreaNthSelector RadiusNthSelector PerpendicularDirSelector TypeSelector diff --git a/doc/classreference.rst b/doc/classreference.rst index ac9e2dd96..aa4bcb12f 100644 --- a/doc/classreference.rst +++ b/doc/classreference.rst @@ -66,6 +66,8 @@ Selector Classes CenterNthSelector DirectionMinMaxSelector DirectionNthSelector + LengthNthSelector + AreaNthSelector BinarySelector AndSelector SumSelector diff --git a/tests/test_selectors.py b/tests/test_selectors.py index 33ad737fb..2ae049d1d 100644 --- a/tests/test_selectors.py +++ b/tests/test_selectors.py @@ -700,28 +700,138 @@ def testRadiusNthSelector(self): wire_circles.wires(selectors.RadiusNthSelector(1)).val().radius(), 4 ) + def testLengthNthSelector_EmptyEdgesList(self): + """ + LengthNthSelector should raise ValueError when + applied to an empty list + """ + with self.assertRaises(ValueError): + Workplane().edges(selectors.LengthNthSelector(0)) + + def testLengthNthSelector_Faces(self): + """ + LengthNthSelector should produce empty list when applied + to list of unsupported Shapes (Faces) + """ + with self.assertRaises(IndexError): + Workplane().box(1, 1, 1).faces(selectors.LengthNthSelector(0)) + + def testLengthNthSelector_EdgesOfUnitCube(self): + """ + Selecting all edges of unit cube + """ + w1 = Workplane(makeUnitCube()).edges(selectors.LengthNthSelector(0)) + self.assertEqual( + 12, + w1.size(), + msg="Failed to select edges of a unit cube: wrong number of edges", + ) + + def testLengthNthSelector_EdgesOf123Cube(self): + """ + Selecting 4 edges of length 2 belonging to 1x2x3 box + """ + w1 = Workplane().box(1, 2, 3).edges(selectors.LengthNthSelector(1)) + self.assertEqual( + 4, + w1.size(), + msg="Failed to select edges of length 2 belonging to 1x2x3 box: wrong number of edges", + ) + self.assertTupleAlmostEquals( + (2, 2, 2, 2), + (edge.Length() for edge in w1.vals()), + 5, + msg="Failed to select edges of length 2 belonging to 1x2x3 box: wrong length", + ) + + def testLengthNthSelector_PlateWithHoles(self): + """ + Creating 10x10 plate with 4 holes (dia=1) + and using LengthNthSelector to select hole rims + and plate perimeter wire on the top surface/ + """ + w2 = ( + Workplane() + .box(10, 10, 1) + .faces(">Z") + .workplane() + .rarray(4, 4, 2, 2) + .hole(1) + .faces(">Z") + ) + + hole_rims = w2.wires(selectors.LengthNthSelector(0)) + + self.assertEqual(4, hole_rims.size()) + self.assertEqual( + 4, hole_rims.size(), msg="Failed to select hole rims: wrong N edges", + ) + + hole_circumference = math.pi * 1 + self.assertTupleAlmostEquals( + [hole_circumference] * 4, + (edge.Length() for edge in hole_rims.vals()), + 5, + msg="Failed to select hole rims: wrong length", + ) + + plate_perimeter = w2.wires(selectors.LengthNthSelector(1)) + + self.assertEqual( + 1, + plate_perimeter.size(), + msg="Failed to select plate perimeter wire: wrong N wires", + ) + + self.assertAlmostEqual( + 10 * 4, + plate_perimeter.val().Length(), + 5, + msg="Failed to select plate perimeter wire: wrong length", + ) + + def testLengthNthSelector_UnsupportedShapes(self): + """ + No length defined for a face, shell, solid or compound + """ + w0 = Workplane().rarray(2, 2, 2, 1).box(1, 1, 1) + for val in [w0.faces().val(), w0.shells().val(), w0.compounds().val()]: + with self.assertRaises(ValueError): + selectors.LengthNthSelector(0).key(val) + + def testLengthNthSelector_UnitEdgeAndWire(self): + """ + Checks that key() method of LengthNthSelector + calculates lengths of unit edge correctly + """ + unit_edge = Edge.makeLine(Vector(0, 0, 0), Vector(0, 0, 1)) + self.assertAlmostEqual(1, selectors.LengthNthSelector(0).key(unit_edge), 5) + + unit_edge = Wire.assembleEdges([unit_edge]) + self.assertAlmostEqual(1, selectors.LengthNthSelector(0).key(unit_edge), 5) + def testAreaNthSelector_Vertices(self): """ - Raising exception when AreaNthSelector is used - on unsupported Shapes (Vertices) + Using AreaNthSelector on unsupported Shapes (Vertices) + should produce empty list """ - with self.assertRaises(TypeError): + with self.assertRaises(IndexError): Workplane("XY").box(10, 10, 10).vertices(selectors.AreaNthSelector(0)) def testAreaNthSelector_Edges(self): """ - Raising exception when AreaNthSelector is used - on unsupported Shapes (Edges) + Using AreaNthSelector on unsupported Shapes (Edges) + should produce empty list """ - with self.assertRaises(TypeError): + with self.assertRaises(IndexError): Workplane("XY").box(10, 10, 10).edges(selectors.AreaNthSelector(0)) def testAreaNthSelector_Wires(self): """ - Tests key parts of case seam leap creation algorithm + Tests key parts of case seam leap creation algorithm (see example 26) - - Selecting top outer wire + - Selecting top outer wire - Applying Offset2D and extruding a "lid" - Selecting the innermost of three wires in preparation to cut through the lid and leave a lip on the case seam @@ -813,46 +923,53 @@ def testAreaNthSelector_Shells(self): """ Selecting one of three shells with the smallest surface area """ - shells = [ - Shell.makeShell(Workplane("XY").box(20, 20, 20).faces().vals()), - Shell.makeShell(Workplane("XY").box(10, 10, 10).faces().vals()), - Shell.makeShell(Workplane("XY").box(30, 30, 30).faces().vals()), - ] - selected_shells = selectors.AreaNthSelector(0).filter(shells) + sizes_iter = iter([10.0, 20.0, 30.0]) + + def next_box_shell(loc): + size = next(sizes_iter) + return Workplane().box(size, size, size).val().located(loc) + + workplane_shells = Workplane().rarray(10, 1, 3, 1).eachpoint(next_box_shell) + + selected_shells = workplane_shells.shells(selectors.AreaNthSelector(0)) self.assertEqual( - len(selected_shells), + len(selected_shells.vals()), 1, msg="Failed to select the smallest shell: wrong N shells", ) self.assertAlmostEqual( - selected_shells[0].Area(), + selected_shells.val().Area(), 10 * 10 * 6, msg="Failed to select the smallest shell: wrong area", ) def testAreaNthSelector_Solids(self): """ - Selecting one of three solids with the smallest surface area + Selecting 2 of 3 solids by surface area """ - shells = [ - Workplane("XY").box(20, 20, 20).solids().val(), - Workplane("XY").box(10, 10, 10).solids().val(), - Workplane("XY").box(30, 30, 30).solids().val(), - ] - selected_shells = selectors.AreaNthSelector(0).filter(shells) + sizes_iter = iter([10.0, 20.0, 20.0]) + + def next_box(loc): + size = next(sizes_iter) + return Workplane().box(size, size, size).val().located(loc) + + workplane_solids = Workplane().rarray(30, 1, 3, 1).eachpoint(next_box) + + selected_solids = workplane_solids.solids(selectors.AreaNthSelector(1)) self.assertEqual( - len(selected_shells), - 1, - msg="Failed to select the smallest solid: wrong N shells", + len(selected_solids.vals()), + 2, + msg="Failed to select two larger solids: wrong N shells", ) - self.assertAlmostEqual( - selected_shells[0].Area(), - 10 * 10 * 6, - msg="Failed to select the smallest solid: wrong area", + self.assertTupleAlmostEquals( + [20 * 20 * 6] * 2, + [solid.Area() for solid in selected_solids.vals()], + 5, + msg="Failed to select two larger solids: wrong area", ) def testAndSelector(self): From 0fe9abd053b7054a9b61646870be2f8d46fa14ac Mon Sep 17 00:00:00 2001 From: Marcus Boyd Date: Tue, 16 Mar 2021 06:39:43 +1030 Subject: [PATCH 7/8] removed unused import --- cadquery/selectors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadquery/selectors.py b/cadquery/selectors.py index 9bc045d57..7f177d21b 100644 --- a/cadquery/selectors.py +++ b/cadquery/selectors.py @@ -47,7 +47,7 @@ Keyword, ) from functools import reduce -from typing import List, Union, Sequence, cast +from typing import List, Union, Sequence class Selector(object): From 93fceff9221a08f1c5d482255ca4eda96fbadba4 Mon Sep 17 00:00:00 2001 From: Fedor Kotov Date: Tue, 16 Mar 2021 13:42:22 +0300 Subject: [PATCH 8/8] AreaNthSelector ignores "bad" Wires AreaNthSelector.key raises ValueError if temporary face creation fails for a wire for any reason (non-planar, non-closed). That causes _NthSelector that it inherits to ignore such wires. Done so for consistency with RadiusNthSelector that ignores anything it can not get radius from. --- cadquery/selectors.py | 17 ++++++++++++----- tests/test_selectors.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/cadquery/selectors.py b/cadquery/selectors.py index 7f177d21b..e4c3e7fc3 100644 --- a/cadquery/selectors.py +++ b/cadquery/selectors.py @@ -488,10 +488,12 @@ class AreaNthSelector(_NthSelector): Applicability: Faces, Shells, Solids - Shape.Area() is used to compute area - planar Wires - a temporary face is created to compute area + closed planar Wires - a temporary face is created to compute area - Among other things can be used to select one of - the nested coplanar wires or faces. + Will ignore non-planar or non-closed wires. + + Among other things can be used to select one of + the nested coplanar wires or faces. For example to create a fillet on a shank: @@ -507,7 +509,7 @@ class AreaNthSelector(_NthSelector): ) Or to create a lip on a case seam: - + result = ( cq.Workplane("XY") .rect(20, 20) @@ -534,7 +536,12 @@ def key(self, obj: Shape) -> float: if isinstance(obj, (Face, Shell, Solid)): return obj.Area() elif isinstance(obj, Wire): - return Face.makeFromWires(obj).Area() + try: + return Face.makeFromWires(obj).Area() + except Exception as ex: + raise ValueError( + f"Can not compute area of the Wire: {ex}. AreaNthSelector supports only closed planar Wires." + ) else: raise ValueError( f"AreaNthSelector supports only Wires, Faces, Shells and Solids, not {type(obj).__name__}" diff --git a/tests/test_selectors.py b/tests/test_selectors.py index 2ae049d1d..0efb18683 100644 --- a/tests/test_selectors.py +++ b/tests/test_selectors.py @@ -826,7 +826,7 @@ def testAreaNthSelector_Edges(self): with self.assertRaises(IndexError): Workplane("XY").box(10, 10, 10).edges(selectors.AreaNthSelector(0)) - def testAreaNthSelector_Wires(self): + def testAreaNthSelector_NestedWires(self): """ Tests key parts of case seam leap creation algorithm (see example 26) @@ -899,6 +899,33 @@ def testAreaNthSelector_Wires(self): msg="Failed to select inner wire of 2 faces: wrong area", ) + def testAreaNthSelector_NonplanarWire(self): + """ + AreaNthSelector should raise ValueError when + used on non-planar wires so that they are ignored by + _NthSelector. + + Non-planar wires in stack should not prevent selection of + planar wires. + """ + wp = Workplane("XY").circle(10).extrude(50) + + with self.assertRaises(IndexError): + wp.wires(selectors.AreaNthSelector(1)) + + cylinder_flat_ends = wp.wires(selectors.AreaNthSelector(0)) + self.assertEqual( + len(cylinder_flat_ends.vals()), + 2, + msg="Failed to select cylinder flat end wires: wrong N wires", + ) + self.assertTupleAlmostEquals( + [math.pi * 10 ** 2] * 2, + [Face.makeFromWires(wire).Area() for wire in cylinder_flat_ends.vals()], + 5, + msg="Failed to select cylinder flat end wires: wrong area", + ) + def testAreaNthSelector_Faces(self): """ Selecting two faces of 10x20x30 box with intermediate area.