diff --git a/cadquery/selectors.py b/cadquery/selectors.py index 761abbf84..5eb0f2d9c 100644 --- a/cadquery/selectors.py +++ b/cadquery/selectors.py @@ -19,8 +19,7 @@ import math from .occ_impl.geom import Vector -from .occ_impl.shapes import Edge, Face -from collections import defaultdict +from .occ_impl.shapes import Shape, Edge, Face, Wire, geom_LUT_EDGE, geom_LUT_FACE from pyparsing import ( Literal, Word, @@ -38,24 +37,24 @@ Keyword, ) from functools import reduce +from typing import List, Union, Sequence class Selector(object): """ - Filters a list of objects + Filters a list of objects. - Filters must provide a single method that filters objects. + Filters must provide a single method that filters objects. """ def filter(self, objectList): """ - Filter the provided list - :param objectList: list to filter - :type objectList: list of FreeCAD primatives - :return: filtered list - - The default implementation returns the original list unfiltered + Filter the provided list. + The default implementation returns the original list unfiltered. + :param objectList: list to filter + :type objectList: list of OCCT primitives + :return: filtered list """ return objectList @@ -155,144 +154,139 @@ def isInsideBox(p): class BaseDirSelector(Selector): """ - A selector that handles selection on the basis of a single - direction vector + A selector that handles selection on the basis of a single direction vector. """ - def __init__(self, vector, tolerance=0.0001): + def __init__(self, vector: Vector, tolerance: float = 0.0001): self.direction = vector - self.TOLERANCE = tolerance + self.tolerance = tolerance - def test(self, vec): + def test(self, vec: Vector) -> bool: "Test a specified vector. Subclasses override to provide other implementations" return True - def filter(self, objectList): + def filter(self, objectList: Sequence[Shape]) -> List[Shape]: """ - There are lots of kinds of filters, but - for planes they are always based on the normal of the plane, - and for edges on the tangent vector along the edge + There are lots of kinds of filters, but for planes they are always + based on the normal of the plane, and for edges on the tangent vector + along the edge """ r = [] for o in objectList: # no really good way to avoid a switch here, edges and faces are simply different! - - if type(o) == Face: - # a face is only parallell to a direction if it is a plane, and its normal is parallel to the dir - normal = o.normalAt(None) - - if self.test(normal): - r.append(o) - elif type(o) == Edge and ( - o.geomType() == "LINE" or o.geomType() == "PLANE" - ): + if isinstance(o, Face) and o.geomType() == "PLANE": + # a face is only parallel to a direction if it is a plane, and + # its normal is parallel to the dir + test_vector = o.normalAt(None) + elif isinstance(o, Edge) and o.geomType() == "LINE": # an edge is parallel to a direction if its underlying geometry is plane or line - tangent = o.tangentAt() - if self.test(tangent): - r.append(o) + test_vector = o.tangentAt() + else: + continue + + if self.test(test_vector): + r.append(o) return r class ParallelDirSelector(BaseDirSelector): """ - Selects objects parallel with the provided direction + Selects objects parallel with the provided direction. - Applicability: - Linear Edges - Planar Faces + Applicability: + Linear Edges + Planar Faces - Use the string syntax shortcut \|(X|Y|Z) if you want to select - based on a cardinal direction. + Use the string syntax shortcut \|(X|Y|Z) if you want to select based on a cardinal direction. - Example:: + Example:: - CQ(aCube).faces(ParallelDirSelector((0,0,1)) + CQ(aCube).faces(ParallelDirSelector((0, 0, 1)) - selects faces with a normals in the z direction, and is equivalent to:: + selects faces with a normals in the z direction, and is equivalent to:: - CQ(aCube).faces("|Z") + CQ(aCube).faces("|Z") """ - def test(self, vec): - return self.direction.cross(vec).Length < self.TOLERANCE + def test(self, vec: Vector) -> bool: + return self.direction.cross(vec).Length < self.tolerance class DirectionSelector(BaseDirSelector): """ - Selects objects aligned with the provided direction + Selects objects aligned with the provided direction. - Applicability: - Linear Edges - Planar Faces + Applicability: + Linear Edges + Planar Faces - Use the string syntax shortcut +/-(X|Y|Z) if you want to select - based on a cardinal direction. + Use the string syntax shortcut +/-(X|Y|Z) if you want to select based on a cardinal direction. - Example:: + Example:: - CQ(aCube).faces(DirectionSelector((0,0,1)) + CQ(aCube).faces(DirectionSelector((0, 0, 1)) - selects faces with a normals in the z direction, and is equivalent to:: + selects faces with a normals in the z direction, and is equivalent to:: - CQ(aCube).faces("+Z") + CQ(aCube).faces("+Z") """ - def test(self, vec): - return abs(self.direction.getAngle(vec) < self.TOLERANCE) + def test(self, vec: Vector) -> bool: + return self.direction.getAngle(vec) < self.tolerance class PerpendicularDirSelector(BaseDirSelector): """ - Selects objects perpendicular with the provided direction + Selects objects perpendicular with the provided direction. - Applicability: - Linear Edges - Planar Faces + Applicability: + Linear Edges + Planar Faces - Use the string syntax shortcut #(X|Y|Z) if you want to select - based on a cardinal direction. + Use the string syntax shortcut #(X|Y|Z) if you want to select based on a + cardinal direction. - Example:: + Example:: - CQ(aCube).faces(PerpendicularDirSelector((0,0,1)) + CQ(aCube).faces(PerpendicularDirSelector((0, 0, 1)) - selects faces with a normals perpendicular to the z direction, and is equivalent to:: + selects faces with a normals perpendicular to the z direction, and is equivalent to:: - CQ(aCube).faces("#Z") + CQ(aCube).faces("#Z") """ - def test(self, vec): + def test(self, vec: Vector) -> bool: angle = self.direction.getAngle(vec) - r = (abs(angle) < self.TOLERANCE) or (abs(angle - math.pi) < self.TOLERANCE) + r = (abs(angle) < self.tolerance) or (abs(angle - math.pi) < self.tolerance) return not r class TypeSelector(Selector): """ - Selects objects of the prescribed topological type. + Selects objects of the prescribed topological type. - Applicability: - Faces: Plane,Cylinder,Sphere - Edges: Line,Circle,Arc + Applicability: + Faces: Plane,Cylinder,Sphere + Edges: Line,Circle,Arc - You can use the shortcut selector %(PLANE|SPHERE|CONE) for faces, - and %(LINE|ARC|CIRCLE) for edges. + You can use the shortcut selector %(PLANE|SPHERE|CONE) for faces, and + %(LINE|ARC|CIRCLE) for edges. - For example this:: + For example this:: - CQ(aCube).faces ( TypeSelector("PLANE") ) + CQ(aCube).faces ( TypeSelector("PLANE") ) - will select 6 faces, and is equivalent to:: + will select 6 faces, and is equivalent to:: - CQ(aCube).faces( "%PLANE" ) + CQ(aCube).faces( "%PLANE" ) """ - def __init__(self, typeString): + def __init__(self, typeString: str): self.typeString = typeString.upper() - def filter(self, objectList): + def filter(self, objectList: Sequence[Shape]) -> List[Shape]: r = [] for o in objectList: if o.geomType() == self.typeString: @@ -300,142 +294,164 @@ def filter(self, objectList): return r -class RadiusNthSelector(Selector): +class _NthSelector(Selector): """ - Select the object with the Nth radius. - - Applicability: - All Edge and Wires. - - Will ignore any shape that can not be represented as a circle or an arc of - a circle. + An abstract class that provides the methods to select the Nth object/objects of an ordered list. """ - def __init__(self, n, directionMax=True, tolerance=0.0001): - self.N = n + def __init__(self, n: int, directionMax: bool = True, tolerance: float = 0.0001): + self.n = n self.directionMax = directionMax - self.TOLERANCE = tolerance - - def filter(self, objectList): - # calculate how many digits of precision do we need - digits = -math.floor(math.log10(self.TOLERANCE)) - - # make a radius dict - # this is one to many mapping so I am using a default dict with list - objectDict = defaultdict(list) - for el in objectList: - try: - rad = el.radius() - except ValueError: - continue - objectDict[round(rad, digits)].append(el) + self.tolerance = tolerance - # choose the Nth unique rounded distance - sortedObjectList = sorted( - list(objectDict.keys()), reverse=not self.directionMax - ) + def filter(self, objectlist: Sequence[Shape]) -> List[Shape]: + """ + Return the nth object in the objectlist sorted by self.key and + clustered if within self.tolerance. + """ + if len(objectlist) == 0: + # nothing to filter + raise ValueError("Can not return the Nth element of an empty list") + clustered = self.cluster(objectlist) + if not self.directionMax: + clustered.reverse() try: - nth_distance = sortedObjectList[self.N] + out = clustered[self.n] except IndexError: raise IndexError( - f"Attempted to access the {self.N}-th radius in a list {len(sortedObjectList)} long" + f"Attempted to access index {self.n} of a list with length {len(clustered)}" ) - # map back to original objects and return - return objectDict[nth_distance] + return out + def key(self, obj: Shape) -> float: + """ + Return the key for ordering. Can raise a ValueError if obj can not be + used to create a key, which will result in obj being dropped by the + clustering method. + """ + raise NotImplementedError -class DirectionMinMaxSelector(Selector): - """ - Selects objects closest or farthest in the specified direction - Used for faces, points, and edges + def cluster(self, objectlist: Sequence[Shape]) -> List[List[Shape]]: + """ + Clusters the elements of objectlist if they are within tolerance. + """ + key_and_obj = [] + for obj in objectlist: + # Need to handle value errors, such as what occurs when you try to + # access the radius of a straight line + try: + key = self.key(obj) + except ValueError: + # forget about this element and continue + continue + key_and_obj.append((key, obj)) + key_and_obj.sort(key=lambda x: x[0]) + clustered = [[]] # type: List[List[Shape]] + start = key_and_obj[0][0] + for key, obj in key_and_obj: + if abs(key - start) <= self.tolerance: + clustered[-1].append(obj) + else: + clustered.append([obj]) + start = key + return clustered - Applicability: - All object types. for a vertex, its point is used. for all other kinds - of objects, the center of mass of the object is used. - You can use the string shortcuts >(X|Y|Z) or <(X|Y|Z) if you want to - select based on a cardinal direction. +class RadiusNthSelector(_NthSelector): + """ + Select the object with the Nth radius. - For example this:: + Applicability: + All Edge and Wires. - CQ(aCube).faces ( DirectionMinMaxSelector((0,0,1),True ) + Will ignore any shape that can not be represented as a circle or an arc of + a circle. + """ - Means to select the face having the center of mass farthest in the positive z direction, - and is the same as: + def key(self, obj: Shape) -> float: + if isinstance(obj, (Edge, Wire)): + return obj.radius() + else: + raise ValueError("Can not get a radius from this object") - CQ(aCube).faces( ">Z" ) +class CenterNthSelector(_NthSelector): """ + Sorts objects into a list with order determined by the distance of their center projected onto the specified direction. - def __init__(self, vector, directionMax=True, tolerance=0.0001): - self.vector = vector - self.max = max - self.directionMax = directionMax - self.TOLERANCE = tolerance + Applicability: + All Shapes. + """ - def filter(self, objectList): - def distance(tShape): - return tShape.Center().dot(self.vector) + def __init__( + self, + vector: Vector, + n: int, + directionMax: bool = True, + tolerance: float = 0.0001, + ): + super().__init__(n, directionMax, tolerance) + self.direction = vector - # import OrderedDict - from collections import OrderedDict + def key(self, obj: Shape) -> float: + return obj.Center().dot(self.direction) - # make and distance to object dict - objectDict = {distance(el): el for el in objectList} - # transform it into an ordered dict - objectDict = OrderedDict(sorted(list(objectDict.items()), key=lambda x: x[0])) - # find out the max/min distance - if self.directionMax: - d = list(objectDict.keys())[-1] - else: - d = list(objectDict.keys())[0] +class DirectionMinMaxSelector(CenterNthSelector): + """ + Selects objects closest or farthest in the specified direction. - # return all objects at the max/min distance (within a tolerance) - return [o for o in objectList if abs(d - distance(o)) < self.TOLERANCE] + Applicability: + All object types. for a vertex, its point is used. for all other kinds + of objects, the center of mass of the object is used. + You can use the string shortcuts >(X|Y|Z) or <(X|Y|Z) if you want to select + based on a cardinal direction. -class DirectionNthSelector(ParallelDirSelector): - """ - Selects nth object parallel (or normal) to the specified direction - Used for faces and edges + For example this:: - Applicability: - Linear Edges - Planar Faces - """ + CQ(aCube).faces(DirectionMinMaxSelector((0, 0, 1), True) - def __init__(self, vector, n, directionMax=True, tolerance=0.0001): - self.direction = vector - self.max = max - self.directionMax = directionMax - self.TOLERANCE = tolerance - self.N = n + Means to select the face having the center of mass farthest in the positive + z direction, and is the same as: - def filter(self, objectList): - # select first the objects that are normal/parallel to a given dir - objectList = super(DirectionNthSelector, self).filter(objectList) + CQ(aCube).faces(">Z") - def distance(tShape): - return tShape.Center().dot(self.direction) + """ - # calculate how many digits of precision do we need - digits = -math.floor(math.log10(self.TOLERANCE)) + def __init__( + self, vector: Vector, directionMax: bool = True, tolerance: float = 0.0001 + ): + super().__init__( + n=-1, vector=vector, directionMax=directionMax, tolerance=tolerance + ) - # make a distance to object dict - # this is one to many mapping so I am using a default dict with list - objectDict = defaultdict(list) - for el in objectList: - objectDict[round(distance(el), digits)].append(el) - # choose the Nth unique rounded distance - nth_distance = sorted(list(objectDict.keys()), reverse=not self.directionMax)[ - self.N - ] +# inherit from CenterNthSelector to get the CenterNthSelector.key method +class DirectionNthSelector(ParallelDirSelector, CenterNthSelector): + """ + Filters for objects parallel (or normal) to the specified direction then returns the Nth one. - # map back to original objects and return - return objectDict[nth_distance] + Applicability: + Linear Edges + Planar Faces + """ + + def __init__( + self, + vector: Vector, + n: int, + directionMax: bool = True, + tolerance: float = 0.0001, + ): + ParallelDirSelector.__init__(self, vector, tolerance) + _NthSelector.__init__(self, n, directionMax, tolerance) + + def filter(self, objectlist: Sequence[Shape]) -> List[Shape]: + objectlist = ParallelDirSelector.filter(self, objectlist) + objectlist = _NthSelector.filter(self, objectlist) + return objectlist class BinarySelector(Selector): @@ -528,7 +544,7 @@ def _makeGrammar(): # CQ type definition cqtype = oneOf( - ["Plane", "Cylinder", "Sphere", "Cone", "Line", "Circle", "Arc"], caseless=True + set(geom_LUT_EDGE.values()) | set(geom_LUT_FACE.values()), caseless=True, ) cqtype = cqtype.setParseAction(upcaseTokens) @@ -538,6 +554,9 @@ def _makeGrammar(): # direction operator direction_op = oneOf([">", "<"]) + # center Nth operator + center_nth_op = oneOf([">>", "<<"]) + # index definition ix_number = Group(Optional("-") + Word(nums)) lsqbracket = Literal("[").suppress() @@ -555,6 +574,7 @@ def _makeGrammar(): direction("only_dir") | (type_op("type_op") + cqtype("cq_type")) | (direction_op("dir_op") + direction("dir") + Optional(index)) + | (center_nth_op("center_nth_op") + direction("dir") + Optional(index)) | (other_op("other_op") + direction("dir")) | named_view("named_view") ) @@ -592,7 +612,9 @@ def __init__(self, parseResults): self.operatorMinMax = { ">": True, + ">>": True, "<": False, + "<<": False, } self.operator = { @@ -627,6 +649,15 @@ def _chooseSelector(self, pr): else: return DirectionMinMaxSelector(vec, minmax) + elif "center_nth_op" in pr: + vec = self._getVector(pr) + minmax = self.operatorMinMax[pr.center_nth_op] + + if "index" in pr: + return CenterNthSelector(vec, int("".join(pr.index.asList())), minmax) + else: + return CenterNthSelector(vec, -1, minmax) + elif "other_op" in pr: vec = self._getVector(pr) return self.operator[pr.other_op](vec) @@ -647,8 +678,8 @@ def _getVector(self, pr): def filter(self, objectList): """ - selects minimum, maximum, positive or negative values relative to a direction - [+\|-\|<\|>\|] \ + selects minimum, maximum, positive or negative values relative to a direction + [+\|-\|<\|>\|] \ """ return self.mySelector.filter(objectList) @@ -754,7 +785,7 @@ class StringSyntaxSelector(Selector): Finally, it is also possible to use even more complex expressions with nesting and arbitrary number of terms, e.g. - (not >X[0] and #XY) or >XY[0] + (not >X[0] and #XY) or >XY[0] Selectors are a complex topic: see :ref:`selector_reference` for more information """ diff --git a/changes.md b/changes.md index 6f21ef9fc..bde56affe 100644 --- a/changes.md +++ b/changes.md @@ -1,6 +1,11 @@ Changes ======= +Master +------ + ### Breaking changes + * Fixed bug in ParallelDirSelector where non-planar faces could be selected. Note this will be breaking if you've used DirectionNthSelector and a non-planar face made it into your object list. In that case eg. ">X[2]" will have to become ">X[1]". + 2.1RC1 (release candidate) ------ ### Breaking changes diff --git a/doc/_static/tables.css b/doc/_static/tables.css new file mode 100644 index 000000000..e54e1b07c --- /dev/null +++ b/doc/_static/tables.css @@ -0,0 +1,6 @@ +.wy-table-responsive table td, .wy-table-responsive table th { + white-space: normal; +} +.wy-table-responsive { + overflow: visible; +} diff --git a/doc/apireference.rst b/doc/apireference.rst index eb398641b..eb20a169d 100644 --- a/doc/apireference.rst +++ b/doc/apireference.rst @@ -178,6 +178,7 @@ as a basis for futher operations. PerpendicularDirSelector TypeSelector DirectionMinMaxSelector + CenterNthSelector BinarySelector AndSelector SumSelector diff --git a/doc/classreference.rst b/doc/classreference.rst index ce4827db6..526b0694d 100644 --- a/doc/classreference.rst +++ b/doc/classreference.rst @@ -60,10 +60,12 @@ Selector Classes BaseDirSelector ParallelDirSelector DirectionSelector - DirectionNthSelector PerpendicularDirSelector TypeSelector + RadiusNthSelector + CenterNthSelector DirectionMinMaxSelector + DirectionNthSelector BinarySelector AndSelector SumSelector @@ -89,4 +91,4 @@ Class Details .. automodule:: cadquery.selectors :show-inheritance: - :members: \ No newline at end of file + :members: diff --git a/doc/conf.py b/doc/conf.py index 327d6d706..f704fe4a7 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -26,6 +26,11 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # sys.path.insert(0, os.path.abspath('.')) + +def setup(app): + app.add_css_file("tables.css") + + # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. diff --git a/doc/selectors.rst b/doc/selectors.rst index f823cb87d..9cc5d65a0 100644 --- a/doc/selectors.rst +++ b/doc/selectors.rst @@ -7,53 +7,63 @@ String Selectors Reference CadQuery selector strings allow filtering various types of object lists. Most commonly, Edges, Faces, and Vertices are used, but all objects types can be filtered. -String selectors are simply shortcuts for using the full object equivalents. If you pass one of the -string patterns in, CadQuery will automatically use the associated selector object. +Object lists are created by using the following methods, which each collect a type of shape: - * :py:meth:`cadquery.Workplane.faces` - * :py:meth:`cadquery.Workplane.edges` * :py:meth:`cadquery.Workplane.vertices` - * :py:meth:`cadquery.Workplane.solids` + * :py:meth:`cadquery.Workplane.edges` + * :py:meth:`cadquery.Workplane.faces` * :py:meth:`cadquery.Workplane.shells` + * :py:meth:`cadquery.Workplane.solids` + +Each of these methods accepts either a Selector object or a string. String selectors are simply +shortcuts for using the full object equivalents. If you pass one of the string patterns in, +CadQuery will automatically use the associated selector object. + .. note:: - String selectors are shortcuts to concrete selector classes, which you can use or extend. See - :ref:`classreference` for more details + String selectors are simply shortcuts to concrete selector classes, which you can use or + extend. For a full description of how each selector class works, see :ref:`classreference`. If you find that the built-in selectors are not sufficient, you can easily plug in your own. See :ref:`extending` to see how. Combining Selectors -========================== +-------------------------- Selectors can be combined logically, currently defined operators include **and**, **or**, **not** and **exc[ept]** (set difference). For example: .. cadquery:: - result = cq.Workplane("XY").box(2, 2, 2) \ - .edges("|Z and >Y") \ + result = ( + cq.Workplane("XY") + .box(2, 2, 2) + .edges("|Z and >Y") .chamfer(0.2) + ) Much more complex expressions are possible as well: .. cadquery:: - result = cq.Workplane("XY").box(2, 2, 2) \ - .faces(">Z") \ - .shell(-0.2) \ - .faces(">Z") \ - .edges("not(X or Y)") \ + result = ( + cq.Workplane("XY") + .box(2, 2, 2) + .faces(">Z") + .shell(-0.2) + .faces(">Z") + .edges("not(X or Y)") .chamfer(0.1) + ) .. _filteringfaces: Filtering Faces ---------------- -All types of filters work on faces. In most cases, the selector refers to the direction of the **normal vector** -of the face. +All types of string selectors work on faces. In most cases, the selector refers to the direction +of the **normal vector** of the face. .. warning:: @@ -62,19 +72,21 @@ of the face. The axis used in the listing below are for illustration: any axis would work similarly in each case. -========= ======================================= ======================================================= ========================== -Selector Selects Selector Class # objects returned -========= ======================================= ======================================================= ========================== -+Z Faces with normal in +z direction :py:class:`cadquery.DirectionSelector` 0..many -\|Z Faces with normal parallel to z dir :py:class:`cadquery.ParallelDirSelector` 0..many --X Faces with normal in neg x direction :py:class:`cadquery.DirectionSelector` 0..many -#Z Faces with normal orthogonal to z dir :py:class:`cadquery.PerpendicularDirSelector` 0..many -%Plane Faces of type plane :py:class:`cadquery.TypeSelector` 0..many ->Y Face farthest in the positive y dir :py:class:`cadquery.DirectionMinMaxSelector` 0..many -Y[-2] 2nd farthest Face normal to the y dir :py:class:`cadquery.DirectionNthSelector` 0..many -Y Face farthest in the positive y dir :py:class:`cadquery.DirectionMinMaxSelector` +Y[-2] 2nd farthest Face **normal** to the y dir :py:class:`cadquery.DirectionNthSelector` +>Y[-2] 2nd farthest Face in the y dir :py:class:`cadquery.CenterNthSelector` +<>). + Non-linear edges are never returned when these filters are applied. The axis used in the listing below are for illustration: any axis would work similarly in each case. -========= ======================================= ======================================================= ========================== -Selector Selects Selector Class # objects returned -========= ======================================= ======================================================= ========================== -+Z Edges aligned in the Z direction :py:class:`cadquery.DirectionSelector` 0..many -\|Z Edges parallel to z direction :py:class:`cadquery.ParallelDirSelector` 0..many --X Edges aligned in neg x direction :py:class:`cadquery.DirectionSelector` 0..many -#Z Edges perpendicular to z direction :py:class:`cadquery.PerpendicularDirSelector` 0..many -%Line Edges of type line :py:class:`cadquery.TypeSelector` 0..many ->Y Edges farthest in the positive y dir :py:class:`cadquery.DirectionMinMaxSelector` 0..many -Y[1] 2nd closest edge in the positive y dir :py:class:`cadquery.DirectionMinMaxSelector` 0..many -Y Edges farthest in the positive y dir :py:class:`cadquery.DirectionMinMaxSelector` +Y[1] 2nd closest **parallel** edge in the positive y dir :py:class:`cadquery.DirectionNthSelector` +>Y[-2] 2nd farthest edge in the y dir :py:class:`cadquery.CenterNthSelector` +<Y Vertices farthest in the positive y dir :py:class:`cadquery.DirectionMinMaxSelector` 0..many -Y Vertices farthest in the positive y dir :py:class:`cadquery.DirectionMinMaxSelector` +>Y[-2] 2nd farthest vertex in the y dir :py:class:`cadquery.CenterNthSelector` +<(-1,1,0)').chamfer(1) + result = result.edges('>(-1, 1, 0)').chamfer(1) diff --git a/tests/test_selectors.py b/tests/test_selectors.py index 229b6efac..37b38b7e5 100644 --- a/tests/test_selectors.py +++ b/tests/test_selectors.py @@ -100,6 +100,16 @@ def testFaceTypesFilter(self): self.assertEqual(0, c.faces("%cone").size()) self.assertEqual(0, c.faces("%SPHERE").size()) + def testEdgeTypesFilter(self): + "Filters by edge type" + c = Workplane().ellipse(3, 4).circle(1).extrude(1) + self.assertEqual(2, c.edges("%Ellipse").size()) + self.assertEqual(2, c.edges("%circle").size()) + self.assertEqual(2, c.edges("%LINE").size()) + self.assertEqual(0, c.edges("%Bspline").size()) + self.assertEqual(0, c.edges("%Offset").size()) + self.assertEqual(0, c.edges("%HYPERBOLA").size()) + def testPerpendicularDirFilter(self): c = CQ(makeUnitCube()) @@ -145,8 +155,39 @@ def testFaceDirFilter(self): self.assertEqual(c.faces("+X").val().Center(), c.faces("X").val().Center()) self.assertNotEqual(c.faces("+X").val().Center(), c.faces("-X").val().Center()) + def testBaseDirSelector(self): + # BaseDirSelector isn't intended to be instantiated, use subclass + # ParallelDirSelector to test the code in BaseDirSelector + loose_selector = ParallelDirSelector(Vector(0, 0, 1), tolerance=10) + + c = Workplane(makeUnitCube(centered=True)) + + # BaseDirSelector should filter out everything but Faces and Edges with + # geomType LINE + self.assertNotEqual(c.vertices().size(), 0) + self.assertEqual(c.vertices(loose_selector).size(), 0) + + # This has an edge that is not a LINE + c_curves = Workplane().sphere(1) + self.assertNotEqual(c_curves.edges(), 0) + self.assertEqual(c_curves.edges(loose_selector).size(), 0) + + # this has a Face that is not a PLANE + face_dir = c_curves.faces().val().normalAt(None) + self.assertNotEqual(c_curves.faces(), 0) + self.assertEqual( + c_curves.faces(ParallelDirSelector(face_dir, tolerance=10)).size(), 0 + ) + + self.assertNotEqual(c.solids().size(), 0) + self.assertEqual(c.solids(loose_selector).size(), 0) + + comp = Workplane(makeUnitCube()).workplane().move(10, 10).box(1, 1, 1) + self.assertNotEqual(comp.compounds().size(), 0) + self.assertEqual(comp.compounds(loose_selector).size(), 0) + def testParallelPlaneFaceFilter(self): - c = CQ(makeUnitCube()) + c = CQ(makeUnitCube(centered=False)) # faces parallel to Z axis # these two should produce the same behaviour: @@ -162,6 +203,13 @@ def testParallelPlaneFaceFilter(self): # just for fun, vertices on faces parallel to z self.assertEqual(8, c.faces("|Z").vertices().size()) + # check that the X & Y center of these faces is the same as the box (ie. we haven't selected the wrong face) + faces = c.faces(selectors.ParallelDirSelector(Vector((0, 0, 1)))).vals() + for f in faces: + c = f.Center() + self.assertAlmostEqual(c.x, 0.5) + self.assertAlmostEqual(c.y, 0.5) + def testParallelEdgeFilter(self): c = CQ(makeUnitCube()) for sel, vec in zip( @@ -174,6 +222,134 @@ def testParallelEdgeFilter(self): for e in edges.vals(): self.assertAlmostEqual(e.tangentAt(0).cross(vec).Length, 0.0) + def testCenterNthSelector(self): + sel = selectors.CenterNthSelector + + nothing = Workplane() + self.assertEqual(nothing.solids().size(), 0) + with self.assertRaises(ValueError): + nothing.solids(sel(Vector(0, 0, 1), 0)) + + c = Workplane(makeUnitCube(centered=True)) + + bottom_face = c.faces(sel(Vector(0, 0, 1), 0)) + self.assertEqual(bottom_face.size(), 1) + self.assertTupleAlmostEquals((0, 0, 0), bottom_face.val().Center().toTuple(), 3) + + side_faces = c.faces(sel(Vector(0, 0, 1), 1)) + self.assertEqual(side_faces.size(), 4) + for f in side_faces.vals(): + self.assertAlmostEqual(0.5, f.Center().z) + + top_face = c.faces(sel(Vector(0, 0, 1), 2)) + self.assertEqual(top_face.size(), 1) + self.assertTupleAlmostEquals((0, 0, 1), top_face.val().Center().toTuple(), 3) + + with self.assertRaises(IndexError): + c.faces(sel(Vector(0, 0, 1), 3)) + + left_face = c.faces(sel(Vector(1, 0, 0), 0)) + self.assertEqual(left_face.size(), 1) + self.assertTupleAlmostEquals( + (-0.5, 0, 0.5), left_face.val().Center().toTuple(), 3 + ) + + middle_faces = c.faces(sel(Vector(1, 0, 0), 1)) + self.assertEqual(middle_faces.size(), 4) + for f in middle_faces.vals(): + self.assertAlmostEqual(0, f.Center().x) + + right_face = c.faces(sel(Vector(1, 0, 0), 2)) + self.assertEqual(right_face.size(), 1) + self.assertTupleAlmostEquals( + (0.5, 0, 0.5), right_face.val().Center().toTuple(), 3 + ) + + with self.assertRaises(IndexError): + c.faces(sel(Vector(1, 0, 0), 3)) + + # lower corner faces + self.assertEqual(c.faces(sel(Vector(1, 1, 1), 0)).size(), 3) + # upper corner faces + self.assertEqual(c.faces(sel(Vector(1, 1, 1), 1)).size(), 3) + with self.assertRaises(IndexError): + c.faces(sel(Vector(1, 1, 1), 2)) + + for idx, z_val in zip([0, 1, 2], [0, 0.5, 1]): + edges = c.edges(sel(Vector(0, 0, 1), idx)) + self.assertEqual(edges.size(), 4) + for e in edges.vals(): + self.assertAlmostEqual(z_val, e.Center().z) + with self.assertRaises(IndexError): + c.edges(sel(Vector(0, 0, 1), 3)) + + for idx, z_val in zip([0, 1], [0, 1]): + vertices = c.vertices(sel(Vector(0, 0, 1), idx)) + self.assertEqual(vertices.size(), 4) + for e in vertices.vals(): + self.assertAlmostEqual(z_val, e.Z) + with self.assertRaises(IndexError): + c.vertices(sel(Vector(0, 0, 1), 3)) + + # test string version + face1 = c.faces(">>X[-1]") + face2 = c.faces("<<(2,0,1)[0]") + face3 = c.faces("<>X") + + self.assertTrue(face1.val().isSame(face2.val())) + self.assertTrue(face1.val().isSame(face3.val())) + self.assertTrue(face1.val().isSame(face4.val())) + + prism = Workplane().rect(2, 2).extrude(1, taper=30) + + # CenterNth disregards orientation + edges1 = prism.edges(">>Z[-2]") + self.assertEqual(len(edges1.vals()), 4) + + # DirectionNth does not + with self.assertRaises(ValueError): + prism.edges(">Z[-2]") + + # select a non-linear edge + part = ( + Workplane() + .rect(10, 10, centered=False) + .extrude(1) + .faces(">Z") + .workplane(centerOption="CenterOfMass") + .move(-3, 0) + .hole(2) + ) + hole = part.faces(">Z").edges(sel(Vector(1, 0, 0), 1)) + # have we selected a single hole? + self.assertEqual(1, hole.size()) + self.assertAlmostEqual(1, hole.val().radius()) + + # can we select a non-planar face? + hole_face = part.faces(sel(Vector(1, 0, 0), 1)) + self.assertEqual(hole_face.size(), 1) + self.assertNotEqual(hole_face.val().geomType(), "PLANE") + + # select solids + box0 = Workplane().box(1, 1, 1, centered=(True, True, True)) + box1 = Workplane("XY", origin=(10, 10, 10)).box( + 1, 1, 1, centered=(True, True, True) + ) + part = box0.add(box1) + self.assertEqual(part.solids().size(), 2) + for direction in [(0, 0, 1), (0, 1, 0), (1, 0, 0)]: + box0_selected = part.solids(sel(Vector(direction), 0)) + self.assertEqual(1, box0_selected.size()) + self.assertTupleAlmostEquals( + (0, 0, 0), box0_selected.val().Center().toTuple(), 3 + ) + box1_selected = part.solids(sel(Vector(direction), 1)) + self.assertEqual(1, box0_selected.size()) + self.assertTupleAlmostEquals( + (10, 10, 10), box1_selected.val().Center().toTuple(), 3 + ) + def testMaxDistance(self): c = CQ(makeUnitCube()) @@ -450,6 +626,19 @@ def testBox(self): self.assertEqual(1, len(fl)) def testRadiusNthSelector(self): + + # test the key method behaves + rad = 2.3 + arc = Edge.makeCircle(radius=rad) + sel = selectors.RadiusNthSelector(0) + self.assertAlmostEqual(rad, sel.key(arc), 3) + line = Edge.makeLine(Vector(0, 0, 0), Vector(1, 1, 1)) + with self.assertRaises(ValueError): + sel.key(line) + solid = makeUnitCube() + with self.assertRaises(ValueError): + sel.key(solid) + part = ( Workplane() .box(10, 10, 1) @@ -630,6 +819,8 @@ def testGrammar(self): "%Plane", ">XZ", ">(1,1,0)", ">(1,4,55.)[20]", "|XY", "