495 lines
18 KiB
Python
495 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
# ***************************************************************************
|
|
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
|
|
# * *
|
|
# * This program is free software; you can redistribute it and/or modify *
|
|
# * it under the terms of the GNU Lesser General Public License (LGPL) *
|
|
# * as published by the Free Software Foundation; either version 2 of *
|
|
# * the License, or (at your option) any later version. *
|
|
# * for detail see the LICENCE text file. *
|
|
# * *
|
|
# * This program is distributed in the hope that it will be useful, *
|
|
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
# * GNU Library General Public License for more details. *
|
|
# * *
|
|
# * You should have received a copy of the GNU Library General Public *
|
|
# * License along with this program; if not, write to the Free Software *
|
|
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
|
|
# * USA *
|
|
# * *
|
|
# ***************************************************************************
|
|
|
|
from __future__ import print_function
|
|
|
|
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."
|
|
|
|
if False:
|
|
PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule())
|
|
PathLog.trackModule(PathLog.thisModule())
|
|
else:
|
|
PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule())
|
|
|
|
translate = FreeCAD.Qt.translate
|
|
|
|
|
|
def radiiInternal(majorDia, minorDia, toolDia, toolCrest=None):
|
|
"""internlThreadRadius(majorDia, minorDia, toolDia, toolCrest) ... returns the 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
|
|
outerTip = majorDia / 2.0 + H / 8.0
|
|
# Compensate for the crest of the tool
|
|
toolTip = (
|
|
outerTip - toolCrest * 0.8660254037844386
|
|
) # math.sqrt(3)/2 ... 60deg triangle height
|
|
return ((minorDia - toolDia) / 2.0, toolTip - toolDia / 2.0)
|
|
|
|
|
|
def threadPasses(count, radii, majorDia, minorDia, toolDia, toolCrest=None):
|
|
PathLog.track(count, radii, majorDia, minorDia, toolDia, toolCrest)
|
|
minor, major = radii(majorDia, minorDia, toolDia, toolCrest)
|
|
dr = float(major - minor) / count
|
|
return [major - dr * (count - (i + 1)) for i in range(count)]
|
|
|
|
|
|
class _InternalThread(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 internalThreadCommands(loc, cmd, zStart, zFinal, pitch, radius, leadInOut):
|
|
"""internalThreadCommands(loc, cmd, zStart, zFinal, pitch, radius) ... returns the g-code to mill the given internal thread"""
|
|
thread = _InternalThread(cmd, zStart, zFinal, pitch)
|
|
|
|
yMin = loc.y - radius
|
|
yMax = loc.y + radius
|
|
|
|
path = []
|
|
# at this point the tool is at a safe height (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": loc.x, "Y": loc.y}))
|
|
path.append(Path.Command("G0", {"Z": thread.zStart}))
|
|
if leadInOut:
|
|
path.append(Path.Command(thread.cmd, {"Y": yMax, "J": (yMax - loc.y) / 2}))
|
|
else:
|
|
path.append(Path.Command("G1", {"Y": yMax}))
|
|
|
|
z = thread.zStart
|
|
r = -radius
|
|
i = 0
|
|
while True:
|
|
z = thread.zStart + i * thread.hPitch
|
|
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 = thread.zStart + i * thread.hPitch
|
|
if PathGeom.isRoughly(z, thread.zFinal):
|
|
x = loc.x
|
|
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(loc.y, r * dy)
|
|
x = thread.adjustX(loc.x, r * dx)
|
|
path.append(
|
|
Path.Command(thread.cmd, {"X": x, "Y": y, "Z": thread.zFinal, "J": r})
|
|
)
|
|
|
|
if leadInOut:
|
|
path.append(
|
|
Path.Command(
|
|
thread.cmd,
|
|
{"X": loc.x, "Y": loc.y, "I": (loc.x - x) / 2, "J": (loc.y - y) / 2},
|
|
)
|
|
)
|
|
else:
|
|
path.append(Path.Command("G1", {"X": loc.x, "Y": loc.y}))
|
|
return path
|
|
|
|
|
|
class ObjectThreadMilling(PathCircularHoleBase.ObjectOp):
|
|
"""Proxy object for thread milling operation."""
|
|
|
|
LeftHand = "LeftHand"
|
|
RightHand = "RightHand"
|
|
ThreadTypeCustom = "Custom"
|
|
ThreadTypeMetricInternal = "MetricInternal"
|
|
ThreadTypeImperialInternal = "ImperialInternal"
|
|
DirectionClimb = "Climb"
|
|
DirectionConventional = "Conventional"
|
|
|
|
ThreadOrientations = [LeftHand, RightHand]
|
|
ThreadTypes = [
|
|
ThreadTypeCustom,
|
|
ThreadTypeMetricInternal,
|
|
ThreadTypeImperialInternal,
|
|
]
|
|
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
|
|
"""
|
|
|
|
# Enumeration lists for App::PropertyEnumeration properties
|
|
enums = {
|
|
"ThreadType": [
|
|
(translate("Path_ThreadMilling", "Custom"), "Custom"),
|
|
(translate("Path_ThreadMilling", "Metric Internal"), "MetricInternal"),
|
|
(
|
|
translate("Path_ThreadMilling", "Imperial Internal"),
|
|
"ImperialInternal",
|
|
),
|
|
], # this is the direction that the profile runs
|
|
"ThreadOrientation": [
|
|
(translate("Path_ThreadMilling", "LeftHand"), "LeftHand"),
|
|
(translate("Path_ThreadMilling", "RightHand"), "RightHand"),
|
|
], # side of profile that cutter is on in relation to direction of profile
|
|
"Direction": [
|
|
(translate("Path_ThreadMilling", "Climb"), "Climb"),
|
|
(translate("Path_ThreadMilling", "Conventional"), "Conventional"),
|
|
], # side of profile that cutter is on in relation to direction of profile
|
|
}
|
|
|
|
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):
|
|
return PathOp.FeatureBaseGeometry
|
|
|
|
def initCircularHoleOperation(self, obj):
|
|
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 threadStartDepth(self, obj):
|
|
if obj.ThreadOrientation == self.RightHand:
|
|
if obj.Direction == self.DirectionClimb:
|
|
PathLog.track(obj.Label, obj.FinalDepth)
|
|
return obj.FinalDepth
|
|
PathLog.track(obj.Label, obj.StartDepth)
|
|
return obj.StartDepth
|
|
if obj.Direction == self.DirectionClimb:
|
|
PathLog.track(obj.Label, obj.StartDepth)
|
|
return obj.StartDepth
|
|
PathLog.track(obj.Label, obj.FinalDepth)
|
|
return obj.FinalDepth
|
|
|
|
def threadFinalDepth(self, obj):
|
|
PathLog.track(obj.Label)
|
|
if obj.ThreadOrientation == self.RightHand:
|
|
if obj.Direction == self.DirectionClimb:
|
|
PathLog.track(obj.Label, obj.StartDepth)
|
|
return obj.StartDepth
|
|
PathLog.track(obj.Label, obj.FinalDepth)
|
|
return obj.FinalDepth
|
|
if obj.Direction == self.DirectionClimb:
|
|
PathLog.track(obj.Label, obj.FinalDepth)
|
|
return obj.FinalDepth
|
|
PathLog.track(obj.Label, obj.StartDepth)
|
|
return obj.StartDepth
|
|
|
|
def threadDirectionCmd(self, obj):
|
|
PathLog.track(obj.Label)
|
|
if obj.ThreadOrientation == self.RightHand:
|
|
if obj.Direction == self.DirectionClimb:
|
|
PathLog.track(obj.Label, "G2")
|
|
return "G2"
|
|
PathLog.track(obj.Label, "G3")
|
|
return "G3"
|
|
if obj.Direction == self.DirectionClimb:
|
|
PathLog.track(obj.Label, "G3")
|
|
return "G3"
|
|
PathLog.track(obj.Label, "G2")
|
|
return "G2"
|
|
|
|
def threadSetup(self, obj):
|
|
# 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 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)
|
|
|
|
self.commandlist.append(
|
|
Path.Command("G0", {"Z": obj.ClearanceHeight.Value, "F": self.vertRapid})
|
|
)
|
|
|
|
for radius in threadPasses(
|
|
obj.Passes,
|
|
radiiInternal,
|
|
obj.MajorDiameter.Value,
|
|
obj.MinorDiameter.Value,
|
|
float(self.tool.Diameter),
|
|
float(self.tool.Crest),
|
|
):
|
|
commands = internalThreadCommands(
|
|
loc, gcode, zStart, zFinal, pitch, radius, obj.LeadInOut
|
|
)
|
|
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):
|
|
obj.ThreadOrientation = self.RightHand
|
|
obj.ThreadType = self.ThreadTypeMetricInternal
|
|
obj.ThreadFit = 50
|
|
obj.Pitch = 1
|
|
obj.TPI = 0
|
|
obj.Passes = 1
|
|
obj.Direction = self.DirectionClimb
|
|
obj.LeadInOut = True
|
|
|
|
def isToolSupported(self, obj, tool):
|
|
"""Thread milling only supports thread milling cutters."""
|
|
return hasattr(tool, "Diameter") and hasattr(tool, "Crest")
|
|
|
|
|
|
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
|