608 lines
21 KiB
Python
608 lines
21 KiB
Python
"""
|
|
Copyright (C) 2011-2015 Parametric Products Intellectual Holdings, LLC
|
|
|
|
This file is part of CadQuery.
|
|
|
|
CadQuery is free software; you can redistribute it and/or
|
|
modify it under the terms of the GNU Lesser General Public
|
|
License as published by the Free Software Foundation; either
|
|
version 2.1 of the License, or (at your option) any later version.
|
|
|
|
CadQuery is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
Lesser General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Lesser General Public
|
|
License along with this library; If not, see <http://www.gnu.org/licenses/>
|
|
"""
|
|
|
|
import math
|
|
import cadquery
|
|
import FreeCAD
|
|
import Part as FreeCADPart
|
|
|
|
def sortWiresByBuildOrder(wireList,plane,result=[]):
|
|
"""
|
|
Tries to determine how wires should be combined into faces.
|
|
Assume:
|
|
The wires make up one or more faces, which could have 'holes'
|
|
Outer wires are listed ahead of inner wires
|
|
there are no wires inside wires inside wires ( IE, islands -- we can deal with that later on )
|
|
none of the wires are construction wires
|
|
Compute:
|
|
one or more sets of wires, with the outer wire listed first, and inner ones
|
|
Returns, list of lists.
|
|
"""
|
|
result = []
|
|
|
|
remainingWires = list(wireList)
|
|
while remainingWires:
|
|
outerWire = remainingWires.pop(0)
|
|
group = [outerWire]
|
|
otherWires = list(remainingWires)
|
|
for w in otherWires:
|
|
if plane.isWireInside(outerWire,w):
|
|
group.append(w)
|
|
remainingWires.remove(w)
|
|
result.append(group)
|
|
|
|
return result
|
|
|
|
class Vector(object):
|
|
"""
|
|
Create a 3-dimensional vector
|
|
|
|
:param *args: a 3-d vector, with x-y-z parts.
|
|
|
|
you can either provide:
|
|
* a FreeCAD vector
|
|
* a vector ( in which case it is copied )
|
|
* a 3-tuple
|
|
* three float values, x, y, and z
|
|
|
|
FreeCAD's vector implementation has a dumb
|
|
implementation for multiply and add-- they modify the existing
|
|
value and return a copy as well.
|
|
|
|
This vector is immutable-- all mutations return a copy!
|
|
|
|
"""
|
|
def __init__(self,*args):
|
|
|
|
if len(args) == 3:
|
|
fV = FreeCAD.Base.Vector(args[0],args[1],args[2])
|
|
elif len(args) == 1:
|
|
if type(args[0]) is tuple:
|
|
fV = FreeCAD.Base.Vector(args[0][0],args[0][1],args[0][2])
|
|
elif type(args[0] is FreeCAD.Base.Vector):
|
|
fV = args[0]
|
|
elif type(args[0] is Vector):
|
|
fV = args[0].wrapped
|
|
else:
|
|
fV = args[0]
|
|
else:
|
|
raise ValueError("Expected three floats, FreeCAD Vector, or 3-tuple")
|
|
|
|
self.wrapped = fV
|
|
self.Length = fV.Length
|
|
self.x = fV.x
|
|
self.y = fV.y
|
|
self.z = fV.z
|
|
|
|
def toTuple(self):
|
|
return (self.x,self.y,self.z)
|
|
|
|
#TODO: is it possible to create a dynamic proxy without all this code?
|
|
def cross(self,v):
|
|
return Vector( self.wrapped.cross(v.wrapped))
|
|
|
|
def dot(self,v):
|
|
return self.wrapped.dot(v.wrapped)
|
|
|
|
def sub(self,v):
|
|
return self.wrapped.sub(v.wrapped)
|
|
|
|
def add(self,v):
|
|
return Vector( self.wrapped.add(v.wrapped))
|
|
|
|
def multiply(self,scale):
|
|
"""
|
|
Return self multiplied by the provided scalar
|
|
|
|
Note: FreeCAD has a bug here, where the
|
|
base is also modified
|
|
"""
|
|
tmp = FreeCAD.Base.Vector(self.wrapped)
|
|
return Vector( tmp.multiply(scale))
|
|
|
|
def normalize(self):
|
|
"""
|
|
Return normalized version this vector.
|
|
|
|
Note: FreeCAD has a bug here, where the
|
|
base is also modified
|
|
"""
|
|
tmp = FreeCAD.Base.Vector(self.wrapped)
|
|
tmp.normalize()
|
|
return Vector( tmp )
|
|
|
|
def Center(self):
|
|
"""
|
|
The center of myself is myself.
|
|
Provided so that vectors, vertexes, and other shapes all support a common interface,
|
|
when Center() is requested for all objects on the stack
|
|
"""
|
|
return self
|
|
|
|
def getAngle(self,v):
|
|
return self.wrapped.getAngle(v.wrapped)
|
|
|
|
def distanceToLine(self):
|
|
raise NotImplementedError("Have not needed this yet, but FreeCAD supports it!")
|
|
|
|
def projectToLine(self):
|
|
raise NotImplementedError("Have not needed this yet, but FreeCAD supports it!")
|
|
|
|
def distanceToPlane(self):
|
|
raise NotImplementedError("Have not needed this yet, but FreeCAD supports it!")
|
|
|
|
def projectToPlane(self):
|
|
raise NotImplementedError("Have not needed this yet, but FreeCAD supports it!")
|
|
|
|
def __hash__(self):
|
|
return self.wrapped.__hash__()
|
|
|
|
def __add__(self,v):
|
|
return self.add(v)
|
|
|
|
def __len__(self):
|
|
return self.Length
|
|
|
|
def __repr__(self):
|
|
return self.wrapped.__repr__()
|
|
|
|
def __str__(self):
|
|
return self.wrapped.__str__()
|
|
|
|
def __len__(self,other):
|
|
return self.wrapped.__len__(other)
|
|
|
|
def __lt__(self,other):
|
|
return self.wrapped.__lt__(other)
|
|
|
|
def __gt__(self,other):
|
|
return self.wrapped.__gt__(other)
|
|
|
|
def __ne__(self,other):
|
|
return self.wrapped.__ne__(other)
|
|
|
|
def __le__(self,other):
|
|
return self.wrapped.__le__(other)
|
|
|
|
def __ge__(self,other):
|
|
return self.wrapped.__ge__(other)
|
|
|
|
def __eq__(self,other):
|
|
return self.wrapped.__eq__(other)
|
|
|
|
class Matrix:
|
|
"""
|
|
A 3d , 4x4 transformation matrix.
|
|
|
|
Used to move geometry in space.
|
|
"""
|
|
def __init__(self,matrix=None):
|
|
if matrix == None:
|
|
self.wrapped = FreeCAD.Base.Matrix()
|
|
else:
|
|
self.wrapped = matrix
|
|
|
|
def rotateX(self,angle):
|
|
self.wrapped.rotateX(angle)
|
|
|
|
def rotateY(self,angle):
|
|
self.wrapped.rotateY(angle)
|
|
|
|
|
|
class Plane:
|
|
"""
|
|
A 2d coordinate system in space, with the x-y axes on the a plane, and a particular point as the origin.
|
|
|
|
A plane allows the use of 2-d coordinates, which are later converted to global, 3d coordinates when
|
|
the operations are complete.
|
|
|
|
Frequently, it is not necessary to create work planes, as they can be created automatically from faces.
|
|
|
|
"""
|
|
|
|
@classmethod
|
|
def named(cls,stdName,origin=(0,0,0)):
|
|
"""
|
|
Create a predefined Plane based on the conventional names.
|
|
|
|
:param stdName: one of (XY|YZ|XZ|front|back|left|right|top|bottom
|
|
:type stdName: string
|
|
:param origin: the desired origin, specified in global coordinates
|
|
:type origin: 3-tuple of the origin of the new plane, in global coorindates.
|
|
|
|
Available named planes are as follows. Direction references refer to the global
|
|
directions
|
|
|
|
=========== ======= ======= ======
|
|
Name xDir yDir zDir
|
|
=========== ======= ======= ======
|
|
XY +x +y +z
|
|
YZ +y +z +x
|
|
XZ +x +z -y
|
|
front +x +y +z
|
|
back -x +y -z
|
|
left +z +y -x
|
|
right -z +y +x
|
|
top +x -z +y
|
|
bottom +x +z -y
|
|
=========== ======= ======= ======
|
|
"""
|
|
|
|
namedPlanes = {
|
|
#origin, xDir, normal
|
|
'XY' : Plane(Vector(origin),Vector((1,0,0)),Vector((0,0,1))),
|
|
'YZ' : Plane(Vector(origin),Vector((0,1,0)),Vector((1,0,0))),
|
|
'XZ' : Plane(Vector(origin),Vector((1,0,0)),Vector((0,-1,0))),
|
|
'front': Plane(Vector(origin),Vector((1,0,0)),Vector((0,0,1))),
|
|
'back': Plane(Vector(origin),Vector((-1,0,0)),Vector((0,0,-1))),
|
|
'left': Plane(Vector(origin),Vector((0,0,1)),Vector((-1,0,0))),
|
|
'right': Plane(Vector(origin),Vector((0,0,-1)),Vector((1,0,0))),
|
|
'top': Plane(Vector(origin),Vector((1,0,0)),Vector((0,1,0))),
|
|
'bottom': Plane(Vector(origin),Vector((1,0,0)),Vector((0,-1,0)))
|
|
}
|
|
|
|
if namedPlanes.has_key(stdName):
|
|
return namedPlanes[stdName]
|
|
else:
|
|
raise ValueError("Supported names are %s " % str(namedPlanes.keys()) )
|
|
|
|
@classmethod
|
|
def XY(cls,origin=(0,0,0),xDir=Vector(1,0,0)):
|
|
return Plane.named('XY',origin)
|
|
|
|
@classmethod
|
|
def YZ(cls,origin=(0,0,0),xDir=Vector(1,0,0)):
|
|
return Plane.named('YZ',origin)
|
|
|
|
@classmethod
|
|
def XZ(cls,origin=(0,0,0),xDir=Vector(1,0,0)):
|
|
return Plane.named('XZ',origin)
|
|
|
|
@classmethod
|
|
def front(cls,origin=(0,0,0),xDir=Vector(1,0,0)):
|
|
return Plane.named('front',origin)
|
|
|
|
@classmethod
|
|
def back(cls,origin=(0,0,0),xDir=Vector(1,0,0)):
|
|
return Plane.named('back',origin)
|
|
|
|
@classmethod
|
|
def left(cls,origin=(0,0,0),xDir=Vector(1,0,0)):
|
|
return Plane.named('left',origin)
|
|
|
|
@classmethod
|
|
def right(cls,origin=(0,0,0),xDir=Vector(1,0,0)):
|
|
return Plane.named('right',origin)
|
|
|
|
@classmethod
|
|
def top(cls,origin=(0,0,0),xDir=Vector(1,0,0)):
|
|
return Plane.named('top',origin)
|
|
|
|
@classmethod
|
|
def bottom(cls,origin=(0,0,0),xDir=Vector(1,0,0)):
|
|
return Plane.named('bottom',origin)
|
|
|
|
def __init__(self, origin, xDir, normal ):
|
|
"""
|
|
Create a Plane with an arbitrary orientation
|
|
|
|
TODO: project x and y vectors so they work even if not orthogonal
|
|
:param origin: the origin
|
|
:type origin: a three-tuple of the origin, in global coordinates
|
|
:param xDir: a vector representing the xDirection.
|
|
:type xDir: a three-tuple representing a vector, or a FreeCAD Vector
|
|
:param normal: the normal direction for the new plane
|
|
:type normal: a FreeCAD Vector
|
|
:raises: ValueError if the specified xDir is not orthogonal to the provided normal.
|
|
:return: a plane in the global space, with the xDirection of the plane in the specified direction.
|
|
|
|
"""
|
|
self.xDir = xDir.normalize()
|
|
self.yDir = normal.cross(self.xDir).normalize()
|
|
self.zDir = normal.normalize()
|
|
|
|
#stupid freeCAD!!!!! multiply has a bug that changes the original also!
|
|
self.invZDir = self.zDir.multiply(-1.0)
|
|
|
|
self.setOrigin3d(origin)
|
|
|
|
|
|
def setOrigin3d(self,originVector):
|
|
"""
|
|
Move the origin of the plane, leaving its orientation and xDirection unchanged.
|
|
:param originVector: the new center of the plane, *global* coordinates
|
|
:type originVector: a FreeCAD Vector.
|
|
:return: void
|
|
|
|
"""
|
|
self.origin = originVector
|
|
self._calcTransforms()
|
|
|
|
def setOrigin2d(self,x,y):
|
|
"""
|
|
Set a new origin based of the plane. The plane's orientation and xDrection are unaffected.
|
|
|
|
:param float x: offset in the x direction
|
|
:param float y: offset in the y direction
|
|
:return: void
|
|
|
|
the new coordinates are specified in terms of the current 2-d system. As an example::
|
|
p = Plane.XY()
|
|
p.setOrigin2d(2,2)
|
|
p.setOrigin2d(2,2)
|
|
|
|
results in a plane with its origin at (x,y)=(4,4) in global coordinates. The both operations were relative to
|
|
local coordinates of the plane.
|
|
|
|
"""
|
|
self.setOrigin3d(self.toWorldCoords((x,y)))
|
|
|
|
|
|
def isWireInside(self,baseWire,testWire):
|
|
"""
|
|
Determine if testWire is inside baseWire, after both wires are projected into the current plane
|
|
|
|
:param baseWire: a reference wire
|
|
:type baseWire: a FreeCAD wire
|
|
:param testWire: another wire
|
|
:type testWire: a FreeCAD wire
|
|
:return: True if testWire is inside baseWire, otherwise False
|
|
|
|
If either wire does not lie in the current plane, it is projected into the plane first.
|
|
|
|
*WARNING*: This method is not 100% reliable. It uses bounding box tests, but needs
|
|
more work to check for cases when curves are complex.
|
|
|
|
Future Enhancements:
|
|
* Discretizing points along each curve to provide a more reliable test
|
|
|
|
"""
|
|
#TODO: also use a set of points along the wire to test as well.
|
|
#TODO: would it be more efficient to create objects in the local coordinate system, and then transform to global
|
|
#coordinates upon extrusion?
|
|
|
|
tBaseWire = baseWire.transformGeometry(self.fG)
|
|
tTestWire = testWire.transformGeometry(self.fG)
|
|
|
|
#these bounding boxes will have z=0, since we transformed them into the space of the plane
|
|
bb = tBaseWire.BoundingBox()
|
|
tb = tTestWire.BoundingBox()
|
|
|
|
#findOutsideBox actually inspects both ways, here we only want to
|
|
#know if one is inside the other
|
|
x = BoundBox.findOutsideBox2D(bb,tb)
|
|
return x == bb
|
|
|
|
def toLocalCoords(self,obj):
|
|
"""
|
|
Project the provided coordinates onto this plane.
|
|
|
|
:param obj: an object or vector to convert
|
|
:type vector: a vector or shape
|
|
:return: an object of the same type as the input, but converted to local coordinates
|
|
|
|
|
|
Most of the time, the z-coordinate returned will be zero, because most operations
|
|
based on a plane are all 2-d. Occasionally, though, 3-d points outside of the current plane are transformed.
|
|
One such example is :py:meth:`Workplane.box`, where 3-d corners of a box are transformed to orient the box in space
|
|
correctly.
|
|
|
|
"""
|
|
if isinstance(obj, Vector):
|
|
return Vector(self.fG.multiply(obj.wrapped))
|
|
elif isinstance(obj, cadquery.Shape):
|
|
return obj.transformShape(self.rG)
|
|
else:
|
|
raise ValueError("Dont know how to convert type %s to local coordinates" % str(type(obj)))
|
|
|
|
|
|
def toWorldCoords(self, tuplePoint):
|
|
"""
|
|
Convert a point in local coordinates to global coordinates.
|
|
|
|
:param tuplePoint: point in local coordinates to convert
|
|
:type tuplePoint: a 2 or three tuple of float. the third value is taken to be zero if not supplied
|
|
:return: a Vector in global coordinates
|
|
|
|
|
|
"""
|
|
if isinstance(tuplePoint, Vector):
|
|
v = tuplePoint
|
|
elif len(tuplePoint) == 2:
|
|
v = Vector(tuplePoint[0], tuplePoint[1], 0)
|
|
else:
|
|
v = Vector(tuplePoint[0],tuplePoint[1],tuplePoint[2])
|
|
return Vector(self.rG.multiply(v.wrapped))
|
|
|
|
|
|
def rotated(self,rotate=(0,0,0)):
|
|
"""
|
|
returns a copy of this plane, rotated about the specified axes, as measured from horizontal
|
|
|
|
Since the z axis is always normal the plane, rotating around Z will always produce a plane
|
|
that is parallel to this one
|
|
|
|
the origin of the workplane is unaffected by the rotation.
|
|
|
|
rotations are done in order x,y,z. if you need a different order, manually chain together multiple .rotate()
|
|
commands
|
|
|
|
:param rotate: Vector [xDegrees,yDegrees,zDegrees]
|
|
:return: a copy of this plane rotated as requested
|
|
"""
|
|
|
|
if rotate.__class__.__name__ != 'Vector':
|
|
rotate = Vector(rotate)
|
|
#convert to radians
|
|
rotate = rotate.multiply(math.pi / 180.0 )
|
|
|
|
#compute rotation matrix
|
|
m = FreeCAD.Base.Matrix()
|
|
m.rotateX(rotate.x)
|
|
m.rotateY(rotate.y)
|
|
m.rotateZ(rotate.z)
|
|
|
|
#compute the new plane
|
|
newXdir = Vector(m.multiply(self.xDir.wrapped))
|
|
newZdir = Vector(m.multiply(self.zDir.wrapped))
|
|
|
|
newP= Plane(self.origin,newXdir,newZdir)
|
|
return newP
|
|
|
|
def rotateShapes(self, listOfShapes, rotationMatrix):
|
|
"""
|
|
rotate the listOfShapes by the rotationMatrix supplied.
|
|
@param listOfShapes is a list of shape objects
|
|
@param rotationMatrix is a geom.Matrix object.
|
|
returns a list of shape objects rotated according to the rotationMatrix
|
|
"""
|
|
|
|
#compute rotation matrix ( global --> local --> rotate --> global )
|
|
#rm = self.plane.fG.multiply(matrix).multiply(self.plane.rG)
|
|
rm = self.computeTransform(rotationMatrix)
|
|
|
|
|
|
#There might be a better way, but to do this rotation takes 3 steps
|
|
#transform geometry to local coordinates
|
|
#then rotate about x
|
|
#then transform back to global coordinates
|
|
|
|
resultWires = []
|
|
for w in listOfShapes:
|
|
mirrored = w.transformGeometry(rotationMatrix.wrapped)
|
|
|
|
# If the first vertex of the second wire is not coincident with the first or last vertices of the first wire
|
|
# we have to fix the wire so that it will mirror correctly
|
|
if (mirrored.wrapped.Vertexes[0].X == w.wrapped.Vertexes[0].X and
|
|
mirrored.wrapped.Vertexes[0].Y == w.wrapped.Vertexes[0].Y and
|
|
mirrored.wrapped.Vertexes[0].Z == w.wrapped.Vertexes[0].Z) or \
|
|
(mirrored.wrapped.Vertexes[0].X == w.wrapped.Vertexes[-1].X and
|
|
mirrored.wrapped.Vertexes[0].Y == w.wrapped.Vertexes[-1].Y and
|
|
mirrored.wrapped.Vertexes[0].Z == w.wrapped.Vertexes[-1].Z):
|
|
|
|
resultWires.append(mirrored)
|
|
else:
|
|
# Make sure that our mirrored edges meet up and are ordered properly
|
|
aEdges = w.wrapped.Edges
|
|
aEdges.extend(mirrored.wrapped.Edges)
|
|
comp = FreeCADPart.Compound(aEdges)
|
|
mirroredWire = comp.connectEdgesToWires(False).Wires[0]
|
|
|
|
resultWires.append(cadquery.Shape.cast(mirroredWire))
|
|
|
|
return resultWires
|
|
|
|
|
|
def _calcTransforms(self):
|
|
"""
|
|
Computes transformation martrices to convert between local and global coordinates
|
|
"""
|
|
#r is the forward transformation matrix from world to local coordinates
|
|
#ok i will be really honest-- i cannot understand exactly why this works
|
|
#something bout the order of the translation and the rotation.
|
|
# the double-inverting is strange, and I don't understand it.
|
|
r = FreeCAD.Base.Matrix()
|
|
|
|
#forward transform must rotate and adjust for origin
|
|
(r.A11, r.A12, r.A13 ) = (self.xDir.x, self.xDir.y, self.xDir.z )
|
|
(r.A21, r.A22, r.A23 ) = (self.yDir.x, self.yDir.y, self.yDir.z )
|
|
(r.A31, r.A32, r.A33 ) = (self.zDir.x, self.zDir.y, self.zDir.z )
|
|
|
|
invR = r.inverse()
|
|
(invR.A14,invR.A24,invR.A34) = (self.origin.x,self.origin.y,self.origin.z)
|
|
|
|
( self.rG,self.fG ) = ( invR,invR.inverse() )
|
|
|
|
def computeTransform(self,tMatrix):
|
|
"""
|
|
Computes the 2-d projection of the supplied matrix
|
|
"""
|
|
|
|
rm = self.fG.multiply(tMatrix.wrapped).multiply(self.rG)
|
|
return Matrix(rm)
|
|
|
|
class BoundBox(object):
|
|
"A BoundingBox for an object or set of objects. Wraps the FreeCAD one"
|
|
def __init__(self,bb):
|
|
self.wrapped = bb
|
|
self.xmin = bb.XMin
|
|
self.xmax = bb.XMax
|
|
self.xlen = bb.XLength
|
|
self.ymin = bb.YMin
|
|
self.ymax = bb.YMax
|
|
self.ylen = bb.YLength
|
|
self.zmin = bb.ZMin
|
|
self.zmax = bb.ZMax
|
|
self.zlen = bb.ZLength
|
|
self.center = Vector(bb.Center)
|
|
self.DiagonalLength = bb.DiagonalLength
|
|
|
|
def add(self,obj):
|
|
"""
|
|
returns a modified (expanded) bounding box
|
|
|
|
obj can be one of several things:
|
|
1. a 3-tuple corresponding to x,y, and z amounts to add
|
|
2. a vector, containing the x,y,z values to add
|
|
3. another bounding box, where a new box will be created that encloses both
|
|
|
|
this bounding box is not changed
|
|
"""
|
|
tmp = FreeCAD.Base.BoundBox(self.wrapped)
|
|
if type(obj) is tuple:
|
|
tmp.add(obj[0],obj[1],obj[2])
|
|
elif type(obj) is Vector:
|
|
tmp.add(obj.fV)
|
|
elif type(obj) is BoundBox:
|
|
tmp.add(obj.wrapped)
|
|
|
|
return BoundBox(tmp)
|
|
|
|
@classmethod
|
|
def findOutsideBox2D(cls,b1, b2):
|
|
"""
|
|
compares bounding boxes. returns none if neither is inside the other. returns
|
|
the outer one if either is outside the other
|
|
|
|
BoundBox.isInside works in 3d, but this is a 2d bounding box, so it doesnt work correctly
|
|
plus, there was all kinds of rounding error in the built-in implementation i do not understand.
|
|
Here we assume that the b
|
|
"""
|
|
bb1 = b1.wrapped
|
|
bb2 = b2.wrapped
|
|
if bb1.XMin < bb2.XMin and\
|
|
bb1.XMax > bb2.XMax and\
|
|
bb1.YMin < bb2.YMin and\
|
|
bb1.YMax > bb2.YMax:
|
|
return b1
|
|
|
|
if bb2.XMin < bb1.XMin and\
|
|
bb2.XMax > bb1.XMax and\
|
|
bb2.YMin < bb1.YMin and\
|
|
bb2.YMax > bb1.YMax:
|
|
return b2
|
|
|
|
return None
|
|
|
|
def isInside(self,anotherBox):
|
|
"""
|
|
is the provided bounding box inside this one?
|
|
"""
|
|
return self.wrapped.isInside(anotherBox.wrapped)
|