This commit is contained in:
Jeremy Mack Wright 2016-07-04 15:11:12 -04:00
commit 02bc403c3e
7 changed files with 290 additions and 57 deletions

Binary file not shown.

View File

@ -1,6 +1,6 @@
language: python language: python
before_install: 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 - sudo apt-get update -qq
install: install:
- sudo apt-get install -y freecad freecad-doc - sudo apt-get install -y freecad freecad-doc
@ -11,6 +11,7 @@ install:
- pip install coveralls - pip install coveralls
- pip install Sphinx==1.3.2 - pip install Sphinx==1.3.2
- pip install travis-sphinx - pip install travis-sphinx
- pip install pyparsing
script: script:
- coverage run --source=cadquery ./runtests.py - coverage run --source=cadquery ./runtests.py
- travis-sphinx --nowarn --source=doc build - travis-sphinx --nowarn --source=doc build

View File

@ -1967,7 +1967,7 @@ class Workplane(CQ):
if clean: newS = newS.clean() if clean: newS = newS.clean()
return newS 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. 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 :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 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 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. :return: a CQ object with the resulting solid selected.
extrude always *adds* material to a part. extrude always *adds* material to a part.
@ -1991,7 +1992,8 @@ class Workplane(CQ):
perpendicular to the plane extrude to surface. this is quite tricky since the surface perpendicular to the plane extrude to surface. this is quite tricky since the surface
selected may not be planar 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: if combine:
newS = self._combineWithBase(r) newS = self._combineWithBase(r)
else: else:
@ -2254,11 +2256,12 @@ class Workplane(CQ):
return self.newObject([r]) 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. Make a prismatic solid from the existing set of pending wires.
:param distance: distance to extrude :param distance: distance to extrude
:param boolean both: extrude in both directions symmetrically
:return: a FreeCAD solid, suitable for boolean operations. :return: a FreeCAD solid, suitable for boolean operations.
This method is a utility method, primarily for plugin and internal use. This method is a utility method, primarily for plugin and internal use.
@ -2306,6 +2309,10 @@ class Workplane(CQ):
thisObj = Solid.extrudeLinear(ws[0], ws[1:], eDir) thisObj = Solid.extrudeLinear(ws[0], ws[1:], eDir)
toFuse.append(thisObj) toFuse.append(thisObj)
if both:
thisObj = Solid.extrudeLinear(ws[0], ws[1:], eDir.multiply(-1.))
toFuse.append(thisObj)
return Compound.makeCompound(toFuse) return Compound.makeCompound(toFuse)
def _revolve(self, angleDegrees, axisStart, axisEnd): def _revolve(self, angleDegrees, axisStart, axisEnd):

View File

