# -*- 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 * # * * # *************************************************************************** __title__ = "CAM Surface Support Module" __author__ = "russ4262 (Russell Johnson)" __url__ = "https://www.freecad.org" __doc__ = "Support functions and classes for 3D Surface and Waterline operations." __contributors__ = "" import FreeCAD import Path import Path.Op.Util as PathOpUtil import PathScripts.PathUtils as PathUtils import math # lazily loaded modules from lazy_loader.lazy_loader import LazyLoader # MeshPart = LazyLoader('MeshPart', globals(), 'MeshPart') Part = LazyLoader("Part", globals(), "Part") if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) translate = FreeCAD.Qt.translate 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. First, call the getCenterOfPattern() method for the CenterOfMass for patterns allowing a custom center. Next, call the generatePathGeometry() 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.centerofPattern = 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.ZLength > 1.0e-8: msg = translate("PathSurfaceSupport", "Shape appears to not be horizontal planar.") msg += " ZMax == {} mm.\n".format(shape.BoundBox.ZMax) FreeCAD.Console.PrintWarning(msg) else: self.shape = shape self._prepareConstants() def _prepareConstants(self): # Compute weighted center of mass of all faces combined if self.pattern in ["Circular", "CircularZigZag", "Spiral"]: if self.obj.PatternCenterAt == "CenterOfMass": 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: msg = translate("PathSurfaceSupport", "Cannot calculate the Center Of Mass.") msg += ( " " + translate("PathSurfaceSupport", "Using Center of Boundbox instead.") + "\n" ) FreeCAD.Console.PrintError(msg) bbC = self.shape.BoundBox.Center zeroCOM = FreeCAD.Vector(bbC.x, bbC.y, 0.0) else: avgArea = totArea / fCnt zeroCOM.multiply(1 / fCnt) zeroCOM.multiply(1 / avgArea) self.centerOfMass = FreeCAD.Vector(zeroCOM.x, zeroCOM.y, 0.0) self.centerOfPattern = self._getPatternCenter() else: bbC = self.shape.BoundBox.Center self.centerOfPattern = FreeCAD.Vector(bbC.x, bbC.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 getCenterOfPattern(self): """getCenterOfPattern()... Returns the Center Of Mass for the current class instance.""" return self.centerOfPattern def generatePathGeometry(self): """generatePathGeometry()... Call this function to obtain the path geometry shape, generated by this class.""" if self.pattern == "None": return False if self.shape is None: 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) return cmnShape # Cut pattern methods def _Circular(self): GeoSet = [] radialPasses = self._getRadialPasses() minRad = self.toolDiam * 0.45 siX3 = 3 * self.obj.SampleInterval.Value minRadSI = (siX3 / 2.0) / math.pi if minRad < minRadSI: minRad = minRadSI Path.Log.debug(" -centerOfPattern: {}".format(self.centerOfPattern)) # Make small center circle to start pattern if self.obj.StepOver > 50: circle = Part.makeCircle(minRad, self.centerOfPattern) GeoSet.append(circle) for lc in range(1, radialPasses + 1): rad = lc * self.cutOut if rad >= minRad: circle = Part.makeCircle(rad, self.centerOfPattern) GeoSet.append(circle) # Efor self.rawGeoList = GeoSet def _CircularZigZag(self): self._Circular() # Use _Circular generator def _Line(self): GeoSet = [] centRot = FreeCAD.Vector(0.0, 0.0, 0.0) # Bottom left corner of face/selection/model # Create end points for set of lines to intersect with cross-section face pntTuples = [] 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 = [] SEGS = [] 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 = math.ceil(self.cutOut * self._getRadialPasses()) # self.halfDiag move = self.centerOfPattern # 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 _getPatternCenter(self): centerAt = self.obj.PatternCenterAt if centerAt == "CenterOfMass": cntrPnt = FreeCAD.Vector(self.centerOfMass.x, self.centerOfMass.y, 0.0) elif centerAt == "CenterOfBoundBox": cent = self.shape.BoundBox.Center cntrPnt = FreeCAD.Vector(cent.x, cent.y, 0.0) elif centerAt == "XminYmin": cntrPnt = FreeCAD.Vector(self.shape.BoundBox.XMin, self.shape.BoundBox.YMin, 0.0) elif centerAt == "Custom": cntrPnt = FreeCAD.Vector( self.obj.PatternCenterCustom.x, self.obj.PatternCenterCustom.y, 0.0 ) # Update centerOfPattern point if centerAt != "Custom": self.obj.PatternCenterCustom = cntrPnt self.centerOfPattern = cntrPnt return cntrPnt def _getRadialPasses(self): # recalculate number of passes, if need be radialPasses = self.halfPasses if self.obj.PatternCenterAt != "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(self.centerOfPattern).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 return radialPasses 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): Path.Log.debug("_extractOffsetFaces()") wires = [] shape = self.shape offset = 0.0 # Start right at the edge of cut area direction = 0 loop_cnt = 0 def _get_direction(w): if PathOpUtil._isWireClockwise(w): return 1 return -1 def _reverse_wire(w): rev_list = [] for e in w.Edges: rev_list.append(PathUtils.reverseEdge(e)) rev_list.reverse() # return Part.Wire(Part.__sortEdges__(rev_list)) return Part.Wire(rev_list) while True: offsetArea = PathUtils.getOffsetArea(shape, offset, plane=self.wpc) if not offsetArea: # Area fully consumed break # set initial cut direction if direction == 0: first_face_wire = offsetArea.Faces[0].Wires[0] direction = _get_direction(first_face_wire) if self.obj.CutMode == "Climb": if direction == 1: direction = -1 else: if direction == -1: direction = 1 # Correct cut direction for `Conventional` cuts if self.obj.CutMode == "Conventional": if loop_cnt == 1: direction = direction * -1 # process each wire within face for f in offsetArea.Faces: wire_cnt = 0 for w in f.Wires: use_direction = direction if wire_cnt > 0: # swap direction for internal features use_direction = direction * -1 wire_direction = _get_direction(w) # Process wire if wire_direction == use_direction: # direction is correct wires.append(w) else: # incorrect direction, so reverse wire rw = _reverse_wire(w) wires.append(rw) offset -= self.cutOut loop_cnt += 1 return wires # Eclass class ProcessSelectedFaces: """ProcessSelectedFaces(JOB, obj) class. This class processes the `obj.Base` object for selected geometery. Calling the preProcessModel(module) method returns two compound objects as a tuple: (FACES, VOIDS) or False.""" def __init__(self, JOB, obj): self.modelSTLs = [] self.profileShapes = [] self.tempGroup = False self.showDebugObjects = False self.checkBase = False self.module = None self.radius = None self.depthParams = None self.msgNoFaces = ( translate( "PathSurfaceSupport", "Face selection is unavailable for Rotational scans.", ) + "\n" ) self.msgNoFaces += " " + translate("PathSurfaceSupport", "Ignoring selected faces.") + "\n" self.JOB = JOB self.obj = obj self.profileEdges = "None" if hasattr(obj, "ProfileEdges"): self.profileEdges = obj.ProfileEdges # Setup STL, model type, and bound box containers for each model in Job for m in range(0, len(JOB.Model.Group)): self.modelSTLs.append(False) self.profileShapes.append(False) # make circle for workplane self.wpc = Part.makeCircle(2.0) def PathSurface(self): if self.obj.Base: if len(self.obj.Base) > 0: self.checkBase = True if self.obj.ScanType == "Rotational": self.checkBase = False FreeCAD.Console.PrintWarning(self.msgNoFaces) def PathWaterline(self): if self.obj.Base: if len(self.obj.Base) > 0: self.checkBase = True if self.obj.Algorithm in ["OCL Dropcutter", "Experimental"]: self.checkBase = False FreeCAD.Console.PrintWarning(self.msgNoFaces) # public class methods def setShowDebugObjects(self, grpObj, val): self.tempGroup = grpObj self.showDebugObjects = val def preProcessModel(self, module): Path.Log.debug("preProcessModel()") if not self._isReady(module): return False FACES = [] VOIDS = [] fShapes = [] vShapes = [] GRP = self.JOB.Model.Group lenGRP = len(GRP) proceed = False # Crete place holders for each base model in Job for m in range(0, lenGRP): FACES.append(False) VOIDS.append(False) fShapes.append(False) vShapes.append(False) # The user has selected subobjects from the base. Pre-Process each. if self.checkBase: Path.Log.debug(" -obj.Base exists. Pre-processing for selected faces.") (hasFace, hasVoid) = self._identifyFacesAndVoids( FACES, VOIDS ) # modifies FACES and VOIDS hasGeometry = True if hasFace or hasVoid else False # Cycle through each base model, processing faces for each for m in range(0, lenGRP): base = GRP[m] (mFS, mVS, mPS) = self._preProcessFacesAndVoids(base, FACES[m], VOIDS[m]) fShapes[m] = mFS vShapes[m] = mVS self.profileShapes[m] = mPS if mFS or mVS: proceed = True if hasGeometry and not proceed: return False else: Path.Log.debug(" -No obj.Base data.") for m in range(0, lenGRP): self.modelSTLs[m] = True # Process each model base, as a whole, as needed for m in range(0, lenGRP): if self.modelSTLs[m] and not fShapes[m]: Path.Log.debug(" -Pre-processing {} as a whole.".format(GRP[m].Label)) if self.obj.BoundBox == "BaseBoundBox": base = GRP[m] elif self.obj.BoundBox == "Stock": base = self.JOB.Stock pPEB = self._preProcessEntireBase(base, m) if pPEB is False: msg = ( translate( "PathSurfaceSupport", "Failed to pre-process base as a whole.", ) + "\n" ) FreeCAD.Console.PrintError(msg) else: (fcShp, prflShp) = pPEB if fcShp: if fcShp is True: Path.Log.debug(" -fcShp is True.") fShapes[m] = True else: fShapes[m] = [fcShp] if prflShp: if fcShp: Path.Log.debug("vShapes[{}]: {}".format(m, vShapes[m])) if vShapes[m]: Path.Log.debug(" -Cutting void from base profile shape.") adjPS = prflShp.cut(vShapes[m][0]) self.profileShapes[m] = [adjPS] else: Path.Log.debug(" -vShapes[m] is False.") self.profileShapes[m] = [prflShp] else: Path.Log.debug(" -Saving base profile shape.") self.profileShapes[m] = [prflShp] Path.Log.debug( "self.profileShapes[{}]: {}".format(m, self.profileShapes[m]) ) # Efor return (fShapes, vShapes) # private class methods def _isReady(self, module): """_isReady(module)... Internal method. Checks if required attributes are available for processing obj.Base (the Base Geometry).""" Path.Log.debug("ProcessSelectedFaces _isReady({})".format(module)) modMethodName = module.replace("Op.", "Path") if hasattr(self, modMethodName): self.module = module modMethod = getattr(self, modMethodName) # gets the attribute only modMethod() # executes as method else: Path.Log.error('PSF._isReady() no "{}" method.'.format(module)) return False if not self.radius: Path.Log.error("PSF._isReady() no cutter radius available.") return False if not self.depthParams: Path.Log.error("PSF._isReady() no depth params available.") return False return True def _identifyFacesAndVoids(self, F, V): TUPS = [] GRP = self.JOB.Model.Group lenGRP = len(GRP) hasFace = False hasVoid = False # Separate selected faces into (base, face) tuples and flag model(s) for STL creation for bs, SBS in self.obj.Base: for sb in SBS: # Flag model for STL creation mdlIdx = None for m in range(0, lenGRP): if bs is GRP[m]: self.modelSTLs[m] = True mdlIdx = m break TUPS.append((mdlIdx, bs, sb)) # (model idx, base, sub) # Apply `AvoidXFaces` value faceCnt = len(TUPS) add = faceCnt - self.obj.AvoidLastX_Faces for bst in range(0, faceCnt): (m, base, sub) = TUPS[bst] shape = getattr(base.Shape, sub) if isinstance(shape, Part.Face): faceIdx = int(sub[4:]) - 1 if bst < add: if F[m] is False: F[m] = [] F[m].append((shape, faceIdx)) Path.Log.debug(".. Cutting {}".format(sub)) hasFace = True else: if V[m] is False: V[m] = [] V[m].append((shape, faceIdx)) Path.Log.debug(".. Avoiding {}".format(sub)) hasVoid = True return (hasFace, hasVoid) def _preProcessFacesAndVoids(self, base, FCS, VDS): mFS = False mVS = False mPS = False mIFS = [] if FCS: isHole = False if self.obj.HandleMultipleFeatures == "Collectively": cont = True Path.Log.debug("Attempting to get cross-section of collective faces.") outFCS, ifL = self.findUnifiedRegions(FCS) if self.obj.InternalFeaturesCut and ifL: ifL = [] # clear avoid shape list if len(outFCS) == 0: msg = "PathSurfaceSupport \n Cannot process selected faces. Check horizontal \n surface exposure.\n" FreeCAD.Console.PrintError(msg) cont = False else: cfsL = Part.makeCompound(outFCS) # Handle profile edges request if cont and self.profileEdges != "None": Path.Log.debug(".. include Profile Edge") ofstVal = self._calculateOffsetValue(isHole) psOfst = PathUtils.getOffsetArea(cfsL, ofstVal, plane=self.wpc) if psOfst: mPS = [psOfst] if self.profileEdges == "Only": mFS = True cont = False else: cont = False if cont: if self.showDebugObjects: T = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmpCollectiveShape") T.Shape = cfsL T.purgeTouched() self.tempGroup.addObject(T) ofstVal = self._calculateOffsetValue(isHole) faceOfstShp = PathUtils.getOffsetArea(cfsL, ofstVal, plane=self.wpc) if not faceOfstShp: msg = "Failed to create offset face." FreeCAD.Console.PrintError(msg) cont = False if cont: lenIfL = len(ifL) if not self.obj.InternalFeaturesCut: if lenIfL == 0: Path.Log.debug(" -No internal features saved.") else: if lenIfL == 1: casL = ifL[0] else: casL = Part.makeCompound(ifL) if self.showDebugObjects: C = FreeCAD.ActiveDocument.addObject( "Part::Feature", "tmpCompoundIntFeat" ) C.Shape = casL C.purgeTouched() self.tempGroup.addObject(C) ofstVal = self._calculateOffsetValue(isHole=True) intOfstShp = PathUtils.getOffsetArea(casL, ofstVal, plane=self.wpc) mIFS.append(intOfstShp) mFS = [faceOfstShp] # Eif elif self.obj.HandleMultipleFeatures == "Individually": for fcshp, fcIdx in FCS: cont = True fNum = fcIdx + 1 outerFace = False gUR, ifL = self.findUnifiedRegions(FCS) if len(gUR) > 0: outerFace = gUR[0] if self.obj.InternalFeaturesCut: ifL = [] # avoid shape list if outerFace: Path.Log.debug("Attempting to create offset face of Face{}".format(fNum)) if self.profileEdges != "None": ofstVal = self._calculateOffsetValue(isHole) psOfst = PathUtils.getOffsetArea(outerFace, ofstVal, plane=self.wpc) if psOfst: if mPS is False: mPS = [] mPS.append(psOfst) if self.profileEdges == "Only": if mFS is False: mFS = [] mFS.append(True) cont = False else: cont = False if cont: ofstVal = self._calculateOffsetValue(isHole) faceOfstShp = PathUtils.getOffsetArea( outerFace, ofstVal, plane=self.wpc ) lenIfl = len(ifL) if self.obj.InternalFeaturesCut is False and lenIfl > 0: if lenIfl == 1: casL = ifL[0] else: casL = Part.makeCompound(ifL) ofstVal = self._calculateOffsetValue(isHole=True) intOfstShp = PathUtils.getOffsetArea(casL, ofstVal, plane=self.wpc) mIFS.append(intOfstShp) # faceOfstShp = faceOfstShp.cut(intOfstShp) if mFS is False: mFS = [] mFS.append(faceOfstShp) # Eif # Efor # Eif # Eif if len(mIFS) > 0: if mVS is False: mVS = [] for ifs in mIFS: mVS.append(ifs) if VDS: Path.Log.debug("Processing avoid faces.") cont = True isHole = False outFCS, intFEAT = self.findUnifiedRegions(VDS) if self.obj.InternalFeaturesCut: intFEAT = [] lenOtFcs = len(outFCS) if lenOtFcs == 0: cont = False else: if lenOtFcs == 1: avoid = outFCS[0] else: avoid = Part.makeCompound(outFCS) if self.showDebugObjects: P = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmpVoidEnvelope") P.Shape = avoid P.purgeTouched() self.tempGroup.addObject(P) if cont: if self.showDebugObjects: P = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmpVoidCompound") P.Shape = avoid P.purgeTouched() self.tempGroup.addObject(P) ofstVal = self._calculateOffsetValue(isHole, isVoid=True) avdOfstShp = PathUtils.getOffsetArea(avoid, ofstVal, plane=self.wpc) if avdOfstShp is False: msg = "Failed to create collective offset avoid face.\n" FreeCAD.Console.PrintError(msg) cont = False if cont: avdShp = avdOfstShp if not self.obj.AvoidLastX_InternalFeatures and len(intFEAT) > 0: if len(intFEAT) > 1: ifc = Part.makeCompound(intFEAT) else: ifc = intFEAT[0] ofstVal = self._calculateOffsetValue(isHole=True) ifOfstShp = PathUtils.getOffsetArea(ifc, ofstVal, plane=self.wpc) if ifOfstShp is False: msg = "Failed to create collective offset avoid internal features.\n" FreeCAD.Console.PrintError(msg) else: avdShp = avdOfstShp.cut(ifOfstShp) if mVS is False: mVS = [] mVS.append(avdShp) return (mFS, mVS, mPS) def _preProcessEntireBase(self, base, m): cont = True isHole = False prflShp = False # Create envelope, extract cross-section and make offset co-planar shape # baseEnv = PathUtils.getEnvelope(base.Shape, subshape=None, depthparams=self.depthParams) try: baseEnv = PathUtils.getEnvelope( partshape=base.Shape, subshape=None, depthparams=self.depthParams ) # Produces .Shape except Exception as ee: Path.Log.error(str(ee)) shell = base.Shape.Shells[0] solid = Part.makeSolid(shell) try: baseEnv = PathUtils.getEnvelope( partshape=solid, subshape=None, depthparams=self.depthParams ) # Produces .Shape except Exception as eee: Path.Log.error(str(eee)) cont = False if cont: csFaceShape = getShapeSlice(baseEnv) if csFaceShape is False: csFaceShape = getCrossSection(baseEnv) if csFaceShape is False: csFaceShape = getSliceFromEnvelope(baseEnv) if csFaceShape is False: Path.Log.debug("Failed to slice baseEnv shape.") cont = False if cont and self.profileEdges != "None": Path.Log.debug(" -Attempting profile geometry for model base.") ofstVal = self._calculateOffsetValue(isHole) psOfst = PathUtils.getOffsetArea(csFaceShape, ofstVal, plane=self.wpc) if psOfst: if self.profileEdges == "Only": return (True, psOfst) prflShp = psOfst else: cont = False if cont: ofstVal = self._calculateOffsetValue(isHole) faceOffsetShape = PathUtils.getOffsetArea(csFaceShape, ofstVal, plane=self.wpc) if faceOffsetShape is False: Path.Log.debug("getOffsetArea() failed for entire base.") else: faceOffsetShape.translate( FreeCAD.Vector(0.0, 0.0, 0.0 - faceOffsetShape.BoundBox.ZMin) ) return (faceOffsetShape, prflShp) return False def _calculateOffsetValue(self, isHole, isVoid=False): """_calculateOffsetValue(self.obj, isHole, isVoid) ... internal function. Calculate the offset for the Path.Area() function.""" self.JOB = PathUtils.findParentJob(self.obj) # We need to offset by at least our linear tessellation deflection # (default GeometryTolerance / 4) to avoid false retracts at the # boundaries. tolrnc = max(self.JOB.GeometryTolerance.Value / 10.0, self.obj.LinearDeflection.Value) if isVoid is False: if isHole is True: offset = -1 * self.obj.InternalFeaturesAdjustment.Value offset += self.radius + tolrnc else: offset = -1 * self.obj.BoundaryAdjustment.Value if self.obj.BoundaryEnforcement is True: offset += self.radius + tolrnc else: offset -= self.radius + tolrnc offset = 0.0 - offset else: offset = -1 * self.obj.BoundaryAdjustment.Value offset += self.radius + tolrnc return offset def findUnifiedRegions(self, shapeAndIndexTuples, useAreaImplementation=True): """Wrapper around area and wire based region unification implementations.""" Path.Log.debug("findUnifiedRegions()") # Allow merging of faces within the LinearDeflection tolerance. tolerance = self.obj.LinearDeflection.Value # Default: normal to Z=1 (XY plane), at Z=0 try: # Use Area based implementation shapes = Part.makeCompound([t[0] for t in shapeAndIndexTuples]) outlineShape = PathUtils.getOffsetArea( shapes, # Make the outline very slightly smaller, to avoid creating # small edges in the cut with the hole-preserving projection. 0.0 - tolerance / 10, removeHoles=True, # Outline has holes filled in tolerance=tolerance, plane=self.wpc, ) projectionShape = PathUtils.getOffsetArea( shapes, # Make the projection very slightly larger tolerance / 10, removeHoles=False, # Projection has holes preserved tolerance=tolerance, plane=self.wpc, ) internalShape = outlineShape.cut(projectionShape) # Filter out tiny faces, usually artifacts around the perimeter of # the cut. minArea = (10 * tolerance) ** 2 internalFaces = [f for f in internalShape.Faces if f.Area > minArea] if internalFaces: internalFaces = Part.makeCompound(internalFaces) return ([outlineShape], [internalFaces]) except Exception as e: Path.Log.warning("getOffsetArea failed: {}; Using FindUnifiedRegions.".format(e)) # Use face-unifying class FUR = FindUnifiedRegions(shapeAndIndexTuples, tolerance) if self.showDebugObjects: FUR.setTempGroup(self.tempGroup) return (FUR.getUnifiedRegions(), FUR.getInternalFeatures) # Eclass # Functions for getting a shape envelope and cross-section def getExtrudedShape(wire): Path.Log.debug("getExtrudedShape()") wBB = wire.BoundBox extFwd = math.floor(2.0 * wBB.ZLength) + 10.0 try: shell = wire.extrude(FreeCAD.Vector(0.0, 0.0, extFwd)) except Exception as ee: Path.Log.error(" -extrude wire failed: \n{}".format(ee)) return False SHP = Part.makeSolid(shell) return SHP def getShapeSlice(shape): Path.Log.debug("getShapeSlice()") bb = shape.BoundBox mid = (bb.ZMin + bb.ZMax) / 2.0 xmin = bb.XMin - 1.0 xmax = bb.XMax + 1.0 ymin = bb.YMin - 1.0 ymax = bb.YMax + 1.0 p1 = FreeCAD.Vector(xmin, ymin, mid) p2 = FreeCAD.Vector(xmax, ymin, mid) p3 = FreeCAD.Vector(xmax, ymax, mid) p4 = FreeCAD.Vector(xmin, ymax, mid) e1 = Part.makeLine(p1, p2) e2 = Part.makeLine(p2, p3) e3 = Part.makeLine(p3, p4) e4 = Part.makeLine(p4, p1) face = Part.Face(Part.Wire([e1, e2, e3, e4])) fArea = face.BoundBox.XLength * face.BoundBox.YLength # face.Wires[0].Area sArea = shape.BoundBox.XLength * shape.BoundBox.YLength midArea = (fArea + sArea) / 2.0 slcShp = shape.common(face) slcArea = slcShp.BoundBox.XLength * slcShp.BoundBox.YLength if slcArea < midArea: for W in slcShp.Wires: if W.isClosed() is False: Path.Log.debug(" -wire.isClosed() is False") return False if len(slcShp.Wires) == 1: wire = slcShp.Wires[0] slc = Part.Face(wire) slc.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - slc.BoundBox.ZMin)) return slc else: fL = [] for W in slcShp.Wires: slc = Part.Face(W) slc.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - slc.BoundBox.ZMin)) fL.append(slc) comp = Part.makeCompound(fL) return comp return False def getProjectedFace(tempGroup, wire): import Draft Path.Log.debug("getProjectedFace()") F = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmpProjectionWire") F.Shape = wire F.purgeTouched() tempGroup.addObject(F) try: prj = Draft.makeShape2DView(F, FreeCAD.Vector(0, 0, 1)) prj.recompute() prj.purgeTouched() tempGroup.addObject(prj) except Exception as ee: Path.Log.error(str(ee)) return False else: pWire = Part.Wire(prj.Shape.Edges) if pWire.isClosed() is False: return False slc = Part.Face(pWire) slc.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - slc.BoundBox.ZMin)) return slc def getCrossSection(shape): Path.Log.debug("getCrossSection()") wires = [] bb = shape.BoundBox mid = (bb.ZMin + bb.ZMax) / 2.0 for i in shape.slice(FreeCAD.Vector(0, 0, 1), mid): wires.append(i) if len(wires) > 0: comp = Part.Compound(wires) # produces correct cross-section wire ! comp.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - comp.BoundBox.ZMin)) csWire = comp.Wires[0] if csWire.isClosed() is False: Path.Log.debug(" -comp.Wires[0] is not closed") return False CS = Part.Face(csWire) CS.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - CS.BoundBox.ZMin)) return CS else: Path.Log.debug(" -No wires from .slice() method") return False def getShapeEnvelope(shape): Path.Log.debug("getShapeEnvelope()") wBB = shape.BoundBox extFwd = wBB.ZLength + 10.0 minz = wBB.ZMin maxz = wBB.ZMin + extFwd stpDwn = (maxz - minz) / 4.0 dep_par = PathUtils.depth_params(maxz + 5.0, maxz + 3.0, maxz, stpDwn, 0.0, minz) try: env = PathUtils.getEnvelope(partshape=shape, depthparams=dep_par) # Produces .Shape except Exception as ee: FreeCAD.Console.PrintError("PathUtils.getEnvelope() failed.\n" + str(ee) + "\n") return False else: return env def getSliceFromEnvelope(env): Path.Log.debug("getSliceFromEnvelope()") eBB = env.BoundBox extFwd = eBB.ZLength + 10.0 maxz = eBB.ZMin + extFwd emax = math.floor(maxz - 1.0) E = [] for e in range(0, len(env.Edges)): emin = env.Edges[e].BoundBox.ZMin if emin > emax: E.append(env.Edges[e]) tf = Part.Face(Part.Wire(Part.__sortEdges__(E))) tf.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - tf.BoundBox.ZMin)) return tf def _prepareModelSTLs(self, JOB, obj, m, ocl): """Tessellate model shapes or copy existing meshes into ocl.STLSurf objects""" if self.modelSTLs[m] is True: model = JOB.Model.Group[m] self.modelSTLs[m] = _makeSTL(model, obj, ocl, self.modelTypes[m]) def _makeSafeSTL(self, JOB, obj, mdlIdx, faceShapes, voidShapes, ocl): """_makeSafeSTL(JOB, obj, mdlIdx, faceShapes, voidShapes)... Creates and OCL.stl object with combined data with waste stock, model, and avoided faces. Travel lines can be checked against this STL object to determine minimum travel height to clear stock and model.""" Path.Log.debug("_makeSafeSTL()") fuseShapes = [] Mdl = JOB.Model.Group[mdlIdx] mBB = Mdl.Shape.BoundBox sBB = JOB.Stock.Shape.BoundBox # add Model shape to safeSTL shape fuseShapes.append(Mdl.Shape) if obj.BoundBox == "BaseBoundBox": cont = False extFwd = sBB.ZLength zmin = mBB.ZMin zmax = mBB.ZMin + extFwd stpDwn = (zmax - zmin) / 4.0 dep_par = PathUtils.depth_params(zmax + 5.0, zmax + 3.0, zmax, stpDwn, 0.0, zmin) try: envBB = PathUtils.getEnvelope( partshape=Mdl.Shape, depthparams=dep_par ) # Produces .Shape cont = True except Exception as ee: Path.Log.error(str(ee)) shell = Mdl.Shape.Shells[0] solid = Part.makeSolid(shell) try: envBB = PathUtils.getEnvelope( partshape=solid, depthparams=dep_par ) # Produces .Shape cont = True except Exception as eee: Path.Log.error(str(eee)) if cont: stckWst = JOB.Stock.Shape.cut(envBB) if obj.BoundaryAdjustment > 0.0: cmpndFS = Part.makeCompound(faceShapes) baBB = PathUtils.getEnvelope( partshape=cmpndFS, depthparams=self.depthParams ) # Produces .Shape adjStckWst = stckWst.cut(baBB) else: adjStckWst = stckWst fuseShapes.append(adjStckWst) else: msg = "Path transitions might not avoid the model. Verify paths.\n" FreeCAD.Console.PrintWarning(msg) else: # If boundbox is Job.Stock, add hidden pad under stock as base plate toolDiam = self.cutter.getDiameter() zMin = JOB.Stock.Shape.BoundBox.ZMin xMin = JOB.Stock.Shape.BoundBox.XMin - toolDiam yMin = JOB.Stock.Shape.BoundBox.YMin - toolDiam bL = JOB.Stock.Shape.BoundBox.XLength + (2 * toolDiam) bW = JOB.Stock.Shape.BoundBox.YLength + (2 * toolDiam) bH = 1.0 crnr = FreeCAD.Vector(xMin, yMin, zMin - 1.0) B = Part.makeBox(bL, bW, bH, crnr, FreeCAD.Vector(0, 0, 1)) fuseShapes.append(B) if voidShapes: voidComp = Part.makeCompound(voidShapes) voidEnv = PathUtils.getEnvelope( partshape=voidComp, depthparams=self.depthParams ) # Produces .Shape fuseShapes.append(voidEnv) fused = Part.makeCompound(fuseShapes) if self.showDebugObjects: T = FreeCAD.ActiveDocument.addObject("Part::Feature", "safeSTLShape") T.Shape = fused T.purgeTouched() self.tempGroup.addObject(T) self.safeSTLs[mdlIdx] = _makeSTL(fused, obj, ocl) def _makeSTL(model, obj, ocl, model_type=None): """Convert a mesh or shape into an OCL STL, using the tessellation tolerance specified in obj.LinearDeflection. Returns an ocl.STLSurf().""" if model_type == "M": facets = model.Mesh.Facets.Points else: if hasattr(model, "Shape"): shape = model.Shape else: shape = model vertices, facet_indices = shape.tessellate(obj.LinearDeflection.Value) facets = ((vertices[f[0]], vertices[f[1]], vertices[f[2]]) for f in facet_indices) stl = ocl.STLSurf() for tri in facets: v1, v2, v3 = tri t = ocl.Triangle( ocl.Point(v1[0], v1[1], v1[2]), ocl.Point(v2[0], v2[1], v2[2]), ocl.Point(v3[0], v3[1], v3[2]), ) stl.addTriangle(t) return stl # Functions to convert path geometry into line/arc segments for OCL input or directly to g-code def pathGeomToLinesPointSet(self, obj, compGeoShp): """pathGeomToLinesPointSet(self, obj, compGeoShp)... Convert a compound set of sequential line segments to directionally-oriented collinear groupings. """ Path.Log.debug("pathGeomToLinesPointSet()") # Extract intersection line segments for return value as [] LINES = [] inLine = [] chkGap = False lnCnt = 0 ec = len(compGeoShp.Edges) cpa = obj.CutPatternAngle edg0 = compGeoShp.Edges[0] p1 = (edg0.Vertexes[0].X, edg0.Vertexes[0].Y) p2 = (edg0.Vertexes[1].X, edg0.Vertexes[1].Y) if self.CutClimb is True: tup = (p2, p1) lst = FreeCAD.Vector(p1[0], p1[1], 0.0) else: tup = (p1, p2) lst = FreeCAD.Vector(p2[0], p2[1], 0.0) inLine.append(tup) sp = FreeCAD.Vector(p1[0], p1[1], 0.0) # start point for ei in range(1, ec): chkGap = False edg = compGeoShp.Edges[ei] # Get edge for vertexes v1 = (edg.Vertexes[0].X, edg.Vertexes[0].Y) # vertex 0 v2 = (edg.Vertexes[1].X, edg.Vertexes[1].Y) # vertex 1 ep = FreeCAD.Vector(v2[0], v2[1], 0.0) # end point cp = FreeCAD.Vector(v1[0], v1[1], 0.0) # check point (first / middle point) # iC = sp.isOnLineSegment(ep, cp) iC = cp.isOnLineSegment(sp, ep) if iC is True: inLine.append("BRK") chkGap = True else: if self.CutClimb is True: inLine.reverse() LINES.append(inLine) # Save inLine segments lnCnt += 1 inLine = [] # reset collinear container if self.CutClimb is True: sp = cp # FreeCAD.Vector(v1[0], v1[1], 0.0) else: sp = ep if self.CutClimb is True: tup = (v2, v1) if chkGap: gap = abs(self.toolDiam - lst.sub(ep).Length) lst = cp else: tup = (v1, v2) if chkGap: gap = abs(self.toolDiam - lst.sub(cp).Length) lst = ep if chkGap: if gap < obj.GapThreshold.Value: inLine.pop() # pop off 'BRK' marker ( vA, vB, ) = inLine.pop() # pop off previous line segment for combining with current tup = (vA, tup[1]) self.closedGap = True else: gap = round(gap, 6) if gap < self.gaps[0]: self.gaps.insert(0, gap) self.gaps.pop() inLine.append(tup) # Efor lnCnt += 1 if self.CutClimb is True: inLine.reverse() LINES.append(inLine) # Save inLine segments # Handle last inLine set, reversing it. if obj.CutPatternReversed is True: if cpa != 0.0 and cpa % 90.0 == 0.0: F = LINES.pop(0) rev = [] for iL in F: if iL == "BRK": rev.append(iL) else: (p1, p2) = iL rev.append((p2, p1)) rev.reverse() LINES.insert(0, rev) isEven = lnCnt % 2 if isEven == 0: Path.Log.debug("Line count is ODD: {}.".format(lnCnt)) else: Path.Log.debug("Line count is even: {}.".format(lnCnt)) return LINES def pathGeomToZigzagPointSet(self, obj, compGeoShp): """_pathGeomToZigzagPointSet(self, obj, compGeoShp)... Convert a compound set of sequential line segments to directionally-oriented collinear groupings with a ZigZag directional indicator included for each collinear group.""" Path.Log.debug("_pathGeomToZigzagPointSet()") # Extract intersection line segments for return value as [] LINES = [] inLine = [] lnCnt = 0 chkGap = False ec = len(compGeoShp.Edges) dirFlg = 1 if self.CutClimb: dirFlg = -1 edg0 = compGeoShp.Edges[0] p1 = (edg0.Vertexes[0].X, edg0.Vertexes[0].Y) p2 = (edg0.Vertexes[1].X, edg0.Vertexes[1].Y) if dirFlg == 1: tup = (p1, p2) lst = FreeCAD.Vector(p2[0], p2[1], 0.0) sp = FreeCAD.Vector(p1[0], p1[1], 0.0) # start point else: tup = (p2, p1) lst = FreeCAD.Vector(p1[0], p1[1], 0.0) sp = FreeCAD.Vector(p2[0], p2[1], 0.0) # start point inLine.append(tup) for ei in range(1, ec): edg = compGeoShp.Edges[ei] v1 = (edg.Vertexes[0].X, edg.Vertexes[0].Y) v2 = (edg.Vertexes[1].X, edg.Vertexes[1].Y) cp = FreeCAD.Vector(v1[0], v1[1], 0.0) # check point (start point of segment) ep = FreeCAD.Vector(v2[0], v2[1], 0.0) # end point iC = cp.isOnLineSegment(sp, ep) if iC: inLine.append("BRK") chkGap = True gap = abs(self.toolDiam - lst.sub(cp).Length) else: chkGap = False if dirFlg == -1: inLine.reverse() LINES.append(inLine) lnCnt += 1 dirFlg = -1 * dirFlg # Change zig to zag inLine = [] # reset collinear container sp = cp # FreeCAD.Vector(v1[0], v1[1], 0.0) lst = ep if dirFlg == 1: tup = (v1, v2) else: tup = (v2, v1) if chkGap: if gap < obj.GapThreshold.Value: inLine.pop() # pop off 'BRK' marker ( vA, vB, ) = inLine.pop() # pop off previous line segment for combining with current if dirFlg == 1: tup = (vA, tup[1]) else: tup = (tup[0], vB) self.closedGap = True else: gap = round(gap, 6) if gap < self.gaps[0]: self.gaps.insert(0, gap) self.gaps.pop() inLine.append(tup) # Efor lnCnt += 1 # Fix directional issue with LAST line when line count is even isEven = lnCnt % 2 if isEven == 0: # Changed to != with 90 degree CutPatternAngle Path.Log.debug("Line count is even: {}.".format(lnCnt)) else: Path.Log.debug("Line count is ODD: {}.".format(lnCnt)) dirFlg = -1 * dirFlg if not obj.CutPatternReversed: if self.CutClimb: dirFlg = -1 * dirFlg if obj.CutPatternReversed: dirFlg = -1 * dirFlg # Handle last inLine list if dirFlg == 1: rev = [] for iL in inLine: if iL == "BRK": rev.append(iL) else: (p1, p2) = iL rev.append((p2, p1)) if not obj.CutPatternReversed: rev.reverse() else: rev2 = [] for iL in rev: if iL == "BRK": rev2.append(iL) else: (p1, p2) = iL rev2.append((p2, p1)) rev2.reverse() rev = rev2 LINES.append(rev) else: LINES.append(inLine) return LINES def pathGeomToCircularPointSet(self, obj, compGeoShp): """pathGeomToCircularPointSet(self, obj, compGeoShp)... Convert a compound set of arcs/circles to a set of directionally-oriented arc end points and the corresponding center point.""" # Extract intersection line segments for return value as [] Path.Log.debug("pathGeomToCircularPointSet()") ARCS = [] stpOvrEI = [] segEI = [] isSame = False sameRad = None ec = len(compGeoShp.Edges) def gapDist(sp, ep): X = (ep[0] - sp[0]) ** 2 Y = (ep[1] - sp[1]) ** 2 return math.sqrt(X + Y) # the 'z' value is zero in both points def dist_to_cent(item): # Sort incoming arcs by distance to center # item: edge type, direction flag, parts tuple # parts: start tuple, end tuple, center tuple s = item[2][0][0] p1 = FreeCAD.Vector(s[0], s[1], 0.0) e = item[2][0][2] p2 = FreeCAD.Vector(e[0], e[1], 0.0) return p1.sub(p2).Length if obj.CutPatternReversed: if self.CutClimb: self.CutClimb = False else: self.CutClimb = True # Separate arc data into Loops and Arcs for ei in range(0, ec): edg = compGeoShp.Edges[ei] if edg.Closed is True: stpOvrEI.append(("L", ei, False)) else: if isSame is False: segEI.append(ei) isSame = True pnt = FreeCAD.Vector(edg.Vertexes[0].X, edg.Vertexes[0].Y, 0.0) sameRad = pnt.sub(self.tmpCOM).Length else: # Check if arc is co-radial to current SEGS pnt = FreeCAD.Vector(edg.Vertexes[0].X, edg.Vertexes[0].Y, 0.0) if abs(sameRad - pnt.sub(self.tmpCOM).Length) > 0.00001: isSame = False if isSame is True: segEI.append(ei) else: # Move co-radial arc segments stpOvrEI.append(["A", segEI, False]) # Start new list of arc segments segEI = [ei] isSame = True pnt = FreeCAD.Vector(edg.Vertexes[0].X, edg.Vertexes[0].Y, 0.0) sameRad = pnt.sub(self.tmpCOM).Length # Process trailing `segEI` data, if available if isSame is True: stpOvrEI.append(["A", segEI, False]) # Identify adjacent arcs with y=0 start/end points that connect for so in range(0, len(stpOvrEI)): SO = stpOvrEI[so] if SO[0] == "A": startOnAxis = [] endOnAxis = [] EI = SO[1] # list of corresponding compGeoShp.Edges indexes # Identify startOnAxis and endOnAxis arcs for i in range(0, len(EI)): ei = EI[i] # edge index E = compGeoShp.Edges[ei] # edge object if abs(self.tmpCOM.y - E.Vertexes[0].Y) < 0.00001: startOnAxis.append((i, ei, E.Vertexes[0])) elif abs(self.tmpCOM.y - E.Vertexes[1].Y) < 0.00001: endOnAxis.append((i, ei, E.Vertexes[1])) # Look for connections between startOnAxis and endOnAxis arcs. Consolidate data when connected lenSOA = len(startOnAxis) lenEOA = len(endOnAxis) if lenSOA > 0 and lenEOA > 0: for soa in range(0, lenSOA): (iS, eiS, vS) = startOnAxis[soa] for eoa in range(0, len(endOnAxis)): (iE, eiE, vE) = endOnAxis[eoa] dist = vE.X - vS.X if abs(dist) < 0.00001: # They connect on axis at same radius SO[2] = (eiE, eiS) break elif dist > 0: break # stop searching # Eif # Eif # Efor # Construct arc data tuples for OCL dirFlg = 1 if not self.CutClimb: # True yields Climb when set to Conventional dirFlg = -1 # Declare center point of circle pattern cp = (self.tmpCOM.x, self.tmpCOM.y, 0.0) # Cycle through stepOver data for so in range(0, len(stpOvrEI)): SO = stpOvrEI[so] if SO[0] == "L": # L = Loop/Ring/Circle # Path.Log.debug("SO[0] == 'Loop'") lei = SO[1] # loop Edges index v1 = compGeoShp.Edges[lei].Vertexes[0] # space = obj.SampleInterval.Value / 10.0 # space = 0.000001 space = self.toolDiam * 0.005 # If too small, OCL will fail to scan the loop # p1 = FreeCAD.Vector(v1.X, v1.Y, v1.Z) p1 = FreeCAD.Vector(v1.X, v1.Y, 0.0) # z=0.0 for waterline; z=v1.Z for 3D Surface rad = p1.sub(self.tmpCOM).Length spcRadRatio = space / rad if spcRadRatio < 1.0: tolrncAng = math.asin(spcRadRatio) else: tolrncAng = 0.99999998 * math.pi EX = self.tmpCOM.x + (rad * math.cos(tolrncAng)) EY = v1.Y - space # rad * math.sin(tolrncAng) sp = (v1.X, v1.Y, 0.0) ep = (EX, EY, 0.0) if dirFlg == 1: arc = (sp, ep, cp) else: arc = ( ep, sp, cp, ) # OCL.Arc(firstPnt, lastPnt, centerPnt, dir=True(CCW direction)) ARCS.append(("L", dirFlg, [arc])) elif SO[0] == "A": # A = Arc # Path.Log.debug("SO[0] == 'Arc'") PRTS = [] EI = SO[1] # list of corresponding Edges indexes CONN = SO[2] # list of corresponding connected edges tuples (iE, iS) chkGap = False lst = None if CONN: # Connected edges(arcs) (iE, iS) = CONN v1 = compGeoShp.Edges[iE].Vertexes[0] v2 = compGeoShp.Edges[iS].Vertexes[1] sp = (v1.X, v1.Y, 0.0) ep = (v2.X, v2.Y, 0.0) if dirFlg == 1: arc = (sp, ep, cp) lst = ep else: arc = ( ep, sp, cp, ) # OCL.Arc(firstPnt, lastPnt, centerPnt, dir=True(CCW direction)) lst = sp PRTS.append(arc) # Pop connected edge index values from arc segments index list iEi = EI.index(iE) iSi = EI.index(iS) if iEi > iSi: EI.pop(iEi) EI.pop(iSi) else: EI.pop(iSi) EI.pop(iEi) if len(EI) > 0: PRTS.append("BRK") chkGap = True cnt = 0 for ei in EI: if cnt > 0: PRTS.append("BRK") chkGap = True v1 = compGeoShp.Edges[ei].Vertexes[0] v2 = compGeoShp.Edges[ei].Vertexes[1] sp = (v1.X, v1.Y, 0.0) ep = (v2.X, v2.Y, 0.0) if dirFlg == 1: arc = (sp, ep, cp) if chkGap: gap = abs( self.toolDiam - gapDist(lst, sp) ) # abs(self.toolDiam - lst.sub(sp).Length) lst = ep else: arc = ( ep, sp, cp, ) # OCL.Arc(firstPnt, lastPnt, centerPnt, dir=True(CCW direction)) if chkGap: gap = abs( self.toolDiam - gapDist(lst, ep) ) # abs(self.toolDiam - lst.sub(ep).Length) lst = sp if chkGap: if gap < obj.GapThreshold.Value: PRTS.pop() # pop off 'BRK' marker ( vA, vB, vC, ) = PRTS.pop() # pop off previous arc segment for combining with current arc = (vA, arc[1], vC) self.closedGap = True else: gap = round(gap, 6) if gap < self.gaps[0]: self.gaps.insert(0, gap) self.gaps.pop() PRTS.append(arc) cnt += 1 if dirFlg == -1: PRTS.reverse() ARCS.append(("A", dirFlg, PRTS)) # Eif if obj.CutPattern == "CircularZigZag": dirFlg = -1 * dirFlg # Efor ARCS.sort(key=dist_to_cent, reverse=obj.CutPatternReversed) return ARCS def pathGeomToSpiralPointSet(obj, compGeoShp): """_pathGeomToSpiralPointSet(obj, compGeoShp)... Convert a compound set of sequential line segments to directional, connected groupings.""" Path.Log.debug("_pathGeomToSpiralPointSet()") # Extract intersection line segments for return value as [] LINES = [] inLine = [] lnCnt = 0 ec = len(compGeoShp.Edges) start = 2 if obj.CutPatternReversed: edg1 = compGeoShp.Edges[ 0 ] # Skip first edge, as it is the closing edge: center to outer tail ec -= 1 start = 1 else: edg1 = compGeoShp.Edges[ 1 ] # Skip first edge, as it is the closing edge: center to outer tail p1 = FreeCAD.Vector(edg1.Vertexes[0].X, edg1.Vertexes[0].Y, 0.0) p2 = FreeCAD.Vector(edg1.Vertexes[1].X, edg1.Vertexes[1].Y, 0.0) tup = ((p1.x, p1.y), (p2.x, p2.y)) inLine.append(tup) for ei in range(start, ec): # Skipped first edge, started with second edge above as edg1 edg = compGeoShp.Edges[ei] # Get edge for vertexes sp = FreeCAD.Vector( edg.Vertexes[0].X, edg.Vertexes[0].Y, 0.0 ) # check point (first / middle point) ep = FreeCAD.Vector(edg.Vertexes[1].X, edg.Vertexes[1].Y, 0.0) # end point tup = ((sp.x, sp.y), (ep.x, ep.y)) if sp.sub(p2).Length < 0.000001: inLine.append(tup) else: LINES.append(inLine) # Save inLine segments lnCnt += 1 inLine = [] # reset container inLine.append(tup) # p1 = sp p2 = ep # Efor lnCnt += 1 LINES.append(inLine) # Save inLine segments return LINES def pathGeomToOffsetPointSet(obj, compGeoShp): """pathGeomToOffsetPointSet(obj, compGeoShp)... Convert a compound set of 3D profile segmented wires to 2D segments, applying linear optimization. """ Path.Log.debug("pathGeomToOffsetPointSet()") LINES = [] optimize = obj.OptimizeLinearPaths ofstCnt = len(compGeoShp) # Cycle through offset loops iPOL = False for ei in range(0, ofstCnt): OS = compGeoShp[ei] lenOS = len(OS) if ei > 0: LINES.append("BRK") fp = FreeCAD.Vector(OS[0].x, OS[0].y, OS[0].z) OS.append(fp) # Cycle through points in each loop prev = OS[0] pnt = OS[1] for v in range(1, lenOS): nxt = OS[v + 1] if optimize: # iPOL = prev.isOnLineSegment(nxt, pnt) iPOL = pnt.isOnLineSegment(prev, nxt) if iPOL: pnt = nxt else: tup = ((prev.x, prev.y), (pnt.x, pnt.y)) LINES.append(tup) prev = pnt pnt = nxt else: tup = ((prev.x, prev.y), (pnt.x, pnt.y)) LINES.append(tup) prev = pnt pnt = nxt if iPOL: tup = ((prev.x, prev.y), (pnt.x, pnt.y)) LINES.append(tup) # Efor return [LINES] class FindUnifiedRegions: """FindUnifiedRegions() This class requires a list of face shapes. It finds the unified horizontal unified regions, if they exist.""" def __init__(self, facesList, geomToler): self.FACES = facesList # format is tuple (faceShape, faceIndex_on_base) self.geomToler = geomToler self.tempGroup = None self.topFaces = [] self.edgeData = [] self.circleData = [] self.noSharedEdges = True self.topWires = [] self.REGIONS = [] self.INTERNALS = [] self.idGroups = [] self.sharedEdgeIdxs = [] self.fusedFaces = None self.internalsReady = False if self.geomToler == 0.0: self.geomToler = 0.00001 # Internal processing methods def _showShape(self, shape, name): if self.tempGroup: S = FreeCAD.ActiveDocument.addObject("Part::Feature", "tmp" + name) S.Shape = shape S.purgeTouched() self.tempGroup.addObject(S) def _extractTopFaces(self): for F, fcIdx in self.FACES: # format is tuple (faceShape, faceIndex_on_base) cont = True fNum = fcIdx + 1 # Extrude face fBB = F.BoundBox extFwd = math.floor(2.0 * fBB.ZLength) + 10.0 ef = F.extrude(FreeCAD.Vector(0.0, 0.0, extFwd)) ef = Part.makeSolid(ef) # Cut top off of extrusion with Part.box efBB = ef.BoundBox ZLen = efBB.ZLength / 2.0 cutBox = Part.makeBox(efBB.XLength + 2.0, efBB.YLength + 2.0, ZLen) zHght = efBB.ZMin + ZLen cutBox.translate(FreeCAD.Vector(efBB.XMin - 1.0, efBB.YMin - 1.0, zHght)) base = ef.cut(cutBox) if base.Volume == 0: Path.Log.debug( "Ignoring Face{}. It is likely vertical with no horizontal exposure.".format( fcIdx ) ) cont = False if cont: # Identify top face of base fIdx = 0 zMin = base.Faces[fIdx].BoundBox.ZMin for bfi in range(0, len(base.Faces)): fzmin = base.Faces[bfi].BoundBox.ZMin if fzmin > zMin: fIdx = bfi zMin = fzmin # Translate top face to Z=0.0 and save to topFaces list topFace = base.Faces[fIdx] # self._showShape(topFace, 'topFace_{}'.format(fNum)) tfBB = topFace.BoundBox tfBB_Area = tfBB.XLength * tfBB.YLength fBB_Area = fBB.XLength * fBB.YLength if tfBB_Area < (fBB_Area * 0.9): # attempt alternate methods topFace = self._getCompleteCrossSection(ef) tfBB = topFace.BoundBox tfBB_Area = tfBB.XLength * tfBB.YLength # self._showShape(topFace, 'topFaceAlt_1_{}'.format(fNum)) if tfBB_Area < (fBB_Area * 0.9): topFace = getShapeSlice(ef) tfBB = topFace.BoundBox tfBB_Area = tfBB.XLength * tfBB.YLength # self._showShape(topFace, 'topFaceAlt_2_{}'.format(fNum)) if tfBB_Area < (fBB_Area * 0.9): msg = "Failed to extract processing region for Face {}\n".format(fNum) FreeCAD.Console.PrintError(msg) cont = False # Eif if cont: topFace.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - zMin)) self.topFaces.append((topFace, fcIdx)) def _fuseTopFaces(self): (one, baseFcIdx) = self.topFaces.pop(0) base = one for face, fcIdx in self.topFaces: base = base.fuse(face) self.topFaces.insert(0, (one, baseFcIdx)) self.fusedFaces = base def _getEdgesData(self): topFaces = self.fusedFaces.Faces tfLen = len(topFaces) count = [0, 0] # Get length and center of mass for each edge in all top faces for fi in range(0, tfLen): F = topFaces[fi] edgCnt = len(F.Edges) for ei in range(0, edgCnt): E = F.Edges[ei] tup = (E.Length, E.CenterOfMass, E, fi) if len(E.Vertexes) == 1: self.circleData.append(tup) count[0] += 1 else: self.edgeData.append(tup) count[1] += 1 def _groupEdgesByLength(self): Path.Log.debug("_groupEdgesByLength()") threshold = self.geomToler grp = [] processLast = False def keyFirst(tup): return tup[0] # Sort edgeData data and prepare proxy indexes self.edgeData.sort(key=keyFirst) DATA = self.edgeData lenDATA = len(DATA) indexes = [i for i in range(0, lenDATA)] idxCnt = len(indexes) while idxCnt > 0: processLast = True # Pop off index for first edge actvIdx = indexes.pop(0) actvItem = DATA[actvIdx][0] # 0 index is length grp.append(actvIdx) idxCnt -= 1 while idxCnt > 0: tstIdx = indexes[0] tstItem = DATA[tstIdx][0] # test case(s) goes here absLenDiff = abs(tstItem - actvItem) if absLenDiff < threshold: # Remove test index from indexes indexes.pop(0) idxCnt -= 1 grp.append(tstIdx) else: if len(grp) > 1: # grp.sort() self.idGroups.append(grp) grp = [] break # Ewhile # Ewhile if processLast: if len(grp) > 1: # grp.sort() self.idGroups.append(grp) def _identifySharedEdgesByLength(self, grp): Path.Log.debug("_identifySharedEdgesByLength()") holds = [] specialIndexes = [] threshold = self.geomToler def keyFirst(tup): return tup[0] # Sort edgeData data self.edgeData.sort(key=keyFirst) DATA = self.edgeData lenGrp = len(grp) while lenGrp > 0: # Pop off index for first edge actvIdx = grp.pop(0) actvItem = DATA[actvIdx][0] # 0 index is length lenGrp -= 1 while lenGrp > 0: isTrue = False # Pop off index for test edge tstIdx = grp.pop(0) tstItem = DATA[tstIdx][0] lenGrp -= 1 # test case(s) goes here lenDiff = tstItem - actvItem absLenDiff = abs(lenDiff) if lenDiff > threshold: break if absLenDiff < threshold: com1 = DATA[actvIdx][1] com2 = DATA[tstIdx][1] comDiff = com2.sub(com1).Length if comDiff < threshold: isTrue = True # Action if test is true (finds special case) if isTrue: specialIndexes.append(actvIdx) specialIndexes.append(tstIdx) break else: holds.append(tstIdx) # Put hold indexes back in search group holds.extend(grp) grp = holds lenGrp = len(grp) holds = [] if len(specialIndexes) > 0: # Remove shared edges from EDGES data uniqueShared = list(set(specialIndexes)) self.sharedEdgeIdxs.extend(uniqueShared) self.noSharedEdges = False def _extractWiresFromEdges(self): Path.Log.debug("_extractWiresFromEdges()") DATA = self.edgeData holds = [] firstEdge = None cont = True connectedEdges = [] connectedIndexes = [] connectedCnt = 0 LOOPS = [] def faceIndex(tup): return tup[3] def faceArea(face): return face.Area # Sort by face index on original model base DATA.sort(key=faceIndex) lenDATA = len(DATA) indexes = [i for i in range(0, lenDATA)] idxCnt = len(indexes) # Add circle edges into REGIONS list if len(self.circleData) > 0: for C in self.circleData: face = Part.Face(Part.Wire(C[2])) self.REGIONS.append(face) actvIdx = indexes.pop(0) actvEdge = DATA[actvIdx][2] firstEdge = actvEdge # DATA[connectedIndexes[0]][2] idxCnt -= 1 connectedIndexes.append(actvIdx) connectedEdges.append(actvEdge) connectedCnt = 1 safety = 750 while cont: # safety > 0 safety -= 1 notConnected = True while idxCnt > 0: isTrue = False # Pop off index for test edge tstIdx = indexes.pop(0) tstEdge = DATA[tstIdx][2] idxCnt -= 1 if self._edgesAreConnected(actvEdge, tstEdge): isTrue = True if isTrue: notConnected = False connectedIndexes.append(tstIdx) connectedEdges.append(tstEdge) connectedCnt += 1 actvIdx = tstIdx actvEdge = tstEdge break else: holds.append(tstIdx) # Ewhile if connectedCnt > 2: if self._edgesAreConnected(actvEdge, firstEdge): notConnected = False # Save loop components LOOPS.append(connectedEdges) # reset connected variables and re-assess connectedEdges = [] connectedIndexes = [] connectedCnt = 0 indexes.sort() idxCnt = len(indexes) if idxCnt > 0: # Pop off index for first edge actvIdx = indexes.pop(0) actvEdge = DATA[actvIdx][2] idxCnt -= 1 firstEdge = actvEdge connectedIndexes.append(actvIdx) connectedEdges.append(actvEdge) connectedCnt = 1 # Eif # Put holds indexes back in search stack if notConnected: holds.append(actvIdx) holds.extend(indexes) indexes = holds idxCnt = len(indexes) holds = [] if idxCnt == 0: cont = False if safety == 0: cont = False # Ewhile numLoops = len(LOOPS) Path.Log.debug(" -numLoops: {}.".format(numLoops)) if numLoops > 0: for li in range(0, numLoops): Edges = LOOPS[li] # for e in Edges: # self._showShape(e, 'Loop_{}_Edge'.format(li)) wire = Part.Wire(Part.__sortEdges__(Edges)) if wire.isClosed(): # This simple Part.Face() method fails to catch # wires with tangent closed wires, or an external # wire with one or more internal tangent wires. # face = Part.Face(wire) # This method works with the complex tangent # closed wires mentioned above. extWire = wire.extrude(FreeCAD.Vector(0.0, 0.0, 2.0)) wireSolid = Part.makeSolid(extWire) extdBBFace1 = makeExtendedBoundBox( wireSolid.BoundBox, 5.0, wireSolid.BoundBox.ZMin + 1.0 ) extdBBFace2 = makeExtendedBoundBox( wireSolid.BoundBox, 5.0, wireSolid.BoundBox.ZMin + 1.0 ) inverse = extdBBFace1.cut(wireSolid) face = extdBBFace2.cut(inverse) self.REGIONS.append(face) self.REGIONS.sort(key=faceArea, reverse=True) def _identifyInternalFeatures(self): Path.Log.debug("_identifyInternalFeatures()") remList = [] for top, fcIdx in self.topFaces: big = Part.Face(top.OuterWire) for s in range(0, len(self.REGIONS)): if s not in remList: small = self.REGIONS[s] if self._isInBoundBox(big, small): cmn = big.common(small) if cmn.Area > 0.0: self.INTERNALS.append(small) remList.append(s) break else: Path.Log.debug(" - No common area.\n") remList.sort(reverse=True) for ri in remList: self.REGIONS.pop(ri) def _processNestedRegions(self): Path.Log.debug("_processNestedRegions()") cont = True hold = [] Ids = [] remList = [] for i in range(0, len(self.REGIONS)): Ids.append(i) idsCnt = len(Ids) while cont: while idsCnt > 0: hi = Ids.pop(0) high = self.REGIONS[hi] idsCnt -= 1 while idsCnt > 0: isTrue = False li = Ids.pop(0) idsCnt -= 1 low = self.REGIONS[li] # Test case here if self._isInBoundBox(high, low): cmn = high.common(low) if cmn.Area > 0.0: isTrue = True # if True action here if isTrue: self.REGIONS[hi] = high.cut(low) remList.append(li) else: hold.append(hi) # Ewhile hold.extend(Ids) Ids = hold hold = [] idsCnt = len(Ids) if len(Ids) == 0: cont = False # Ewhile # Ewhile remList.sort(reverse=True) for ri in remList: self.REGIONS.pop(ri) # Accessory methods def _getCompleteCrossSection(self, shape): Path.Log.debug("_getCompleteCrossSection()") wires = [] bb = shape.BoundBox mid = (bb.ZMin + bb.ZMax) / 2.0 for i in shape.slice(FreeCAD.Vector(0, 0, 1), mid): wires.append(i) if len(wires) > 0: comp = Part.Compound(wires) # produces correct cross-section wire ! CS = Part.Face(comp.Wires[0]) CS.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - CS.BoundBox.ZMin)) return CS Path.Log.debug(" -No wires from .slice() method") return False def _edgesAreConnected(self, e1, e2): # Assumes edges are flat and are at Z=0.0 def isSameVertex(v1, v2): # Assumes vertexes at Z=0.0 if abs(v1.X - v2.X) < 0.000001: if abs(v1.Y - v2.Y) < 0.000001: return True return False if isSameVertex(e1.Vertexes[0], e2.Vertexes[0]): return True if isSameVertex(e1.Vertexes[0], e2.Vertexes[1]): return True if isSameVertex(e1.Vertexes[1], e2.Vertexes[0]): return True if isSameVertex(e1.Vertexes[1], e2.Vertexes[1]): return True return False def _isInBoundBox(self, outShp, inShp): obb = outShp.BoundBox ibb = inShp.BoundBox if obb.XMin < ibb.XMin: if obb.XMax > ibb.XMax: if obb.YMin < ibb.YMin: if obb.YMax > ibb.YMax: return True return False # Public methods def setTempGroup(self, grpObj): """setTempGroup(grpObj)... For debugging, pass temporary object group.""" self.tempGroup = grpObj def getUnifiedRegions(self): """getUnifiedRegions()... Returns a list of unified regions from list of tuples (faceShape, faceIndex) received at instantiation of the class object.""" Path.Log.debug("getUnifiedRegions()") if len(self.FACES) == 0: msg = "No FACE data tuples received at instantiation of class.\n" FreeCAD.Console.PrintError(msg) return [] self._extractTopFaces() lenFaces = len(self.topFaces) if lenFaces == 0: return [] # if single topFace, return it if lenFaces == 1: topFace = self.topFaces[0][0] self._showShape(topFace, "TopFace") # prepare inner wires as faces for internal features lenWrs = len(topFace.Wires) if lenWrs > 1: for w in range(1, lenWrs): wr = topFace.Wires[w] self.INTERNALS.append(Part.Face(wr)) self.internalsReady = True # Flatten face and extract outer wire, then convert to face extWire = getExtrudedShape(topFace) wCS = getCrossSection(extWire) if wCS: face = Part.Face(wCS) return [face] else: (faceShp, fcIdx) = self.FACES[0] msg = translate( "PathSurfaceSupport", "Failed to identify a horizontal cross-section for Face", ) msg += "{}.\n".format(fcIdx + 1) FreeCAD.Console.PrintWarning(msg) return [] # process multiple top faces, unifying if possible self._fuseTopFaces() for F in self.fusedFaces.Faces: self._showShape(F, "TopFaceFused") self._getEdgesData() self._groupEdgesByLength() for grp in self.idGroups: self._identifySharedEdgesByLength(grp) if self.noSharedEdges: Path.Log.debug("No shared edges by length detected.") allTopFaces = [] for topFace, fcIdx in self.topFaces: allTopFaces.append(topFace) # Identify internal features lenWrs = len(topFace.Wires) if lenWrs > 1: for w in range(1, lenWrs): wr = topFace.Wires[w] self.INTERNALS.append(Part.Face(wr)) self.internalsReady = True return allTopFaces else: # Delete shared edges from edgeData list self.sharedEdgeIdxs.sort(reverse=True) for se in self.sharedEdgeIdxs: self.edgeData.pop(se) self._extractWiresFromEdges() self._identifyInternalFeatures() self._processNestedRegions() # for ri in range(0, len(self.REGIONS)): # self._showShape(self.REGIONS[ri], 'UnifiedRegion_{}'.format(ri)) self.internalsReady = True return self.REGIONS def getInternalFeatures(self): """getInternalFeatures()... Returns internal features identified after calling getUnifiedRegions().""" if self.internalsReady: if len(self.INTERNALS) > 0: return self.INTERNALS else: return False msg = "getUnifiedRegions() must be called before getInternalFeatures().\n" FreeCAD.Console.PrintError(msg) return False class OCL_Tool: """The OCL_Tool class is designed to translate a FreeCAD standard ToolBit shape in the active Tool Controller, into an OCL tool type.""" def __init__(self, ocl, obj, safe=False): self.ocl = ocl self.obj = obj self.tool = None self.tiltCutter = False self.safe = safe self.oclTool = None self.toolType = None self.toolMode = None self.toolMethod = None self.diameter = -1.0 self.cornerRadius = -1.0 self.flatRadius = -1.0 self.cutEdgeHeight = -1.0 self.cutEdgeAngle = -1.0 # Default to zero. ToolBit likely is without. self.lengthOffset = 0.0 if hasattr(obj, "ToolController"): if hasattr(obj.ToolController, "Tool"): self.tool = obj.ToolController.Tool if hasattr(self.tool, "ShapeName"): self.toolType = self.tool.ShapeName # Indicates ToolBit tool self.toolMode = "ToolBit" if self.toolType: Path.Log.debug("OCL_Tool tool mode, type: {}, {}".format(self.toolMode, self.toolType)) """ #### FreeCAD Legacy tool shape properties per tool type shape = EndMill Diameter CuttingEdgeHeight LengthOffset shape = Drill Diameter CuttingEdgeAngle # TipAngle from above, center shaft. 180 = flat tip (endmill) CuttingEdgeHeight LengthOffset shape = CenterDrill Diameter FlatRadius CornerRadius CuttingEdgeAngle # TipAngle from above, center shaft. 180 = flat tip (endmill) CuttingEdgeHeight LengthOffset shape = CounterSink Diameter FlatRadius CornerRadius CuttingEdgeAngle # TipAngle from above, center shaft. 180 = flat tip (endmill) CuttingEdgeHeight LengthOffset shape = CounterBore Diameter FlatRadius CornerRadius CuttingEdgeAngle # TipAngle from above, center shaft. 180 = flat tip (endmill) CuttingEdgeHeight LengthOffset shape = FlyCutter Diameter FlatRadius CornerRadius CuttingEdgeAngle # TipAngle from above, center shaft. 180 = flat tip (endmill) CuttingEdgeHeight LengthOffset shape = Reamer Diameter FlatRadius CornerRadius CuttingEdgeAngle # TipAngle from above, center shaft. 180 = flat tip (endmill) CuttingEdgeHeight LengthOffset shape = Tap Diameter FlatRadius CornerRadius CuttingEdgeAngle # TipAngle from above, center shaft. 180 = flat tip (endmill) CuttingEdgeHeight LengthOffset shape = SlotCutter Diameter FlatRadius CornerRadius CuttingEdgeAngle # TipAngle from above, center shaft. 180 = flat tip (endmill) CuttingEdgeHeight LengthOffset shape = BallEndMill Diameter FlatRadius CornerRadius CuttingEdgeAngle # TipAngle from above, center shaft. 180 = flat tip (endmill) CuttingEdgeHeight LengthOffset shape = ChamferMill Diameter FlatRadius CornerRadius CuttingEdgeAngle # TipAngle from above, center shaft. 180 = flat tip (endmill) CuttingEdgeHeight LengthOffset shape = CornerRound Diameter FlatRadius CornerRadius CuttingEdgeAngle # TipAngle from above, center shaft. 180 = flat tip (endmill) CuttingEdgeHeight LengthOffset shape = Engraver Diameter CuttingEdgeAngle # TipAngle from above, center shaft. 180 = flat tip (endmill) CuttingEdgeHeight LengthOffset #### FreeCAD packaged ToolBit named constraints per shape files shape = endmill Diameter; Endmill diameter Length; Overall length of the endmill ShankDiameter; diameter of the shank CuttingEdgeHeight shape = ballend Diameter; Endmill diameter Length; Overall length of the endmill ShankDiameter; diameter of the shank CuttingEdgeHeight shape = bullnose Diameter; Endmill diameter Length; Overall length of the endmill ShankDiameter; diameter of the shank FlatRadius;Radius of the bottom flat part. CuttingEdgeHeight shape = drill TipAngle; Full angle of the drill tip Diameter; Drill bit diameter Length; Overall length of the drillbit shape = v-bit Diameter; Overall diameter of the V-bit CuttingEdgeAngle;Full angle of the v-bit Length; Overall bit length ShankDiameter FlatHeight;Height of the flat extension of the v-bit FlatRadius; Diameter of the flat end of the tip """ # Private methods def _setDimensions(self): """_setDimensions() ... Set values for possible dimensions.""" if hasattr(self.tool, "Diameter"): self.diameter = float(self.tool.Diameter) else: msg = translate("PathSurfaceSupport", "Diameter dimension missing from ToolBit shape.") FreeCAD.Console.PrintError(msg + "\n") return False if hasattr(self.tool, "LengthOffset"): self.lengthOffset = float(self.tool.LengthOffset) if hasattr(self.tool, "FlatRadius"): self.flatRadius = float(self.tool.FlatRadius) if hasattr(self.tool, "CuttingEdgeHeight"): self.cutEdgeHeight = float(self.tool.CuttingEdgeHeight) if hasattr(self.tool, "CuttingEdgeAngle"): self.cutEdgeAngle = float(self.tool.CuttingEdgeAngle) return True def _makeSafeCutter(self): # Make safeCutter with 25% buffer around physical cutter if self.safe: self.diameter = self.diameter * 1.25 if self.flatRadius == 0.0: self.flatRadius = self.diameter * 0.25 elif self.flatRadius > 0.0: self.flatRadius = self.flatRadius * 1.25 def _oclCylCutter(self): # Standard End Mill, Slot cutter, or Fly cutter # OCL -> CylCutter::CylCutter(diameter, length) if self.diameter == -1.0 or self.cutEdgeHeight == -1.0: return self.oclTool = self.ocl.CylCutter(self.diameter, self.cutEdgeHeight + self.lengthOffset) def _oclBallCutter(self): # Standard Ball End Mill # OCL -> BallCutter::BallCutter(diameter, length) if self.diameter == -1.0 or self.cutEdgeHeight == -1.0: return self.tiltCutter = True if self.cutEdgeHeight == 0: self.cutEdgeHeight = self.diameter / 2 self.oclTool = self.ocl.BallCutter(self.diameter, self.cutEdgeHeight + self.lengthOffset) def _oclBullCutter(self): # Standard Bull Nose cutter # Reference: https://www.fine-tools.com/halbstabfraeser.html # OCL -> BullCutter::BullCutter(diameter, minor radius, length) if self.diameter == -1.0 or self.flatRadius == -1.0 or self.cutEdgeHeight == -1.0: return self.oclTool = self.ocl.BullCutter( self.diameter, self.diameter / 2 - self.flatRadius, self.cutEdgeHeight + self.lengthOffset, ) def _oclConeCutter(self): # Engraver or V-bit cutter # OCL -> ConeCutter::ConeCutter(diameter, angle, length) if self.diameter == -1.0 or self.cutEdgeAngle == -1.0 or self.cutEdgeHeight == -1.0: return self.oclTool = self.ocl.ConeCutter(self.diameter, self.cutEdgeAngle / 2, self.lengthOffset) def _setToolMethod(self): toolMap = dict() if self.toolMode == "ToolBit": toolMap = { "endmill": "CylCutter", "ballend": "BallCutter", "bullnose": "BullCutter", "drill": "ConeCutter", "engraver": "ConeCutter", "v_bit": "ConeCutter", "chamfer": "None", } self.toolMethod = "None" if self.toolType in toolMap: self.toolMethod = toolMap[self.toolType] # Public methods def getOclTool(self): """getOclTool()... Call this method after class instantiation to return OCL tool object.""" # Check for tool controller and tool object if not self.tool or not self.toolMode: msg = translate("PathSurface", "Failed to identify tool for operation.") FreeCAD.Console.PrintError(msg + "\n") return False if not self._setDimensions(): return False self._setToolMethod() if self.toolMethod == "None": err = translate("PathSurface", "Failed to map selected tool to an OCL tool type.") FreeCAD.Console.PrintError(err + "\n") return False else: Path.Log.debug("OCL_Tool tool method: {}".format(self.toolMethod)) oclToolMethod = getattr(self, "_ocl" + self.toolMethod) oclToolMethod() if self.oclTool: return self.oclTool # Set error messages err = translate("PathSurface", "Failed to translate active tool to OCL tool type.") FreeCAD.Console.PrintError(err + "\n") return False def useTiltCutter(self): """useTiltCutter()... Call this method after getOclTool() method to return status of cutter tilt availability - generally this is for a ball end mill.""" if not self.tool or not self.oclTool: err = translate( "PathSurface", "OCL tool not available. Cannot determine is cutter has tilt available.", ) FreeCAD.Console.PrintError(err + "\n") return False return self.tiltCutter # Eclass # Support functions def makeExtendedBoundBox(wBB, bbBfr, zDep): Path.Log.debug("makeExtendedBoundBox()") p1 = FreeCAD.Vector(wBB.XMin - bbBfr, wBB.YMin - bbBfr, zDep) p2 = FreeCAD.Vector(wBB.XMax + bbBfr, wBB.YMin - bbBfr, zDep) p3 = FreeCAD.Vector(wBB.XMax + bbBfr, wBB.YMax + bbBfr, zDep) p4 = FreeCAD.Vector(wBB.XMin - bbBfr, wBB.YMax + bbBfr, zDep) L1 = Part.makeLine(p1, p2) L2 = Part.makeLine(p2, p3) L3 = Part.makeLine(p3, p4) L4 = Part.makeLine(p4, p1) return Part.Face(Part.Wire([L1, L2, L3, L4]))