442 lines
18 KiB
Python
442 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
# ***************************************************************************
|
|
# * *
|
|
# * Copyright (c) 2020 russ4262 <russ4262@gmail.com> *
|
|
# * *
|
|
# * This program is free software; you can redistribute it and/or modify *
|
|
# * it under the terms of the GNU Lesser General Public License (LGPL) *
|
|
# * as published by the Free Software Foundation; either version 2 of *
|
|
# * the License, or (at your option) any later version. *
|
|
# * for detail see the LICENCE text file. *
|
|
# * *
|
|
# * This program 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 Library General Public License for more details. *
|
|
# * *
|
|
# * You should have received a copy of the GNU Library General Public *
|
|
# * License along with this program; if not, write to the Free Software *
|
|
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
|
|
# * USA *
|
|
# * *
|
|
# ***************************************************************************
|
|
|
|
from __future__ import print_function
|
|
|
|
__title__ = "Path Surface Support Module"
|
|
__author__ = "russ4262 (Russell Johnson)"
|
|
__url__ = "http://www.freecadweb.org"
|
|
__doc__ = "Support functions and classes for 3D Surface and Waterline operations."
|
|
__contributors__ = ""
|
|
|
|
import FreeCAD
|
|
from PySide import QtCore
|
|
import Path
|
|
import PathScripts.PathLog as PathLog
|
|
import PathScripts.PathUtils as PathUtils
|
|
import math
|
|
import Part
|
|
|
|
|
|
PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule())
|
|
# PathLog.trackModule(PathLog.thisModule())
|
|
|
|
|
|
# Qt translation handling
|
|
def translate(context, text, disambig=None):
|
|
return QtCore.QCoreApplication.translate(context, text, disambig)
|
|
|
|
|
|
class PathGeometryGenerator:
|
|
'''Creates a path geometry shape from an assigned pattern for conversion to tool paths.
|
|
PathGeometryGenerator(obj, shape, pattern)
|
|
`obj` is the operation object, `shape` is the horizontal planar shape object,
|
|
and `pattern` is the name of the geometric pattern to apply.
|
|
Frist, call the getCenterOfMass() method for the CenterOfMass for patterns allowing a custom center.
|
|
Next, call the getPathGeometryGenerator() method to request the path geometry shape.'''
|
|
|
|
# Register valid patterns here by name
|
|
# Create a corresponding processing method below. Precede the name with an underscore(_)
|
|
patterns = ('Circular', 'CircularZigZag', 'Line', 'Offset', 'Spiral', 'ZigZag')
|
|
|
|
def __init__(self, obj, shape, pattern):
|
|
'''__init__(obj, shape, pattern)... Instantiate PathGeometryGenerator class.
|
|
Required arguments are the operation object, horizontal planar shape, and pattern name.'''
|
|
self.debugObjectsGroup = False
|
|
self.pattern = None
|
|
self.shape = None
|
|
self.pathGeometry = None
|
|
self.rawGeoList = None
|
|
self.centerOfMass = None
|
|
self.deltaX = None
|
|
self.deltaY = None
|
|
self.deltaC = None
|
|
self.halfDiag = None
|
|
self.halfPasses = None
|
|
self.obj = obj
|
|
self.toolDiam = float(obj.ToolController.Tool.Diameter)
|
|
self.cutOut = self.toolDiam * (float(obj.StepOver) / 100.0)
|
|
self.wpc = Part.makeCircle(2.0) # make circle for workplane
|
|
|
|
# validate requested pattern
|
|
if pattern in self.patterns:
|
|
if hasattr(self, '_' + pattern):
|
|
self.pattern = pattern
|
|
|
|
if shape.BoundBox.ZMin != 0.0:
|
|
shape.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - shape.BoundBox.ZMin))
|
|
if shape.BoundBox.ZMax == 0.0:
|
|
self.shape = shape
|
|
else:
|
|
PathLog.warning('Shape appears to not be horizontal planar. ZMax is {}.'.format(shape.BoundBox.ZMax))
|
|
|
|
self._prepareConstants()
|
|
|
|
def _prepareConstants(self):
|
|
# Apply drop cutter extra offset and set the max and min XY area of the operation
|
|
xmin = self.shape.BoundBox.XMin
|
|
xmax = self.shape.BoundBox.XMax
|
|
ymin = self.shape.BoundBox.YMin
|
|
ymax = self.shape.BoundBox.YMax
|
|
|
|
# Compute weighted center of mass of all faces combined
|
|
fCnt = 0
|
|
totArea = 0.0
|
|
zeroCOM = FreeCAD.Vector(0.0, 0.0, 0.0)
|
|
for F in self.shape.Faces:
|
|
comF = F.CenterOfMass
|
|
areaF = F.Area
|
|
totArea += areaF
|
|
fCnt += 1
|
|
zeroCOM = zeroCOM.add(FreeCAD.Vector(comF.x, comF.y, 0.0).multiply(areaF))
|
|
if fCnt == 0:
|
|
PathLog.error(translate('PathSurface', 'Cannot calculate the Center Of Mass. Using Center of Boundbox.'))
|
|
zeroCOM = FreeCAD.Vector((xmin + xmax) / 2.0, (ymin + ymax) / 2.0, 0.0)
|
|
else:
|
|
avgArea = totArea / fCnt
|
|
zeroCOM.multiply(1 / fCnt)
|
|
zeroCOM.multiply(1 / avgArea)
|
|
self.centerOfMass = FreeCAD.Vector(zeroCOM.x, zeroCOM.y, 0.0)
|
|
|
|
# get X, Y, Z spans; Compute center of rotation
|
|
self.deltaX = self.shape.BoundBox.XLength
|
|
self.deltaY = self.shape.BoundBox.YLength
|
|
self.deltaC = self.shape.BoundBox.DiagonalLength # math.sqrt(self.deltaX**2 + self.deltaY**2)
|
|
lineLen = self.deltaC + (2.0 * self.toolDiam) # Line length to span boundbox diag with 2x cutter diameter extra on each end
|
|
self.halfDiag = math.ceil(lineLen / 2.0)
|
|
cutPasses = math.ceil(lineLen / self.cutOut) + 1 # Number of lines(passes) required to cover boundbox diagonal
|
|
self.halfPasses = math.ceil(cutPasses / 2.0)
|
|
|
|
# Public methods
|
|
def setDebugObjectsGroup(self, tmpGrpObject):
|
|
'''setDebugObjectsGroup(tmpGrpObject)...
|
|
Pass the temporary object group to show temporary construction objects'''
|
|
self.debugObjectsGroup = tmpGrpObject
|
|
|
|
def getCenterOfMass(self):
|
|
'''getCenterOfMass()...
|
|
Returns the Center Of Mass for the current class instance.'''
|
|
return self.centerOfMass
|
|
|
|
def getPathGeometryGenerator(self):
|
|
'''getPathGeometryGenerator()...
|
|
Call this function to obtain the path geometry shape, generated by this class.'''
|
|
if self.pattern is None:
|
|
PathLog.warning('PGG: No pattern set.')
|
|
return False
|
|
|
|
if self.shape is None:
|
|
PathLog.warning('PGG: No shape set.')
|
|
return False
|
|
|
|
cmd = 'self._' + self.pattern + '()'
|
|
exec(cmd)
|
|
|
|
if self.obj.CutPatternReversed is True:
|
|
self.rawGeoList.reverse()
|
|
|
|
# Create compound object to bind all lines in Lineset
|
|
geomShape = Part.makeCompound(self.rawGeoList)
|
|
|
|
# Position and rotate the Line and ZigZag geometry
|
|
if self.pattern in ['Line', 'ZigZag']:
|
|
if self.obj.CutPatternAngle != 0.0:
|
|
geomShape.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), self.obj.CutPatternAngle)
|
|
bbC = self.shape.BoundBox.Center
|
|
geomShape.Placement.Base = FreeCAD.Vector(bbC.x, bbC.y, 0.0 - geomShape.BoundBox.ZMin)
|
|
|
|
if self.debugObjectsGroup:
|
|
F = FreeCAD.ActiveDocument.addObject('Part::Feature','tmpGeometrySet')
|
|
F.Shape = geomShape
|
|
F.purgeTouched()
|
|
self.debugObjectsGroup.addObject(F)
|
|
|
|
if self.pattern == 'Offset':
|
|
return geomShape
|
|
|
|
# Identify intersection of cross-section face and lineset
|
|
cmnShape = self.shape.common(geomShape)
|
|
|
|
if self.debugObjectsGroup:
|
|
F = FreeCAD.ActiveDocument.addObject('Part::Feature','tmpPathGeometry')
|
|
F.Shape = cmnShape
|
|
F.purgeTouched()
|
|
self.debugObjectsGroup.addObject(F)
|
|
|
|
self.tmpCOM = FreeCAD.Vector(self.centerOfMass.x, self.centerOfMass.y, 0.0)
|
|
return cmnShape
|
|
|
|
# Cut pattern methods
|
|
def _Circular(self):
|
|
GeoSet = list()
|
|
zTgt = 0.0 # self.shape.BoundBox.ZMin
|
|
centerAt = self.obj.CircularCenterAt
|
|
cntr = FreeCAD.Placement()
|
|
|
|
if centerAt == 'CenterOfMass':
|
|
cntrPnt = FreeCAD.Vector(self.centerOfMass.x, self.centerOfMass.y, zTgt) # self.centerOfMass # Use center of Mass
|
|
elif centerAt == 'CenterOfBoundBox':
|
|
cent = self.shape.BoundBox.Center
|
|
cntrPnt = FreeCAD.Vector(cent.x, cent.y, zTgt)
|
|
elif centerAt == 'XminYmin':
|
|
cntrPnt = FreeCAD.Vector(self.shape.BoundBox.XMin, self.shape.BoundBox.YMin, zTgt)
|
|
elif centerAt == 'Custom':
|
|
newCent = FreeCAD.Vector(self.obj.CircularCenterCustom.x, self.obj.CircularCenterCustom.y, zTgt)
|
|
cntrPnt = newCent
|
|
|
|
# recalculate number of passes, if need be
|
|
radialPasses = self.halfPasses
|
|
if centerAt != 'CenterOfBoundBox':
|
|
# make 4 corners of boundbox in XY plane, find which is greatest distance to new circular center
|
|
EBB = self.shape.BoundBox
|
|
CORNERS = [
|
|
FreeCAD.Vector(EBB.XMin, EBB.YMin, 0.0),
|
|
FreeCAD.Vector(EBB.XMin, EBB.YMax, 0.0),
|
|
FreeCAD.Vector(EBB.XMax, EBB.YMax, 0.0),
|
|
FreeCAD.Vector(EBB.XMax, EBB.YMin, 0.0),
|
|
]
|
|
dMax = 0.0
|
|
for c in range(0, 4):
|
|
dist = CORNERS[c].sub(cntrPnt).Length
|
|
if dist > dMax:
|
|
dMax = dist
|
|
diag = dMax + (2.0 * self.toolDiam) # Line length to span boundbox diag with 2x cutter diameter extra on each end
|
|
radialPasses = math.ceil(diag / self.cutOut) + 1 # Number of lines(passes) required to cover boundbox diagonal
|
|
|
|
# Update self.centerOfMass point and current CircularCenter
|
|
if centerAt != 'Custom':
|
|
self.obj.CircularCenterCustom = cntrPnt
|
|
|
|
minRad = self.toolDiam * 0.45
|
|
siX3 = 3 * self.obj.SampleInterval.Value
|
|
minRadSI = (siX3 / 2.0) / math.pi
|
|
if minRad < minRadSI:
|
|
minRad = minRadSI
|
|
|
|
# Make small center circle to start pattern
|
|
if self.obj.StepOver > 50:
|
|
circle = Part.makeCircle(minRad, cntrPnt)
|
|
GeoSet.append(circle)
|
|
|
|
for lc in range(1, radialPasses + 1):
|
|
rad = (lc * self.cutOut)
|
|
if rad >= minRad:
|
|
circle = Part.makeCircle(rad, cntrPnt)
|
|
GeoSet.append(circle)
|
|
# Efor
|
|
self.centerOfMass = cntrPnt
|
|
self.rawGeoList = GeoSet
|
|
|
|
def _CircularZigZag(self):
|
|
self._Circular() # Use _Circular generator
|
|
|
|
def _Line(self):
|
|
GeoSet = list()
|
|
centRot = FreeCAD.Vector(0.0, 0.0, 0.0) # Bottom left corner of face/selection/model
|
|
cAng = math.atan(self.deltaX / self.deltaY) # BoundaryBox angle
|
|
|
|
# Determine end points and create top lines
|
|
x1 = centRot.x - self.halfDiag
|
|
x2 = centRot.x + self.halfDiag
|
|
diag = None
|
|
if self.obj.CutPatternAngle == 0 or self.obj.CutPatternAngle == 180:
|
|
diag = self.deltaY
|
|
elif self.obj.CutPatternAngle == 90 or self.obj.CutPatternAngle == 270:
|
|
diag = self.deltaX
|
|
else:
|
|
perpDist = math.cos(cAng - math.radians(self.obj.CutPatternAngle)) * self.deltaC
|
|
diag = perpDist
|
|
y1 = centRot.y + diag
|
|
# y2 = y1
|
|
|
|
# Create end points for set of lines to intersect with cross-section face
|
|
pntTuples = list()
|
|
for lc in range((-1 * (self.halfPasses - 1)), self.halfPasses + 1):
|
|
x1 = centRot.x - self.halfDiag
|
|
x2 = centRot.x + self.halfDiag
|
|
y1 = centRot.y + (lc * self.cutOut)
|
|
# y2 = y1
|
|
p1 = FreeCAD.Vector(x1, y1, 0.0)
|
|
p2 = FreeCAD.Vector(x2, y1, 0.0)
|
|
pntTuples.append( (p1, p2) )
|
|
|
|
# Convert end points to lines
|
|
for (p1, p2) in pntTuples:
|
|
line = Part.makeLine(p1, p2)
|
|
GeoSet.append(line)
|
|
|
|
self.rawGeoList = GeoSet
|
|
|
|
def _Offset(self):
|
|
self.rawGeoList = self._extractOffsetFaces()
|
|
|
|
def _Spiral(self):
|
|
GeoSet = list()
|
|
SEGS = list()
|
|
draw = True
|
|
loopRadians = 0.0 # Used to keep track of complete loops/cycles
|
|
sumRadians = 0.0
|
|
loopCnt = 0
|
|
segCnt = 0
|
|
twoPi = 2.0 * math.pi
|
|
maxDist = self.halfDiag
|
|
move = self.centerOfMass # FreeCAD.Vector(0.0, 0.0, 0.0) # Use to translate the center of the spiral
|
|
lastPoint = FreeCAD.Vector(0.0, 0.0, 0.0)
|
|
|
|
# Set tool properties and calculate cutout
|
|
cutOut = self.cutOut / twoPi
|
|
segLen = self.obj.SampleInterval.Value # CutterDiameter / 10.0 # SampleInterval.Value
|
|
stepAng = segLen / ((loopCnt + 1) * self.cutOut) # math.pi / 18.0 # 10 degrees
|
|
stopRadians = maxDist / cutOut
|
|
|
|
if self.obj.CutPatternReversed:
|
|
if self.obj.CutMode == 'Conventional':
|
|
getPoint = self._makeOppSpiralPnt
|
|
else:
|
|
getPoint = self._makeRegSpiralPnt
|
|
|
|
while draw:
|
|
radAng = sumRadians + stepAng
|
|
p1 = lastPoint
|
|
p2 = getPoint(move, cutOut, radAng) # cutOut is 'b' in the equation r = b * radAng
|
|
sumRadians += stepAng # Increment sumRadians
|
|
loopRadians += stepAng # Increment loopRadians
|
|
if loopRadians > twoPi:
|
|
loopCnt += 1
|
|
loopRadians -= twoPi
|
|
stepAng = segLen / ((loopCnt + 1) * self.cutOut) # adjust stepAng with each loop/cycle
|
|
segCnt += 1
|
|
lastPoint = p2
|
|
if sumRadians > stopRadians:
|
|
draw = False
|
|
# Create line and show in Object tree
|
|
lineSeg = Part.makeLine(p2, p1)
|
|
SEGS.append(lineSeg)
|
|
# Ewhile
|
|
SEGS.reverse()
|
|
else:
|
|
if self.obj.CutMode == 'Climb':
|
|
getPoint = self._makeOppSpiralPnt
|
|
else:
|
|
getPoint = self._makeRegSpiralPnt
|
|
|
|
while draw:
|
|
radAng = sumRadians + stepAng
|
|
p1 = lastPoint
|
|
p2 = getPoint(move, cutOut, radAng) # cutOut is 'b' in the equation r = b * radAng
|
|
sumRadians += stepAng # Increment sumRadians
|
|
loopRadians += stepAng # Increment loopRadians
|
|
if loopRadians > twoPi:
|
|
loopCnt += 1
|
|
loopRadians -= twoPi
|
|
stepAng = segLen / ((loopCnt + 1) * self.cutOut) # adjust stepAng with each loop/cycle
|
|
segCnt += 1
|
|
lastPoint = p2
|
|
if sumRadians > stopRadians:
|
|
draw = False
|
|
# Create line and show in Object tree
|
|
lineSeg = Part.makeLine(p1, p2)
|
|
SEGS.append(lineSeg)
|
|
# Ewhile
|
|
# Eif
|
|
spiral = Part.Wire([ls.Edges[0] for ls in SEGS])
|
|
GeoSet.append(spiral)
|
|
|
|
self.rawGeoList = GeoSet
|
|
|
|
def _ZigZag(self):
|
|
self._Line() # Use _Line generator
|
|
|
|
# Support methods
|
|
def _makeRegSpiralPnt(self, move, b, radAng):
|
|
x = b * radAng * math.cos(radAng)
|
|
y = b * radAng * math.sin(radAng)
|
|
return FreeCAD.Vector(x, y, 0.0).add(move)
|
|
|
|
def _makeOppSpiralPnt(self, move, b, radAng):
|
|
x = b * radAng * math.cos(radAng)
|
|
y = b * radAng * math.sin(radAng)
|
|
return FreeCAD.Vector(-1 * x, y, 0.0).add(move)
|
|
|
|
def _extractOffsetFaces(self):
|
|
PathLog.debug('_extractOffsetFaces()')
|
|
wires = list()
|
|
faces = list()
|
|
ofst = 0.0 # - self.cutOut
|
|
shape = self.shape
|
|
cont = True
|
|
cnt = 0
|
|
while cont:
|
|
ofstArea = self._getFaceOffset(shape, ofst)
|
|
if not ofstArea:
|
|
PathLog.warning('PGG: No offset clearing area returned.')
|
|
cont = False
|
|
break
|
|
for F in ofstArea.Faces:
|
|
faces.append(F)
|
|
for w in F.Wires:
|
|
wires.append(w)
|
|
shape = ofstArea
|
|
if cnt == 0:
|
|
ofst = 0.0 - self.cutOut
|
|
cnt += 1
|
|
return wires
|
|
|
|
def _getFaceOffset(self, shape, offset):
|
|
'''_getFaceOffset(shape, offset) ... internal function.
|
|
Original _buildPathArea() version copied from PathAreaOp.py module. This version is modified.
|
|
Adjustments made based on notes by @sliptonic at this webpage: https://github.com/sliptonic/FreeCAD/wiki/PathArea-notes.'''
|
|
PathLog.debug('_getFaceOffset()')
|
|
|
|
areaParams = {}
|
|
areaParams['Offset'] = offset
|
|
areaParams['Fill'] = 1 # 1
|
|
areaParams['Coplanar'] = 0
|
|
areaParams['SectionCount'] = 1 # -1 = full(all per depthparams??) sections
|
|
areaParams['Reorient'] = True
|
|
areaParams['OpenMode'] = 0
|
|
areaParams['MaxArcPoints'] = 400 # 400
|
|
areaParams['Project'] = True
|
|
|
|
area = Path.Area() # Create instance of Area() class object
|
|
# area.setPlane(PathUtils.makeWorkplane(shape)) # Set working plane
|
|
area.setPlane(PathUtils.makeWorkplane(self.wpc)) # Set working plane to normal at Z=1
|
|
area.add(shape)
|
|
area.setParams(**areaParams) # set parameters
|
|
|
|
offsetShape = area.getShape()
|
|
wCnt = len(offsetShape.Wires)
|
|
if wCnt == 0:
|
|
return False
|
|
elif wCnt == 1:
|
|
ofstFace = Part.Face(offsetShape.Wires[0])
|
|
else:
|
|
W = list()
|
|
for wr in offsetShape.Wires:
|
|
W.append(Part.Face(wr))
|
|
ofstFace = Part.makeCompound(W)
|
|
|
|
return ofstFace
|
|
# Eclass
|