@ -20,6 +20,8 @@
import re import re
import math import math
from cadquery import Vector,Edge,Vertex,Face,Solid,Shell,Compound 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): class Selector(object):
@ -306,15 +308,66 @@ class DirectionMinMaxSelector(Selector):
# pnt = tShape.Center() # pnt = tShape.Center()
#return pnt.dot(self.vector) #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 # find out the max/min distance
if self.directionMax: if self.directionMax:
d = max(map(distance, objectList)) d = objectDict.keys()[-1]
else: else:
d = min(map(distance, objectList)) d = objectDict.keys()[0]
# return all objects at the max/min distance (within a tolerance) # return all objects at the max/min distance (within a tolerance)
return filter(lambda o: abs(d - distance(o)) < self.TOLERANCE, objectList) 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): class BinarySelector(Selector):
""" """
Base class for selectors that operates with two other Base class for selectors that operates with two other
@ -367,6 +420,62 @@ class InverseSelector(Selector):
# note that Selector() selects everything # note that Selector() selects everything
return SubtractSelector(Selector(), self.selector).filter(objectList) 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): class StringSyntaxSelector(Selector):
""" """
Filter lists objects using a simple string syntax. All of the filters available in the string syntax Filter lists objects using a simple string syntax. All of the filters available in the string syntax
@ -414,57 +523,69 @@ class StringSyntaxSelector(Selector):
'XZ': Vector(1,0,1) 'XZ': Vector(1,0,1)
} }
namedViews = { self.namedViews = {
'front': ('>','Z' ), 'front' : (Vector(0,0,1),True),
'back': ('<','Z'), 'back' : (Vector(0,0,1),False),
'left':('<', 'X'), 'left' : (Vector(1,0,0),False),
'right': ('>', 'X'), 'right' : (Vector(1,0,0),True),
'top': ('>','Y'), 'top' : (Vector(0,1,0),True),
'bottom': ('<','Y') 'bottom': (Vector(0,1,0),False)
} }
self.operatorMinMax = {
'>' : True,
'<' : False,
'+' : True,
'-' : False
}
self.operator = {
'+' : DirectionSelector,
'-' : DirectionSelector,
'#' : PerpendicularDirSelector,
'|' : ParallelDirSelector}
self.selectorString = selectorString self.selectorString = selectorString
r = re.compile("\s*([-\+<>\|\%#])*\s*(\w+)\s*",re.IGNORECASE) parsing_result = _grammar.parseString(selectorString)
m = r.match(selectorString) self.mySelector = self._chooseSelector(parsing_result)
if m != None: def _chooseSelector(self,pr):
if namedViews.has_key(selectorString): """
(a,b) = namedViews[selectorString] Sets up the underlying filters accordingly
self.mySelector = self._chooseSelector(a,b ) """
else: if 'only_dir' in pr:
self.mySelector = self._chooseSelector(m.groups()[0],m.groups()[1]) vec = self._getVector(pr)
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
return DirectionSelector(vec) return DirectionSelector(vec)
elif selType == '-':
#just use the reverse of the direction vector elif 'type_op' in pr:
return DirectionSelector(vec.multiply(-1.0)) return TypeSelector(pr.cq_type)
elif selType == "|":
return ParallelDirSelector(vec) elif 'dir_op' in pr:
elif selType == ">": vec = self._getVector(pr)
return DirectionMinMaxSelector(vec,True) minmax = self.operatorMinMax[pr.dir_op]
elif selType == "<":
return DirectionMinMaxSelector(vec,False) if 'index' in pr:
elif selType == '#': return DirectionNthSelector(vec,int(''.join(pr.index.asList())),minmax)
return PerpendicularDirSelector(vec)
else: else:
raise ValueError ("Selector String format must be [-+<>|] X|Y|Z ") return DirectionMinMaxSelector(vec,minmax)
elif 'other_op' in pr:
vec = self._getVector(pr)
return self.operator[pr.other_op](vec)
else:
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): def filter(self,objectList):
""" """

View File

@ -84,3 +84,12 @@ v0.5.1
------ ------
* Mirroring fixes (thanks @huskier) * Mirroring fixes (thanks @huskier)
* Added a mirroring example (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)

View File

@ -167,6 +167,50 @@ class TestCQSelectors(BaseTest):
el = c.edges("<Z").vals() el = c.edges("<Z").vals()
self.assertEqual(4, len(el)) self.assertEqual(4, len(el))
def testNthDistance(self):
c = Workplane('XY').pushPoints([(-2,0),(2,0)]).box(1,1,1)
#2nd face
val = c.faces(selectors.DirectionNthSelector(Vector(1,0,0),1)).val()
self.assertAlmostEqual(val.Center().x,-1.5)
#2nd face with inversed selection vector
val = c.faces(selectors.DirectionNthSelector(Vector(-1,0,0),1)).val()
self.assertAlmostEqual(val.Center().x,1.5)
#2nd last face
val = c.faces(selectors.DirectionNthSelector(Vector(1,0,0),-2)).val()
self.assertAlmostEqual(val.Center().x,1.5)
#Last face
val = c.faces(selectors.DirectionNthSelector(Vector(1,0,0),-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)
#repeat the test using string based selector
#2nd face
val = c.faces('>(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): def testNearestTo(self):
c = CQ(makeUnitCube()) c = CQ(makeUnitCube())
@ -356,3 +400,30 @@ class TestCQSelectors(BaseTest):
#make sure the vertex is the right one #make sure the vertex is the right one
self.assertTupleAlmostEquals((0.0,0.0,1.0),v2.val().toTuple() ,3) 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',
'<Z[-2]',
'>(1,4,55.)[20]',
'|XY',
'<YZ[0]',
'front',
'back',
'left',
'right',
'top',
'bottom']
for e in expressions: gram.parseString(e)

View File

@ -1387,3 +1387,27 @@ class TestCadQuery(BaseTest):
result =topOfLid.union(bottom) result =topOfLid.union(bottom)
self.saveModel(result) self.saveModel(result)
def testExtrude(self):
"""
Test symmetric extrude
"""
r = 1.
h = 1.
decimal_places = 9.
#extrude symmetrically
s = Workplane("XY").circle(r).extrude(h,both=True)
top_face = s.faces(">Z")
bottom_face = s.faces("<Z")
#calculate the distance between the top and the bottom face
delta = top_face.val().Center().sub(bottom_face.val().Center())
self.assertTupleAlmostEquals(delta.toTuple(),
(0.,0.,2.*h),
decimal_places)