# -*- 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