diff --git a/CadQuery/Libs/cadquery-lib/.coverage b/CadQuery/Libs/cadquery-lib/.coverage index 0a4ac1c..09e0f75 100644 Binary files a/CadQuery/Libs/cadquery-lib/.coverage and b/CadQuery/Libs/cadquery-lib/.coverage differ diff --git a/CadQuery/Libs/cadquery-lib/.travis.yml b/CadQuery/Libs/cadquery-lib/.travis.yml index e4e38b0..3537f48 100644 --- a/CadQuery/Libs/cadquery-lib/.travis.yml +++ b/CadQuery/Libs/cadquery-lib/.travis.yml @@ -1,6 +1,6 @@ language: python before_install: -- sudo add-apt-repository -y ppa:freecad-maintainers/freecad-daily +- sudo add-apt-repository -y ppa:freecad-maintainers/freecad-stable - sudo apt-get update -qq install: - sudo apt-get install -y freecad freecad-doc @@ -11,6 +11,7 @@ install: - pip install coveralls - pip install Sphinx==1.3.2 - pip install travis-sphinx +- pip install pyparsing script: - coverage run --source=cadquery ./runtests.py - travis-sphinx --nowarn --source=doc build diff --git a/CadQuery/Libs/cadquery-lib/cadquery/cq.py b/CadQuery/Libs/cadquery-lib/cadquery/cq.py index 2ac9d7e..b2d3a50 100644 --- a/CadQuery/Libs/cadquery-lib/cadquery/cq.py +++ b/CadQuery/Libs/cadquery-lib/cadquery/cq.py @@ -1967,7 +1967,7 @@ class Workplane(CQ): if clean: newS = newS.clean() return newS - def extrude(self, distance, combine=True, clean=True): + def extrude(self, distance, combine=True, clean=True, both=False): """ Use all un-extruded wires in the parent chain to create a prismatic solid. @@ -1975,6 +1975,7 @@ class Workplane(CQ): :type distance: float, negative means opposite the normal direction :param boolean combine: True to combine the resulting solid with parent solids if found. :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape + :param boolean both: extrude in both directions symmetrically :return: a CQ object with the resulting solid selected. extrude always *adds* material to a part. @@ -1990,8 +1991,9 @@ class Workplane(CQ): Support for non-prismatic extrusion ( IE, sweeping along a profile, not just perpendicular to the plane extrude to surface. this is quite tricky since the surface selected may not be planar - """ - r = self._extrude(distance) # returns a Solid (or a compound if there were multiple) + """ + r = self._extrude(distance,both=both) # returns a Solid (or a compound if there were multiple) + if combine: newS = self._combineWithBase(r) else: @@ -2254,11 +2256,12 @@ class Workplane(CQ): return self.newObject([r]) - def _extrude(self, distance): + def _extrude(self, distance, both=False): """ Make a prismatic solid from the existing set of pending wires. :param distance: distance to extrude + :param boolean both: extrude in both directions symmetrically :return: a FreeCAD solid, suitable for boolean operations. This method is a utility method, primarily for plugin and internal use. @@ -2305,6 +2308,10 @@ class Workplane(CQ): for ws in wireSets: thisObj = Solid.extrudeLinear(ws[0], ws[1:], eDir) toFuse.append(thisObj) + + if both: + thisObj = Solid.extrudeLinear(ws[0], ws[1:], eDir.multiply(-1.)) + toFuse.append(thisObj) return Compound.makeCompound(toFuse) diff --git a/CadQuery/Libs/cadquery-lib/cadquery/selectors.py b/CadQuery/Libs/cadquery-lib/cadquery/selectors.py index be07d7b..5d81e8a 100644 --- a/CadQuery/Libs/cadquery-lib/cadquery/selectors.py +++ b/CadQuery/Libs/cadquery-lib/cadquery/selectors.py @@ -20,6 +20,8 @@ import re import math from cadquery import Vector,Edge,Vertex,Face,Solid,Shell,Compound +from pyparsing import Literal,Word,nums,Optional,Combine,oneOf,\ + upcaseTokens,CaselessLiteral,Group class Selector(object): @@ -306,15 +308,66 @@ class DirectionMinMaxSelector(Selector): # pnt = tShape.Center() #return pnt.dot(self.vector) + # import OrderedDict + from collections import OrderedDict + #make and distance to object dict + objectDict = {distance(el) : el for el in objectList} + #transform it into an ordered dict + objectDict = OrderedDict(sorted(objectDict.items(), + key=lambda x: x[0])) + # find out the max/min distance if self.directionMax: - d = max(map(distance, objectList)) + d = objectDict.keys()[-1] else: - d = min(map(distance, objectList)) - + d = objectDict.keys()[0] + # return all objects at the max/min distance (within a tolerance) return filter(lambda o: abs(d - distance(o)) < self.TOLERANCE, objectList) +class DirectionNthSelector(ParallelDirSelector): + """ + Selects nth object parallel (or normal) to the specified direction + Used for faces and edges + + Applicability: + Linear Edges + Planar Faces + """ + def __init__(self, vector, n, directionMax=True, tolerance=0.0001): + self.direction = vector + self.max = max + self.directionMax = directionMax + self.TOLERANCE = tolerance + if directionMax: + self.N = n #do we want indexing from 0 or from 1? + else: + self.N = -n + + def filter(self,objectList): + #select first the objects that are normal/parallel to a given dir + objectList = super(DirectionNthSelector,self).filter(objectList) + + def distance(tShape): + return tShape.Center().dot(self.direction) + #if tShape.ShapeType == 'Vertex': + # pnt = tShape.Point + #else: + # pnt = tShape.Center() + #return pnt.dot(self.vector) + + #make and distance to object dict + objectDict = {distance(el) : el for el in objectList} + #calculate how many digits of precision do we need + digits = int(1/self.TOLERANCE) + # create a rounded distance to original distance mapping (implicitly perfroms unique operation) + dist_round_dist = {round(d,digits) : d for d in objectDict.keys()} + # choose the Nth unique rounded distance + nth_d = dist_round_dist[sorted(dist_round_dist.keys())[self.N]] + + # map back to original objects and return + return [objectDict[d] for d in objectDict.keys() if abs(d-nth_d) < self.TOLERANCE] + class BinarySelector(Selector): """ Base class for selectors that operates with two other @@ -367,6 +420,62 @@ class InverseSelector(Selector): # note that Selector() selects everything return SubtractSelector(Selector(), self.selector).filter(objectList) + +def _makeGrammar(): + """ + Define the string selector grammar using PyParsing + """ + + #float definition + point = Literal('.') + plusmin = Literal('+') | Literal('-') + number = Word(nums) + integer = Combine(Optional(plusmin) + number) + floatn = Combine(integer + Optional(point + Optional(number))) + + #vector definition + lbracket = Literal('(') + rbracket = Literal(')') + comma = Literal(',') + vector = Combine(lbracket + floatn('x') + comma + \ + floatn('y') + comma + floatn('z') + rbracket) + + #direction definition + simple_dir = oneOf(['X','Y','Z','XY','XZ','YZ']) + direction = simple_dir('simple_dir') | vector('vector_dir') + + #CQ type definition + cqtype = oneOf(['Plane','Cylinder','Sphere','Cone','Line','Circle','Arc'], + caseless=True) + cqtype = cqtype.setParseAction(upcaseTokens) + + #type operator + type_op = Literal('%') + + #direction operator + direction_op = oneOf(['>','<']) + + #index definition + ix_number = Group(Optional('-')+Word(nums)) + lsqbracket = Literal('[').suppress() + rsqbracket = Literal(']').suppress() + + index = lsqbracket + ix_number('index') + rsqbracket + + #other operators + other_op = oneOf(['|','#','+','-']) + + #named view + named_view = oneOf(['front','back','left','right','top','bottom']) + + return direction('only_dir') | \ + (type_op('type_op') + cqtype('cq_type')) | \ + (direction_op('dir_op') + direction('dir') + Optional(index)) | \ + (other_op('other_op') + direction('dir')) | \ + named_view('named_view') + +_grammar = _makeGrammar() #make a grammar instance + class StringSyntaxSelector(Selector): """ Filter lists objects using a simple string syntax. All of the filters available in the string syntax @@ -414,58 +523,70 @@ class StringSyntaxSelector(Selector): 'XZ': Vector(1,0,1) } - namedViews = { - 'front': ('>','Z' ), - 'back': ('<','Z'), - 'left':('<', 'X'), - 'right': ('>', 'X'), - 'top': ('>','Y'), - 'bottom': ('<','Y') + self.namedViews = { + 'front' : (Vector(0,0,1),True), + 'back' : (Vector(0,0,1),False), + 'left' : (Vector(1,0,0),False), + 'right' : (Vector(1,0,0),True), + 'top' : (Vector(0,1,0),True), + 'bottom': (Vector(0,1,0),False) } + + self.operatorMinMax = { + '>' : True, + '<' : False, + '+' : True, + '-' : False + } + + self.operator = { + '+' : DirectionSelector, + '-' : DirectionSelector, + '#' : PerpendicularDirSelector, + '|' : ParallelDirSelector} + self.selectorString = selectorString - r = re.compile("\s*([-\+<>\|\%#])*\s*(\w+)\s*",re.IGNORECASE) - m = r.match(selectorString) - - if m != None: - if namedViews.has_key(selectorString): - (a,b) = namedViews[selectorString] - self.mySelector = self._chooseSelector(a,b ) - else: - self.mySelector = self._chooseSelector(m.groups()[0],m.groups()[1]) - else: - raise ValueError ("Selector String format must be [-+<>|#%] X|Y|Z ") - - - def _chooseSelector(self,selType,selAxis): - """Sets up the underlying filters accordingly""" - - if selType == "%": - return TypeSelector(selAxis) - - #all other types need to select axis as a vector - #get the axis vector first, will throw an except if an unknown axis is used - try: - vec = self.axes[selAxis] - except KeyError: - raise ValueError ("Axis value %s not allowed: must be one of %s" % (selAxis, str(self.axes))) - - if selType in (None, "+"): - #use direction filter + parsing_result = _grammar.parseString(selectorString) + self.mySelector = self._chooseSelector(parsing_result) + + def _chooseSelector(self,pr): + """ + Sets up the underlying filters accordingly + """ + if 'only_dir' in pr: + vec = self._getVector(pr) return DirectionSelector(vec) - elif selType == '-': - #just use the reverse of the direction vector - return DirectionSelector(vec.multiply(-1.0)) - elif selType == "|": - return ParallelDirSelector(vec) - elif selType == ">": - return DirectionMinMaxSelector(vec,True) - elif selType == "<": - return DirectionMinMaxSelector(vec,False) - elif selType == '#': - return PerpendicularDirSelector(vec) + + elif 'type_op' in pr: + return TypeSelector(pr.cq_type) + + elif 'dir_op' in pr: + vec = self._getVector(pr) + minmax = self.operatorMinMax[pr.dir_op] + + if 'index' in pr: + return DirectionNthSelector(vec,int(''.join(pr.index.asList())),minmax) + else: + return DirectionMinMaxSelector(vec,minmax) + + elif 'other_op' in pr: + vec = self._getVector(pr) + return self.operator[pr.other_op](vec) + else: - raise ValueError ("Selector String format must be [-+<>|] X|Y|Z ") - + args = self.namedViews[pr.named_view] + return DirectionMinMaxSelector(*args) + + def _getVector(self,pr): + """ + Translate parsed vector string into a CQ Vector + """ + if 'vector_dir' in pr: + vec = pr.vector_dir + return Vector(float(vec.x),float(vec.y),float(vec.z)) + else: + return self.axes[pr.simple_dir] + def filter(self,objectList): """ selects minimum, maximum, positive or negative values relative to a direction diff --git a/CadQuery/Libs/cadquery-lib/changes.md b/CadQuery/Libs/cadquery-lib/changes.md index 0d23132..8a4cd0b 100644 --- a/CadQuery/Libs/cadquery-lib/changes.md +++ b/CadQuery/Libs/cadquery-lib/changes.md @@ -84,3 +84,12 @@ v0.5.1 ------ * Mirroring fixes (thanks @huskier) * Added a mirroring example (thanks @huskier) + +v0.5.2 +------ + * Added the sweep operation #33 + +v1.0.0 (unreleased) +------ + * Added an option to do symmetric extrusion about the workplane (thanks @adam-urbanczyk) + * Extended selector syntax to include Nth selector and re-implemented selectors using pyparsing (thanks @adam-urbanczyk) diff --git a/CadQuery/Libs/cadquery-lib/tests/TestCQSelectors.py b/CadQuery/Libs/cadquery-lib/tests/TestCQSelectors.py index f90e14b..fdb20cf 100644 --- a/CadQuery/Libs/cadquery-lib/tests/TestCQSelectors.py +++ b/CadQuery/Libs/cadquery-lib/tests/TestCQSelectors.py @@ -166,7 +166,51 @@ class TestCQSelectors(BaseTest): # test the case of multiple objects at the same distance el = c.edges("(1,0,0)[1]').val() + self.assertAlmostEqual(val.Center().x,-1.5) + + #2nd face with inversed selection vector + val = c.faces('>(-1,0,0)[1]').val() + self.assertAlmostEqual(val.Center().x,1.5) + + #2nd last face + val = c.faces('>X[-2]').val() + self.assertAlmostEqual(val.Center().x,1.5) + + #Last face + val = c.faces('>X[-1]').val() + self.assertAlmostEqual(val.Center().x,2.5) + + #check if the selected face if normal to the specified Vector + self.assertAlmostEqual(val.normalAt().cross(Vector(1,0,0)).Length,0.0) + + def testNearestTo(self): c = CQ(makeUnitCube()) @@ -356,3 +400,30 @@ class TestCQSelectors(BaseTest): #make sure the vertex is the right one self.assertTupleAlmostEquals((0.0,0.0,1.0),v2.val().toTuple() ,3) + + def testGrammar(self): + """ + Test if reasonable string selector expressions parse without an error + """ + + gram = selectors._makeGrammar() + + expressions = ['+X ', + '-Y', + '|(1,0,0)', + '#(1.,1.4114,-0.532)', + '%Plane', + '>XZ', + '(1,4,55.)[20]', + '|XY', + 'Z") + bottom_face = s.faces("