diff --git a/src/Mod/CAM/CAMTests/TestSnapmakerPost.py b/src/Mod/CAM/CAMTests/TestSnapmakerPost.py index bfa832a438..a141271b8f 100644 --- a/src/Mod/CAM/CAMTests/TestSnapmakerPost.py +++ b/src/Mod/CAM/CAMTests/TestSnapmakerPost.py @@ -16,7 +16,9 @@ # * implied. See the Licence for the specific language governing * # * permissions and limitations under the Licence. * # *************************************************************************** +import argparse import re +from typing import List import FreeCAD @@ -63,7 +65,7 @@ class TestSnapmakerPost(PathTestUtils.PathTestBase): """Unit test tear down""" pass - def get_gcode(self, ops: [str], arguments: str) -> str: + def get_gcode(self, ops: List[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 @@ -75,10 +77,10 @@ class TestSnapmakerPost(PathTestUtils.PathTestBase): 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} +;machine: Snapmaker 2 A350 50W CNC module +;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 = """\ @@ -107,7 +109,7 @@ M5 """ # test header and body with comments - gcode = self.get_gcode([], "--machine=A350 --toolhead=50W --spindle-percent") + gcode = self.get_gcode([], "--machine=A350 --toolhead=50W_CNC") g_lines = gcode.splitlines() e_lines = expected_header.splitlines() + expected_body.splitlines() @@ -117,16 +119,14 @@ M5 if exp.startswith(";Output Time:"): self.assertTrue(re.match(exp, line) is not None) else: - self.assertTrue(line, exp) + self.assertEqual(exp, line) # test body without header - gcode = self.get_gcode([], "--machine=A350 --toolhead=50W --spindle-percent --no-header") + gcode = self.get_gcode([], "--machine=A350 --toolhead=50W_CNC --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" - ) + gcode = self.get_gcode([], "--machine=A350 --toolhead=50W_CNC --no-header --no-comments") expected = "".join( [line for line in expected_body.splitlines(keepends=True) if not line.startswith(";")] ) @@ -137,9 +137,7 @@ M5 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" - ) + gcode = self.get_gcode([command], "--machine=A350 --toolhead=50W_CNC --no-header") result = gcode.splitlines()[18] self.assertEqual(result, expected) @@ -150,7 +148,7 @@ M5 expected = "G0 X10.00 Y20.00 Z30.00" gcode = self.get_gcode( - [command], "--machine=A350 --toolhead=50W --spindle-percent --no-header --precision=2" + [command], "--machine=A350 --toolhead=50W_CNC --no-header --precision=2" ) result = gcode.splitlines()[18] self.assertEqual(result, expected) @@ -162,7 +160,7 @@ M5 gcode = self.get_gcode( [command], - "--machine=A350 --toolhead=50W --spindle-percent --no-header --line-numbers --line-number=10 --line-increment=2", + "--machine=A350 --toolhead=50W_CNC --no-header --line-numbers --line-number=10 --line-increment=2", ) result = gcode.splitlines()[18] self.assertEqual(result, expected) @@ -171,7 +169,7 @@ M5 """Test Pre-amble""" gcode = self.get_gcode( [], - "--machine=A350 --toolhead=50W --spindle-percent --no-header --preamble='G18 G55' --no-comments", + "--machine=A350 --toolhead=50W_CNC --no-header --preamble='G18 G55' --no-comments", ) result = gcode.splitlines()[0] self.assertEqual(result, "G18 G55") @@ -180,7 +178,7 @@ M5 """Test Post-amble""" gcode = self.get_gcode( [], - "--machine=A350 --toolhead=50W --spindle-percent --no-header --postamble='G0 Z50\nM2' --no-comments", + "--machine=A350 --toolhead=50W_CNC --no-header --postamble='G0 Z50\nM2' --no-comments", ) result = gcode.splitlines()[-2] self.assertEqual(result, "G0 Z50") @@ -193,9 +191,7 @@ M5 # 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" - ) + gcode = self.get_gcode([command], "--machine=A350 --toolhead=50W_CNC --no-header --inches") self.assertEqual(gcode.splitlines()[3], "G20") result = gcode.splitlines()[18] self.assertEqual(result, expected) @@ -204,7 +200,7 @@ M5 expected = "G0 X0.39 Y0.79 Z1.18" gcode = self.get_gcode( [command], - "--machine=A350 --toolhead=50W --spindle-percent --no-header --inches --precision=2", + "--machine=A350 --toolhead=50W_CNC --no-header --inches --precision=2", ) result = gcode.splitlines()[18] self.assertEqual(result, expected) @@ -217,7 +213,7 @@ M5 expected = "G0 Y30.000" gcode = self.get_gcode( - [c0, c1], "--machine=A350 --toolhead=50W --spindle-percent --no-header --axis-modal" + [c0, c1], "--machine=A350 --toolhead=50W_CNC --no-header --axis-modal" ) result = gcode.splitlines()[19] self.assertEqual(result, expected) @@ -228,35 +224,361 @@ M5 c0 = Path.Command("M6 T2") c1 = Path.Command("M3 S3000") - gcode = self.get_gcode( - [c0, c1], "--machine=A350 --toolhead=50W --spindle-percent --no-header" - ) + gcode = self.get_gcode([c0, c1], "--machine=A350 --toolhead=50W_CNC --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): + def test_models(self): + """Test the various models, and also test models that don't exist cause an error.""" + command = Path.Command("G0 X10 Y20 Z30") + expected = "G0 X10.000 Y20.000 Z30.000" + + with self.assertRaises(SystemExit): + self.get_gcode( + [command], + "--no-header", + ) + + with self.assertRaises(SystemExit): + gcode = self.get_gcode( + [command], + "--machine=robot --no-header", + ) + + gcode = self.get_gcode( + [command], + "--machine=Original --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + gcode = self.get_gcode( + [command], + "--machine=A150 --toolhead=50W_CNC --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + gcode = self.get_gcode( + [command], + "--machine=A250 --toolhead=50W_CNC --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + gcode = self.get_gcode( + [command], + "--machine=A250T --toolhead=50W_CNC --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + gcode = self.get_gcode( + [command], + "--machine=A250T --toolhead=200W_CNC --bracing-kit --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + gcode = self.get_gcode( + [command], + "--machine=A350 --toolhead=50W_CNC --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + gcode = self.get_gcode( + [command], + "--machine=A350T --toolhead=50W_CNC --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + gcode = self.get_gcode( + [command], + "--machine=A350T --toolhead=200W_CNC --bracing-kit --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + gcode = self.get_gcode( + [command], + "--machine=Artisan --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + + def test_mod_kits(self): + """Test the various mod kits against various models.""" + + # Reference for boundaries with the bracing kit and quick swap kit combinations + # [1] https://support.snapmaker.com/hc/en-us/articles/20786910972311-FAQ-for-Bracing-Kit-for-Snapmaker-2-0-Linear-Modules#h_01HN4Z7S9WJE5BRT492WR0CKH1 + # Reference for quick swap kit + # [2] https://support.snapmaker.com/hc/en-us/articles/15320624494103-Pre-sale-FAQ-for-Quick-Swap-Kit + + command = Path.Command("G0 X10 Y20 Z30") + expected = "G0 X10.000 Y20.000 Z30.000" + + gcode = self.get_gcode( + [command], + "--machine=Original --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], []) + # https://forum.snapmaker.com/t/cnc-work-area-size/5178 + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=125, Y=125, Z=50)) + + gcode = self.get_gcode( + [command], + "--machine=Original --quick-swap --no-header", + ) + # I don't understand why export returns the arguments + # if snapmaker_process_arguments fails. + self.assertTrue(isinstance(gcode, argparse.Namespace)) + self.assertFalse(isinstance(gcode, str)) + + gcode = self.get_gcode( + [command], + "--machine=Original --bracing-kit --no-header", + ) + # I don't understand why export returns the arguments + # if snapmaker_process_arguments fails. + self.assertTrue(isinstance(gcode, argparse.Namespace)) + self.assertFalse(isinstance(gcode, str)) + + # This is incompatible according to [2] + gcode = self.get_gcode( + [command], + "--machine=A150 --quick-swap --no-header", + ) + # I don't understand why export returns the arguments + # if snapmaker_process_arguments fails. + self.assertTrue(isinstance(gcode, argparse.Namespace)) + self.assertFalse(isinstance(gcode, str)) + + gcode = self.get_gcode( + [command], + "--machine=Artisan --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], []) + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=400, Y=400, Z=400)) + + gcode = self.get_gcode( + [command], + "--machine=Artisan --quick-swap --no-header", + ) + # I don't understand why export returns the arguments + # if snapmaker_process_arguments fails. + self.assertTrue(isinstance(gcode, argparse.Namespace)) + self.assertFalse(isinstance(gcode, str)) + + gcode = self.get_gcode( + [command], + "--machine=Artisan --bracing-kit --no-header", + ) + # I don't understand why export returns the arguments + # if snapmaker_process_arguments fails. + self.assertTrue(isinstance(gcode, argparse.Namespace)) + self.assertFalse(isinstance(gcode, str)) + + # This test case is covered in reference [1] + gcode = self.get_gcode( + [command], + "--machine=A150 --toolhead=50W_CNC --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], []) + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=145, Y=160, Z=90)) + + # This test case is covered in reference [1] + gcode = self.get_gcode( + [command], + "--machine=A150 --toolhead=50W_CNC --bracing-kit --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], ["BK"]) + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=145, Y=148, Z=90)) + + # This test case is covered in reference [1] + gcode = self.get_gcode( + [command], + "--machine=A250 --toolhead=50W_CNC --bracing-kit --quick-swap --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], ["QS", "BK"]) + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=230, Y=223, Z=180)) + + # This test case is covered in reference [1] + gcode = self.get_gcode( + [command], + "--machine=A250T --toolhead=50W_CNC --quick-swap --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], ["QS"]) + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=230, Y=235, Z=180)) + + # This test case is covered in reference [1] + gcode = self.get_gcode( + [command], + "--machine=A250T --toolhead=200W_CNC --bracing-kit --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], ["BK"]) + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=230, Y=225, Z=180)) + + # This test case is covered in reference [1] + gcode = self.get_gcode( + [command], + "--machine=A350 --toolhead=50W_CNC --bracing-kit --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], ["BK"]) + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=320, Y=338, Z=275)) + + # This test case is covered in reference [1] + gcode = self.get_gcode( + [command], + "--machine=A350 --toolhead=50W_CNC --quick-swap --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], ["QS"]) + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=320, Y=335, Z=275)) + + # This test case is covered in reference [1] + gcode = self.get_gcode( + [command], + "--machine=A350 --toolhead=50W_CNC --bracing-kit --quick-swap --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], ["QS", "BK"]) + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=320, Y=323, Z=275)) + + # This test case is covered in reference [1] + gcode = self.get_gcode( + [command], + "--machine=A350T --toolhead=50W_CNC --bracing-kit --quick-swap --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], ["QS", "BK"]) + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=320, Y=323, Z=275)) + + # This test case is covered in reference [1] + gcode = self.get_gcode( + [command], + "--machine=A350T --toolhead=200W_CNC --bracing-kit --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], ["BK"]) + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=320, Y=325, Z=275)) + + # This test case is covered in reference [1] + gcode = self.get_gcode( + [command], + "--machine=A350T --toolhead=200W_CNC --bracing-kit --quick-swap --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["MOD_KITS_INSTALLED"], ["QS", "BK"]) + self.assertEqual(self.post.values["BOUNDARIES"], dict(X=320, Y=310, Z=275)) + + def test_toolhead_selection(self): + """Test automatic selection of toolhead where appropriate""" + + # check succeeds + command = Path.Command("G0 X10 Y20 Z30") + expected = "G0 X10.000 Y20.000 Z30.000" + + gcode = self.get_gcode( + [command], + "--machine=Original --no-header", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["TOOLHEAD_NAME"], "Original CNC module") + + gcode = self.get_gcode( + [command], + "--machine=A350 --no-header", + ) + self.assertFalse(isinstance(gcode, str)) + + gcode = self.get_gcode( + [command], + "--machine=A350T --no-header", + ) + self.assertFalse(isinstance(gcode, str)) + + # check succeed with artisan (which base is bigger) + gcode = self.get_gcode( + [command], + "--machine=Artisan --no-header --boundaries-check", + ) + result = gcode.splitlines()[18] + self.assertEqual(result, expected) + self.assertEqual(self.post.values["TOOLHEAD_NAME"], "200W CNC module") + + def test_spindle_percent_rpm_auto_select(self): + """Test automatic selection of spindle speed rpm vs percent""" + + command = Path.Command("M3 S2100") + + # test original toolhead + gcode = self.get_gcode([command], "--machine=Original --no-header") + self.assertEqual(gcode.splitlines()[18], "M3 P30") + + command = Path.Command("M3 S3600") + + # test 50W toolhead + gcode = self.get_gcode([command], "--machine=A350 --toolhead=50W_CNC --no-header") + self.assertEqual(gcode.splitlines()[18], "M3 P30") + + # test 200W toolhead + gcode = self.get_gcode( + [command], "--machine=A350 --toolhead=200W_CNC --bracing-kit --no-header" + ) + self.assertEqual(gcode.splitlines()[18], "M3 S3600") + + # test 200W toolhead + gcode = self.get_gcode([command], "--machine=Artisan --no-header") + self.assertEqual(gcode.splitlines()[18], "M3 S3600") + + def test_spindle_percent(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" + [command], "--machine=A350 --toolhead=50W_CNC --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" + [command], + "--machine=A350 --toolhead=200W_CNC --bracing-kit --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", + "--machine=A350 --toolhead=200W_CNC --bracing-kit --spindle-percent --no-header --spindle-speeds=3000,4000", ) self.assertEqual(gcode.splitlines()[18], "M3 P90") @@ -265,7 +587,7 @@ M5 command = Path.Command("(comment)") gcode = self.get_gcode( - [command], "--machine=A350 --toolhead=50W --spindle-percent --no-header" + [command], "--machine=A350 --toolhead=50W_CNC --spindle-percent --no-header" ) result = gcode.splitlines()[18] expected = ";comment" @@ -279,7 +601,7 @@ M5 gcode = self.get_gcode( [command], - "--machine=A350 --toolhead=50W --spindle-percent --no-header --boundaries-check", + "--machine=A350 --toolhead=50W_CNC --no-header --boundaries-check", ) self.assertTrue(self.post.check_boundaries(gcode.splitlines())) @@ -288,20 +610,20 @@ M5 c1 = Path.Command("G02 Y260") gcode = self.get_gcode( [c0, c1], - "--machine=A350 --toolhead=50W --spindle-percent --no-header --boundaries-check", + "--machine=A350 --toolhead=50W_CNC --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", + "--machine=Artisan --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'", + "--machine=A350 --toolhead=50W_CNC --no-header --boundaries-check --boundaries='50,400,10'", ) self.assertFalse(self.post.check_boundaries(gcode.splitlines())) diff --git a/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py b/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py index 1b13581edc..925f406bba 100644 --- a/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py +++ b/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py @@ -9,7 +9,7 @@ # * 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 * +# * 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, * @@ -21,12 +21,13 @@ import argparse import base64 +import copy import datetime import os import pathlib import re import tempfile -from typing import Any +from typing import Any, List, Tuple import FreeCAD import Path @@ -45,18 +46,156 @@ if DEBUG := False: 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(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), + 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 = { - "50W": dict(name="50W CNC module", min=0, max=12000, percent=True), - "200W": dict(name="200W CNC module", min=8000, max=18000, percent=False), + "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, + ), } @@ -144,7 +283,7 @@ class Snapmaker(Path.Post.Processor.PostProcessor): 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["MACHINE_NAME"] = None self.values["MODAL"] = False self.values["OUTPUT_PATH_LABELS"] = True self.values["OUTPUT_HEADER"] = ( @@ -171,7 +310,7 @@ class Snapmaker(Path.Post.Processor.PostProcessor): "P", "O", ] - self.values["PREAMBLE"] = f"""G90\nG17""" + self.values["PREAMBLE"] = """G90\nG17""" self.values["PRE_OPERATION"] = """""" self.values["POST_OPERATION"] = """""" self.values["POSTAMBLE"] = """M400\nM5""" @@ -187,17 +326,11 @@ class Snapmaker(Path.Post.Processor.PostProcessor): 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 - # 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" - ] + 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.""" @@ -210,7 +343,6 @@ class Snapmaker(Path.Post.Processor.PostProcessor): self.argument_defaults["thumbnail"] = True self.argument_defaults["gui"] = True self.argument_defaults["boundaries-check"] = True - self.argument_defaults["spindle-percent"] = True def snapmaker_init_arguments_visible(self) -> None: """Initialize which argument pairs are visible in TOOLTIP_ARGS.""" @@ -286,21 +418,30 @@ class Snapmaker(Path.Post.Processor.PostProcessor): "--boundaries", action=CoordinatesAction, default=None, - help='Custom boundaries (e.g. "100, 200, 300"). Overrides --machine', + 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", + 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", + help=f"Snapmaker toolhead. Choose from [{self.values['TOOLHEADS'].keys()}].", ) group.add_argument( @@ -313,14 +454,8 @@ class Snapmaker(Path.Post.Processor.PostProcessor): 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", + default=None, + help="use percent as toolhead spindle speed unit (default: use RPM if supported by toolhead, otherwise percent)", ) group.add_argument( @@ -339,7 +474,9 @@ class Snapmaker(Path.Post.Processor.PostProcessor): return parser - def snapmaker_process_arguments(self, filename: str = "-") -> (bool, str | argparse.Namespace): + 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 @@ -347,38 +484,103 @@ class Snapmaker(Path.Post.Processor.PostProcessor): 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")} + # --machine is a required "option" + machine = self.values["MACHINES"][args.machine] + self.values["MACHINE_KEY"] = machine["key"] + self.values["MACHINE_NAME"] = machine["name"] - if args.boundaries: # may override machine boundaries, which is expected - self.values["BOUNDARIES"] = args.boundaries + 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] - self.values["TOOLHEAD_NAME"] = toolhead["name"] + elif len(compatible_toolheads) == 1: + toolhead_key = next(iter(compatible_toolheads)) + toolhead = self.values["TOOLHEADS"][toolhead_key] else: - FreeCAD.Console.PrintWarning( - f'No toolhead selected, using default ({self.values["TOOLHEAD_NAME"]}). ' - f"Consider adding --toolhead\n" + FreeCAD.Console.PrintError( + f"Machine {machine['name']} has multiple compatible toolheads:\n" + f"{compatible_toolheads}\n" + "Please add --toolhead argument.\n" ) - toolhead = self.values["TOOLHEADS"][self.values["DEFAULT_TOOLHEAD"]] + flag = False + return (flag, args) + self.values["TOOLHEAD_KEY"] = toolhead["key"] + self.values["TOOLHEAD_NAME"] = toolhead["name"] - self.values["SPINDLE_SPEEDS"] = {key: toolhead[key] for key in ("min", "max")} + 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["percent"] is True: + if toolhead["has_percent"]: 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 + 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 overide=" + 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 @@ -393,9 +595,9 @@ class Snapmaker(Path.Post.Processor.PostProcessor): return flag, args - def snapmaker_process_postables(self, filename: str = "-") -> [(str, str)]: + def snapmaker_process_postables(self, filename: str = "-") -> List[Tuple[str, str]]: """process job sections to gcode""" - sections: [(str, str)] = list() + sections: List[Tuple[str, str]] = list() postables = self._buildPostList() @@ -468,11 +670,9 @@ class Snapmaker(Path.Post.Processor.PostProcessor): return f"thumbnail: data:image/png;base64,{base64.b64encode(data).decode()}" - def output_header(self, gcode: [[]]): + def output_header(self, gcode: List[str]): """custom method derived from Path.Post.UtilsExport.output_header""" cam_file: str - comment: str - nl: str = "\n" if not self.values["OUTPUT_HEADER"]: return @@ -486,24 +686,22 @@ class Snapmaker(Path.Post.Processor.PostProcessor): 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}") + add_comment(f'Post Processor: {self.values["POSTPROCESSOR_FILE_NAME"]}') if FreeCAD.ActiveDocument: cam_file = os.path.basename(FreeCAD.ActiveDocument.FileName) else: cam_file = "" - add_comment(f"Cam File: {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]: + 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 + return gcode - # TODO: check if percentage covers range 0-max (most probable) or min-max (200W has a documented min speed) + # 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"]): @@ -518,7 +716,7 @@ class Snapmaker(Path.Post.Processor.PostProcessor): ) return gcode - def check_boundaries(self, gcode: [str]) -> bool: + def check_boundaries(self, gcode: List[str]) -> bool: """Check boundaries and return whether it succeeded""" status = True FreeCAD.Console.PrintLog("Boundaries check\n") @@ -540,11 +738,11 @@ class Snapmaker(Path.Post.Processor.PostProcessor): 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]) + 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][0] - extrema[axis][1]) > self.values["BOUNDARIES"][axis]: + 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" @@ -553,10 +751,10 @@ class Snapmaker(Path.Post.Processor.PostProcessor): return status - def export_common(self, objects: list, filename: str | pathlib.Path) -> str: + def export_common(self, objects: List, filename: str | pathlib.Path) -> str: """custom method derived from Path.Post.UtilsExport.export_common""" final: str - gcode: [[]] = [] + gcode: List = [] result: bool for obj in objects: