diff --git a/src/Mod/CAM/Path/Post/scripts/opensbp_post.py b/src/Mod/CAM/Path/Post/scripts/opensbp_post.py index 06a87ae532..85bb64ebf4 100644 --- a/src/Mod/CAM/Path/Post/scripts/opensbp_post.py +++ b/src/Mod/CAM/Path/Post/scripts/opensbp_post.py @@ -1,7 +1,7 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - # *************************************************************************** # * Copyright (c) 2014 sliptonic * +# * Copyright (c) 2022 - 2025 Larry Woestman * +# * Copyright (c) 2025 Alan Grover * # * * # * This file is part of the FreeCAD CAx development system. * # * * @@ -22,355 +22,1444 @@ # * USA * # * * # *************************************************************************** +"""FreeCAD CAM post-processor for Shopbot native opensbb, in the "refactored" style""" -import datetime -import Path.Post.Utils as PostUtils -import PathScripts.PathUtils as PathUtils -from builtins import open as pyopen +import re +import argparse +from copy import copy +import operator +import math +import time +from typing import Any -TOOLTIP = """ -This is an postprocessor file for the Path workbench. It will output path data -in a format suitable for OpenSBP controllers like shopbot. This postprocessor, -once placed in the appropriate PathScripts folder, can be used directly from -inside FreeCAD, via the GUI importer or via python scripts with: +from Path.Post.Processor import PostProcessor +import Path.Post.UtilsArguments as PostUtilsArguments +import Path.Post.UtilsExport as PostUtilsExport +import Path.Post.UtilsParse as PostUtilsParse import Path -Path.write(object,"/path/to/file.ncc","post_opensbp") -""" +import FreeCAD +import FreeCADGui -""" -DONE: - uses native commands - handles feed and jog moves - handles XY, Z, and XYZ feed speeds - handles arcs - support for inch output -ToDo - comments may not format correctly - drilling. Haven't looked at it. - many other things +translate = FreeCAD.Qt.translate -""" +DEBUG = False +if DEBUG: + 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()) -TOOLTIP_ARGS = """ -Arguments for opensbp: - --comments ... insert comments - mostly for debugging - --inches ... convert output to inches - --no-header ... suppress header output - --no-show-editor ... don't show editor, just save result -""" +# +# Define some types that are used throughout this file. +# +Defaults = dict[str | bool] +FormatHelp = str +GCodeOrNone = [str | None] +GCodeSections = list[tuple[str, GCodeOrNone]] +Parser = argparse.ArgumentParser +ParserArgs = [None | str | argparse.Namespace] +Postables = [list | list[tuple[str, list]]] +Section = tuple[str, list] +Sublist = list +Units = str +Values = dict[str, Any] +Visible = dict[str, bool] +nl = "\n" # particularly useful in a f-string +PreventGuiTimeout = 1 # seconds -now = datetime.datetime.now() - -OUTPUT_COMMENTS = False -OUTPUT_HEADER = True -SHOW_EDITOR = True -COMMAND_SPACE = "," - -# Preamble text will appear at the beginning of the GCODE output file. -PREAMBLE = """""" -# Postamble text will appear following the last operation. -POSTAMBLE = """""" - -# Pre operation text will be inserted before every operation -PRE_OPERATION = """""" - -# Post operation text will be inserted after every operation -POST_OPERATION = """""" - -# Tool Change commands will be inserted before a tool change -TOOL_CHANGE = """""" +SpeedPrecisions = {"G20": 2, "G21": 1} # for in/sec, for mm/sec +MinSpeeds = {"G20": 0.05, "G21": 1.3} # for in/sec, for mm/sec -CurrentState = {} +class Opensbp(PostProcessor): + """For ShopBot (or other opensbp controllers), this is a CAM postprocessor. + We have to translate gcode to opensbp, so the output is NOT gcode. + Typically, you'd want a .sbp file extension. + + Special behaviors: + * Many defaults have changed: read the tooltip for the Arguments + * This always tests the ShopBot app/machine units (see --inches --metric), and on the machine will give an error and exit if in the wrong units. No more mismatches. + * G54 (fixture/coordinate-system) is accepted, but is a noop (because it's the default in Operations). Others (G55 etc) are not-accepted. + * Note the default of --tool-change == False. Use that for manual tool change, but you only get 1 tool per file. True means call C9. Note: C9 seems to not work when there is no auto-tool-changer (corrupts units and erases your settings file!) + * This does arc gcodes (G02 and G03), so helical operations should work. + * This does Probe (G38.2), defaults to the same folder as the .sbp, and .txt as the extension for output filename + * Relative movement (G91) is not supported. + * opensbp commands can be in-lined via a comment: + (MC_RUN_COMMAND: ) + * By default, this post-processor fails if it sees a gcode it doesn't understand. but see --abort-on-unknown and --allow-unknown + * Do opensbp prompt (dialog-box) behavior like this: + (this comment becomes a dialog-box if followed by M00) + M00 + * --modal only applies to G00 and G01. Notably NOT to arcs. + * Note that --preamble occurs before the machine-units are set, could be a problem! (not under our control) + # preamble/postamble are G-Code. + * Most optimizations assume that the instruction stream isn't interrupted with manually added commands (e.g. assumes the xyz position from the previous instructions). So, don't stop, and insert commands if you've used any of the optimizations. + * This always sets the move & jog speeds (feed and rapid) at the beginning of each operation, by fetching the Tool speeds. If rapid-speeds are 0.0, it will not set the jog-speed, so will use the machine setting. + * For spindle speed changes, your ShopBot C6 "cut" command should be configured for having-a-speed-control-module, or for not having. It will prompt you if configured for not-having the module. + * The A and B axis are not supported + """ + + def __init__( + self, + job, + tooltip=translate("CAM", "see property below"), + tooltipargs=[""], + units="Metric", + ) -> None: + super().__init__( + job=job, + tooltip=tooltip, + tooltipargs=tooltipargs, + units=units, + ) + self.reinitialize() + Path.Log.debug("Refactored opensbp post processor initialized.") + + def reinitialize(self) -> None: + """Initialize or reinitialize the 'core' data structures for the postprocessor.""" + # + # This is also used to reinitialize the data structures between tests. + # + self.values: Values = {} + self.init_values(self.values) + # debug_str = "\n".join(f"{k}:{self.values[k]}" for k in sorted(self.values.keys())); print(f"### .values\n{debug_str}") + + self.argument_defaults: Defaults = {} + self.init_argument_defaults(self.argument_defaults) + Path.Log.debug(f".argument_defaults\n{self.argument_defaults}") + self.arguments_visible: Visible = {} + self.init_arguments_visible(self.arguments_visible) + self.parser: Parser = self.init_arguments( + self.values, self.argument_defaults, self.arguments_visible + ) + self.arguments = None # the parser.parse_args result + + # + # Create another parser just to get a list of all possible arguments + # that may be output using --output_all_arguments. + # + self.all_arguments_visible: Visible = {} + for k in iter(self.arguments_visible): + self.all_arguments_visible[k] = True + self.all_visible: Parser = self.init_arguments( + self.values, self.argument_defaults, self.all_arguments_visible + ) + + def init_values(self, values: Values) -> None: + """Initialize values that are used throughout the postprocessor.""" + # + PostUtilsArguments.init_shared_values(values) + # + # Set any values here that need to override the default values set + # in the init_shared_values routine. + # + + # Used in the argparser code as the "name" of the postprocessor program. + values["MACHINE_NAME"] = "opensbp" + + values.update( + { + "DRILL_CYCLES_TO_TRANSLATE": ["G73", "G81", "G82", "G83", "G85"], + "ENABLE_COOLANT": False, + "ENABLE_MACHINE_SPECIFIC_COMMANDS": True, + "LINE_INCREMENT": 1, + "OUTPUT_PATH_LABELS": True, + "OUTPUT_TOOL_CHANGE": False, + "OUTPUT_BLANK_LINES": False, + # 'PARAMETER_ORDER' : don't care about order, we aren't gcode + "POSTAMBLE": "", + "POSTPROCESSOR_FILE_NAME": __name__, + "PREAMBLE": "", + "SPINDLE_WAIT": 3, # for auto case + "STOP_SPINDLE_FOR_TOOL_CHANGE": True, + "SUPPRESS_COMMANDS": [ + "G54" + ], # we don't have coord-systems (yet) # G99,G98,G80 added automatically + # 'TOOL_CHANGE' : we have to generate this dynamically + "TRANSLATE_DRILL_CYCLES": True, + "UNITS": self._units, + "UNIT_SPEED_FORMAT": "mm/s", + "USE_TLO": False, + "line_number": 1, + "ALLOW_UNKNOWN": [], # --allow-unknown + "last_command": None, + "first_probe": True, + "SPEED_PRECISION": 2, # updated at process_arguments + "MIN_SPEED": 0.05, # updated at process_arguments + # these seem safe defaults + "MODAL": True, # --modal + "OUTPUT_DOUBLES": False, # --axis-modal + } + ) + # FIXME: should be done by PostProcessor, isn't there yet in 1.0. + if "G38.2" not in self.values["MOTION_COMMANDS"]: + self.values["MOTION_COMMANDS"].append("G38.2") + + def init_argument_defaults(self, argument_defaults: Defaults) -> None: + """Initialize which arguments (in a pair) are shown as the default argument.""" + # + # Modify which argument to show as the default in flag-type arguments here. + # If the value is True, the first argument will be shown as the default. + # If the value is False, the second argument will be shown as the default. + # + # For example, if you want to show Metric mode as the default, use: + # argument_defaults["metric_inch"] = True + # + # If you want to show that "Don't pop up editor for writing output" is + # the default, use: + # argument_defaults["show-editor"] = False. + PostUtilsArguments.init_argument_defaults(argument_defaults) + # + # Note: You also need to modify the corresponding entries in the "values" hash + # to actually make the default value(s) change to match. + # + + # argument_defaults['metric_inches'] = True if From doc + argument_defaults["wait-for-spindle"] = self.values["SPINDLE_WAIT"] + defaults = { + "tlo": False, + "translate_drill": True, + "tool_change": False, + "modal": True, + "no_modal": False, + "axis_modal": True, + "no_axis_modal": False, + } + for arg, v in defaults.items(): + argument_defaults[arg] = v + + def init_arguments_visible(self, arguments_visible: Visible) -> None: + """Initialize which argument pairs are visible in TOOLTIP_ARGS.""" + PostUtilsArguments.init_arguments_visible(arguments_visible) + # + # Modify the visibility of any arguments from the defaults here. + # + arguments_visible.update( + { + k: False + for k in ( + "bcnc", + "tlo", + "translate_drill", + "tool-change", + "line-numbers", + "gcode-comments", # FIXME: doesn't hide "our" arguments + "axis-modal", + "modal", + ) + } + ) + arguments_visible.update( + { + k: True + for k in ( + "return_to", + "wait-for-spindle", + ) + } + ) + + def init_arguments( + self, + values: Values, + argument_defaults: Defaults, + arguments_visible: Visible, + ) -> Parser: + """Initialize the shared argument definitions.""" + + _parser: Parser = PostUtilsArguments.init_shared_arguments( + values, argument_defaults, arguments_visible + ) + # + # Add any argument definitions that are not shared with all other + # postprocessors here. + # + _parser.add_argument( + "--o1", + action="store_true", + help="--no-comments --no-header", + ) + + # _parser.add_argument( + # # this should probably be True for most shopbot installations + # "--ab-is-distance", action="store_true", help="A & B axis are distances, default=degrees" + # ) + """ # FIXME: not-implemented + _parser.add_argument( + "--filter", + "--filters", + help="a ',' list of filters in FreeCAD.getUserMacroDir()/post to run on the gcode of each Path object, before we see it (i.e. cleanups). A class of same (camelcase) name as file, __init__(self,objectslist, filename, argstring), .filter(eachpathobj, its-.Commands) -> gcode", + ) + """ + _parser.add_argument( + "--abort-on-unknown", + action=argparse.BooleanOptionalAction, + help="Generate an error and fail if an unknown gcode is seen. default=True", + default=True, + ) + _parser.add_argument( + "--allow-unknown", + help="if --abort-on-unknown, allow these gcodes, but change them to a comment. E.g. --allow-unknown G55,G56. Always includes G54,G99,G98,G80", + ) + _parser.add_argument( + "--native-rapid-fallback", + action=argparse.BooleanOptionalAction, + help="Use machine's rapid speeds if ToolController rapid speeds are zero, default=true. --no-native-rapid-fallback causes error on zeros.", + default=True, + ) + _parser.add_argument( + "--gcode-comments", + action=argparse.BooleanOptionalAction, + # help="Add the original gcode as a comment, for debugging", + help=argparse.SUPPRESS, + default=False, + ) + + return _parser + + def process_arguments(self) -> tuple[bool, ParserArgs]: + """Process any arguments to the postprocessor.""" + # + # This function is separated out to make it easier to inherit from this postprocessor + # + args: ParserArgs + flag: bool + + (flag, args) = PostUtilsArguments.process_shared_arguments( + self.values, self.parser, self._job.PostProcessorArgs, self.all_visible, "-" + ) + # + # If the flag is True, then all of the arguments should be processed normally. + # + if flag: + # + # Process any additional arguments here. + # + # + # Update any variables that might have been modified while processing the arguments. + # + self._units = self.values["UNITS"] + self.values["UNIT_SPEED_FORMAT"] = ( + "mm/s" if self.values["UNIT_FORMAT"] == "mm" else "in/s" + ) + self.values["SPEED_PRECISION"] = SpeedPrecisions[self.values["UNITS"]] + self.values["MIN_SPEED"] = MinSpeeds[self.values["UNITS"]] + + if args.allow_unknown: + self.values["SUPPRESS_COMMANDS"].extend( + # and canonicalize + [ + re.sub(r"^([A-Z])(\d(\.|$))", r"\g<1>0\2", g) + for g in args.allow_unknown.split(",") + ] + ) + + # too late for .values, so do them by hand + arg_sets = { + "o1": { + "cli": { + "comments": False, + "no_comments": True, + "no_header": True, + "header": False, + }, + "values": {"OUTPUT_HEADER": False, "OUTPUT_COMMENTS": False}, + }, + } + + for opt in ("o1", "o2", "o3"): + if getattr(args, opt, None): + cli_set = arg_sets[opt]["cli"] + value_set = arg_sets[opt]["values"] + for arg_name, value in cli_set.items(): + Path.Log.debug(f"--{opt} set {arg_name}={value}") + setattr(args, arg_name, value) + for value_key, value in value_set.items(): + self.values[value_key] = value + + # + # If the flag is False, then args is either None (indicating an error while + # processing the arguments) or a string containing the argument list formatted + # for output. Either way the calling routine will need to handle the args value. + # + return (flag, args) + + def process_postables(self) -> GCodeSections: + """Postprocess the 'postables' in the job to g code sections.""" + # + # This function is separated out to make it easier to inherit from this postprocessor. + # + gcode: GCodeOrNone + g_code_sections: GCodeSections + partname: str + postables: Postables + section: Section + sublist: Sublist + + postables = self._buildPostList() + + Path.Log.debug(f"postables count: {len(postables)}") + + g_code_sections = [] + # a section seems to be for one output file of gcode (a "split") + for _, section in enumerate(postables): + # partname is "allitems" if not splitting, and the operation/etc. if splitting + # sublist is each "phase" of the operation, like Fixture, ToolController, Operation + partname, sublist = section + + # We have to lie about self.values to get some expanded g-code that we rely on + # e.g. we rely on comments + was_values = copy(self.values) + self.values.update( + { + "MODAL": False, # if true, this would elide the gcode "command", which screws us up. so, we do it later. + "OUTPUT_COMMENTS": True, # we use this to detect operation and... + "OUTPUT_TOOL_CHANGE": True, # we need this to ensure speeds + "SHOW_MACHINE_UNITS": True, # we have to set the machine + "AXIS_PRECISION": 5, # we do calculations, so more precision to prevent rounding errors + "FEED_PRECISION": 5, + "SPINDLE_WAIT": 0, # we'll do the logic in the right place later + } + ) + try: + # We get back a processed (expanded) set of gcode + # things like pre/post amble, canned-drill expansion, coolant on/off + # We could note where each `partname` begins, if we need that + # but we get no access to where each `sublist` starts/ends + # `gcode` is a multi-line string + gcode = PostUtilsExport.export_common(self.values, sublist, "-") + finally: + self.values = was_values + + # print(f"-gcode expanded-\n{gcode}--") + + # ToOpenSBP will modify our .values, and not restore them + was_values = copy(self.values) + try: + # Treat each `sublist` as a unit/file, so new instance + native = ToOpenSBP(self).translate(gcode) + finally: + self.values = was_values + + g_code_sections.append((partname, native)) + + return g_code_sections + + def export(self) -> GCodeSections: + """Process the parser arguments, then postprocess the 'postables'.""" + args: ParserArgs + flag: bool + + Path.Log.debug("Exporting the job") + self.reinitialize() + + (flag, args) = self.process_arguments() + # + # If the flag is True, then continue postprocessing the 'postables' + # + if flag: + self.arguments = args + Path.Log.debug(f"cli args {self._job.PostProcessorArgs}") + Path.Log.debug(f"argparsed {args}") + return self.process_postables() + # + # The flag is False meaning something unusual happened. + # + # If args is None then there was an error during argument processing. + # + if args is None: + return None + # + # Otherwise args will contain the argument list formatted for output + # instead of the "usual" gcode. + # + return [("allitems", args)] # type: ignore + + @property + def tooltip(self): + return self.__doc__ + + @property + def tooltipArgs(self) -> FormatHelp: + return self.parser.format_help() + + @property + def units(self) -> Units: + return self._units -def getMetricValue(val): - return val +def gcode(*commands): + """Only for use in class ToOpenSBP, for methods. + Decorator to add the function to the translate-map. + `commands` is list of Gcodes, i.e. "G54", "G55", + specify two digit gcode: e.g. "G01", "M06" + """ + + def gcode(func): + # we really just want to make a map with it + # But, we want the map to belong to the class + # which doesn't exist at "use" time + # so, annotate the method + # and collect/insert after the class + func._gcode = [] + for c in commands: + func._gcode.append(c) + canonical = re.sub(r"^([A-Z])(\d(\.|$))", r"\g<1>0\2", c) + if canonical != c: + raise Exception(f"Internal: Must be a 2 digit gcode (e.g. G00): @gcode('{c}')") + + # null wrapper + # FIXME: I think if we wrap it, then do obj.func(args), we'll get proper inhertance dispatch + return func + + return gcode -def getImperialValue(val): - return val / 25.4 +def gcode_insertmap(): + # see gcode() above. this inserts the map + for attr in ToOpenSBP.__dict__.values(): + if callable(attr) and hasattr(attr, "_gcode"): + for g in attr._gcode: + ToOpenSBP.DispatchMap[g] = attr -GetValue = getMetricValue +class ToOpenSBP: + """Translate gcode to opensbp""" + PositionAxis = "XYZAB" # though AB is barely supported here -def export(objectslist, filename, argstring): - global OUTPUT_COMMENTS - global OUTPUT_HEADER - global SHOW_EDITOR - global CurrentState - global GetValue + DispatchMap = {} - for arg in argstring.split(): - if arg == "--comments": - OUTPUT_COMMENTS = True - if arg == "--inches": - GetValue = getImperialValue - if arg == "--no-header": - OUTPUT_HEADER = False - if arg == "--no-show-editor": - SHOW_EDITOR = False + def __init__(self, postprocessor: Opensbp): + # we will use specific features (.values[x] and arguments from the opensbp post) + self.post = postprocessor - for obj in objectslist: - if not hasattr(obj, "Path"): - s = "the object " + obj.Name - s += " is not a path. Please select only path and Compounds." - print(s) - return + # xyzf etc state + self.current_location = {p: None for p in self.post.values["PARAMETER_ORDER"]} + # for our speed-modal + self.current_location["ms"] = ["", ""] + self.current_location["js"] = ["", ""] + self.end_location = [None for x in self.PositionAxis] - CurrentState = { - "X": 0, - "Y": 0, - "Z": 0, - "F": 0, - "S": 0, - "JSXY": 0, - "JSZ": 0, - "MSXY": 0, - "MSZ": 0, - } - print("postprocessing...") - gcode = "" + self.set_units = None # flag and memory of the first time we see a G20/G21 set-units + self._postfix = [] # balancing things to add to end + self.last_gui_update = 0 + self.first_tool = True + self.first_no_F = True - # write header - if OUTPUT_HEADER: - gcode += linenumber() + "'Exported by FreeCAD\n" - gcode += linenumber() + "'Post Processor: " + __name__ + "\n" - gcode += linenumber() + "'Output Time:" + str(now) + "\n" + def translate(self, gcode): + """Entry point, returns the translated contents, e.g. opensbp lines. + We rely on self.post to hold some state vars: we reuse self.post.values. + We have lost most context, and structuring of the gcode/job at this point, + and the export_common() has potentially inserted and translated stuff. + So, we repeat some logic from UtilsParse, like "is this a tool change?", etc. + Line-numbers are also lost, and won't match the "inspect toolpath commands" g-code. + We do have self.post._job + """ + native = "" + before_len = len(gcode.split(nl)) - # Write the preamble - if OUTPUT_COMMENTS: - gcode += linenumber() + "'(begin preamble)\n" - for line in PREAMBLE.splitlines(True): - gcode += linenumber() + line + # We reuse the postprocessor.values + # So, we have to reset some values + self.reset_values() + Path.Log.debug(f"post.values {self.post.values}") - for obj in objectslist: + for gcode_line in gcode.split(nl): - # do the pre_op - if OUTPUT_COMMENTS: - gcode += linenumber() + "'(begin operation: " + obj.Label + ")\n" - for line in PRE_OPERATION.splitlines(True): - gcode += linenumber() + line + # be nice + if time.monotonic() - self.last_gui_update >= PreventGuiTimeout: + if "Gui" in dir(FreeCADGui): + FreeCADGui.updateGui() + self.last_gui_update = time.monotonic() - gcode += parse(obj) + self.post.values["line_number"] += 1 # actual line number - # do the post_op - if OUTPUT_COMMENTS: - gcode += linenumber() + "'(finish operation: " + obj.Label + ")\n" - for line in POST_OPERATION.splitlines(True): - gcode += linenumber() + line + # we need Path.Commands to work with + path_command = self.to_path_command( + gcode_line, f"expanded gcode line {self.post.values['line_number']}" + ) + if path_command is None: + continue - # do the post_amble - if OUTPUT_COMMENTS: - gcode += "'(begin postamble)\n" - for line in POSTAMBLE.splitlines(True): - gcode += linenumber() + line - - if SHOW_EDITOR: - dia = PostUtils.GCodeEditorDialog() - dia.editor.setText(gcode) - result = dia.exec_() - if result: - final = dia.editor.toPlainText() - else: - final = gcode - else: - final = gcode - - print("done postprocessing.") - - # Write the output - if not filename == "-": - gfile = pyopen(filename, "w") - gfile.write(final) - gfile.close() - - return final - - -def move(command): - txt = "" - - # if 'F' in command.Parameters: - # txt += feedrate(command) - - axis = "" - for p in ["X", "Y", "Z"]: - if p in command.Parameters: - if command.Parameters[p] != CurrentState[p]: - axis += p - - if "F" in command.Parameters: - speed = command.Parameters["F"] - if command.Name in ["G1", "G01"]: # move - movetype = "MS" - else: # jog - movetype = "JS" - zspeed = "" - xyspeed = "" - if "Z" in axis: - speedKey = "{}Z".format(movetype) - speedVal = GetValue(speed) - if CurrentState[speedKey] != speedVal: - CurrentState[speedKey] = speedVal - zspeed = "{:f}".format(speedVal) - if ("X" in axis) or ("Y" in axis): - speedKey = "{}XY".format(movetype) - speedVal = GetValue(speed) - if CurrentState[speedKey] != speedVal: - CurrentState[speedKey] = speedVal - xyspeed = "{:f}".format(speedVal) - if zspeed or xyspeed: - txt += "{},{},{}\n".format(movetype, xyspeed, zspeed) - - if command.Name in ["G0", "G00"]: - pref = "J" - else: - pref = "M" - - if axis == "X": - txt += pref + "X" - txt += "," + format(GetValue(command.Parameters["X"]), ".4f") - txt += "\n" - elif axis == "Y": - txt += pref + "Y" - txt += "," + format(GetValue(command.Parameters["Y"]), ".4f") - txt += "\n" - elif axis == "Z": - txt += pref + "Z" - txt += "," + format(GetValue(command.Parameters["Z"]), ".4f") - txt += "\n" - elif axis == "XY": - txt += pref + "2" - txt += "," + format(GetValue(command.Parameters["X"]), ".4f") - txt += "," + format(GetValue(command.Parameters["Y"]), ".4f") - txt += "\n" - elif axis == "XZ": - txt += pref + "3" - txt += "," + format(GetValue(command.Parameters["X"]), ".4f") - txt += "," - txt += "," + format(GetValue(command.Parameters["Z"]), ".4f") - txt += "\n" - elif axis == "XYZ": - txt += pref + "3" - txt += "," + format(GetValue(command.Parameters["X"]), ".4f") - txt += "," + format(GetValue(command.Parameters["Y"]), ".4f") - txt += "," + format(GetValue(command.Parameters["Z"]), ".4f") - txt += "\n" - elif axis == "YZ": - txt += pref + "3" - txt += "," - txt += "," + format(GetValue(command.Parameters["Y"]), ".4f") - txt += "," + format(GetValue(command.Parameters["Z"]), ".4f") - txt += "\n" - elif axis == "": - print("warning: skipping duplicate move.") - else: - print(CurrentState) - print(command) - print("I don't know how to handle '{}' for a move.".format(axis)) - - return txt - - -def arc(command): - if command.Name == "G2": # CW - dirstring = "1" - else: # G3 means CCW - dirstring = "-1" - txt = "CG,," - txt += format(GetValue(command.Parameters["X"]), ".4f") + "," - txt += format(GetValue(command.Parameters["Y"]), ".4f") + "," - txt += format(GetValue(command.Parameters["I"]), ".4f") + "," - txt += format(GetValue(command.Parameters["J"]), ".4f") + "," - txt += "T" + "," - txt += dirstring - txt += "\n" - return txt - - -def tool_change(command): - txt = "" - if OUTPUT_COMMENTS: - txt += "'a tool change happens now\n" - for line in TOOL_CHANGE.splitlines(True): - txt += line - txt += "&ToolName=" + str(int(command.Parameters["T"])) - txt += "\n" - txt += "&Tool=" + str(int(command.Parameters["T"])) - txt += "\n" - return txt - - -def comment(command): - print("a comment", command) - return - - -def spindle(command): - txt = "" - if command.Name == "M3": # CW - pass - else: - pass - txt += "TR," + str(command.Parameters["S"]) + "\n" - txt += "C6\n" - txt += "PAUSE 2\n" - return txt - - -# Supported Commands -scommands = { - "G0": move, - "G1": move, - "G2": arc, - "G3": arc, - "M6": tool_change, - "M3": spindle, - "G00": move, - "G01": move, - "G02": arc, - "G03": arc, - "M06": tool_change, - "M03": spindle, - "message": comment, -} - - -def parse(pathobj): - output = "" - # Above list controls the order of parameters - - if hasattr(pathobj, "Group"): # We have a compound or project. - if OUTPUT_COMMENTS: - output += linenumber() + "'(compound: " + pathobj.Label + ")\n" - for p in pathobj.Group: - output += parse(p) - else: # parsing simple path - # groups might contain non-path things like stock. - if not hasattr(pathobj, "Path"): - return output - if OUTPUT_COMMENTS: - output += linenumber() + "'(Path: " + pathobj.Label + ")\n" - for c in PathUtils.getPathWithPlacement(pathobj).Commands: - command = c.Name - if command in scommands: - output += scommands[command](c) - if c.Parameters: - CurrentState.update(c.Parameters) - elif command.startswith("("): - output += "' " + command + "\n" + # canonicalize to 2 digits + if path_command.Name.startswith("("): + pass else: - print("I don't know what the hell the command: ", end="") - print(command + " means. Maybe I should support it.") - return output + # canonicalize to 2 digits + # All the @gcode() methods can now assume 2 digits + # We have conflated Path.Command.Name and general gcode. this allows us to expand drill, then "post process" that. + path_command.Name = re.sub(r"^([A-Z])(\d(\.|$))", r"\g<1>0\2", path_command.Name) + + # print(f"### GCODE [{self.post.values['line_number']}] {path_command.toGCode()}") + + # And now we reproduce most of UtilsParse.parse_a_path + + self.track_by_comments(path_command) + + if self.post.arguments.gcode_comments and not path_command.Name.startswith("("): + native += self.comment( + f"[{self.post.values['line_number']}] {path_command.toGCode()}" + ) + + new_location = [ + float(path_command.Parameters.get(a, self.current_location.get(a, 0.0)) or 0.0) + for a in self.PositionAxis + ] + skip_modal = False + + if ( + path_command.Name in {"G00", "G01"} + and self.post.values["MODAL"] + and new_location == self.end_location + ): + skip_modal = True + + # one place to figure out our end, used by set_speed() + if path_command.Name in self.post.values["MOTION_COMMANDS"]: + if self.post.values["MOTION_MODE"] == "G90": + self.end_location = new_location + else: + raise Exception("Relative G91 not supported yet") + # FIXME: not tested (relative mode not fully implemented): + # self.end_location = [ + # float(self.current_location[a] or 0.0) + float(path_command.Parameters.get(a, 0.0)) + # for a in self.current_location if a in self.PositionAxis + # ] + # print(f"### end at {self.end_location}") + + if skip_modal: + rez = "" + else: + # handle that gcode + # print(f"### path_command is {path_command.__class__.__name__} {path_command}") + rez = self.dispatch(path_command) + # print(f"### translated: {rez.rstrip()}") + + # append to buffer + native += rez + + if path_command.Name in self.post.values["MOTION_COMMANDS"]: + # does the right thing for position axis, for relative + for i, a in enumerate(self.PositionAxis): + self.current_location[a] = self.end_location[i] + # all other parameters (especially F) + for a, v in path_command.Parameters.items(): + if a not in self.PositionAxis: + self.current_location[a] = v + # print(f"### current {self.current_location}") + + self.post.values["last_command"] = path_command + + native += self.postfix() + + Path.Log.debug(f"GCode in {before_len} -> out {len(native.split(nl))}") + return native + + def postfix(self): + rez = "" + + if self._postfix: + rez += nl.join(reversed(self._postfix)) + rez += nl + + return rez + + def reset_values(self): + self.post.values.update( + { + "line_number": 0, + "COMMENT_SYMBOL": "'", + } + ) + + def track_by_comments(self, path_command): + """Since we've lost the Path objects structure (we only have the gcode str), + we'll track things of interest by comments here. + """ + if path_command.Name.startswith("("): + if m := ( + re.match(r"\(Path: ([^)]+)\)", path_command.Name) + or re.match(r"\(Begin operation: ([^)]+)\)", path_command.Name) + or re.match(r"\(Begin (preamble)\)", path_command.Name) + ): + self.post.values["Operation"] = m.group(1) + + def to_path_command(self, gcode: str, during: str): + """Utility, + One gcode line to a Path.Command, with some error handling + e.g. to_path_command("G0 X50", "preamble") + `during` is explanatory text if we get a conversion error + """ + + if gcode == "": + return None + + pc = Path.Command() + try: + pc.setFromGCode(gcode) + except ValueError as e: + # can't tell if it is really 'Badly formatted GCode argument', so just add our gcode to the message + message = f"During {during}, {self.location(gcode)}" + FreeCAD.Console.PrintError(message) + raise ValueError(message) from e + return pc + + def dispatch(self, path_command): + """The whole point of this class is to put the translate stuff in a modular-unit, + and translate g-codes. + So, each gcode maps to a function that handles it, + so, we use @gcode decorators to collect the gcodes and functions, + and we dispatch here. + We return a string, which might be multi-line, + or '' to mean nothing resulted + """ + command = None + if path_command.Name.startswith("("): + # sadly, Path.Command doesn't set .Name to "comment", but rather to the comment string + command = "comment" + else: + command = path_command.Name + + # Call the translate handler + if command in self.DispatchMap: + rez = self.DispatchMap[command]( + self, path_command + ) # careful, doesn't do inheritance lookup + return rez + + else: + message = f"gcode not handled at {self.location(path_command)}" + if ( + self.post.arguments.abort_on_unknown + # FIXME: cf SUPPRESS_UNKNOWN + and command not in self.post.values["ALLOW_UNKNOWN"] + ): + FreeCAD.Console.PrintError(message + "\n") + raise NotImplementedError(message) + else: + FreeCAD.Console.PrintWarning("Skipped: " + message + "\n") + return self.comment(message) + + def location(self, path_command=None): + """a message fragment of where we are, and the path_command if you want + `path_command` can be a literal-string gcode, or usually a Path.Command + """ + g = ( + f": {self.post.values['line_number']} {path_command if isinstance(path_command, str) else path_command.toGCode()}" + if path_command + else "" + ) + return f"{self.post.values['Operation']}{g}" + + def comment(self, message, force=False): + """if OUTPUT_COMMENTS, then generate the comment, + `force` ignores OUTPUT_COMMENTS and always generates. + INCLUDES the \n (nl)! + """ + # note that comments from FreeCAD and export_common are strings with "()" + # so, you can't tell what comments came from a Path operation object, or from Path/Post utils + # and the parenthesis are preservered, so the final looks like '(blah blah) + # Comments from this postprocessor are w/o (), so the final looks like 'our comment + if self.post.values["OUTPUT_COMMENTS"] or force: + return self.post.values["COMMENT_SYMBOL"] + message + nl + else: + return "" + + @gcode("comment") + def t_comment(self, path_command): + # leaves () + rez = "" + + rez += self.comment(path_command.Name) + + # fixups + # We don't have access to the Path object, and we need/want to know where we are + # e.g. probing. This should be fixed in the new "machine" style + if path_command.Name.startswith("(Post Processor: "): + rez += self.comment(" " + self.post._job.PostProcessorArgs) + elif path_command.Name.startswith("(Cam File: "): + rez += self.comment(f"Job: {self.post._job.Label}") + elif m := re.match(r"\(\s*MC_RUN_COMMAND\s+(.+)\)$", path_command.Name): + # let's leave the original as a comment (if comments are on) + rez += m.group(1) + "\n" + elif m := re.match(r"\(PROBEOPEN (.+)\)$", path_command.Name): + filename = m.group(1) + if "." not in filename: + # default .txt (really "space delimited values") + filename += ".txt" + + # the Probe operation + + # can't get &UserDataFolder to catenate properly anywhere... + # so, just filename + rez += self.comment( + "Load the My_Variables file from Custom Cut 90 in C:\\SbParts\\Custom" + ) + rez += "C#,90" + nl + # if re.match(r'[^:]+:', filename): + # # "absolute" + # rez += f'OPEN "{filename}" FOR OUTPUT as #1' + nl + # else: + # # "relative" + # rez += "GetUsrPath, &UserDataFolder" + nl + # rez += f'OPEN &UserDataFolder & "/{filename}" FOR OUTPUT as #1' + nl + rez += f'OPEN "{filename}" FOR OUTPUT as #1' + nl + + rez += "&hit = 0" + nl + # subroutines, cleanup + # but only once per post + if self.post.values["first_probe"]: + self.post.values["first_probe"] = False + self._postfix.append( + """GOTO SkipProbeSubRoutines +CaptureZPos: + ' for g38.2 probe, write the data on probe-contact + ' and set flag for didn't-fail + ' xyzab + WRITE #1; %(1); " "; %(2); " "; %(3); " "; %(4); " "; %(5) + &hit = 1 + RETURN +FailedToTouch: + ' for g38.2 probe, when + ' failed to trigger w/in movement + MSGBOX(Failed to touch...Exiting,16,Probe Failed) + END +SkipProbeSubRoutines:""" + ) + elif path_command.Name == "(PROBECLOSE)": + rez += self.comment("Clear probe-switch-trigger") + rez += "ON INPUT(&my_ZzeroInput, 1)" + nl + rez += "CLOSE #1" + nl + + return rez + + @gcode("G20", "G21") # inches, metric + def t_units(self, path_command): + if self.set_units: + raise ValueError( + "You can only set the units once, already {self.set_units['command']} at {self.set_units['at']}. You tried again at {self.location(path_command)}" + ) + else: + # remember where + self.set_units = {"command": path_command.Name, "at": self.location()} + + undesired_units = {"G20": "1", "G21": "0"}[path_command.Name] # OPPOSITE! + rez = [ + f"IF %(25) = {undesired_units} THEN GOTO WrongUnits", + ] + + pp_which = {"G20": "G20/--inches", "G21": "G21/--metric"}[path_command.Name] + self._postfix.append( + nl.join( + [ + "GOTO AfterWrongUnits", + "WrongUnits:", + ' if %(25) = 0 THEN &shopbot_which="inches"', + ' if %(25) = 1 THEN &shopbot_which="mm"', + # NB: no commas in strings! + f' MSGBOX("Post-processor wants {pp_which} but ShopBot is " & &shopbot_which & ". Change Units in ShopBot and try again.",0,"Change Units")', + " ENDALL", + "AfterWrongUnits:", + ] + ) + ) + + return nl.join(rez) + nl + + @gcode("G90") # no relative (G91) yet, have to fix modal handling for relative + def t_absolute_mode(self, path_command): + self.post.values["MOTION_MODE"] = path_command.Name + return {"G90": "SA", "G91": "SR"}[path_command.Name] + nl + + @gcode("M06") + def t_toolchange(self, path_command): + tool_number = int(path_command.Parameters["T"]) + + # check for tool actually existing + tool_controller = next( + (x for x in self.post._job.Tools.Group if x.ToolNumber == tool_number), None + ) + if not tool_controller: + # HACK: at least till 1.1, nothing enforces tool-numbers in the job to be unique + # and "Tn" doesn't have to match a ToolNumber + # we'll do a compatibility hack ONLY if all tools == 1 + if ( + all(x.ToolNumber == 1 for x in self.post._job.Tools.Group) + and len(self.post._job.Tools.Group) >= tool_number + ): + tool_controller = self.post._job.Tools.Group[tool_number - 1] + FreeCAD.Console.PrintWarning( + f"Job <{self.post._job.Label}> doesn't have unique tool-numbers? at {self.location(path_command)}" + ) + else: + raise ValueError( + f"Toolchange with non-existent tool_number {tool_number} at {self.location(path_command)}. Do tools have unique tool-numbers?" + ) + + tool_name = f"{tool_controller.Label}, {tool_controller.Tool.Label}" # not sure if we want both .Label's, just trying to help the operator + safe_tool_name = re.sub(r"[^A-Za-z0-9/_ .-]", "", tool_name) + + rez = [] + + if not self.post.values["OUTPUT_TOOL_CHANGE"]: + rez.append( + self.comment( + f"First change tool, should already be #{tool_number}: {safe_tool_name}", + force=True, + ).rstrip() + ) + + rez += [ + f"&Tool={tool_number}", + f'&ToolName="{safe_tool_name}"', + ] + + if self.post.values["OUTPUT_TOOL_CHANGE"]: + # automatic no prompt, or manual prompt (depends on correct shopbot setup) + rez.append("C9") + + else: + if self.first_tool: + self.first_tool = False + else: + raise NotImplementedError( + f"2nd tool can't be done, #{tool_number}, no way to change-tool when --no-tool-changer at {self.location(path_command)}. Try 'Order by Tool' or 'Order by Operation' in job's 'Output' tab." + ) + + rez.append(self.set_initial_speeds(tool_controller, path_command).rstrip()) + rez = nl.join((x for x in rez if x != "")) + nl + + return rez + + @gcode("G00", "G01") + def t_move(self, path_command): + """Oh boy. + opensbp specifies the xy speed, and Z speed separately for a motion. + e.g. a "VS,sxy,sz" then the move like "M3,x,y,z". + But, gcode has a F which the speed of the vector + (for rapid, it's whatever-the-machine-setting-is). + FreeCAD has horizontal speed (xy), and vertical speed (z), + which it uses to calculate F, + (we already set rapid at set_initial_speed() time for each tool-change). + Finally, we have to take the delta(x,y,z) vector and project the F speed onto xy, and z + to generate the MS command before each move command. + The Mx or Jx just uses the axis distances. + FIXME? If the first motion is not G0, and axis are 'Z' + X|Y, + (e.g. G1 X10 Y20 Z30) + then we don't know how to calculate the MS speed, + because we need to know the last-position to split F across Z & X|Y, + and there isn't one (that we know about), though we init'd to 0,0,0. + We assume a G1 happens before any other motion, to establish a position. + We could abort on this... + """ + rez = "" + + # Optimize the command, specifying 1..5 axis values + axis = [path_command.Parameters.get(a, None) for a in self.PositionAxis] + last_not_none = 0 + # XYZABC, but reversed + for i in reversed(range(0, len(axis))): + if axis[i] is not None: + last_not_none = i + break + axis = axis[: last_not_none + 1] + + if feed_rate := path_command.Parameters.get("F", None): + if path_command.Name == "G00": + if self.post.arguments.abort_on_unknown: + raise ValueError( + f"Rapid moves (G0) can't have an F at {self.location(path_command)}" + ) + + _, speed_command = self.set_speed(path_command) + rez += speed_command + + native_command = "J" if path_command.Name == "G00" else "M" + # print(f" ### is a '{native_command}'") + + # nb, we don't have to do anything for --axis-modal, handled by common stuff earlier! + + axis_ct = len(axis) + if axis_ct == 1: + native_command += "X" + else: + native_command += str(axis_ct) + + formatted_axis = ( + (format(a, f".{self.post.values['FEED_PRECISION']}f") if a is not None else "") + for a in axis + ) + rez += f"{native_command},{','.join(formatted_axis)}" + nl + + return rez + + @gcode("M03") # clockwise only. do the spindle-controlers do CCW? + def t_spindle_speed(self, path_command): + native = "" + + if "S" in path_command.Parameters: + native += f"TR,{int(path_command.Parameters['S'])}\n" # rpm units + + # macro will do the dialog-box if you don't have a controlled spindle + native += "C6\n" + + if self.post.values["SPINDLE_WAIT"] > 0: + native += f"PAUSE {int(self.post.values['SPINDLE_WAIT'])}\n" + + return native + + def format_value(self, value, precision_type="FEED_PRECISION"): + """format for the precision (e.g. AXIS_PRECISION) + notably dealing with slightly-less-than-zero == "0" not "-0" + """ + # format rounds, so duplicate that effect + if abs(value) < 0.5 * 10 ** (-self.post.values[precision_type]): + value = 0.0 + return format(value, f".{self.post.values[precision_type]}f") + + @gcode("G02", "G03") + def t_arc(self, path_command): + # only center-format: IJ + # only absolute mode + # only xy plane + # only P1 (or no P) + # cases: + # current-position is start + # Z causes helical-arc + # Pn causes arc-as-defined + (n-1) whole circles: not handled + # XY is the end-position for a segment + # none of XY means whole circle + # IJ is the location of the arc-center: an offset. at least one of is required + # F is required + + # we would have to generate multiple CG's for repetitions (P) + handled_parameters = "XYZIJFPK" # notably, not R + + not_handled = [] + not_handled = [a for a in path_command.Parameters if a not in handled_parameters] + # we handle K=0.0 by ignoring it (xy-plane), other K's we don't handle + if "K" in path_command.Parameters and path_command.Parameters["K"] != 0.0: + not_handled.append("K") + if "P" in path_command.Parameters and path_command.Parameters["P"] != 1: + not_handled.append("P") + if not_handled: + message = ( + f"We can't do parameters {not_handled} for an arc in {self.location(path_command)}" + ) + FreeCAD.Console.PrintError(message) + if self.post.arguments.abort_on_unknown: + raise ValueError(message) + else: + return "" + + if self.post.values["MOTION_MODE"] != "G90": + opname = self.post.values["Operation"].Label if self.post.values["Operation"] else "" + message = f"We can't do relative mode for arcs in [{self.post.values['line_number']}] {opname} {path_command.toGCode()}" + FreeCAD.Console.PrintError(message) + if self.post.arguments.abort_on_unknown: + raise NotImplementedError(message) + else: + return "" + + if path_command.Name == "G02": # CW + dirstring = "1" + else: # G3 means CCW + dirstring = "-1" + txt = "" + + dz, speed_command = self.set_speed(path_command) + txt += speed_command + + txt += "CG," + txt += "," # no diameter + + # end + # Omitting XY has special meaning to ShopBot, it is not the same as modal-axis + # The PostProcess code will drop the XYZ axis on --axis-modal, but we need it: + x = path_command.Parameters.get("X", self.current_location["X"]) + y = path_command.Parameters.get("Y", self.current_location["Y"]) + + txt += self.format_value(x) + "," + txt += self.format_value(y) + "," + + # Center is at offset: + txt += ( + self.format_value( + path_command.Parameters["I"] if "I" in path_command.Parameters.keys() else 0.0 + ) + + "," + ) + txt += ( + self.format_value( + path_command.Parameters["J"] if "J" in path_command.Parameters.keys() else 0.0 + ) + + "," + ) + txt += "T" + "," # move on diameter + txt += dirstring + "," + + if "Z" not in path_command.Parameters: + dz = 0 + + # Z causes a helical, "causes the defined plunge to be made gradually as the cutter is circling down" + # Note, dz is actual distance vector, but ShopBot uses -dz to mean "plunge" relative + txt += self.format_value(-dz) + "," + + txt += "," # repetitions + txt += "," # proportion-x + txt += "," # proportion-y + + if dz != 0.0: + # helical cases + # we don't do "bottom pass" (4) because FreeCAD seems to do that and it's not a g-code thing anyway + if "X" not in path_command.Parameters and "Y" not in path_command.Parameters: + # circle + feature = 3 # spiral + else: + feature = 3 # spiral + else: + feature = 0 + + txt += f"{feature}," + txt += "1," # continue the CG plunging (don't pull up) + txt += "0" # no move before plunge + + # actual Z, opensbp plunge is a delta, note the actual Z as a comment + z = path_command.Parameters.get("Z", self.current_location["Z"]) + txt += " ' Z" + self.format_value(z) + txt += "\n" + return txt + + @gcode("M00", "M01") + def t_prompt(self, command): + # Prompt with "Continue?" and pause, wait for user-interaction + # If a comment precedes M00, that is used as the prompt (to emulate opensbp behavior) + + txt = "" + if not self.post.values["last_command"].Name.startswith("("): + # default prompt + where = [] + if self.post._job: + where.append(f"<{self.post._job.Label}>") + if self.post.values["Operation"]: + where.append(f"<{self.post.values['Operation']}>") + txt += self.comment(f"Continue {'.'.join(where)}?", force=True) + elif not self.post.values["OUTPUT_COMMENTS"]: + # Force inclusion of that preceding comment as a prompt + txt += self.comment(self.post.values["last_command"].Name, force=True) + txt += "PAUSE\n" + return txt + + @gcode("M05") + def t_stop_spindle(self, command): + return "C7\n" + + @gcode("M02", "M30") + def t_stop(self, command): + return "END\n" + + @gcode("M08") + def t_coolant_on(self, command): + return "SO,3,1\n" + + @gcode("M09") + def t_cooland_off(self, command): + return "SO,3,0\n" + + @gcode("G38.2") + def t_probe(self, command): + + speed = command.Parameters.get("F", None) + if speed is not None: + speed = float(speed) + if speed == 0.0: + FreeCAD.Console.PrintWarning( + f"G38.2 with an F0.0, set Tool speeds? at {self.location(command)}\n" + ) + return "" + if speed is None: + speed = self.current_location["ms"][1] # out of [ xy, z ] + + axis = " ".join( + [f"{a}{command.Parameters[a]}" for a in self.PositionAxis if a in command.Parameters] + ) + + # PROBEOPEN sets up the contact-detect, so G38.2 are just moves + rez = "" + + rez += "&hit = 0" + nl # for did-we-hit OR fail + + # for probing, we have to setup the on-input for every move + rez += "ON INPUT(&my_ZzeroInput, 1) GOSUB CaptureZPos" + nl + + g = f"G01 F{speed} {axis}" + rez += self.t_move(Path.Command(g)) + + # and check for fail to contact + rez += "IF &hit = 0 THEN GOTO FailedToTouch\n" + + return rez + + def set_speed(self, path_command): + # For non-rapid, F applies to the vector of all the axis + # For rapid, full speed on the axis from the toolchange settings + # (so no output here) + # Projects F into XY plane, and Z plane for shopbot + # Always uses "VS" command to not "punctuate" the stack (ms/js will cause ramp up/down in speed) + # Elides VS values if not change or 0.0 (shopbot doesn't like 0) + # Elides trailing , + # Elides whole VS if no values + + if path_command.Name == "G00": + # Actually, we just use the full speed on xy and z axis + # which was initialized at toolchange time + # the args to vs would be: VS,xy,z,a,b,xy_job,z_job,a_job,b_jog + return (0, "") + + # we output VS, but this lets us track MS vs JS + which_speed = "MS" + + last_position = [float(self.current_location[a] or 0) for a in self.PositionAxis] + + def fmt_diff(l): + return [(f"{p:9.3f}" if p is not None else f"{str(p):9s}") for p in l] + + # Linear move + if path_command.Name == "G01": + d_axis = list(map(operator.sub, self.end_location, last_position)) + + squared_d_axis = [v**2 for v in d_axis] + distance = math.sqrt(sum(squared_d_axis)) + z_distance = abs(d_axis[2]) + xy_distance = math.sqrt(sum(squared_d_axis[:2])) + axis = [a for a in self.PositionAxis if a in path_command.Parameters] + + # Arcs + elif path_command.Name in {"G02", "G03"}: + + def arc_length_3d(center, start, end, clockwise): + """center, start, end: (x, y, z) tuples + clockwise: True for G2, False for G3 + Returns the true 3D arc length. + """ + + cx, cy, cz = center + sx, sy, sz = start + ex, ey, ez = end + + # ---- linear Z interpolation ---- + dz = ez - sz + + # ---- XY arc angle ---- + r = math.hypot(sx - cx, sy - cy) + + a0 = math.atan2(sy - cy, sx - cx) + a1 = math.atan2(ey - cy, ex - cx) + + dtheta = a1 - a0 + + if dtheta == 0: + dtheta = 2 * math.pi + elif clockwise: + if dtheta > 0: + dtheta -= 2 * math.pi + else: + if dtheta < 0: + dtheta += 2 * math.pi + + arc_xy = abs(r * dtheta) + + # ---- true helical arc length ---- + return (arc_xy, math.hypot(arc_xy, dz)) + + start_position = [float(self.current_location[a] or 0) for a in "XYZ"] + center_offset = [ + float(path_command.Parameters.get(a, None) or 0) for a in "IJK" + ] # k always 0 + end_position = [ + float(path_command.Parameters.get(k, start_position[i])) + for i, k in enumerate("XYZ") + ] + z_distance = start_position[2] - end_position[2] + + # If the XY is omitted, it means a whole circle, and arc-length-3d will give that + xy_distance, distance = arc_length_3d( + map(operator.add, start_position, center_offset), # center + start_position, + end_position, + path_command.Name == "G02", # clockwise? + ) + + axis = list("XYZ") + + distances_for_speed = [abs(xy_distance), abs(z_distance)] # abs(xy) shouldn't be necessary + + if path_command.Name == "G00": + # see above, we don't get to here on G0 + # Rapid speed is the full speed on xy, and z + # no projecting on to the vector + # FIXME: AB not handled yet + speeds = self.current_location["js"] + + # feed motions + else: + # FIXME: AB speeds not handled yet + + f = path_command.Parameters.get("F", None) + if f is None: + f = self.current_location["F"] + # print(f" ### no f, last= '{f}'") + + if f is None: + # No F and no previous, which is not good. default to machine's feed speeds. + f = None + if self.first_no_F: + FreeCAD.Console.PrintWarning( + f"No F, and no previous F speed at {self.location(path_command)}. Using tool's feed speeds.\n" + ) + self.first_no_F = False + speeds = ["", ""] + + else: + # have a F/previous: use it + f = float(f) + + # if only in xy, or only in z, then speed=F + if xy_distance != 0.0 and z_distance == 0.0: + speeds = [f, 0.0] + elif xy_distance == 0.0 and z_distance != 0.0: + speeds = [0.0, f] + + else: + # FIXME: AB not handled yet + speeds = [ + ((f * d / distance) if distance != 0 else 0) for d in distances_for_speed + ] + # print(f" ### speed w/xyz {speeds}") + # print(f" ### speeds {speeds}") + + min_speed = self.post.values["MIN_SPEED"] + + def gtmin(s): + if s == "": + return s + elif abs(s) >= min_speed: + return s + elif s == 0.0: + return "" + elif abs(s) < min_speed: + return min_speed * (-1 if s < 0 else 1) + # that's all the cases + + speeds = [gtmin(s) for s in speeds] + speeds = [ + (format(s, f'.{self.post.values["SPEED_PRECISION"]}f') if s != "" else "") + for s in speeds + ] + + if which_speed == "MS": + # axis-modal against actual output (formatted to SPEED_PRECISION) + # and save as current + + non_elided = copy(speeds) + + if True: # speed-modal always true + # compare to previous 'ms' speeds, so we can skip 'MS' if nothing changes + for i, new_speed in enumerate(speeds): + old_speed = self.current_location[which_speed.lower()][i] + if old_speed == new_speed: + speeds[i] = "" + + # save it for next time, for --speed-modal + self.current_location["ms"] = non_elided + + # cleans up trailing , when trailing speeds elided + if which_speed == "MS": + cmd = f"VS,{','.join(speeds)}".rstrip(",") + elif which_speed == "JS": + # again, shouldn't get here in this version of the code + cmd = f"VS,,,,,{','.join(speeds)}".rstrip(",") + + # If there is no speed to set (e.g. the move ends up as delta-0), no MS needed + if cmd == "VS": + cmd = "" + else: + cmd += nl + return (z_distance, cmd) + + def set_initial_speeds(self, tool_controller, path_command): + # need to ensure initial values for speeds + # rapid-speed is never emitted by gcode, but we need to set it! + # and we just set the initial speed for "feed" too + + Path.Log.debug(f"Setspeeds {tool_controller.Label}") + native = "" + + native += self.comment(f"set speeds: {tool_controller.Label}") + speeds = { + "ms": [], # xy,z + "js": [], # xy,z + } + + def append_speed(which_speed, which_speed_key): + with_units = getattr(tool_controller, which_speed) + speed = float(with_units.getValueAs(self.post.values["UNIT_SPEED_FORMAT"])) + + if abs(speed) >= 0.5 * 10 ** (-self.post.values["SPEED_PRECISION"]): # i.e. not zero + + formatted = format(speed, f'.{self.post.values["SPEED_PRECISION"]}f') + + speeds[which_speed_key].append(formatted) + return None + else: + FreeCAD.Console.PrintWarning( + f"ToolController <{self.post._job.Label}>.<{tool_controller.Label}> did not set {which_speed} speed, set the HorizFeed and VertFeed. ( for {self.location(path_command)} )\n" + ) + speeds[which_speed_key].append("") + if which_speed.endswith("Rapid"): + warn_rapid.append(which_speed) + return self.comment(f"no {which_speed}", force=True) + + warn_rapid = [] # empty is no warning + + # tool's speeds -> speeds[ 'ms' & 'js' ] + for which_speed_key, which_speed_properties in { + "ms": ["HorizFeed", "VertFeed"], + "js": ["HorizRapid", "VertRapid"], + }.items(): + for which_speed in which_speed_properties: # by property for warning messages + comment = append_speed(which_speed, which_speed_key) + if comment is not None: + native += comment + + # for --speed-modal + self.current_location[which_speed_key] = speeds[which_speed_key] + + # add to command-stream + command_prefix = which_speed_key.upper() + command = (f"{command_prefix}," + ",".join(speeds[which_speed_key])).rstrip(",") + if command != command_prefix: # has actual speeds + native += command + "\n" + + # fixme: where to get A&B values? + # speeds['ms'].append('') # a-move-speed + # speeds['ms'].append('') # b-move-speed + + if warn_rapid: + if not self.post.arguments.native_rapid_fallback: + raise ValueError( + f"ToolController <{self.post._job.Label}>.<{tool_controller.Label}> did not set xy&z rapid speeds, and you specified --no-native-rapid-fallback. Set the rapid speeds. {self.location(path_command)}" + ) + else: + FreeCAD.Console.PrintWarning( + f'Using machine\'s rapid ("jog") for {" and ".join(warn_rapid)}, for ToolController <{self.post._job.Label}>.<{tool_controller.Label}>\n' + ) + + Path.Log.debug(f"setspeeds {speeds}") + + return native -def linenumber(): - return "" - - -# print(__name__ + " gcode postprocessor loaded.") +gcode_insertmap() # fixup DispatchMap