540 lines
20 KiB
Python
540 lines
20 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 *
|
|
# * *
|
|
# ***************************************************************************
|
|
|
|
|
|
import FreeCAD
|
|
import Path
|
|
import Path.Base.Generator.threadmilling as threadmilling
|
|
import Path.Op.Base as PathOp
|
|
import Path.Op.CircularHoleBase as PathCircularHoleBase
|
|
import math
|
|
from PySide.QtCore import QT_TRANSLATE_NOOP
|
|
|
|
__title__ = "CAM Thread Milling Operation"
|
|
__author__ = "sliptonic (Brad Collette)"
|
|
__url__ = "https://www.freecad.org"
|
|
__doc__ = "CAM thread milling operation."
|
|
|
|
# math.sqrt(3)/2 ... 60deg triangle height
|
|
SQRT_3_DIVIDED_BY_2 = 0.8660254037844386
|
|
|
|
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
|
|
|
|
# Constants
|
|
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]
|
|
|
|
|
|
def _isThreadInternal(obj):
|
|
return obj.ThreadType in ThreadTypesInternal
|
|
|
|
|
|
def threadSetupInternal(obj, zTop, zBottom):
|
|
Path.Log.track()
|
|
if obj.ThreadOrientation == RightHand:
|
|
# Right hand thread, G2, top down -> conventional milling
|
|
if obj.Direction == DirectionConventional:
|
|
return ("G2", zTop, zBottom)
|
|
# For climb milling we need to cut the thread from the bottom up
|
|
# in the opposite direction -> G3
|
|
return ("G3", zBottom, zTop)
|
|
# Left hand thread, G3, top down -> climb milling
|
|
if obj.Direction == DirectionClimb:
|
|
return ("G3", zTop, zBottom)
|
|
# for conventional milling, cut bottom up with G2
|
|
return ("G2", zBottom, zTop)
|
|
|
|
|
|
def threadSetupExternal(obj, zTop, zBottom):
|
|
Path.Log.track()
|
|
if obj.ThreadOrientation == RightHand:
|
|
# right hand thread, G2, top down -> climb milling
|
|
if obj.Direction == DirectionClimb:
|
|
return ("G2", zTop, zBottom)
|
|
# for conventional, mill bottom up the other way around
|
|
return ("G3", zBottom, zTop)
|
|
# left hand thread, G3, top down -> conventional milling
|
|
if obj.Direction == DirectionConventional:
|
|
return ("G3", zTop, zBottom)
|
|
# for climb milling need to go bottom up and the other way
|
|
return ("G2", zBottom, zTop)
|
|
|
|
|
|
def threadSetup(obj):
|
|
"""Return (cmd, zbegin, zend) of thread milling operation"""
|
|
Path.Log.track()
|
|
|
|
zTop = obj.StartDepth.Value
|
|
zBottom = obj.FinalDepth.Value
|
|
|
|
if _isThreadInternal(obj):
|
|
return threadSetupInternal(obj, zTop, zBottom)
|
|
else:
|
|
return threadSetupExternal(obj, zTop, zBottom)
|
|
|
|
|
|
def threadRadii(internal, majorDia, minorDia, toolDia, toolCrest):
|
|
"""threadRadii(majorDia, minorDia, toolDia, toolCrest) ... returns the minimum and maximum radius for thread."""
|
|
Path.Log.track(internal, 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
|
|
radii = ((minorDia - toolDia) / 2.0, toolTip - toolDia / 2.0)
|
|
else:
|
|
# 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
|
|
radii = ((majorDia + toolDia) / 2.0, toolTip + toolDia / 2.0)
|
|
Path.Log.track(radii)
|
|
return radii
|
|
|
|
|
|
def threadPasses(count, radii, internal, majorDia, minorDia, toolDia, toolCrest):
|
|
Path.Log.track(count, radii, internal, majorDia, minorDia, toolDia, toolCrest)
|
|
# the logic goes as follows, total area to be removed:
|
|
# A = H * W ... where H is the depth and W is half the width of a thread
|
|
# H = k * sin(30) = k * 1/2 -> k = 2 * H
|
|
# W = k * cos(30) = k * sqrt(3)/2
|
|
# -> W = (2 * H) * sqrt(3) / 2 = H * sqrt(3)
|
|
# A = sqrt(3) * H^2
|
|
# Each pass has to remove the same area
|
|
# An = A / count = sqrt(3) * H^2 / count
|
|
# Because each successive pass doesn't have to remove the aera of the previous
|
|
# passes the result for the height:
|
|
# Ai = (i + 1) * An = (i + 1) * sqrt(3) * Hi^2 = sqrt(3) * H^2 / count
|
|
# Hi = sqrt(H^2 * (i + 1) / count)
|
|
# Hi = H * sqrt((i + 1) / count)
|
|
minor, major = radii(internal, majorDia, minorDia, toolDia, toolCrest)
|
|
H = float(major - minor)
|
|
Hi = [H * math.sqrt((i + 1) / count) for i in range(count)]
|
|
|
|
# For external threads threadRadii returns the radii in reverse order because that's
|
|
# the order in which they have to get milled. As a result H ends up being negative
|
|
# and the math for internal and external threads is identical.
|
|
passes = [minor + h for h in Hi]
|
|
Path.Log.debug(f"threadPasses({minor}, {major}) -> H={H} : {Hi} --> {passes}")
|
|
|
|
return passes
|
|
|
|
|
|
def elevatorRadius(obj, center, internal, tool):
|
|
"""elevatorLocation(obj, center, internal, tool) ... return suitable location for the tool elevator"""
|
|
Path.Log.track(center, internal, tool.Diameter)
|
|
if internal:
|
|
dy = float(obj.MinorDiameter - tool.Diameter) / 2 - 1
|
|
if dy < 0:
|
|
if obj.MinorDiameter < tool.Diameter:
|
|
Path.Log.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
|
|
|
|
|
|
class ObjectThreadMilling(PathCircularHoleBase.ObjectOp):
|
|
"""Proxy object for thread milling operation."""
|
|
|
|
@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
|
|
"""
|
|
Path.Log.track()
|
|
|
|
# Enumeration lists for App::PropertyEnumeration properties
|
|
enums = {
|
|
"ThreadType": [
|
|
(
|
|
translate("CAM_ThreadMilling", "Custom External"),
|
|
ThreadTypeCustomExternal,
|
|
),
|
|
(
|
|
translate("CAM_ThreadMilling", "Custom Internal"),
|
|
ThreadTypeCustomInternal,
|
|
),
|
|
(
|
|
translate("CAM_ThreadMilling", "Imperial External (2A)"),
|
|
ThreadTypeImperialExternal2A,
|
|
),
|
|
(
|
|
translate("CAM_ThreadMilling", "Imperial External (3A)"),
|
|
ThreadTypeImperialExternal3A,
|
|
),
|
|
(
|
|
translate("CAM_ThreadMilling", "Imperial Internal (2B)"),
|
|
ThreadTypeImperialInternal2B,
|
|
),
|
|
(
|
|
translate("CAM_ThreadMilling", "Imperial Internal (3B)"),
|
|
ThreadTypeImperialInternal3B,
|
|
),
|
|
(
|
|
translate("CAM_ThreadMilling", "Metric External (4G6G)"),
|
|
ThreadTypeMetricExternal4G6G,
|
|
),
|
|
(
|
|
translate("CAM_ThreadMilling", "Metric External (6G)"),
|
|
ThreadTypeMetricExternal6G,
|
|
),
|
|
(
|
|
translate("CAM_ThreadMilling", "Metric Internal (6H)"),
|
|
ThreadTypeMetricInternal6H,
|
|
),
|
|
],
|
|
"ThreadOrientation": [
|
|
(
|
|
translate("CAM_ThreadMilling", "LeftHand"),
|
|
LeftHand,
|
|
),
|
|
(
|
|
translate("CAM_ThreadMilling", "RightHand"),
|
|
RightHand,
|
|
),
|
|
],
|
|
"Direction": [
|
|
(
|
|
translate("CAM_ThreadMilling", "Climb"),
|
|
DirectionClimb,
|
|
),
|
|
(
|
|
translate("CAM_ThreadMilling", "Conventional"),
|
|
DirectionConventional,
|
|
),
|
|
],
|
|
}
|
|
|
|
if dataType == "raw":
|
|
return enums
|
|
|
|
data = list()
|
|
idx = 0 if dataType == "translated" else 1
|
|
|
|
Path.Log.debug(enums)
|
|
|
|
for k, v in enumerate(enums):
|
|
data.append((v, [tup[idx] for tup in enums[v]]))
|
|
Path.Log.debug(data)
|
|
|
|
return data
|
|
|
|
def circularHoleFeatures(self, obj):
|
|
Path.Log.track()
|
|
return PathOp.FeatureBaseGeometry
|
|
|
|
def initCircularHoleOperation(self, obj):
|
|
Path.Log.track()
|
|
obj.addProperty(
|
|
"App::PropertyEnumeration",
|
|
"ThreadOrientation",
|
|
"Thread",
|
|
QT_TRANSLATE_NOOP("App::Property", "Set thread orientation"),
|
|
)
|
|
# obj.ThreadOrientation = ThreadOrientations
|
|
obj.addProperty(
|
|
"App::PropertyEnumeration",
|
|
"ThreadType",
|
|
"Thread",
|
|
QT_TRANSLATE_NOOP("App::Property", "Currently only internal"),
|
|
)
|
|
# obj.ThreadType = 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",
|
|
"Override to control how loose or tight the threads are milled",
|
|
),
|
|
)
|
|
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 threadPassRadii(self, obj):
|
|
Path.Log.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):
|
|
Path.Log.track(obj.Label, loc, gcode, zStart, zFinal, pitch)
|
|
elevator = elevatorRadius(obj, loc, _isThreadInternal(obj), self.tool)
|
|
|
|
move2clearance = Path.Command("G0", {"Z": obj.ClearanceHeight.Value, "F": self.vertRapid})
|
|
self.commandlist.append(move2clearance)
|
|
|
|
start = None
|
|
for radius in threadPasses(
|
|
obj.Passes,
|
|
threadRadii,
|
|
_isThreadInternal(obj),
|
|
obj.MajorDiameter.Value,
|
|
obj.MinorDiameter.Value,
|
|
float(self.tool.Diameter),
|
|
float(self.tool.Crest),
|
|
):
|
|
if not start is None:
|
|
# and not _isThreadInternal(obj):
|
|
# and not obj.LeadInOut:
|
|
# external thread without lead in/out have to go up and over
|
|
# in other words we need a move to clearance and not take any
|
|
# shortcuts when moving to the elevator position
|
|
self.commandlist.append(move2clearance)
|
|
start = None
|
|
commands, start = threadmilling.generate(
|
|
loc,
|
|
gcode,
|
|
zStart,
|
|
zFinal,
|
|
pitch,
|
|
radius,
|
|
obj.LeadInOut,
|
|
elevator,
|
|
start,
|
|
)
|
|
|
|
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):
|
|
Path.Log.track()
|
|
if self.isToolSupported(obj, self.tool):
|
|
self.commandlist.append(Path.Command("(Begin Thread Milling)"))
|
|
|
|
(cmd, zStart, zFinal) = threadSetup(obj)
|
|
pitch = obj.Pitch.Value
|
|
if obj.TPI > 0:
|
|
pitch = 25.4 / obj.TPI
|
|
if pitch <= 0:
|
|
Path.Log.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:
|
|
Path.Log.error("No suitable Tool found for thread milling operation")
|
|
|
|
def opSetDefaultValues(self, obj, job):
|
|
Path.Log.track()
|
|
obj.ThreadOrientation = RightHand
|
|
obj.ThreadType = ThreadTypeMetricInternal6H
|
|
obj.ThreadFit = 50
|
|
obj.Pitch = 1
|
|
obj.TPI = 0
|
|
obj.Passes = 1
|
|
obj.Direction = DirectionClimb
|
|
obj.LeadInOut = False
|
|
|
|
def isToolSupported(self, obj, tool):
|
|
"""Thread milling only supports thread milling cutters."""
|
|
support = hasattr(tool, "Diameter") and hasattr(tool, "Crest")
|
|
Path.Log.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
|