Merge branch 'master' of https://github.com/dcowden/cadquery
This commit is contained in:
commit
02bc403c3e
Binary file not shown.
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
@ -1990,8 +1991,9 @@ class Workplane(CQ):
|
||||||
Support for non-prismatic extrusion ( IE, sweeping along a profile, not just
|
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
|
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.
|
||||||
|
@ -2305,6 +2308,10 @@ class Workplane(CQ):
|
||||||
for ws in wireSets:
|
for ws in wireSets:
|
||||||
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)
|
||||||
|
|
||||||
|
|
|
@ -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,58 +523,70 @@ 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:
|
||||||
|
return DirectionMinMaxSelector(vec,minmax)
|
||||||
|
|
||||||
|
elif 'other_op' in pr:
|
||||||
|
vec = self._getVector(pr)
|
||||||
|
return self.operator[pr.other_op](vec)
|
||||||
|
|
||||||
else:
|
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):
|
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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -166,7 +166,51 @@ class TestCQSelectors(BaseTest):
|
||||||
# test the case of multiple objects at the same distance
|
# test the case of multiple objects at the same distance
|
||||||
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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user