# -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (c) 2019 sliptonic * # * * # * 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 import FreeCAD import Path import PathScripts.PathCircularHoleBase as PathCircularHoleBase import PathScripts.PathGeom as PathGeom import PathScripts.PathLog as PathLog import PathScripts.PathOp as PathOp import math from PySide.QtCore import QT_TRANSLATE_NOOP __title__ = "Path Thread Milling Operation" __author__ = "sliptonic (Brad Collette)" __url__ = "http://www.freecadweb.org" __doc__ = "Path thread milling operation." # math.sqrt(3)/2 ... 60deg triangle height SQRT_3_DIVIDED_BY_2 = 0.8660254037844386 if True: PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule()) PathLog.trackModule(PathLog.thisModule()) else: PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) translate = FreeCAD.Qt.translate def threadRadii(internal, majorDia, minorDia, toolDia, toolCrest): """threadRadii(majorDia, minorDia, toolDia, toolCrest) ... returns the minimum and maximum radius for thread.""" PathLog.track(majorDia, minorDia, toolDia, toolCrest) if toolCrest is None: toolCrest = 0.0 # As it turns out metric and imperial standard threads follow the same rules. # The determining factor is the height of the full 60 degree triangle H. # - The minor diameter is 1/4 * H smaller than the pitch diameter. # - The major diameter is 3/8 * H bigger than the pitch diameter # Since we already have the outer diameter it's simpler to just add 1/8 * H # to get the outer tip of the thread. H = ((majorDia - minorDia) / 2.0) * 1.6 # (D - d)/2 = 5/8 * H if internal: # mill inside out outerTip = majorDia / 2.0 + H / 8.0 # Compensate for the crest of the tool toolTip = outerTip - toolCrest * SQRT_3_DIVIDED_BY_2 return ((minorDia - toolDia) / 2.0, toolTip - toolDia / 2.0) # mill outside in innerTip = minorDia / 2.0 - H / 4.0 # Compensate for the crest of the tool toolTip = innerTip - toolCrest * SQRT_3_DIVIDED_BY_2 return ((majorDia + toolDia) / 2.0, toolTip + toolDia / 2.0) def threadPasses(count, radii, internal, majorDia, minorDia, toolDia, toolCrest): PathLog.track(count, radii, internal, majorDia, minorDia, toolDia, toolCrest) minor, major = radii(internal, majorDia, minorDia, toolDia, toolCrest) dr = float(major - minor) / count if internal: return [minor + dr * (i + 1) for i in range(count)] return [major - dr * (i + 1) for i in range(count)] def elevatorRadius(obj, center, internal, tool): '''elevatorLocation(obj, center, internal, tool) ... return suitable location for the tool elevator''' if internal: dy = float(obj.MinorDiameter - tool.Diameter) / 2 - 1 if dy < 0: if (obj.MinorDiameter < tool.Diameter): PathLog.error("The selected tool is too big (d={}) for milling a thread with minor diameter D={}".format(tool.Diameter, obj.MinorDiameter)) dy = 0 else: dy = float(obj.MajorDiameter + tool.Diameter) / 2 + 1 return dy def comment(path, msg): if True: path.append(Path.Command("(------- {} -------)".format(msg))) class _ThreadInternal(object): """Helper class for dealing with different thread types""" def __init__(self, cmd, zStart, zFinal, pitch): self.cmd = cmd if zStart < zFinal: self.pitch = pitch else: self.pitch = -pitch self.hPitch = self.pitch / 2 self.zStart = zStart self.zFinal = zFinal def overshoots(self, z): """overshoots(z) ... returns true if adding another half helix goes beyond the thread bounds""" if self.pitch < 0: return z + self.hPitch < self.zFinal return z + self.hPitch > self.zFinal def adjustX(self, x, dx): """adjustX(x, dx) ... move x by dx, the direction depends on the thread settings""" if self.isG3() == (self.pitch > 0): return x + dx return x - dx def adjustY(self, y, dy): """adjustY(y, dy) ... move y by dy, the direction depends on the thread settings""" if self.isG3(): return y - dy return y - dy def isG3(self): """isG3() ... returns True if this is a G3 command""" return self.cmd in ["G3", "G03", "g3", "g03"] def isUp(self): """isUp() ... returns True if the thread goes from the bottom up""" return self.pitch > 0 def threadCommands(center, cmd, zStart, zFinal, pitch, radius, leadInOut, elevator): """threadCommands(center, cmd, zStart, zFinal, pitch, radius) ... returns the g-code to mill the given internal thread""" thread = _ThreadInternal(cmd, zStart, zFinal, pitch) yMin = center.y - radius yMax = center.y + radius path = [] # at this point the tool is at a safe heiht (depending on the previous thread), so we can move # into position first, and then drop to the start height. If there is any material in the way this # op hasn't been setup properly. path.append(Path.Command("G0", {"X": center.x, "Y": center.y + elevator})) path.append(Path.Command("G0", {"Z": thread.zStart})) if leadInOut: comment(path, 'lead-in') path.append(Path.Command(thread.cmd, {"Y": yMax, "J": (yMax - (center.y + elevator)) / 2})) comment(path, 'lead-in') else: path.append(Path.Command("G1", {"Y": yMax})) z = thread.zStart r = -radius i = 0 while not PathGeom.isRoughly(z, thread.zFinal): if thread.overshoots(z): break if 0 == (i & 0x01): y = yMin else: y = yMax path.append(Path.Command(thread.cmd, {"Y": y, "Z": z + thread.hPitch, "J": r})) r = -r i = i + 1 z = z + thread.hPitch if PathGeom.isRoughly(z, thread.zFinal): x = center.x y = yMin if 0 == (i & 0x01) else yMax else: n = math.fabs(thread.zFinal - thread.zStart) / thread.hPitch k = n - int(n) dy = math.cos(k * math.pi) dx = math.sin(k * math.pi) y = thread.adjustY(center.y, r * dy) x = thread.adjustX(center.x, r * dx) comment(path, 'finish-thread') path.append( Path.Command(thread.cmd, {"X": x, "Y": y, "Z": thread.zFinal, "J": r}) ) comment(path, 'finish-thread') a = math.atan2(y - center.y, x - center.x) dx = math.cos(a) * elevator dy = math.sin(a) * elevator if leadInOut: comment(path, 'lead-out') path.append( Path.Command( thread.cmd, {"X": center.x + dx, "Y": center.y + dy, "I": dx / 2, "J": dy / 2}, ) ) comment(path, 'lead-out') path.append(Path.Command("G1", {"X": center.x + dx, "Y": center.y - dy})) return path class ObjectThreadMilling(PathCircularHoleBase.ObjectOp): """Proxy object for thread milling operation.""" LeftHand = "LeftHand" RightHand = "RightHand" ThreadTypeCustomExternal = "CustomExternal" ThreadTypeCustomInternal = "CustomInternal" ThreadTypeImperialExternal2A = "ImperialExternal2A" ThreadTypeImperialExternal3A = "ImperialExternal3A" ThreadTypeImperialInternal2B = "ImperialInternal2B" ThreadTypeImperialInternal3B = "ImperialInternal3B" ThreadTypeMetricExternal4G6G = "MetricExternal4G6G" ThreadTypeMetricExternal6G = "MetricExternal6G" ThreadTypeMetricInternal6H = "MetricInternal6H" DirectionClimb = "Climb" DirectionConventional = "Conventional" ThreadOrientations = [LeftHand, RightHand] ThreadTypeData = { ThreadTypeImperialExternal2A : 'imperial-external-2A.csv', ThreadTypeImperialExternal3A : 'imperial-external-3A.csv', ThreadTypeImperialInternal2B : 'imperial-internal-2B.csv', ThreadTypeImperialInternal3B : 'imperial-internal-3B.csv', ThreadTypeMetricExternal4G6G : 'metric-external-4G6G.csv', ThreadTypeMetricExternal6G : 'metric-external-6G.csv', ThreadTypeMetricInternal6H : 'metric-internal-6H.csv', } ThreadTypesExternal = [ ThreadTypeCustomExternal, ThreadTypeImperialExternal2A, ThreadTypeImperialExternal3A, ThreadTypeMetricExternal4G6G, ThreadTypeMetricExternal6G, ] ThreadTypesInternal = [ ThreadTypeCustomInternal, ThreadTypeImperialInternal2B, ThreadTypeImperialInternal3B, ThreadTypeMetricInternal6H, ] ThreadTypesImperial = [ ThreadTypeImperialExternal2A, ThreadTypeImperialExternal3A, ThreadTypeImperialInternal2B, ThreadTypeImperialInternal3B, ] ThreadTypesMetric = [ ThreadTypeMetricExternal4G6G, ThreadTypeMetricExternal6G, ThreadTypeMetricInternal6H, ] ThreadTypes = ThreadTypesInternal + ThreadTypesExternal Directions = [DirectionClimb, DirectionConventional] @classmethod def propertyEnumerations(self, dataType="data"): """helixOpPropertyEnumerations(dataType="data")... return property enumeration lists of specified dataType. Args: dataType = 'data', 'raw', 'translated' Notes: 'data' is list of internal string literals used in code 'raw' is list of (translated_text, data_string) tuples 'translated' is list of translated string literals """ PathLog.track() # Enumeration lists for App::PropertyEnumeration properties enums = { "ThreadType": [ (translate("Path_ThreadMilling", "Custom External"), ObjectThreadMilling.ThreadTypeCustomExternal), (translate("Path_ThreadMilling", "Custom Internal"), ObjectThreadMilling.ThreadTypeCustomInternal), (translate("Path_ThreadMilling", "Imperial External (2A)"), ObjectThreadMilling.ThreadTypeImperialExternal2A), (translate("Path_ThreadMilling", "Imperial External (3A)"), ObjectThreadMilling.ThreadTypeImperialExternal3A), (translate("Path_ThreadMilling", "Imperial Internal (2B)"), ObjectThreadMilling.ThreadTypeImperialInternal2B), (translate("Path_ThreadMilling", "Imperial Internal (3B)"), ObjectThreadMilling.ThreadTypeImperialInternal3B), (translate("Path_ThreadMilling", "Metric External (4G6G)"), ObjectThreadMilling.ThreadTypeMetricExternal4G6G), (translate("Path_ThreadMilling", "Metric External (6G)"), ObjectThreadMilling.ThreadTypeMetricExternal6G), (translate("Path_ThreadMilling", "Metric Internal (6H)"), ObjectThreadMilling.ThreadTypeMetricInternal6H), ], "ThreadOrientation": [ (translate("Path_ThreadMilling", "LeftHand"), ObjectThreadMilling.LeftHand), (translate("Path_ThreadMilling", "RightHand"), ObjectThreadMilling.RightHand), ], "Direction": [ (translate("Path_ThreadMilling", "Climb"), ObjectThreadMilling.DirectionClimb), (translate("Path_ThreadMilling", "Conventional"), ObjectThreadMilling.DirectionConventional), ], } if dataType == "raw": return enums data = list() idx = 0 if dataType == "translated" else 1 PathLog.debug(enums) for k, v in enumerate(enums): data.append((v, [tup[idx] for tup in enums[v]])) PathLog.debug(data) return data def circularHoleFeatures(self, obj): PathLog.track() return PathOp.FeatureBaseGeometry def initCircularHoleOperation(self, obj): PathLog.track() obj.addProperty( "App::PropertyEnumeration", "ThreadOrientation", "Thread", QT_TRANSLATE_NOOP("App::Property", "Set thread orientation"), ) # obj.ThreadOrientation = self.ThreadOrientations obj.addProperty( "App::PropertyEnumeration", "ThreadType", "Thread", QT_TRANSLATE_NOOP("App::Property", "Currently only internal"), ) # obj.ThreadType = self.ThreadTypes obj.addProperty( "App::PropertyString", "ThreadName", "Thread", QT_TRANSLATE_NOOP( "App::Property", "Defines which standard thread was chosen" ), ) obj.addProperty( "App::PropertyLength", "MajorDiameter", "Thread", QT_TRANSLATE_NOOP("App::Property", "Set thread's major diameter"), ) obj.addProperty( "App::PropertyLength", "MinorDiameter", "Thread", QT_TRANSLATE_NOOP("App::Property", "Set thread's minor diameter"), ) obj.addProperty( "App::PropertyLength", "Pitch", "Thread", QT_TRANSLATE_NOOP( "App::Property", "Set thread's pitch - used for metric threads" ), ) obj.addProperty( "App::PropertyInteger", "TPI", "Thread", QT_TRANSLATE_NOOP( "App::Property", "Set thread's TPI (turns per inch) - used for imperial threads", ), ) obj.addProperty( "App::PropertyInteger", "ThreadFit", "Thread", QT_TRANSLATE_NOOP( "App::Property", "Set how many passes are used to cut the thread" ), ) obj.addProperty( "App::PropertyInteger", "Passes", "Operation", QT_TRANSLATE_NOOP( "App::Property", "Set how many passes are used to cut the thread" ), ) obj.addProperty( "App::PropertyEnumeration", "Direction", "Operation", QT_TRANSLATE_NOOP("App::Property", "Direction of thread cutting operation"), ) obj.addProperty( "App::PropertyBool", "LeadInOut", "Operation", QT_TRANSLATE_NOOP( "App::Property", "Set to True to get lead in and lead out arcs at the start and end of the thread cut", ), ) obj.addProperty( "App::PropertyLink", "ClearanceOp", "Operation", QT_TRANSLATE_NOOP( "App::Property", "Operation to clear the inside of the thread" ), ) for n in self.propertyEnumerations(): setattr(obj, n[0], n[1]) def _isThreadInternal(self, obj): return obj.ThreadType in self.ThreadTypesInternal def _threadSetupInternal(self, obj): PathLog.track() # the thing to remember is that Climb, for an internal thread must always be G3 if obj.Direction == self.DirectionClimb: if obj.ThreadOrientation == self.RightHand: return ("G3", obj.FinalDepth.Value, obj.StartDepth.Value) return ("G3", obj.StartDepth.Value, obj.FinalDepth.Value) if obj.ThreadOrientation == self.RightHand: return ("G2", obj.StartDepth.Value, obj.FinalDepth.Value) return ("G2", obj.FinalDepth.Value, obj.StartDepth.Value) def threadSetup(self, obj): PathLog.track() cmd, zbegin, zend = self._threadSetupInternal(obj) if obj.ThreadType in self.ThreadTypesInternal: return (cmd, zbegin, zend) # need to reverse direction for external threads if cmd == 'G2': return ('G3', zbegin, zend) return ('G2', zbegin, zend) def threadPassRadii(self, obj): PathLog.track(obj.Label) rMajor = (obj.MajorDiameter.Value - self.tool.Diameter) / 2.0 rMinor = (obj.MinorDiameter.Value - self.tool.Diameter) / 2.0 if obj.Passes < 1: obj.Passes = 1 rPass = (rMajor - rMinor) / obj.Passes passes = [rMajor] for i in range(1, obj.Passes): passes.append(rMajor - rPass * i) return list(reversed(passes)) def executeThreadMill(self, obj, loc, gcode, zStart, zFinal, pitch): PathLog.track(obj.Label, loc, gcode, zStart, zFinal, pitch) elevator = elevatorRadius(obj, loc, self._isThreadInternal(obj), self.tool) self.commandlist.append( Path.Command("G0", {"Z": obj.ClearanceHeight.Value, "F": self.vertRapid}) ) for radius in threadPasses( obj.Passes, threadRadii, self._isThreadInternal(obj), obj.MajorDiameter.Value, obj.MinorDiameter.Value, float(self.tool.Diameter), float(self.tool.Crest), ): commands = threadCommands(loc, gcode, zStart, zFinal, pitch, radius, obj.LeadInOut, elevator) for cmd in commands: p = cmd.Parameters if cmd.Name in ["G0"]: p.update({"F": self.vertRapid}) if cmd.Name in ["G1", "G2", "G3"]: p.update({"F": self.horizFeed}) cmd.Parameters = p self.commandlist.extend(commands) self.commandlist.append( Path.Command("G0", {"Z": obj.ClearanceHeight.Value, "F": self.vertRapid}) ) def circularHoleExecute(self, obj, holes): PathLog.track() if self.isToolSupported(obj, self.tool): self.commandlist.append(Path.Command("(Begin Thread Milling)")) (cmd, zStart, zFinal) = self.threadSetup(obj) pitch = obj.Pitch.Value if obj.TPI > 0: pitch = 25.4 / obj.TPI if pitch <= 0: PathLog.error("Cannot create thread with pitch {}".format(pitch)) return # rapid to clearance height for loc in holes: self.executeThreadMill( obj, FreeCAD.Vector(loc["x"], loc["y"], 0), cmd, zStart, zFinal, pitch, ) else: PathLog.error("No suitable Tool found for thread milling operation") def opSetDefaultValues(self, obj, job): PathLog.track() obj.ThreadOrientation = self.RightHand obj.ThreadType = self.ThreadTypeMetricInternal6H obj.ThreadFit = 50 obj.Pitch = 1 obj.TPI = 0 obj.Passes = 1 obj.Direction = self.DirectionClimb obj.LeadInOut = False def isToolSupported(self, obj, tool): """Thread milling only supports thread milling cutters.""" support = hasattr(tool, "Diameter") and hasattr(tool, "Crest") PathLog.track(tool.Label, support) return support def SetupProperties(): setup = [] setup.append("ThreadOrientation") setup.append("ThreadType") setup.append("ThreadName") setup.append("ThreadFit") setup.append("MajorDiameter") setup.append("MinorDiameter") setup.append("Pitch") setup.append("TPI") setup.append("Passes") setup.append("Direction") setup.append("LeadInOut") return setup def Create(name, obj=None, parentJob=None): """Create(name) ... Creates and returns a thread milling operation.""" if obj is None: obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name) obj.Proxy = ObjectThreadMilling(obj, name, parentJob) if obj.Proxy: obj.Proxy.findAllHoles(obj) return obj