diff --git a/src/Mod/Path/CMakeLists.txt b/src/Mod/Path/CMakeLists.txt index ded7c91a93..78415e1e32 100644 --- a/src/Mod/Path/CMakeLists.txt +++ b/src/Mod/Path/CMakeLists.txt @@ -107,6 +107,7 @@ SET(PathScripts_SRCS PathScripts/PathStop.py PathScripts/PathSurface.py PathScripts/PathSurfaceGui.py + PathScripts/PathSurfaceSupport.py PathScripts/PathToolBit.py PathScripts/PathToolBitCmd.py PathScripts/PathToolBitEdit.py diff --git a/src/Mod/Path/PathScripts/PathSurfaceSupport.py b/src/Mod/Path/PathScripts/PathSurfaceSupport.py new file mode 100644 index 0000000000..68abd2abc5 --- /dev/null +++ b/src/Mod/Path/PathScripts/PathSurfaceSupport.py @@ -0,0 +1,441 @@ +# -*- coding: utf-8 -*- + +# *************************************************************************** +# * * +# * Copyright (c) 2020 russ4262 * +# * * +# * 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