diff --git a/src/Mod/CAM/App/PathSegmentWalker.cpp b/src/Mod/CAM/App/PathSegmentWalker.cpp index 536b63ddc8..a7ada7d21e 100644 --- a/src/Mod/CAM/App/PathSegmentWalker.cpp +++ b/src/Mod/CAM/App/PathSegmentWalker.cpp @@ -312,8 +312,9 @@ void PathSegmentWalker::walk(PathSegmentVisitor& cb, const Base::Vector3d& start // relative mode absolutecenter = false; } - else if ((name == "G73") || (name == "G81") || (name == "G82") || (name == "G83") - || (name == "G84") || (name == "G85") || (name == "G86") || (name == "G89")) { + else if ((name == "G73") || (name == "G74") || (name == "G81") || (name == "G82") + || (name == "G83") || (name == "G84") || (name == "G85") || (name == "G86") + || (name == "G89")) { // drill,tap,bore double r = 0; if (cmd.has("R")) { diff --git a/src/Mod/CAM/InitGui.py b/src/Mod/CAM/InitGui.py index ec2ef11ebc..b34fc8408d 100644 --- a/src/Mod/CAM/InitGui.py +++ b/src/Mod/CAM/InitGui.py @@ -148,7 +148,7 @@ class CAMWorkbench(Workbench): ] threedopcmdlist = ["CAM_Pocket3D"] engravecmdlist = ["CAM_Engrave", "CAM_Deburr", "CAM_Vcarve"] - drillingcmdlist = ["CAM_Drilling", "CAM_Tapping"] + drillingcmdlist = ["CAM_Drilling"] modcmdlist = ["CAM_OperationCopy", "CAM_Array", "CAM_SimpleCopy"] dressupcmdlist = [ "CAM_DressupArray", @@ -175,14 +175,21 @@ class CAMWorkbench(Workbench): QT_TRANSLATE_NOOP("CAM_EngraveTools", "Engraving Operations"), ), ) - drillingcmdgroup = ["CAM_DrillingTools"] - FreeCADGui.addCommand( - "CAM_DrillingTools", - PathCommandGroup( - drillingcmdlist, - QT_TRANSLATE_NOOP("CAM_DrillingTools", "Drilling Operations"), - ), - ) + if Path.Preferences.experimentalFeaturesEnabled(): + drillingcmdlist.append("CAM_Tapping") + + if set(["CAM_Drilling", "CAM_Tapping"]).issubset(drillingcmdlist): + drillingcmdgroup = ["CAM_DrillingTools"] + FreeCADGui.addCommand( + "CAM_DrillingTools", + PathCommandGroup( + drillingcmdlist, + QT_TRANSLATE_NOOP("CAM_DrillingTools", "Drilling Operations"), + ), + ) + else: + drillingcmdgroup = drillingcmdlist + dressupcmdgroup = ["CAM_DressupTools"] FreeCADGui.addCommand( "CAM_DressupTools", diff --git a/src/Mod/CAM/Path/Base/Generator/tapping.py b/src/Mod/CAM/Path/Base/Generator/tapping.py index 08a0216b00..a7bf5a0cac 100644 --- a/src/Mod/CAM/Path/Base/Generator/tapping.py +++ b/src/Mod/CAM/Path/Base/Generator/tapping.py @@ -38,7 +38,15 @@ else: Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) -def generate(edge, dwelltime=0.0, repeat=1, retractheight=None, righthand=True): +def generate( + edge, + dwelltime=0.0, + repeat=1, + retractheight=None, + righthand=True, + pitch=None, + spindle_speed=None, +): """ Generates Gcode for tapping a single hole. @@ -90,6 +98,8 @@ def generate(edge, dwelltime=0.0, repeat=1, retractheight=None, righthand=True): cmdParams["Y"] = startPoint.y cmdParams["Z"] = endPoint.z cmdParams["R"] = retractheight if retractheight is not None else startPoint.z + cmdParams["S"] = spindle_speed if spindle_speed is not None else 1.0 # Sanity default + cmdParams["F"] = float(pitch) if pitch is not None else 100.0 # Sanity default if repeat < 1: raise ValueError("repeat must be 1 or greater") diff --git a/src/Mod/CAM/Path/Op/Tapping.py b/src/Mod/CAM/Path/Op/Tapping.py index e0b888368c..8f38a6f2fa 100644 --- a/src/Mod/CAM/Path/Op/Tapping.py +++ b/src/Mod/CAM/Path/Op/Tapping.py @@ -147,13 +147,11 @@ class ObjectTapping(PathCircularHoleBase.ObjectOp): Path.Log.track() machine = PathMachineState.MachineState() - if not hasattr(obj.ToolController.Tool, "Pitch") or not hasattr( - obj.ToolController.Tool, "TPI" - ): + if not hasattr(obj.ToolController.Tool, "Pitch"): Path.Log.error( translate( "Path_Tapping", - "Tapping Operation requires a Tap tool with Pitch or TPI", + "Tapping Operation requires a Tap tool with Pitch", ) ) return @@ -192,7 +190,6 @@ class ObjectTapping(PathCircularHoleBase.ObjectOp): # iterate the edgelist and generate gcode for edge in edgelist: - Path.Log.debug(edge) # move to hole location @@ -222,11 +219,40 @@ class ObjectTapping(PathCircularHoleBase.ObjectOp): repeat = 1 # technical debt: Add a repeat property for user control # Get attribute from obj.tool, assign default and set to bool for passing to generate - isRightHand = getattr(obj.ToolController.Tool, "Rotation", "Right Hand") == "Right Hand" + isRightHand = ( + getattr(obj.ToolController.Tool, "SpindleDirection", "Forward") == "Forward" + ) + + # Get pitch in mm as a float (no unit string) + pitch = getattr(obj.ToolController.Tool, "Pitch", None) + if pitch is None or pitch == 0: + Path.Log.error( + translate( + "Path_Tapping", + "Tapping Operation requires a Tap tool with non-zero Pitch", + ) + ) + continue + + spindle_speed = getattr(obj.ToolController, "SpindleSpeed", None) + if spindle_speed is None or spindle_speed == 0: + Path.Log.error( + translate( + "Path_Tapping", + "Tapping Operation requires a ToolController with non-zero SpindleSpeed", + ) + ) + continue try: tappingcommands = tapping.generate( - edge, dwelltime, repeat, obj.RetractHeight.Value, isRightHand + edge, + dwelltime, + repeat, + obj.RetractHeight.Value, + isRightHand, + pitch, + spindle_speed, ) except ValueError as e: # any targets that fail the generator are ignored diff --git a/src/Mod/CAM/Path/Post/scripts/linuxcnc_post.py b/src/Mod/CAM/Path/Post/scripts/linuxcnc_post.py index 94adc56c76..d13d3848bd 100644 --- a/src/Mod/CAM/Path/Post/scripts/linuxcnc_post.py +++ b/src/Mod/CAM/Path/Post/scripts/linuxcnc_post.py @@ -202,7 +202,6 @@ def export(objectslist, filename, argstring): gcode += linenumber() + UNITS + "\n" for obj in objectslist: - # Skip inactive operations if not PathUtil.activeForOp(obj): continue @@ -321,7 +320,6 @@ def parse(pathobj): out += parse(p) return out else: # parsing simple path - # groups might contain non-path things like stock. if not hasattr(pathobj, "Path"): return out @@ -337,7 +335,6 @@ def parse(pathobj): # # for c in PathUtils.getPathWithPlacement(pathobj).Commands: for c in pathobj.Path.Commands: - outstring = [] command = c.Name outstring.append(command) @@ -350,6 +347,34 @@ def parse(pathobj): if c.Name.startswith("(") and not OUTPUT_COMMENTS: # command is a comment continue + # Handle G84/G74 tapping cycles + if command in ("G84", "G74") and "F" in c.Parameters: + pitch_mm = float(c.Parameters["F"]) + c.Parameters.pop("F") # Remove F from output, we'll handle it + + # Get spindle speed (from S param or last known value) + spindle_speed = None + if "S" in c.Parameters: + spindle_speed = float(c.Parameters["S"]) + c.Parameters.pop("S") + + # Convert pitch to inches if needed + if UNITS == "G20": # imperial + pitch = pitch_mm / 25.4 + else: + pitch = pitch_mm + + # Calculate feed rate + if spindle_speed is not None: + feed_rate = pitch * spindle_speed + speed = Units.Quantity(feed_rate, UNIT_SPEED_FORMAT) + outstring.append( + "F" + format(float(speed.getValueAs(UNIT_SPEED_FORMAT)), precision_string) + ) + else: + # No spindle speed found, output pitch as F + outstring.append("F" + format(pitch, precision_string)) + # Now add the remaining parameters in order for param in params: if param in c.Parameters: @@ -428,8 +453,6 @@ def parse(pathobj): # append the line to the final output for w in outstring: out += w + COMMAND_SPACE - # Note: Do *not* strip `out`, since that forces the allocation - # of a contiguous string & thus quadratic complexity. out += "\n" return out diff --git a/src/Mod/CAM/Path/Tool/shape/models/tap.py b/src/Mod/CAM/Path/Tool/shape/models/tap.py index 494d0f3164..1d4f5a18a9 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/tap.py +++ b/src/Mod/CAM/Path/Tool/shape/models/tap.py @@ -56,6 +56,10 @@ class ToolBitShapeTap(ToolBitShape): FreeCAD.Qt.translate("ToolBitShape", "Tip angle"), "App::PropertyAngle", ), + "Pitch": ( + FreeCAD.Qt.translate("ToolBitShape", "Thread pitch"), + "App::PropertyLength", + ), } @property diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/tap.py b/src/Mod/CAM/Path/Tool/toolbit/models/tap.py index 4a83a59822..8e605b07cb 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/tap.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/tap.py @@ -24,6 +24,7 @@ import Path from ...shape import ToolBitShapeTap from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit +from ..util import is_imperial_pitch class ToolBitTap(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @@ -39,7 +40,34 @@ class ToolBitTap(ToolBit, CuttingToolMixin, RotaryToolBitMixin): diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") cutting_edge_length = self.get_property_str("CuttingEdgeLength", "?", precision=3) + pitch_raw = self.get_property("Pitch") + + spindle_direction = self.get_property_str("SpindleDirection", "Forward") + if spindle_direction == "Forward": + rotation = "Right Hand" + elif spindle_direction == "Reverse": + rotation = "Left Hand" + else: + rotation = spindle_direction + + if isinstance(pitch_raw, FreeCAD.Units.Quantity): + pitch_mm = pitch_raw.getValueAs("mm") + else: + pitch_mm = FreeCAD.Units.Quantity(str(pitch_raw)).getValueAs("mm") + + if pitch_raw: + try: + if is_imperial_pitch(pitch_raw): + tpi = round(25.4 / pitch_mm, 2) + pitch = f"{int(tpi) if tpi == int(tpi) else tpi} TPI" + else: + pitch = f"{pitch_mm} mm" + except Exception: + pitch = str(pitch_raw) + else: + pitch = "?" return FreeCAD.Qt.translate( - "CAM", f"{diameter} tap, {flutes}-flute, {cutting_edge_length} cutting edge" + "CAM", + f"{diameter} {pitch} {rotation} tap, {flutes}-flute, {cutting_edge_length} cutting edge", ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/util.py b/src/Mod/CAM/Path/Tool/toolbit/util.py index bc21722814..87ed862ad2 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/util.py +++ b/src/Mod/CAM/Path/Tool/toolbit/util.py @@ -47,3 +47,33 @@ def format_value(value: FreeCAD.Units.Quantity | int | float | None, precision: return value.getUserPreferred()[0] return value.UserString return str(value) + + +def is_imperial_pitch(pitch_mm, tol=1e-6): + """ + Classify a pitch in mm as imperial vs metric. + Rule: + - If pitch_mm is ~2 decimal places clean -> metric, + unless it corresponds to an exact whole-number TPI. + - Otherwise, treat as imperial. + """ + import math + + try: + mm = float(pitch_mm) + except Exception: + return False + if mm <= 0: + return False + + # Check if it's "two-decimal clean" + two_dec_clean = abs(mm - round(mm, 2)) <= tol + + # Compute TPI + tpi = 25.4 / mm + whole_tpi = round(tpi) + is_whole_tpi = math.isclose(tpi, whole_tpi, abs_tol=1e-6) + + if two_dec_clean and not is_whole_tpi: + return False # metric + return True # imperial diff --git a/src/Mod/CAM/Tools/Bit/375-16_Tap.fctb b/src/Mod/CAM/Tools/Bit/375-16_Tap.fctb index 2688479c5e..4329193cb8 100644 --- a/src/Mod/CAM/Tools/Bit/375-16_Tap.fctb +++ b/src/Mod/CAM/Tools/Bit/375-16_Tap.fctb @@ -4,17 +4,13 @@ "shape": "tap.fcstd", "shape-type": "tap", "parameter": { - "Coating": "None", "CuttingEdgeLength": "1.063 \"", "Diameter": "0.375 \"", "Flutes": "3", "Length": "2.500 \"", "Pitch": "0.000 in", - "Rotation": "Right Hand", "ShankDiameter": "0.250 \"", - "TPI": "16", - "TipAngle": "90.000 \u00b0", - "Type": "HSS" + "TipAngle": "90.000 \u00b0" }, "attribute": {} } diff --git a/src/Mod/CAM/Tools/Shape/tap.fcstd b/src/Mod/CAM/Tools/Shape/tap.fcstd index 2ee6cbcda3..a8febea77b 100644 Binary files a/src/Mod/CAM/Tools/Shape/tap.fcstd and b/src/Mod/CAM/Tools/Shape/tap.fcstd differ