diff --git a/src/Mod/CAM/CAMTests/PostTestMocks.py b/src/Mod/CAM/CAMTests/PostTestMocks.py new file mode 100644 index 0000000000..479d0e7080 --- /dev/null +++ b/src/Mod/CAM/CAMTests/PostTestMocks.py @@ -0,0 +1,155 @@ +# *************************************************************************** +# * Copyright (c) 2025 sliptonic * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Mock objects for postprocessor testing. + +This module provides mock objects that simulate FreeCAD CAM job structure +without requiring disk I/O or loading actual FreeCAD documents. +""" + +import Path + + +class MockToolController: + """Mock ToolController for operations.""" + + def __init__( + self, + tool_number=1, + label="TC: Default Tool", + spindle_speed=1000, + spindle_dir="Forward", + ): + self.ToolNumber = tool_number + self.Label = label + self.SpindleSpeed = spindle_speed + self.SpindleDir = spindle_dir + self.Name = f"TC{tool_number}" + + # Create a simple path with tool change commands + self.Path = Path.Path() + self.Path.addCommands( + [Path.Command(f"M6 T{tool_number}"), Path.Command(f"M3 S{spindle_speed}")] + ) + + def InList(self): + return [] + + +class MockOperation: + """Mock Operation object for testing postprocessors.""" + + def __init__(self, name="Operation", label=None, tool_controller=None, active=True): + self.Name = name + self.Label = label or name + self.Active = active + self.ToolController = tool_controller + + # Create an empty path by default + self.Path = Path.Path() + + def InList(self): + """Mock InList - operations belong to a job.""" + return [] + + +class MockStock: + """Mock Stock object with BoundBox.""" + + def __init__(self, xmin=0.0, xmax=100.0, ymin=0.0, ymax=100.0, zmin=0.0, zmax=10.0): + self.Shape = type( + "obj", + (object,), + { + "BoundBox": type( + "obj", + (object,), + { + "XMin": xmin, + "XMax": xmax, + "YMin": ymin, + "YMax": ymax, + "ZMin": zmin, + "ZMax": zmax, + }, + )() + }, + )() + + +class MockSetupSheet: + """Mock SetupSheet object.""" + + def __init__(self, clearance_height=5.0, safe_height=3.0): + self.ClearanceHeightOffset = type("obj", (object,), {"Value": clearance_height})() + self.SafeHeightOffset = type("obj", (object,), {"Value": safe_height})() + + +class MockJob: + """Mock Job object for testing postprocessors.""" + + def __init__(self): + # Create mock Stock with BoundBox + self.Stock = MockStock() + + # Create mock SetupSheet + self.SetupSheet = MockSetupSheet() + + # Create mock Operations group + self.Operations = type("obj", (object,), {"Group": []})() + + # Create mock Tools group + self.Tools = type("obj", (object,), {"Group": []})() + + # Create mock Model group + self.Model = type("obj", (object,), {"Group": []})() + + # Basic properties + self.Label = "MockJob" + self.PostProcessor = "" + self.PostProcessorArgs = "" + self.PostProcessorOutputFile = "" + self.Fixtures = ["G54"] + self.OrderOutputBy = "Tool" + self.SplitOutput = False + + def InList(self): + """Mock InList for fixture setup.""" + return [] + + +def create_default_job_with_operation(): + """Create a mock job with a default tool controller and operation. + + This is a convenience function for common test scenarios. + Returns: (job, operation, tool_controller) + """ + job = MockJob() + + # Create default tool controller + tc = MockToolController(tool_number=1, label="TC: Default Tool", spindle_speed=1000) + job.Tools.Group = [tc] + + # Create default operation + op = MockOperation(name="Profile", label="Profile", tool_controller=tc) + job.Operations.Group = [op] + + return job, op, tc diff --git a/src/Mod/CAM/CAMTests/TestCentroidLegacyPost.py b/src/Mod/CAM/CAMTests/TestCentroidLegacyPost.py new file mode 100644 index 0000000000..1efedb82dd --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestCentroidLegacyPost.py @@ -0,0 +1,326 @@ +# *************************************************************************** +# * Copyright (c) 2022 sliptonic * +# * Copyright (c) 2022 Larry Woestman * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +from importlib import reload + +import FreeCAD + +import Path +from CAMTests import PathTestUtils +from Path.Post.scripts import centroid_legacy_post as postprocessor + + +Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) +Path.Log.trackModule(Path.Log.thisModule()) + + +class TestCentroidLegacyPost(PathTestUtils.PathTestBase): + """Test suite for the Centroid legacy postprocessor. + + This class verifies the functionality of the centroid_legacy_post postprocessor, + which generates G-code for Centroid CNC controllers. Tests cover: + + - Output generation with various options (header, comments, editor suppression) + - Command formatting and coordinate precision control + - Line numbering + - Preamble and postamble generation + - Unit conversion (metric/imperial) + - Modal command suppression + - Axis modal (coordinate suppression for unchanged axes) + - Tool change command generation with tool length offset (TLO) + - Comment formatting and conversion + + The legacy postprocessor has limited configurability compared to newer + postprocessors, and some command-line options are tested to verify they + don't break the default behavior. + """ + + @classmethod + def setUpClass(cls): + """setUpClass()... + This method is called upon instantiation of this test class. Add code + and objects here that are needed for the duration of the test() methods + in this class. In other words, set up the 'global' test environment + here; use the `setUp()` method to set up a 'local' test environment. + This method does not have access to the class `self` reference, but it + is able to call static methods within this same class. + """ + + # Open existing FreeCAD document with test geometry + FreeCAD.newDocument("Unnamed") + + @classmethod + def tearDownClass(cls): + """tearDownClass()... + This method is called prior to destruction of this test class. Add + code and objects here that cleanup the test environment after the + test() methods in this class have been executed. This method does + not have access to the class `self` reference. This method + is able to call static methods within this same class. + """ + # Close geometry document without saving + FreeCAD.closeDocument(FreeCAD.ActiveDocument.Name) + + # Setup and tear down methods called before and after each unit test + def setUp(self): + """setUp()... + This method is called prior to each `test()` method. Add code and + objects here that are needed for multiple `test()` methods. + """ + self.doc = FreeCAD.ActiveDocument + self.con = FreeCAD.Console + self.docobj = FreeCAD.ActiveDocument.addObject("Path::Feature", "testpath") + reload( + postprocessor + ) # technical debt. This shouldn't be necessary but here to bypass a bug + + def tearDown(self): + """tearDown()... + This method is called after each test() method. Add cleanup instructions here. + Such cleanup instructions will likely undo those in the setUp() method. + """ + FreeCAD.ActiveDocument.removeObject("testpath") + + def test000(self): + """Test Output Generation. + Empty path. Produces only the preamble and postable. + """ + + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + + # Test generating with header + # Header contains a time stamp that messes up unit testing. + # Only test length of result. + args = "--no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(len(gcode.splitlines()), 16) + + # Test without header + expected = """G90 G80 G40 G49 +;begin preamble +G53 G00 G17 +G21 +;begin operation +;end operation: testpath +;begin postamble +M5 +M25 +G49 H0 +G90 G80 G40 G49 +M99 +""" + + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode, expected) + + # test without comments + expected = """G90 G80 G40 G49 +G53 G00 G17 +G21 +M5 +M25 +G49 H0 +G90 G80 G40 G49 +M99 +""" + + args = "--no-header --no-comments --no-show-editor" + # args = ("--no-header --no-comments --no-show-editor --precision=2") + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode, expected) + + def test010(self): + """Test command Generation. + Test Precision + """ + c = Path.Command("G0 X10 Y20 Z30") + + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = "G0 X10.0000 Y20.0000 Z30.0000" + self.assertEqual(result, expected) + + args = "--no-header --axis-precision=2 --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = "G0 X10.00 Y20.00 Z30.00" + self.assertEqual(result, expected) + + def test020(self): + """ + Test Line Numbers + """ + c = Path.Command("G0 X10 Y20 Z30") + + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --line-numbers --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = "N150 G0 X10.0000 Y20.0000 Z30.0000" + self.assertEqual(result, expected) + + def test030(self): + """ + Test Pre-amble + """ + + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + + # + # The original centroid postprocessor does not have a + # --preamble option. We end up with the default preamble. + # + args = "--no-header --no-comments --preamble='G18 G55' --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[1] + self.assertEqual(result, "G53 G00 G17") + + def test040(self): + """ + Test Post-amble + """ + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + # + # The original centroid postprocessor does not have a + # --postamble option. We end up with the default postamble. + # + args = "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode.splitlines()[-1], "M99") + + def test050(self): + """ + Test inches + """ + + c = Path.Command("G0 X10 Y20 Z30") + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --inches --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode.splitlines()[3], "G20") + + result = gcode.splitlines()[5] + expected = "G0 X0.3937 Y0.7874 Z1.1811" + self.assertEqual(result, expected) + + args = "--no-header --inches --axis-precision=2 --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = "G0 X0.39 Y0.79 Z1.18" + self.assertEqual(result, expected) + + def test060(self): + """ + Test test modal + Suppress the command name if the same as previous + """ + c = Path.Command("G0 X10 Y20 Z30") + c1 = Path.Command("G0 X10 Y30 Z30") + + self.docobj.Path = Path.Path([c, c1]) + postables = [self.docobj] + + # + # The original centroid postprocessor does not have a + # --modal option. We end up with the original gcode. + # + args = "--no-header --modal --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[6] + expected = "G0 X10.0000 Y30.0000 Z30.0000" + self.assertEqual(result, expected) + + def test070(self): + """ + Test axis modal + Suppress the axis coordinate if the same as previous + """ + c = Path.Command("G0 X10 Y20 Z30") + c1 = Path.Command("G0 X10 Y30 Z30") + + self.docobj.Path = Path.Path([c, c1]) + postables = [self.docobj] + + # + # The original centroid postprocessor does not have an + # --axis-modal option. We end up with the original gcode. + # + args = "--no-header --axis-modal --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[6] + expected = "G0 X10.0000 Y30.0000 Z30.0000" + self.assertEqual(result, expected) + + def test080(self): + """ + Test tool change + """ + c = Path.Command("M6 T2") + c2 = Path.Command("M3 S3000") + self.docobj.Path = Path.Path([c, c2]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode.splitlines()[5], "G43 H2") + self.assertEqual(gcode.splitlines()[6], "M6 T2") + self.assertEqual(gcode.splitlines()[7], "M3 S3000") + + # suppress TLO + # + # The original centroid postprocessor does not have an + # --no-tlo option. We end up with the original gcode. + # + args = "--no-header --no-tlo --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode.splitlines()[6], "M3 S3000") + + def test090(self): + """ + Test comment + """ + + c = Path.Command("(comment)") + + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = ";comment" + self.assertEqual(result, expected) diff --git a/src/Mod/CAM/CAMTests/TestCentroidPost.py b/src/Mod/CAM/CAMTests/TestCentroidPost.py index 6a3ee7348b..0b0ef14e04 100644 --- a/src/Mod/CAM/CAMTests/TestCentroidPost.py +++ b/src/Mod/CAM/CAMTests/TestCentroidPost.py @@ -2,7 +2,7 @@ # *************************************************************************** # * Copyright (c) 2022 sliptonic * -# * Copyright (c) 2022 Larry Woestman * +# * Copyright (c) 2022 - 2025 Larry Woestman * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * @@ -22,13 +22,11 @@ # * * # *************************************************************************** -from importlib import reload - import FreeCAD import Path import CAMTests.PathTestUtils as PathTestUtils -from Path.Post.scripts import centroid_post as postprocessor +from Path.Post.Processor import PostProcessorFactory Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -36,9 +34,12 @@ Path.Log.trackModule(Path.Log.thisModule()) class TestCentroidPost(PathTestUtils.PathTestBase): + """Test the centroid_post.py postprocessor.""" + @classmethod def setUpClass(cls): """setUpClass()... + This method is called upon instantiation of this test class. Add code and objects here that are needed for the duration of the test() methods in this class. In other words, set up the 'global' test environment @@ -47,64 +48,109 @@ class TestCentroidPost(PathTestUtils.PathTestBase): is able to call static methods within this same class. """ - # Open existing FreeCAD document with test geometry - FreeCAD.newDocument("Unnamed") + 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, "centroid") + # 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): """tearDownClass()... + This method is called prior to destruction of this test class. Add code and objects here that cleanup the test environment after the - test() methods in this class have been executed. This method does - not have access to the class `self` reference. This method - is able to call static methods within this same class. + test() methods in this class have been executed. This method does not + have access to the class `self` reference. This method is able to + call static methods within this same class. """ - # Close geometry document without saving - FreeCAD.closeDocument(FreeCAD.ActiveDocument.Name) + FreeCAD.closeDocument(cls.doc.Name) + FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") # Setup and tear down methods called before and after each unit test + def setUp(self): """setUp()... + This method is called prior to each `test()` method. Add code and objects here that are needed for multiple `test()` methods. """ - self.doc = FreeCAD.ActiveDocument - self.con = FreeCAD.Console - self.docobj = FreeCAD.ActiveDocument.addObject("Path::Feature", "testpath") - reload( - postprocessor - ) # technical debt. This shouldn't be necessary but here to bypass a bug + # allow a full length "diff" if an error occurs + self.maxDiff = None + # reinitialize the postprocessor data structures between tests + self.post.reinitialize() def tearDown(self): """tearDown()... + This method is called after each test() method. Add cleanup instructions here. Such cleanup instructions will likely undo those in the setUp() method. """ - FreeCAD.ActiveDocument.removeObject("testpath") + pass + + def single_compare(self, path, expected, args, debug=False): + """Perform a test with a single line of gcode comparison.""" + nl = "\n" + self.job.PostProcessorArgs = args + # replace the original path (that came with the job and operation) with our path + self.profile_op.Path = Path.Path(path) + # the gcode is in the first section for this particular job and operation + gcode = self.post.export()[0][1] + if debug: + print(f"--------{nl}{gcode}--------{nl}") + # there are 4 lines of "other stuff" before the line we are interested in + self.assertEqual(gcode.splitlines()[4], expected) + + def multi_compare(self, path, expected, args, debug=False): + """Perform a test with multiple lines of gcode comparison.""" + nl = "\n" + self.job.PostProcessorArgs = args + # replace the original path (that came with the job and operation) with our path + self.profile_op.Path = Path.Path(path) + # the gcode is in the first section for this particular job and operation + gcode = self.post.export()[0][1] + if debug: + print(f"--------{nl}{gcode}--------{nl}") + self.assertEqual(gcode, expected) def test000(self): """Test Output Generation. Empty path. Produces only the preamble and postable. """ + nl = "\n" - self.docobj.Path = Path.Path([]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([]) # Test generating with header # Header contains a time stamp that messes up unit testing. # Only test length of result. - args = "--no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertTrue(len(gcode.splitlines()) == 16) + self.job.PostProcessorArgs = "--no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + self.assertTrue(len(gcode.splitlines()) == 25) # Test without header expected = """G90 G80 G40 G49 -;begin preamble +;T1=TC__Default_Tool +;Begin preamble G53 G00 G17 G21 -;begin operation -;end operation: testpath -;begin postamble +;Begin operation +G54 +;End operation +;Begin operation +;TC: Default Tool +;Begin toolchange +M6 T1 +;End operation +;Begin operation +;End operation +;Begin postamble M5 M25 G49 H0 @@ -112,17 +158,17 @@ G90 G80 G40 G49 M99 """ - self.docobj.Path = Path.Path([]) - postables = [self.docobj] - - args = "--no-header --no-show-editor" - gcode = postprocessor.export(postables, "-", args) + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") self.assertEqual(gcode, expected) # test without comments expected = """G90 G80 G40 G49 G53 G00 G17 G21 +G54 +M6 T1 M5 M25 G49 H0 @@ -130,29 +176,33 @@ G90 G80 G40 G49 M99 """ - args = "--no-header --no-comments --no-show-editor" # args = ("--no-header --no-comments --no-show-editor --precision=2") - gcode = postprocessor.export(postables, "-", args) + self.job.PostProcessorArgs = "--no-header --no-comments --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") self.assertEqual(gcode, expected) def test010(self): """Test command Generation. Test Precision """ + nl = "\n" + c = Path.Command("G0 X10 Y20 Z30") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c]) - args = "--no-header --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[14] expected = "G0 X10.0000 Y20.0000 Z30.0000" self.assertEqual(result, expected) - args = "--no-header --axis-precision=2 --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] + self.job.PostProcessorArgs = "--no-header --axis-precision=2 --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[14] expected = "G0 X10.00 Y20.00 Z30.00" self.assertEqual(result, expected) @@ -160,68 +210,75 @@ M99 """ Test Line Numbers """ + nl = "\n" + c = Path.Command("G0 X10 Y20 Z30") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c]) - args = "--no-header --line-numbers --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] - expected = "N150 G0 X10.0000 Y20.0000 Z30.0000" + self.job.PostProcessorArgs = "--no-header --line-numbers --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[14] + expected = "N240 G0 X10.0000 Y20.0000 Z30.0000" self.assertEqual(result, expected) def test030(self): """ Test Pre-amble """ + nl = "\n" - self.docobj.Path = Path.Path([]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([]) - # - # The original centroid postprocessor does not have a - # --preamble option. We end up with the default preamble. - # - args = "--no-header --no-comments --preamble='G18 G55' --no-show-editor" - gcode = postprocessor.export(postables, "-", args) + self.job.PostProcessorArgs = ( + "--no-header --no-comments --preamble='G18 G55' --no-show-editor" + ) + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") result = gcode.splitlines()[1] - self.assertEqual(result, "G53 G00 G17") + self.assertEqual(result, "G18 G55") def test040(self): """ Test Post-amble """ - self.docobj.Path = Path.Path([]) - postables = [self.docobj] - # - # The original centroid postprocessor does not have a - # --postamble option. We end up with the default postamble. - # - args = "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertEqual(gcode.splitlines()[-1], "M99") + nl = "\n" + + self.profile_op.Path = Path.Path([]) + + self.job.PostProcessorArgs = ( + "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" + ) + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[-2] + self.assertEqual(result, "G0 Z50") + self.assertEqual(gcode.splitlines()[-1], "M2") def test050(self): """ Test inches """ + nl = "\n" c = Path.Command("G0 X10 Y20 Z30") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] - args = "--no-header --inches --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertEqual(gcode.splitlines()[3], "G20") + self.profile_op.Path = Path.Path([c]) - result = gcode.splitlines()[5] + self.job.PostProcessorArgs = "--no-header --inches --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + self.assertEqual(gcode.splitlines()[4], "G20") + + result = gcode.splitlines()[14] expected = "G0 X0.3937 Y0.7874 Z1.1811" self.assertEqual(result, expected) - args = "--no-header --inches --axis-precision=2 --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] + self.job.PostProcessorArgs = "--no-header --inches --axis-precision=2 --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[14] expected = "G0 X0.39 Y0.79 Z1.18" self.assertEqual(result, expected) @@ -230,20 +287,18 @@ M99 Test test modal Suppress the command name if the same as previous """ + nl = "\n" + c = Path.Command("G0 X10 Y20 Z30") c1 = Path.Command("G0 X10 Y30 Z30") - self.docobj.Path = Path.Path([c, c1]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c, c1]) - # - # The original centroid postprocessor does not have a - # --modal option. We end up with the original gcode. - # - args = "--no-header --modal --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[6] - expected = "G0 X10.0000 Y30.0000 Z30.0000" + self.job.PostProcessorArgs = "--no-header --modal --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[15] + expected = "X10.0000 Y30.0000 Z30.0000" self.assertEqual(result, expected) def test070(self): @@ -251,58 +306,56 @@ M99 Test axis modal Suppress the axis coordinate if the same as previous """ + nl = "\n" + c = Path.Command("G0 X10 Y20 Z30") c1 = Path.Command("G0 X10 Y30 Z30") - self.docobj.Path = Path.Path([c, c1]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c, c1]) - # - # The original centroid postprocessor does not have an - # --axis-modal option. We end up with the original gcode. - # - args = "--no-header --axis-modal --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[6] - expected = "G0 X10.0000 Y30.0000 Z30.0000" + self.job.PostProcessorArgs = "--no-header --axis-modal --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[15] + expected = "G0 Y30.0000" self.assertEqual(result, expected) def test080(self): """ Test tool change """ + nl = "\n" + c = Path.Command("M6 T2") c2 = Path.Command("M3 S3000") - self.docobj.Path = Path.Path([c, c2]) - postables = [self.docobj] - args = "--no-header --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertEqual(gcode.splitlines()[5], "G43 H2") - self.assertEqual(gcode.splitlines()[6], "M6 T2") - self.assertEqual(gcode.splitlines()[7], "M3 S3000") + self.profile_op.Path = Path.Path([c, c2]) + + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + self.assertEqual(gcode.splitlines()[15], "M6 T2") + self.assertEqual(gcode.splitlines()[16], "M3 S3000") # suppress TLO - # - # The original centroid postprocessor does not have an - # --no-tlo option. We end up with the original gcode. - # - args = "--no-header --no-tlo --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertEqual(gcode.splitlines()[6], "M3 S3000") + self.job.PostProcessorArgs = "--no-header --no-tlo --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + self.assertEqual(gcode.splitlines()[16], "M3 S3000") def test090(self): """ Test comment """ + nl = "\n" c = Path.Command("(comment)") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c]) - args = "--no-header --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[14] expected = ";comment" self.assertEqual(result, expected) diff --git a/src/Mod/CAM/CAMTests/TestRefactoredTestDressupPost.py b/src/Mod/CAM/CAMTests/TestDressupPost.py similarity index 97% rename from src/Mod/CAM/CAMTests/TestRefactoredTestDressupPost.py rename to src/Mod/CAM/CAMTests/TestDressupPost.py index b7268bb385..258bfd5d87 100644 --- a/src/Mod/CAM/CAMTests/TestRefactoredTestDressupPost.py +++ b/src/Mod/CAM/CAMTests/TestDressupPost.py @@ -34,8 +34,8 @@ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) -class TestRefactoredTestDressupPost(PathTestUtils.PathTestBase): - """Test the refactored_test_post.py postprocessor command line arguments.""" +class TestDressupPost(PathTestUtils.PathTestBase): + """Test the test_post.py postprocessor command line arguments.""" @classmethod def setUpClass(cls): @@ -51,7 +51,7 @@ class TestRefactoredTestDressupPost(PathTestUtils.PathTestBase): FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "True") cls.doc = FreeCAD.open(FreeCAD.getHomePath() + "/Mod/CAM/CAMTests/dressuptest.FCStd") cls.job = cls.doc.getObject("Job") - cls.post = PostProcessorFactory.get_post_processor(cls.job, "refactored_test") + cls.post = PostProcessorFactory.get_post_processor(cls.job, "test") # there are 4 operations in dressuptest.FCStd # every operation uses a different ToolController diff --git a/src/Mod/CAM/CAMTests/TestRefactoredLinuxCNCPost.py b/src/Mod/CAM/CAMTests/TestGenericPost.py similarity index 87% rename from src/Mod/CAM/CAMTests/TestRefactoredLinuxCNCPost.py rename to src/Mod/CAM/CAMTests/TestGenericPost.py index 7657272b54..c2de12dbdc 100644 --- a/src/Mod/CAM/CAMTests/TestRefactoredLinuxCNCPost.py +++ b/src/Mod/CAM/CAMTests/TestGenericPost.py @@ -22,18 +22,12 @@ # * * # *************************************************************************** -# *************************************************************************** -# * Note: TestRefactoredMassoG3Post.py is a modified clone of this file * -# * any changes to this file should be applied to the other * -# * * -# * * -# *************************************************************************** - import FreeCAD import Path import CAMTests.PathTestUtils as PathTestUtils +import CAMTests.PostTestMocks as PostTestMocks from Path.Post.Processor import PostProcessorFactory @@ -41,8 +35,8 @@ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) -class TestRefactoredLinuxCNCPost(PathTestUtils.PathTestBase): - """Test the refactored_linuxcnc_post.py postprocessor.""" +class TestGenericPost(PathTestUtils.PathTestBase): + """Test the generic postprocessor.""" @classmethod def setUpClass(cls): @@ -56,16 +50,13 @@ class TestRefactoredLinuxCNCPost(PathTestUtils.PathTestBase): is able to call static methods within this same class. """ - 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, "refactored_linuxcnc") - # 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 + # Create mock job with default operation and tool controller + cls.job, cls.profile_op, cls.tool_controller = ( + PostTestMocks.create_default_job_with_operation() + ) + + # Create postprocessor using the mock job + cls.post = PostProcessorFactory.get_post_processor(cls.job, "generic") @classmethod def tearDownClass(cls): @@ -77,8 +68,8 @@ class TestRefactoredLinuxCNCPost(PathTestUtils.PathTestBase): have access to the class `self` reference. This method is able to call static methods within this same class. """ - FreeCAD.closeDocument(cls.doc.Name) - FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") + # No cleanup needed for mock objects + pass # Setup and tear down methods called before and after each unit test @@ -114,32 +105,29 @@ class TestRefactoredLinuxCNCPost(PathTestUtils.PathTestBase): # Only test length of result. self.job.PostProcessorArgs = "--no-show-editor" gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertTrue(len(gcode.splitlines()) == 26) + # print(f"--------{nl}Actual line count: {len(gcode.splitlines())}{nl}{gcode}--------{nl}") + self.assertTrue(len(gcode.splitlines()) == 23) # Test without header expected = """(Begin preamble) -G17 G54 G40 G49 G80 G90 +G90 G21 -(Begin operation: Fixture) -(Machine units: mm/min) -G54 -(Finish operation: Fixture) (Begin operation: TC: Default Tool) (Machine units: mm/min) -(TC: Default Tool) (Begin toolchange) M5 M6 T1 G43 H1 +M3 S1000 (Finish operation: TC: Default Tool) +(Begin operation: Fixture) +(Machine units: mm/min) +G54 +(Finish operation: Fixture) (Begin operation: Profile) (Machine units: mm/min) (Finish operation: Profile) (Begin postamble) -M05 -G17 G54 G90 G80 G40 -M2 """ self.profile_op.Path = Path.Path([]) @@ -151,15 +139,13 @@ M2 self.assertEqual(gcode, expected) # test without comments - expected = """G17 G54 G40 G49 G80 G90 + expected = """G90 G21 -G54 M5 M6 T1 G43 H1 -M05 -G17 G54 G90 G80 G40 -M2 +M3 S1000 +G54 """ # args = ("--no-header --no-comments --no-show-editor --precision=2") @@ -222,8 +208,9 @@ M2 ) gcode = self.post.export()[0][1] # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[0] - self.assertEqual(result, "G18 G55") + lines = gcode.splitlines() + # Preamble should be in the output + self.assertIn("G18 G55", gcode) def test040(self): """ diff --git a/src/Mod/CAM/CAMTests/TestGrblLegacyPost.py b/src/Mod/CAM/CAMTests/TestGrblLegacyPost.py new file mode 100644 index 0000000000..aae4d38de4 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestGrblLegacyPost.py @@ -0,0 +1,254 @@ +# *************************************************************************** +# * Copyright (c) 2022 sliptonic * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD + +import Path +import CAMTests.PathTestUtils as PathTestUtils +from importlib import reload +from Path.Post.scripts import grbl_legacy_post as postprocessor + + +Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) +Path.Log.trackModule(Path.Log.thisModule()) + + +class TestGrblLegacyPost(PathTestUtils.PathTestBase): + @classmethod + def setUpClass(cls): + """setUpClass()... + This method is called upon instantiation of this test class. Add code + and objects here that are needed for the duration of the test() methods + in this class. In other words, set up the 'global' test environment + here; use the `setUp()` method to set up a 'local' test environment. + This method does not have access to the class `self` reference, but it + is able to call static methods within this same class. + """ + + # Open existing FreeCAD document with test geometry + FreeCAD.newDocument("Unnamed") + + @classmethod + def tearDownClass(cls): + """tearDownClass()... + This method is called prior to destruction of this test class. Add + code and objects here that cleanup the test environment after the + test() methods in this class have been executed. This method does + not have access to the class `self` reference. This method is able + to call static methods within this same class. + """ + # Close geometry document without saving + FreeCAD.closeDocument(FreeCAD.ActiveDocument.Name) + + # Setup and tear down methods called before and after each unit test + def setUp(self): + """setUp()... + This method is called prior to each `test()` method. Add code and + objects here that are needed for multiple `test()` methods. + """ + self.doc = FreeCAD.ActiveDocument + self.con = FreeCAD.Console + self.docobj = FreeCAD.ActiveDocument.addObject("Path::Feature", "testpath") + reload( + postprocessor + ) # technical debt. This shouldn't be necessary but here to bypass a bug + + def tearDown(self): + """tearDown()... + This method is called after each test() method. Add cleanup instructions here. + Such cleanup instructions will likely undo those in the setUp() method. + """ + FreeCAD.ActiveDocument.removeObject("testpath") + + def test000(self): + """Test Output Generation. + Empty path. Produces only the preamble and postable. + """ + + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + + # Test generating with header + # Header contains a time stamp that messes up unit testing. Only test + # length of result. + args = "--no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(len(gcode.splitlines()), 13) + + # Test without header + expected = """(Begin preamble) +G17 G90 +G21 +(Begin operation: testpath) +(Path: testpath) +(Finish operation: testpath) +(Begin postamble) +M5 +G17 G90 +M2 +""" + + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + # args = ("--no-header --no-comments --no-show-editor --precision=2") + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode, expected) + + # test without comments + expected = """G17 G90 +G21 +M5 +G17 G90 +M2 +""" + + args = "--no-header --no-comments --no-show-editor" + # args = ("--no-header --no-comments --no-show-editor --precision=2") + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode, expected) + + def test010(self): + """Test command Generation. + Test Precision + Test imperial / inches + """ + c = Path.Command("G0 X10 Y20 Z30") + + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = "G0 X10.000 Y20.000 Z30.000" + self.assertEqual(result, expected) + + args = "--no-header --precision=2 --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = "G0 X10.00 Y20.00 Z30.00" + self.assertEqual(result, expected) + + def test020(self): + """ + Test Line Numbers + """ + c = Path.Command("G0 X10 Y20 Z30") + + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --line-numbers --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = "N150 G0 X10.000 Y20.000 Z30.000" + self.assertEqual(result, expected) + + def test030(self): + """ + Test Pre-amble + """ + + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + + args = "--no-header --no-comments --preamble='G18 G55\n' --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[0] + self.assertEqual(result, "G18 G55") + + def test040(self): + """ + Test Post-amble + """ + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + args = "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[-2] + self.assertEqual(result, "G0 Z50") + self.assertEqual(gcode.splitlines()[-1], "M2") + + def test050(self): + """ + Test inches + """ + + c = Path.Command("G0 X10 Y20 Z30") + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --inches --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode.splitlines()[2], "G20") + + result = gcode.splitlines()[5] + expected = "G0 X0.3937 Y0.7874 Z1.1811" + self.assertEqual(result, expected) + + # Technical debt. The following test fails. Precision not working + # with imperial units. + + # args = ("--no-header --inches --precision=2") + # gcode = postprocessor.export(postables, "-", args) + # result = gcode.splitlines()[5] + # expected = "G0 X0.39 Y0.78 Z1.18 " + # self.assertEqual(result, expected) + + def test080(self): + """ + Test tool change + """ + c = Path.Command("M6 T2") + c2 = Path.Command("M3 S3000") + self.docobj.Path = Path.Path([c, c2]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode.splitlines()[6], "( M6 T2 )") + self.assertEqual(gcode.splitlines()[7], "M3 S3000") + + # suppress TLO + # + # The grbl postprocessor does not have a --no-tlo option. + # + # args = "--no-header --no-tlo --no-show-editor" + # gcode = postprocessor.export(postables, "-", args) + # self.assertEqual(gcode.splitlines()[7], "M3 S3000 ") + + def test090(self): + """ + Test comment + """ + + c = Path.Command("(comment)") + + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = "(comment)" + self.assertEqual(result, expected) diff --git a/src/Mod/CAM/CAMTests/TestGrblPost.py b/src/Mod/CAM/CAMTests/TestGrblPost.py index 0a584624e6..d3e0bbaee5 100644 --- a/src/Mod/CAM/CAMTests/TestGrblPost.py +++ b/src/Mod/CAM/CAMTests/TestGrblPost.py @@ -2,6 +2,7 @@ # *************************************************************************** # * Copyright (c) 2022 sliptonic * +# * Copyright (c) 2022 - 2025 Larry Woestman * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * @@ -25,8 +26,7 @@ import FreeCAD import Path import CAMTests.PathTestUtils as PathTestUtils -from importlib import reload -from Path.Post.scripts import grbl_post as postprocessor +from Path.Post.Processor import PostProcessorFactory Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -34,9 +34,12 @@ Path.Log.trackModule(Path.Log.thisModule()) class TestGrblPost(PathTestUtils.PathTestBase): + """Test the grbl_post.py postprocessor.""" + @classmethod def setUpClass(cls): """setUpClass()... + This method is called upon instantiation of this test class. Add code and objects here that are needed for the duration of the test() methods in this class. In other words, set up the 'global' test environment @@ -45,109 +48,134 @@ class TestGrblPost(PathTestUtils.PathTestBase): is able to call static methods within this same class. """ - # Open existing FreeCAD document with test geometry - FreeCAD.newDocument("Unnamed") + 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, "grbl") + # 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): """tearDownClass()... + This method is called prior to destruction of this test class. Add code and objects here that cleanup the test environment after the - test() methods in this class have been executed. This method does - not have access to the class `self` reference. This method is able - to call static methods within this same class. + test() methods in this class have been executed. This method does not + have access to the class `self` reference. This method + is able to call static methods within this same class. """ - # Close geometry document without saving - FreeCAD.closeDocument(FreeCAD.ActiveDocument.Name) + FreeCAD.closeDocument(cls.doc.Name) + FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") # Setup and tear down methods called before and after each unit test + def setUp(self): """setUp()... + This method is called prior to each `test()` method. Add code and objects here that are needed for multiple `test()` methods. """ - self.doc = FreeCAD.ActiveDocument - self.con = FreeCAD.Console - self.docobj = FreeCAD.ActiveDocument.addObject("Path::Feature", "testpath") - reload( - postprocessor - ) # technical debt. This shouldn't be necessary but here to bypass a bug + # allow a full length "diff" if an error occurs + self.maxDiff = None + # reinitialize the postprocessor data structures between tests + self.post.reinitialize() def tearDown(self): """tearDown()... + This method is called after each test() method. Add cleanup instructions here. Such cleanup instructions will likely undo those in the setUp() method. """ - FreeCAD.ActiveDocument.removeObject("testpath") + pass def test000(self): """Test Output Generation. Empty path. Produces only the preamble and postable. """ + nl = "\n" - self.docobj.Path = Path.Path([]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([]) # Test generating with header - # Header contains a time stamp that messes up unit testing. Only test - # length of result. - args = "--no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertTrue(len(gcode.splitlines()) == 13) + # Header contains a time stamp that messes up unit testing. + # Only test length of result. + self.job.PostProcessorArgs = "--no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + self.assertTrue(len(gcode.splitlines()) == 24) # Test without header expected = """(Begin preamble) G17 G90 G21 -(Begin operation: testpath) -(Path: testpath) -(Finish operation: testpath) +(Begin operation: Fixture) +(Path: Fixture) +G54 +(Finish operation: Fixture) +(Begin operation: TC: Default Tool) +(Path: TC: Default Tool) +(TC: Default Tool) +(Begin toolchange) +(M6 T1) +(Finish operation: TC: Default Tool) +(Begin operation: Profile) +(Path: Profile) +(Finish operation: Profile) (Begin postamble) M5 G17 G90 M2 """ - self.docobj.Path = Path.Path([]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([]) - args = "--no-header --no-show-editor" # args = ("--no-header --no-comments --no-show-editor --precision=2") - gcode = postprocessor.export(postables, "-", args) + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") self.assertEqual(gcode, expected) # test without comments expected = """G17 G90 G21 +G54 M5 G17 G90 M2 """ - args = "--no-header --no-comments --no-show-editor" # args = ("--no-header --no-comments --no-show-editor --precision=2") - gcode = postprocessor.export(postables, "-", args) + self.job.PostProcessorArgs = "--no-header --no-comments --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") self.assertEqual(gcode, expected) def test010(self): """Test command Generation. Test Precision - Test imperial / inches """ + nl = "\n" + c = Path.Command("G0 X10 Y20 Z30") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c]) - args = "--no-header --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[15] expected = "G0 X10.000 Y20.000 Z30.000" self.assertEqual(result, expected) - args = "--no-header --precision=2 --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] + self.job.PostProcessorArgs = "--no-header --precision=2 --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[15] expected = "G0 X10.00 Y20.00 Z30.00" self.assertEqual(result, expected) @@ -155,27 +183,32 @@ M2 """ Test Line Numbers """ + nl = "\n" + c = Path.Command("G0 X10 Y20 Z30") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c]) - args = "--no-header --line-numbers --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] - expected = "N150 G0 X10.000 Y20.000 Z30.000" + self.job.PostProcessorArgs = "--no-header --line-numbers --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[15] + expected = "N250 G0 X10.000 Y20.000 Z30.000" self.assertEqual(result, expected) def test030(self): """ Test Pre-amble """ + nl = "\n" - self.docobj.Path = Path.Path([]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([]) - args = "--no-header --no-comments --preamble='G18 G55\n' --no-show-editor" - gcode = postprocessor.export(postables, "-", args) + self.job.PostProcessorArgs = ( + "--no-header --no-comments --preamble='G18 G55' --no-show-editor" + ) + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") result = gcode.splitlines()[0] self.assertEqual(result, "G18 G55") @@ -183,10 +216,15 @@ M2 """ Test Post-amble """ - self.docobj.Path = Path.Path([]) - postables = [self.docobj] - args = "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" - gcode = postprocessor.export(postables, "-", args) + nl = "\n" + + self.profile_op.Path = Path.Path([]) + + self.job.PostProcessorArgs = ( + "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" + ) + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") result = gcode.splitlines()[-2] self.assertEqual(result, "G0 Z50") self.assertEqual(gcode.splitlines()[-1], "M2") @@ -195,102 +233,121 @@ M2 """ Test inches """ + nl = "\n" c = Path.Command("G0 X10 Y20 Z30") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] - args = "--no-header --inches --no-show-editor" - gcode = postprocessor.export(postables, "-", args) + self.profile_op.Path = Path.Path([c]) + + self.job.PostProcessorArgs = "--no-header --inches --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") self.assertEqual(gcode.splitlines()[2], "G20") - result = gcode.splitlines()[5] + result = gcode.splitlines()[15] expected = "G0 X0.3937 Y0.7874 Z1.1811" self.assertEqual(result, expected) - # Technical debt. The following test fails. Precision not working - # with imperial units. - - # args = ("--no-header --inches --precision=2") - # gcode = postprocessor.export(postables, "-", args) - # result = gcode.splitlines()[5] - # expected = "G0 X0.39 Y0.78 Z1.18 " - # self.assertEqual(result, expected) + self.job.PostProcessorArgs = "--no-header --inches --precision=2 --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[15] + expected = "G0 X0.39 Y0.79 Z1.18" + self.assertEqual(result, expected) def test060(self): """ Test test modal Suppress the command name if the same as previous """ + nl = "\n" + c = Path.Command("G0 X10 Y20 Z30") c1 = Path.Command("G0 X10 Y30 Z30") - self.docobj.Path = Path.Path([c, c1]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c, c1]) - # - # The grbl postprocessor does not have a --modal option. - # - # args = "--no-header --modal --no-show-editor" - # gcode = postprocessor.export(postables, "-", args) - # result = gcode.splitlines()[6] - # expected = "X10.000 Y30.000 Z30.000 " - # self.assertEqual(result, expected) + self.job.PostProcessorArgs = "--no-header --modal --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[16] + expected = "X10.000 Y30.000 Z30.000" + self.assertEqual(result, expected) def test070(self): """ Test axis modal Suppress the axis coordinate if the same as previous """ + nl = "\n" + c = Path.Command("G0 X10 Y20 Z30") c1 = Path.Command("G0 X10 Y30 Z30") - self.docobj.Path = Path.Path([c, c1]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c, c1]) - # - # The grbl postprocessor does not have a --axis-modal option. - # - # args = "--no-header --axis-modal --no-show-editor" - # gcode = postprocessor.export(postables, "-", args) - # result = gcode.splitlines()[6] - # expected = "G0 Y30.000 " - # self.assertEqual(result, expected) + self.job.PostProcessorArgs = "--no-header --axis-modal --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[16] + expected = "G0 Y30.000" + self.assertEqual(result, expected) def test080(self): """ Test tool change """ + nl = "\n" + c = Path.Command("M6 T2") c2 = Path.Command("M3 S3000") - self.docobj.Path = Path.Path([c, c2]) - postables = [self.docobj] - args = "--no-header --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertEqual(gcode.splitlines()[6], "( M6 T2 )") - self.assertEqual(gcode.splitlines()[7], "M3 S3000") + self.profile_op.Path = Path.Path([c, c2]) + + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + self.assertEqual(gcode.splitlines()[16], "(M6 T2)") + self.assertEqual(gcode.splitlines()[17], "M3 S3000") # suppress TLO - # - # The grbl postprocessor does not have a --no-tlo option. - # - # args = "--no-header --no-tlo --no-show-editor" - # gcode = postprocessor.export(postables, "-", args) - # self.assertEqual(gcode.splitlines()[7], "M3 S3000 ") + self.job.PostProcessorArgs = "--no-header --no-tlo --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + self.assertEqual(gcode.splitlines()[17], "M3 S3000") def test090(self): """ Test comment """ + nl = "\n" c = Path.Command("(comment)") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c]) - args = "--no-header --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[15] expected = "(comment)" self.assertEqual(result, expected) + + def test100(self): + """ + Test if coolant is enabled. + """ + nl = "\n" + + c = Path.Command("M7") + c1 = Path.Command("M8") + c2 = Path.Command("M9") + + self.profile_op.Path = Path.Path([c, c1, c2]) + + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + self.assertEqual(gcode.splitlines()[15], "M7") + self.assertEqual(gcode.splitlines()[16], "M8") + self.assertEqual(gcode.splitlines()[17], "M9") diff --git a/src/Mod/CAM/CAMTests/TestLinuxCNCLegacyPost.py b/src/Mod/CAM/CAMTests/TestLinuxCNCLegacyPost.py new file mode 100644 index 0000000000..63e0b7849a --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestLinuxCNCLegacyPost.py @@ -0,0 +1,420 @@ +# *************************************************************************** +# * Copyright (c) 2022 sliptonic * +# * Copyright (c) 2023 Larry Woestman * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD + +import Path +import CAMTests.PathTestUtils as PathTestUtils +from importlib import reload +from Path.Post.scripts import linuxcnc_legacy_post as postprocessor + +Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) +Path.Log.trackModule(Path.Log.thisModule()) + + +class TestLinuxCNCLegacyPost(PathTestUtils.PathTestBase): + @classmethod + def setUpClass(cls): + """setUpClass()... + This method is called upon instantiation of this test class. Add code + and objects here that are needed for the duration of the test() methods + in this class. In other words, set up the 'global' test environment + here; use the `setUp()` method to set up a 'local' test environment. + This method does not have access to the class `self` reference, but it + is able to call static methods within this same class. + """ + + # Open existing FreeCAD document with test geometry + FreeCAD.newDocument("Unnamed") + + @classmethod + def tearDownClass(cls): + """tearDownClass()... + This method is called prior to destruction of this test class. Add + code and objects here that cleanup the test environment after the + test() methods in this class have been executed. This method does + not have access to the class `self` reference. This method is able + to call static methods within this same class. + """ + # Close geometry document without saving + FreeCAD.closeDocument(FreeCAD.ActiveDocument.Name) + + # Setup and tear down methods called before and after each unit test + def setUp(self): + """setUp()... + This method is called prior to each `test()` method. Add code and + objects here that are needed for multiple `test()` methods. + """ + self.doc = FreeCAD.ActiveDocument + self.con = FreeCAD.Console + self.docobj = FreeCAD.ActiveDocument.addObject("Path::Feature", "testpath") + reload( + postprocessor + ) # technical debt. This shouldn't be necessary but here to bypass a bug + + def tearDown(self): + """tearDown()... + This method is called after each test() method. Add cleanup instructions here. + Such cleanup instructions will likely undo those in the setUp() method. + """ + FreeCAD.ActiveDocument.removeObject("testpath") + + def compare_sixth_line(self, path_string, expected, args, debug=False): + """Perform a test with a single comparison to the sixth line of the output.""" + nl = "\n" + if path_string: + self.docobj.Path = Path.Path([Path.Command(path_string)]) + else: + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + gcode = postprocessor.export(postables, "-", args) + if debug: + print(f"--------{nl}{gcode}--------{nl}") + self.assertEqual(gcode.splitlines()[5], expected) + + def test000(self): + """Test Output Generation. + Empty path. Produces only the preamble and postable. + """ + + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + + # Test generating with header + # Header contains a time stamp that messes up unit testing. + # Only test length of result. + args = "--no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertTrue(len(gcode.splitlines()) == 13) + + # Test without header + expected = """(begin preamble) +G17 G54 G40 G49 G80 G90 +G21 +(begin operation: testpath) +(machine units: mm/min) +(finish operation: testpath) +(begin postamble) +M05 +G17 G54 G90 G80 G40 +M2 +""" + + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + # args = ("--no-header --no-comments --no-show-editor --precision=2") + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode, expected) + + # test without comments + expected = """G17 G54 G40 G49 G80 G90 +G21 +M05 +G17 G54 G90 G80 G40 +M2 +""" + + args = "--no-header --no-comments --no-show-editor" + # args = ("--no-header --no-comments --no-show-editor --precision=2") + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode, expected) + + def test010(self): + """Test command Generation. + Test Precision + Test imperial / inches + """ + self.compare_sixth_line( + "G0 X10 Y20 Z30", "G0 X10.000 Y20.000 Z30.000 ", "--no-header --no-show-editor" + ) + self.compare_sixth_line( + "G0 X10 Y20 Z30", + "G0 X10.00 Y20.00 Z30.00 ", + "--no-header --precision=2 --no-show-editor", + ) + + def test020(self): + """ + Test Line Numbers + """ + self.compare_sixth_line( + "G0 X10 Y20 Z30", + "N160 G0 X10.000 Y20.000 Z30.000 ", + "--no-header --line-numbers --no-show-editor", + ) + + def test030(self): + """ + Test Pre-amble + """ + + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + + args = "--no-header --no-comments --preamble='G18 G55' --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[0] + self.assertEqual(result, "G18 G55") + + def test040(self): + """ + Test Post-amble + """ + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + args = "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[-2] + self.assertEqual(result, "G0 Z50") + self.assertEqual(gcode.splitlines()[-1], "M2") + + def test050(self): + """ + Test inches + """ + + c = Path.Command("G0 X10 Y20 Z30") + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --inches --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode.splitlines()[2], "G20") + + result = gcode.splitlines()[5] + expected = "G0 X0.3937 Y0.7874 Z1.1811 " + self.assertEqual(result, expected) + + # Technical debt. The following test fails. Precision not working + # with imperial units. + + # args = ("--no-header --inches --precision=2") + # gcode = postprocessor.export(postables, "-", args) + # result = gcode.splitlines()[5] + # expected = "G0 X0.39 Y0.78 Z1.18 " + # self.assertEqual(result, expected) + + def test060(self): + """ + Test test modal + Suppress the command name if the same as previous + """ + c = Path.Command("G0 X10 Y20 Z30") + c1 = Path.Command("G0 X10 Y30 Z30") + + self.docobj.Path = Path.Path([c, c1]) + postables = [self.docobj] + + args = "--no-header --modal --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[6] + expected = "X10.000 Y30.000 Z30.000 " + self.assertEqual(result, expected) + + def test070(self): + """ + Test axis modal + Suppress the axis coordinate if the same as previous + """ + c = Path.Command("G0 X10 Y20 Z30") + c1 = Path.Command("G0 X10 Y30 Z30") + + self.docobj.Path = Path.Path([c, c1]) + postables = [self.docobj] + + args = "--no-header --axis-modal --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[6] + expected = "G0 Y30.000 " + self.assertEqual(result, expected) + + def test080(self): + """ + Test tool change + """ + c = Path.Command("M6 T2") + c2 = Path.Command("M3 S3000") + self.docobj.Path = Path.Path([c, c2]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode.splitlines()[5], "M5") + self.assertEqual(gcode.splitlines()[6], "M6 T2 ") + self.assertEqual(gcode.splitlines()[7], "G43 H2 ") + self.assertEqual(gcode.splitlines()[8], "M3 S3000 ") + + # suppress TLO + args = "--no-header --no-tlo --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode.splitlines()[7], "M3 S3000 ") + + def test090(self): + """ + Test comment + """ + self.compare_sixth_line("(comment)", "(comment) ", "--no-header --no-show-editor") + + def test100(self): + """Test A, B, & C axis output for values between 0 and 90 degrees""" + self.compare_sixth_line( + "G1 X10 Y20 Z30 A40 B50 C60", + "G1 X10.000 Y20.000 Z30.000 A40.000 B50.000 C60.000 ", + "--no-header --no-show-editor", + ) + self.compare_sixth_line( + "G1 X10 Y20 Z30 A40 B50 C60", + "G1 X0.3937 Y0.7874 Z1.1811 A40.0000 B50.0000 C60.0000 ", + "--no-header --inches --no-show-editor", + ) + + def test110(self): + """Test A, B, & C axis output for 89 degrees""" + self.compare_sixth_line( + "G1 X10 Y20 Z30 A89 B89 C89", + "G1 X10.000 Y20.000 Z30.000 A89.000 B89.000 C89.000 ", + "--no-header --no-show-editor", + ) + self.compare_sixth_line( + "G1 X10 Y20 Z30 A89 B89 C89", + "G1 X0.3937 Y0.7874 Z1.1811 A89.0000 B89.0000 C89.0000 ", + "--no-header --inches --no-show-editor", + ) + + def test120(self): + """Test A, B, & C axis output for 90 degrees""" + self.compare_sixth_line( + "G1 X10 Y20 Z30 A90 B90 C90", + "G1 X10.000 Y20.000 Z30.000 A90.000 B90.000 C90.000 ", + "--no-header --no-show-editor", + ) + self.compare_sixth_line( + "G1 X10 Y20 Z30 A90 B90 C90", + "G1 X0.3937 Y0.7874 Z1.1811 A90.0000 B90.0000 C90.0000 ", + "--no-header --inches --no-show-editor", + ) + + def test130(self): + """Test A, B, & C axis output for 91 degrees""" + self.compare_sixth_line( + "G1 X10 Y20 Z30 A91 B91 C91", + "G1 X10.000 Y20.000 Z30.000 A91.000 B91.000 C91.000 ", + "--no-header --no-show-editor", + ) + self.compare_sixth_line( + "G1 X10 Y20 Z30 A91 B91 C91", + "G1 X0.3937 Y0.7874 Z1.1811 A91.0000 B91.0000 C91.0000 ", + "--no-header --inches --no-show-editor", + ) + + def test140(self): + """Test A, B, & C axis output for values between 90 and 180 degrees""" + self.compare_sixth_line( + "G1 X10 Y20 Z30 A100 B110 C120", + "G1 X10.000 Y20.000 Z30.000 A100.000 B110.000 C120.000 ", + "--no-header --no-show-editor", + ) + self.compare_sixth_line( + "G1 X10 Y20 Z30 A100 B110 C120", + "G1 X0.3937 Y0.7874 Z1.1811 A100.0000 B110.0000 C120.0000 ", + "--no-header --inches --no-show-editor", + ) + + def test150(self): + """Test A, B, & C axis output for values between 180 and 360 degrees""" + self.compare_sixth_line( + "G1 X10 Y20 Z30 A240 B250 C260", + "G1 X10.000 Y20.000 Z30.000 A240.000 B250.000 C260.000 ", + "--no-header --no-show-editor", + ) + self.compare_sixth_line( + "G1 X10 Y20 Z30 A240 B250 C260", + "G1 X0.3937 Y0.7874 Z1.1811 A240.0000 B250.0000 C260.0000 ", + "--no-header --inches --no-show-editor", + ) + + def test160(self): + """Test A, B, & C axis output for values greater than 360 degrees""" + self.compare_sixth_line( + "G1 X10 Y20 Z30 A440 B450 C460", + "G1 X10.000 Y20.000 Z30.000 A440.000 B450.000 C460.000 ", + "--no-header --no-show-editor", + ) + self.compare_sixth_line( + "G1 X10 Y20 Z30 A440 B450 C460", + "G1 X0.3937 Y0.7874 Z1.1811 A440.0000 B450.0000 C460.0000 ", + "--no-header --inches --no-show-editor", + ) + + def test170(self): + """Test A, B, & C axis output for values between 0 and -90 degrees""" + self.compare_sixth_line( + "G1 X10 Y20 Z30 A-40 B-50 C-60", + "G1 X10.000 Y20.000 Z30.000 A-40.000 B-50.000 C-60.000 ", + "--no-header --no-show-editor", + ) + self.compare_sixth_line( + "G1 X10 Y20 Z30 A-40 B-50 C-60", + "G1 X0.3937 Y0.7874 Z1.1811 A-40.0000 B-50.0000 C-60.0000 ", + "--no-header --inches --no-show-editor", + ) + + def test180(self): + """Test A, B, & C axis output for values between -90 and -180 degrees""" + self.compare_sixth_line( + "G1 X10 Y20 Z30 A-100 B-110 C-120", + "G1 X10.000 Y20.000 Z30.000 A-100.000 B-110.000 C-120.000 ", + "--no-header --no-show-editor", + ) + self.compare_sixth_line( + "G1 X10 Y20 Z30 A-100 B-110 C-120", + "G1 X0.3937 Y0.7874 Z1.1811 A-100.0000 B-110.0000 C-120.0000 ", + "--no-header --inches --no-show-editor", + ) + + def test190(self): + """Test A, B, & C axis output for values between -180 and -360 degrees""" + self.compare_sixth_line( + "G1 X10 Y20 Z30 A-240 B-250 C-260", + "G1 X10.000 Y20.000 Z30.000 A-240.000 B-250.000 C-260.000 ", + "--no-header --no-show-editor", + ) + self.compare_sixth_line( + "G1 X10 Y20 Z30 A-240 B-250 C-260", + "G1 X0.3937 Y0.7874 Z1.1811 A-240.0000 B-250.0000 C-260.0000 ", + "--no-header --inches --no-show-editor", + ) + + def test200(self): + """Test A, B, & C axis output for values below -360 degrees""" + self.compare_sixth_line( + "G1 X10 Y20 Z30 A-440 B-450 C-460", + "G1 X10.000 Y20.000 Z30.000 A-440.000 B-450.000 C-460.000 ", + "--no-header --no-show-editor", + ) + self.compare_sixth_line( + "G1 X10 Y20 Z30 A-440 B-450 C-460", + "G1 X0.3937 Y0.7874 Z1.1811 A-440.0000 B-450.0000 C-460.0000 ", + "--no-header --inches --no-show-editor", + ) diff --git a/src/Mod/CAM/CAMTests/TestLinuxCNCPost.py b/src/Mod/CAM/CAMTests/TestLinuxCNCPost.py index a7014ff4a8..a33ee00998 100644 --- a/src/Mod/CAM/CAMTests/TestLinuxCNCPost.py +++ b/src/Mod/CAM/CAMTests/TestLinuxCNCPost.py @@ -2,7 +2,7 @@ # *************************************************************************** # * Copyright (c) 2022 sliptonic * -# * Copyright (c) 2023 Larry Woestman * +# * Copyright (c) 2022 - 2025 Larry Woestman * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * @@ -26,17 +26,25 @@ import FreeCAD import Path import CAMTests.PathTestUtils as PathTestUtils -from importlib import reload -from Path.Post.scripts import linuxcnc_post as postprocessor +import CAMTests.PostTestMocks as PostTestMocks +from Path.Post.Processor import PostProcessorFactory + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) class TestLinuxCNCPost(PathTestUtils.PathTestBase): + """Test LinuxCNC-specific features of the inuxcnc_post.py postprocessor. + + This test suite focuses on LinuxCNC-specific functionality such as path blending modes. + Generic postprocessor functionality is tested in TestGenericPost. + """ + @classmethod def setUpClass(cls): """setUpClass()... + This method is called upon instantiation of this test class. Add code and objects here that are needed for the duration of the test() methods in this class. In other words, set up the 'global' test environment @@ -45,378 +53,138 @@ class TestLinuxCNCPost(PathTestUtils.PathTestBase): is able to call static methods within this same class. """ - # Open existing FreeCAD document with test geometry - FreeCAD.newDocument("Unnamed") + # Create mock job with default operation and tool controller + cls.job, cls.profile_op, cls.tool_controller = ( + PostTestMocks.create_default_job_with_operation() + ) + + # Create postprocessor using the mock job + cls.post = PostProcessorFactory.get_post_processor(cls.job, "linuxcnc") @classmethod def tearDownClass(cls): """tearDownClass()... + This method is called prior to destruction of this test class. Add code and objects here that cleanup the test environment after the - test() methods in this class have been executed. This method does - not have access to the class `self` reference. This method is able - to call static methods within this same class. + test() methods in this class have been executed. This method does not + have access to the class `self` reference. This method + is able to call static methods within this same class. """ - # Close geometry document without saving - FreeCAD.closeDocument(FreeCAD.ActiveDocument.Name) + # No cleanup needed for mock objects + pass # Setup and tear down methods called before and after each unit test + def setUp(self): """setUp()... + This method is called prior to each `test()` method. Add code and objects here that are needed for multiple `test()` methods. """ - self.doc = FreeCAD.ActiveDocument - self.con = FreeCAD.Console - self.docobj = FreeCAD.ActiveDocument.addObject("Path::Feature", "testpath") - reload( - postprocessor - ) # technical debt. This shouldn't be necessary but here to bypass a bug + # allow a full length "diff" if an error occurs + self.maxDiff = None + # reinitialize the postprocessor data structures between tests + self.post.reinitialize() def tearDown(self): """tearDown()... + This method is called after each test() method. Add cleanup instructions here. Such cleanup instructions will likely undo those in the setUp() method. """ - FreeCAD.ActiveDocument.removeObject("testpath") + pass - def compare_sixth_line(self, path_string, expected, args, debug=False): - """Perform a test with a single comparison to the sixth line of the output.""" - nl = "\n" - if path_string: - self.docobj.Path = Path.Path([Path.Command(path_string)]) - else: - self.docobj.Path = Path.Path([]) - postables = [self.docobj] - gcode = postprocessor.export(postables, "-", args) - if debug: - print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode.splitlines()[5], expected) - - def test000(self): - """Test Output Generation. - Empty path. Produces only the preamble and postable. - """ - - self.docobj.Path = Path.Path([]) - postables = [self.docobj] - - # Test generating with header - # Header contains a time stamp that messes up unit testing. - # Only test length of result. - args = "--no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertTrue(len(gcode.splitlines()) == 13) - - # Test without header - expected = """(begin preamble) -G17 G54 G40 G49 G80 G90 -G21 -(begin operation: testpath) -(machine units: mm/min) -(finish operation: testpath) -(begin postamble) -M05 -G17 G54 G90 G80 G40 -M2 -""" - - self.docobj.Path = Path.Path([]) - postables = [self.docobj] - - args = "--no-header --no-show-editor" - # args = ("--no-header --no-comments --no-show-editor --precision=2") - gcode = postprocessor.export(postables, "-", args) - self.assertEqual(gcode, expected) - - # test without comments - expected = """G17 G54 G40 G49 G80 G90 -G21 -M05 -G17 G54 G90 G80 G40 -M2 -""" - - args = "--no-header --no-comments --no-show-editor" - # args = ("--no-header --no-comments --no-show-editor --precision=2") - gcode = postprocessor.export(postables, "-", args) - self.assertEqual(gcode, expected) - - def test010(self): - """Test command Generation. - Test Precision - Test imperial / inches - """ - self.compare_sixth_line( - "G0 X10 Y20 Z30", "G0 X10.000 Y20.000 Z30.000 ", "--no-header --no-show-editor" + def test_blend_mode_exact_path(self): + """Test EXACT_PATH blend mode outputs G61.""" + self.profile_op.Path = Path.Path([]) + self.job.PostProcessorArgs = ( + "--no-header --no-comments --blend-mode EXACT_PATH --no-show-editor" ) - self.compare_sixth_line( - "G0 X10 Y20 Z30", - "G0 X10.00 Y20.00 Z30.00 ", - "--no-header --precision=2 --no-show-editor", + gcode = self.post.export()[0][1] + + # G61 should be in the preamble + self.assertIn("G61", gcode) + # Should not have G64 + self.assertNotIn("G64", gcode) + # Should not have G61.1 + self.assertNotIn("G61.1", gcode) + + def test_blend_mode_exact_stop(self): + """Test EXACT_STOP blend mode outputs G61.1.""" + self.profile_op.Path = Path.Path([]) + self.job.PostProcessorArgs = ( + "--no-header --no-comments --blend-mode EXACT_STOP --no-show-editor" ) + gcode = self.post.export()[0][1] - def test020(self): - """ - Test Line Numbers - """ - self.compare_sixth_line( - "G0 X10 Y20 Z30", - "N160 G0 X10.000 Y20.000 Z30.000 ", - "--no-header --line-numbers --no-show-editor", + # G61.1 should be in the preamble + self.assertIn("G61.1", gcode) + # Should not have G64 + self.assertNotIn("G64", gcode) + + def test_blend_mode_blend_default(self): + """Test BLEND mode with default tolerance (0) outputs G64.""" + self.profile_op.Path = Path.Path([]) + self.job.PostProcessorArgs = "--no-header --no-comments --blend-mode BLEND --no-show-editor" + gcode = self.post.export()[0][1] + + # G64 should be in the preamble (without P parameter) + lines = gcode.splitlines() + has_g64 = any("G64" in line and "P" not in line for line in lines) + self.assertTrue(has_g64, "Expected G64 without P parameter") + + def test_blend_mode_blend_with_tolerance(self): + """Test BLEND mode with tolerance outputs G64 P.""" + self.profile_op.Path = Path.Path([]) + self.job.PostProcessorArgs = ( + "--no-header --no-comments --blend-mode BLEND --blend-tolerance 0.05 --no-show-editor" ) + gcode = self.post.export()[0][1] - def test030(self): - """ - Test Pre-amble - """ + # G64 P0.05 should be in the preamble + self.assertIn("G64 P0.0500", gcode) - self.docobj.Path = Path.Path([]) - postables = [self.docobj] - - args = "--no-header --no-comments --preamble='G18 G55' --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[0] - self.assertEqual(result, "G18 G55") - - def test040(self): - """ - Test Post-amble - """ - self.docobj.Path = Path.Path([]) - postables = [self.docobj] - args = "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[-2] - self.assertEqual(result, "G0 Z50") - self.assertEqual(gcode.splitlines()[-1], "M2") - - def test050(self): - """ - Test inches - """ - - c = Path.Command("G0 X10 Y20 Z30") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] - - args = "--no-header --inches --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertEqual(gcode.splitlines()[2], "G20") - - result = gcode.splitlines()[5] - expected = "G0 X0.3937 Y0.7874 Z1.1811 " - self.assertEqual(result, expected) - - # Technical debt. The following test fails. Precision not working - # with imperial units. - - # args = ("--no-header --inches --precision=2") - # gcode = postprocessor.export(postables, "-", args) - # result = gcode.splitlines()[5] - # expected = "G0 X0.39 Y0.78 Z1.18 " - # self.assertEqual(result, expected) - - def test060(self): - """ - Test test modal - Suppress the command name if the same as previous - """ - c = Path.Command("G0 X10 Y20 Z30") - c1 = Path.Command("G0 X10 Y30 Z30") - - self.docobj.Path = Path.Path([c, c1]) - postables = [self.docobj] - - args = "--no-header --modal --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[6] - expected = "X10.000 Y30.000 Z30.000 " - self.assertEqual(result, expected) - - def test070(self): - """ - Test axis modal - Suppress the axis coordinate if the same as previous - """ - c = Path.Command("G0 X10 Y20 Z30") - c1 = Path.Command("G0 X10 Y30 Z30") - - self.docobj.Path = Path.Path([c, c1]) - postables = [self.docobj] - - args = "--no-header --axis-modal --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[6] - expected = "G0 Y30.000 " - self.assertEqual(result, expected) - - def test080(self): - """ - Test tool change - """ - c = Path.Command("M6 T2") - c2 = Path.Command("M3 S3000") - self.docobj.Path = Path.Path([c, c2]) - postables = [self.docobj] - - args = "--no-header --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertEqual(gcode.splitlines()[5], "M5") - self.assertEqual(gcode.splitlines()[6], "M6 T2 ") - self.assertEqual(gcode.splitlines()[7], "G43 H2 ") - self.assertEqual(gcode.splitlines()[8], "M3 S3000 ") - - # suppress TLO - args = "--no-header --no-tlo --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertEqual(gcode.splitlines()[7], "M3 S3000 ") - - def test090(self): - """ - Test comment - """ - self.compare_sixth_line("(comment)", "(comment) ", "--no-header --no-show-editor") - - def test100(self): - """Test A, B, & C axis output for values between 0 and 90 degrees""" - self.compare_sixth_line( - "G1 X10 Y20 Z30 A40 B50 C60", - "G1 X10.000 Y20.000 Z30.000 A40.000 B50.000 C60.000 ", - "--no-header --no-show-editor", - ) - self.compare_sixth_line( - "G1 X10 Y20 Z30 A40 B50 C60", - "G1 X0.3937 Y0.7874 Z1.1811 A40.0000 B50.0000 C60.0000 ", - "--no-header --inches --no-show-editor", + def test_blend_mode_blend_with_custom_tolerance(self): + """Test BLEND mode with custom tolerance value.""" + self.profile_op.Path = Path.Path([]) + self.job.PostProcessorArgs = ( + "--no-header --no-comments --blend-mode BLEND --blend-tolerance 0.02 --no-show-editor" ) + gcode = self.post.export()[0][1] - def test110(self): - """Test A, B, & C axis output for 89 degrees""" - self.compare_sixth_line( - "G1 X10 Y20 Z30 A89 B89 C89", - "G1 X10.000 Y20.000 Z30.000 A89.000 B89.000 C89.000 ", - "--no-header --no-show-editor", - ) - self.compare_sixth_line( - "G1 X10 Y20 Z30 A89 B89 C89", - "G1 X0.3937 Y0.7874 Z1.1811 A89.0000 B89.0000 C89.0000 ", - "--no-header --inches --no-show-editor", - ) + # G64 P0.02 should be in the preamble + self.assertIn("G64 P0.0200", gcode) - def test120(self): - """Test A, B, & C axis output for 90 degrees""" - self.compare_sixth_line( - "G1 X10 Y20 Z30 A90 B90 C90", - "G1 X10.000 Y20.000 Z30.000 A90.000 B90.000 C90.000 ", - "--no-header --no-show-editor", - ) - self.compare_sixth_line( - "G1 X10 Y20 Z30 A90 B90 C90", - "G1 X0.3937 Y0.7874 Z1.1811 A90.0000 B90.0000 C90.0000 ", - "--no-header --inches --no-show-editor", + def test_blend_mode_in_preamble_position(self): + """Test that blend mode command appears in correct position in preamble.""" + self.profile_op.Path = Path.Path([]) + self.job.PostProcessorArgs = ( + "--no-header --no-comments --blend-mode BLEND --blend-tolerance 0.1 --no-show-editor" ) + gcode = self.post.export()[0][1] + lines = gcode.splitlines() - def test130(self): - """Test A, B, & C axis output for 91 degrees""" - self.compare_sixth_line( - "G1 X10 Y20 Z30 A91 B91 C91", - "G1 X10.000 Y20.000 Z30.000 A91.000 B91.000 C91.000 ", - "--no-header --no-show-editor", - ) - self.compare_sixth_line( - "G1 X10 Y20 Z30 A91 B91 C91", - "G1 X0.3937 Y0.7874 Z1.1811 A91.0000 B91.0000 C91.0000 ", - "--no-header --inches --no-show-editor", - ) + # Find G64 P line + g64_line_idx = None + for i, line in enumerate(lines): + if "G64 P" in line: + g64_line_idx = i + break - def test140(self): - """Test A, B, & C axis output for values between 90 and 180 degrees""" - self.compare_sixth_line( - "G1 X10 Y20 Z30 A100 B110 C120", - "G1 X10.000 Y20.000 Z30.000 A100.000 B110.000 C120.000 ", - "--no-header --no-show-editor", - ) - self.compare_sixth_line( - "G1 X10 Y20 Z30 A100 B110 C120", - "G1 X0.3937 Y0.7874 Z1.1811 A100.0000 B110.0000 C120.0000 ", - "--no-header --inches --no-show-editor", - ) + self.assertIsNotNone(g64_line_idx, "G64 P command not found") + # Should be early in output (within first few lines of preamble) + self.assertLess(g64_line_idx, 5, "G64 command should be in preamble") - def test150(self): - """Test A, B, & C axis output for values between 180 and 360 degrees""" - self.compare_sixth_line( - "G1 X10 Y20 Z30 A240 B250 C260", - "G1 X10.000 Y20.000 Z30.000 A240.000 B250.000 C260.000 ", - "--no-header --no-show-editor", - ) - self.compare_sixth_line( - "G1 X10 Y20 Z30 A240 B250 C260", - "G1 X0.3937 Y0.7874 Z1.1811 A240.0000 B250.0000 C260.0000 ", - "--no-header --inches --no-show-editor", + def test_blend_tolerance_zero_equals_no_tolerance(self): + """Test that blend tolerance of 0 outputs G64 without P parameter.""" + self.profile_op.Path = Path.Path([]) + self.job.PostProcessorArgs = ( + "--no-header --no-comments --blend-mode BLEND --blend-tolerance 0 --no-show-editor" ) + gcode = self.post.export()[0][1] - def test160(self): - """Test A, B, & C axis output for values greater than 360 degrees""" - self.compare_sixth_line( - "G1 X10 Y20 Z30 A440 B450 C460", - "G1 X10.000 Y20.000 Z30.000 A440.000 B450.000 C460.000 ", - "--no-header --no-show-editor", - ) - self.compare_sixth_line( - "G1 X10 Y20 Z30 A440 B450 C460", - "G1 X0.3937 Y0.7874 Z1.1811 A440.0000 B450.0000 C460.0000 ", - "--no-header --inches --no-show-editor", - ) - - def test170(self): - """Test A, B, & C axis output for values between 0 and -90 degrees""" - self.compare_sixth_line( - "G1 X10 Y20 Z30 A-40 B-50 C-60", - "G1 X10.000 Y20.000 Z30.000 A-40.000 B-50.000 C-60.000 ", - "--no-header --no-show-editor", - ) - self.compare_sixth_line( - "G1 X10 Y20 Z30 A-40 B-50 C-60", - "G1 X0.3937 Y0.7874 Z1.1811 A-40.0000 B-50.0000 C-60.0000 ", - "--no-header --inches --no-show-editor", - ) - - def test180(self): - """Test A, B, & C axis output for values between -90 and -180 degrees""" - self.compare_sixth_line( - "G1 X10 Y20 Z30 A-100 B-110 C-120", - "G1 X10.000 Y20.000 Z30.000 A-100.000 B-110.000 C-120.000 ", - "--no-header --no-show-editor", - ) - self.compare_sixth_line( - "G1 X10 Y20 Z30 A-100 B-110 C-120", - "G1 X0.3937 Y0.7874 Z1.1811 A-100.0000 B-110.0000 C-120.0000 ", - "--no-header --inches --no-show-editor", - ) - - def test190(self): - """Test A, B, & C axis output for values between -180 and -360 degrees""" - self.compare_sixth_line( - "G1 X10 Y20 Z30 A-240 B-250 C-260", - "G1 X10.000 Y20.000 Z30.000 A-240.000 B-250.000 C-260.000 ", - "--no-header --no-show-editor", - ) - self.compare_sixth_line( - "G1 X10 Y20 Z30 A-240 B-250 C-260", - "G1 X0.3937 Y0.7874 Z1.1811 A-240.0000 B-250.0000 C-260.0000 ", - "--no-header --inches --no-show-editor", - ) - - def test200(self): - """Test A, B, & C axis output for values below -360 degrees""" - self.compare_sixth_line( - "G1 X10 Y20 Z30 A-440 B-450 C-460", - "G1 X10.000 Y20.000 Z30.000 A-440.000 B-450.000 C-460.000 ", - "--no-header --no-show-editor", - ) - self.compare_sixth_line( - "G1 X10 Y20 Z30 A-440 B-450 C-460", - "G1 X0.3937 Y0.7874 Z1.1811 A-440.0000 B-450.0000 C-460.0000 ", - "--no-header --inches --no-show-editor", - ) + # Should have G64 without P + lines = gcode.splitlines() + has_g64_without_p = any("G64" in line and "P" not in line for line in lines) + self.assertTrue(has_g64_without_p, "Expected G64 without P parameter when tolerance is 0") diff --git a/src/Mod/CAM/CAMTests/TestMach3Mach4LegacyPost.py b/src/Mod/CAM/CAMTests/TestMach3Mach4LegacyPost.py new file mode 100644 index 0000000000..2f6ab13129 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestMach3Mach4LegacyPost.py @@ -0,0 +1,288 @@ +# *************************************************************************** +# * Copyright (c) 2022 sliptonic * +# * Copyright (c) 2022 Larry Woestman * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +from importlib import reload + +import FreeCAD + +import Path +import CAMTests.PathTestUtils as PathTestUtils +from Path.Post.scripts import mach3_mach4_legacy_post as postprocessor + + +Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) +Path.Log.trackModule(Path.Log.thisModule()) + + +class TestMach3Mach4LegacyPost(PathTestUtils.PathTestBase): + @classmethod + def setUpClass(cls): + """setUpClass()... + This method is called upon instantiation of this test class. Add code + and objects here that are needed for the duration of the test() methods + in this class. In other words, set up the 'global' test environment + here; use the `setUp()` method to set up a 'local' test environment. + This method does not have access to the class `self` reference, but it + is able to call static methods within this same class. + """ + + # Open existing FreeCAD document with test geometry + FreeCAD.newDocument("Unnamed") + + @classmethod + def tearDownClass(cls): + """tearDownClass()... + This method is called prior to destruction of this test class. Add + code and objects here that cleanup the test environment after the + test() methods in this class have been executed. This method does + not have access to the class `self` reference. This method is able + to call static methods within this same class. + """ + # Close geometry document without saving + FreeCAD.closeDocument(FreeCAD.ActiveDocument.Name) + + # Setup and tear down methods called before and after each unit test + def setUp(self): + """setUp()... + This method is called prior to each `test()` method. Add code and + objects here that are needed for multiple `test()` methods. + """ + self.doc = FreeCAD.ActiveDocument + self.con = FreeCAD.Console + self.docobj = FreeCAD.ActiveDocument.addObject("Path::Feature", "testpath") + reload( + postprocessor + ) # technical debt. This shouldn't be necessary but here to bypass a bug + + def tearDown(self): + """tearDown()... + This method is called after each test() method. Add cleanup instructions here. + Such cleanup instructions will likely undo those in the setUp() method. + """ + FreeCAD.ActiveDocument.removeObject("testpath") + + def test000(self): + """Test Output Generation. + Empty path. Produces only the preamble and postable. + """ + + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + + # Test generating with header + # Header contains a time stamp that messes up unit testing. + # Only test length of result. + args = "--no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertTrue(len(gcode.splitlines()) == 13) + + # Test without header + expected = """(begin preamble) +G17 G54 G40 G49 G80 G90 +G21 +(begin operation: testpath) +(machine: mach3_4, mm/min) +(finish operation: testpath) +(begin postamble) +M05 +G17 G54 G90 G80 G40 +M2 +""" + + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + # args = ("--no-header --no-comments --no-show-editor --precision=2") + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode, expected) + + # test without comments + expected = """G17 G54 G40 G49 G80 G90 +G21 +M05 +G17 G54 G90 G80 G40 +M2 +""" + + args = "--no-header --no-comments --no-show-editor" + # args = ("--no-header --no-comments --no-show-editor --precision=2") + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode, expected) + + def test010(self): + """Test command Generation. + Test Precision + """ + c = Path.Command("G0 X10 Y20 Z30") + + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = "G0 X10.000 Y20.000 Z30.000" + self.assertEqual(result, expected) + + args = "--no-header --precision=2 --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = "G0 X10.00 Y20.00 Z30.00" + self.assertEqual(result, expected) + + def test020(self): + """ + Test Line Numbers + """ + c = Path.Command("G0 X10 Y20 Z30") + + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --line-numbers --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = "N160 G0 X10.000 Y20.000 Z30.000" + self.assertEqual(result, expected) + + def test030(self): + """ + Test Pre-amble + """ + + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + + args = "--no-header --no-comments --preamble='G18 G55' --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[0] + self.assertEqual(result, "G18 G55") + + def test040(self): + """ + Test Post-amble + """ + self.docobj.Path = Path.Path([]) + postables = [self.docobj] + args = "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[-2] + self.assertEqual(result, "G0 Z50") + self.assertEqual(gcode.splitlines()[-1], "M2") + + def test050(self): + """ + Test inches + """ + + c = Path.Command("G0 X10 Y20 Z30") + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --inches --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode.splitlines()[2], "G20") + + result = gcode.splitlines()[5] + expected = "G0 X0.3937 Y0.7874 Z1.1811" + self.assertEqual(result, expected) + + # Technical debt. The following test fails. Precision not working + # with imperial units. + + # args = ("--no-header --inches --precision=2 --no-show-editor") + # gcode = postprocessor.export(postables, "-", args) + # result = gcode.splitlines()[5] + # expected = "G0 X0.39 Y0.79 Z1.18" + # self.assertEqual(result, expected) + + def test060(self): + """ + Test test modal + Suppress the command name if the same as previous + """ + c = Path.Command("G0 X10 Y20 Z30") + c1 = Path.Command("G0 X10 Y30 Z30") + + self.docobj.Path = Path.Path([c, c1]) + postables = [self.docobj] + + args = "--no-header --modal --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[6] + expected = "X10.000 Y30.000 Z30.000" + self.assertEqual(result, expected) + + def test070(self): + """ + Test axis modal + Suppress the axis coordinate if the same as previous + """ + c = Path.Command("G0 X10 Y20 Z30") + c1 = Path.Command("G0 X10 Y30 Z30") + + self.docobj.Path = Path.Path([c, c1]) + postables = [self.docobj] + + args = "--no-header --axis-modal --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[6] + expected = "G0 Y30.000" + self.assertEqual(result, expected) + + def test080(self): + """ + Test tool change + """ + c = Path.Command("M6 T2") + c2 = Path.Command("M3 S3000") + self.docobj.Path = Path.Path([c, c2]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode.splitlines()[5], "M5") + self.assertEqual(gcode.splitlines()[6], "M6 T2 ") + self.assertEqual(gcode.splitlines()[7], "G43 H2") + self.assertEqual(gcode.splitlines()[8], "M3 S3000") + + # suppress TLO + args = "--no-header --no-tlo --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + self.assertEqual(gcode.splitlines()[7], "M3 S3000") + + def test090(self): + """ + Test comment + """ + + c = Path.Command("(comment)") + + self.docobj.Path = Path.Path([c]) + postables = [self.docobj] + + args = "--no-header --no-show-editor" + gcode = postprocessor.export(postables, "-", args) + result = gcode.splitlines()[5] + expected = "(comment)" + self.assertEqual(result, expected) diff --git a/src/Mod/CAM/CAMTests/TestMach3Mach4Post.py b/src/Mod/CAM/CAMTests/TestMach3Mach4Post.py index 5bf7f4c614..7f4432aa3a 100644 --- a/src/Mod/CAM/CAMTests/TestMach3Mach4Post.py +++ b/src/Mod/CAM/CAMTests/TestMach3Mach4Post.py @@ -2,7 +2,7 @@ # *************************************************************************** # * Copyright (c) 2022 sliptonic * -# * Copyright (c) 2022 Larry Woestman * +# * Copyright (c) 2022 - 2025 Larry Woestman * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * @@ -22,13 +22,11 @@ # * * # *************************************************************************** -from importlib import reload - import FreeCAD import Path import CAMTests.PathTestUtils as PathTestUtils -from Path.Post.scripts import mach3_mach4_post as postprocessor +from Path.Post.Processor import PostProcessorFactory Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -36,9 +34,12 @@ Path.Log.trackModule(Path.Log.thisModule()) class TestMach3Mach4Post(PathTestUtils.PathTestBase): + """Test the mach3_mach4_post.py postprocessor.""" + @classmethod - def setUpClass(cls): + def setUpClass(cls) -> None: """setUpClass()... + This method is called upon instantiation of this test class. Add code and objects here that are needed for the duration of the test() methods in this class. In other words, set up the 'global' test environment @@ -47,108 +48,162 @@ class TestMach3Mach4Post(PathTestUtils.PathTestBase): is able to call static methods within this same class. """ - # Open existing FreeCAD document with test geometry - FreeCAD.newDocument("Unnamed") + 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, "mach3_mach4") + # 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): + def tearDownClass(cls) -> None: """tearDownClass()... + This method is called prior to destruction of this test class. Add code and objects here that cleanup the test environment after the - test() methods in this class have been executed. This method does - not have access to the class `self` reference. This method is able - to call static methods within this same class. + test() methods in this class have been executed. This method does not + have access to the class `self` reference. This method is able to + call static methods within this same class. """ - # Close geometry document without saving - FreeCAD.closeDocument(FreeCAD.ActiveDocument.Name) + FreeCAD.closeDocument(cls.doc.Name) + FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") # Setup and tear down methods called before and after each unit test - def setUp(self): + + def setUp(self) -> None: """setUp()... + This method is called prior to each `test()` method. Add code and objects here that are needed for multiple `test()` methods. """ - self.doc = FreeCAD.ActiveDocument - self.con = FreeCAD.Console - self.docobj = FreeCAD.ActiveDocument.addObject("Path::Feature", "testpath") - reload( - postprocessor - ) # technical debt. This shouldn't be necessary but here to bypass a bug + # allow a full length "diff" if an error occurs + self.maxDiff = None + # reinitialize the postprocessor data structures between tests + self.post.reinitialize() - def tearDown(self): + def tearDown(self) -> None: """tearDown()... + This method is called after each test() method. Add cleanup instructions here. Such cleanup instructions will likely undo those in the setUp() method. """ - FreeCAD.ActiveDocument.removeObject("testpath") + pass - def test000(self): + def single_compare(self, path, expected, args, debug=False): + """Perform a test with a single line of gcode comparison.""" + nl = "\n" + self.job.PostProcessorArgs = args + # replace the original path (that came with the job and operation) with our path + self.profile_op.Path = Path.Path(path) + # the gcode is in the first section for this particular job and operation + gcode = self.post.export()[0][1] + if debug: + print(f"--------{nl}{gcode}--------{nl}") + # there are 4 lines of "other stuff" before the line we are interested in + self.assertEqual(gcode.splitlines()[4], expected) + + def multi_compare(self, path, expected, args, debug=False): + """Perform a test with multiple lines of gcode comparison.""" + nl = "\n" + self.job.PostProcessorArgs = args + # replace the original path (that came with the job and operation) with our path + self.profile_op.Path = Path.Path(path) + # the gcode is in the first section for this particular job and operation + gcode = self.post.export()[0][1] + if debug: + print(f"--------{nl}{gcode}--------{nl}") + self.assertEqual(gcode, expected) + + def test000(self) -> None: """Test Output Generation. Empty path. Produces only the preamble and postable. """ + nl = "\n" - self.docobj.Path = Path.Path([]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([]) # Test generating with header # Header contains a time stamp that messes up unit testing. # Only test length of result. - args = "--no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertTrue(len(gcode.splitlines()) == 13) + self.job.PostProcessorArgs = "--no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + self.assertTrue(len(gcode.splitlines()) == 26) # Test without header - expected = """(begin preamble) + expected = """(Begin preamble) G17 G54 G40 G49 G80 G90 G21 -(begin operation: testpath) -(machine: mach3_4, mm/min) -(finish operation: testpath) -(begin postamble) +(Begin operation: Fixture) +(Machine: mach3_4, mm/min) +G54 +(Finish operation: Fixture) +(Begin operation: TC: Default Tool) +(Machine: mach3_4, mm/min) +(TC: Default Tool) +(Begin toolchange) +M5 +M6 T1 +G43 H1 +(Finish operation: TC: Default Tool) +(Begin operation: Profile) +(Machine: mach3_4, mm/min) +(Finish operation: Profile) +(Begin postamble) M05 G17 G54 G90 G80 G40 M2 """ - self.docobj.Path = Path.Path([]) - postables = [self.docobj] - - args = "--no-header --no-show-editor" # args = ("--no-header --no-comments --no-show-editor --precision=2") - gcode = postprocessor.export(postables, "-", args) + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") self.assertEqual(gcode, expected) # test without comments expected = """G17 G54 G40 G49 G80 G90 G21 +G54 +M5 +M6 T1 +G43 H1 M05 G17 G54 G90 G80 G40 M2 """ - args = "--no-header --no-comments --no-show-editor" # args = ("--no-header --no-comments --no-show-editor --precision=2") - gcode = postprocessor.export(postables, "-", args) + self.job.PostProcessorArgs = "--no-header --no-comments --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") self.assertEqual(gcode, expected) def test010(self): """Test command Generation. Test Precision """ + nl = "\n" + c = Path.Command("G0 X10 Y20 Z30") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c]) - args = "--no-header --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[17] expected = "G0 X10.000 Y20.000 Z30.000" self.assertEqual(result, expected) - args = "--no-header --precision=2 --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] + self.job.PostProcessorArgs = "--no-header --precision=2 --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[17] expected = "G0 X10.00 Y20.00 Z30.00" self.assertEqual(result, expected) @@ -156,27 +211,32 @@ M2 """ Test Line Numbers """ + nl = "\n" + c = Path.Command("G0 X10 Y20 Z30") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c]) - args = "--no-header --line-numbers --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] - expected = "N160 G0 X10.000 Y20.000 Z30.000" + self.job.PostProcessorArgs = "--no-header --line-numbers --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[17] + expected = "N270 G0 X10.000 Y20.000 Z30.000" self.assertEqual(result, expected) def test030(self): """ Test Pre-amble """ + nl = "\n" - self.docobj.Path = Path.Path([]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([]) - args = "--no-header --no-comments --preamble='G18 G55' --no-show-editor" - gcode = postprocessor.export(postables, "-", args) + self.job.PostProcessorArgs = ( + "--no-header --no-comments --preamble='G18 G55' --no-show-editor" + ) + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") result = gcode.splitlines()[0] self.assertEqual(result, "G18 G55") @@ -184,10 +244,15 @@ M2 """ Test Post-amble """ - self.docobj.Path = Path.Path([]) - postables = [self.docobj] - args = "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" - gcode = postprocessor.export(postables, "-", args) + nl = "\n" + + self.profile_op.Path = Path.Path([]) + + self.job.PostProcessorArgs = ( + "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" + ) + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") result = gcode.splitlines()[-2] self.assertEqual(result, "G0 Z50") self.assertEqual(gcode.splitlines()[-1], "M2") @@ -196,42 +261,44 @@ M2 """ Test inches """ + nl = "\n" c = Path.Command("G0 X10 Y20 Z30") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] - args = "--no-header --inches --no-show-editor" - gcode = postprocessor.export(postables, "-", args) + self.profile_op.Path = Path.Path([c]) + + self.job.PostProcessorArgs = "--no-header --inches --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") self.assertEqual(gcode.splitlines()[2], "G20") - result = gcode.splitlines()[5] + result = gcode.splitlines()[17] expected = "G0 X0.3937 Y0.7874 Z1.1811" self.assertEqual(result, expected) - # Technical debt. The following test fails. Precision not working - # with imperial units. - - # args = ("--no-header --inches --precision=2 --no-show-editor") - # gcode = postprocessor.export(postables, "-", args) - # result = gcode.splitlines()[5] - # expected = "G0 X0.39 Y0.79 Z1.18" - # self.assertEqual(result, expected) + self.job.PostProcessorArgs = "--no-header --inches --precision=2 --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[17] + expected = "G0 X0.39 Y0.79 Z1.18" + self.assertEqual(result, expected) def test060(self): """ Test test modal Suppress the command name if the same as previous """ + nl = "\n" + c = Path.Command("G0 X10 Y20 Z30") c1 = Path.Command("G0 X10 Y30 Z30") - self.docobj.Path = Path.Path([c, c1]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c, c1]) - args = "--no-header --modal --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[6] + self.job.PostProcessorArgs = "--no-header --modal --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[18] expected = "X10.000 Y30.000 Z30.000" self.assertEqual(result, expected) @@ -240,15 +307,17 @@ M2 Test axis modal Suppress the axis coordinate if the same as previous """ + nl = "\n" + c = Path.Command("G0 X10 Y20 Z30") c1 = Path.Command("G0 X10 Y30 Z30") - self.docobj.Path = Path.Path([c, c1]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c, c1]) - args = "--no-header --axis-modal --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[6] + self.job.PostProcessorArgs = "--no-header --axis-modal --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[18] expected = "G0 Y30.000" self.assertEqual(result, expected) @@ -256,35 +325,41 @@ M2 """ Test tool change """ + nl = "\n" + c = Path.Command("M6 T2") c2 = Path.Command("M3 S3000") - self.docobj.Path = Path.Path([c, c2]) - postables = [self.docobj] - args = "--no-header --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertEqual(gcode.splitlines()[5], "M5") - self.assertEqual(gcode.splitlines()[6], "M6 T2 ") - self.assertEqual(gcode.splitlines()[7], "G43 H2") - self.assertEqual(gcode.splitlines()[8], "M3 S3000") + self.profile_op.Path = Path.Path([c, c2]) + + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + split_gcode = gcode.splitlines() + self.assertEqual(split_gcode[18], "M5") + self.assertEqual(split_gcode[19], "M6 T2") + self.assertEqual(split_gcode[20], "G43 H2") + self.assertEqual(split_gcode[21], "M3 S3000") # suppress TLO - args = "--no-header --no-tlo --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - self.assertEqual(gcode.splitlines()[7], "M3 S3000") + self.job.PostProcessorArgs = "--no-header --no-tlo --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + self.assertEqual(gcode.splitlines()[19], "M3 S3000") def test090(self): """ Test comment """ + nl = "\n" c = Path.Command("(comment)") - self.docobj.Path = Path.Path([c]) - postables = [self.docobj] + self.profile_op.Path = Path.Path([c]) - args = "--no-header --no-show-editor" - gcode = postprocessor.export(postables, "-", args) - result = gcode.splitlines()[5] + self.job.PostProcessorArgs = "--no-header --no-show-editor" + gcode = self.post.export()[0][1] + # print(f"--------{nl}{gcode}--------{nl}") + result = gcode.splitlines()[17] expected = "(comment)" self.assertEqual(result, expected) diff --git a/src/Mod/CAM/CAMTests/TestRefactoredMassoG3Post.py b/src/Mod/CAM/CAMTests/TestMassoG3Post.py similarity index 98% rename from src/Mod/CAM/CAMTests/TestRefactoredMassoG3Post.py rename to src/Mod/CAM/CAMTests/TestMassoG3Post.py index 90e32c2de9..a7b93f1987 100644 --- a/src/Mod/CAM/CAMTests/TestRefactoredMassoG3Post.py +++ b/src/Mod/CAM/CAMTests/TestMassoG3Post.py @@ -43,7 +43,7 @@ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) -class TestRefactoredMassoG3Post(PathTestUtils.PathTestBase): +class TestMassoG3Post(PathTestUtils.PathTestBase): @classmethod def setUpClass(cls): """setUpClass()... @@ -58,7 +58,7 @@ class TestRefactoredMassoG3Post(PathTestUtils.PathTestBase): 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, "refactored_masso_g3") + cls.post = PostProcessorFactory.get_post_processor(cls.job, "masso_g3") # locate the operation named "Profile" for op in cls.job.Operations.Group: if op.Label == "Profile": @@ -109,7 +109,6 @@ class TestRefactoredMassoG3Post(PathTestUtils.PathTestBase): # Only test length of result. self.job.PostProcessorArgs = "--no-show-editor" gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") self.assertTrue(len(gcode.splitlines()) == 26) # Test without header diff --git a/src/Mod/CAM/CAMTests/TestPathPost.py b/src/Mod/CAM/CAMTests/TestPathPost.py index 4fde303f77..3955e3d334 100644 --- a/src/Mod/CAM/CAMTests/TestPathPost.py +++ b/src/Mod/CAM/CAMTests/TestPathPost.py @@ -470,14 +470,14 @@ class TestPostProcessorFactory(unittest.TestCase): def test030(self): # test wrapping of old school postprocessor scripts - post = PostProcessorFactory.get_post_processor(self.job, "linuxcnc") + post = PostProcessorFactory.get_post_processor(self.job, "linuxcnc_legacy") self.assertTrue(post is not None) self.assertTrue(hasattr(post, "_buildPostList")) def test040(self): """Test that the __name__ of the postprocessor is correct.""" - post = PostProcessorFactory.get_post_processor(self.job, "linuxcnc") - self.assertEqual(post.script_module.__name__, "linuxcnc_post") + post = PostProcessorFactory.get_post_processor(self.job, "linuxcnc_legacy") + self.assertEqual(post.script_module.__name__, "linuxcnc_legacy_post") class TestPathPostUtils(unittest.TestCase): diff --git a/src/Mod/CAM/CAMTests/TestRefactoredTestPostGCodes.py b/src/Mod/CAM/CAMTests/TestPostGCodes.py similarity index 99% rename from src/Mod/CAM/CAMTests/TestRefactoredTestPostGCodes.py rename to src/Mod/CAM/CAMTests/TestPostGCodes.py index 7848dfadf3..d6ad008dd1 100644 --- a/src/Mod/CAM/CAMTests/TestRefactoredTestPostGCodes.py +++ b/src/Mod/CAM/CAMTests/TestPostGCodes.py @@ -33,8 +33,8 @@ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) -class TestRefactoredTestPostGCodes(PathTestUtils.PathTestBase): - """Test the refactored_test_post.py postprocessor G codes.""" +class TestPostGCodes(PathTestUtils.PathTestBase): + """Test the test_post.py postprocessor G codes.""" @classmethod def setUpClass(cls): @@ -50,7 +50,7 @@ class TestRefactoredTestPostGCodes(PathTestUtils.PathTestBase): 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, "refactored_test") + cls.post = PostProcessorFactory.get_post_processor(cls.job, "test") # locate the operation named "Profile" for op in cls.job.Operations.Group: if op.Label == "Profile": diff --git a/src/Mod/CAM/CAMTests/TestRefactoredTestPostMCodes.py b/src/Mod/CAM/CAMTests/TestPostMCodes.py similarity index 98% rename from src/Mod/CAM/CAMTests/TestRefactoredTestPostMCodes.py rename to src/Mod/CAM/CAMTests/TestPostMCodes.py index c430987c3d..ef30401006 100644 --- a/src/Mod/CAM/CAMTests/TestRefactoredTestPostMCodes.py +++ b/src/Mod/CAM/CAMTests/TestPostMCodes.py @@ -33,8 +33,8 @@ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) -class TestRefactoredTestPostMCodes(PathTestUtils.PathTestBase): - """Test the refactored_test_post.py postprocessor.""" +class TestPostMCodes(PathTestUtils.PathTestBase): + """Test the test_post.py postprocessor M codes.""" @classmethod def setUpClass(cls): @@ -50,7 +50,7 @@ class TestRefactoredTestPostMCodes(PathTestUtils.PathTestBase): 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, "refactored_test") + cls.post = PostProcessorFactory.get_post_processor(cls.job, "test") # locate the operation named "Profile" for op in cls.job.Operations.Group: if op.Label == "Profile": diff --git a/src/Mod/CAM/CAMTests/TestRefactoredCentroidPost.py b/src/Mod/CAM/CAMTests/TestRefactoredCentroidPost.py deleted file mode 100644 index 4abd170178..0000000000 --- a/src/Mod/CAM/CAMTests/TestRefactoredCentroidPost.py +++ /dev/null @@ -1,361 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * Copyright (c) 2022 sliptonic * -# * Copyright (c) 2022 - 2025 Larry Woestman * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * This program is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Library General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with this program; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -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 TestRefactoredCentroidPost(PathTestUtils.PathTestBase): - """Test the refactored_centroid_post.py postprocessor.""" - - @classmethod - def setUpClass(cls): - """setUpClass()... - - This method is called upon instantiation of this test class. Add code - and objects here that are needed for the duration of the test() methods - in this class. In other words, set up the 'global' test environment - here; use the `setUp()` method to set up a 'local' test environment. - This method does not have access to the class `self` reference, but it - is able to call static methods within this same class. - """ - - 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, "refactored_centroid") - # 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): - """tearDownClass()... - - This method is called prior to destruction of this test class. Add - code and objects here that cleanup the test environment after the - test() methods in this class have been executed. This method does not - have access to the class `self` reference. This method is able to - call static methods within this same class. - """ - FreeCAD.closeDocument(cls.doc.Name) - FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") - - # Setup and tear down methods called before and after each unit test - - def setUp(self): - """setUp()... - - This method is called prior to each `test()` method. Add code and - objects here that are needed for multiple `test()` methods. - """ - # allow a full length "diff" if an error occurs - self.maxDiff = None - # reinitialize the postprocessor data structures between tests - self.post.reinitialize() - - def tearDown(self): - """tearDown()... - - This method is called after each test() method. Add cleanup instructions here. - Such cleanup instructions will likely undo those in the setUp() method. - """ - pass - - def single_compare(self, path, expected, args, debug=False): - """Perform a test with a single line of gcode comparison.""" - nl = "\n" - self.job.PostProcessorArgs = args - # replace the original path (that came with the job and operation) with our path - self.profile_op.Path = Path.Path(path) - # the gcode is in the first section for this particular job and operation - gcode = self.post.export()[0][1] - if debug: - print(f"--------{nl}{gcode}--------{nl}") - # there are 4 lines of "other stuff" before the line we are interested in - self.assertEqual(gcode.splitlines()[4], expected) - - def multi_compare(self, path, expected, args, debug=False): - """Perform a test with multiple lines of gcode comparison.""" - nl = "\n" - self.job.PostProcessorArgs = args - # replace the original path (that came with the job and operation) with our path - self.profile_op.Path = Path.Path(path) - # the gcode is in the first section for this particular job and operation - gcode = self.post.export()[0][1] - if debug: - print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode, expected) - - def test000(self): - """Test Output Generation. - Empty path. Produces only the preamble and postable. - """ - nl = "\n" - - self.profile_op.Path = Path.Path([]) - - # Test generating with header - # Header contains a time stamp that messes up unit testing. - # Only test length of result. - self.job.PostProcessorArgs = "--no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertTrue(len(gcode.splitlines()) == 25) - - # Test without header - expected = """G90 G80 G40 G49 -;T1=TC__Default_Tool -;Begin preamble -G53 G00 G17 -G21 -;Begin operation -G54 -;End operation -;Begin operation -;TC: Default Tool -;Begin toolchange -M6 T1 -;End operation -;Begin operation -;End operation -;Begin postamble -M5 -M25 -G49 H0 -G90 G80 G40 G49 -M99 -""" - - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode, expected) - - # test without comments - expected = """G90 G80 G40 G49 -G53 G00 G17 -G21 -G54 -M6 T1 -M5 -M25 -G49 H0 -G90 G80 G40 G49 -M99 -""" - - # args = ("--no-header --no-comments --no-show-editor --precision=2") - self.job.PostProcessorArgs = "--no-header --no-comments --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode, expected) - - def test010(self): - """Test command Generation. - Test Precision - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - - self.profile_op.Path = Path.Path([c]) - - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[14] - expected = "G0 X10.0000 Y20.0000 Z30.0000" - self.assertEqual(result, expected) - - self.job.PostProcessorArgs = "--no-header --axis-precision=2 --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[14] - expected = "G0 X10.00 Y20.00 Z30.00" - self.assertEqual(result, expected) - - def test020(self): - """ - Test Line Numbers - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - - self.profile_op.Path = Path.Path([c]) - - self.job.PostProcessorArgs = "--no-header --line-numbers --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[14] - expected = "N240 G0 X10.0000 Y20.0000 Z30.0000" - self.assertEqual(result, expected) - - def test030(self): - """ - Test Pre-amble - """ - nl = "\n" - - self.profile_op.Path = Path.Path([]) - - self.job.PostProcessorArgs = ( - "--no-header --no-comments --preamble='G18 G55' --no-show-editor" - ) - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[1] - self.assertEqual(result, "G18 G55") - - def test040(self): - """ - Test Post-amble - """ - nl = "\n" - - self.profile_op.Path = Path.Path([]) - - self.job.PostProcessorArgs = ( - "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" - ) - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[-2] - self.assertEqual(result, "G0 Z50") - self.assertEqual(gcode.splitlines()[-1], "M2") - - def test050(self): - """ - Test inches - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - - self.profile_op.Path = Path.Path([c]) - - self.job.PostProcessorArgs = "--no-header --inches --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode.splitlines()[4], "G20") - - result = gcode.splitlines()[14] - expected = "G0 X0.3937 Y0.7874 Z1.1811" - self.assertEqual(result, expected) - - self.job.PostProcessorArgs = "--no-header --inches --axis-precision=2 --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[14] - expected = "G0 X0.39 Y0.79 Z1.18" - self.assertEqual(result, expected) - - def test060(self): - """ - Test test modal - Suppress the command name if the same as previous - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - c1 = Path.Command("G0 X10 Y30 Z30") - - self.profile_op.Path = Path.Path([c, c1]) - - self.job.PostProcessorArgs = "--no-header --modal --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[15] - expected = "X10.0000 Y30.0000 Z30.0000" - self.assertEqual(result, expected) - - def test070(self): - """ - Test axis modal - Suppress the axis coordinate if the same as previous - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - c1 = Path.Command("G0 X10 Y30 Z30") - - self.profile_op.Path = Path.Path([c, c1]) - - self.job.PostProcessorArgs = "--no-header --axis-modal --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[15] - expected = "G0 Y30.0000" - self.assertEqual(result, expected) - - def test080(self): - """ - Test tool change - """ - nl = "\n" - - c = Path.Command("M6 T2") - c2 = Path.Command("M3 S3000") - - self.profile_op.Path = Path.Path([c, c2]) - - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode.splitlines()[15], "M6 T2") - self.assertEqual(gcode.splitlines()[16], "M3 S3000") - - # suppress TLO - self.job.PostProcessorArgs = "--no-header --no-tlo --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode.splitlines()[16], "M3 S3000") - - def test090(self): - """ - Test comment - """ - nl = "\n" - - c = Path.Command("(comment)") - - self.profile_op.Path = Path.Path([c]) - - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[14] - expected = ";comment" - self.assertEqual(result, expected) diff --git a/src/Mod/CAM/CAMTests/TestRefactoredGrblPost.py b/src/Mod/CAM/CAMTests/TestRefactoredGrblPost.py deleted file mode 100644 index c4dbd697e8..0000000000 --- a/src/Mod/CAM/CAMTests/TestRefactoredGrblPost.py +++ /dev/null @@ -1,353 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * Copyright (c) 2022 sliptonic * -# * Copyright (c) 2022 - 2025 Larry Woestman * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * This program is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Library General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with this program; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -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 TestRefactoredGrblPost(PathTestUtils.PathTestBase): - """Test the refactored_grbl_post.py postprocessor.""" - - @classmethod - def setUpClass(cls): - """setUpClass()... - - This method is called upon instantiation of this test class. Add code - and objects here that are needed for the duration of the test() methods - in this class. In other words, set up the 'global' test environment - here; use the `setUp()` method to set up a 'local' test environment. - This method does not have access to the class `self` reference, but it - is able to call static methods within this same class. - """ - - 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, "refactored_grbl") - # 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): - """tearDownClass()... - - This method is called prior to destruction of this test class. Add - code and objects here that cleanup the test environment after the - test() methods in this class have been executed. This method does not - have access to the class `self` reference. This method - is able to call static methods within this same class. - """ - FreeCAD.closeDocument(cls.doc.Name) - FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") - - # Setup and tear down methods called before and after each unit test - - def setUp(self): - """setUp()... - - This method is called prior to each `test()` method. Add code and - objects here that are needed for multiple `test()` methods. - """ - # allow a full length "diff" if an error occurs - self.maxDiff = None - # reinitialize the postprocessor data structures between tests - self.post.reinitialize() - - def tearDown(self): - """tearDown()... - - This method is called after each test() method. Add cleanup instructions here. - Such cleanup instructions will likely undo those in the setUp() method. - """ - pass - - def test000(self): - """Test Output Generation. - Empty path. Produces only the preamble and postable. - """ - nl = "\n" - - self.profile_op.Path = Path.Path([]) - - # Test generating with header - # Header contains a time stamp that messes up unit testing. - # Only test length of result. - self.job.PostProcessorArgs = "--no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertTrue(len(gcode.splitlines()) == 24) - - # Test without header - expected = """(Begin preamble) -G17 G90 -G21 -(Begin operation: Fixture) -(Path: Fixture) -G54 -(Finish operation: Fixture) -(Begin operation: TC: Default Tool) -(Path: TC: Default Tool) -(TC: Default Tool) -(Begin toolchange) -(M6 T1) -(Finish operation: TC: Default Tool) -(Begin operation: Profile) -(Path: Profile) -(Finish operation: Profile) -(Begin postamble) -M5 -G17 G90 -M2 -""" - - self.profile_op.Path = Path.Path([]) - - # args = ("--no-header --no-comments --no-show-editor --precision=2") - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode, expected) - - # test without comments - expected = """G17 G90 -G21 -G54 -M5 -G17 G90 -M2 -""" - - # args = ("--no-header --no-comments --no-show-editor --precision=2") - self.job.PostProcessorArgs = "--no-header --no-comments --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode, expected) - - def test010(self): - """Test command Generation. - Test Precision - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - - self.profile_op.Path = Path.Path([c]) - - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[15] - expected = "G0 X10.000 Y20.000 Z30.000" - self.assertEqual(result, expected) - - self.job.PostProcessorArgs = "--no-header --precision=2 --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[15] - expected = "G0 X10.00 Y20.00 Z30.00" - self.assertEqual(result, expected) - - def test020(self): - """ - Test Line Numbers - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - - self.profile_op.Path = Path.Path([c]) - - self.job.PostProcessorArgs = "--no-header --line-numbers --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[15] - expected = "N250 G0 X10.000 Y20.000 Z30.000" - self.assertEqual(result, expected) - - def test030(self): - """ - Test Pre-amble - """ - nl = "\n" - - self.profile_op.Path = Path.Path([]) - - self.job.PostProcessorArgs = ( - "--no-header --no-comments --preamble='G18 G55' --no-show-editor" - ) - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[0] - self.assertEqual(result, "G18 G55") - - def test040(self): - """ - Test Post-amble - """ - nl = "\n" - - self.profile_op.Path = Path.Path([]) - - self.job.PostProcessorArgs = ( - "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" - ) - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[-2] - self.assertEqual(result, "G0 Z50") - self.assertEqual(gcode.splitlines()[-1], "M2") - - def test050(self): - """ - Test inches - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - - self.profile_op.Path = Path.Path([c]) - - self.job.PostProcessorArgs = "--no-header --inches --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode.splitlines()[2], "G20") - - result = gcode.splitlines()[15] - expected = "G0 X0.3937 Y0.7874 Z1.1811" - self.assertEqual(result, expected) - - self.job.PostProcessorArgs = "--no-header --inches --precision=2 --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[15] - expected = "G0 X0.39 Y0.79 Z1.18" - self.assertEqual(result, expected) - - def test060(self): - """ - Test test modal - Suppress the command name if the same as previous - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - c1 = Path.Command("G0 X10 Y30 Z30") - - self.profile_op.Path = Path.Path([c, c1]) - - self.job.PostProcessorArgs = "--no-header --modal --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[16] - expected = "X10.000 Y30.000 Z30.000" - self.assertEqual(result, expected) - - def test070(self): - """ - Test axis modal - Suppress the axis coordinate if the same as previous - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - c1 = Path.Command("G0 X10 Y30 Z30") - - self.profile_op.Path = Path.Path([c, c1]) - - self.job.PostProcessorArgs = "--no-header --axis-modal --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[16] - expected = "G0 Y30.000" - self.assertEqual(result, expected) - - def test080(self): - """ - Test tool change - """ - nl = "\n" - - c = Path.Command("M6 T2") - c2 = Path.Command("M3 S3000") - - self.profile_op.Path = Path.Path([c, c2]) - - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode.splitlines()[16], "(M6 T2)") - self.assertEqual(gcode.splitlines()[17], "M3 S3000") - - # suppress TLO - self.job.PostProcessorArgs = "--no-header --no-tlo --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode.splitlines()[17], "M3 S3000") - - def test090(self): - """ - Test comment - """ - nl = "\n" - - c = Path.Command("(comment)") - - self.profile_op.Path = Path.Path([c]) - - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[15] - expected = "(comment)" - self.assertEqual(result, expected) - - def test100(self): - """ - Test if coolant is enabled. - """ - nl = "\n" - - c = Path.Command("M7") - c1 = Path.Command("M8") - c2 = Path.Command("M9") - - self.profile_op.Path = Path.Path([c, c1, c2]) - - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode.splitlines()[15], "M7") - self.assertEqual(gcode.splitlines()[16], "M8") - self.assertEqual(gcode.splitlines()[17], "M9") diff --git a/src/Mod/CAM/CAMTests/TestRefactoredMach3Mach4Post.py b/src/Mod/CAM/CAMTests/TestRefactoredMach3Mach4Post.py deleted file mode 100644 index 2dfe0505ff..0000000000 --- a/src/Mod/CAM/CAMTests/TestRefactoredMach3Mach4Post.py +++ /dev/null @@ -1,365 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * Copyright (c) 2022 sliptonic * -# * Copyright (c) 2022 - 2025 Larry Woestman * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * This program is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Library General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with this program; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -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 TestRefactoredMach3Mach4Post(PathTestUtils.PathTestBase): - """Test the refactored_mach3_mach4_post.py postprocessor.""" - - @classmethod - def setUpClass(cls) -> None: - """setUpClass()... - - This method is called upon instantiation of this test class. Add code - and objects here that are needed for the duration of the test() methods - in this class. In other words, set up the 'global' test environment - here; use the `setUp()` method to set up a 'local' test environment. - This method does not have access to the class `self` reference, but it - is able to call static methods within this same class. - """ - - 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, "refactored_mach3_mach4") - # 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) -> None: - """tearDownClass()... - - This method is called prior to destruction of this test class. Add - code and objects here that cleanup the test environment after the - test() methods in this class have been executed. This method does not - have access to the class `self` reference. This method is able to - call static methods within this same class. - """ - FreeCAD.closeDocument(cls.doc.Name) - FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") - - # Setup and tear down methods called before and after each unit test - - def setUp(self) -> None: - """setUp()... - - This method is called prior to each `test()` method. Add code and - objects here that are needed for multiple `test()` methods. - """ - # allow a full length "diff" if an error occurs - self.maxDiff = None - # reinitialize the postprocessor data structures between tests - self.post.reinitialize() - - def tearDown(self) -> None: - """tearDown()... - - This method is called after each test() method. Add cleanup instructions here. - Such cleanup instructions will likely undo those in the setUp() method. - """ - pass - - def single_compare(self, path, expected, args, debug=False): - """Perform a test with a single line of gcode comparison.""" - nl = "\n" - self.job.PostProcessorArgs = args - # replace the original path (that came with the job and operation) with our path - self.profile_op.Path = Path.Path(path) - # the gcode is in the first section for this particular job and operation - gcode = self.post.export()[0][1] - if debug: - print(f"--------{nl}{gcode}--------{nl}") - # there are 4 lines of "other stuff" before the line we are interested in - self.assertEqual(gcode.splitlines()[4], expected) - - def multi_compare(self, path, expected, args, debug=False): - """Perform a test with multiple lines of gcode comparison.""" - nl = "\n" - self.job.PostProcessorArgs = args - # replace the original path (that came with the job and operation) with our path - self.profile_op.Path = Path.Path(path) - # the gcode is in the first section for this particular job and operation - gcode = self.post.export()[0][1] - if debug: - print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode, expected) - - def test000(self) -> None: - """Test Output Generation. - Empty path. Produces only the preamble and postable. - """ - nl = "\n" - - self.profile_op.Path = Path.Path([]) - - # Test generating with header - # Header contains a time stamp that messes up unit testing. - # Only test length of result. - self.job.PostProcessorArgs = "--no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertTrue(len(gcode.splitlines()) == 26) - - # Test without header - expected = """(Begin preamble) -G17 G54 G40 G49 G80 G90 -G21 -(Begin operation: Fixture) -(Machine: mach3_4, mm/min) -G54 -(Finish operation: Fixture) -(Begin operation: TC: Default Tool) -(Machine: mach3_4, mm/min) -(TC: Default Tool) -(Begin toolchange) -M5 -M6 T1 -G43 H1 -(Finish operation: TC: Default Tool) -(Begin operation: Profile) -(Machine: mach3_4, mm/min) -(Finish operation: Profile) -(Begin postamble) -M05 -G17 G54 G90 G80 G40 -M2 -""" - - # args = ("--no-header --no-comments --no-show-editor --precision=2") - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode, expected) - - # test without comments - expected = """G17 G54 G40 G49 G80 G90 -G21 -G54 -M5 -M6 T1 -G43 H1 -M05 -G17 G54 G90 G80 G40 -M2 -""" - - # args = ("--no-header --no-comments --no-show-editor --precision=2") - self.job.PostProcessorArgs = "--no-header --no-comments --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode, expected) - - def test010(self): - """Test command Generation. - Test Precision - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - - self.profile_op.Path = Path.Path([c]) - - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[17] - expected = "G0 X10.000 Y20.000 Z30.000" - self.assertEqual(result, expected) - - self.job.PostProcessorArgs = "--no-header --precision=2 --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[17] - expected = "G0 X10.00 Y20.00 Z30.00" - self.assertEqual(result, expected) - - def test020(self): - """ - Test Line Numbers - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - - self.profile_op.Path = Path.Path([c]) - - self.job.PostProcessorArgs = "--no-header --line-numbers --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[17] - expected = "N270 G0 X10.000 Y20.000 Z30.000" - self.assertEqual(result, expected) - - def test030(self): - """ - Test Pre-amble - """ - nl = "\n" - - self.profile_op.Path = Path.Path([]) - - self.job.PostProcessorArgs = ( - "--no-header --no-comments --preamble='G18 G55' --no-show-editor" - ) - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[0] - self.assertEqual(result, "G18 G55") - - def test040(self): - """ - Test Post-amble - """ - nl = "\n" - - self.profile_op.Path = Path.Path([]) - - self.job.PostProcessorArgs = ( - "--no-header --no-comments --postamble='G0 Z50\nM2' --no-show-editor" - ) - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[-2] - self.assertEqual(result, "G0 Z50") - self.assertEqual(gcode.splitlines()[-1], "M2") - - def test050(self): - """ - Test inches - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - - self.profile_op.Path = Path.Path([c]) - - self.job.PostProcessorArgs = "--no-header --inches --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode.splitlines()[2], "G20") - - result = gcode.splitlines()[17] - expected = "G0 X0.3937 Y0.7874 Z1.1811" - self.assertEqual(result, expected) - - self.job.PostProcessorArgs = "--no-header --inches --precision=2 --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[17] - expected = "G0 X0.39 Y0.79 Z1.18" - self.assertEqual(result, expected) - - def test060(self): - """ - Test test modal - Suppress the command name if the same as previous - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - c1 = Path.Command("G0 X10 Y30 Z30") - - self.profile_op.Path = Path.Path([c, c1]) - - self.job.PostProcessorArgs = "--no-header --modal --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[18] - expected = "X10.000 Y30.000 Z30.000" - self.assertEqual(result, expected) - - def test070(self): - """ - Test axis modal - Suppress the axis coordinate if the same as previous - """ - nl = "\n" - - c = Path.Command("G0 X10 Y20 Z30") - c1 = Path.Command("G0 X10 Y30 Z30") - - self.profile_op.Path = Path.Path([c, c1]) - - self.job.PostProcessorArgs = "--no-header --axis-modal --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[18] - expected = "G0 Y30.000" - self.assertEqual(result, expected) - - def test080(self): - """ - Test tool change - """ - nl = "\n" - - c = Path.Command("M6 T2") - c2 = Path.Command("M3 S3000") - - self.profile_op.Path = Path.Path([c, c2]) - - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - split_gcode = gcode.splitlines() - self.assertEqual(split_gcode[18], "M5") - self.assertEqual(split_gcode[19], "M6 T2") - self.assertEqual(split_gcode[20], "G43 H2") - self.assertEqual(split_gcode[21], "M3 S3000") - - # suppress TLO - self.job.PostProcessorArgs = "--no-header --no-tlo --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - self.assertEqual(gcode.splitlines()[19], "M3 S3000") - - def test090(self): - """ - Test comment - """ - nl = "\n" - - c = Path.Command("(comment)") - - self.profile_op.Path = Path.Path([c]) - - self.job.PostProcessorArgs = "--no-header --no-show-editor" - gcode = self.post.export()[0][1] - # print(f"--------{nl}{gcode}--------{nl}") - result = gcode.splitlines()[17] - expected = "(comment)" - self.assertEqual(result, expected) diff --git a/src/Mod/CAM/CAMTests/TestRefactoredTestPost.py b/src/Mod/CAM/CAMTests/TestTestPost.py similarity index 99% rename from src/Mod/CAM/CAMTests/TestRefactoredTestPost.py rename to src/Mod/CAM/CAMTests/TestTestPost.py index 76aad6155e..b6590c3110 100644 --- a/src/Mod/CAM/CAMTests/TestRefactoredTestPost.py +++ b/src/Mod/CAM/CAMTests/TestTestPost.py @@ -39,8 +39,8 @@ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) -class TestRefactoredTestPost(PathTestUtils.PathTestBase): - """Test the refactored_test_post.py postprocessor command line arguments.""" +class TestTestPost(PathTestUtils.PathTestBase): + """Test the test_post.py postprocessor command line arguments.""" @classmethod def setUpClass(cls): @@ -56,7 +56,7 @@ class TestRefactoredTestPost(PathTestUtils.PathTestBase): 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, "refactored_test") + cls.post = PostProcessorFactory.get_post_processor(cls.job, "test") # locate the operation named "Profile" for op in cls.job.Operations.Group: if op.Label == "Profile": @@ -944,7 +944,7 @@ G54 # print(f"--------{nl}{gcode}--------{nl}") split_gcode = gcode.splitlines() self.assertEqual(split_gcode[0], "(Exported by FreeCAD)") - self.assertEqual(split_gcode[1], "(Post Processor: refactored_test_post)") + self.assertEqual(split_gcode[1], "(Post Processor: test_post)") self.assertEqual(split_gcode[2], "(Cam File: boxtest.fcstd)") # The header contains a time stamp that messes up unit testing. # Only test the length of the line that contains the time. @@ -992,7 +992,7 @@ G54 split_gcode = gcode.splitlines() # print(f"--------{nl}{gcode}--------{nl}") self.assertEqual(split_gcode[0], "(Exported by FreeCAD)") - self.assertEqual(split_gcode[1], "(Post Processor: refactored_test_post)") + self.assertEqual(split_gcode[1], "(Post Processor: test_post)") self.assertEqual(split_gcode[2], "(Cam File: boxtest.fcstd)") # The header contains a time stamp that messes up unit testing. # Only test the length of the line that contains the time. @@ -1278,6 +1278,8 @@ G0 Z8.000 e.g. --return-to=0,0,0 (default is do not move) --show-editor Pop up editor before writing output (default) --no-show-editor Don't pop up editor before writing output + --split-arcs Convert G2/G3 arc commands to discrete G1 line segments + --no-split-arcs Output G2/G3 arc commands as-is (default) --tlo Output tool length offset (G43) following tool changes (default) --no-tlo Suppress tool length offset (G43) following tool diff --git a/src/Mod/CAM/CAMTests/boxtest1.fcstd b/src/Mod/CAM/CAMTests/boxtest1.fcstd deleted file mode 100644 index d150419bef..0000000000 Binary files a/src/Mod/CAM/CAMTests/boxtest1.fcstd and /dev/null differ diff --git a/src/Mod/CAM/CAMTests/drill_test1.FCStd b/src/Mod/CAM/CAMTests/drill_test1.FCStd deleted file mode 100644 index 7a82630b52..0000000000 Binary files a/src/Mod/CAM/CAMTests/drill_test1.FCStd and /dev/null differ diff --git a/src/Mod/CAM/CAMTests/test_centroid_00.ngc b/src/Mod/CAM/CAMTests/test_centroid_00.ngc deleted file mode 100644 index 974c8a6250..0000000000 --- a/src/Mod/CAM/CAMTests/test_centroid_00.ngc +++ /dev/null @@ -1,69 +0,0 @@ -G90 G80 G40 G49 -G53 G00 G17 -G20 -;Default_Tool -M6 T2 -M3 S0 -;Contour -;Uncompensated Tool Path -G0 Z15.00 -G90 -G17 -G0 Z15.00 -G0 X10.00 Y10.00 -G0 Z10.00 -G1 X10.00 Y10.00 Z9.00 -G1 X10.00 Y0.00 Z9.00 -G1 X0.00 Y0.00 Z9.00 -G1 X0.00 Y10.00 Z9.00 -G1 X10.00 Y10.00 Z9.00 -G1 X10.00 Y10.00 Z8.00 -G1 X10.00 Y0.00 Z8.00 -G1 X0.00 Y0.00 Z8.00 -G1 X0.00 Y10.00 Z8.00 -G1 X10.00 Y10.00 Z8.00 -G1 X10.00 Y10.00 Z7.00 -G1 X10.00 Y0.00 Z7.00 -G1 X0.00 Y0.00 Z7.00 -G1 X0.00 Y10.00 Z7.00 -G1 X10.00 Y10.00 Z7.00 -G1 X10.00 Y10.00 Z6.00 -G1 X10.00 Y0.00 Z6.00 -G1 X0.00 Y0.00 Z6.00 -G1 X0.00 Y10.00 Z6.00 -G1 X10.00 Y10.00 Z6.00 -G1 X10.00 Y10.00 Z5.00 -G1 X10.00 Y0.00 Z5.00 -G1 X0.00 Y0.00 Z5.00 -G1 X0.00 Y10.00 Z5.00 -G1 X10.00 Y10.00 Z5.00 -G1 X10.00 Y10.00 Z4.00 -G1 X10.00 Y0.00 Z4.00 -G1 X0.00 Y0.00 Z4.00 -G1 X0.00 Y10.00 Z4.00 -G1 X10.00 Y10.00 Z4.00 -G1 X10.00 Y10.00 Z3.00 -G1 X10.00 Y0.00 Z3.00 -G1 X0.00 Y0.00 Z3.00 -G1 X0.00 Y10.00 Z3.00 -G1 X10.00 Y10.00 Z3.00 -G1 X10.00 Y10.00 Z2.00 -G1 X10.00 Y0.00 Z2.00 -G1 X0.00 Y0.00 Z2.00 -G1 X0.00 Y10.00 Z2.00 -G1 X10.00 Y10.00 Z2.00 -G1 X10.00 Y10.00 Z1.00 -G1 X10.00 Y0.00 Z1.00 -G1 X0.00 Y0.00 Z1.00 -G1 X0.00 Y10.00 Z1.00 -G1 X10.00 Y10.00 Z1.00 -G1 X10.00 Y10.00 Z0.00 -G1 X10.00 Y0.00 Z0.00 -G1 X0.00 Y0.00 Z0.00 -G1 X0.00 Y10.00 Z0.00 -G1 X10.00 Y10.00 Z0.00 -G0 Z15.00 -M5 M25 -G49 H0 -G90 G80 G40 G49 -M99 diff --git a/src/Mod/CAM/CAMTests/test_filenaming.fcstd b/src/Mod/CAM/CAMTests/test_filenaming.fcstd deleted file mode 100644 index 398a99b783..0000000000 Binary files a/src/Mod/CAM/CAMTests/test_filenaming.fcstd and /dev/null differ diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index 2c4cdb4eb5..a044b516d5 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -302,34 +302,33 @@ SET(PathPythonPost_SRCS SET(PathPythonPostScripts_SRCS Path/Post/scripts/__init__.py Path/Post/scripts/centroid_post.py - Path/Post/scripts/comparams_post.py + Path/Post/scripts/centroid_legacy_post.py Path/Post/scripts/dxf_post.py Path/Post/scripts/dynapath_post.py Path/Post/scripts/dynapath_4060_post.py Path/Post/scripts/estlcam_post.py - Path/Post/scripts/example_pre.py Path/Post/scripts/fablin_post.py Path/Post/scripts/fanuc_post.py Path/Post/scripts/fangling_post.py Path/Post/scripts/gcode_pre.py Path/Post/scripts/generic_post.py - Path/Post/scripts/grbl_post.py + Path/Post/scripts/grbl_legacy_post.py Path/Post/scripts/heidenhain_post.py Path/Post/scripts/jtech_post.py Path/Post/scripts/KineticNCBeamicon2_post.py Path/Post/scripts/linuxcnc_post.py + Path/Post/scripts/linuxcnc_legacy_post.py Path/Post/scripts/mach3_mach4_post.py + Path/Post/scripts/mach3_mach4_legacy_post.py + Path/Post/scripts/masso_g3_post.py Path/Post/scripts/marlin_post.py Path/Post/scripts/nccad_post.py Path/Post/scripts/opensbp_post.py Path/Post/scripts/opensbp_pre.py Path/Post/scripts/philips_post.py - Path/Post/scripts/refactored_centroid_post.py - Path/Post/scripts/refactored_grbl_post.py - Path/Post/scripts/refactored_linuxcnc_post.py - Path/Post/scripts/refactored_mach3_mach4_post.py - Path/Post/scripts/refactored_masso_g3_post.py - Path/Post/scripts/refactored_test_post.py + Path/Post/scripts/grbl_post.py + Path/Post/scripts/grbl_legacy_post.py + Path/Post/scripts/test_post.py Path/Post/scripts/rml_post.py Path/Post/scripts/rrf_post.py Path/Post/scripts/slic3r_pre.py @@ -481,23 +480,26 @@ SET(Tools_Shape_SRCS SET(Tests_SRCS CAMTests/__init__.py CAMTests/boxtest.fcstd - CAMTests/boxtest1.fcstd CAMTests/dressuptest.FCStd CAMTests/Drilling_1.FCStd - CAMTests/drill_test1.FCStd CAMTests/FilePathTestUtils.py CAMTests/PathTestUtils.py + CAMTests/PostTestMocks.py CAMTests/test_adaptive.fcstd CAMTests/test_profile.fcstd - CAMTests/test_centroid_00.ngc - CAMTests/test_filenaming.fcstd CAMTests/test_geomop.fcstd CAMTests/test_holes00.fcstd CAMTests/TestCAMSanity.py CAMTests/TestCentroidPost.py + CAMTests/TestCentroidLegacyPost.py + CAMTests/TestGenericPost.py CAMTests/TestGrblPost.py + CAMTests/TestGrblLegacyPost.py CAMTests/TestLinuxCNCPost.py + CAMTests/TestLinuxCNCLegacyPost.py CAMTests/TestMach3Mach4Post.py + CAMTests/TestMach3Mach4LegacyPost.py + CAMTests/TestMassoG3Post.py CAMTests/TestPathAdaptive.py CAMTests/TestPathCommandAnnotations.py CAMTests/TestPathCore.py @@ -550,15 +552,12 @@ SET(Tests_SRCS CAMTests/TestPathUtil.py CAMTests/TestPathVcarve.py CAMTests/TestPathVoronoi.py - CAMTests/TestRefactoredCentroidPost.py - CAMTests/TestRefactoredGrblPost.py - CAMTests/TestRefactoredLinuxCNCPost.py - CAMTests/TestRefactoredMach3Mach4Post.py - CAMTests/TestRefactoredMassoG3Post.py - CAMTests/TestRefactoredTestDressupPost.py - CAMTests/TestRefactoredTestPost.py - CAMTests/TestRefactoredTestPostGCodes.py - CAMTests/TestRefactoredTestPostMCodes.py + CAMTests/TestGrblLegacyPost.py + CAMTests/TestLinuxCNCLegacyPost.py + CAMTests/TestDressupPost.py + CAMTests/TestTestPost.py + CAMTests/TestPostGCodes.py + CAMTests/TestPostMCodes.py CAMTests/TestSnapmakerPost.py CAMTests/Tools/Bit/test-path-tool-bit-bit-00.fctb CAMTests/Tools/Library/test-path-tool-bit-library-00.fctl diff --git a/src/Mod/CAM/Path/Post/Utils.py b/src/Mod/CAM/Path/Post/Utils.py index f71a26f848..020c7552a9 100644 --- a/src/Mod/CAM/Path/Post/Utils.py +++ b/src/Mod/CAM/Path/Post/Utils.py @@ -335,31 +335,40 @@ def fcoms(string, commentsym): return comment -def splitArcs(path): +def splitArcs(path, deflection=None): """Filter a path object and replace all G2/G3 moves with discrete G1 moves. - Returns a Path object. - """ - prefGrp = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/CAM") - deflection = prefGrp.GetFloat("LibAreaCurveAccuarcy", 0.01) + Args: + path: Path.Path object to process + deflection: Curve deflection tolerance (default: from preferences) - results = [] + Returns: + Path.Path object with arcs replaced by G1 segments. + """ if not isinstance(path, Path.Path): raise TypeError("path must be a Path object") + if deflection is None: + prefGrp = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/CAM") + deflection = prefGrp.GetFloat("LibAreaCurveAccuracy", 0.01) + + results = [] machine = MachineState() + for command in path.Commands: - if command.Name not in Path.Geom.CmdMoveArc: - machine.addCommand(command) results.append(command) - continue + else: + # Discretize arc into line segments + edge = Path.Geom.edgeForCmd(command, machine.getPosition()) + pts = edge.discretize(Deflection=deflection) - edge = Path.Geom.edgeForCmd(command, machine.getPosition()) - pts = edge.discretize(Deflection=deflection) - edges = [Part.makeLine(v1, v2) for v1, v2 in zip(pts, pts[1:])] - for edge in edges: - results.extend(Path.Geom.cmdsForEdge(edge)) + # Convert points directly to G1 commands + feed_params = {"F": command.Parameters["F"]} if "F" in command.Parameters else {} + for pt in pts[1:]: # Skip first point (already at that position) + params = {"X": pt.x, "Y": pt.y, "Z": pt.z} + params.update(feed_params) + results.append(Path.Command("G1", params)) machine.addCommand(command) diff --git a/src/Mod/CAM/Path/Post/UtilsArguments.py b/src/Mod/CAM/Path/Post/UtilsArguments.py index ea2b3308af..86fffd5b29 100644 --- a/src/Mod/CAM/Path/Post/UtilsArguments.py +++ b/src/Mod/CAM/Path/Post/UtilsArguments.py @@ -87,6 +87,7 @@ def init_argument_defaults(argument_defaults: Dict[str, bool]) -> None: argument_defaults["output_path_labels"] = False argument_defaults["output_visible_arguments"] = False argument_defaults["show-editor"] = True + argument_defaults["split_arcs"] = False argument_defaults["tlo"] = True argument_defaults["tool_change"] = True argument_defaults["translate_drill"] = False @@ -123,6 +124,7 @@ def init_arguments_visible(arguments_visible: Dict[str, bool]) -> None: arguments_visible["precision"] = True arguments_visible["return-to"] = False arguments_visible["show-editor"] = True + arguments_visible["split_arcs"] = True arguments_visible["tlo"] = True arguments_visible["tool_change"] = False arguments_visible["translate_drill"] = False @@ -436,6 +438,15 @@ def init_shared_arguments( "Don't pop up editor before writing output", arguments_visible["show-editor"], ) + add_flag_type_arguments( + shared, + argument_defaults["split_arcs"], + "--split-arcs", + "--no-split-arcs", + "Convert G2/G3 arc commands to discrete G1 line segments", + "Output G2/G3 arc commands as-is", + arguments_visible["split_arcs"], + ) add_flag_type_arguments( shared, argument_defaults["tlo"], @@ -703,6 +714,10 @@ def init_shared_values(values: Values) -> None: # values["SHOW_EDITOR"] = True # + # If True then G2/G3 arc commands will be converted to discrete G1 line segments. + # + values["SPLIT_ARCS"] = False + # # If True then the current machine units are output just before the PRE_OPERATION. # values["SHOW_MACHINE_UNITS"] = True @@ -906,6 +921,10 @@ def process_shared_arguments( values["SHOW_EDITOR"] = True if args.no_show_editor: values["SHOW_EDITOR"] = False + if args.split_arcs: + values["SPLIT_ARCS"] = True + if args.no_split_arcs: + values["SPLIT_ARCS"] = False if args.tlo: values["USE_TLO"] = True if args.no_tlo: diff --git a/src/Mod/CAM/Path/Post/UtilsParse.py b/src/Mod/CAM/Path/Post/UtilsParse.py index 6c763a68ba..6b9713f05b 100644 --- a/src/Mod/CAM/Path/Post/UtilsParse.py +++ b/src/Mod/CAM/Path/Post/UtilsParse.py @@ -37,6 +37,7 @@ import FreeCAD from FreeCAD import Units import Path +import Path.Post.Utils as PostUtils # Define some types that are used throughout this file CommandLine = List[str] @@ -704,7 +705,12 @@ def parse_a_path(values: Values, gcode: Gcode, pathobj) -> None: ) adaptive_op_variables = determine_adaptive_op(values, pathobj) - for c in pathobj.Path.Commands: + # Apply arc splitting if requested + path_to_process = pathobj.Path + if values["SPLIT_ARCS"]: + path_to_process = PostUtils.splitArcs(path_to_process) + + for c in path_to_process.Commands: command = c.Name command_line = [] diff --git a/src/Mod/CAM/Path/Post/scripts/centroid_legacy_post.py b/src/Mod/CAM/Path/Post/scripts/centroid_legacy_post.py new file mode 100644 index 0000000000..1c33671f40 --- /dev/null +++ b/src/Mod/CAM/Path/Post/scripts/centroid_legacy_post.py @@ -0,0 +1,347 @@ +# *************************************************************************** +# * Copyright (c) 2015 Dan Falck * +# * Copyright (c) 2020 Schildkroet * +# * * +# * This file is part of the FreeCAD CAx development system. * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with FreeCAD; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import os +import FreeCAD +from FreeCAD import Units +import Path.Post.Utils as PostUtils +from PathScripts import PathUtils +import datetime +import Path +from builtins import open as pyopen + +TOOLTIP = """ +This is a postprocessor file for the Path workbench. It is used to +take a pseudo-G-code fragment outputted by a Path object, and output +real G-code suitable for a centroid 3 axis mill. This postprocessor, once placed +in the appropriate Path/Tool folder, can be used directly from inside +FreeCAD, via the GUI importer or via python scripts with: + +import centroid_legacy_post +centroid_legacy_post.export(object,"/path/to/file.ncc","") +""" + +TOOLTIP_ARGS = """ +Arguments for centroid: + --header,--no-header ... output headers (--header) + --comments,--no-comments ... output comments (--comments) + --line-numbers,--no-line-numbers ... prefix with line numbers (--no-lin-numbers) + --show-editor, --no-show-editor ... pop up editor before writing output(--show-editor) + --feed-precision=1 ... number of digits of precision for feed rate. Default=1 + --axis-precision=4 ... number of digits of precision for axis moves. Default=4 + --inches ... Convert output for US imperial mode (G20) + --no-tlo ... Suppress tool length offset (G43) following tool changes +""" + +# These globals set common customization preferences +OUTPUT_COMMENTS = True +OUTPUT_HEADER = True +OUTPUT_LINE_NUMBERS = False +if FreeCAD.GuiUp: + SHOW_EDITOR = True +else: + SHOW_EDITOR = False +MODAL = False # if true commands are suppressed if the same as previous line. +USE_TLO = True # if true G43 will be output following tool changes + +COMMAND_SPACE = " " +LINENR = 100 # line number starting value + +# These globals will be reflected in the Machine configuration of the project +UNITS = "G21" # G21 for metric, G20 for us standard +UNIT_FORMAT = "mm" +UNIT_SPEED_FORMAT = "mm/min" +MACHINE_NAME = "Centroid" +CORNER_MIN = {"x": -609.6, "y": -152.4, "z": 0} # use metric for internal units +CORNER_MAX = {"x": 609.6, "y": 152.4, "z": 304.8} # use metric for internal units +AXIS_PRECISION = 4 +FEED_PRECISION = 1 +SPINDLE_DECIMALS = 0 + +COMMENT = ";" + +# gCode header with information about CAD-software, post-processor +# and date/time +if FreeCAD.ActiveDocument: + cam_file = os.path.basename(FreeCAD.ActiveDocument.FileName) +else: + cam_file = "" + +HEADER = """;Exported by FreeCAD +;Post Processor: {} +;CAM file: {} +;Output Time: {} +""".format( + __name__, cam_file, str(datetime.datetime.now()) +) + +# Preamble text will appear at the beginning of the GCODE output file. +PREAMBLE = """G53 G00 G17 +""" + +# Postamble text will appear following the last operation. +POSTAMBLE = """M99 +""" + +TOOLRETURN = """M5 +M25 +G49 H0 +""" # spindle off,height offset canceled,spindle retracted (M25 is a centroid command to retract spindle) + +ZAXISRETURN = """G91 G28 X0 Z0 +G90 +""" + +SAFETYBLOCK = """G90 G80 G40 G49 +""" + +# Pre operation text will be inserted before every operation +PRE_OPERATION = """""" + +# Post operation text will be inserted after every operation +POST_OPERATION = """""" + +# Tool Change commands will be inserted before a tool change +TOOL_CHANGE = """""" + + +def processArguments(argstring): + global OUTPUT_HEADER + global OUTPUT_COMMENTS + global OUTPUT_LINE_NUMBERS + global SHOW_EDITOR + global AXIS_PRECISION + global FEED_PRECISION + global UNIT_SPEED_FORMAT + global UNIT_FORMAT + global UNITS + global USE_TLO + + for arg in argstring.split(): + if arg == "--header": + OUTPUT_HEADER = True + elif arg == "--no-header": + OUTPUT_HEADER = False + elif arg == "--comments": + OUTPUT_COMMENTS = True + elif arg == "--no-comments": + OUTPUT_COMMENTS = False + elif arg == "--line-numbers": + OUTPUT_LINE_NUMBERS = True + elif arg == "--no-line-numbers": + OUTPUT_LINE_NUMBERS = False + elif arg == "--show-editor": + SHOW_EDITOR = True + elif arg == "--no-show-editor": + SHOW_EDITOR = False + elif arg.split("=")[0] == "--axis-precision": + AXIS_PRECISION = arg.split("=")[1] + elif arg.split("=")[0] == "--feed-precision": + FEED_PRECISION = arg.split("=")[1] + elif arg == "--inches": + UNITS = "G20" + UNIT_SPEED_FORMAT = "in/min" + UNIT_FORMAT = "in" + elif arg == "--no-tlo": + USE_TLO = False + + +def export(objectslist, filename, argstring): + processArguments(argstring) + for i in objectslist: + print(i.Name) + global UNITS + global UNIT_FORMAT + global UNIT_SPEED_FORMAT + + print("postprocessing...") + gcode = "" + + # write header + if OUTPUT_HEADER: + gcode += HEADER + + gcode += SAFETYBLOCK + + # Write the preamble + if OUTPUT_COMMENTS: + for item in objectslist: + if hasattr(item, "Proxy") and isinstance( + item.Proxy, Path.Tool.Controller.ToolController + ): + gcode += ";T{}={}\n".format(item.ToolNumber, item.Name) + gcode += linenumber() + ";begin preamble\n" + for line in PREAMBLE.splitlines(): + gcode += linenumber() + line + "\n" + + gcode += linenumber() + UNITS + "\n" + + for obj in objectslist: + # do the pre_op + if OUTPUT_COMMENTS: + gcode += linenumber() + ";begin operation\n" + for line in PRE_OPERATION.splitlines(True): + gcode += linenumber() + line + + gcode += parse(obj) + + # do the post_op + if OUTPUT_COMMENTS: + gcode += linenumber() + ";end operation: %s\n" % obj.Label + for line in POST_OPERATION.splitlines(True): + gcode += linenumber() + line + + # do the post_amble + + if OUTPUT_COMMENTS: + gcode += ";begin postamble\n" + for line in TOOLRETURN.splitlines(True): + gcode += linenumber() + line + for line in SAFETYBLOCK.splitlines(True): + gcode += linenumber() + line + for line in POSTAMBLE.splitlines(): + gcode += linenumber() + line + "\n" + + if SHOW_EDITOR: + dia = PostUtils.GCodeEditorDialog() + dia.editor.setText(gcode) + result = dia.exec_() + if result: + final = dia.editor.toPlainText() + else: + final = gcode + else: + final = gcode + + print("done postprocessing.") + + if not filename == "-": + gfile = pyopen(filename, "w") + gfile.write(final) + gfile.close() + + return final + + +def linenumber(): + global LINENR + if OUTPUT_LINE_NUMBERS is True: + LINENR += 10 + return "N" + str(LINENR) + " " + return "" + + +def parse(pathobj): + out = "" + lastcommand = None + axis_precision_string = "." + str(AXIS_PRECISION) + "f" + feed_precision_string = "." + str(FEED_PRECISION) + "f" + # the order of parameters + # centroid doesn't want K properties on XY plane Arcs need work. + params = ["X", "Y", "Z", "A", "B", "I", "J", "F", "S", "T", "Q", "R", "L", "H"] + + if hasattr(pathobj, "Group"): # We have a compound or project. + # if OUTPUT_COMMENTS: + # out += linenumber() + "(compound: " + pathobj.Label + ")\n" + for p in pathobj.Group: + out += parse(p) + return out + else: # parsing simple path + + # groups might contain non-path things like stock. + if not hasattr(pathobj, "Path"): + return out + + for c in PathUtils.getPathWithPlacement(pathobj).Commands: + commandlist = [] # list of elements in the command, code and params. + command = c.Name # command M or G code or comment string + + if command.startswith("("): + command = PostUtils.fcoms(command, COMMENT) + + commandlist.append(command) + if MODAL is True: + if command == lastcommand: + commandlist.pop(0) + + # Now add the remaining parameters in order + for param in params: + if param in c.Parameters: + if param == "F": + if c.Name not in [ + "G0", + "G00", + ]: # centroid doesn't use rapid speeds + speed = Units.Quantity(c.Parameters["F"], FreeCAD.Units.Velocity) + commandlist.append( + param + + format( + float(speed.getValueAs(UNIT_SPEED_FORMAT)), + feed_precision_string, + ) + ) + elif param == "H": + commandlist.append(param + str(int(c.Parameters["H"]))) + elif param == "S": + commandlist.append( + param + PostUtils.fmt(c.Parameters["S"], SPINDLE_DECIMALS, "G21") + ) + elif param == "T": + commandlist.append(param + str(int(c.Parameters["T"]))) + else: + pos = Units.Quantity(c.Parameters[param], FreeCAD.Units.Length) + commandlist.append( + param + + format( + float(pos.getValueAs(UNIT_FORMAT)), + axis_precision_string, + ) + ) + outstr = str(commandlist) + outstr = outstr.replace("[", "") + outstr = outstr.replace("]", "") + outstr = outstr.replace("'", "") + outstr = outstr.replace(",", "") + + # store the latest command + lastcommand = command + + # Check for Tool Change: + if command == "M6": + for line in TOOL_CHANGE.splitlines(True): + out += linenumber() + line + if USE_TLO: + out += linenumber() + "G43 H" + str(int(c.Parameters["T"])) + "\n" + + # prepend a line number and append a newline + if len(commandlist) >= 1: + if OUTPUT_LINE_NUMBERS: + commandlist.insert(0, (linenumber())) + + # append the line to the final output + for w in commandlist: + out += w + COMMAND_SPACE + out = out.strip() + "\n" + + return out diff --git a/src/Mod/CAM/Path/Post/scripts/centroid_post.py b/src/Mod/CAM/Path/Post/scripts/centroid_post.py index 169031a322..7daf3651e5 100644 --- a/src/Mod/CAM/Path/Post/scripts/centroid_post.py +++ b/src/Mod/CAM/Path/Post/scripts/centroid_post.py @@ -1,8 +1,9 @@ # SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** -# * Copyright (c) 2015 Dan Falck * -# * Copyright (c) 2020 Schildkroet * +# * Copyright (c) 2014 sliptonic * +# * Copyright (c) 2022 - 2025 Larry Woestman * +# * Copyright (c) 2024 Ondsel * # * * # * This file is part of the FreeCAD CAx development system. * # * * @@ -24,340 +25,172 @@ # * * # *************************************************************************** -import os -import FreeCAD -from FreeCAD import Units -import Path.Post.Utils as PostUtils -import PathScripts.PathUtils as PathUtils -import datetime +from typing import Any, Dict + +from Path.Post.Processor import PostProcessor + import Path -from builtins import open as pyopen +import FreeCAD -TOOLTIP = """ -This is a postprocessor file for the Path workbench. It is used to -take a pseudo-G-code fragment outputted by a Path object, and output -real G-code suitable for a centroid 3 axis mill. This postprocessor, once placed -in the appropriate Path/Tool folder, can be used directly from inside -FreeCAD, via the GUI importer or via python scripts with: +translate = FreeCAD.Qt.translate -import centroid_post -centroid_post.export(object,"/path/to/file.ncc","") -""" - -TOOLTIP_ARGS = """ -Arguments for centroid: - --header,--no-header ... output headers (--header) - --comments,--no-comments ... output comments (--comments) - --line-numbers,--no-line-numbers ... prefix with line numbers (--no-lin-numbers) - --show-editor, --no-show-editor ... pop up editor before writing output(--show-editor) - --feed-precision=1 ... number of digits of precision for feed rate. Default=1 - --axis-precision=4 ... number of digits of precision for axis moves. Default=4 - --inches ... Convert output for US imperial mode (G20) - --no-tlo ... Suppress tool length offset (G43) following tool changes -""" - -# These globals set common customization preferences -OUTPUT_COMMENTS = True -OUTPUT_HEADER = True -OUTPUT_LINE_NUMBERS = False -if FreeCAD.GuiUp: - SHOW_EDITOR = True +DEBUG = False +if DEBUG: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) else: - SHOW_EDITOR = False -MODAL = False # if true commands are suppressed if the same as previous line. -USE_TLO = True # if true G43 will be output following tool changes + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) -COMMAND_SPACE = " " -LINENR = 100 # line number starting value +# +# Define some types that are used throughout this file. +# +Values = Dict[str, Any] +Visible = Dict[str, bool] -# These globals will be reflected in the Machine configuration of the project -UNITS = "G21" # G21 for metric, G20 for us standard -UNIT_FORMAT = "mm" -UNIT_SPEED_FORMAT = "mm/min" -MACHINE_NAME = "Centroid" -CORNER_MIN = {"x": -609.6, "y": -152.4, "z": 0} # use metric for internal units -CORNER_MAX = {"x": 609.6, "y": 152.4, "z": 304.8} # use metric for internal units -AXIS_PRECISION = 4 -FEED_PRECISION = 1 -SPINDLE_DECIMALS = 0 -COMMENT = ";" +class Centroid(PostProcessor): + """The Centroid post processor class.""" -# gCode header with information about CAD-software, post-processor -# and date/time -if FreeCAD.ActiveDocument: - cam_file = os.path.basename(FreeCAD.ActiveDocument.FileName) -else: - cam_file = "" + def __init__( + self, + job, + tooltip=translate("CAM", "Centroid post processor"), + tooltipargs=[""], + units="Metric", + ) -> None: + super().__init__( + job=job, + tooltip=tooltip, + tooltipargs=tooltipargs, + units=units, + ) + Path.Log.debug("Centroid post processor initialized.") -HEADER = """;Exported by FreeCAD -;Post Processor: {} -;CAM file: {} -;Output Time: {} -""".format( - __name__, cam_file, str(datetime.datetime.now()) -) - -# Preamble text will appear at the beginning of the GCODE output file. -PREAMBLE = """G53 G00 G17 -""" - -# Postamble text will appear following the last operation. -POSTAMBLE = """M99 -""" - -TOOLRETURN = """M5 + def init_values(self, values: Values) -> None: + """Initialize values that are used throughout the postprocessor.""" + # + super().init_values(values) + # + # Set any values here that need to override the default values set + # in the parent routine. + # + # Use 4 digits for axis precision by default. + # + values["AXIS_PRECISION"] = 4 + values["DEFAULT_AXIS_PRECISION"] = 4 + values["DEFAULT_INCH_AXIS_PRECISION"] = 4 + # + # Use ";" as the comment symbol + # + values["COMMENT_SYMBOL"] = ";" + # + # Use 1 digit for feed precision by default. + # + values["FEED_PRECISION"] = 1 + values["DEFAULT_FEED_PRECISION"] = 1 + values["DEFAULT_INCH_FEED_PRECISION"] = 1 + # + # This value usually shows up in the post_op comment as "Finish operation:". + # Change it to "End" to produce "End operation:". + # + values["FINISH_LABEL"] = "End" + # + # If this value is True, then a list of tool numbers + # with their labels are output just before the preamble. + # + values["LIST_TOOLS_IN_PREAMBLE"] = True + # + # Used in the argparser code as the "name" of the postprocessor program. + # This would normally show up in the usage message in the TOOLTIP_ARGS. + # + values["MACHINE_NAME"] = "Centroid" + # + # This list controls the order of parameters in a line during output. + # centroid doesn't want K properties on XY plane; Arcs need work. + # + values["PARAMETER_ORDER"] = [ + "X", + "Y", + "Z", + "A", + "B", + "I", + "J", + "F", + "S", + "T", + "Q", + "R", + "L", + "H", + ] + # + # Any commands in this value will be output as the last commands + # in the G-code file. + # + values["POSTAMBLE"] = """M99""" + values["POSTPROCESSOR_FILE_NAME"] = __name__ + # + # Any commands in this value will be output after the header and + # safety block at the beginning of the G-code file. + # + values["PREAMBLE"] = """G53 G00 G17""" + # + # Output any messages. + # + values["REMOVE_MESSAGES"] = False + # + # Any commands in this value are output after the header but before the preamble, + # then again after the TOOLRETURN but before the POSTAMBLE. + # + values["SAFETYBLOCK"] = """G90 G80 G40 G49""" + # + # Do not show the current machine units just before the PRE_OPERATION. + # + values["SHOW_MACHINE_UNITS"] = False + # + # Do not show the current operation label just before the PRE_OPERATION. + # + values["SHOW_OPERATION_LABELS"] = False + # + # Do not output an M5 command to stop the spindle for tool changes. + # + values["STOP_SPINDLE_FOR_TOOL_CHANGE"] = False + # + # spindle off, height offset canceled, spindle retracted + # (M25 is a centroid command to retract spindle) + # + values[ + "TOOLRETURN" + ] = """M5 M25 -G49 H0 -""" # spindle off,height offset canceled,spindle retracted (M25 is a centroid command to retract spindle) +G49 H0""" + # + # Default to not outputting a G43 following tool changes + # + values["USE_TLO"] = False + # + # This was in the original centroid postprocessor file + # but does not appear to be used anywhere. + # + # ZAXISRETURN = """G91 G28 X0 Z0 G90""" + # -ZAXISRETURN = """G91 G28 X0 Z0 -G90 -""" + def init_arguments_visible(self, arguments_visible: Visible) -> None: + """Initialize which argument pairs are visible in TOOLTIP_ARGS.""" + super().init_arguments_visible(arguments_visible) + # + # Modify the visibility of any arguments from the defaults here. + # + arguments_visible["axis-modal"] = False + arguments_visible["precision"] = False + arguments_visible["tlo"] = False -SAFETYBLOCK = """G90 G80 G40 G49 -""" - -# Pre operation text will be inserted before every operation -PRE_OPERATION = """""" - -# Post operation text will be inserted after every operation -POST_OPERATION = """""" - -# Tool Change commands will be inserted before a tool change -TOOL_CHANGE = """""" - - -def processArguments(argstring): - global OUTPUT_HEADER - global OUTPUT_COMMENTS - global OUTPUT_LINE_NUMBERS - global SHOW_EDITOR - global AXIS_PRECISION - global FEED_PRECISION - global UNIT_SPEED_FORMAT - global UNIT_FORMAT - global UNITS - global USE_TLO - - for arg in argstring.split(): - if arg == "--header": - OUTPUT_HEADER = True - elif arg == "--no-header": - OUTPUT_HEADER = False - elif arg == "--comments": - OUTPUT_COMMENTS = True - elif arg == "--no-comments": - OUTPUT_COMMENTS = False - elif arg == "--line-numbers": - OUTPUT_LINE_NUMBERS = True - elif arg == "--no-line-numbers": - OUTPUT_LINE_NUMBERS = False - elif arg == "--show-editor": - SHOW_EDITOR = True - elif arg == "--no-show-editor": - SHOW_EDITOR = False - elif arg.split("=")[0] == "--axis-precision": - AXIS_PRECISION = arg.split("=")[1] - elif arg.split("=")[0] == "--feed-precision": - FEED_PRECISION = arg.split("=")[1] - elif arg == "--inches": - UNITS = "G20" - UNIT_SPEED_FORMAT = "in/min" - UNIT_FORMAT = "in" - elif arg == "--no-tlo": - USE_TLO = False - - -def export(objectslist, filename, argstring): - processArguments(argstring) - for i in objectslist: - print(i.Name) - global UNITS - global UNIT_FORMAT - global UNIT_SPEED_FORMAT - - print("postprocessing...") - gcode = "" - - # write header - if OUTPUT_HEADER: - gcode += HEADER - - gcode += SAFETYBLOCK - - # Write the preamble - if OUTPUT_COMMENTS: - for item in objectslist: - if hasattr(item, "Proxy") and isinstance( - item.Proxy, Path.Tool.Controller.ToolController - ): - gcode += ";T{}={}\n".format(item.ToolNumber, item.Name) - gcode += linenumber() + ";begin preamble\n" - for line in PREAMBLE.splitlines(): - gcode += linenumber() + line + "\n" - - gcode += linenumber() + UNITS + "\n" - - for obj in objectslist: - # do the pre_op - if OUTPUT_COMMENTS: - gcode += linenumber() + ";begin operation\n" - for line in PRE_OPERATION.splitlines(True): - gcode += linenumber() + line - - gcode += parse(obj) - - # do the post_op - if OUTPUT_COMMENTS: - gcode += linenumber() + ";end operation: %s\n" % obj.Label - for line in POST_OPERATION.splitlines(True): - gcode += linenumber() + line - - # do the post_amble - - if OUTPUT_COMMENTS: - gcode += ";begin postamble\n" - for line in TOOLRETURN.splitlines(True): - gcode += linenumber() + line - for line in SAFETYBLOCK.splitlines(True): - gcode += linenumber() + line - for line in POSTAMBLE.splitlines(): - gcode += linenumber() + line + "\n" - - if SHOW_EDITOR: - dia = PostUtils.GCodeEditorDialog() - dia.editor.setText(gcode) - result = dia.exec_() - if result: - final = dia.editor.toPlainText() - else: - final = gcode - else: - final = gcode - - print("done postprocessing.") - - if not filename == "-": - gfile = pyopen(filename, "w") - gfile.write(final) - gfile.close() - - return final - - -def linenumber(): - global LINENR - if OUTPUT_LINE_NUMBERS is True: - LINENR += 10 - return "N" + str(LINENR) + " " - return "" - - -def parse(pathobj): - out = "" - lastcommand = None - axis_precision_string = "." + str(AXIS_PRECISION) + "f" - feed_precision_string = "." + str(FEED_PRECISION) + "f" - # params = ['X','Y','Z','A','B','I','J','K','F','S'] #This list control - # the order of parameters - # centroid doesn't want K properties on XY plane Arcs need work. - params = ["X", "Y", "Z", "A", "B", "I", "J", "F", "S", "T", "Q", "R", "L", "H"] - - if hasattr(pathobj, "Group"): # We have a compound or project. - # if OUTPUT_COMMENTS: - # out += linenumber() + "(compound: " + pathobj.Label + ")\n" - for p in pathobj.Group: - out += parse(p) - return out - else: # parsing simple path - - # groups might contain non-path things like stock. - if not hasattr(pathobj, "Path"): - return out - - # if OUTPUT_COMMENTS: - # out += linenumber() + "(" + pathobj.Label + ")\n" - - for c in PathUtils.getPathWithPlacement(pathobj).Commands: - commandlist = [] # list of elements in the command, code and params. - command = c.Name # command M or G code or comment string - - if command.startswith("("): - command = PostUtils.fcoms(command, COMMENT) - - commandlist.append(command) - # if modal: only print the command if it is not the same as the - # last one - if MODAL is True: - if command == lastcommand: - commandlist.pop(0) - - # Now add the remaining parameters in order - for param in params: - if param in c.Parameters: - if param == "F": - if c.Name not in [ - "G0", - "G00", - ]: # centroid doesn't use rapid speeds - speed = Units.Quantity(c.Parameters["F"], FreeCAD.Units.Velocity) - commandlist.append( - param - + format( - float(speed.getValueAs(UNIT_SPEED_FORMAT)), - feed_precision_string, - ) - ) - elif param == "H": - commandlist.append(param + str(int(c.Parameters["H"]))) - elif param == "S": - commandlist.append( - param + PostUtils.fmt(c.Parameters["S"], SPINDLE_DECIMALS, "G21") - ) - elif param == "T": - commandlist.append(param + str(int(c.Parameters["T"]))) - else: - pos = Units.Quantity(c.Parameters[param], FreeCAD.Units.Length) - commandlist.append( - param - + format( - float(pos.getValueAs(UNIT_FORMAT)), - axis_precision_string, - ) - ) - outstr = str(commandlist) - outstr = outstr.replace("[", "") - outstr = outstr.replace("]", "") - outstr = outstr.replace("'", "") - outstr = outstr.replace(",", "") - - # store the latest command - lastcommand = command - - # Check for Tool Change: - if command == "M6": - # if OUTPUT_COMMENTS: - # out += linenumber() + "(begin toolchange)\n" - for line in TOOL_CHANGE.splitlines(True): - out += linenumber() + line - if USE_TLO: - out += linenumber() + "G43 H" + str(int(c.Parameters["T"])) + "\n" - - # if command == "message": - # if OUTPUT_COMMENTS is False: - # out = [] - # else: - # commandlist.pop(0) # remove the command - - # prepend a line number and append a newline - if len(commandlist) >= 1: - if OUTPUT_LINE_NUMBERS: - commandlist.insert(0, (linenumber())) - - # append the line to the final output - for w in commandlist: - out += w + COMMAND_SPACE - out = out.strip() + "\n" - - return out + @property + def tooltip(self): + tooltip: str = """ + This is a postprocessor file for the CAM workbench. + It is used to take a pseudo-gcode fragment from a CAM object + and output 'real' GCode suitable for a centroid 3 axis mill. + """ + return tooltip diff --git a/src/Mod/CAM/Path/Post/scripts/comparams_post.py b/src/Mod/CAM/Path/Post/scripts/comparams_post.py deleted file mode 100644 index bd3fd6c424..0000000000 --- a/src/Mod/CAM/Path/Post/scripts/comparams_post.py +++ /dev/null @@ -1,116 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * Copyright (c) 2015 Dan Falck * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * This program is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Library General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with this program; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -import FreeCAD -import Path -import Path.Post.Utils as PostUtils -import PathScripts.PathUtils as PathUtils - -TOOLTIP = ( - """Example Post, using Path.Commands instead of Path.toGCode strings for Path G-code output.""" -) - -SHOW_EDITOR = True - - -def fmt(num): - fnum = "" - fnum += "%.3f" % (num) - return fnum - - -def ffmt(num): - fnum = "" - fnum += "%.1f" % (num) - return fnum - - -class saveVals(object): - """save command info for modal output""" - - def __init__(self, command): - self.com = command.Name - self.params = command.Parameters - - def retVals(self): - return self.com, self.params - - -def lineout(command, oldvals, modal): - line = "" - if modal and (oldvals.com == command.Name): - line += "" - else: - line += str(command.Name) - if command.Name == "M6": - line += "T" + str(int(command.Parameters["T"])) - if command.Name == "M3": - line += "S" + str(ffmt(command.Parameters["S"])) - if command.Name == "M4": - line += "S" + str(ffmt(command.Parameters["S"])) - if "X" in command.Parameters: - line += "X" + str(fmt(command.Parameters["X"])) - if "Y" in command.Parameters: - line += "Y" + str(fmt(command.Parameters["Y"])) - if "Z" in command.Parameters: - line += "Z" + str(fmt(command.Parameters["Z"])) - if "I" in command.Parameters: - line += "I" + str(fmt(command.Parameters["I"])) - if "J" in command.Parameters: - line += "J" + str(fmt(command.Parameters["J"])) - if "F" in command.Parameters: - line += "F" + str(ffmt(command.Parameters["F"])) - return line - - -def export(obj, filename, argstring): - modal = True - gcode = "" - safetyblock1 = "G90G40G49\n" - gcode += safetyblock1 - - units = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Units") - if units.GetInt("UserSchema") == 0: - firstcommand = Path.Command("G21") # metric mode - else: - firstcommand = Path.Command("G20") # inch mode - oldvals = saveVals(firstcommand) # save first command for modal use - fp = obj[0] - gcode += firstcommand.Name - - if hasattr(fp, "Path"): - for c in PathUtils.getPathWithPlacement(fp).Commands: - gcode += lineout(c, oldvals, modal) + "\n" - oldvals = saveVals(c) - gcode += "M2\n" - gfile = open(filename, "w") - gfile.write(gcode) - gfile.close() - else: - FreeCAD.Console.PrintError("Select a path object and try again\n") - - if SHOW_EDITOR: - FreeCAD.Console.PrintMessage("Editor Activated\n") - dia = PostUtils.GCodeEditorDialog() - dia.editor.setText(gcode) - dia.exec_() diff --git a/src/Mod/CAM/Path/Post/scripts/dumper_post.py b/src/Mod/CAM/Path/Post/scripts/dumper_post.py deleted file mode 100644 index 440b92c51f..0000000000 --- a/src/Mod/CAM/Path/Post/scripts/dumper_post.py +++ /dev/null @@ -1,98 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * Copyright (c) 2014 sliptonic * -# * * -# * This file is part of the FreeCAD CAx development system. * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * FreeCAD is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Lesser General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with FreeCAD; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -import datetime -import Path.Post.Utils as PostUtils -import PathScripts.PathUtils as PathUtils -from builtins import open as pyopen - -TOOLTIP = """ -Dumper is an extremely simple postprocessor file for the CAM workbench. It is used -to dump the command list from one or more Path objects for simple inspection. This post -doesn't do any manipulation of the path and doesn't write anything to disk. It just -shows the dialog so you can see it. Useful for debugging, but not much else. -""" - -now = datetime.datetime.now() -SHOW_EDITOR = True - - -def export(objectslist, filename, argstring): - "called when freecad exports a list of objects" - - output = """(This output produced with the dump post processor) -(Dump is useful for inspecting the raw commands in your paths) -(but is not useful for driving machines.) -(Consider setting a default postprocessor in your project or ) -(exporting your paths using a specific post that matches your machine) - -""" - - for obj in objectslist: - - if not hasattr(obj, "Path"): - print( - "the object " + obj.Name + " is not a path. Please select only path and Compounds." - ) - return - print("postprocessing...") - output += parse(obj) - - if SHOW_EDITOR: - dia = PostUtils.GCodeEditorDialog() - dia.editor.setText(output) - result = dia.exec_() - if result: - final = dia.editor.toPlainText() - else: - final = output - else: - final = output - - print("done postprocessing.") - return final - - -def parse(pathobj): - out = "" - - if hasattr(pathobj, "Group"): # We have a compound or project. - out += "(Group: " + pathobj.Label + ")\n" - for p in pathobj.Group: - out += parse(p) - return out - else: # parsing simple path - - if not hasattr(pathobj, "Path"): # groups might contain non-path things like stock. - return out - - out += "(Path: " + pathobj.Label + ")\n" - - for c in PathUtils.getPathWithPlacement(pathobj).Commands: - out += str(c) + "\n" - return out - - -# print(__name__ + " gcode postprocessor loaded.") diff --git a/src/Mod/CAM/Path/Post/scripts/example_post.py b/src/Mod/CAM/Path/Post/scripts/example_post.py deleted file mode 100644 index 9c6d427b2f..0000000000 --- a/src/Mod/CAM/Path/Post/scripts/example_post.py +++ /dev/null @@ -1,100 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * Copyright (c) 2014 Yorik van Havre * -# * * -# * This file is part of the FreeCAD CAx development system. * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * FreeCAD is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Lesser General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with FreeCAD; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -import datetime -from builtins import open as pyopen - -TOOLTIP = """ -This is an example postprocessor file for the Path workbench. It is used -to save a list of FreeCAD Path objects to a file. - -Read the Path Workbench documentation to know how to convert Path objects -to GCode. -""" - -now = datetime.datetime.now() - - -def export(objectslist, filename, argstring): - "called when freecad exports a list of objects" - if len(objectslist) > 1: - print("This script is unable to write more than one Path object") - return - obj = objectslist[0] - if not hasattr(obj, "Path"): - print("the given object is not a path") - gcode = obj.Path.toGCode() - gcode = parse(gcode) - gfile = pyopen(filename, "w") - gfile.write(gcode) - gfile.close() - - -def parse(inputstring): - "parse(inputstring): returns a parsed output string" - print("postprocessing...") - - output = "" - - # write some stuff first - output += "N10 ;time:" + str(now) + "\n" - output += "N20 G17 G20 G80 G40 G90\n" - output += "N30 (Exported by FreeCAD)\n" - - linenr = 100 - lastcommand = None - # treat the input line by line - lines = inputstring.split("\n") - for line in lines: - # split the G/M command from the arguments - if " " in line: - command, args = line.split(" ", 1) - else: - # no space found, which means there are no arguments - command = line - args = "" - # add a line number - output += "N" + str(linenr) + " " - # only print the command if it is not the same as the last one - if command != lastcommand: - output += command + " " - output += args + "\n" - # increment the line number - linenr += 10 - # store the latest command - lastcommand = command - - # write some more stuff at the end - output += "N" + str(linenr) + " M05\n" - output += "N" + str(linenr + 10) + " M25\n" - output += "N" + str(linenr + 20) + " G00 X-1.0 Y1.0\n" - output += "N" + str(linenr + 30) + " G17 G80 G40 G90\n" - output += "N" + str(linenr + 40) + " M99\n" - - print("done postprocessing.") - return output - - -# print(__name__ + " gcode postprocessor loaded.") diff --git a/src/Mod/CAM/Path/Post/scripts/example_pre.py b/src/Mod/CAM/Path/Post/scripts/example_pre.py deleted file mode 100644 index 6ae0c5fa39..0000000000 --- a/src/Mod/CAM/Path/Post/scripts/example_pre.py +++ /dev/null @@ -1,115 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * Copyright (c) 2014 Yorik van Havre * -# * * -# * This file is part of the FreeCAD CAx development system. * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * FreeCAD is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Lesser General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with FreeCAD; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - - -""" -This is an example preprocessor file for the Path workbench. Its aim is to -open a gcode file, parse its contents, and create the appropriate objects -in FreeCAD. This preprocessor will not add imported gcode to an existing -job. For a more useful preprocessor, look at the gcode_pre.py file - -Read the Path Workbench documentation to know how to create Path objects -from GCode. -""" - -import FreeCAD -import Path -import os -from builtins import open as pyopen - -# LEVEL = Path.Log.Level.DEBUG -LEVEL = Path.Log.Level.INFO -Path.Log.setLevel(LEVEL, Path.Log.thisModule()) - -if LEVEL == Path.Log.Level.DEBUG: - Path.Log.trackModule(Path.Log.thisModule()) - - -def open(filename): - "called when freecad opens a file." - Path.Log.track(filename) - docname = os.path.splitext(os.path.basename(filename))[0] - doc = FreeCAD.newDocument(docname) - insert(filename, doc.Name) - - -def insert(filename, docname): - "called when freecad imports a file" - Path.Log.track(filename) - gfile = pyopen(filename) - gcode = gfile.read() - gfile.close() - gcode = parse(gcode) - doc = FreeCAD.getDocument(docname) - obj = doc.addObject("Path::Feature", "Path") - path = Path.Path(gcode) - obj.Path = path - - -def parse(inputstring): - "parse(inputstring): returns a parsed output string" - print("preprocessing...") - Path.Log.track(inputstring) - # split the input by line - lines = inputstring.split("\n") - output = [] - lastcommand = None - - for lin in lines: - # remove any leftover trailing and preceding spaces - lin = lin.strip() - if not lin: - # discard empty lines - continue - if lin[0].upper() in ["N"]: - # remove line numbers - lin = lin.split(" ", 1) - if len(lin) >= 1: - lin = lin[1].strip() - else: - continue - - if lin[0] in ["(", "%", "#", ";"]: - # discard comment and other non strictly gcode lines - continue - if lin[0].upper() in ["G", "M"]: - # found a G or M command: we store it - output.append(Path.Command(str(lin))) - last = lin[0].upper() - for c in lin[1:]: - if not c.isdigit(): - break - else: - last += c - lastcommand = last - elif lastcommand: - # no G or M command: we repeat the last one - output.append(Path.Command(str(lastcommand + " " + lin))) - - print("done preprocessing.") - return output - - -print(__name__ + " gcode preprocessor loaded.") diff --git a/src/Mod/CAM/Path/Post/scripts/generic_post.py b/src/Mod/CAM/Path/Post/scripts/generic_post.py index c5f562b67f..521bd1789c 100644 --- a/src/Mod/CAM/Path/Post/scripts/generic_post.py +++ b/src/Mod/CAM/Path/Post/scripts/generic_post.py @@ -21,7 +21,7 @@ # * * # *************************************************************************** -import os +from typing import Any, Dict from Path.Post.Processor import PostProcessor import Path import FreeCAD @@ -30,57 +30,65 @@ Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) translate = FreeCAD.Qt.translate -debug = True +debug = False if debug: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) +Values = Dict[str, Any] + class Generic(PostProcessor): def __init__(self, job): super().__init__( job, tooltip=translate("CAM", "Generic post processor"), - tooltipargs=["arg1", "arg2"], - units="kg", + tooltipargs=[], + units="Metric", ) Path.Log.debug("Generic post processor initialized") - def export(self): - Path.Log.debug("Exporting the job") + def init_values(self, values: Values) -> None: + """Initialize values that are used throughout the postprocessor.""" + # + super().init_values(values) + values["POSTPROCESSOR_FILE_NAME"] = __name__ + values["MACHINE_NAME"] = "Generic" - postables = self._buildPostList() - Path.Log.debug(f"postables count: {len(postables)}") - - g_code_sections = [] - for idx, section in enumerate(postables): - partname, sublist = section - - # here is where the sections are converted to gcode. - g_code_sections.append((idx, partname)) - - return g_code_sections + # Set any values here that need to override the default values set + # in the parent routine. + # + # Any commands in this value will be output after the header and + # safety block at the beginning of the G-code file. + # + values["PREAMBLE"] = """""" + # + # Any commands in this value will be output as the last commands + # in the G-code file. + # + values["POSTAMBLE"] = """""" @property def tooltip(self): tooltip = """ This is a generic post processor. - It doesn't do anything yet because we haven't immplemented it. - - Implementing it would be a good idea + It exposes functionality of the base post processor. """ return tooltip @property def tooltipArgs(self): - argtooltip = """ - --arg1: This is the first argument - --arg2: This is the second argument + argtooltip = super().tooltipArgs - """ + # One could add additional arguments here. + # argtooltip += """ + # --arg1: This is the first argument + # --arg2: This is the second argument + + # """ return argtooltip @property diff --git a/src/Mod/CAM/Path/Post/scripts/grbl_legacy_post.py b/src/Mod/CAM/Path/Post/scripts/grbl_legacy_post.py new file mode 100644 index 0000000000..d3b6b96f25 --- /dev/null +++ b/src/Mod/CAM/Path/Post/scripts/grbl_legacy_post.py @@ -0,0 +1,717 @@ +# *************************************************************************** +# * Copyright (c) 2014 sliptonic * +# * Copyright (c) 2018, 2019 Gauthier Briere * +# * Copyright (c) 2019, 2020 Schildkroet * +# * * +# * This file is part of the FreeCAD CAx development system. * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with FreeCAD; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from FreeCAD import Units +import Path +import Path.Base.Util as PathUtil +import Path.Post.Utils as PostUtils +import PathScripts.PathUtils as PathUtils +import argparse +import datetime +import shlex +import re +from builtins import open as pyopen + + +TOOLTIP = """ +Generate g-code from a Path that is compatible with the grbl controller. +import grbl_legacy_post +grbl_legacy_post.export(object, "/path/to/file.ncc") +""" + + +# *************************************************************************** +# * Globals set customization preferences +# *************************************************************************** + +# Default values for command line arguments: +OUTPUT_COMMENTS = True # default output of comments in output gCode file +OUTPUT_HEADER = True # default output header in output gCode file +OUTPUT_LINE_NUMBERS = False # default doesn't output line numbers in output gCode file +OUTPUT_BCNC = False # default doesn't add bCNC operation block headers in output gCode file +SHOW_EDITOR = True # default show the resulting file dialog output in GUI +PRECISION = 3 # Default precision for metric (see http://linuxcnc.org/docs/2.7/html/gcode/overview.html#_g_code_best_practices) +TRANSLATE_DRILL_CYCLES = True # If true, G81, G82 & G83 are translated in G0/G1 moves +PREAMBLE = """G17 G90 +""" # default preamble text will appear at the beginning of the gCode output file. +POSTAMBLE = """M5 +G17 G90 +M2 +""" # default postamble text will appear following the last operation. + +SPINDLE_WAIT = 0 # no waiting after M3 / M4 by default +RETURN_TO = None # no movements after end of program + +# Customisation with no command line argument +MODAL = False # if true commands are suppressed if the same as previous line. +LINENR = 100 # line number starting value +LINEINCR = 10 # line number increment +OUTPUT_TOOL_CHANGE = ( + False # default don't output M6 tool changes (comment it) as grbl currently does not handle it +) +DRILL_RETRACT_MODE = ( + "G98" # Default value of drill retractations (CURRENT_Z) other possible value is G99 +) +MOTION_MODE = "G90" # G90 for absolute moves, G91 for relative +UNITS = "G21" # G21 for metric, G20 for us standard +UNIT_FORMAT = "mm" +UNIT_SPEED_FORMAT = "mm/min" +PRE_OPERATION = """""" # Pre operation text will be inserted before every operation +POST_OPERATION = """""" # Post operation text will be inserted after every operation +TOOL_CHANGE = """""" # Tool Change commands will be inserted before a tool change + +# *************************************************************************** +# * End of customization +# *************************************************************************** + +# Parser arguments list & definition +parser = argparse.ArgumentParser(prog="grbl", add_help=False) +parser.add_argument("--comments", action="store_true", help="output comment (default)") +parser.add_argument("--no-comments", action="store_true", help="suppress comment output") +parser.add_argument("--header", action="store_true", help="output headers (default)") +parser.add_argument("--no-header", action="store_true", help="suppress header output") +parser.add_argument("--line-numbers", action="store_true", help="prefix with line numbers") +parser.add_argument( + "--no-line-numbers", + action="store_true", + help="don't prefix with line numbers (default)", +) +parser.add_argument( + "--show-editor", + action="store_true", + help="pop up editor before writing output (default)", +) +parser.add_argument( + "--no-show-editor", + action="store_true", + help="don't pop up editor before writing output", +) +parser.add_argument("--precision", default="3", help="number of digits of precision, default=3") +parser.add_argument( + "--translate_drill", + action="store_true", + help="translate drill cycles G81, G82 & G83 in G0/G1 movements", +) +parser.add_argument( + "--no-translate_drill", + action="store_true", + help="don't translate drill cycles G81, G82 & G83 in G0/G1 movements (default)", +) +parser.add_argument( + "--preamble", + help='set commands to be issued before the first command, default="G17 G90\\n"', +) +parser.add_argument( + "--postamble", + help='set commands to be issued after the last command, default="M5\\nG17 G90\\nM2\\n"', +) +parser.add_argument( + "--inches", action="store_true", help="Convert output for US imperial mode (G20)" +) +parser.add_argument("--tool-change", action="store_true", help="Insert M6 for all tool changes") +parser.add_argument( + "--wait-for-spindle", + type=int, + default=0, + help="Wait for spindle to reach desired speed after M3 / M4, default=0", +) +parser.add_argument( + "--return-to", + default="", + help="Move to the specified coordinates at the end, e.g. --return-to=0,0", +) +parser.add_argument( + "--bcnc", + action="store_true", + help="Add Job operations as bCNC block headers. Consider suppressing existing comments: Add argument --no-comments", +) +parser.add_argument( + "--no-bcnc", action="store_true", help="suppress bCNC block header output (default)" +) +TOOLTIP_ARGS = parser.format_help() + + +# *************************************************************************** +# * Internal global variables +# *************************************************************************** +MOTION_COMMANDS = [ + "G0", + "G00", + "G1", + "G01", + "G2", + "G02", + "G3", + "G03", +] # Motion gCode commands definition +RAPID_MOVES = ["G0", "G00"] # Rapid moves gCode commands definition +SUPPRESS_COMMANDS = [] # These commands are ignored by commenting them out +COMMAND_SPACE = " " +# Global variables storing current position +CURRENT_X = 0 +CURRENT_Y = 0 +CURRENT_Z = 0 + + +def processArguments(argstring): + + global OUTPUT_HEADER + global OUTPUT_COMMENTS + global OUTPUT_LINE_NUMBERS + global SHOW_EDITOR + global PRECISION + global PREAMBLE + global POSTAMBLE + global UNITS + global UNIT_SPEED_FORMAT + global UNIT_FORMAT + global TRANSLATE_DRILL_CYCLES + global OUTPUT_TOOL_CHANGE + global SPINDLE_WAIT + global RETURN_TO + global OUTPUT_BCNC + + try: + args = parser.parse_args(shlex.split(argstring)) + if args.no_header: + OUTPUT_HEADER = False + if args.header: + OUTPUT_HEADER = True + if args.no_comments: + OUTPUT_COMMENTS = False + if args.comments: + OUTPUT_COMMENTS = True + if args.no_line_numbers: + OUTPUT_LINE_NUMBERS = False + if args.line_numbers: + OUTPUT_LINE_NUMBERS = True + if args.no_show_editor: + SHOW_EDITOR = False + if args.show_editor: + SHOW_EDITOR = True + PRECISION = args.precision + if args.preamble is not None: + PREAMBLE = args.preamble.replace("\\n", "\n") + if args.postamble is not None: + POSTAMBLE = args.postamble.replace("\\n", "\n") + if args.no_translate_drill: + TRANSLATE_DRILL_CYCLES = False + if args.translate_drill: + TRANSLATE_DRILL_CYCLES = True + if args.inches: + UNITS = "G20" + UNIT_SPEED_FORMAT = "in/min" + UNIT_FORMAT = "in" + PRECISION = 4 + if args.tool_change: + OUTPUT_TOOL_CHANGE = True + if args.wait_for_spindle > 0: + SPINDLE_WAIT = args.wait_for_spindle + if args.return_to != "": + RETURN_TO = [int(v) for v in args.return_to.split(",")] + if len(RETURN_TO) != 2: + RETURN_TO = None + print("--return-to coordinates must be specified as ,, ignoring") + if args.bcnc: + OUTPUT_BCNC = True + if args.no_bcnc: + OUTPUT_BCNC = False + + except Exception as e: + return False + + return True + + +# For debug... +def dump(obj): + for attr in dir(obj): + print("obj.%s = %s" % (attr, getattr(obj, attr))) + + +def export(objectslist, filename, argstring): + + if not processArguments(argstring): + return None + + global UNITS + global UNIT_FORMAT + global UNIT_SPEED_FORMAT + global MOTION_MODE + global SUPPRESS_COMMANDS + + print("Post Processor: " + __name__ + " postprocessing...") + gcode = "" + + # write header + if OUTPUT_HEADER: + gcode += linenumber() + "(Exported by FreeCAD)\n" + gcode += linenumber() + "(Post Processor: " + __name__ + ")\n" + gcode += linenumber() + "(Output Time:" + str(datetime.datetime.now()) + ")\n" + + # Check canned cycles for drilling + if TRANSLATE_DRILL_CYCLES: + if len(SUPPRESS_COMMANDS) == 0: + SUPPRESS_COMMANDS = ["G99", "G98", "G80"] + else: + SUPPRESS_COMMANDS += ["G99", "G98", "G80"] + + # Write the preamble + if OUTPUT_COMMENTS: + gcode += linenumber() + "(Begin preamble)\n" + for line in PREAMBLE.splitlines(): + gcode += linenumber() + line + "\n" + # verify if PREAMBLE have changed MOTION_MODE or UNITS + if "G90" in PREAMBLE: + MOTION_MODE = "G90" + elif "G91" in PREAMBLE: + MOTION_MODE = "G91" + else: + gcode += linenumber() + MOTION_MODE + "\n" + if "G21" in PREAMBLE: + UNITS = "G21" + UNIT_FORMAT = "mm" + UNIT_SPEED_FORMAT = "mm/min" + elif "G20" in PREAMBLE: + UNITS = "G20" + UNIT_FORMAT = "in" + UNIT_SPEED_FORMAT = "in/min" + else: + gcode += linenumber() + UNITS + "\n" + + for obj in objectslist: + # Debug... + # print("\n" + "*"*70) + # dump(obj) + # print("*"*70 + "\n") + if not hasattr(obj, "Path"): + print( + "The object " + obj.Name + " is not a path. Please select only path and Compounds." + ) + return + + # Skip inactive operations + if not PathUtil.activeForOp(obj): + continue + + # do the pre_op + if OUTPUT_BCNC: + gcode += linenumber() + "(Block-name: " + obj.Label + ")\n" + gcode += linenumber() + "(Block-expand: 0)\n" + gcode += linenumber() + "(Block-enable: 1)\n" + if OUTPUT_COMMENTS: + gcode += linenumber() + "(Begin operation: " + obj.Label + ")\n" + for line in PRE_OPERATION.splitlines(True): + gcode += linenumber() + line + + # get coolant mode + coolantMode = PathUtil.coolantModeForOp(obj) + + # turn coolant on if required + if OUTPUT_COMMENTS: + if not coolantMode == "None": + gcode += linenumber() + "(Coolant On:" + coolantMode + ")\n" + if coolantMode == "Flood": + gcode += linenumber() + "M8" + "\n" + if coolantMode == "Mist": + gcode += linenumber() + "M7" + "\n" + + # Parse the op + gcode += parse(obj) + + # do the post_op + if OUTPUT_COMMENTS: + gcode += linenumber() + "(Finish operation: " + obj.Label + ")\n" + for line in POST_OPERATION.splitlines(True): + gcode += linenumber() + line + + # turn coolant off if required + if not coolantMode == "None": + if OUTPUT_COMMENTS: + gcode += linenumber() + "(Coolant Off:" + coolantMode + ")\n" + gcode += linenumber() + "M9" + "\n" + + if RETURN_TO: + gcode += linenumber() + "G0 X%s Y%s\n" % tuple(RETURN_TO) + + # do the post_amble + if OUTPUT_BCNC: + gcode += linenumber() + "(Block-name: post_amble)\n" + gcode += linenumber() + "(Block-expand: 0)\n" + gcode += linenumber() + "(Block-enable: 1)\n" + if OUTPUT_COMMENTS: + gcode += linenumber() + "(Begin postamble)\n" + for line in POSTAMBLE.splitlines(): + gcode += linenumber() + line + "\n" + + # show the gCode result dialog + if FreeCAD.GuiUp and SHOW_EDITOR: + dia = PostUtils.GCodeEditorDialog() + dia.editor.setText(gcode) + result = dia.exec_() + if result: + final = dia.editor.toPlainText() + else: + final = gcode + else: + final = gcode + + print("Done postprocessing.") + + # write the file + if filename != "-": + with pyopen(filename, "w") as gfile: + gfile.write(final) + + return final + + +def linenumber(): + if not OUTPUT_LINE_NUMBERS: + return "" + global LINENR + global LINEINCR + s = "N" + str(LINENR) + " " + LINENR += LINEINCR + return s + + +def format_outstring(strTable): + global COMMAND_SPACE + # construct the line for the final output + s = "" + for w in strTable: + s += w + COMMAND_SPACE + s = s.strip() + return s + + +def parse(pathobj): + + global DRILL_RETRACT_MODE + global MOTION_MODE + global CURRENT_X + global CURRENT_Y + global CURRENT_Z + + out = "" + lastcommand = None + precision_string = "." + str(PRECISION) + "f" + + params = [ + "X", + "Y", + "Z", + "A", + "B", + "C", + "U", + "V", + "W", + "I", + "J", + "K", + "F", + "S", + "T", + "Q", + "R", + "L", + "P", + ] + + if hasattr(pathobj, "Group"): # We have a compound or project. + if OUTPUT_COMMENTS: + out += linenumber() + "(Compound: " + pathobj.Label + ")\n" + for p in pathobj.Group: + out += parse(p) + return out + + else: # parsing simple path + if not hasattr(pathobj, "Path"): # groups might contain non-path things like stock. + return out + + if OUTPUT_COMMENTS: + out += linenumber() + "(Path: " + pathobj.Label + ")\n" + + for c in PathUtils.getPathWithPlacement(pathobj).Commands: + outstring = [] + command = c.Name + + outstring.append(command) + + # if modal: only print the command if it is not the same as the last one + if MODAL: + if command == lastcommand: + outstring.pop(0) + + # Now add the remaining parameters in order + for param in params: + if param in c.Parameters: + if param == "F": + if command not in RAPID_MOVES: + speed = Units.Quantity(c.Parameters["F"], FreeCAD.Units.Velocity) + if speed.getValueAs(UNIT_SPEED_FORMAT) > 0.0: + outstring.append( + param + + format( + float(speed.getValueAs(UNIT_SPEED_FORMAT)), + precision_string, + ) + ) + elif param in ["T", "H", "S"]: + outstring.append(param + str(int(c.Parameters[param]))) + elif param in ["D", "P", "L"]: + outstring.append(param + str(c.Parameters[param])) + elif param in ["A", "B", "C"]: + outstring.append(param + format(c.Parameters[param], precision_string)) + else: # [X, Y, Z, U, V, W, I, J, K, R, Q] (Conversion eventuelle mm/inches) + pos = Units.Quantity(c.Parameters[param], FreeCAD.Units.Length) + outstring.append( + param + format(float(pos.getValueAs(UNIT_FORMAT)), precision_string) + ) + + # store the latest command + lastcommand = command + + # Memorizes the current position for calculating the related movements and the withdrawal plan + if command in MOTION_COMMANDS: + if "X" in c.Parameters: + CURRENT_X = Units.Quantity(c.Parameters["X"], FreeCAD.Units.Length) + if "Y" in c.Parameters: + CURRENT_Y = Units.Quantity(c.Parameters["Y"], FreeCAD.Units.Length) + if "Z" in c.Parameters: + CURRENT_Z = Units.Quantity(c.Parameters["Z"], FreeCAD.Units.Length) + + if command in ("G98", "G99"): + DRILL_RETRACT_MODE = command + + if command in ("G90", "G91"): + MOTION_MODE = command + + if TRANSLATE_DRILL_CYCLES: + if command in ("G81", "G82", "G83"): + out += drill_translate(outstring, command, c.Parameters) + # Erase the line we just translated + outstring = [] + + if SPINDLE_WAIT > 0: + if command in ("M3", "M03", "M4", "M04"): + out += linenumber() + format_outstring(outstring) + "\n" + out += linenumber() + format_outstring(["G4", "P%s" % SPINDLE_WAIT]) + "\n" + outstring = [] + + # Check for Tool Change: + if command in ("M6", "M06"): + if OUTPUT_COMMENTS: + out += linenumber() + "(Begin toolchange)\n" + if not OUTPUT_TOOL_CHANGE: + outstring.insert(0, "(") + outstring.append(")") + else: + for line in TOOL_CHANGE.splitlines(True): + out += linenumber() + line + + if command == "message": + if OUTPUT_COMMENTS is False: + out = [] + else: + outstring.pop(0) # remove the command + + if command in SUPPRESS_COMMANDS: + outstring.insert(0, "(") + outstring.append(")") + + # prepend a line number and append a newline + if len(outstring) >= 1: + out += linenumber() + format_outstring(outstring) + "\n" + + # Check for comments containing machine-specific commands to pass literally to the controller + m = re.match(r"^\(MC_RUN_COMMAND: ([^)]+)\)$", command) + if m: + raw_command = m.group(1) + out += linenumber() + raw_command + "\n" + + return out + + +def drill_translate(outstring, cmd, params): + global DRILL_RETRACT_MODE + global MOTION_MODE + global CURRENT_X + global CURRENT_Y + global CURRENT_Z + global UNITS + global UNIT_FORMAT + global UNIT_SPEED_FORMAT + + strFormat = "." + str(PRECISION) + "f" + trBuff = "" + + if OUTPUT_COMMENTS: # Comment the original command + outstring[0] = "(" + outstring[0] + outstring[-1] = outstring[-1] + ")" + trBuff += linenumber() + format_outstring(outstring) + "\n" + + # cycle conversion + # currently only cycles in XY are provided (G17) + # other plains ZX (G18) and YZ (G19) are not dealt with : Z drilling only. + param_X = Units.Quantity(params["X"], FreeCAD.Units.Length) + param_Y = Units.Quantity(params["Y"], FreeCAD.Units.Length) + param_Z = Units.Quantity(params["Z"], FreeCAD.Units.Length) + param_R = Units.Quantity(params["R"], FreeCAD.Units.Length) + # R less than Z is error + if param_R < param_Z: + trBuff += linenumber() + "(drill cycle error: R less than Z )\n" + return trBuff + + if MOTION_MODE == "G91": # G91 relative movements, (not generated by CAM WB drilling) + param_X += CURRENT_X + param_Y += CURRENT_Y + param_Z += CURRENT_Z + param_R += param_Z + + # NIST-RS274 + # 3.5.20 Set Canned Cycle Return Level — G98 and G99 + # When the spindle retracts during canned cycles, there is a choice of how far it retracts: (1) retract + # perpendicular to the selected plane to the position indicated by the R word, or (2) retract + # perpendicular to the selected plane to the position that axis was in just before the canned cycle + # started (unless that position is lower than the position indicated by the R word, in which case use + # the R word position). + # To use option (1), program G99. To use option (2), program G98. Remember that the R word has + # different meanings in absolute distance mode and incremental distance mode. + # """ + + if DRILL_RETRACT_MODE == "G99": + clear_Z = param_R + if DRILL_RETRACT_MODE == "G98" and CURRENT_Z >= param_R: + clear_Z = CURRENT_Z + else: + clear_Z = param_R + + strG0_clear_Z = "G0 Z" + format(float(clear_Z.getValueAs(UNIT_FORMAT)), strFormat) + "\n" + strG0_param_R = "G0 Z" + format(float(param_R.getValueAs(UNIT_FORMAT)), strFormat) + "\n" + + # get the other parameters + drill_feedrate = Units.Quantity(params["F"], FreeCAD.Units.Velocity) + strF_Feedrate = " F" + format(float(drill_feedrate.getValueAs(UNIT_SPEED_FORMAT)), ".2f") + "\n" + + if cmd == "G83": + drill_Step = Units.Quantity(params["Q"], FreeCAD.Units.Length) + a_bit = ( + drill_Step * 0.05 + ) # NIST 3.5.16.4 G83 Cycle: "current hole bottom, backed off a bit." + elif cmd == "G82": + drill_DwellTime = params["P"] + + # wrap this block to ensure machine MOTION_MODE is restored in case of error + try: + if MOTION_MODE == "G91": + trBuff += linenumber() + "G90\n" # force absolute coordinates during cycles + + # NIST-RS274 + # 3.5.16.1 Preliminary and In-Between Motion + # At the very beginning of the execution of any of the canned cycles, with the XY-plane selected, if + # the current Z position is below the R position, the Z-axis is traversed to the R position. This + # happens only once, regardless of the value of L. + # In addition, at the beginning of the first cycle and each repeat, the following one or two moves are + # made: + # 1. a straight traverse parallel to the XY-plane to the given XY-position, + # 2. a straight traverse of the Z-axis only to the R position, if it is not already at the R position. + + if CURRENT_Z < param_R: + trBuff += linenumber() + strG0_param_R + trBuff += ( + linenumber() + + "G0 X" + + format(float(param_X.getValueAs(UNIT_FORMAT)), strFormat) + + " Y" + + format(float(param_Y.getValueAs(UNIT_FORMAT)), strFormat) + + "\n" + ) + if CURRENT_Z > param_R: + trBuff += linenumber() + strG0_param_R + + last_Stop_Z = param_R + + # drill moves + if cmd in ("G81", "G82"): + trBuff += ( + linenumber() + + "G1 Z" + + format(float(param_Z.getValueAs(UNIT_FORMAT)), strFormat) + + strF_Feedrate + ) + # pause where applicable + if cmd == "G82": + trBuff += linenumber() + "G4 P" + str(drill_DwellTime) + "\n" + trBuff += linenumber() + strG0_clear_Z + else: # 'G83' + if params["Q"] != 0: + while 1: + if last_Stop_Z != clear_Z: + clearance_depth = ( + last_Stop_Z + a_bit + ) # rapid move to just short of last drilling depth + trBuff += ( + linenumber() + + "G0 Z" + + format( + float(clearance_depth.getValueAs(UNIT_FORMAT)), + strFormat, + ) + + "\n" + ) + next_Stop_Z = last_Stop_Z - drill_Step + if next_Stop_Z > param_Z: + trBuff += ( + linenumber() + + "G1 Z" + + format(float(next_Stop_Z.getValueAs(UNIT_FORMAT)), strFormat) + + strF_Feedrate + ) + trBuff += linenumber() + strG0_clear_Z + last_Stop_Z = next_Stop_Z + else: + trBuff += ( + linenumber() + + "G1 Z" + + format(float(param_Z.getValueAs(UNIT_FORMAT)), strFormat) + + strF_Feedrate + ) + trBuff += linenumber() + strG0_clear_Z + break + + except Exception as e: + pass + + if MOTION_MODE == "G91": + trBuff += linenumber() + "G91" # Restore if changed + + return trBuff + + +# print(__name__ + ": GCode postprocessor loaded.") diff --git a/src/Mod/CAM/Path/Post/scripts/grbl_post.py b/src/Mod/CAM/Path/Post/scripts/grbl_post.py index 84c4009529..d1af303e0b 100644 --- a/src/Mod/CAM/Path/Post/scripts/grbl_post.py +++ b/src/Mod/CAM/Path/Post/scripts/grbl_post.py @@ -2,8 +2,8 @@ # *************************************************************************** # * Copyright (c) 2014 sliptonic * -# * Copyright (c) 2018, 2019 Gauthier Briere * -# * Copyright (c) 2019, 2020 Schildkroet * +# * Copyright (c) 2022 - 2025 Larry Woestman * +# * Copyright (c) 2024 Ondsel * # * * # * This file is part of the FreeCAD CAx development system. * # * * @@ -25,695 +25,165 @@ # * * # *************************************************************************** -import FreeCAD -from FreeCAD import Units -import Path -import Path.Base.Util as PathUtil -import Path.Post.Utils as PostUtils -import PathScripts.PathUtils as PathUtils import argparse -import datetime -import shlex -import re -from builtins import open as pyopen - -TOOLTIP = """ -Generate g-code from a Path that is compatible with the grbl controller. -import grbl_post -grbl_post.export(object, "/path/to/file.ncc") -""" - - -# *************************************************************************** -# * Globals set customization preferences -# *************************************************************************** - -# Default values for command line arguments: -OUTPUT_COMMENTS = True # default output of comments in output gCode file -OUTPUT_HEADER = True # default output header in output gCode file -OUTPUT_LINE_NUMBERS = False # default doesn't output line numbers in output gCode file -OUTPUT_BCNC = False # default doesn't add bCNC operation block headers in output gCode file -SHOW_EDITOR = True # default show the resulting file dialog output in GUI -PRECISION = 3 # Default precision for metric (see http://linuxcnc.org/docs/2.7/html/gcode/overview.html#_g_code_best_practices) -TRANSLATE_DRILL_CYCLES = True # If true, G81, G82 & G83 are translated in G0/G1 moves -PREAMBLE = """G17 G90 -""" # default preamble text will appear at the beginning of the gCode output file. -POSTAMBLE = """M5 -G17 G90 -M2 -""" # default postamble text will appear following the last operation. - -SPINDLE_WAIT = 0 # no waiting after M3 / M4 by default -RETURN_TO = None # no movements after end of program - -# Customisation with no command line argument -MODAL = False # if true commands are suppressed if the same as previous line. -LINENR = 100 # line number starting value -LINEINCR = 10 # line number increment -OUTPUT_TOOL_CHANGE = ( - False # default don't output M6 tool changes (comment it) as grbl currently does not handle it -) -DRILL_RETRACT_MODE = ( - "G98" # Default value of drill retractations (CURRENT_Z) other possible value is G99 -) -MOTION_MODE = "G90" # G90 for absolute moves, G91 for relative -UNITS = "G21" # G21 for metric, G20 for us standard -UNIT_FORMAT = "mm" -UNIT_SPEED_FORMAT = "mm/min" -PRE_OPERATION = """""" # Pre operation text will be inserted before every operation -POST_OPERATION = """""" # Post operation text will be inserted after every operation -TOOL_CHANGE = """""" # Tool Change commands will be inserted before a tool change - -# *************************************************************************** -# * End of customization -# *************************************************************************** - -# Parser arguments list & definition -parser = argparse.ArgumentParser(prog="grbl", add_help=False) -parser.add_argument("--comments", action="store_true", help="output comment (default)") -parser.add_argument("--no-comments", action="store_true", help="suppress comment output") -parser.add_argument("--header", action="store_true", help="output headers (default)") -parser.add_argument("--no-header", action="store_true", help="suppress header output") -parser.add_argument("--line-numbers", action="store_true", help="prefix with line numbers") -parser.add_argument( - "--no-line-numbers", - action="store_true", - help="don't prefix with line numbers (default)", -) -parser.add_argument( - "--show-editor", - action="store_true", - help="pop up editor before writing output (default)", -) -parser.add_argument( - "--no-show-editor", - action="store_true", - help="don't pop up editor before writing output", -) -parser.add_argument("--precision", default="3", help="number of digits of precision, default=3") -parser.add_argument( - "--translate_drill", - action="store_true", - help="translate drill cycles G81, G82 & G83 in G0/G1 movements", -) -parser.add_argument( - "--no-translate_drill", - action="store_true", - help="don't translate drill cycles G81, G82 & G83 in G0/G1 movements (default)", -) -parser.add_argument( - "--preamble", - help='set commands to be issued before the first command, default="G17 G90\\n"', -) -parser.add_argument( - "--postamble", - help='set commands to be issued after the last command, default="M5\\nG17 G90\\nM2\\n"', -) -parser.add_argument( - "--inches", action="store_true", help="Convert output for US imperial mode (G20)" -) -parser.add_argument("--tool-change", action="store_true", help="Insert M6 for all tool changes") -parser.add_argument( - "--wait-for-spindle", - type=int, - default=0, - help="Wait for spindle to reach desired speed after M3 / M4, default=0", -) -parser.add_argument( - "--return-to", - default="", - help="Move to the specified coordinates at the end, e.g. --return-to=0,0", -) -parser.add_argument( - "--bcnc", - action="store_true", - help="Add Job operations as bCNC block headers. Consider suppressing existing comments: Add argument --no-comments", -) -parser.add_argument( - "--no-bcnc", action="store_true", help="suppress bCNC block header output (default)" -) -TOOLTIP_ARGS = parser.format_help() - - -# *************************************************************************** -# * Internal global variables -# *************************************************************************** -MOTION_COMMANDS = [ - "G0", - "G00", - "G1", - "G01", - "G2", - "G02", - "G3", - "G03", -] # Motion gCode commands definition -RAPID_MOVES = ["G0", "G00"] # Rapid moves gCode commands definition -SUPPRESS_COMMANDS = [] # These commands are ignored by commenting them out -COMMAND_SPACE = " " -# Global variables storing current position -CURRENT_X = 0 -CURRENT_Y = 0 -CURRENT_Z = 0 - - -def processArguments(argstring): - - global OUTPUT_HEADER - global OUTPUT_COMMENTS - global OUTPUT_LINE_NUMBERS - global SHOW_EDITOR - global PRECISION - global PREAMBLE - global POSTAMBLE - global UNITS - global UNIT_SPEED_FORMAT - global UNIT_FORMAT - global TRANSLATE_DRILL_CYCLES - global OUTPUT_TOOL_CHANGE - global SPINDLE_WAIT - global RETURN_TO - global OUTPUT_BCNC - - try: - args = parser.parse_args(shlex.split(argstring)) - if args.no_header: - OUTPUT_HEADER = False - if args.header: - OUTPUT_HEADER = True - if args.no_comments: - OUTPUT_COMMENTS = False - if args.comments: - OUTPUT_COMMENTS = True - if args.no_line_numbers: - OUTPUT_LINE_NUMBERS = False - if args.line_numbers: - OUTPUT_LINE_NUMBERS = True - if args.no_show_editor: - SHOW_EDITOR = False - if args.show_editor: - SHOW_EDITOR = True - PRECISION = args.precision - if args.preamble is not None: - PREAMBLE = args.preamble.replace("\\n", "\n") - if args.postamble is not None: - POSTAMBLE = args.postamble.replace("\\n", "\n") - if args.no_translate_drill: - TRANSLATE_DRILL_CYCLES = False - if args.translate_drill: - TRANSLATE_DRILL_CYCLES = True - if args.inches: - UNITS = "G20" - UNIT_SPEED_FORMAT = "in/min" - UNIT_FORMAT = "in" - PRECISION = 4 - if args.tool_change: - OUTPUT_TOOL_CHANGE = True - if args.wait_for_spindle > 0: - SPINDLE_WAIT = args.wait_for_spindle - if args.return_to != "": - RETURN_TO = [int(v) for v in args.return_to.split(",")] - if len(RETURN_TO) != 2: - RETURN_TO = None - print("--return-to coordinates must be specified as ,, ignoring") - if args.bcnc: - OUTPUT_BCNC = True - if args.no_bcnc: - OUTPUT_BCNC = False - - except Exception as e: - return False - - return True - - -# For debug... -def dump(obj): - for attr in dir(obj): - print("obj.%s = %s" % (attr, getattr(obj, attr))) - - -def export(objectslist, filename, argstring): - - if not processArguments(argstring): - return None - - global UNITS - global UNIT_FORMAT - global UNIT_SPEED_FORMAT - global MOTION_MODE - global SUPPRESS_COMMANDS - - print("Post Processor: " + __name__ + " postprocessing...") - gcode = "" - - # write header - if OUTPUT_HEADER: - gcode += linenumber() + "(Exported by FreeCAD)\n" - gcode += linenumber() + "(Post Processor: " + __name__ + ")\n" - gcode += linenumber() + "(Output Time:" + str(datetime.datetime.now()) + ")\n" - - # Check canned cycles for drilling - if TRANSLATE_DRILL_CYCLES: - if len(SUPPRESS_COMMANDS) == 0: - SUPPRESS_COMMANDS = ["G99", "G98", "G80"] - else: - SUPPRESS_COMMANDS += ["G99", "G98", "G80"] - - # Write the preamble - if OUTPUT_COMMENTS: - gcode += linenumber() + "(Begin preamble)\n" - for line in PREAMBLE.splitlines(): - gcode += linenumber() + line + "\n" - # verify if PREAMBLE have changed MOTION_MODE or UNITS - if "G90" in PREAMBLE: - MOTION_MODE = "G90" - elif "G91" in PREAMBLE: - MOTION_MODE = "G91" - else: - gcode += linenumber() + MOTION_MODE + "\n" - if "G21" in PREAMBLE: - UNITS = "G21" - UNIT_FORMAT = "mm" - UNIT_SPEED_FORMAT = "mm/min" - elif "G20" in PREAMBLE: - UNITS = "G20" - UNIT_FORMAT = "in" - UNIT_SPEED_FORMAT = "in/min" - else: - gcode += linenumber() + UNITS + "\n" - - for obj in objectslist: - # Debug... - # print("\n" + "*"*70) - # dump(obj) - # print("*"*70 + "\n") - if not hasattr(obj, "Path"): - print( - "The object " + obj.Name + " is not a path. Please select only path and Compounds." - ) - return - - # Skip inactive operations - if not PathUtil.activeForOp(obj): - continue - - # do the pre_op - if OUTPUT_BCNC: - gcode += linenumber() + "(Block-name: " + obj.Label + ")\n" - gcode += linenumber() + "(Block-expand: 0)\n" - gcode += linenumber() + "(Block-enable: 1)\n" - if OUTPUT_COMMENTS: - gcode += linenumber() + "(Begin operation: " + obj.Label + ")\n" - for line in PRE_OPERATION.splitlines(True): - gcode += linenumber() + line - - # get coolant mode - coolantMode = PathUtil.coolantModeForOp(obj) - - # turn coolant on if required - if OUTPUT_COMMENTS: - if not coolantMode == "None": - gcode += linenumber() + "(Coolant On:" + coolantMode + ")\n" - if coolantMode == "Flood": - gcode += linenumber() + "M8" + "\n" - if coolantMode == "Mist": - gcode += linenumber() + "M7" + "\n" - - # Parse the op - gcode += parse(obj) - - # do the post_op - if OUTPUT_COMMENTS: - gcode += linenumber() + "(Finish operation: " + obj.Label + ")\n" - for line in POST_OPERATION.splitlines(True): - gcode += linenumber() + line - - # turn coolant off if required - if not coolantMode == "None": - if OUTPUT_COMMENTS: - gcode += linenumber() + "(Coolant Off:" + coolantMode + ")\n" - gcode += linenumber() + "M9" + "\n" - - if RETURN_TO: - gcode += linenumber() + "G0 X%s Y%s\n" % tuple(RETURN_TO) - - # do the post_amble - if OUTPUT_BCNC: - gcode += linenumber() + "(Block-name: post_amble)\n" - gcode += linenumber() + "(Block-expand: 0)\n" - gcode += linenumber() + "(Block-enable: 1)\n" - if OUTPUT_COMMENTS: - gcode += linenumber() + "(Begin postamble)\n" - for line in POSTAMBLE.splitlines(): - gcode += linenumber() + line + "\n" - - # show the gCode result dialog - if FreeCAD.GuiUp and SHOW_EDITOR: - dia = PostUtils.GCodeEditorDialog() - dia.editor.setText(gcode) - result = dia.exec_() - if result: - final = dia.editor.toPlainText() - else: - final = gcode - else: - final = gcode - - print("Done postprocessing.") - - # write the file - if filename != "-": - with pyopen(filename, "w") as gfile: - gfile.write(final) - - return final - - -def linenumber(): - if not OUTPUT_LINE_NUMBERS: - return "" - global LINENR - global LINEINCR - s = "N" + str(LINENR) + " " - LINENR += LINEINCR - return s - - -def format_outstring(strTable): - global COMMAND_SPACE - # construct the line for the final output - s = "" - for w in strTable: - s += w + COMMAND_SPACE - s = s.strip() - return s - - -def parse(pathobj): - - global DRILL_RETRACT_MODE - global MOTION_MODE - global CURRENT_X - global CURRENT_Y - global CURRENT_Z - - out = "" - lastcommand = None - precision_string = "." + str(PRECISION) + "f" - - params = [ - "X", - "Y", - "Z", - "A", - "B", - "C", - "U", - "V", - "W", - "I", - "J", - "K", - "F", - "S", - "T", - "Q", - "R", - "L", - "P", - ] - - if hasattr(pathobj, "Group"): # We have a compound or project. - if OUTPUT_COMMENTS: - out += linenumber() + "(Compound: " + pathobj.Label + ")\n" - for p in pathobj.Group: - out += parse(p) - return out - - else: # parsing simple path - if not hasattr(pathobj, "Path"): # groups might contain non-path things like stock. - return out - - if OUTPUT_COMMENTS: - out += linenumber() + "(Path: " + pathobj.Label + ")\n" - - for c in PathUtils.getPathWithPlacement(pathobj).Commands: - outstring = [] - command = c.Name - - outstring.append(command) - - # if modal: only print the command if it is not the same as the last one - if MODAL: - if command == lastcommand: - outstring.pop(0) - - # Now add the remaining parameters in order - for param in params: - if param in c.Parameters: - if param == "F": - if command not in RAPID_MOVES: - speed = Units.Quantity(c.Parameters["F"], FreeCAD.Units.Velocity) - if speed.getValueAs(UNIT_SPEED_FORMAT) > 0.0: - outstring.append( - param - + format( - float(speed.getValueAs(UNIT_SPEED_FORMAT)), - precision_string, - ) - ) - elif param in ["T", "H", "S"]: - outstring.append(param + str(int(c.Parameters[param]))) - elif param in ["D", "P", "L"]: - outstring.append(param + str(c.Parameters[param])) - elif param in ["A", "B", "C"]: - outstring.append(param + format(c.Parameters[param], precision_string)) - else: # [X, Y, Z, U, V, W, I, J, K, R, Q] (Conversion eventuelle mm/inches) - pos = Units.Quantity(c.Parameters[param], FreeCAD.Units.Length) - outstring.append( - param + format(float(pos.getValueAs(UNIT_FORMAT)), precision_string) - ) - - # store the latest command - lastcommand = command - - # Memorizes the current position for calculating the related movements and the withdrawal plan - if command in MOTION_COMMANDS: - if "X" in c.Parameters: - CURRENT_X = Units.Quantity(c.Parameters["X"], FreeCAD.Units.Length) - if "Y" in c.Parameters: - CURRENT_Y = Units.Quantity(c.Parameters["Y"], FreeCAD.Units.Length) - if "Z" in c.Parameters: - CURRENT_Z = Units.Quantity(c.Parameters["Z"], FreeCAD.Units.Length) - - if command in ("G98", "G99"): - DRILL_RETRACT_MODE = command - - if command in ("G90", "G91"): - MOTION_MODE = command - - if TRANSLATE_DRILL_CYCLES: - if command in ("G81", "G82", "G83"): - out += drill_translate(outstring, command, c.Parameters) - # Erase the line we just translated - outstring = [] - - if SPINDLE_WAIT > 0: - if command in ("M3", "M03", "M4", "M04"): - out += linenumber() + format_outstring(outstring) + "\n" - out += linenumber() + format_outstring(["G4", "P%s" % SPINDLE_WAIT]) + "\n" - outstring = [] - - # Check for Tool Change: - if command in ("M6", "M06"): - if OUTPUT_COMMENTS: - out += linenumber() + "(Begin toolchange)\n" - if not OUTPUT_TOOL_CHANGE: - outstring.insert(0, "(") - outstring.append(")") - else: - for line in TOOL_CHANGE.splitlines(True): - out += linenumber() + line - - if command == "message": - if OUTPUT_COMMENTS is False: - out = [] - else: - outstring.pop(0) # remove the command - - if command in SUPPRESS_COMMANDS: - outstring.insert(0, "(") - outstring.append(")") - - # prepend a line number and append a newline - if len(outstring) >= 1: - out += linenumber() + format_outstring(outstring) + "\n" - - # Check for comments containing machine-specific commands to pass literally to the controller - m = re.match(r"^\(MC_RUN_COMMAND: ([^)]+)\)$", command) - if m: - raw_command = m.group(1) - out += linenumber() + raw_command + "\n" - - return out - - -def drill_translate(outstring, cmd, params): - global DRILL_RETRACT_MODE - global MOTION_MODE - global CURRENT_X - global CURRENT_Y - global CURRENT_Z - global UNITS - global UNIT_FORMAT - global UNIT_SPEED_FORMAT - - strFormat = "." + str(PRECISION) + "f" - trBuff = "" - - if OUTPUT_COMMENTS: # Comment the original command - outstring[0] = "(" + outstring[0] - outstring[-1] = outstring[-1] + ")" - trBuff += linenumber() + format_outstring(outstring) + "\n" - - # cycle conversion - # currently only cycles in XY are provided (G17) - # other plains ZX (G18) and YZ (G19) are not dealt with : Z drilling only. - param_X = Units.Quantity(params["X"], FreeCAD.Units.Length) - param_Y = Units.Quantity(params["Y"], FreeCAD.Units.Length) - param_Z = Units.Quantity(params["Z"], FreeCAD.Units.Length) - param_R = Units.Quantity(params["R"], FreeCAD.Units.Length) - # R less than Z is error - if param_R < param_Z: - trBuff += linenumber() + "(drill cycle error: R less than Z )\n" - return trBuff - - if MOTION_MODE == "G91": # G91 relative movements, (not generated by CAM WB drilling) - param_X += CURRENT_X - param_Y += CURRENT_Y - param_Z += CURRENT_Z - param_R += param_Z - - # NIST-RS274 - # 3.5.20 Set Canned Cycle Return Level — G98 and G99 - # When the spindle retracts during canned cycles, there is a choice of how far it retracts: (1) retract - # perpendicular to the selected plane to the position indicated by the R word, or (2) retract - # perpendicular to the selected plane to the position that axis was in just before the canned cycle - # started (unless that position is lower than the position indicated by the R word, in which case use - # the R word position). - # To use option (1), program G99. To use option (2), program G98. Remember that the R word has - # different meanings in absolute distance mode and incremental distance mode. - # """ - - if DRILL_RETRACT_MODE == "G99": - clear_Z = param_R - if DRILL_RETRACT_MODE == "G98" and CURRENT_Z >= param_R: - clear_Z = CURRENT_Z - else: - clear_Z = param_R - - strG0_clear_Z = "G0 Z" + format(float(clear_Z.getValueAs(UNIT_FORMAT)), strFormat) + "\n" - strG0_param_R = "G0 Z" + format(float(param_R.getValueAs(UNIT_FORMAT)), strFormat) + "\n" - - # get the other parameters - drill_feedrate = Units.Quantity(params["F"], FreeCAD.Units.Velocity) - strF_Feedrate = " F" + format(float(drill_feedrate.getValueAs(UNIT_SPEED_FORMAT)), ".2f") + "\n" - - if cmd == "G83": - drill_Step = Units.Quantity(params["Q"], FreeCAD.Units.Length) - a_bit = ( - drill_Step * 0.05 - ) # NIST 3.5.16.4 G83 Cycle: "current hole bottom, backed off a bit." - elif cmd == "G82": - drill_DwellTime = params["P"] - - # wrap this block to ensure machine MOTION_MODE is restored in case of error - try: - if MOTION_MODE == "G91": - trBuff += linenumber() + "G90\n" # force absolute coordinates during cycles - - # NIST-RS274 - # 3.5.16.1 Preliminary and In-Between Motion - # At the very beginning of the execution of any of the canned cycles, with the XY-plane selected, if - # the current Z position is below the R position, the Z-axis is traversed to the R position. This - # happens only once, regardless of the value of L. - # In addition, at the beginning of the first cycle and each repeat, the following one or two moves are - # made: - # 1. a straight traverse parallel to the XY-plane to the given XY-position, - # 2. a straight traverse of the Z-axis only to the R position, if it is not already at the R position. - - if CURRENT_Z < param_R: - trBuff += linenumber() + strG0_param_R - trBuff += ( - linenumber() - + "G0 X" - + format(float(param_X.getValueAs(UNIT_FORMAT)), strFormat) - + " Y" - + format(float(param_Y.getValueAs(UNIT_FORMAT)), strFormat) - + "\n" +from typing import Any, Dict + +from Path.Post.Processor import PostProcessor + +import Path +import FreeCAD + +translate = FreeCAD.Qt.translate + +DEBUG = False +if DEBUG: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + +# +# Define some types that are used throughout this file. +# +Defaults = Dict[str, bool] +Values = Dict[str, Any] +Visible = Dict[str, bool] + + +class Grbl(PostProcessor): + """The Grbl post processor class.""" + + def __init__( + self, + job, + tooltip=translate("CAM", "Grbl post processor"), + tooltipargs=[""], + units="Metric", + ) -> None: + super().__init__( + job=job, + tooltip=tooltip, + tooltipargs=tooltipargs, + units=units, ) - if CURRENT_Z > param_R: - trBuff += linenumber() + strG0_param_R + Path.Log.debug("Grbl post processor initialized.") - last_Stop_Z = param_R + def init_values(self, values: Values) -> None: + """Initialize values that are used throughout the postprocessor.""" + # + super().init_values(values) + # + # Set any values here that need to override the default values set + # in the parent routine. + # + values["ENABLE_COOLANT"] = True + # + # If this is set to True, then commands that are placed in + # comments that look like (MC_RUN_COMMAND: blah) will be output. + # + values["ENABLE_MACHINE_SPECIFIC_COMMANDS"] = True + # + # Used in the argparser code as the "name" of the postprocessor program. + # This would normally show up in the usage message in the TOOLTIP_ARGS. + # + values["MACHINE_NAME"] = "Grbl" + # + # Default to outputting Path labels at the beginning of each Path. + # + values["OUTPUT_PATH_LABELS"] = True + # + # Default to not outputting M6 tool changes (comment it) as grbl + # currently does not handle it. + # + values["OUTPUT_TOOL_CHANGE"] = False + # + # The order of the parameters. + # Arcs may only work on the XY plane (this needs to be verified). + # + values["PARAMETER_ORDER"] = [ + "X", + "Y", + "Z", + "A", + "B", + "C", + "U", + "V", + "W", + "I", + "J", + "F", + "S", + "T", + "Q", + "R", + "L", + "P", + ] + # + # Any commands in this value will be output as the last commands in the G-code file. + # + values[ + "POSTAMBLE" + ] = """M5 +G17 G90 +M2""" + values["POSTPROCESSOR_FILE_NAME"] = __name__ + # + # Any commands in this value will be output after the header and + # safety block at the beginning of the G-code file. + # + values["PREAMBLE"] = """G17 G90""" + # + # Do not show the current machine units just before the PRE_OPERATION. + # + values["SHOW_MACHINE_UNITS"] = False + # + # Default to not outputting a G43 following tool changes + # + values["USE_TLO"] = False - # drill moves - if cmd in ("G81", "G82"): - trBuff += ( - linenumber() - + "G1 Z" - + format(float(param_Z.getValueAs(UNIT_FORMAT)), strFormat) - + strF_Feedrate - ) - # pause where applicable - if cmd == "G82": - trBuff += linenumber() + "G4 P" + str(drill_DwellTime) + "\n" - trBuff += linenumber() + strG0_clear_Z - else: # 'G83' - if params["Q"] != 0: - while 1: - if last_Stop_Z != clear_Z: - clearance_depth = ( - last_Stop_Z + a_bit - ) # rapid move to just short of last drilling depth - trBuff += ( - linenumber() - + "G0 Z" - + format( - float(clearance_depth.getValueAs(UNIT_FORMAT)), - strFormat, - ) - + "\n" - ) - next_Stop_Z = last_Stop_Z - drill_Step - if next_Stop_Z > param_Z: - trBuff += ( - linenumber() - + "G1 Z" - + format(float(next_Stop_Z.getValueAs(UNIT_FORMAT)), strFormat) - + strF_Feedrate - ) - trBuff += linenumber() + strG0_clear_Z - last_Stop_Z = next_Stop_Z - else: - trBuff += ( - linenumber() - + "G1 Z" - + format(float(param_Z.getValueAs(UNIT_FORMAT)), strFormat) - + strF_Feedrate - ) - trBuff += linenumber() + strG0_clear_Z - break + def init_argument_defaults(self, argument_defaults: Defaults) -> None: + """Initialize which arguments (in a pair) are shown as the default argument.""" + super().init_argument_defaults(argument_defaults) + # + # Modify which argument to show as the default in flag-type arguments here. + # If the value is True, the first argument will be shown as the default. + # If the value is False, the second argument will be shown as the default. + # + # For example, if you want to show Metric mode as the default, use: + # argument_defaults["metric_inch"] = True + # + # If you want to show that "Don't pop up editor for writing output" is + # the default, use: + # argument_defaults["show-editor"] = False. + # + # Note: You also need to modify the corresponding entries in the "values" hash + # to actually make the default value(s) change to match. + # + argument_defaults["tlo"] = False + argument_defaults["tool_change"] = False - except Exception as e: - pass + def init_arguments_visible(self, arguments_visible: Visible) -> None: + """Initialize which argument pairs are visible in TOOLTIP_ARGS.""" + super().init_arguments_visible(arguments_visible) + # + # Modify the visibility of any arguments from the defaults here. + # + arguments_visible["bcnc"] = True + arguments_visible["axis-modal"] = False + arguments_visible["return-to"] = True + arguments_visible["tlo"] = False + arguments_visible["tool_change"] = True + arguments_visible["translate_drill"] = True + arguments_visible["wait-for-spindle"] = True - if MOTION_MODE == "G91": - trBuff += linenumber() + "G91" # Restore if changed - - return trBuff - - -# print(__name__ + ": GCode postprocessor loaded.") + @property + def tooltip(self): + tooltip: str = """ + This is a postprocessor file for the CAM workbench. + It is used to take a pseudo-gcode fragment from a CAM object + and output 'real' GCode suitable for a Grbl 3 axis mill. + """ + return tooltip diff --git a/src/Mod/CAM/Path/Post/scripts/linuxcnc_legacy_post.py b/src/Mod/CAM/Path/Post/scripts/linuxcnc_legacy_post.py new file mode 100644 index 0000000000..acd2801c01 --- /dev/null +++ b/src/Mod/CAM/Path/Post/scripts/linuxcnc_legacy_post.py @@ -0,0 +1,461 @@ +# *************************************************************************** +# * Copyright (c) 2014 sliptonic * +# * * +# * This file is part of the FreeCAD CAx development system. * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with FreeCAD; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from FreeCAD import Units +import Path +import argparse +import datetime +import shlex +import Path.Base.Util as PathUtil +import Path.Post.Utils as PostUtils +import PathScripts.PathUtils as PathUtils +from builtins import open as pyopen + +TOOLTIP = """ +This is a postprocessor file for the Path workbench. It is used to +take a pseudo-G-code fragment outputted by a Path object, and output +real G-code suitable for a linuxcnc 3 axis mill. This postprocessor, once placed +in the appropriate PathScripts folder, can be used directly from inside +FreeCAD, via the GUI importer or via python scripts with: + +import linuxcnc_legacy_post +linuxcnc_legacy_post.export(object,"/path/to/file.ncc","") +""" + +now = datetime.datetime.now() + +parser = argparse.ArgumentParser(prog="linuxcnc", add_help=False) +parser.add_argument("--no-header", action="store_true", help="suppress header output") +parser.add_argument("--no-comments", action="store_true", help="suppress comment output") +parser.add_argument("--line-numbers", action="store_true", help="prefix with line numbers") +parser.add_argument( + "--no-show-editor", + action="store_true", + help="don't pop up editor before writing output", +) +parser.add_argument("--precision", default="3", help="number of digits of precision, default=3") +parser.add_argument( + "--preamble", + help='set commands to be issued before the first command, default="G17 G54 G40 G49 G80 G90\\n"', +) +parser.add_argument( + "--postamble", + help='set commands to be issued after the last command, default="M05\\nG17 G54 G90 G80 G40\\nM2\\n"', +) +parser.add_argument( + "--inches", action="store_true", help="Convert output for US imperial mode (G20)" +) +parser.add_argument( + "--modal", + action="store_true", + help="Output the Same G-command Name USE NonModal Mode", +) +parser.add_argument("--axis-modal", action="store_true", help="Output the Same Axis Value Mode") +parser.add_argument( + "--no-tlo", + action="store_true", + help="suppress tool length offset (G43) following tool changes", +) + +TOOLTIP_ARGS = parser.format_help() + +# These globals set common customization preferences +OUTPUT_COMMENTS = True +OUTPUT_HEADER = True +OUTPUT_LINE_NUMBERS = False +SHOW_EDITOR = True +MODAL = False # if true commands are suppressed if the same as previous line. +USE_TLO = True # if true G43 will be output following tool changes +OUTPUT_DOUBLES = True # if false duplicate axis values are suppressed if the same as previous line. +COMMAND_SPACE = " " +LINENR = 100 # line number starting value + +# These globals will be reflected in the Machine configuration of the project +UNITS = "G21" # G21 for metric, G20 for us standard +UNIT_SPEED_FORMAT = "mm/min" +UNIT_FORMAT = "mm" + +MACHINE_NAME = "LinuxCNC" +CORNER_MIN = {"x": 0, "y": 0, "z": 0} +CORNER_MAX = {"x": 500, "y": 300, "z": 300} +PRECISION = 3 + +# Preamble text will appear at the beginning of the GCODE output file. +PREAMBLE = """G17 G54 G40 G49 G80 G90 +""" + +# Postamble text will appear following the last operation. +POSTAMBLE = """M05 +G17 G54 G90 G80 G40 +M2 +""" + +# Pre operation text will be inserted before every operation +PRE_OPERATION = """""" + +# Post operation text will be inserted after every operation +POST_OPERATION = """""" + +# Tool Change commands will be inserted before a tool change +TOOL_CHANGE = """""" + + +def processArguments(argstring): + global OUTPUT_HEADER + global OUTPUT_COMMENTS + global OUTPUT_LINE_NUMBERS + global SHOW_EDITOR + global PRECISION + global PREAMBLE + global POSTAMBLE + global UNITS + global UNIT_SPEED_FORMAT + global UNIT_FORMAT + global MODAL + global USE_TLO + global OUTPUT_DOUBLES + + try: + args = parser.parse_args(shlex.split(argstring)) + if args.no_header: + OUTPUT_HEADER = False + if args.no_comments: + OUTPUT_COMMENTS = False + if args.line_numbers: + OUTPUT_LINE_NUMBERS = True + if args.no_show_editor: + SHOW_EDITOR = False + print("Show editor = %d" % SHOW_EDITOR) + PRECISION = args.precision + if args.preamble is not None: + PREAMBLE = args.preamble.replace("\\n", "\n") + if args.postamble is not None: + POSTAMBLE = args.postamble.replace("\\n", "\n") + if args.inches: + UNITS = "G20" + UNIT_SPEED_FORMAT = "in/min" + UNIT_FORMAT = "in" + PRECISION = 4 + if args.modal: + MODAL = True + if args.no_tlo: + USE_TLO = False + if args.axis_modal: + print("here") + OUTPUT_DOUBLES = False + + except Exception: + return False + + return True + + +def export(objectslist, filename, argstring): + if not processArguments(argstring): + return None + global UNITS + global UNIT_FORMAT + global UNIT_SPEED_FORMAT + + for obj in objectslist: + if not hasattr(obj, "Path"): + print( + "the object " + obj.Name + " is not a path. Please select only path and Compounds." + ) + return None + + print("postprocessing...") + gcode = "" + + # write header + if OUTPUT_HEADER: + gcode += linenumber() + "(Exported by FreeCAD)\n" + gcode += linenumber() + "(Post Processor: " + __name__ + ")\n" + gcode += linenumber() + "(Output Time:" + str(now) + ")\n" + + # Write the preamble + if OUTPUT_COMMENTS: + gcode += linenumber() + "(begin preamble)\n" + for line in PREAMBLE.splitlines(): + gcode += linenumber() + line + "\n" + gcode += linenumber() + UNITS + "\n" + + for obj in objectslist: + # Skip inactive operations + if not PathUtil.activeForOp(obj): + continue + + # do the pre_op + if OUTPUT_COMMENTS: + gcode += linenumber() + "(begin operation: %s)\n" % obj.Label + gcode += linenumber() + "(machine units: %s)\n" % (UNIT_SPEED_FORMAT) + for line in PRE_OPERATION.splitlines(True): + gcode += linenumber() + line + + # get coolant mode + coolantMode = PathUtil.coolantModeForOp(obj) + + # turn coolant on if required + if OUTPUT_COMMENTS: + if not coolantMode == "None": + gcode += linenumber() + "(Coolant On:" + coolantMode + ")\n" + if coolantMode == "Flood": + gcode += linenumber() + "M8" + "\n" + if coolantMode == "Mist": + gcode += linenumber() + "M7" + "\n" + + # process the operation gcode + gcode += parse(obj) + + # do the post_op + if OUTPUT_COMMENTS: + gcode += linenumber() + "(finish operation: %s)\n" % obj.Label + for line in POST_OPERATION.splitlines(True): + gcode += linenumber() + line + + # turn coolant off if required + if not coolantMode == "None": + if OUTPUT_COMMENTS: + gcode += linenumber() + "(Coolant Off:" + coolantMode + ")\n" + gcode += linenumber() + "M9" + "\n" + + # do the post_amble + if OUTPUT_COMMENTS: + gcode += "(begin postamble)\n" + for line in POSTAMBLE.splitlines(): + gcode += linenumber() + line + "\n" + + if FreeCAD.GuiUp and SHOW_EDITOR: + final = gcode + if len(gcode) > 100000: + print("Skipping editor since output is greater than 100kb") + else: + dia = PostUtils.GCodeEditorDialog() + dia.editor.setText(gcode) + result = dia.exec_() + if result: + final = dia.editor.toPlainText() + else: + final = gcode + + print("done postprocessing.") + + if not filename == "-": + gfile = pyopen(filename, "w") + gfile.write(final) + gfile.close() + + return final + + +def linenumber(): + global LINENR + if OUTPUT_LINE_NUMBERS is True: + LINENR += 10 + return "N" + str(LINENR) + " " + return "" + + +def parse(pathobj): + global PRECISION + global MODAL + global OUTPUT_DOUBLES + global UNIT_FORMAT + global UNIT_SPEED_FORMAT + + out = "" + lastcommand = None + precision_string = "." + str(PRECISION) + "f" + currLocation = {} # keep track for no doubles + + # the order of parameters + # linuxcnc doesn't want K properties on XY plane Arcs need work. + params = [ + "X", + "Y", + "Z", + "A", + "B", + "C", + "I", + "J", + "F", + "S", + "T", + "Q", + "R", + "L", + "H", + "D", + "P", + ] + firstmove = Path.Command("G0", {"X": -1, "Y": -1, "Z": -1, "F": 0.0}) + currLocation.update(firstmove.Parameters) # set First location Parameters + + if hasattr(pathobj, "Group"): # We have a compound or project. + # if OUTPUT_COMMENTS: + # out += linenumber() + "(compound: " + pathobj.Label + ")\n" + for p in pathobj.Group: + out += parse(p) + return out + else: # parsing simple path + # groups might contain non-path things like stock. + if not hasattr(pathobj, "Path"): + return out + + # if OUTPUT_COMMENTS: + # out += linenumber() + "(" + pathobj.Label + ")\n" + + # The following "for" statement was fairly recently added + # but seems to be using the A, B, and C parameters in ways + # that don't appear to be compatible with how the PATH code + # uses the A, B, and C parameters. I have reverted the + # change here until we can figure out what it going on. + # + # for c in PathUtils.getPathWithPlacement(pathobj).Commands: + for c in pathobj.Path.Commands: + outstring = [] + command = c.Name + outstring.append(command) + + # if modal: suppress the command if it is the same as the last one + if MODAL is True: + if command == lastcommand: + outstring.pop(0) + + if c.Name.startswith("(") and not OUTPUT_COMMENTS: # command is a comment + continue + + # Handle G84/G74 tapping cycles + if command in ("G84", "G74") and "F" in c.Parameters: + pitch_mm = float(c.Parameters["F"]) + c.Parameters.pop("F") # Remove F from output, we'll handle it + + # Get spindle speed (from S param or last known value) + spindle_speed = None + if "S" in c.Parameters: + spindle_speed = float(c.Parameters["S"]) + c.Parameters.pop("S") + + # Convert pitch to inches if needed + if UNITS == "G20": # imperial + pitch = pitch_mm / 25.4 + else: + pitch = pitch_mm + + # Calculate feed rate + if spindle_speed is not None: + feed_rate = pitch * spindle_speed + speed = Units.Quantity(feed_rate, UNIT_SPEED_FORMAT) + outstring.append( + "F" + format(float(speed.getValueAs(UNIT_SPEED_FORMAT)), precision_string) + ) + else: + # No spindle speed found, output pitch as F + outstring.append("F" + format(pitch, precision_string)) + + # Now add the remaining parameters in order + for param in params: + if param in c.Parameters: + if param == "F" and ( + currLocation[param] != c.Parameters[param] or OUTPUT_DOUBLES + ): + if c.Name not in [ + "G0", + "G00", + ]: # linuxcnc doesn't use rapid speeds + speed = Units.Quantity(c.Parameters["F"], FreeCAD.Units.Velocity) + if speed.getValueAs(UNIT_SPEED_FORMAT) > 0.0: + outstring.append( + param + + format( + float(speed.getValueAs(UNIT_SPEED_FORMAT)), + precision_string, + ) + ) + else: + continue + elif param == "T": + outstring.append(param + str(int(c.Parameters["T"]))) + elif param == "H": + outstring.append(param + str(int(c.Parameters["H"]))) + elif param == "D": + outstring.append(param + str(int(c.Parameters["D"]))) + elif param == "S": + outstring.append(param + str(int(c.Parameters["S"]))) + else: + if ( + (not OUTPUT_DOUBLES) + and (param in currLocation) + and (currLocation[param] == c.Parameters[param]) + ): + continue + else: + if param in ("A", "B", "C"): + outstring.append( + param + format(float(c.Parameters[param]), precision_string) + ) + else: + pos = Units.Quantity(c.Parameters[param], FreeCAD.Units.Length) + outstring.append( + param + + format(float(pos.getValueAs(UNIT_FORMAT)), precision_string) + ) + + # store the latest command + lastcommand = command + currLocation.update(c.Parameters) + + # Check for Tool Change: + if command == "M6": + # stop the spindle + out += linenumber() + "M5\n" + for line in TOOL_CHANGE.splitlines(True): + out += linenumber() + line + + # add height offset + if USE_TLO: + tool_height = "\nG43 H" + str(int(c.Parameters["T"])) + outstring.append(tool_height) + + if command == "message": + if OUTPUT_COMMENTS is False: + out = [] + else: + outstring.pop(0) # remove the command + + # prepend a line number and append a newline + if len(outstring) >= 1: + if OUTPUT_LINE_NUMBERS: + outstring.insert(0, (linenumber())) + + # append the line to the final output + for w in outstring: + out += w + COMMAND_SPACE + out += "\n" + + return out + + +# print(__name__ + " gcode postprocessor loaded.") diff --git a/src/Mod/CAM/Path/Post/scripts/linuxcnc_post.py b/src/Mod/CAM/Path/Post/scripts/linuxcnc_post.py index 98758d7cad..71e22a9d3e 100644 --- a/src/Mod/CAM/Path/Post/scripts/linuxcnc_post.py +++ b/src/Mod/CAM/Path/Post/scripts/linuxcnc_post.py @@ -2,6 +2,9 @@ # *************************************************************************** # * Copyright (c) 2014 sliptonic * +# * Copyright (c) 2022 - 2025 Larry Woestman * +# * Copyright (c) 2024 Ondsel * +# * Copyright (c) 2024 Carl Slater * # * * # * This file is part of the FreeCAD CAx development system. * # * * @@ -23,517 +26,182 @@ # * * # *************************************************************************** -import FreeCAD -from FreeCAD import Units +from typing import Any, Dict + +from Path.Post.Processor import PostProcessor + import Path -import argparse -import datetime -import shlex -import Path.Base.Util as PathUtil -import Path.Post.Utils as PostUtils -import PathScripts.PathUtils as PathUtils -from builtins import open as pyopen +import FreeCAD -TOOLTIP = """ -This is a postprocessor file for the Path workbench. It is used to -take a pseudo-G-code fragment outputted by a Path object, and output -real G-code suitable for a linuxcnc 3 axis mill. This postprocessor, once placed -in the appropriate PathScripts folder, can be used directly from inside -FreeCAD, via the GUI importer or via python scripts with: +translate = FreeCAD.Qt.translate -import linuxcnc_post -linuxcnc_post.export(object,"/path/to/file.ncc","") -""" +DEBUG = False +if DEBUG: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) -now = datetime.datetime.now() - -parser = argparse.ArgumentParser(prog="linuxcnc", add_help=False) -parser.add_argument("--no-header", action="store_true", help="suppress header output") -parser.add_argument("--no-comments", action="store_true", help="suppress comment output") -parser.add_argument("--line-numbers", action="store_true", help="prefix with line numbers") -parser.add_argument( - "--no-show-editor", - action="store_true", - help="don't pop up editor before writing output", -) -parser.add_argument("--precision", default="3", help="number of digits of precision, default=3") -parser.add_argument( - "--preamble", - help='set commands to be issued before the first command, default="G17 G54 G40 G49 G80 G90\\n"', -) -parser.add_argument( - "--postamble", - help='set commands to be issued after the last command, default="M05\\nG17 G54 G90 G80 G40\\nM2\\n"', -) -parser.add_argument( - "--inches", action="store_true", help="Convert output for US imperial mode (G20)" -) -parser.add_argument( - "--modal", - action="store_true", - help="Output the Same G-command Name USE NonModal Mode", -) -parser.add_argument("--axis-modal", action="store_true", help="Output the Same Axis Value Mode") -parser.add_argument( - "--no-tlo", - action="store_true", - help="suppress tool length offset (G43) following tool changes", -) -parser.add_argument("--rigid-tap", action="store_true", help="Enable G33.1 rigid tapping cycle") - -TOOLTIP_ARGS = parser.format_help() - -# These globals set common customization preferences -OUTPUT_COMMENTS = True -OUTPUT_HEADER = True -OUTPUT_LINE_NUMBERS = False -SHOW_EDITOR = True -MODAL = False # if true commands are suppressed if the same as previous line. -USE_TLO = True # if true G43 will be output following tool changes -OUTPUT_DOUBLES = True # if false duplicate axis values are suppressed if the same as previous line. -COMMAND_SPACE = " " -LINENR = 100 # line number starting value - -# These globals will be reflected in the Machine configuration of the project -UNITS = "G21" # G21 for metric, G20 for us standard -UNIT_SPEED_FORMAT = "mm/min" -UNIT_FORMAT = "mm" - -MACHINE_NAME = "LinuxCNC" -CORNER_MIN = {"x": 0, "y": 0, "z": 0} -CORNER_MAX = {"x": 500, "y": 300, "z": 300} -PRECISION = 3 - -RIGID_TAP = False - -# Preamble text will appear at the beginning of the GCODE output file. -PREAMBLE = """G17 G54 G40 G49 G80 G90 -""" - -# Postamble text will appear following the last operation. -POSTAMBLE = """M05 -G17 G54 G90 G80 G40 -M2 -""" - -# Pre operation text will be inserted before every operation -PRE_OPERATION = """""" - -# Post operation text will be inserted after every operation -POST_OPERATION = """""" - -# Tool Change commands will be inserted before a tool change -TOOL_CHANGE = """""" +# +# Define some types that are used throughout this file. +# +Values = Dict[str, Any] -def processArguments(argstring): - global OUTPUT_HEADER - global OUTPUT_COMMENTS - global OUTPUT_LINE_NUMBERS - global SHOW_EDITOR - global PRECISION - global PREAMBLE - global POSTAMBLE - global UNITS - global UNIT_SPEED_FORMAT - global UNIT_FORMAT - global MODAL - global USE_TLO - global OUTPUT_DOUBLES - global RIGID_TAP +class Linuxcnc(PostProcessor): + """ + The LinuxCNC post processor class. + LinuxCNC supports various trajectory control methods (path blending) as + described at https://linuxcnc.org/docs/2.4/html/common_User_Concepts.html#r1_1_2 - try: - args = parser.parse_args(shlex.split(argstring)) - if args.no_header: - OUTPUT_HEADER = False - if args.no_comments: - OUTPUT_COMMENTS = False - if args.line_numbers: - OUTPUT_LINE_NUMBERS = True - if args.no_show_editor: - SHOW_EDITOR = False - print("Show editor = %d" % SHOW_EDITOR) - PRECISION = args.precision - if args.preamble is not None: - PREAMBLE = args.preamble.replace("\\n", "\n") - if args.postamble is not None: - POSTAMBLE = args.postamble.replace("\\n", "\n") - if args.inches: - UNITS = "G20" - UNIT_SPEED_FORMAT = "in/min" - UNIT_FORMAT = "in" - PRECISION = 4 - if args.modal: - MODAL = True - if args.no_tlo: - USE_TLO = False - if args.axis_modal: - print("here") - OUTPUT_DOUBLES = False - if args.rigid_tap: - RIGID_TAP = True - - except Exception: - return False - - return True + This post processor implements the following trajectory control methods: + - Exact Path (G61) + - Exact Stop (G64) + - Blend (G61.1) -def export(objectslist, filename, argstring): - if not processArguments(argstring): - return None - global UNITS - global UNIT_FORMAT - global UNIT_SPEED_FORMAT + """ - for obj in objectslist: - if not hasattr(obj, "Path"): - print( - "the object " + obj.Name + " is not a path. Please select only path and Compounds." - ) - return None + def __init__( + self, + job, + tooltip=translate("CAM", "LinuxCNC post processor"), + tooltipargs=["blend-mode", "blend-tolerance"], + units="Metric", + ) -> None: + super().__init__( + job=job, + tooltip=tooltip, + tooltipargs=tooltipargs, + units=units, + ) + Path.Log.debug("LinuxCNC post processor initialized.") - print("postprocessing...") - gcode = "" - - # write header - if OUTPUT_HEADER: - gcode += linenumber() + "(Exported by FreeCAD)\n" - gcode += linenumber() + "(Post Processor: " + __name__ + ")\n" - gcode += linenumber() + "(Output Time:" + str(now) + ")\n" - - # Write the preamble - if OUTPUT_COMMENTS: - gcode += linenumber() + "(begin preamble)\n" - for line in PREAMBLE.splitlines(): - gcode += linenumber() + line + "\n" - gcode += linenumber() + UNITS + "\n" - - for obj in objectslist: - # Skip inactive operations - if not PathUtil.activeForOp(obj): - continue - - # do the pre_op - if OUTPUT_COMMENTS: - gcode += linenumber() + "(begin operation: %s)\n" % obj.Label - gcode += linenumber() + "(machine units: %s)\n" % (UNIT_SPEED_FORMAT) - for line in PRE_OPERATION.splitlines(True): - gcode += linenumber() + line - - # get coolant mode - coolantMode = PathUtil.coolantModeForOp(obj) - - # turn coolant on if required - if OUTPUT_COMMENTS: - if not coolantMode == "None": - gcode += linenumber() + "(Coolant On:" + coolantMode + ")\n" - if coolantMode == "Flood": - gcode += linenumber() + "M8" + "\n" - if coolantMode == "Mist": - gcode += linenumber() + "M7" + "\n" - - # process the operation gcode - gcode += parse(obj) - - # do the post_op - if OUTPUT_COMMENTS: - gcode += linenumber() + "(finish operation: %s)\n" % obj.Label - for line in POST_OPERATION.splitlines(True): - gcode += linenumber() + line - - # turn coolant off if required - if not coolantMode == "None": - if OUTPUT_COMMENTS: - gcode += linenumber() + "(Coolant Off:" + coolantMode + ")\n" - gcode += linenumber() + "M9" + "\n" - - # do the post_amble - if OUTPUT_COMMENTS: - gcode += "(begin postamble)\n" - for line in POSTAMBLE.splitlines(): - gcode += linenumber() + line + "\n" - - if FreeCAD.GuiUp and SHOW_EDITOR: - final = gcode - if len(gcode) > 100000: - print("Skipping editor since output is greater than 100kb") - else: - dia = PostUtils.GCodeEditorDialog() - dia.editor.setText(gcode) - result = dia.exec_() - if result: - final = dia.editor.toPlainText() - else: - final = gcode - - print("done postprocessing.") - - if not filename == "-": - gfile = pyopen(filename, "w") - gfile.write(final) - gfile.close() - - return final - - -def linenumber(): - global LINENR - if OUTPUT_LINE_NUMBERS is True: - LINENR += 10 - return "N" + str(LINENR) + " " - return "" - - -def parse(pathobj): - global PRECISION - global MODAL - global OUTPUT_DOUBLES - global UNIT_FORMAT - global UNIT_SPEED_FORMAT - - out = "" - lastcommand = None - precision_string = "." + str(PRECISION) + "f" - currLocation = {} # keep track for no doubles - - # the order of parameters - # linuxcnc doesn't want K properties on XY plane Arcs need work. - params = [ - "X", - "Y", - "Z", - "A", - "B", - "C", - "I", - "J", - "F", - "S", - "T", - "Q", - "R", - "L", - "H", - "D", - "P", - ] - firstmove = Path.Command("G0", {"X": -1, "Y": -1, "Z": -1, "F": 0.0}) - currLocation.update(firstmove.Parameters) # set First location Parameters - - if hasattr(pathobj, "Group"): # We have a compound or project. - # if OUTPUT_COMMENTS: - # out += linenumber() + "(compound: " + pathobj.Label + ")\n" - for p in pathobj.Group: - out += parse(p) - return out - else: # parsing simple path - # groups might contain non-path things like stock. - if not hasattr(pathobj, "Path"): - return out - - # if OUTPUT_COMMENTS: - # out += linenumber() + "(" + pathobj.Label + ")\n" - - # The following "for" statement was fairly recently added - # but seems to be using the A, B, and C parameters in ways - # that don't appear to be compatible with how the PATH code - # uses the A, B, and C parameters. I have reverted the - # change here until we can figure out what it going on. + def init_values(self, values: Values) -> None: + """Initialize values that are used throughout the postprocessor.""" # - # for c in PathUtils.getPathWithPlacement(pathobj).Commands: - for c in pathobj.Path.Commands: - outstring = [] - command = c.Name - outstring.append(command) + super().init_values(values) + # + # Set any values here that need to override the default values set + # in the parent routine. + # + values["ENABLE_COOLANT"] = True + # + # The order of parameters. + # + # linuxcnc doesn't want K properties on XY plane; Arcs need work. + # + values["PARAMETER_ORDER"] = [ + "X", + "Y", + "Z", + "A", + "B", + "C", + "I", + "J", + "F", + "S", + "T", + "Q", + "R", + "L", + "H", + "D", + "P", + ] + # + # Used in the argparser code as the "name" of the postprocessor program. + # + values["MACHINE_NAME"] = "LinuxCNC" + # + # Any commands in this value will be output as the last commands + # in the G-code file. + # + values[ + "POSTAMBLE" + ] = """M05 +G17 G54 G90 G80 G40 +M2""" + values["POSTPROCESSOR_FILE_NAME"] = __name__ + # + # Path blending mode configuration (LinuxCNC-specific) + # + values["BLEND_MODE"] = "BLEND" # Options: EXACT_PATH, EXACT_STOP, BLEND + values["BLEND_TOLERANCE"] = 0.0 # P value for BLEND mode (0 = G64, >0 = G64 P-) + # + # Any commands in this value will be output after the header and + # safety block at the beginning of the G-code file. + # + values["PREAMBLE"] = """G17 G54 G40 G49 G80 G90 """ - # if modal: suppress the command if it is the same as the last one - if MODAL is True: - if command == lastcommand: - outstring.pop(0) + def init_arguments(self, values, argument_defaults, arguments_visible): + """Initialize command-line arguments, including LinuxCNC-specific options.""" + parser = super().init_arguments(values, argument_defaults, arguments_visible) - if c.Name.startswith("(") and not OUTPUT_COMMENTS: # command is a comment - continue + # Add LinuxCNC-specific argument group + linuxcnc_group = parser.add_argument_group("LinuxCNC-specific arguments") - # Check for G80, G98, G99 with rigid tapping and annotation - if ( - command in ("G80", "G98", "G99") - and RIGID_TAP - and hasattr(c, "Annotations") - and c.Annotations.get("operation") == "tapping" - ): - continue # Skip this command + linuxcnc_group.add_argument( + "--blend-mode", + choices=["EXACT_PATH", "EXACT_STOP", "BLEND"], + default="BLEND", + help="Path blending mode: EXACT_PATH (G61), EXACT_STOP (G61.1), " + "BLEND (G64/G64 P-) (default: BLEND)", + ) - # Handle G84/G74 tapping cycles - if command in ("G84", "G74") and "F" in c.Parameters: - pitch_mm = float(c.Parameters["F"]) - c.Parameters.pop("F") # Remove F from output, we'll handle it + linuxcnc_group.add_argument( + "--blend-tolerance", + type=float, + default=0.0, + help="Tolerance for BLEND mode (P value): 0 = no tolerance (G64), " + ">0 = tolerance (G64 P-), in current units (default: 0.0)", + ) + return parser - # Get spindle speed (from S param or last known value) - spindle_speed = None - if "S" in c.Parameters: - spindle_speed = float(c.Parameters["S"]) - c.Parameters.pop("S") + def process_arguments(self): + """Process arguments and update values, including blend mode handling.""" + flag, args = super().process_arguments() - # Convert pitch to inches if needed - if UNITS == "G20": # imperial - pitch = pitch_mm / 25.4 - else: - pitch = pitch_mm + if flag and args: + # Update blend mode values from parsed arguments + if hasattr(args, "blend_mode"): + self.values["BLEND_MODE"] = args.blend_mode + if hasattr(args, "blend_tolerance"): + self.values["BLEND_TOLERANCE"] = args.blend_tolerance - # Rigid tapping logic - if RIGID_TAP: - # Output initial tapping command - outstring[0] = "G33.1" - outstring.append("K" + format(pitch, precision_string)) + # Update PREAMBLE with blend command + blend_cmd = self._get_blend_command() + self.values[ + "PREAMBLE" + ] = f"""G17 G54 G40 G49 G80 G90 +{blend_cmd}""" - if "Z" in c.Parameters: - outstring.append("Z" + format(float(c.Parameters["Z"]), precision_string)) + return flag, args - # Output the tapping line - if len(outstring) >= 1: - if OUTPUT_LINE_NUMBERS: - outstring.insert(0, (linenumber())) - for w in outstring: - out += w + COMMAND_SPACE - out += "\n" + def _get_blend_command(self) -> str: + """Generate the path blending G-code command based on current settings.""" + mode = self.values.get("BLEND_MODE", "BLEND") - if "P" in c.Parameters: - # Issue spindle stop - out += linenumber() + "M5\n" - # Issue dwell with P value - out += linenumber() + f"G04 P{c.Parameters['P']}\n" + if mode == "EXACT_PATH": + return "G61" + elif mode == "EXACT_STOP": + return "G61.1" + else: # BLEND + tolerance = self.values.get("BLEND_TOLERANCE", 0.0) + if tolerance > 0: + return f"G64 P{tolerance:.4f}" + else: + return "G64" - # Now handle reverse out and spindle restore - if command == "G84": - # Reverse spindle (M4) with spindle speed - out += linenumber() + "M4\n" - # Repeat tapping command to reverse out, use R for Z - reverse_z = c.Parameters.get("R") - if reverse_z is not None: - pos = Units.Quantity(reverse_z, FreeCAD.Units.Length) - reverse_z = float(pos.getValueAs(UNIT_FORMAT)) - out += ( - linenumber() - + f"G33.1 K{format(pitch, precision_string)} Z{format(float(reverse_z), precision_string)}\n" - ) - else: - out += linenumber() + f"G33.1 K{format(pitch, precision_string)}\n" - # Restore original spindle direction (M3) with spindle speed - out += linenumber() + "M3\n" - elif command == "G74": - # Forward spindle (M3) with spindle speed - out += linenumber() + "M3\n" - # Repeat tapping command to reverse out, use R for Z - reverse_z = c.Parameters.get("R") - if reverse_z is not None: - pos = Units.Quantity(reverse_z, FreeCAD.Units.Length) - reverse_z = float(pos.getValueAs(UNIT_FORMAT)) - out += ( - linenumber() - + f"G33.1 K{format(pitch, precision_string)} Z{format(float(reverse_z), precision_string)}\n" - ) - else: - out += linenumber() + f"G33.1 K{format(pitch, precision_string)}\n" - # Restore original spindle direction (M4) with spindle speed - out += linenumber() + "M4\n" + # tooltipArgs is inherited from base class and automatically includes + # all arguments from init_arguments() via parser.format_help() - continue # Skip the rest of the parameter output for this command - - else: - # Calculate feed rate - if spindle_speed is not None: - feed_rate = pitch * spindle_speed - speed = Units.Quantity(feed_rate, UNIT_SPEED_FORMAT) - outstring.append( - "F" - + format(float(speed.getValueAs(UNIT_SPEED_FORMAT)), precision_string) - ) - else: - # No spindle speed found, output pitch as F - outstring.append("F" + format(pitch, precision_string)) - - # Now add the remaining parameters in order - for param in params: - if param in c.Parameters: - if param == "F" and ( - currLocation[param] != c.Parameters[param] or OUTPUT_DOUBLES - ): - if c.Name not in [ - "G0", - "G00", - ]: # linuxcnc doesn't use rapid speeds - speed = Units.Quantity(c.Parameters["F"], FreeCAD.Units.Velocity) - if speed.getValueAs(UNIT_SPEED_FORMAT) > 0.0: - outstring.append( - param - + format( - float(speed.getValueAs(UNIT_SPEED_FORMAT)), - precision_string, - ) - ) - else: - continue - elif param == "T": - outstring.append(param + str(int(c.Parameters["T"]))) - elif param == "H": - outstring.append(param + str(int(c.Parameters["H"]))) - elif param == "D": - outstring.append(param + str(int(c.Parameters["D"]))) - elif param == "S": - outstring.append(param + str(int(c.Parameters["S"]))) - else: - if ( - (not OUTPUT_DOUBLES) - and (param in currLocation) - and (currLocation[param] == c.Parameters[param]) - ): - continue - else: - if param in ("A", "B", "C"): - outstring.append( - param + format(float(c.Parameters[param]), precision_string) - ) - else: - pos = Units.Quantity(c.Parameters[param], FreeCAD.Units.Length) - outstring.append( - param - + format(float(pos.getValueAs(UNIT_FORMAT)), precision_string) - ) - - # store the latest command - lastcommand = command - currLocation.update(c.Parameters) - - # Check for Tool Change: - if command == "M6": - # stop the spindle - out += linenumber() + "M5\n" - for line in TOOL_CHANGE.splitlines(True): - out += linenumber() + line - - # add height offset - if USE_TLO: - tool_height = "\nG43 H" + str(int(c.Parameters["T"])) - outstring.append(tool_height) - - if command == "message": - if OUTPUT_COMMENTS is False: - out = [] - else: - outstring.pop(0) # remove the command - - # prepend a line number and append a newline - if len(outstring) >= 1: - if OUTPUT_LINE_NUMBERS: - outstring.insert(0, (linenumber())) - - # append the line to the final output - for w in outstring: - out += w + COMMAND_SPACE - out += "\n" - - return out - - -# print(__name__ + " gcode postprocessor loaded.") + @property + def tooltip(self): + tooltip: str = """ + This is a postprocessor file for the CAM workbench. + It is used to take a pseudo-gcode fragment from a CAM object + and output 'real' GCode suitable for a linuxcnc 3 axis mill. + """ + return tooltip diff --git a/src/Mod/CAM/Path/Post/scripts/mach3_mach4_legacy_post.py b/src/Mod/CAM/Path/Post/scripts/mach3_mach4_legacy_post.py new file mode 100644 index 0000000000..0353a1f21d --- /dev/null +++ b/src/Mod/CAM/Path/Post/scripts/mach3_mach4_legacy_post.py @@ -0,0 +1,481 @@ +# *************************************************************************** +# * Copyright (c) 2014 sliptonic * +# * * +# * This file is part of the FreeCAD CAx development system. * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with FreeCAD; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# ***************************************************************************/ + +import FreeCAD +from FreeCAD import Units +import Path +import argparse +import datetime +import shlex +import Path.Base.Util as PathUtil +import Path.Post.Utils as PostUtils +import PathScripts.PathUtils as PathUtils +from builtins import open as pyopen + +TOOLTIP = """ +This is a postprocessor file for the Path workbench. It is used to +take a pseudo-G-code fragment outputted by a Path object, and output +real G-code suitable for a mach3_4 3 axis mill. This postprocessor, once placed +in the appropriate PathScripts folder, can be used directly from inside +FreeCAD, via the GUI importer or via python scripts with: + +import mach3_4_legacy_post +mach3_4_legacy_post.export(object,"/path/to/file.ncc","") +""" + +now = datetime.datetime.now() + +parser = argparse.ArgumentParser(prog="mach3_4", add_help=False) +parser.add_argument("--no-header", action="store_true", help="suppress header output") +parser.add_argument("--no-comments", action="store_true", help="suppress comment output") +parser.add_argument("--line-numbers", action="store_true", help="prefix with line numbers") +parser.add_argument( + "--no-show-editor", + action="store_true", + help="don't pop up editor before writing output", +) +parser.add_argument("--precision", default="3", help="number of digits of precision, default=3") +parser.add_argument( + "--preamble", + help='set commands to be issued before the first command, default="G17 G54 G40 G49 G80 G90\\n"', +) +parser.add_argument( + "--postamble", + help='set commands to be issued after the last command, default="M05\\nG17 G54 G90 G80 G40\\nM2\\n"', +) +parser.add_argument( + "--inches", action="store_true", help="Convert output for US imperial mode (G20)" +) +parser.add_argument( + "--modal", + action="store_true", + help="Output the Same G-command Name USE NonModal Mode", +) +parser.add_argument("--axis-modal", action="store_true", help="Output the Same Axis Value Mode") +parser.add_argument( + "--no-tlo", + action="store_true", + help="suppress tool length offset (G43) following tool changes", +) + +TOOLTIP_ARGS = parser.format_help() + +# These globals set common customization preferences +OUTPUT_COMMENTS = True +OUTPUT_HEADER = True +OUTPUT_LINE_NUMBERS = False +SHOW_EDITOR = True +MODAL = False # if true commands are suppressed if the same as previous line. +USE_TLO = True # if true G43 will be output following tool changes +OUTPUT_DOUBLES = True # if false duplicate axis values are suppressed if the same as previous line. +COMMAND_SPACE = " " +LINENR = 100 # line number starting value + +# These globals will be reflected in the Machine configuration of the project +UNITS = "G21" # G21 for metric, G20 for us standard +UNIT_SPEED_FORMAT = "mm/min" +UNIT_FORMAT = "mm" + +MACHINE_NAME = "mach3_4" +CORNER_MIN = {"x": 0, "y": 0, "z": 0} +CORNER_MAX = {"x": 500, "y": 300, "z": 300} +PRECISION = 3 + +# Preamble text will appear at the beginning of the GCODE output file. +PREAMBLE = """G17 G54 G40 G49 G80 G90 +""" + +# Postamble text will appear following the last operation. +POSTAMBLE = """M05 +G17 G54 G90 G80 G40 +M2 +""" + +# Pre operation text will be inserted before every operation +PRE_OPERATION = """""" + +# Post operation text will be inserted after every operation +POST_OPERATION = """""" + +# Tool Change commands will be inserted before a tool change +TOOL_CHANGE = """""" + + +def processArguments(argstring): + global OUTPUT_HEADER + global OUTPUT_COMMENTS + global OUTPUT_LINE_NUMBERS + global SHOW_EDITOR + global PRECISION + global PREAMBLE + global POSTAMBLE + global UNITS + global UNIT_SPEED_FORMAT + global UNIT_FORMAT + global MODAL + global USE_TLO + global OUTPUT_DOUBLES + + try: + args = parser.parse_args(shlex.split(argstring)) + if args.no_header: + OUTPUT_HEADER = False + if args.no_comments: + OUTPUT_COMMENTS = False + if args.line_numbers: + OUTPUT_LINE_NUMBERS = True + if args.no_show_editor: + SHOW_EDITOR = False + print("Show editor = %d" % SHOW_EDITOR) + PRECISION = args.precision + if args.preamble is not None: + PREAMBLE = args.preamble.replace("\\n", "\n") + if args.postamble is not None: + POSTAMBLE = args.postamble.replace("\\n", "\n") + if args.inches: + UNITS = "G20" + UNIT_SPEED_FORMAT = "in/min" + UNIT_FORMAT = "in" + PRECISION = 4 + if args.modal: + MODAL = True + if args.no_tlo: + USE_TLO = False + if args.axis_modal: + print("here") + OUTPUT_DOUBLES = False + + except Exception: + return False + + return True + + +def export(objectslist, filename, argstring): + if not processArguments(argstring): + return None + global UNITS + global UNIT_FORMAT + global UNIT_SPEED_FORMAT + + for obj in objectslist: + if not hasattr(obj, "Path"): + print( + "the object " + obj.Name + " is not a path. Please select only path and Compounds." + ) + return None + + print("postprocessing...") + gcode = "" + + # write header + if OUTPUT_HEADER: + gcode += linenumber() + "(Exported by FreeCAD)\n" + gcode += linenumber() + "(Post Processor: " + __name__ + ")\n" + gcode += linenumber() + "(Output Time:" + str(now) + ")\n" + + # Write the preamble + if OUTPUT_COMMENTS: + gcode += linenumber() + "(begin preamble)\n" + for line in PREAMBLE.splitlines(): + gcode += linenumber() + line + "\n" + gcode += linenumber() + UNITS + "\n" + + for obj in objectslist: + + # Skip inactive operations + if not PathUtil.activeForOp(obj): + continue + + # do the pre_op + if OUTPUT_COMMENTS: + gcode += linenumber() + "(begin operation: %s)\n" % obj.Label + gcode += linenumber() + "(machine: %s, %s)\n" % ( + MACHINE_NAME, + UNIT_SPEED_FORMAT, + ) + for line in PRE_OPERATION.splitlines(True): + gcode += linenumber() + line + + # get coolant mode + coolantMode = PathUtil.coolantModeForOp(obj) + + # turn coolant on if required + if OUTPUT_COMMENTS: + if not coolantMode == "None": + gcode += linenumber() + "(Coolant On:" + coolantMode + ")\n" + if coolantMode == "Flood": + gcode += linenumber() + "M8" + "\n" + if coolantMode == "Mist": + gcode += linenumber() + "M7" + "\n" + + # process the operation gcode + gcode += parse(obj) + + # do the post_op + if OUTPUT_COMMENTS: + gcode += linenumber() + "(finish operation: %s)\n" % obj.Label + for line in POST_OPERATION.splitlines(True): + gcode += linenumber() + line + + # turn coolant off if required + if not coolantMode == "None": + if OUTPUT_COMMENTS: + gcode += linenumber() + "(Coolant Off:" + coolantMode + ")\n" + gcode += linenumber() + "M9" + "\n" + + # do the post_amble + if OUTPUT_COMMENTS: + gcode += "(begin postamble)\n" + for line in POSTAMBLE.splitlines(): + gcode += linenumber() + line + "\n" + + if FreeCAD.GuiUp and SHOW_EDITOR: + dia = PostUtils.GCodeEditorDialog() + dia.editor.setText(gcode) + result = dia.exec_() + if result: + final = dia.editor.toPlainText() + else: + final = gcode + else: + final = gcode + + print("done postprocessing.") + + if not filename == "-": + gfile = pyopen(filename, "w") + gfile.write(final) + gfile.close() + + return final + + +def linenumber(): + global LINENR + if OUTPUT_LINE_NUMBERS is True: + LINENR += 10 + return "N" + str(LINENR) + " " + return "" + + +def parse(pathobj): + global PRECISION + global MODAL + global OUTPUT_DOUBLES + global UNIT_FORMAT + global UNIT_SPEED_FORMAT + + out = "" + lastcommand = None + precision_string = "." + str(PRECISION) + "f" + currLocation = {} # keep track for no doubles + + # the order of parameters + # mach3_4 doesn't want K properties on XY plane Arcs need work. + params = [ + "X", + "Y", + "Z", + "A", + "B", + "C", + "I", + "J", + "F", + "S", + "T", + "Q", + "R", + "L", + "H", + "D", + "P", + ] + firstmove = Path.Command("G0", {"X": -1, "Y": -1, "Z": -1, "F": 0.0}) + currLocation.update(firstmove.Parameters) # set First location Parameters + + if hasattr(pathobj, "Group"): # We have a compound or project. + # if OUTPUT_COMMENTS: + # out += linenumber() + "(compound: " + pathobj.Label + ")\n" + for p in pathobj.Group: + out += parse(p) + return out + else: # parsing simple path + + # groups might contain non-path things like stock. + if not hasattr(pathobj, "Path"): + return out + + # if OUTPUT_COMMENTS: + # out += linenumber() + "(" + pathobj.Label + ")\n" + + adaptiveOp = False + opHorizRapid = 0 + opVertRapid = 0 + + if "Adaptive" in pathobj.Name: + adaptiveOp = True + if hasattr(pathobj, "ToolController"): + if ( + hasattr(pathobj.ToolController, "HorizRapid") + and pathobj.ToolController.HorizRapid > 0 + ): + opHorizRapid = Units.Quantity( + pathobj.ToolController.HorizRapid, FreeCAD.Units.Velocity + ) + else: + FreeCAD.Console.PrintWarning( + "Tool Controller Horizontal Rapid Values are unset" + "\n" + ) + + if ( + hasattr(pathobj.ToolController, "VertRapid") + and pathobj.ToolController.VertRapid > 0 + ): + opVertRapid = Units.Quantity( + pathobj.ToolController.VertRapid, FreeCAD.Units.Velocity + ) + else: + FreeCAD.Console.PrintWarning( + "Tool Controller Vertical Rapid Values are unset" + "\n" + ) + + for c in PathUtils.getPathWithPlacement(pathobj).Commands: + + outstring = [] + command = c.Name + + if adaptiveOp and c.Name in ["G0", "G00"]: + if opHorizRapid and opVertRapid: + command = "G1" + else: + outstring.append("(Tool Controller Rapid Values are unset)" + "\n") + + outstring.append(command) + + # if modal: suppress the command if it is the same as the last one + if MODAL is True: + if command == lastcommand: + outstring.pop(0) + + if c.Name.startswith("(") and not OUTPUT_COMMENTS: # command is a comment + continue + + # Now add the remaining parameters in order + for param in params: + if param in c.Parameters: + if param == "F" and ( + currLocation[param] != c.Parameters[param] or OUTPUT_DOUBLES + ): + if c.Name not in [ + "G0", + "G00", + ]: # mach3_4 doesn't use rapid speeds + speed = Units.Quantity(c.Parameters["F"], FreeCAD.Units.Velocity) + if speed.getValueAs(UNIT_SPEED_FORMAT) > 0.0: + outstring.append( + param + + format( + float(speed.getValueAs(UNIT_SPEED_FORMAT)), + precision_string, + ) + ) + else: + continue + elif param == "T": + outstring.append(param + str(int(c.Parameters["T"]))) + elif param == "H": + outstring.append(param + str(int(c.Parameters["H"]))) + elif param == "D": + outstring.append(param + str(int(c.Parameters["D"]))) + elif param == "S": + outstring.append(param + str(int(c.Parameters["S"]))) + else: + if ( + (not OUTPUT_DOUBLES) + and (param in currLocation) + and (currLocation[param] == c.Parameters[param]) + ): + continue + else: + pos = Units.Quantity(c.Parameters[param], FreeCAD.Units.Length) + outstring.append( + param + format(float(pos.getValueAs(UNIT_FORMAT)), precision_string) + ) + + if adaptiveOp and c.Name in ["G0", "G00"]: + if opHorizRapid and opVertRapid: + if "Z" not in c.Parameters: + outstring.append( + "F" + + format( + float(opHorizRapid.getValueAs(UNIT_SPEED_FORMAT)), + precision_string, + ) + ) + else: + outstring.append( + "F" + + format( + float(opVertRapid.getValueAs(UNIT_SPEED_FORMAT)), + precision_string, + ) + ) + + # store the latest command + lastcommand = command + currLocation.update(c.Parameters) + + # Check for Tool Change: + if command == "M6": + # stop the spindle + out += linenumber() + "M5\n" + for line in TOOL_CHANGE.splitlines(True): + out += linenumber() + line + + # add height offset + if USE_TLO: + tool_height = "\nG43 H" + str(int(c.Parameters["T"])) + outstring.append(tool_height) + + if command == "message": + if OUTPUT_COMMENTS is False: + out = [] + else: + outstring.pop(0) # remove the command + + # prepend a line number and append a newline + if len(outstring) >= 1: + if OUTPUT_LINE_NUMBERS: + outstring.insert(0, (linenumber())) + + # append the line to the final output + for w in outstring: + out += w + COMMAND_SPACE + out = out.strip() + "\n" + + return out + + +# print(__name__ + " gcode postprocessor loaded.") diff --git a/src/Mod/CAM/Path/Post/scripts/mach3_mach4_post.py b/src/Mod/CAM/Path/Post/scripts/mach3_mach4_post.py index 43decaaa6b..b9bf80475f 100644 --- a/src/Mod/CAM/Path/Post/scripts/mach3_mach4_post.py +++ b/src/Mod/CAM/Path/Post/scripts/mach3_mach4_post.py @@ -2,6 +2,8 @@ # *************************************************************************** # * Copyright (c) 2014 sliptonic * +# * Copyright (c) 2022 - 2025 Larry Woestman * +# * Copyright (c) 2024 Ondsel * # * * # * This file is part of the FreeCAD CAx development system. * # * * @@ -21,463 +23,128 @@ # * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * # * USA * # * * -# ***************************************************************************/ +# *************************************************************************** + +from typing import Any, Dict + +from Path.Post.Processor import PostProcessor -import FreeCAD -from FreeCAD import Units import Path -import argparse -import datetime -import shlex -import Path.Base.Util as PathUtil -import Path.Post.Utils as PostUtils -import PathScripts.PathUtils as PathUtils -from builtins import open as pyopen +import FreeCAD -TOOLTIP = """ -This is a postprocessor file for the Path workbench. It is used to -take a pseudo-G-code fragment outputted by a Path object, and output -real G-code suitable for a mach3_4 3 axis mill. This postprocessor, once placed -in the appropriate PathScripts folder, can be used directly from inside -FreeCAD, via the GUI importer or via python scripts with: +translate = FreeCAD.Qt.translate -import mach3_4_post -mach3_4_post.export(object,"/path/to/file.ncc","") -""" +DEBUG = False +if DEBUG: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) -now = datetime.datetime.now() +# +# Define some types that are used throughout this file. +# +Values = Dict[str, Any] +Visible = Dict[str, bool] -parser = argparse.ArgumentParser(prog="mach3_4", add_help=False) -parser.add_argument("--no-header", action="store_true", help="suppress header output") -parser.add_argument("--no-comments", action="store_true", help="suppress comment output") -parser.add_argument("--line-numbers", action="store_true", help="prefix with line numbers") -parser.add_argument( - "--no-show-editor", - action="store_true", - help="don't pop up editor before writing output", -) -parser.add_argument("--precision", default="3", help="number of digits of precision, default=3") -parser.add_argument( - "--preamble", - help='set commands to be issued before the first command, default="G17 G54 G40 G49 G80 G90\\n"', -) -parser.add_argument( - "--postamble", - help='set commands to be issued after the last command, default="M05\\nG17 G54 G90 G80 G40\\nM2\\n"', -) -parser.add_argument( - "--inches", action="store_true", help="Convert output for US imperial mode (G20)" -) -parser.add_argument( - "--modal", - action="store_true", - help="Output the Same G-command Name USE NonModal Mode", -) -parser.add_argument("--axis-modal", action="store_true", help="Output the Same Axis Value Mode") -parser.add_argument( - "--no-tlo", - action="store_true", - help="suppress tool length offset (G43) following tool changes", -) -TOOLTIP_ARGS = parser.format_help() +class Mach3_Mach4(PostProcessor): + """The Mach3_Mach4 post processor class.""" -# These globals set common customization preferences -OUTPUT_COMMENTS = True -OUTPUT_HEADER = True -OUTPUT_LINE_NUMBERS = False -SHOW_EDITOR = True -MODAL = False # if true commands are suppressed if the same as previous line. -USE_TLO = True # if true G43 will be output following tool changes -OUTPUT_DOUBLES = True # if false duplicate axis values are suppressed if the same as previous line. -COMMAND_SPACE = " " -LINENR = 100 # line number starting value + def __init__( + self, + job, + tooltip=translate("CAM", "Mach3_Mach4 post processor"), + tooltipargs=[""], + units="Metric", + ) -> None: + super().__init__( + job=job, + tooltip=tooltip, + tooltipargs=tooltipargs, + units=units, + ) + Path.Log.debug("Mach3_Mach4 post processor initialized.") -# These globals will be reflected in the Machine configuration of the project -UNITS = "G21" # G21 for metric, G20 for us standard -UNIT_SPEED_FORMAT = "mm/min" -UNIT_FORMAT = "mm" - -MACHINE_NAME = "mach3_4" -CORNER_MIN = {"x": 0, "y": 0, "z": 0} -CORNER_MAX = {"x": 500, "y": 300, "z": 300} -PRECISION = 3 - -# Preamble text will appear at the beginning of the GCODE output file. -PREAMBLE = """G17 G54 G40 G49 G80 G90 -""" - -# Postamble text will appear following the last operation. -POSTAMBLE = """M05 + def init_values(self, values: Values) -> None: + """Initialize values that are used throughout the postprocessor.""" + # + super().init_values(values) + # + # Set any values here that need to override the default values set + # in the parent routine. + # + values["ENABLE_COOLANT"] = True + # + # Used in the argparser code as the "name" of the postprocessor program. + # This would normally show up in the usage message in the TOOLTIP_ARGS. + # + values["MACHINE_NAME"] = "mach3_4" + # + # Enable special processing for operations with "Adaptive" in the name. + # + values["OUTPUT_ADAPTIVE"] = True + # + # Output the machine name for mach3_mach4 instead of the machine units alone. + # + values["OUTPUT_MACHINE_NAME"] = True + # + # The order of parameters. + # + # mach3_mach4 doesn't want K properties on XY plane; Arcs need work. + # + values["PARAMETER_ORDER"] = [ + "X", + "Y", + "Z", + "A", + "B", + "C", + "I", + "J", + "F", + "S", + "T", + "Q", + "R", + "L", + "H", + "D", + "P", + ] + # + # Any commands in this value will be output as the last commands + # in the G-code file. + # + values[ + "POSTAMBLE" + ] = """M05 G17 G54 G90 G80 G40 -M2 -""" +M2""" + values["POSTPROCESSOR_FILE_NAME"] = __name__ + # + # Any commands in this value will be output after the header and + # safety block at the beginning of the G-code file. + # + values["PREAMBLE"] = """G17 G54 G40 G49 G80 G90""" + # + # Output the machine name for mach3_mach4 instead of the machine units alone. + # + values["SHOW_MACHINE_UNITS"] = False -# Pre operation text will be inserted before every operation -PRE_OPERATION = """""" + def init_arguments_visible(self, arguments_visible: Visible) -> None: + """Initialize which argument pairs are visible in TOOLTIP_ARGS.""" + super().init_arguments_visible(arguments_visible) + # + # Modify the visibility of any arguments from the defaults here. + # + arguments_visible["axis-modal"] = True -# Post operation text will be inserted after every operation -POST_OPERATION = """""" - -# Tool Change commands will be inserted before a tool change -TOOL_CHANGE = """""" - - -def processArguments(argstring): - global OUTPUT_HEADER - global OUTPUT_COMMENTS - global OUTPUT_LINE_NUMBERS - global SHOW_EDITOR - global PRECISION - global PREAMBLE - global POSTAMBLE - global UNITS - global UNIT_SPEED_FORMAT - global UNIT_FORMAT - global MODAL - global USE_TLO - global OUTPUT_DOUBLES - - try: - args = parser.parse_args(shlex.split(argstring)) - if args.no_header: - OUTPUT_HEADER = False - if args.no_comments: - OUTPUT_COMMENTS = False - if args.line_numbers: - OUTPUT_LINE_NUMBERS = True - if args.no_show_editor: - SHOW_EDITOR = False - print("Show editor = %d" % SHOW_EDITOR) - PRECISION = args.precision - if args.preamble is not None: - PREAMBLE = args.preamble.replace("\\n", "\n") - if args.postamble is not None: - POSTAMBLE = args.postamble.replace("\\n", "\n") - if args.inches: - UNITS = "G20" - UNIT_SPEED_FORMAT = "in/min" - UNIT_FORMAT = "in" - PRECISION = 4 - if args.modal: - MODAL = True - if args.no_tlo: - USE_TLO = False - if args.axis_modal: - print("here") - OUTPUT_DOUBLES = False - - except Exception: - return False - - return True - - -def export(objectslist, filename, argstring): - if not processArguments(argstring): - return None - global UNITS - global UNIT_FORMAT - global UNIT_SPEED_FORMAT - - for obj in objectslist: - if not hasattr(obj, "Path"): - print( - "the object " + obj.Name + " is not a path. Please select only path and Compounds." - ) - return None - - print("postprocessing...") - gcode = "" - - # write header - if OUTPUT_HEADER: - gcode += linenumber() + "(Exported by FreeCAD)\n" - gcode += linenumber() + "(Post Processor: " + __name__ + ")\n" - gcode += linenumber() + "(Output Time:" + str(now) + ")\n" - - # Write the preamble - if OUTPUT_COMMENTS: - gcode += linenumber() + "(begin preamble)\n" - for line in PREAMBLE.splitlines(): - gcode += linenumber() + line + "\n" - gcode += linenumber() + UNITS + "\n" - - for obj in objectslist: - - # Skip inactive operations - if not PathUtil.activeForOp(obj): - continue - - # do the pre_op - if OUTPUT_COMMENTS: - gcode += linenumber() + "(begin operation: %s)\n" % obj.Label - gcode += linenumber() + "(machine: %s, %s)\n" % ( - MACHINE_NAME, - UNIT_SPEED_FORMAT, - ) - for line in PRE_OPERATION.splitlines(True): - gcode += linenumber() + line - - # get coolant mode - coolantMode = PathUtil.coolantModeForOp(obj) - - # turn coolant on if required - if OUTPUT_COMMENTS: - if not coolantMode == "None": - gcode += linenumber() + "(Coolant On:" + coolantMode + ")\n" - if coolantMode == "Flood": - gcode += linenumber() + "M8" + "\n" - if coolantMode == "Mist": - gcode += linenumber() + "M7" + "\n" - - # process the operation gcode - gcode += parse(obj) - - # do the post_op - if OUTPUT_COMMENTS: - gcode += linenumber() + "(finish operation: %s)\n" % obj.Label - for line in POST_OPERATION.splitlines(True): - gcode += linenumber() + line - - # turn coolant off if required - if not coolantMode == "None": - if OUTPUT_COMMENTS: - gcode += linenumber() + "(Coolant Off:" + coolantMode + ")\n" - gcode += linenumber() + "M9" + "\n" - - # do the post_amble - if OUTPUT_COMMENTS: - gcode += "(begin postamble)\n" - for line in POSTAMBLE.splitlines(): - gcode += linenumber() + line + "\n" - - if FreeCAD.GuiUp and SHOW_EDITOR: - dia = PostUtils.GCodeEditorDialog() - dia.editor.setText(gcode) - result = dia.exec_() - if result: - final = dia.editor.toPlainText() - else: - final = gcode - else: - final = gcode - - print("done postprocessing.") - - if not filename == "-": - gfile = pyopen(filename, "w") - gfile.write(final) - gfile.close() - - return final - - -def linenumber(): - global LINENR - if OUTPUT_LINE_NUMBERS is True: - LINENR += 10 - return "N" + str(LINENR) + " " - return "" - - -def parse(pathobj): - global PRECISION - global MODAL - global OUTPUT_DOUBLES - global UNIT_FORMAT - global UNIT_SPEED_FORMAT - - out = "" - lastcommand = None - precision_string = "." + str(PRECISION) + "f" - currLocation = {} # keep track for no doubles - - # the order of parameters - # mach3_4 doesn't want K properties on XY plane Arcs need work. - params = [ - "X", - "Y", - "Z", - "A", - "B", - "C", - "I", - "J", - "F", - "S", - "T", - "Q", - "R", - "L", - "H", - "D", - "P", - ] - firstmove = Path.Command("G0", {"X": -1, "Y": -1, "Z": -1, "F": 0.0}) - currLocation.update(firstmove.Parameters) # set First location Parameters - - if hasattr(pathobj, "Group"): # We have a compound or project. - # if OUTPUT_COMMENTS: - # out += linenumber() + "(compound: " + pathobj.Label + ")\n" - for p in pathobj.Group: - out += parse(p) - return out - else: # parsing simple path - - # groups might contain non-path things like stock. - if not hasattr(pathobj, "Path"): - return out - - # if OUTPUT_COMMENTS: - # out += linenumber() + "(" + pathobj.Label + ")\n" - - adaptiveOp = False - opHorizRapid = 0 - opVertRapid = 0 - - if "Adaptive" in pathobj.Name: - adaptiveOp = True - if hasattr(pathobj, "ToolController"): - if ( - hasattr(pathobj.ToolController, "HorizRapid") - and pathobj.ToolController.HorizRapid > 0 - ): - opHorizRapid = Units.Quantity( - pathobj.ToolController.HorizRapid, FreeCAD.Units.Velocity - ) - else: - FreeCAD.Console.PrintWarning( - "Tool Controller Horizontal Rapid Values are unset" + "\n" - ) - - if ( - hasattr(pathobj.ToolController, "VertRapid") - and pathobj.ToolController.VertRapid > 0 - ): - opVertRapid = Units.Quantity( - pathobj.ToolController.VertRapid, FreeCAD.Units.Velocity - ) - else: - FreeCAD.Console.PrintWarning( - "Tool Controller Vertical Rapid Values are unset" + "\n" - ) - - for c in PathUtils.getPathWithPlacement(pathobj).Commands: - - outstring = [] - command = c.Name - - if adaptiveOp and c.Name in ["G0", "G00"]: - if opHorizRapid and opVertRapid: - command = "G1" - else: - outstring.append("(Tool Controller Rapid Values are unset)" + "\n") - - outstring.append(command) - - # if modal: suppress the command if it is the same as the last one - if MODAL is True: - if command == lastcommand: - outstring.pop(0) - - if c.Name.startswith("(") and not OUTPUT_COMMENTS: # command is a comment - continue - - # Now add the remaining parameters in order - for param in params: - if param in c.Parameters: - if param == "F" and ( - currLocation[param] != c.Parameters[param] or OUTPUT_DOUBLES - ): - if c.Name not in [ - "G0", - "G00", - ]: # mach3_4 doesn't use rapid speeds - speed = Units.Quantity(c.Parameters["F"], FreeCAD.Units.Velocity) - if speed.getValueAs(UNIT_SPEED_FORMAT) > 0.0: - outstring.append( - param - + format( - float(speed.getValueAs(UNIT_SPEED_FORMAT)), - precision_string, - ) - ) - else: - continue - elif param == "T": - outstring.append(param + str(int(c.Parameters["T"]))) - elif param == "H": - outstring.append(param + str(int(c.Parameters["H"]))) - elif param == "D": - outstring.append(param + str(int(c.Parameters["D"]))) - elif param == "S": - outstring.append(param + str(int(c.Parameters["S"]))) - else: - if ( - (not OUTPUT_DOUBLES) - and (param in currLocation) - and (currLocation[param] == c.Parameters[param]) - ): - continue - else: - pos = Units.Quantity(c.Parameters[param], FreeCAD.Units.Length) - outstring.append( - param + format(float(pos.getValueAs(UNIT_FORMAT)), precision_string) - ) - - if adaptiveOp and c.Name in ["G0", "G00"]: - if opHorizRapid and opVertRapid: - if "Z" not in c.Parameters: - outstring.append( - "F" - + format( - float(opHorizRapid.getValueAs(UNIT_SPEED_FORMAT)), - precision_string, - ) - ) - else: - outstring.append( - "F" - + format( - float(opVertRapid.getValueAs(UNIT_SPEED_FORMAT)), - precision_string, - ) - ) - - # store the latest command - lastcommand = command - currLocation.update(c.Parameters) - - # Check for Tool Change: - if command == "M6": - # stop the spindle - out += linenumber() + "M5\n" - for line in TOOL_CHANGE.splitlines(True): - out += linenumber() + line - - # add height offset - if USE_TLO: - tool_height = "\nG43 H" + str(int(c.Parameters["T"])) - outstring.append(tool_height) - - if command == "message": - if OUTPUT_COMMENTS is False: - out = [] - else: - outstring.pop(0) # remove the command - - # prepend a line number and append a newline - if len(outstring) >= 1: - if OUTPUT_LINE_NUMBERS: - outstring.insert(0, (linenumber())) - - # append the line to the final output - for w in outstring: - out += w + COMMAND_SPACE - out = out.strip() + "\n" - - return out - - -# print(__name__ + " gcode postprocessor loaded.") + @property + def tooltip(self): + tooltip: str = """ + This is a postprocessor file for the CAM workbench. + It is used to take a pseudo-gcode fragment from a CAM object + and output 'real' GCode suitable for a Mach3_4 3 axis mill. + """ + return tooltip diff --git a/src/Mod/CAM/Path/Post/scripts/refactored_masso_g3_post.py b/src/Mod/CAM/Path/Post/scripts/masso_g3_post.py similarity index 80% rename from src/Mod/CAM/Path/Post/scripts/refactored_masso_g3_post.py rename to src/Mod/CAM/Path/Post/scripts/masso_g3_post.py index dfb08accaa..2463f4edc6 100644 --- a/src/Mod/CAM/Path/Post/scripts/refactored_masso_g3_post.py +++ b/src/Mod/CAM/Path/Post/scripts/masso_g3_post.py @@ -28,7 +28,7 @@ from typing import Any, Dict -from Path.Post.scripts.refactored_linuxcnc_post import Refactored_Linuxcnc +from Path.Post.Processor import PostProcessor import Path import FreeCAD @@ -48,13 +48,13 @@ else: Values = Dict[str, Any] -class Refactored_Masso_G3(Refactored_Linuxcnc): - """The Refactored Masso G3 post processor class.""" +class Masso_G3(PostProcessor): + """The Masso G3 post processor class.""" def __init__( self, job, - tooltip=translate("CAM", "Refactored Masso G3 post processor"), + tooltip=translate("CAM", "Masso G3 post processor"), tooltipargs=[""], units="Metric", ) -> None: @@ -64,25 +64,39 @@ class Refactored_Masso_G3(Refactored_Linuxcnc): tooltipargs=tooltipargs, units=units, ) - Path.Log.debug("Refactored Masso G3 post processor initialized.") + Path.Log.debug("Masso G3 post processor initialized.") def init_values(self, values: Values) -> None: """Initialize values that are used throughout the postprocessor.""" - # super().init_values(values) - # - # Set any values here that need to override the default values set - # in the parent routine. - # - # - # Used in the argparser code as the "name" of the postprocessor program. - # + values["ENABLE_COOLANT"] = True + values["PARAMETER_ORDER"] = [ + "X", + "Y", + "Z", + "A", + "B", + "C", + "I", + "J", + "F", + "S", + "T", + "Q", + "R", + "L", + "H", + "D", + "P", + ] + values[ + "POSTAMBLE" + ] = """M05 +G17 G54 G90 G80 G40 +M2""" + values["PREAMBLE"] = """G17 G54 G40 G49 G80 G90""" values["MACHINE_NAME"] = "Masso G3" values["POSTPROCESSOR_FILE_NAME"] = __name__ - # - # setting TOOL_BEFORE_CHANGE to True will output T# M6 before each tool change - # rather than M6 T#. - # values["TOOL_BEFORE_CHANGE"] = True @property diff --git a/src/Mod/CAM/Path/Post/scripts/refactored_centroid_post.py b/src/Mod/CAM/Path/Post/scripts/refactored_centroid_post.py deleted file mode 100644 index 3db3944fec..0000000000 --- a/src/Mod/CAM/Path/Post/scripts/refactored_centroid_post.py +++ /dev/null @@ -1,196 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * Copyright (c) 2014 sliptonic * -# * Copyright (c) 2022 - 2025 Larry Woestman * -# * Copyright (c) 2024 Ondsel * -# * * -# * This file is part of the FreeCAD CAx development system. * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * FreeCAD is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Lesser General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with FreeCAD; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -from typing import Any, Dict - -from Path.Post.Processor import PostProcessor - -import Path -import FreeCAD - -translate = FreeCAD.Qt.translate - -DEBUG = False -if DEBUG: - Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) - Path.Log.trackModule(Path.Log.thisModule()) -else: - Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) - -# -# Define some types that are used throughout this file. -# -Values = Dict[str, Any] -Visible = Dict[str, bool] - - -class Refactored_Centroid(PostProcessor): - """The Refactored Centroid post processor class.""" - - def __init__( - self, - job, - tooltip=translate("CAM", "Refactored Centroid post processor"), - tooltipargs=[""], - units="Metric", - ) -> None: - super().__init__( - job=job, - tooltip=tooltip, - tooltipargs=tooltipargs, - units=units, - ) - Path.Log.debug("Refactored Centroid post processor initialized.") - - def init_values(self, values: Values) -> None: - """Initialize values that are used throughout the postprocessor.""" - # - super().init_values(values) - # - # Set any values here that need to override the default values set - # in the parent routine. - # - # Use 4 digits for axis precision by default. - # - values["AXIS_PRECISION"] = 4 - values["DEFAULT_AXIS_PRECISION"] = 4 - values["DEFAULT_INCH_AXIS_PRECISION"] = 4 - # - # Use ";" as the comment symbol - # - values["COMMENT_SYMBOL"] = ";" - # - # Use 1 digit for feed precision by default. - # - values["FEED_PRECISION"] = 1 - values["DEFAULT_FEED_PRECISION"] = 1 - values["DEFAULT_INCH_FEED_PRECISION"] = 1 - # - # This value usually shows up in the post_op comment as "Finish operation:". - # Change it to "End" to produce "End operation:". - # - values["FINISH_LABEL"] = "End" - # - # If this value is True, then a list of tool numbers - # with their labels are output just before the preamble. - # - values["LIST_TOOLS_IN_PREAMBLE"] = True - # - # Used in the argparser code as the "name" of the postprocessor program. - # This would normally show up in the usage message in the TOOLTIP_ARGS. - # - values["MACHINE_NAME"] = "Centroid" - # - # This list controls the order of parameters in a line during output. - # centroid doesn't want K properties on XY plane; Arcs need work. - # - values["PARAMETER_ORDER"] = [ - "X", - "Y", - "Z", - "A", - "B", - "I", - "J", - "F", - "S", - "T", - "Q", - "R", - "L", - "H", - ] - # - # Any commands in this value will be output as the last commands - # in the G-code file. - # - values["POSTAMBLE"] = """M99""" - values["POSTPROCESSOR_FILE_NAME"] = __name__ - # - # Any commands in this value will be output after the header and - # safety block at the beginning of the G-code file. - # - values["PREAMBLE"] = """G53 G00 G17""" - # - # Output any messages. - # - values["REMOVE_MESSAGES"] = False - # - # Any commands in this value are output after the header but before the preamble, - # then again after the TOOLRETURN but before the POSTAMBLE. - # - values["SAFETYBLOCK"] = """G90 G80 G40 G49""" - # - # Do not show the current machine units just before the PRE_OPERATION. - # - values["SHOW_MACHINE_UNITS"] = False - # - # Do not show the current operation label just before the PRE_OPERATION. - # - values["SHOW_OPERATION_LABELS"] = False - # - # Do not output an M5 command to stop the spindle for tool changes. - # - values["STOP_SPINDLE_FOR_TOOL_CHANGE"] = False - # - # spindle off, height offset canceled, spindle retracted - # (M25 is a centroid command to retract spindle) - # - values[ - "TOOLRETURN" - ] = """M5 -M25 -G49 H0""" - # - # Default to not outputting a G43 following tool changes - # - values["USE_TLO"] = False - # - # This was in the original centroid postprocessor file - # but does not appear to be used anywhere. - # - # ZAXISRETURN = """G91 G28 X0 Z0 G90""" - # - - def init_arguments_visible(self, arguments_visible: Visible) -> None: - """Initialize which argument pairs are visible in TOOLTIP_ARGS.""" - super().init_arguments_visible(arguments_visible) - # - # Modify the visibility of any arguments from the defaults here. - # - arguments_visible["axis-modal"] = False - arguments_visible["precision"] = False - arguments_visible["tlo"] = False - - @property - def tooltip(self): - tooltip: str = """ - This is a postprocessor file for the CAM workbench. - It is used to take a pseudo-gcode fragment from a CAM object - and output 'real' GCode suitable for a centroid 3 axis mill. - """ - return tooltip diff --git a/src/Mod/CAM/Path/Post/scripts/refactored_grbl_post.py b/src/Mod/CAM/Path/Post/scripts/refactored_grbl_post.py deleted file mode 100644 index 6304710fc2..0000000000 --- a/src/Mod/CAM/Path/Post/scripts/refactored_grbl_post.py +++ /dev/null @@ -1,190 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * Copyright (c) 2014 sliptonic * -# * Copyright (c) 2022 - 2025 Larry Woestman * -# * Copyright (c) 2024 Ondsel * -# * * -# * This file is part of the FreeCAD CAx development system. * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * FreeCAD is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Lesser General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with FreeCAD; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -import argparse - -from typing import Any, Dict - -from Path.Post.Processor import PostProcessor - -import Path -import FreeCAD - -translate = FreeCAD.Qt.translate - -DEBUG = False -if DEBUG: - Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) - Path.Log.trackModule(Path.Log.thisModule()) -else: - Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) - -# -# Define some types that are used throughout this file. -# -Defaults = Dict[str, bool] -Values = Dict[str, Any] -Visible = Dict[str, bool] - - -class Refactored_Grbl(PostProcessor): - """The Refactored Grbl post processor class.""" - - def __init__( - self, - job, - tooltip=translate("CAM", "Refactored Grbl post processor"), - tooltipargs=[""], - units="Metric", - ) -> None: - super().__init__( - job=job, - tooltip=tooltip, - tooltipargs=tooltipargs, - units=units, - ) - Path.Log.debug("Refactored Grbl post processor initialized.") - - def init_values(self, values: Values) -> None: - """Initialize values that are used throughout the postprocessor.""" - # - super().init_values(values) - # - # Set any values here that need to override the default values set - # in the parent routine. - # - values["ENABLE_COOLANT"] = True - # - # If this is set to True, then commands that are placed in - # comments that look like (MC_RUN_COMMAND: blah) will be output. - # - values["ENABLE_MACHINE_SPECIFIC_COMMANDS"] = True - # - # Used in the argparser code as the "name" of the postprocessor program. - # This would normally show up in the usage message in the TOOLTIP_ARGS. - # - values["MACHINE_NAME"] = "Grbl" - # - # Default to outputting Path labels at the beginning of each Path. - # - values["OUTPUT_PATH_LABELS"] = True - # - # Default to not outputting M6 tool changes (comment it) as grbl - # currently does not handle it. - # - values["OUTPUT_TOOL_CHANGE"] = False - # - # The order of the parameters. - # Arcs may only work on the XY plane (this needs to be verified). - # - values["PARAMETER_ORDER"] = [ - "X", - "Y", - "Z", - "A", - "B", - "C", - "U", - "V", - "W", - "I", - "J", - "K", - "F", - "S", - "T", - "Q", - "R", - "L", - "P", - ] - # - # Any commands in this value will be output as the last commands in the G-code file. - # - values[ - "POSTAMBLE" - ] = """M5 -G17 G90 -M2""" - values["POSTPROCESSOR_FILE_NAME"] = __name__ - # - # Any commands in this value will be output after the header and - # safety block at the beginning of the G-code file. - # - values["PREAMBLE"] = """G17 G90""" - # - # Do not show the current machine units just before the PRE_OPERATION. - # - values["SHOW_MACHINE_UNITS"] = False - # - # Default to not outputting a G43 following tool changes - # - values["USE_TLO"] = False - - def init_argument_defaults(self, argument_defaults: Defaults) -> None: - """Initialize which arguments (in a pair) are shown as the default argument.""" - super().init_argument_defaults(argument_defaults) - # - # Modify which argument to show as the default in flag-type arguments here. - # If the value is True, the first argument will be shown as the default. - # If the value is False, the second argument will be shown as the default. - # - # For example, if you want to show Metric mode as the default, use: - # argument_defaults["metric_inch"] = True - # - # If you want to show that "Don't pop up editor for writing output" is - # the default, use: - # argument_defaults["show-editor"] = False. - # - # Note: You also need to modify the corresponding entries in the "values" hash - # to actually make the default value(s) change to match. - # - argument_defaults["tlo"] = False - argument_defaults["tool_change"] = False - - def init_arguments_visible(self, arguments_visible: Visible) -> None: - """Initialize which argument pairs are visible in TOOLTIP_ARGS.""" - super().init_arguments_visible(arguments_visible) - # - # Modify the visibility of any arguments from the defaults here. - # - arguments_visible["bcnc"] = True - arguments_visible["axis-modal"] = False - arguments_visible["return-to"] = True - arguments_visible["tlo"] = False - arguments_visible["tool_change"] = True - arguments_visible["translate_drill"] = True - arguments_visible["wait-for-spindle"] = True - - @property - def tooltip(self): - tooltip: str = """ - This is a postprocessor file for the CAM workbench. - It is used to take a pseudo-gcode fragment from a CAM object - and output 'real' GCode suitable for a Grbl 3 axis mill. - """ - return tooltip diff --git a/src/Mod/CAM/Path/Post/scripts/refactored_linuxcnc_post.py b/src/Mod/CAM/Path/Post/scripts/refactored_linuxcnc_post.py deleted file mode 100644 index 0cfecba7c0..0000000000 --- a/src/Mod/CAM/Path/Post/scripts/refactored_linuxcnc_post.py +++ /dev/null @@ -1,129 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * Copyright (c) 2014 sliptonic * -# * Copyright (c) 2022 - 2025 Larry Woestman * -# * Copyright (c) 2024 Ondsel * -# * Copyright (c) 2024 Carl Slater * -# * * -# * This file is part of the FreeCAD CAx development system. * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * FreeCAD is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Lesser General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with FreeCAD; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -from typing import Any, Dict - -from Path.Post.Processor import PostProcessor - -import Path -import FreeCAD - -translate = FreeCAD.Qt.translate - -DEBUG = False -if DEBUG: - Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) - Path.Log.trackModule(Path.Log.thisModule()) -else: - Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) - -# -# Define some types that are used throughout this file. -# -Values = Dict[str, Any] - - -class Refactored_Linuxcnc(PostProcessor): - """The Refactored LinuxCNC post processor class.""" - - def __init__( - self, - job, - tooltip=translate("CAM", "Refactored LinuxCNC post processor"), - tooltipargs=[""], - units="Metric", - ) -> None: - super().__init__( - job=job, - tooltip=tooltip, - tooltipargs=tooltipargs, - units=units, - ) - Path.Log.debug("Refactored LinuxCNC post processor initialized.") - - def init_values(self, values: Values) -> None: - """Initialize values that are used throughout the postprocessor.""" - # - super().init_values(values) - # - # Set any values here that need to override the default values set - # in the parent routine. - # - values["ENABLE_COOLANT"] = True - # - # The order of parameters. - # - # linuxcnc doesn't want K properties on XY plane; Arcs need work. - # - values["PARAMETER_ORDER"] = [ - "X", - "Y", - "Z", - "A", - "B", - "C", - "I", - "J", - "F", - "S", - "T", - "Q", - "R", - "L", - "H", - "D", - "P", - ] - # - # Used in the argparser code as the "name" of the postprocessor program. - # - values["MACHINE_NAME"] = "LinuxCNC" - # - # Any commands in this value will be output as the last commands - # in the G-code file. - # - values[ - "POSTAMBLE" - ] = """M05 -G17 G54 G90 G80 G40 -M2""" - values["POSTPROCESSOR_FILE_NAME"] = __name__ - # - # Any commands in this value will be output after the header and - # safety block at the beginning of the G-code file. - # - values["PREAMBLE"] = """G17 G54 G40 G49 G80 G90""" - - @property - def tooltip(self): - tooltip: str = """ - This is a postprocessor file for the CAM workbench. - It is used to take a pseudo-gcode fragment from a CAM object - and output 'real' GCode suitable for a linuxcnc 3 axis mill. - """ - return tooltip diff --git a/src/Mod/CAM/Path/Post/scripts/refactored_mach3_mach4_post.py b/src/Mod/CAM/Path/Post/scripts/refactored_mach3_mach4_post.py deleted file mode 100644 index fddd3a66ed..0000000000 --- a/src/Mod/CAM/Path/Post/scripts/refactored_mach3_mach4_post.py +++ /dev/null @@ -1,150 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * Copyright (c) 2014 sliptonic * -# * Copyright (c) 2022 - 2025 Larry Woestman * -# * Copyright (c) 2024 Ondsel * -# * * -# * This file is part of the FreeCAD CAx development system. * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * FreeCAD is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Lesser General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with FreeCAD; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -from typing import Any, Dict - -from Path.Post.Processor import PostProcessor - -import Path -import FreeCAD - -translate = FreeCAD.Qt.translate - -DEBUG = False -if DEBUG: - Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) - Path.Log.trackModule(Path.Log.thisModule()) -else: - Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) - -# -# Define some types that are used throughout this file. -# -Values = Dict[str, Any] -Visible = Dict[str, bool] - - -class Refactored_Mach3_Mach4(PostProcessor): - """The Refactored Mach3_Mach4 post processor class.""" - - def __init__( - self, - job, - tooltip=translate("CAM", "Refactored Mach3_Mach4 post processor"), - tooltipargs=[""], - units="Metric", - ) -> None: - super().__init__( - job=job, - tooltip=tooltip, - tooltipargs=tooltipargs, - units=units, - ) - Path.Log.debug("Refactored Mach3_Mach4 post processor initialized.") - - def init_values(self, values: Values) -> None: - """Initialize values that are used throughout the postprocessor.""" - # - super().init_values(values) - # - # Set any values here that need to override the default values set - # in the parent routine. - # - values["ENABLE_COOLANT"] = True - # - # Used in the argparser code as the "name" of the postprocessor program. - # This would normally show up in the usage message in the TOOLTIP_ARGS. - # - values["MACHINE_NAME"] = "mach3_4" - # - # Enable special processing for operations with "Adaptive" in the name. - # - values["OUTPUT_ADAPTIVE"] = True - # - # Output the machine name for mach3_mach4 instead of the machine units alone. - # - values["OUTPUT_MACHINE_NAME"] = True - # - # The order of parameters. - # - # mach3_mach4 doesn't want K properties on XY plane; Arcs need work. - # - values["PARAMETER_ORDER"] = [ - "X", - "Y", - "Z", - "A", - "B", - "C", - "I", - "J", - "F", - "S", - "T", - "Q", - "R", - "L", - "H", - "D", - "P", - ] - # - # Any commands in this value will be output as the last commands - # in the G-code file. - # - values[ - "POSTAMBLE" - ] = """M05 -G17 G54 G90 G80 G40 -M2""" - values["POSTPROCESSOR_FILE_NAME"] = __name__ - # - # Any commands in this value will be output after the header and - # safety block at the beginning of the G-code file. - # - values["PREAMBLE"] = """G17 G54 G40 G49 G80 G90""" - # - # Output the machine name for mach3_mach4 instead of the machine units alone. - # - values["SHOW_MACHINE_UNITS"] = False - - def init_arguments_visible(self, arguments_visible: Visible) -> None: - """Initialize which argument pairs are visible in TOOLTIP_ARGS.""" - super().init_arguments_visible(arguments_visible) - # - # Modify the visibility of any arguments from the defaults here. - # - arguments_visible["axis-modal"] = True - - @property - def tooltip(self): - tooltip: str = """ - This is a postprocessor file for the CAM workbench. - It is used to take a pseudo-gcode fragment from a CAM object - and output 'real' GCode suitable for a Mach3_4 3 axis mill. - """ - return tooltip diff --git a/src/Mod/CAM/Path/Post/scripts/smoothie_post.py b/src/Mod/CAM/Path/Post/scripts/smoothie_post.py index b0476015d3..17d6f4ebc1 100644 --- a/src/Mod/CAM/Path/Post/scripts/smoothie_post.py +++ b/src/Mod/CAM/Path/Post/scripts/smoothie_post.py @@ -23,441 +23,265 @@ # * * # *************************************************************************** +import os +import socket +import sys +from typing import Any, Dict, Optional -import argparse -import datetime -import Path.Post.Utils as PostUtils -import PathScripts.PathUtils as PathUtils +from Path.Post.Processor import PostProcessor + +import Path import FreeCAD -from FreeCAD import Units -import shlex -from builtins import open as pyopen -TOOLTIP = """ -This is a postprocessor file for the Path workbench. It is used to -take a pseudo-G-code fragment outputted by a Path object, and output -real G-code suitable for a smoothieboard. This postprocessor, once placed -in the appropriate PathScripts folder, can be used directly from inside -FreeCAD, via the GUI importer or via python scripts with: +translate = FreeCAD.Qt.translate -import smoothie_post -smoothie_post.export(object,"/path/to/file.ncc","") -""" - -now = datetime.datetime.now() - -parser = argparse.ArgumentParser(prog="linuxcnc", add_help=False) -parser.add_argument("--header", action="store_true", help="output headers (default)") -parser.add_argument("--no-header", action="store_true", help="suppress header output") -parser.add_argument("--comments", action="store_true", help="output comment (default)") -parser.add_argument("--no-comments", action="store_true", help="suppress comment output") -parser.add_argument("--line-numbers", action="store_true", help="prefix with line numbers") -parser.add_argument( - "--no-line-numbers", - action="store_true", - help="don't prefix with line numbers (default)", -) -parser.add_argument( - "--show-editor", - action="store_true", - help="pop up editor before writing output (default)", -) -parser.add_argument( - "--no-show-editor", - action="store_true", - help="don't pop up editor before writing output", -) -parser.add_argument("--precision", default="4", help="number of digits of precision, default=4") -parser.add_argument( - "--preamble", - help='set commands to be issued before the first command, default="G17\\nG90\\n"', -) -parser.add_argument( - "--postamble", - help='set commands to be issued after the last command, default="M05\\nG17 G90\\nM2\\n"', -) -parser.add_argument("--IP_ADDR", help="IP Address for machine target machine") -parser.add_argument( - "--verbose", - action="store_true", - help='verbose output for debugging, default="False"', -) -parser.add_argument( - "--inches", action="store_true", help="Convert output for US imperial mode (G20)" -) - -TOOLTIP_ARGS = parser.format_help() - -# These globals set common customization preferences -OUTPUT_COMMENTS = True -OUTPUT_HEADER = True -OUTPUT_LINE_NUMBERS = False -IP_ADDR = None -VERBOSE = False - -SPINDLE_SPEED = 0.0 - -if FreeCAD.GuiUp: - SHOW_EDITOR = True +DEBUG = False +if DEBUG: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) else: - SHOW_EDITOR = False -MODAL = False # if true commands are suppressed if the same as previous line. -COMMAND_SPACE = " " -LINENR = 100 # line number starting value + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) -# These globals will be reflected in the Machine configuration of the project -UNITS = "G21" # G21 for metric, G20 for us standard -UNIT_SPEED_FORMAT = "mm/min" -UNIT_FORMAT = "mm" +# +# Define some types that are used throughout this file. +# +Values = Dict[str, Any] -MACHINE_NAME = "SmoothieBoard" -CORNER_MIN = {"x": 0, "y": 0, "z": 0} -CORNER_MAX = {"x": 500, "y": 300, "z": 300} -# Preamble text will appear at the beginning of the GCODE output file. -PREAMBLE = """G17 G90 -""" +class Smoothie(PostProcessor): + """ + The SmoothieBoard post processor class. -# Postamble text will appear following the last operation. -POSTAMBLE = """M05 + This postprocessor outputs G-code suitable for SmoothieBoard controllers. + It supports direct network upload to the SmoothieBoard via TCP/IP. + """ + + def __init__( + self, + job, + tooltip=translate("CAM", "Refactored SmoothieBoard post processor"), + tooltipargs=["ip-addr", "verbose"], + units="Metric", + ) -> None: + super().__init__( + job=job, + tooltip=tooltip, + tooltipargs=tooltipargs, + units=units, + ) + Path.Log.debug("Refactored SmoothieBoard post processor initialized.") + self.ip_addr: Optional[str] = None + self.verbose: bool = False + + def init_values(self, values: Values) -> None: + """Initialize values that are used throughout the postprocessor.""" + # + super().init_values(values) + # + # Set any values here that need to override the default values set + # in the parent routine. + # + # The order of parameters. + # SmoothieBoard doesn't want K properties on XY plane (like LinuxCNC). + # + values["PARAMETER_ORDER"] = [ + "X", + "Y", + "Z", + "A", + "B", + "I", + "J", + "F", + "S", + "T", + "Q", + "R", + "L", + ] + # + # Used in the argparser code as the "name" of the postprocessor program. + # + values["MACHINE_NAME"] = "SmoothieBoard" + # + # Any commands in this value will be output as the last commands + # in the G-code file. + # + values[ + "POSTAMBLE" + ] = """M05 G17 G90 -M2 -""" +M2""" + values["POSTPROCESSOR_FILE_NAME"] = __name__ + # + # Any commands in this value will be output after the header and + # safety block at the beginning of the G-code file. + # + values["PREAMBLE"] = """G17 G90""" + def init_arguments(self, values, argument_defaults, arguments_visible): + """Initialize command-line arguments, including SmoothieBoard-specific options.""" + parser = super().init_arguments(values, argument_defaults, arguments_visible) -# Pre operation text will be inserted before every operation -PRE_OPERATION = """""" + # Add SmoothieBoard-specific argument group + smoothie_group = parser.add_argument_group("SmoothieBoard-specific arguments") -# Post operation text will be inserted after every operation -POST_OPERATION = """""" + smoothie_group.add_argument( + "--ip-addr", help="IP address for direct upload to SmoothieBoard (e.g., 192.168.1.100)" + ) -# Tool Change commands will be inserted before a tool change -TOOL_CHANGE = """""" + smoothie_group.add_argument( + "--verbose", + action="store_true", + help="Enable verbose output for network transfer debugging", + ) -# Number of digits after the decimal point -PRECISION = 5 + return parser + def process_arguments(self): + """Process arguments and update values, including SmoothieBoard-specific settings.""" + flag, args = super().process_arguments() -def processArguments(argstring): - global OUTPUT_HEADER - global OUTPUT_COMMENTS - global OUTPUT_LINE_NUMBERS - global SHOW_EDITOR - global IP_ADDR - global VERBOSE - global PRECISION - global PREAMBLE - global POSTAMBLE - global UNITS - global UNIT_SPEED_FORMAT - global UNIT_FORMAT + if flag and args: + # Update SmoothieBoard-specific values from parsed arguments + if hasattr(args, "ip_addr") and args.ip_addr: + self.ip_addr = args.ip_addr + Path.Log.info(f"SmoothieBoard IP address set to: {self.ip_addr}") - try: - args = parser.parse_args(shlex.split(argstring)) + if hasattr(args, "verbose"): + self.verbose = args.verbose + if self.verbose: + Path.Log.info("Verbose mode enabled") - if args.no_header: - OUTPUT_HEADER = False - if args.header: - OUTPUT_HEADER = True - if args.no_comments: - OUTPUT_COMMENTS = False - if args.comments: - OUTPUT_COMMENTS = True - if args.no_line_numbers: - OUTPUT_LINE_NUMBERS = False - if args.line_numbers: - OUTPUT_LINE_NUMBERS = True - if args.no_show_editor: - SHOW_EDITOR = False - if args.show_editor: - SHOW_EDITOR = True - print("Show editor = %d" % SHOW_EDITOR) - PRECISION = args.precision - if args.preamble is not None: - PREAMBLE = args.preamble.replace("\\n", "\n") - if args.postamble is not None: - POSTAMBLE = args.postamble.replace("\\n", "\n") - if args.inches: - UNITS = "G20" - UNIT_SPEED_FORMAT = "in/min" - UNIT_FORMAT = "in" + return flag, args - IP_ADDR = args.IP_ADDR - VERBOSE = args.verbose + def export(self): + """Override export to handle network upload to SmoothieBoard.""" + # First, do the standard export processing + gcode_sections = super().export() - except Exception: - return False + if gcode_sections is None: + return None - return True + # If IP address is specified, send to SmoothieBoard instead of writing to file + if self.ip_addr: + # Combine all G-code sections + gcode = "" + for section_name, section_gcode in gcode_sections: + if section_gcode: + gcode += section_gcode + # Get the output filename from the job + filename = self._job.PostProcessorOutputFile + if not filename or filename == "-": + filename = "output.nc" -def export(objectslist, filename, argstring): - processArguments(argstring) - global UNITS - for obj in objectslist: - if not hasattr(obj, "Path"): + self._send_to_smoothie(self.ip_addr, gcode, filename) + + # Return the gcode for display/editor + return gcode_sections + + # Normal file-based export + return gcode_sections + + def _send_to_smoothie(self, ip: str, gcode: str, fname: str) -> None: + """ + Send G-code directly to SmoothieBoard via network. + + Args: + ip: IP address of the SmoothieBoard + gcode: G-code string to send + fname: Filename to use on the SmoothieBoard SD card + """ + fname = os.path.basename(fname) + FreeCAD.Console.PrintMessage(f"Sending to SmoothieBoard: {fname}\n") + + gcode = gcode.rstrip() + filesize = len(gcode) + + try: + # Make connection to SmoothieBoard SFTP server (port 115) + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(4.0) + s.connect((ip, 115)) + tn = s.makefile(mode="rw") + + # Read startup prompt + ln = tn.readline() + if not ln.startswith("+"): + FreeCAD.Console.PrintError(f"Failed to connect with SFTP: {ln}\n") + return + + if self.verbose: + print("RSP: " + ln.strip()) + + # Issue initial store command + tn.write(f"STOR OLD /sd/{fname}\n") + tn.flush() + + ln = tn.readline() + if not ln.startswith("+"): + FreeCAD.Console.PrintError(f"Failed to create file: {ln}\n") + return + + if self.verbose: + print("RSP: " + ln.strip()) + + # Send size of file + tn.write(f"SIZE {filesize}\n") + tn.flush() + + ln = tn.readline() + if not ln.startswith("+"): + FreeCAD.Console.PrintError(f"Failed: {ln}\n") + return + + if self.verbose: + print("RSP: " + ln.strip()) + + # Now send file + cnt = 0 + for line in gcode.splitlines(True): + tn.write(line) + if self.verbose: + cnt += len(line) + print("SND: " + line.strip()) + print(f"{cnt}/{filesize}\r", end="") + + tn.flush() + + ln = tn.readline() + if not ln.startswith("+"): + FreeCAD.Console.PrintError(f"Failed to save file: {ln}\n") + return + + if self.verbose: + print("RSP: " + ln.strip()) + + # Exit + tn.write("DONE\n") + tn.flush() + tn.close() + + FreeCAD.Console.PrintMessage("Upload complete\n") + + except socket.timeout: + FreeCAD.Console.PrintError(f"Connection timeout while connecting to {ip}:115\n") + except ConnectionRefusedError: FreeCAD.Console.PrintError( - "the object " - + obj.Name - + " is not a path. Please select only path and Compounds.\n" + f"Connection refused by {ip}:115. Is the SmoothieBoard running?\n" ) - return + except Exception as e: + FreeCAD.Console.PrintError(f"Error sending to SmoothieBoard: {str(e)}\n") - FreeCAD.Console.PrintMessage("postprocessing...\n") - gcode = "" + @property + def tooltip(self): + tooltip: str = """ + This is a postprocessor file for the CAM workbench. + It is used to take a pseudo-gcode fragment from a CAM object + and output 'real' GCode suitable for a SmoothieBoard controller. - # Find the machine. - # The user my have overridden post processor defaults in the GUI. Make - # sure we're using the current values in the Machine Def. - myMachine = None - for pathobj in objectslist: - if hasattr(pathobj, "MachineName"): - myMachine = pathobj.MachineName - if hasattr(pathobj, "MachineUnits"): - if pathobj.MachineUnits == "Metric": - UNITS = "G21" - else: - UNITS = "G20" - if myMachine is None: - FreeCAD.Console.PrintWarning("No machine found in this selection\n") - - # write header - if OUTPUT_HEADER: - gcode += linenumber() + "(Exported by FreeCAD)\n" - gcode += linenumber() + "(Post Processor: " + __name__ + ")\n" - gcode += linenumber() + "(Output Time:" + str(now) + ")\n" - - # Write the preamble - if OUTPUT_COMMENTS: - gcode += linenumber() + "(begin preamble)\n" - for line in PREAMBLE.splitlines(): - gcode += linenumber() + line + "\n" - gcode += linenumber() + UNITS + "\n" - - for obj in objectslist: - - # do the pre_op - if OUTPUT_COMMENTS: - gcode += linenumber() + "(begin operation: " + obj.Label + ")\n" - for line in PRE_OPERATION.splitlines(True): - gcode += linenumber() + line - - gcode += parse(obj) - - # do the post_op - if OUTPUT_COMMENTS: - gcode += linenumber() + "(finish operation: " + obj.Label + ")\n" - for line in POST_OPERATION.splitlines(True): - gcode += linenumber() + line - - # do the post_amble - - if OUTPUT_COMMENTS: - gcode += "(begin postamble)\n" - for line in POSTAMBLE.splitlines(): - gcode += linenumber() + line + "\n" - - if SHOW_EDITOR: - dia = PostUtils.GCodeEditorDialog() - dia.editor.setText(gcode) - result = dia.exec_() - if result: - final = dia.editor.toPlainText() - else: - final = gcode - else: - final = gcode - - if IP_ADDR is not None: - sendToSmoothie(IP_ADDR, final, filename) - else: - - if not filename == "-": - gfile = pyopen(filename, "w") - gfile.write(final) - gfile.close() - - FreeCAD.Console.PrintMessage("done postprocessing.\n") - return final - - -def sendToSmoothie(ip, GCODE, fname): - import sys - import socket - import os - - fname = os.path.basename(fname) - FreeCAD.Console.PrintMessage("sending to smoothie: {}\n".format(fname)) - - f = GCODE.rstrip() - filesize = len(f) - # make connection to sftp server - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(4.0) - s.connect((ip, 115)) - tn = s.makefile(mode="rw") - - # read startup prompt - ln = tn.readline() - if not ln.startswith("+"): - FreeCAD.Console.PrintMessage("Failed to connect with sftp: {}\n".format(ln)) - sys.exit() - - if VERBOSE: - print("RSP: " + ln.strip()) - - # Issue initial store command - tn.write("STOR OLD /sd/" + fname + "\n") - tn.flush() - - ln = tn.readline() - if not ln.startswith("+"): - FreeCAD.Console.PrintError("Failed to create file: {}\n".format(ln)) - sys.exit() - - if VERBOSE: - print("RSP: " + ln.strip()) - - # send size of file - tn.write("SIZE " + str(filesize) + "\n") - tn.flush() - - ln = tn.readline() - if not ln.startswith("+"): - FreeCAD.Console.PrintError("Failed: {}\n".format(ln)) - sys.exit() - - if VERBOSE: - print("RSP: " + ln.strip()) - - cnt = 0 - # now send file - for line in f.splitlines(1): - tn.write(line) - if VERBOSE: - cnt += len(line) - print("SND: " + line.strip()) - print(str(cnt) + "/" + str(filesize) + "\r", end="") - - tn.flush() - - ln = tn.readline() - if not ln.startswith("+"): - FreeCAD.Console.PrintError("Failed to save file: {}\n".format(ln)) - sys.exit() - - if VERBOSE: - print("RSP: " + ln.strip()) - - # exit - tn.write("DONE\n") - tn.flush() - tn.close() - - FreeCAD.Console.PrintMessage("Upload complete\n") - - -def linenumber(): - global LINENR - if OUTPUT_LINE_NUMBERS is True: - LINENR += 10 - return "N" + str(LINENR) + " " - return "" - - -def parse(pathobj): - global SPINDLE_SPEED - - out = "" - lastcommand = None - precision_string = "." + str(PRECISION) + "f" - - # params = ['X','Y','Z','A','B','I','J','K','F','S'] #This list control - # the order of parameters - # linuxcnc doesn't want K properties on XY plane Arcs need work. - params = ["X", "Y", "Z", "A", "B", "I", "J", "F", "S", "T", "Q", "R", "L"] - - if hasattr(pathobj, "Group"): # We have a compound or project. - # if OUTPUT_COMMENTS: - # out += linenumber() + "(compound: " + pathobj.Label + ")\n" - for p in pathobj.Group: - out += parse(p) - return out - else: # parsing simple path - - # groups might contain non-path things like stock. - if not hasattr(pathobj, "Path"): - return out - - # if OUTPUT_COMMENTS: - # out += linenumber() + "(" + pathobj.Label + ")\n" - - for c in PathUtils.getPathWithPlacement(pathobj).Commands: - outstring = [] - command = c.Name - outstring.append(command) - # if modal: only print the command if it is not the same as the - # last one - if MODAL is True: - if command == lastcommand: - outstring.pop(0) - - # Now add the remaining parameters in order - for param in params: - if param in c.Parameters: - if param == "F": - if c.Name not in [ - "G0", - "G00", - ]: # linuxcnc doesn't use rapid speeds - speed = Units.Quantity(c.Parameters["F"], FreeCAD.Units.Velocity) - outstring.append( - param - + format( - float(speed.getValueAs(UNIT_SPEED_FORMAT)), - precision_string, - ) - ) - elif param == "T": - outstring.append(param + str(c.Parameters["T"])) - elif param == "S": - outstring.append(param + str(c.Parameters["S"])) - SPINDLE_SPEED = c.Parameters["S"] - else: - pos = Units.Quantity(c.Parameters[param], FreeCAD.Units.Length) - outstring.append( - param + format(float(pos.getValueAs(UNIT_FORMAT)), precision_string) - ) - if command in ["G1", "G01", "G2", "G02", "G3", "G03"]: - outstring.append("S" + str(SPINDLE_SPEED)) - - # store the latest command - lastcommand = command - - # Check for Tool Change: - if command == "M6": - # if OUTPUT_COMMENTS: - # out += linenumber() + "(begin toolchange)\n" - for line in TOOL_CHANGE.splitlines(True): - out += linenumber() + line - - if command == "message": - if OUTPUT_COMMENTS is False: - out = [] - else: - outstring.pop(0) # remove the command - - # prepend a line number and append a newline - if len(outstring) >= 1: - if OUTPUT_LINE_NUMBERS: - outstring.insert(0, (linenumber())) - - # append the line to the final output - for w in outstring: - out += w + COMMAND_SPACE - out = out.strip() + "\n" - - return out - - -# print(__name__ + " gcode postprocessor loaded.") + This postprocessor supports direct network upload to SmoothieBoard + via the --ip-addr argument. + """ + return tooltip diff --git a/src/Mod/CAM/Path/Post/scripts/refactored_test_post.py b/src/Mod/CAM/Path/Post/scripts/test_post.py similarity index 95% rename from src/Mod/CAM/Path/Post/scripts/refactored_test_post.py rename to src/Mod/CAM/Path/Post/scripts/test_post.py index 69ebe34d81..621c76a346 100644 --- a/src/Mod/CAM/Path/Post/scripts/refactored_test_post.py +++ b/src/Mod/CAM/Path/Post/scripts/test_post.py @@ -48,13 +48,13 @@ Values = Dict[str, Any] Visible = Dict[str, bool] -class Refactored_Test(PostProcessor): - """The Refactored Test post processor class.""" +class Test(PostProcessor): + """The Test post processor class.""" def __init__( self, job, - tooltip=translate("CAM", "Refactored Test post processor"), + tooltip=translate("CAM", "Test post processor"), tooltipargs=[""], units="Metric", ) -> None: @@ -64,7 +64,7 @@ class Refactored_Test(PostProcessor): tooltipargs=tooltipargs, units=units, ) - Path.Log.debug("Refactored Test post processor initialized") + Path.Log.debug("Test post processor initialized") def init_values(self, values: Values) -> None: """Initialize values that are used throughout the postprocessor.""" diff --git a/src/Mod/CAM/TestCAMApp.py b/src/Mod/CAM/TestCAMApp.py index 9fb88a13c7..80a0c6f744 100644 --- a/src/Mod/CAM/TestCAMApp.py +++ b/src/Mod/CAM/TestCAMApp.py @@ -97,17 +97,20 @@ from CAMTests.TestPathUtil import TestPathUtil from CAMTests.TestPathVcarve import TestPathVcarve from CAMTests.TestPathVoronoi import TestPathVoronoi -from CAMTests.TestCentroidPost import TestCentroidPost -from CAMTests.TestGrblPost import TestGrblPost +from CAMTests.TestGenericPost import TestGenericPost from CAMTests.TestLinuxCNCPost import TestLinuxCNCPost +from CAMTests.TestGrblPost import TestGrblPost +from CAMTests.TestMassoG3Post import TestMassoG3Post +from CAMTests.TestCentroidPost import TestCentroidPost from CAMTests.TestMach3Mach4Post import TestMach3Mach4Post -from CAMTests.TestRefactoredCentroidPost import TestRefactoredCentroidPost -from CAMTests.TestRefactoredGrblPost import TestRefactoredGrblPost -from CAMTests.TestRefactoredLinuxCNCPost import TestRefactoredLinuxCNCPost -from CAMTests.TestRefactoredMassoG3Post import TestRefactoredMassoG3Post -from CAMTests.TestRefactoredMach3Mach4Post import TestRefactoredMach3Mach4Post -from CAMTests.TestRefactoredTestDressupPost import TestRefactoredTestDressupPost -from CAMTests.TestRefactoredTestPost import TestRefactoredTestPost -from CAMTests.TestRefactoredTestPostGCodes import TestRefactoredTestPostGCodes -from CAMTests.TestRefactoredTestPostMCodes import TestRefactoredTestPostMCodes +from CAMTests.TestTestPost import TestTestPost +from CAMTests.TestPostGCodes import TestPostGCodes +from CAMTests.TestPostMCodes import TestPostMCodes +from CAMTests.TestDressupPost import TestDressupPost + +from CAMTests.TestLinuxCNCLegacyPost import TestLinuxCNCLegacyPost +from CAMTests.TestGrblLegacyPost import TestGrblLegacyPost +from CAMTests.TestCentroidLegacyPost import TestCentroidLegacyPost +from CAMTests.TestMach3Mach4LegacyPost import TestMach3Mach4LegacyPost + from CAMTests.TestSnapmakerPost import TestSnapmakerPost