From ea3d78c3985de2811c497981116f88a4ea3105b3 Mon Sep 17 00:00:00 2001 From: clsergent Date: Wed, 12 Mar 2025 20:00:55 +0100 Subject: [PATCH] Added snapmaker postprocessor to CAM --- src/Mod/CAM/CAMTests/TestSnapmakerPost.py | 307 +++++++++ src/Mod/CAM/CMakeLists.txt | 2 + .../CAM/Path/Post/scripts/snapmaker_post.py | 642 ++++++++++++++++++ src/Mod/CAM/TestCAMApp.py | 2 + 4 files changed, 953 insertions(+) create mode 100644 src/Mod/CAM/CAMTests/TestSnapmakerPost.py create mode 100644 src/Mod/CAM/Path/Post/scripts/snapmaker_post.py diff --git a/src/Mod/CAM/CAMTests/TestSnapmakerPost.py b/src/Mod/CAM/CAMTests/TestSnapmakerPost.py new file mode 100644 index 0000000000..bfa832a438 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestSnapmakerPost.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +# *************************************************************************** +# * Copyright (c) 2025 Clair-Loup Sergent * +# * * +# * 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://joinup.ec.europa.eu/software/page/eupl5 * +# * * +# * 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 re + +import FreeCAD + +import Path +import CAMTests.PathTestUtils as PathTestUtils +from Path.Post.Processor import PostProcessorFactory + +Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) +Path.Log.trackModule(Path.Log.thisModule()) + + +class TestSnapmakerPost(PathTestUtils.PathTestBase): + """Test the Snapmaker postprocessor.""" + + @classmethod + def setUpClass(cls): + """Set up the test environment""" + + FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "True") + cls.doc = FreeCAD.open(FreeCAD.getHomePath() + "/Mod/CAM/CAMTests/boxtest.fcstd") + cls.job = cls.doc.getObject("Job") + cls.post = PostProcessorFactory.get_post_processor(cls.job, "snapmaker") + # locate the operation named "Profile" + for op in cls.job.Operations.Group: + if op.Label == "Profile": + # remember the "Profile" operation + cls.profile_op = op + return + + @classmethod + def tearDownClass(cls): + """Tear down the test environment""" + FreeCAD.closeDocument(cls.doc.Name) + FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") + + def setUp(self): + """Unit test init""" + # allow a full length "diff" if an error occurs + self.maxDiff = None + # reinitialize the postprocessor data structures between tests + self.post.initialize() + + def tearDown(self): + """Unit test tear down""" + pass + + def get_gcode(self, ops: [str], arguments: str) -> str: + """Get postprocessed gcode from a list of operations and postprocessor arguments""" + self.profile_op.Path = Path.Path(ops) + self.job.PostProcessorArgs = "--no-show-editor --no-gui --no-thumbnail " + arguments + return self.post.export()[0][1] + + def test_general(self): + """Test Output Generation""" + + expected_header = """\ +;Header Start +;header_type: cnc +;machine: Snapmaker 2 A350(T) +;Post Processor: Snapmaker_post +;Cam File: boxtest.fcstd +;Output Time: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{0,6} +;thumbnail: deactivated.""" + + expected_body = """\ +;Begin preamble +G90 +G17 +G21 +;Begin operation: Fixture +;Path: Fixture +G54 +;End operation: Fixture +;Begin operation: TC: Default Tool +;Path: TC: Default Tool +;TC: Default Tool +;Begin toolchange +M5 +M76 +M6 T1 +;End operation: TC: Default Tool +;Begin operation: Profile +;Path: Profile +;End operation: Profile +;Begin postamble +M400 +M5 +""" + + # test header and body with comments + gcode = self.get_gcode([], "--machine=A350 --toolhead=50W --spindle-percent") + + g_lines = gcode.splitlines() + e_lines = expected_header.splitlines() + expected_body.splitlines() + + self.assertTrue(len(g_lines), len(e_lines)) + for (nbr, exp), line in zip(enumerate(e_lines), g_lines): + if exp.startswith(";Output Time:"): + self.assertTrue(re.match(exp, line) is not None) + else: + self.assertTrue(line, exp) + + # test body without header + gcode = self.get_gcode([], "--machine=A350 --toolhead=50W --spindle-percent --no-header") + self.assertEqual(gcode, expected_body) + + # test body without comments + gcode = self.get_gcode( + [], "--machine=A350 --toolhead=50W --spindle-percent --no-header --no-comments" + ) + expected = "".join( + [line for line in expected_body.splitlines(keepends=True) if not line.startswith(";")] + ) + self.assertEqual(gcode, expected) + + def test_command(self): + """Test command Generation""" + command = Path.Command("G0 X10 Y20 Z30") + expected = "G0 X10.000 Y20.000 Z30.000" + + gcode = self.get_gcode( + [command], "--machine=A350 --toolhead=50W --spindle-percent --no-header" + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + def test_precision(self): + """Test Precision""" + # test G0 command with precision 2 digits precision + command = Path.Command("G0 X10 Y20 Z30") + expected = "G0 X10.00 Y20.00 Z30.00" + + gcode = self.get_gcode( + [command], "--machine=A350 --toolhead=50W --spindle-percent --no-header --precision=2" + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + def test_lines(self): + """Test Line Numbers""" + command = Path.Command("G0 X10 Y20 Z30") + expected = "N46 G0 X10.000 Y20.000 Z30.000" + + gcode = self.get_gcode( + [command], + "--machine=A350 --toolhead=50W --spindle-percent --no-header --line-numbers --line-number=10 --line-increment=2", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + def test_preamble(self): + """Test Pre-amble""" + gcode = self.get_gcode( + [], + "--machine=A350 --toolhead=50W --spindle-percent --no-header --preamble='G18 G55' --no-comments", + ) + result = gcode.splitlines()[0] + self.assertEqual(result, "G18 G55") + + def test_postamble(self): + """Test Post-amble""" + gcode = self.get_gcode( + [], + "--machine=A350 --toolhead=50W --spindle-percent --no-header --postamble='G0 Z50\nM2' --no-comments", + ) + result = gcode.splitlines()[-2] + self.assertEqual(result, "G0 Z50") + self.assertEqual(gcode.splitlines()[-1], "M2") + + def test_inches(self): + """Test inches conversion""" + + command = Path.Command("G0 X10 Y20 Z30") + + # test inches conversion + expected = "G0 X0.3937 Y0.7874 Z1.1811" + gcode = self.get_gcode( + [command], "--machine=A350 --toolhead=50W --spindle-percent --no-header --inches" + ) + self.assertEqual(gcode.splitlines()[3], "G20") + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + # test inches conversion with 2 digits precision + expected = "G0 X0.39 Y0.79 Z1.18" + gcode = self.get_gcode( + [command], + "--machine=A350 --toolhead=50W --spindle-percent --no-header --inches --precision=2", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + def test_axis_modal(self): + """Test axis modal - Suppress the axis coordinate if the same as previous""" + + c0 = Path.Command("G0 X10 Y20 Z30") + c1 = Path.Command("G0 X10 Y30 Z30") + expected = "G0 Y30.000" + + gcode = self.get_gcode( + [c0, c1], "--machine=A350 --toolhead=50W --spindle-percent --no-header --axis-modal" + ) + result = gcode.splitlines()[19] + self.assertEqual(result, expected) + + def test_tool_change(self): + """Test tool change""" + + c0 = Path.Command("M6 T2") + c1 = Path.Command("M3 S3000") + + gcode = self.get_gcode( + [c0, c1], "--machine=A350 --toolhead=50W --spindle-percent --no-header" + ) + self.assertEqual(gcode.splitlines()[19:22], ["M5", "M76", "M6 T2"]) + self.assertEqual( + gcode.splitlines()[22], "M3 P25" + ) # no TLO on Snapmaker (G43 inserted after tool change) + + def test_spindle(self): + """Test spindle speed conversion from RPM to percents""" + + command = Path.Command("M3 S3600") + + # test 50W toolhead + gcode = self.get_gcode( + [command], "--machine=A350 --toolhead=50W --spindle-percent --no-header" + ) + self.assertEqual(gcode.splitlines()[18], "M3 P30") + + # test 200W toolhead + gcode = self.get_gcode( + [command], "--machine=A350 --toolhead=200W --spindle-percent --no-header" + ) + self.assertEqual(gcode.splitlines()[18], "M3 P20") + + # test custom spindle speed extrema + gcode = self.get_gcode( + [command], + "--machine=A350 --toolhead=200W --spindle-percent --no-header --spindle-speeds=3000,4000", + ) + self.assertEqual(gcode.splitlines()[18], "M3 P90") + + def test_comment(self): + """Test comment""" + + command = Path.Command("(comment)") + gcode = self.get_gcode( + [command], "--machine=A350 --toolhead=50W --spindle-percent --no-header" + ) + result = gcode.splitlines()[18] + expected = ";comment" + self.assertEqual(result, expected) + + def test_boundaries(self): + """Test boundaries check""" + + # check succeeds + command = Path.Command("G0 X100 Y-100.5 Z-1") + + gcode = self.get_gcode( + [command], + "--machine=A350 --toolhead=50W --spindle-percent --no-header --boundaries-check", + ) + self.assertTrue(self.post.check_boundaries(gcode.splitlines())) + + # check fails with A350 + c0 = Path.Command("G01 X100 Y-100.5 Z-1") + c1 = Path.Command("G02 Y260") + gcode = self.get_gcode( + [c0, c1], + "--machine=A350 --toolhead=50W --spindle-percent --no-header --boundaries-check", + ) + self.assertFalse(self.post.check_boundaries(gcode.splitlines())) + + # check succeed with artisan (which base is bigger) + gcode = self.get_gcode( + [c0, c1], + "--machine=artisan --toolhead=50W --spindle-percent --no-header --boundaries-check", + ) + self.assertTrue(self.post.check_boundaries(gcode.splitlines())) + + # check fails with custom boundaries + gcode = self.get_gcode( + [c0, c1], + "--machine=A350 --toolhead=50W --spindle-percent --no-header --boundaries-check --boundaries='50,400,10'", + ) + self.assertFalse(self.post.check_boundaries(gcode.splitlines())) diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index de03de7190..5acfaab8be 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -171,6 +171,7 @@ SET(PathPythonPostScripts_SRCS Path/Post/scripts/rrf_post.py Path/Post/scripts/slic3r_pre.py Path/Post/scripts/smoothie_post.py + Path/Post/scripts/snapmaker_post.py Path/Post/scripts/uccnc_post.py Path/Post/scripts/wedm_post.py ) @@ -353,6 +354,7 @@ SET(Tests_SRCS CAMTests/TestRefactoredTestPost.py CAMTests/TestRefactoredTestPostGCodes.py CAMTests/TestRefactoredTestPostMCodes.py + CAMTests/TestSnapmakerPost.py CAMTests/Tools/Bit/test-path-tool-bit-bit-00.fctb CAMTests/Tools/Library/test-path-tool-bit-library-00.fctl CAMTests/Tools/Shape/test-path-tool-bit-shape-00.fcstd diff --git a/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py b/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py new file mode 100644 index 0000000000..80338513fb --- /dev/null +++ b/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +# A FreeCAD postprocessor targeting Snapmaker machines with CNC capabilities +# *************************************************************************** +# * Copyright (c) 2025 Clair-Loup Sergent * +# * * +# * 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://joinup.ec.europa.eu/software/page/eupl5 * +# * * +# * 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 datetime +import os +import pathlib +import re +import tempfile +from typing import Any + +import FreeCAD +import Path +import Path.Post.Processor +import Path.Post.UtilsArguments +import Path.Post.UtilsExport +import Path.Post.Utils +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()) + +SNAPMAKER_MACHINES = dict( + original=dict(name="Snapmaker Original", X=90, Y=90, Z=50), + original_z_extension=dict(name="Snapmaker Original with Z extension", X=90, Y=90, Z=146), + a150=dict(name="A150", X=160, Y=160, Z=90), + **dict.fromkeys(("A250", "A250T"), dict(name="Snapmaker 2 A250(T)", X=230, Y=250, Z=180)), + **dict.fromkeys(("A350", "A350T"), dict(name="Snapmaker 2 A350(T)", X=320, Y=350, Z=275)), + artisan=dict(name="Snapmaker Artisan", X=400, Y=400, Z=400), +) + +SNAPMAKER_TOOLHEADS = { + "50W": dict(name="50W CNC module", min=0, max=12000, percent=True), + "200W": dict(name="200W CNC module", min=8000, max=18000, percent=False), +} + + +class CoordinatesAction(argparse.Action): + """argparse Action to handle coordinates (x,y,z)""" + + def __call__(self, parser, namespace, values, option_string=None): + match = re.match( + "^\s*(?P-?\d+\.?\d*),?\s*(?P-?\d+\.?\d*),?\s*(?P-?\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("^ *(\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.init_values() + self.init_argument_defaults() + self.init_arguments_visible() + self.parser = self.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.init_parser( + self.values, self.argument_defaults, all_arguments_visible + ) + + FreeCAD.Console.PrintLog(f'{self.values["POSTPROCESSOR_FILE_NAME"]}: initialized.\n') + + def 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"] = "Generic Snapmaker" + 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"] = f"""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["TOOLHEADS"] = SNAPMAKER_TOOLHEADS + # default toolhead is 50W (the weakest one) + self.values["DEFAULT_TOOLHEAD"] = "50W" + self.values["TOOLHEAD_NAME"] = SNAPMAKER_TOOLHEADS[self.values["DEFAULT_TOOLHEAD"]]["name"] + self.values["SPINDLE_SPEEDS"] = dict( + min=SNAPMAKER_TOOLHEADS[self.values["DEFAULT_TOOLHEAD"]]["min"], + max=SNAPMAKER_TOOLHEADS[self.values["DEFAULT_TOOLHEAD"]]["max"], + ) + self.values["SPINDLE_PERCENT"] = SNAPMAKER_TOOLHEADS[self.values["DEFAULT_TOOLHEAD"]][ + "percent" + ] + + def 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 + self.argument_defaults["spindle-percent"] = True + + def 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 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 --machine', + ) + + group.add_argument( + "--machine", + default=None, + choices=self.values["MACHINES"].keys(), + help=f"Snapmaker machine", + ) + + group.add_argument( + "--toolhead", + default=None, + choices=self.values["TOOLHEADS"].keys(), + help=f"Snapmaker toolhead", + ) + + 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=argument_defaults["spindle-percent"], + help="use percent as toolhead spindle speed unit", + ) + group.add_argument( + "--spindle-rpm", + action="store_false", + dest="spindle_percent", + help="Use RPM as toolhead spindle speed unit", + ) + + 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 process_arguments(self, filename: str = "-") -> (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"] + + if args.machine: + machine = self.values["MACHINES"][args.machine] + self.values["MACHINE_NAME"] = machine["name"] + self.values["BOUNDARIES"] = {key: machine[key] for key in ("X", "Y", "Z")} + + if args.boundaries: # may override machine boundaries, which is expected + self.values["BOUNDARIES"] = args.boundaries + + if args.toolhead: + toolhead = self.values["TOOLHEADS"][args.toolhead] + self.values["TOOLHEAD_NAME"] = toolhead["name"] + else: + FreeCAD.Console.PrintWarning( + f'No toolhead selected, using default ({self.values["TOOLHEAD_NAME"]}). ' + f"Consider adding --toolhead\n" + ) + toolhead = self.values["TOOLHEADS"][self.values["DEFAULT_TOOLHEAD"]] + + self.values["SPINDLE_SPEEDS"] = {key: toolhead[key] for key in ("min", "max")} + + 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["percent"] is True: + self.values["SPINDLE_PERCENT"] = True + if args.spindle_percent is False: + FreeCAD.Console.PrintWarning( + f"Toolhead does not handle RPM spindle speed, using percents instead.\n" + ) + else: + self.values["SPINDLE_PERCENT"] = args.spindle_percent + + 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 process_postables(self, filename: str = "-") -> [(str, str)]: + """process job sections to gcode""" + sections: [(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: [[]]): + """custom method derived from Path.Post.UtilsExport.output_header""" + cam_file: str + comment: str + nl: str = "\n" + + 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}{self.values["END_OF_LINE_CHARACTERS"]}' + ) + + 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}{nl}") + if FreeCAD.ActiveDocument: + cam_file = os.path.basename(FreeCAD.ActiveDocument.FileName) + else: + cam_file = "" + 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: [str]) -> [str]: + """convert spindle speed values from RPM to percent (%) (M3/M4 commands)""" + if self.values["SPINDLE_PERCENT"] is False: + return + + # TODO: check if percentage covers range 0-max (most probable) or min-max (200W has a documented min speed) + for index, commandline in enumerate( + gcode + ): # .split(self.values["END_OF_LINE_CHARACTERS"]): + if match := re.match("(?PM0?[34])\D.*(?PS\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: [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("G90(?:\D|$)", commandline): + relative = False + elif re.match("G91(?:\D|$)", commandline): + relative = True + elif re.match("G0?[12](?:\D|$)", commandline): + for axis, value in re.findall( + "(?P[XYZ])(?P-?\d+\.?\d*)(?:\D|$)", commandline + ): + if relative: + position[axis] += float(value) + else: + position[axis] = float(value) + extrema[axis][0] = max(extrema[axis][0], position[axis]) + extrema[axis][1] = min(extrema[axis][1], position[axis]) + + for axis in extrema.keys(): + if abs(extrema[axis][0] - extrema[axis][1]) > 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: [[]] = [] + 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 hasattr(obj, "Active") and not obj.Active: + continue + if hasattr(obj, "Base") and hasattr(obj.Base, "Active") and not obj.Base.Active: + continue + coolant_mode = Path.Post.UtilsExport.determine_coolant_mode(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) + + final = "".join(gcode) + + if FreeCAD.GuiUp and self.values["SHOW_EDITOR"]: + # size limit removed as irrelevant on my computer - see if issues occur + dia = Path.Post.Utils.GCodeEditorDialog() + dia.editor.setText(final) + result = dia.exec_() + if result: + final = dia.editor.toPlainText() + + if not filename == "-": + with open( + filename, "w", encoding="utf-8", newline=self.values["END_OF_LINE_CHARACTERS"] + ) as gfile: + gfile.write(final) + + return final + + def export(self, filename: str | pathlib.Path = "-"): + """process gcode and export""" + (flag, args) = self.process_arguments() + if flag: + return self.process_postables(filename) + else: + return [("allitems", args)] + + @property + def tooltip(self) -> str: + tooltip = "Postprocessor of the FreeCAD CAM workbench for the Snapmaker machines" + return tooltip + + @property + def tooltipArgs(self) -> str: + return self.parser.format_help() + + +if __name__ == "__main__": + Snapmaker(None).visible_parser.format_help() diff --git a/src/Mod/CAM/TestCAMApp.py b/src/Mod/CAM/TestCAMApp.py index 80554918d9..da31bb3255 100644 --- a/src/Mod/CAM/TestCAMApp.py +++ b/src/Mod/CAM/TestCAMApp.py @@ -80,6 +80,7 @@ from CAMTests.TestRefactoredMach3Mach4Post import TestRefactoredMach3Mach4Post from CAMTests.TestRefactoredTestPost import TestRefactoredTestPost from CAMTests.TestRefactoredTestPostGCodes import TestRefactoredTestPostGCodes from CAMTests.TestRefactoredTestPostMCodes import TestRefactoredTestPostMCodes +from CAMTests.TestSnapmakerPost import TestSnapmakerPost # dummy usage to get flake8 and lgtm quiet False if TestCAMSanity.__name__ else True @@ -136,3 +137,4 @@ False if TestRefactoredMach3Mach4Post.__name__ else True False if TestRefactoredTestPost.__name__ else True False if TestRefactoredTestPostGCodes.__name__ else True False if TestRefactoredTestPostMCodes.__name__ else True +False if TestSnapmakerPost.__name__ else True