Download src/Mod/CAM/Path/Post/scripts/snapmaker_post.py from AbdulElahGwaith/FreeCAD: direct link, hf CLI and curl.
- Browser
- Download file 35.8 kB
-
https://huggingface.co/AbdulElahGwaith/FreeCAD/resolve/main/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py
- Command line
-
hf download hf://AbdulElahGwaith/FreeCAD/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py
-
curl -L -o snapmaker_post.py https://huggingface.co/AbdulElahGwaith/FreeCAD/resolve/main/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py
35.8 kB
| #!/usr/bin/env python3 | |
| # SPDX-License-Identifier: EUPL-1.2 | |
| # A FreeCAD postprocessor targeting Snapmaker machines with CNC capabilities | |
| # *************************************************************************** | |
| # * Copyright (c) 2025 Clair-Loup Sergent <clsergent@free.fr> * | |
| # * * | |
| # * Licensed under the EUPL-1.2 with the specific provision * | |
| # * (EUPL articles 14 & 15) that the applicable law is the French law. * | |
| # * and the Jurisdiction Paris. * | |
| # * Any redistribution must include the specific provision above. * | |
| # * * | |
| # * You may obtain a copy of the Licence at: * | |
| # * https://interoperable-europe.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * | |
| # * * | |
| # * Unless required by applicable law or agreed to in writing, software * | |
| # * distributed under the Licence is distributed on an "AS IS" basis, * | |
| # * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or * | |
| # * implied. See the Licence for the specific language governing * | |
| # * permissions and limitations under the Licence. * | |
| # *************************************************************************** | |
| import argparse | |
| import base64 | |
| import copy | |
| import datetime | |
| import os | |
| import pathlib | |
| import re | |
| import tempfile | |
| from typing import Any, List, Tuple | |
| import FreeCAD | |
| import Path | |
| import Path.Base.Util as PathUtil | |
| import Path.Post.Processor | |
| import Path.Post.UtilsArguments | |
| import Path.Post.UtilsExport | |
| import Path.Post.Utils as PostUtils | |
| import Path.Post.UtilsParse | |
| import Path.Main.Job | |
| translate = FreeCAD.Qt.translate | |
| if DEBUG := False: | |
| Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) | |
| Path.Log.trackModule(Path.Log.thisModule()) | |
| else: | |
| Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) | |
| def convert_option_to_attr(option_name): | |
| # transforms argparse options into identifiers | |
| if option_name.startswith("--"): | |
| option_name = option_name[2:] | |
| elif option_name.startswith("-"): | |
| option_name = option_name[1:] | |
| return option_name.replace("-", "_") | |
| SNAPMAKER_MACHINES = dict( | |
| Original=dict( | |
| key="Original", | |
| name="Snapmaker Original", | |
| boundaries_table=[ | |
| # https://forum.snapmaker.com/t/cnc-work-area-size/5178 | |
| dict(boundaries=dict(X=125, Y=125, Z=50), toolhead="Original_CNC", mods=set()), | |
| ], | |
| lead=dict(X=8, Y=8, Z=8), # Linear module screw pitch (mm/turn) | |
| ), | |
| Original_Z_Extension=dict( | |
| key="Original_Z_Extension", | |
| name="Snapmaker Original with Z extension", | |
| boundaries_table=[ | |
| # https://forum.snapmaker.com/t/cnc-work-area-size/5178 | |
| dict(boundaries=dict(X=125, Y=125, Z=146), toolhead="Original_CNC", mods=set()), | |
| ], | |
| lead=dict(X=8, Y=8, Z=8), # Linear module screw pitch (mm/turn) | |
| ), | |
| A150=dict( | |
| key="A150", | |
| name="Snapmaker 2 A150", | |
| boundaries_table=[ | |
| # [1] https://support.snapmaker.com/hc/en-us/articles/20786910972311-FAQ-for-Bracing-Kit-for-Snapmaker-2-0-Linear-Modules#h_01HN4Z7S9WJE5BRT492WR0CKH1 | |
| dict(boundaries=dict(X=145, Y=160, Z=90), toolhead="50W_CNC", mods=set()), | |
| dict(boundaries=dict(X=145, Y=148, Z=90), toolhead="50W_CNC", mods={"BK"}), | |
| ], | |
| lead=dict(X=8, Y=8, Z=8), # Linear module screw pitch (mm/turn) | |
| ), | |
| A250=dict( | |
| key="A250", | |
| name="Snapmaker 2 A250", | |
| boundaries_table=[ | |
| # [1] https://support.snapmaker.com/hc/en-us/articles/20786910972311-FAQ-for-Bracing-Kit-for-Snapmaker-2-0-Linear-Modules#h_01HN4Z7S9WJE5BRT492WR0CKH1 | |
| dict(boundaries=dict(X=230, Y=250, Z=180), toolhead="50W_CNC", mods=set()), | |
| dict(boundaries=dict(X=230, Y=238, Z=180), toolhead="50W_CNC", mods={"BK"}), | |
| dict(boundaries=dict(X=230, Y=235, Z=180), toolhead="50W_CNC", mods={"QS"}), | |
| dict(boundaries=dict(X=230, Y=223, Z=180), toolhead="50W_CNC", mods={"BK", "QS"}), | |
| dict(boundaries=dict(X=230, Y=225, Z=180), toolhead="200W_CNC", mods={"BK"}), | |
| dict(boundaries=dict(X=230, Y=210, Z=180), toolhead="200W_CNC", mods={"BK", "QS"}), | |
| ], | |
| lead=dict(X=8, Y=8, Z=8), # Linear module screw pitch (mm/turn) | |
| ), | |
| A250T=dict( | |
| key="A250T", | |
| name="Snapmaker 2 A250T", | |
| boundaries_table=[ | |
| # [1] https://support.snapmaker.com/hc/en-us/articles/20786910972311-FAQ-for-Bracing-Kit-for-Snapmaker-2-0-Linear-Modules#h_01HN4Z7S9WJE5BRT492WR0CKH1 | |
| dict(boundaries=dict(X=230, Y=250, Z=180), toolhead="50W_CNC", mods=set()), | |
| dict(boundaries=dict(X=230, Y=238, Z=180), toolhead="50W_CNC", mods={"BK"}), | |
| dict(boundaries=dict(X=230, Y=235, Z=180), toolhead="50W_CNC", mods={"QS"}), | |
| dict(boundaries=dict(X=230, Y=223, Z=180), toolhead="50W_CNC", mods={"BK", "QS"}), | |
| dict(boundaries=dict(X=230, Y=225, Z=180), toolhead="200W_CNC", mods={"BK"}), | |
| dict(boundaries=dict(X=230, Y=210, Z=180), toolhead="200W_CNC", mods={"BK", "QS"}), | |
| ], | |
| lead=dict(X=20, Y=20, Z=8), # Linear module screw pitch (mm/turn) | |
| ), | |
| A350=dict( | |
| key="A350", | |
| name="Snapmaker 2 A350", | |
| boundaries_table=[ | |
| # [1] https://support.snapmaker.com/hc/en-us/articles/20786910972311-FAQ-for-Bracing-Kit-for-Snapmaker-2-0-Linear-Modules#h_01HN4Z7S9WJE5BRT492WR0CKH1 | |
| dict(boundaries=dict(X=320, Y=350, Z=275), toolhead="50W_CNC", mods=set()), | |
| dict(boundaries=dict(X=320, Y=338, Z=275), toolhead="50W_CNC", mods={"BK"}), | |
| dict(boundaries=dict(X=320, Y=335, Z=275), toolhead="50W_CNC", mods={"QS"}), | |
| dict(boundaries=dict(X=320, Y=323, Z=275), toolhead="50W_CNC", mods={"BK", "QS"}), | |
| dict(boundaries=dict(X=320, Y=325, Z=275), toolhead="200W_CNC", mods={"BK"}), | |
| dict(boundaries=dict(X=320, Y=310, Z=275), toolhead="200W_CNC", mods={"BK", "QS"}), | |
| ], | |
| lead=dict(X=8, Y=8, Z=8), # Linear module screw pitch (mm/turn) | |
| ), | |
| A350T=dict( | |
| key="A350T", | |
| name="Snapmaker 2 A350T", | |
| boundaries_table=[ | |
| # [1] https://support.snapmaker.com/hc/en-us/articles/20786910972311-FAQ-for-Bracing-Kit-for-Snapmaker-2-0-Linear-Modules#h_01HN4Z7S9WJE5BRT492WR0CKH1 | |
| dict(boundaries=dict(X=320, Y=350, Z=275), toolhead="50W_CNC", mods=set()), | |
| dict(boundaries=dict(X=320, Y=338, Z=275), toolhead="50W_CNC", mods={"BK"}), | |
| dict(boundaries=dict(X=320, Y=335, Z=275), toolhead="50W_CNC", mods={"QS"}), | |
| dict(boundaries=dict(X=320, Y=323, Z=275), toolhead="50W_CNC", mods={"BK", "QS"}), | |
| dict(boundaries=dict(X=320, Y=325, Z=275), toolhead="200W_CNC", mods={"BK"}), | |
| dict(boundaries=dict(X=320, Y=310, Z=275), toolhead="200W_CNC", mods={"BK", "QS"}), | |
| ], | |
| lead=dict(X=20, Y=20, Z=8), # Linear module screw pitch (mm/turn) | |
| ), | |
| Artisan=dict( | |
| key="Artisan", | |
| name="Snapmaker Artisan", | |
| boundaries_table=[ | |
| dict(boundaries=dict(X=400, Y=400, Z=400), toolhead="200W_CNC", mods=set()), | |
| ], | |
| lead=dict(X=40, Y=40, Z=8), # Linear module screw pitch (mm/turn) | |
| ), | |
| ) | |
| # These modifications were released to upgrade the Snapmaker 2.0 machines | |
| # which started on Kickstarter. | |
| SNAPMAKER_MOD_KITS = { | |
| "QS": dict( | |
| key="QS", | |
| name="Quick Swap Kit", | |
| option_name="--quick-swap", | |
| option_help_text="Indicates that the quick swap kit is installed. Only compatible with Snapmaker 2 machines.", | |
| ), | |
| "BK": dict( | |
| key="BK", | |
| name="Bracing Kit", | |
| option_name="--bracing-kit", | |
| option_help_text="Indicates that the bracing kit is installed. Only compatible with Snapmaker 2 machines.", | |
| ), | |
| } | |
| # Could support other types of toolheads (laser, drag knife, 3DP, ...) in the future | |
| # https://wiki.snapmaker.com/en/Snapmaker_Luban/manual/2_supported_gcode_references#m3m4-modified-cnclaser-on | |
| SNAPMAKER_TOOLHEADS = { | |
| "Original_CNC": dict( | |
| key="Original_CNC", | |
| name="Original CNC module", | |
| speed_rpm=dict(min=0, max=7000), | |
| boundaries_delta=dict(X=0, Y=0, Z=0), | |
| has_percent=True, | |
| has_speed_s=False, | |
| ), | |
| "50W_CNC": dict( | |
| key="50W_CNC", | |
| name="50W CNC module", | |
| speed_rpm=dict(min=0, max=12000), | |
| boundaries_delta=dict(X=0, Y=0, Z=0), | |
| has_percent=True, | |
| has_speed_s=False, | |
| ), | |
| "200W_CNC": dict( | |
| key="200W_CNC", | |
| name="200W CNC module", | |
| speed_rpm=dict(min=0, max=18000), | |
| boundaries_delta=dict(X=0, Y=-13, Z=0), | |
| has_percent=True, | |
| has_speed_s=True, | |
| ), | |
| } | |
| class CoordinatesAction(argparse.Action): | |
| """argparse Action to handle coordinates (x,y,z)""" | |
| def __call__(self, parser, namespace, values, option_string=None): | |
| match = re.match( | |
| r"^\s*(?P<X>-?\d+\.?\d*),?\s*(?P<Y>-?\d+\.?\d*),?\s*(?P<Z>-?\d+\.?\d*)\s*$", values | |
| ) | |
| if match: | |
| # setattr(namespace, self.dest, 'G0 X{0} Y{1} Z{2}'.format(*match.groups())) | |
| params = {key: float(value) for key, value in match.groupdict().items()} | |
| setattr(namespace, self.dest, params) | |
| else: | |
| raise argparse.ArgumentError(None, message="invalid coordinates provided") | |
| class ExtremaAction(argparse.Action): | |
| """argparse Action to handle integer extrema (min,max)""" | |
| def __call__(self, parser, namespace, values, option_string=None): | |
| if match := re.match(r"^ *(\d+),? *(\d+) *$", values): | |
| # setattr(namespace, self.dest, 'G0 X{0} Y{1} Z{2}'.format(*match.groups())) | |
| params = { | |
| key: int(value) | |
| for key, value in zip( | |
| ( | |
| "min", | |
| "max", | |
| ), | |
| match.groups(), | |
| ) | |
| } | |
| setattr(namespace, self.dest, params) | |
| else: | |
| raise argparse.ArgumentError(None, message="invalid values provided, should be int,int") | |
| class Snapmaker(Path.Post.Processor.PostProcessor): | |
| """FreeCAD postprocessor targeting Snapmaker machines with CNC capabilities""" | |
| def __init__(self, job) -> None: | |
| super().__init__( | |
| job=job, | |
| tooltip=translate("CAM", "Snapmaker post processor"), | |
| tooltipargs=[""], | |
| units="Metric", | |
| ) | |
| self.initialize() | |
| def initialize(self): | |
| """initialize values and arguments""" | |
| self.values: dict[str, Any] = dict() | |
| self.argument_defaults: dict[str, bool] = dict() | |
| self.arguments_visible: dict[str, bool] = dict() | |
| self.parser = argparse.ArgumentParser() | |
| self.snapmaker_init_values() | |
| self.snapmaker_init_argument_defaults() | |
| self.snapmaker_init_arguments_visible() | |
| self.parser = self.snapmaker_init_parser( | |
| self.values, self.argument_defaults, self.arguments_visible | |
| ) | |
| # create another parser with all visible arguments | |
| all_arguments_visible = dict() | |
| for key in iter(self.arguments_visible): | |
| all_arguments_visible[key] = True | |
| self.visible_parser = self.snapmaker_init_parser( | |
| self.values, self.argument_defaults, all_arguments_visible | |
| ) | |
| FreeCAD.Console.PrintLog(f'{self.values["POSTPROCESSOR_FILE_NAME"]}: initialized.\n') | |
| def snapmaker_init_values(self): | |
| """Initialize values that are used throughout the postprocessor.""" | |
| Path.Post.UtilsArguments.init_shared_values(self.values) | |
| # shared values | |
| self.values["POSTPROCESSOR_FILE_NAME"] = __name__ | |
| self.values["COMMENT_SYMBOL"] = ";" | |
| self.values["ENABLE_MACHINE_SPECIFIC_COMMANDS"] = True | |
| self.values["END_OF_LINE_CHARACTERS"] = "\n" | |
| self.values["FINISH_LABEL"] = "End" | |
| self.values["LINE_INCREMENT"] = 1 | |
| self.values["MACHINE_NAME"] = None | |
| self.values["MODAL"] = False | |
| self.values["OUTPUT_PATH_LABELS"] = True | |
| self.values["OUTPUT_HEADER"] = ( | |
| True # remove FreeCAD standard header and use a custom Snapmaker Header | |
| ) | |
| self.values["OUTPUT_TOOL_CHANGE"] = True | |
| self.values["PARAMETER_ORDER"] = [ | |
| "X", | |
| "Y", | |
| "Z", | |
| "A", | |
| "B", | |
| "C", | |
| "I", | |
| "J", | |
| "F", | |
| "S", | |
| "T", | |
| "Q", | |
| "R", | |
| "L", | |
| "H", | |
| "D", | |
| "P", | |
| "O", | |
| ] | |
| self.values["PREAMBLE"] = """G90\nG17""" | |
| self.values["PRE_OPERATION"] = """""" | |
| self.values["POST_OPERATION"] = """""" | |
| self.values["POSTAMBLE"] = """M400\nM5""" | |
| self.values["SHOW_MACHINE_UNITS"] = False | |
| self.values["SPINDLE_DECIMALS"] = 0 | |
| self.values["SPINDLE_WAIT"] = 4.0 | |
| self.values["TOOL_CHANGE"] = "M76" # handle tool change by inserting an HMI pause | |
| self.values["TRANSLATE_DRILL_CYCLES"] = True # drill cycle gcode must be translated | |
| self.values["USE_TLO"] = False # G43 is not handled. | |
| # snapmaker values | |
| self.values["THUMBNAIL"] = True | |
| self.values["BOUNDARIES"] = None | |
| self.values["BOUNDARIES_CHECK"] = False | |
| self.values["MACHINES"] = SNAPMAKER_MACHINES | |
| self.values["MOD_KITS_ALL"] = SNAPMAKER_MOD_KITS | |
| self.values["TOOLHEADS"] = SNAPMAKER_TOOLHEADS | |
| self.values["TOOLHEAD_NAME"] = None | |
| self.values["SPINDLE_SPEEDS"] = dict() | |
| self.values["SPINDLE_PERCENT"] = None | |
| def snapmaker_init_argument_defaults(self) -> None: | |
| """Initialize which arguments (in a pair) are shown as the default argument.""" | |
| Path.Post.UtilsArguments.init_argument_defaults(self.argument_defaults) | |
| self.argument_defaults["tlo"] = False | |
| self.argument_defaults["translate-drill"] = True | |
| # snapmaker arguments | |
| self.argument_defaults["thumbnail"] = True | |
| self.argument_defaults["gui"] = True | |
| self.argument_defaults["boundaries-check"] = True | |
| def snapmaker_init_arguments_visible(self) -> None: | |
| """Initialize which argument pairs are visible in TOOLTIP_ARGS.""" | |
| Path.Post.UtilsArguments.init_arguments_visible(self.arguments_visible) | |
| self.arguments_visible["axis-modal"] = False | |
| self.arguments_visible["header"] = False | |
| self.arguments_visible["return-to"] = True | |
| self.arguments_visible["tlo"] = False | |
| self.arguments_visible["tool_change"] = True | |
| self.arguments_visible["translate-drill"] = False | |
| self.arguments_visible["wait-for-spindle"] = True | |
| # snapmaker arguments (for record, always visible) | |
| self.arguments_visible["thumbnail"] = True | |
| self.arguments_visible["gui"] = True | |
| self.arguments_visible["boundaries"] = True | |
| self.arguments_visible["boundaries-check"] = True | |
| self.arguments_visible["machine"] = True | |
| self.arguments_visible["toolhead"] = True | |
| self.arguments_visible["line-increment"] = True | |
| self.arguments_visible["spindle-speeds"] = True | |
| def snapmaker_init_parser( | |
| self, values, argument_defaults, arguments_visible | |
| ) -> argparse.ArgumentParser: | |
| """Initialize the postprocessor arguments parser""" | |
| parser = Path.Post.UtilsArguments.init_shared_arguments( | |
| values, argument_defaults, arguments_visible | |
| ) | |
| # snapmaker custom arguments | |
| group = parser.add_argument_group("Snapmaker only arguments") | |
| # add_flag_type_arguments function is not used as its behavior is inconsistent with argparse | |
| # handle thumbnail generation | |
| group.add_argument( | |
| "--thumbnail", | |
| action="store_true", | |
| default=argument_defaults["thumbnail"], | |
| help="Include a thumbnail (require --gui)", | |
| ) | |
| group.add_argument( | |
| "--no-thumbnail", action="store_false", dest="thumbnail", help="Remove thumbnail" | |
| ) | |
| group.add_argument( | |
| "--gui", | |
| action="store_true", | |
| default=argument_defaults["gui"], | |
| help="allow the postprocessor to execute GUI methods", | |
| ) | |
| group.add_argument( | |
| "--no-gui", | |
| action="store_false", | |
| dest="gui", | |
| help="Execute postprocessor without requiring GUI", | |
| ) | |
| group.add_argument( | |
| "--boundaries-check", | |
| action="store_true", | |
| default=argument_defaults["boundaries-check"], | |
| help="check boundaries according to the machine build area", | |
| ) | |
| group.add_argument( | |
| "--no-boundaries-check", | |
| action="store_false", | |
| dest="boundaries_check", | |
| help="Disable boundaries check", | |
| ) | |
| group.add_argument( | |
| "--boundaries", | |
| action=CoordinatesAction, | |
| default=None, | |
| help='Custom boundaries (e.g. "100, 200, 300"). Overrides boundaries from --machine', | |
| ) | |
| group.add_argument( | |
| "--machine", | |
| default=None, | |
| required=True, | |
| choices=self.values["MACHINES"].keys(), | |
| help=f"Snapmaker machine. Choose from [{self.values['MACHINES'].keys()}].", | |
| ) | |
| for key, value in SNAPMAKER_MOD_KITS.items(): | |
| group.add_argument( | |
| value["option_name"], | |
| default=False, | |
| action="store_true", | |
| help=value["option_help_text"], | |
| ) | |
| group.add_argument( | |
| "--toolhead", | |
| default=None, | |
| choices=self.values["TOOLHEADS"].keys(), | |
| help=f"Snapmaker toolhead. Choose from [{self.values['TOOLHEADS'].keys()}].", | |
| ) | |
| group.add_argument( | |
| "--spindle-speeds", | |
| action=ExtremaAction, | |
| default=None, | |
| help="Set minimum/maximum spindle speeds as --spindle-speeds='min,max'", | |
| ) | |
| group.add_argument( | |
| "--spindle-percent", | |
| action="store_true", | |
| default=None, | |
| help="use percent as toolhead spindle speed unit (default: use RPM if supported by toolhead, otherwise percent)", | |
| ) | |
| group.add_argument( | |
| "--line-number", | |
| type=int, | |
| default=self.values["line_number"], | |
| help="Set the line starting value", | |
| ) | |
| group.add_argument( | |
| "--line-increment", | |
| type=int, | |
| default=self.values["LINE_INCREMENT"], | |
| help="Set the line increment value", | |
| ) | |
| return parser | |
| def snapmaker_process_arguments( | |
| self, filename: str = "-" | |
| ) -> Tuple[bool, str | argparse.Namespace]: | |
| """Process any arguments to the postprocessor.""" | |
| (flag, args) = Path.Post.UtilsArguments.process_shared_arguments( | |
| self.values, self.parser, self._job.PostProcessorArgs, self.visible_parser, filename | |
| ) | |
| if flag: # process extra arguments only if flag is True | |
| self._units = self.values["UNITS"] | |
| # --machine is a required "option" | |
| machine = self.values["MACHINES"][args.machine] | |
| self.values["MACHINE_KEY"] = machine["key"] | |
| self.values["MACHINE_NAME"] = machine["name"] | |
| compatible_toolheads = {bt["toolhead"] for bt in machine["boundaries_table"]} | |
| if args.toolhead: | |
| if args.toolhead not in compatible_toolheads: | |
| FreeCAD.Console.PrintError( | |
| f"Selected --toolhead={args.toolhead} is not compatible with machine {machine['name']}." | |
| + f" Choose from [{compatible_toolheads}]\n" | |
| ) | |
| flag = False | |
| return (flag, args) | |
| toolhead = self.values["TOOLHEADS"][args.toolhead] | |
| elif len(compatible_toolheads) == 1: | |
| toolhead_key = next(iter(compatible_toolheads)) | |
| toolhead = self.values["TOOLHEADS"][toolhead_key] | |
| else: | |
| FreeCAD.Console.PrintError( | |
| f"Machine {machine['name']} has multiple compatible toolheads:\n" | |
| f"{compatible_toolheads}\n" | |
| "Please add --toolhead argument.\n" | |
| ) | |
| flag = False | |
| return (flag, args) | |
| self.values["TOOLHEAD_KEY"] = toolhead["key"] | |
| self.values["TOOLHEAD_NAME"] = toolhead["name"] | |
| self.values["SPINDLE_SPEEDS"] = toolhead["speed_rpm"] | |
| if args.spindle_speeds: # may override toolhead value, which is expected | |
| self.values["SPINDLE_SPEEDS"] = args.spindle_speeds | |
| if args.spindle_percent is not None: | |
| if toolhead["has_percent"]: | |
| self.values["SPINDLE_PERCENT"] = True | |
| else: | |
| FreeCAD.Console.PrintError( | |
| f"Requested spindle speed in percent, but toolhead {toolhead['name']}" | |
| + " does not support speed as percent.\n" | |
| ) | |
| flag = False | |
| return (flag, args) | |
| else: | |
| # Prefer speed S over percent P | |
| self.values["SPINDLE_PERCENT"] = ( | |
| toolhead["has_percent"] and not toolhead["has_speed_s"] | |
| ) | |
| if self.values["SPINDLE_PERCENT"]: | |
| FreeCAD.Console.PrintWarning( | |
| "Spindle speed will be controlled using using percentages.\n" | |
| ) | |
| else: | |
| FreeCAD.Console.PrintWarning("Spindle speed will be controlled using using RPM.\n") | |
| self.values["MOD_KITS_INSTALLED"] = [] | |
| if args.boundaries: # may override machine boundaries, which is expected | |
| self.values["BOUNDARIES"] = args.boundaries | |
| self.values["MACHINE_NAME"] += " Boundaries override=" + str(args.boundaries) | |
| else: | |
| compatible_modkit_combos = [ | |
| bt["mods"] | |
| for bt in machine["boundaries_table"] | |
| if toolhead["key"] == bt["toolhead"] | |
| ] | |
| configured_modkits = set() | |
| # Determine which mod kits are requested from the options | |
| for mod_kit in self.values["MOD_KITS_ALL"].values(): | |
| if getattr(args, convert_option_to_attr(mod_kit["option_name"])): | |
| configured_modkits.add(mod_kit["key"]) | |
| self.values["MACHINE_NAME"] += " " + mod_kit["name"] | |
| self.values["MOD_KITS_INSTALLED"].append(mod_kit["key"]) | |
| if configured_modkits not in compatible_modkit_combos: | |
| FreeCAD.Console.PrintError( | |
| f"Machine {machine['name']} with toolhead {toolhead['name']}" | |
| + f" is not compatible with modkit {configured_modkits if configured_modkits else None}.\n" | |
| + f" Choose from {compatible_modkit_combos}." | |
| ) | |
| flag = False | |
| return (flag, args) | |
| # Update machine dimensions based on installed toolhead and mod kits | |
| boundaries_table_entry_l = [ | |
| bt | |
| for bt in machine["boundaries_table"] | |
| if bt["toolhead"] == toolhead["key"] and bt["mods"] == configured_modkits | |
| ] | |
| assert len(boundaries_table_entry_l) == 1 | |
| boundaries_table_entry = boundaries_table_entry_l[0] | |
| # The deepcopy is necessary to avoid modifying the boundaries in the MACHINES dict. | |
| self.values["BOUNDARIES"] = copy.deepcopy(boundaries_table_entry["boundaries"]) | |
| self.values["MACHINE_NAME"] += " " + toolhead["name"] | |
| self.values["THUMBNAIL"] = args.thumbnail | |
| self.values["ALLOW_GUI"] = args.gui | |
| self.values["line_number"] = args.line_number | |
| self.values["LINE_INCREMENT"] = args.line_increment | |
| if args.boundaries_check and not self.values["BOUNDARIES"]: | |
| FreeCAD.Console.PrintError("Boundary check skipped: no valid boundaries supplied\n") | |
| self.values["BOUNDARIES_CHECK"] = False | |
| else: | |
| self.values["BOUNDARIES_CHECK"] = args.boundaries_check | |
| return flag, args | |
| def snapmaker_process_postables(self, filename: str = "-") -> List[Tuple[str, str]]: | |
| """process job sections to gcode""" | |
| sections: List[Tuple[str, str]] = list() | |
| postables = self._buildPostList() | |
| # basic filename handling | |
| if len(postables) > 1 and filename != "-": | |
| filename = pathlib.Path(filename) | |
| filename = str(filename.with_stem(filename.stem + "_{name}")) | |
| for name, objects in postables: | |
| gcode = self.export_common(objects, filename.format(name=name)) | |
| sections.append((name, gcode)) | |
| return sections | |
| def get_thumbnail(self) -> str: | |
| """generate a thumbnail of the job from the given objects""" | |
| if self.values["THUMBNAIL"] is False: | |
| return "thumbnail: deactivated." | |
| if not (self.values["ALLOW_GUI"] and FreeCAD.GuiUp): | |
| FreeCAD.Console.PrintError( | |
| "GUI access required: thumbnail generation skipped. Consider adding --gui\n" | |
| ) | |
| return "thumbnail: GUI required." | |
| # get FreeCAD references | |
| import FreeCADGui | |
| view = FreeCADGui.activeDocument().activeView() | |
| selection = FreeCADGui.Selection | |
| # save current selection | |
| selected = [ | |
| obj.Object for obj in selection.getCompleteSelection() if hasattr(obj, "Object") | |
| ] | |
| selection.clearSelection() | |
| # clear view | |
| FreeCADGui.runCommand("Std_SelectAll", 0) | |
| all = [] | |
| for obj in selection.getCompleteSelection(): | |
| if hasattr(obj, "Object"): | |
| all.append((obj.Object, obj.Object.Visibility)) | |
| obj.Object.ViewObject.hide() | |
| # select models to display | |
| for model in self._job.Model.Group: | |
| model.ViewObject.show() | |
| selection.addSelection(model.Document.Name, model.Name) | |
| view.fitAll() # center selection | |
| view.viewIsometric() # display as isometric | |
| selection.clearSelection() | |
| # generate thumbnail | |
| with tempfile.TemporaryDirectory() as temp: | |
| path = os.path.join(temp, "thumbnail.png") | |
| view.saveImage(path, 720, 480, "Transparent") | |
| with open(path, "rb") as file: | |
| data = file.read() | |
| # restore view | |
| for obj, visibility in all: | |
| if visibility: | |
| obj.ViewObject.show() | |
| # restore selection | |
| for obj in selected: | |
| selection.clearSelection() | |
| selection.addSelection(obj.Document.Name, obj.Name) | |
| return f"thumbnail: data:image/png;base64,{base64.b64encode(data).decode()}" | |
| def output_header(self, gcode: List[str]): | |
| """custom method derived from Path.Post.UtilsExport.output_header""" | |
| cam_file: str | |
| comment: str | |
| if not self.values["OUTPUT_HEADER"]: | |
| return | |
| def add_comment(text): | |
| com = Path.Post.UtilsParse.create_comment(self.values, text) | |
| gcode.append(f"{Path.Post.UtilsParse.linenumber(self.values)}{com}") | |
| add_comment("Header Start") | |
| add_comment("header_type: cnc") | |
| add_comment(f'machine: {self.values["MACHINE_NAME"]}') | |
| comment = Path.Post.UtilsParse.create_comment( | |
| self.values, f'Post Processor: {self.values["POSTPROCESSOR_FILE_NAME"]}' | |
| ) | |
| gcode.append(f"{Path.Post.UtilsParse.linenumber(self.values)}{comment}") | |
| if FreeCAD.ActiveDocument: | |
| cam_file = os.path.basename(FreeCAD.ActiveDocument.FileName) | |
| else: | |
| cam_file = "<None>" | |
| add_comment(f"CAM File: {cam_file}") | |
| add_comment(f"Output Time: {datetime.datetime.now()}") | |
| add_comment(self.get_thumbnail()) | |
| def convert_spindle(self, gcode: List[str]) -> List[str]: | |
| """convert spindle speed values from RPM to percent (%) (M3/M4 commands)""" | |
| if self.values["SPINDLE_PERCENT"] is False: | |
| return gcode | |
| # https://wiki.snapmaker.com/en/Snapmaker_Luban/manual/2_supported_gcode_references#m3m4-modified-cnclaser-on | |
| # Speed as percentage in [0,100]% range | |
| for index, commandline in enumerate( | |
| gcode | |
| ): # .split(self.values["END_OF_LINE_CHARACTERS"]): | |
| if match := re.match(r"(?P<command>M0?[34])\D.*(?P<spindle>S\d+.?\d*)", commandline): | |
| percent = ( | |
| float(match.group("spindle")[1:]) * 100 / self.values["SPINDLE_SPEEDS"]["max"] | |
| ) | |
| gcode[index] = ( | |
| gcode[index][: match.span("spindle")[0]] | |
| + f'P{percent:.{self.values["SPINDLE_DECIMALS"]}f}' | |
| + gcode[index][match.span("spindle")[1] :] | |
| ) | |
| return gcode | |
| def check_boundaries(self, gcode: List[str]) -> bool: | |
| """Check boundaries and return whether it succeeded""" | |
| status = True | |
| FreeCAD.Console.PrintLog("Boundaries check\n") | |
| extrema = dict(X=[0, 0], Y=[0, 0], Z=[0, 0]) | |
| position = dict(X=0, Y=0, Z=0) | |
| relative = False | |
| for index, commandline in enumerate(gcode): | |
| if re.match(r"G90(?:\D|$)", commandline): | |
| relative = False | |
| elif re.match(r"G91(?:\D|$)", commandline): | |
| relative = True | |
| elif re.match(r"G0?[12](?:\D|$)", commandline): | |
| for axis, value in re.findall( | |
| r"(?P<axis>[XYZ])(?P<value>-?\d+\.?\d*)(?:\D|$)", commandline | |
| ): | |
| if relative: | |
| position[axis] += float(value) | |
| else: | |
| position[axis] = float(value) | |
| extrema[axis][0] = min(extrema[axis][0], position[axis]) | |
| extrema[axis][1] = max(extrema[axis][1], position[axis]) | |
| for axis in extrema.keys(): | |
| if abs(extrema[axis][1] - extrema[axis][0]) > self.values["BOUNDARIES"][axis]: | |
| # gcode.insert(0, f';WARNING: Boundary check: job exceeds machine limit on {axis} axis{self.values["END_OF_LINE_CHARACTERS"]}') | |
| FreeCAD.Console.PrintWarning( | |
| f"Boundary check: job exceeds machine limit on {axis} axis\n" | |
| ) | |
| status = False | |
| return status | |
| def export_common(self, objects: List, filename: str | pathlib.Path) -> str: | |
| """custom method derived from Path.Post.UtilsExport.export_common""" | |
| final: str | |
| gcode: List = [] | |
| result: bool | |
| for obj in objects: | |
| if not hasattr(obj, "Path"): | |
| print(f"The object {obj.Name} is not a path.") | |
| print("Please select only path and Compounds.") | |
| return "" | |
| Path.Post.UtilsExport.check_canned_cycles(self.values) | |
| self.output_header(gcode) | |
| Path.Post.UtilsExport.output_safetyblock(self.values, gcode) | |
| Path.Post.UtilsExport.output_tool_list(self.values, gcode, objects) | |
| Path.Post.UtilsExport.output_preamble(self.values, gcode) | |
| Path.Post.UtilsExport.output_motion_mode(self.values, gcode) | |
| Path.Post.UtilsExport.output_units(self.values, gcode) | |
| for obj in objects: | |
| # Skip inactive operations | |
| if not PathUtil.activeForOp(obj): | |
| continue | |
| coolant_mode = PathUtil.coolantModeForOp(obj) | |
| Path.Post.UtilsExport.output_start_bcnc(self.values, gcode, obj) | |
| Path.Post.UtilsExport.output_preop(self.values, gcode, obj) | |
| Path.Post.UtilsExport.output_coolant_on(self.values, gcode, coolant_mode) | |
| # output the G-code for the group (compound) or simple path | |
| Path.Post.UtilsParse.parse_a_group(self.values, gcode, obj) | |
| Path.Post.UtilsExport.output_postop(self.values, gcode, obj) | |
| Path.Post.UtilsExport.output_coolant_off(self.values, gcode, coolant_mode) | |
| Path.Post.UtilsExport.output_return_to(self.values, gcode) | |
| # | |
| # This doesn't make sense to me. It seems that both output_start_bcnc and | |
| # output_end_bcnc should be in the for loop or both should be out of the | |
| # for loop. However, that is the way that grbl post code was written, so | |
| # for now I will leave it that way until someone has time to figure it out. | |
| # | |
| Path.Post.UtilsExport.output_end_bcnc(self.values, gcode) | |
| Path.Post.UtilsExport.output_postamble_header(self.values, gcode) | |
| Path.Post.UtilsExport.output_tool_return(self.values, gcode) | |
| Path.Post.UtilsExport.output_safetyblock(self.values, gcode) | |
| Path.Post.UtilsExport.output_postamble(self.values, gcode) | |
| gcode = self.convert_spindle(gcode) | |
| if self.values["BOUNDARIES_CHECK"]: | |
| self.check_boundaries(gcode) | |
| # add the appropriate end-of-line characters to the gcode, including after the last line | |
| gcode.append("") | |
| final = self.values["END_OF_LINE_CHARACTERS"].join(gcode) | |
| if FreeCAD.GuiUp and self.values["SHOW_EDITOR"]: | |
| # size limit removed as irrelevant on my computer - see if issues occur | |
| dia = PostUtils.GCodeEditorDialog() | |
| # the editor expects lines to end in "\n", and returns lines ending in "\n" | |
| if self.values["END_OF_LINE_CHARACTERS"] == "\n": | |
| dia.editor.setPlainText(final) | |
| if dia.exec_(): | |
| final = dia.editor.toPlainText() | |
| else: | |
| final_for_editor = "\n".join(gcode) | |
| dia.editor.setPlainText(final_for_editor) | |
| if dia.exec_(): | |
| final_for_editor = dia.editor.toPlainText() | |
| # convert all "\n" to the appropriate end-of-line characters | |
| final = final_for_editor.replace("\n", self.values["END_OF_LINE_CHARACTERS"]) | |
| if not filename == "-": | |
| with open(filename, "w", encoding="utf-8", newline="") as gfile: | |
| gfile.write(final) | |
| return final | |
| def export(self, filename: str | pathlib.Path = "-"): | |
| """process gcode and export""" | |
| (flag, args) = self.snapmaker_process_arguments() | |
| if flag: | |
| return self.snapmaker_process_postables(filename) | |
| if args is None: | |
| return None | |
| return [("allitems", args)] | |
| def tooltip(self) -> str: | |
| tooltip = "Postprocessor of the FreeCAD CAM workbench for the Snapmaker machines" | |
| return tooltip | |
| def tooltipArgs(self) -> str: | |
| return self.parser.format_help() | |
| if __name__ == "__main__": | |
| Snapmaker(None).visible_parser.format_help() | |