# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * Copyright (c) 2014 Yorik van Havre * # * Copyright (c) 2014 sliptonic * # * Copyright (c) 2015 Dan Falck * # * Copyright (c) 2018, 2019 Gauthier Briere * # * Copyright (c) 2019, 2020 Schildkroet * # * Copyright (c) 2022 Larry Woestman * # * Copyright (c) 2024 Carl Slater * # * * # * This file is part of the FreeCAD CAx development system. * # * * # * 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. * # * * # * FreeCAD 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 Lesser General Public License for more details. * # * * # * You should have received a copy of the GNU Library General Public * # * License along with FreeCAD; if not, write to the Free Software * # * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * # * USA * # * * # *************************************************************************** import math import re from typing import Any, Callable, Dict, List, Tuple, Union import FreeCAD from FreeCAD import Units import Path import Path.Post.Utils as PostUtils # Define some types that are used throughout this file CommandLine = List[str] Gcode = List[str] PathParameter = float PathParameters = Dict[str, PathParameter] Values = Dict[str, Any] ParameterFunction = Callable[[Values, str, str, PathParameter, PathParameters], str] def check_for_an_adaptive_op( values: Values, command: str, command_line: CommandLine, adaptive_op_variables: Tuple[bool, float, float], ) -> str: """Check to see if the current command is an adaptive op.""" adaptiveOp: bool opHorizRapid: float opVertRapid: float (adaptiveOp, opHorizRapid, opVertRapid) = adaptive_op_variables if values["OUTPUT_ADAPTIVE"] and adaptiveOp and command in values["RAPID_MOVES"]: if opHorizRapid and opVertRapid: return "G1" command_line.append(f"(Tool Controller Rapid Values are unset)") return "" def check_for_drill_translate( values: Values, gcode: Gcode, command: str, command_line: CommandLine, params: PathParameters, motion_location: PathParameters, drill_retract_mode: str, ) -> bool: """Check for drill commands to translate.""" comment: str if values["TRANSLATE_DRILL_CYCLES"] and command in values["DRILL_CYCLES_TO_TRANSLATE"]: if values["OUTPUT_COMMENTS"]: # Comment the original command comment = create_comment(values, format_command_line(values, command_line)) gcode.append(f"{linenumber(values)}{comment}") # wrap this block to ensure that the value of values["MOTION_MODE"] # is restored in case of error try: drill_translate( values, gcode, command, params, motion_location, drill_retract_mode, ) except (ArithmeticError, LookupError) as err: print("exception occurred", err) # drill_translate uses G90 mode internally, so need to # switch back to G91 mode if it was that way originally if values["MOTION_MODE"] == "G91": gcode.append(f"{linenumber(values)}G91") return True return False def check_for_machine_specific_commands(values: Values, gcode: Gcode, command: str) -> None: """Check for comments containing machine-specific commands.""" m: object raw_command: str if values["ENABLE_MACHINE_SPECIFIC_COMMANDS"]: m = re.match(r"^\(MC_RUN_COMMAND: ([^)]+)\)$", command) if m: raw_command = m.group(1) # pass literally to the controller gcode.append(f"{linenumber(values)}{raw_command}") def check_for_spindle_wait( values: Values, gcode: Gcode, command: str, command_line: CommandLine ) -> None: """Check for commands that might need a wait command after them.""" cmd: str if values["SPINDLE_WAIT"] > 0 and command in ("M3", "M03", "M4", "M04"): gcode.append(f"{linenumber(values)}{format_command_line(values, command_line)}") cmd = format_command_line(values, ["G4", f'P{values["SPINDLE_WAIT"]}']) gcode.append(f"{linenumber(values)}{cmd}") def check_for_suppressed_commands( values: Values, gcode: Gcode, command: str, command_line: CommandLine ) -> bool: """Check for commands that will be suppressed.""" comment: str if command in values["SUPPRESS_COMMANDS"]: if values["OUTPUT_COMMENTS"]: # convert the command to a comment comment = create_comment(values, format_command_line(values, command_line)) gcode.append(f"{linenumber(values)}{comment}") # remove the command return True return False def check_for_tlo(values: Values, gcode: Gcode, command: str, params: PathParameters) -> None: """Output a tool length command if USE_TLO is True.""" if command in ("M6", "M06") and values["USE_TLO"]: cmd = format_command_line(values, ["G43", f'H{str(int(params["T"]))}']) gcode.append(f"{linenumber(values)}{cmd}") def check_for_tool_change( values: Values, gcode: Gcode, command: str, command_line: CommandLine ) -> bool: """Check for a tool change.""" if command in ("M6", "M06"): if values["OUTPUT_COMMENTS"]: comment = create_comment(values, "Begin toolchange") gcode.append(f"{linenumber(values)}{comment}") if values["OUTPUT_TOOL_CHANGE"]: if values["STOP_SPINDLE_FOR_TOOL_CHANGE"]: # stop the spindle gcode.append(f"{linenumber(values)}M5") for line in values["TOOL_CHANGE"].splitlines(False): gcode.append(f"{linenumber(values)}{line}") return False if values["OUTPUT_COMMENTS"]: # convert the tool change to a comment comment = create_comment(values, format_command_line(values, command_line)) gcode.append(f"{linenumber(values)}{comment}") return True return False def create_comment(values: Values, comment_string: str) -> str: """Create a comment from a string using the correct comment symbol.""" if values["COMMENT_SYMBOL"] == "(": return f"({comment_string})" return values["COMMENT_SYMBOL"] + comment_string def default_axis_parameter( values: Values, command: str, # pylint: disable=unused-argument param: str, param_value: PathParameter, parameters: PathParameters, # pylint: disable=unused-argument current_location: PathParameters, ) -> str: """Process an axis parameter.""" # # used to compare two floating point numbers for "close-enough equality" # epsilon: float = 0.00001 if ( not values["OUTPUT_DOUBLES"] and param in current_location and math.fabs(current_location[param] - param_value) < epsilon ): return "" return format_for_axis(values, Units.Quantity(param_value, Units.Length)) def default_D_parameter( values: Values, command: str, param: str, # pylint: disable=unused-argument param_value: PathParameter, parameters: PathParameters, # pylint: disable=unused-argument current_location: PathParameters, # pylint: disable=unused-argument ) -> str: """Process the D parameter.""" if command in ("G41", "G42"): return str(int(param_value)) if command in ("G41.1", "G42.1"): return format_for_axis(values, Units.Quantity(param_value, Units.Length)) if command in ("G96", "G97"): return format_for_spindle(values, param_value) # anything else that is supported return str(float(param_value)) def default_F_parameter( values: Values, command: str, param: str, param_value: PathParameter, parameters: PathParameters, current_location: PathParameters, ) -> str: """Process the F parameter.""" # # used to compare two floating point numbers for "close-enough equality" # epsilon: float = 0.00001 found: bool if ( not values["OUTPUT_DOUBLES"] and param in current_location and math.fabs(current_location[param] - param_value) < epsilon ): return "" # Many posts don't use rapid speeds, but eventually # there will be refactored posts that do, so this # "if statement" is being kept separate to make it # more obvious where to put that check. if command in values["RAPID_MOVES"]: return "" feed = Units.Quantity(param_value, Units.Velocity) if feed.getValueAs(values["UNIT_SPEED_FORMAT"]) <= 0.0: return "" # if any of X, Y, Z, U, V, or W are in the parameters # and any of their values is different than where the device currently should be # then feed is in linear units found = False for key in ("X", "Y", "Z", "U", "V", "W"): if key in parameters and math.fabs(current_location[key] - parameters[key]) > epsilon: found = True if found: return format_for_feed(values, feed) # else if any of A, B, or C are in the parameters, the feed is in degrees, # which should not be converted when in --inches mode found = False for key in ("A", "B", "C"): if key in parameters: found = True if found: # converting from degrees per second to degrees per minute as well return format(float(feed * 60.0), f'.{str(values["FEED_PRECISION"])}f') # which leaves none of X, Y, Z, U, V, W, A, B, C, # which should not be valid but return a converted value just in case return format_for_feed(values, feed) def default_int_parameter( values: Values, # pylint: disable=unused-argument command: str, # pylint: disable=unused-argument param: str, # pylint: disable=unused-argument param_value: PathParameter, parameters: PathParameters, # pylint: disable=unused-argument current_location: PathParameters, # pylint: disable=unused-argument ) -> str: """Process a parameter that is treated like an integer.""" return str(int(param_value)) def default_length_parameter( values: Values, command: str, # pylint: disable=unused-argument param: str, # pylint: disable=unused-argument param_value: PathParameter, parameters: PathParameters, # pylint: disable=unused-argument current_location: PathParameters, # pylint: disable=unused-argument ) -> str: """Process a parameter that is treated like a length.""" return format_for_axis(values, Units.Quantity(param_value, Units.Length)) def default_P_parameter( values: Values, command: str, param: str, # pylint: disable=unused-argument param_value: PathParameter, parameters: PathParameters, # pylint: disable=unused-argument current_location: PathParameters, # pylint: disable=unused-argument ) -> str: """Process the P parameter.""" if command in ("G2", "G02", "G3", "G03", "G5.2", "G5.3", "G10", "G54.1", "G59"): return str(int(param_value)) if command in ("G4", "G04", "G76", "G82", "G86", "G89"): return str(float(param_value)) if command in ("G5", "G05", "G64"): return format_for_axis(values, Units.Quantity(param_value, Units.Length)) # anything else that is supported return str(param_value) def default_Q_parameter( values: Values, command: str, param: str, # pylint: disable=unused-argument param_value: PathParameter, parameters: PathParameters, # pylint: disable=unused-argument current_location: PathParameters, # pylint: disable=unused-argument ) -> str: """Process the Q parameter.""" if command == "G10": return str(int(param_value)) if command in ("G64", "G73", "G83"): return format_for_axis(values, Units.Quantity(param_value, Units.Length)) return "" def default_rotary_parameter( values: Values, command: str, # pylint: disable=unused-argument param: str, param_value: PathParameter, parameters: PathParameters, # pylint: disable=unused-argument current_location: PathParameters, ) -> str: """Process a rotarty parameter (such as A, B, and C).""" # # used to compare two floating point numbers for "close-enough equality" # epsilon: float = 0.00001 if ( not values["OUTPUT_DOUBLES"] and param in current_location and math.fabs(current_location[param] - param_value) < epsilon ): return "" # unlike other axis, rotary axis such as A, B, and C are always in degrees # and should not be converted when in --inches mode return str(format(float(param_value), f'.{str(values["AXIS_PRECISION"])}f')) def default_S_parameter( values: Values, command: str, # pylint: disable=unused-argument param: str, # pylint: disable=unused-argument param_value: PathParameter, parameters: PathParameters, # pylint: disable=unused-argument current_location: PathParameters, # pylint: disable=unused-argument ) -> str: """Process the S parameter.""" return format_for_spindle(values, param_value) def determine_adaptive_op(values: Values, pathobj) -> Tuple[bool, float, float]: """Determine if the pathobj contains an Adaptive operation.""" nl = "\n" adaptiveOp: bool = False opHorizRapid: float = 0.0 opVertRapid: float = 0.0 if values["OUTPUT_ADAPTIVE"] and "Adaptive" in pathobj.Name: adaptiveOp = True if hasattr(pathobj, "ToolController"): tc = pathobj.ToolController if hasattr(tc, "HorizRapid") and tc.HorizRapid > 0: opHorizRapid = Units.Quantity(tc.HorizRapid, Units.Velocity) else: FreeCAD.Console.PrintWarning( f"Tool Controller Horizontal Rapid Values are unset{nl}" ) if hasattr(tc, "VertRapid") and tc.VertRapid > 0: opVertRapid = Units.Quantity(tc.VertRapid, Units.Velocity) else: FreeCAD.Console.PrintWarning(f"Tool Controller Vertical Rapid Values are unset{nl}") return (adaptiveOp, opHorizRapid, opVertRapid) def drill_translate( values: Values, gcode: Gcode, command: str, params: PathParameters, motion_location: PathParameters, drill_retract_mode: str, ) -> None: """Translate drill cycles. Currently only cycles in XY are provided (G17). XZ (G18) and YZ (G19) are not dealt with. In other words only Z drilling can be translated. """ cmd: str comment: str drill_x: float drill_y: float drill_z: float motion_z: float retract_z: float F_feedrate: str G0_retract_z: str if values["MOTION_MODE"] == "G91": # force absolute coordinates during cycles gcode.append(f"{linenumber(values)}G90") drill_x = Units.Quantity(params["X"], Units.Length) drill_y = Units.Quantity(params["Y"], Units.Length) drill_z = Units.Quantity(params["Z"], Units.Length) retract_z = Units.Quantity(params["R"], Units.Length) if retract_z < drill_z: # R less than Z is error comment = create_comment(values, "Drill cycle error: R less than Z") gcode.append(f"{linenumber(values)}{comment}") return motion_z = Units.Quantity(motion_location["Z"], Units.Length) if values["MOTION_MODE"] == "G91": # relative movements drill_x += Units.Quantity(motion_location["X"], Units.Length) drill_y += Units.Quantity(motion_location["Y"], Units.Length) drill_z += motion_z retract_z += motion_z if drill_retract_mode == "G98" and motion_z >= retract_z: retract_z = motion_z cmd = format_command_line(values, ["G0", f"Z{format_for_axis(values, retract_z)}"]) G0_retract_z = f"{cmd}" cmd = format_for_feed(values, Units.Quantity(params["F"], Units.Velocity)) F_feedrate = f'{values["COMMAND_SPACE"]}F{cmd}' # preliminary movement(s) if motion_z < retract_z: gcode.append(f"{linenumber(values)}{G0_retract_z}") cmd = format_command_line( values, [ "G0", f"X{format_for_axis(values, drill_x)}", f"Y{format_for_axis(values, drill_y)}", ], ) gcode.append(f"{linenumber(values)}{cmd}") if motion_z > retract_z: # NIST GCODE 3.5.16.1 Preliminary and In-Between Motion says G0 to retract_z # Here use G1 since retract height may be below surface ! cmd = format_command_line(values, ["G1", f"Z{format_for_axis(values, retract_z)}"]) gcode.append(f"{linenumber(values)}{cmd}{F_feedrate}") # drill moves if command in ("G81", "G82"): output_G81_G82_drill_moves( values, gcode, command, params, drill_z, F_feedrate, G0_retract_z ) elif command in ("G73", "G83"): output_G73_G83_drill_moves( values, gcode, command, params, drill_z, retract_z, F_feedrate, G0_retract_z ) def format_command_line(values: Values, command_line: CommandLine) -> str: """Construct the command line for the final output.""" return values["COMMAND_SPACE"].join(command_line) def format_for_axis(values: Values, number) -> str: """Format a number using the precision for an axis value.""" return str( format( float(number.getValueAs(values["UNIT_FORMAT"])), f'.{str(values["AXIS_PRECISION"])}f', ) ) def format_for_feed(values: Values, number) -> str: """Format a number using the precision for a feed rate.""" return str( format( float(number.getValueAs(values["UNIT_SPEED_FORMAT"])), f'.{str(values["FEED_PRECISION"])}f', ) ) def format_for_spindle(values: Values, number) -> str: """Format a number using the precision for a spindle speed.""" return str(format(float(number), f'.{str(values["SPINDLE_DECIMALS"])}f')) def init_parameter_functions(parameter_functions: Dict[str, ParameterFunction]) -> None: """Initialize a list of parameter functions. These functions are called in the UtilsParse.parse_a_path function to return the appropriate parameter value. """ default_parameter_functions: Dict[str, ParameterFunction] parameter: str default_parameter_functions = { "A": default_rotary_parameter, "B": default_rotary_parameter, "C": default_rotary_parameter, "D": default_D_parameter, "E": default_length_parameter, "F": default_F_parameter, # "G" is reserved for G-code commands "H": default_int_parameter, "I": default_length_parameter, "J": default_length_parameter, "K": default_length_parameter, "L": default_int_parameter, # "M" is reserved for M-code commands # "N" is reserved for the line numbers # "O" is reserved for the line numbers for subroutines "P": default_P_parameter, "Q": default_Q_parameter, "R": default_length_parameter, "S": default_S_parameter, "T": default_int_parameter, "U": default_axis_parameter, "V": default_axis_parameter, "W": default_axis_parameter, "X": default_axis_parameter, "Y": default_axis_parameter, "Z": default_axis_parameter, # "$" is used by LinuxCNC (and others?) to designate which spindle } for parameter in default_parameter_functions: # pylint: disable=consider-using-dict-items parameter_functions[parameter] = default_parameter_functions[parameter] def linenumber(values: Values, space: Union[str, None] = None) -> str: """Output the next line number if appropriate.""" line_num: str if not values["OUTPUT_LINE_NUMBERS"]: return "" if space is None: space = values["COMMAND_SPACE"] line_num = str(values["line_number"]) values["line_number"] += values["LINE_INCREMENT"] return f"N{line_num}{space}" def output_G73_G83_drill_moves( values: Values, gcode: Gcode, command: str, params: PathParameters, drill_z: float, retract_z: float, F_feedrate: str, G0_retract_z: str, ) -> None: """Output the movement G code for G73 and G83.""" a_bit: float chip_breaker_height: float clearance_depth: float cmd: str drill_step: float last_stop_z: float next_stop_z: float last_stop_z = retract_z drill_step = Units.Quantity(params["Q"], Units.Length) # NIST 3.5.16.4 G83 Cycle: "current hole bottom, backed off a bit." a_bit = drill_step * 0.05 if drill_step != 0: while True: if last_stop_z != retract_z: # rapid move to just short of last drilling depth clearance_depth = last_stop_z + a_bit cmd = format_command_line( values, ["G0", f"Z{format_for_axis(values, clearance_depth)}"], ) gcode.append(f"{linenumber(values)}{cmd}") next_stop_z = last_stop_z - drill_step if next_stop_z > drill_z: cmd = format_command_line( values, ["G1", f"Z{format_for_axis(values, next_stop_z)}"] ) gcode.append(f"{linenumber(values)}{cmd}{F_feedrate}") if command == "G73": # Rapid up "a small amount". chip_breaker_height = next_stop_z + values["CHIPBREAKING_AMOUNT"] cmd = format_command_line( values, [ "G0", f"Z{format_for_axis(values, chip_breaker_height)}", ], ) gcode.append(f"{linenumber(values)}{cmd}") elif command == "G83": # Rapid up to the retract height gcode.append(f"{linenumber(values)}{G0_retract_z}") last_stop_z = next_stop_z else: cmd = format_command_line(values, ["G1", f"Z{format_for_axis(values, drill_z)}"]) gcode.append(f"{linenumber(values)}{cmd}{F_feedrate}") gcode.append(f"{linenumber(values)}{G0_retract_z}") break def output_G81_G82_drill_moves( values: Values, gcode: Gcode, command: str, params: PathParameters, drill_z: float, F_feedrate: str, G0_retract_z: str, ) -> None: """Output the movement G code for G81 and G82.""" cmd: str cmd = format_command_line(values, ["G1", f"Z{format_for_axis(values, drill_z)}"]) gcode.append(f"{linenumber(values)}{cmd}{F_feedrate}") # pause where applicable if command == "G82": cmd = format_command_line(values, ["G4", f'P{str(params["P"])}']) gcode.append(f"{linenumber(values)}{cmd}") gcode.append(f"{linenumber(values)}{G0_retract_z}") def parse_a_group(values: Values, gcode: Gcode, pathobj) -> None: """Parse a Group (compound, project, or simple path).""" comment: str if hasattr(pathobj, "Group"): # We have a compound or project. if values["OUTPUT_COMMENTS"]: comment = create_comment(values, f"Compound: {pathobj.Label}") gcode.append(f"{linenumber(values)}{comment}") for p in pathobj.Group: parse_a_group(values, gcode, p) else: # parsing simple path # groups might contain non-path things like stock. if not hasattr(pathobj, "Path"): return if values["OUTPUT_PATH_LABELS"] and values["OUTPUT_COMMENTS"]: comment = create_comment(values, f"Path: {pathobj.Label}") gcode.append(f"{linenumber(values)}{comment}") parse_a_path(values, gcode, pathobj) def parse_a_path(values: Values, gcode: Gcode, pathobj) -> None: """Parse a simple Path.""" adaptive_op_variables: Tuple[bool, float, float] cmd: str command: str command_line: CommandLine current_location: PathParameters = {} # keep track for no doubles drill_retract_mode: str = "G98" lastcommand: str = "" motion_location: PathParameters = {} # keep track of last motion location parameter: str parameter_value: str # Check to see if values["TOOL_BEFORE_CHANGE"] is set and value is true # doing it here to reduce the number of times it is checked swap_tool_change_order = False if "TOOL_BEFORE_CHANGE" in values and values["TOOL_BEFORE_CHANGE"]: swap_tool_change_order = True current_location.update( # the goal is to have initial values that aren't likely to match # any "real" first parameter values Path.Command( "G0", { "X": 123456789.0, "Y": 123456789.0, "Z": 123456789.0, "U": 123456789.0, "V": 123456789.0, "W": 123456789.0, "A": 123456789.0, "B": 123456789.0, "C": 123456789.0, "F": 123456789.0, }, ).Parameters ) adaptive_op_variables = determine_adaptive_op(values, pathobj) # Apply arc splitting if requested path_to_process = pathobj.Path if values["SPLIT_ARCS"]: path_to_process = PostUtils.splitArcs(path_to_process) for c in path_to_process.Commands: command = c.Name command_line = [] # Skip blank lines if requested if not command: if not values["OUTPUT_BLANK_LINES"]: continue # Modify the command name if necessary if command.startswith("("): if not values["OUTPUT_COMMENTS"]: continue if values["COMMENT_SYMBOL"] != "(" and len(command) > 2: command = create_comment(values, command[1:-1]) cmd = check_for_an_adaptive_op(values, command, command_line, adaptive_op_variables) if cmd: command = cmd # Add the command name to the command line command_line.append(command) # if modal: suppress the command if it is the same as the last one if values["MODAL"] and command == lastcommand: command_line.pop(0) # Now add the remaining parameters in order for parameter in values["PARAMETER_ORDER"]: if parameter in c.Parameters: parameter_value = values["PARAMETER_FUNCTIONS"][parameter]( values, command, parameter, c.Parameters[parameter], c.Parameters, current_location, ) if parameter_value: command_line.append(f"{parameter}{parameter_value}") set_adaptive_op_speed(values, command, command_line, c.Parameters, adaptive_op_variables) # Remember the current command lastcommand = command # Remember the current location current_location.update(c.Parameters) if command in ("G90", "G91"): # Remember the motion mode values["MOTION_MODE"] = command elif command in ("G98", "G99"): # Remember the drill retract mode for drill_translate drill_retract_mode = command if command in values["MOTION_COMMANDS"]: # Remember the current location for drill_translate motion_location.update(c.Parameters) if check_for_drill_translate( values, gcode, command, command_line, c.Parameters, motion_location, drill_retract_mode, ): command_line = [] check_for_spindle_wait(values, gcode, command, command_line) if check_for_tool_change(values, gcode, command, command_line): command_line = [] if check_for_suppressed_commands(values, gcode, command, command_line): command_line = [] if command_line: if command in ("M6", "M06") and swap_tool_change_order: swapped_command_line = [ command_line[1], command_line[0], ] # swap the order of the commands # Add a line number to the front of the command line gcode.append( f"{linenumber(values)}{format_command_line(values, swapped_command_line)}" ) else: # Add a line number to the front of the command line gcode.append(f"{linenumber(values)}{format_command_line(values, command_line)}") check_for_tlo(values, gcode, command, c.Parameters) check_for_machine_specific_commands(values, gcode, command) def set_adaptive_op_speed( values: Values, command: str, command_line: CommandLine, params: PathParameters, adaptive_op_variables: Tuple[bool, float, float], ) -> None: """Set the appropriate feed speed for an adaptive op.""" adaptiveOp: bool opHorizRapid: float opVertRapid: float param_num: str (adaptiveOp, opHorizRapid, opVertRapid) = adaptive_op_variables if ( values["OUTPUT_ADAPTIVE"] and adaptiveOp and command in values["RAPID_MOVES"] and opHorizRapid and opVertRapid ): if "Z" not in params: param_num = format_for_feed(values, opHorizRapid) else: param_num = format_for_feed(values, opVertRapid) command_line.append(f"F{param_num}")