diff --git a/src/Mod/Path/CMakeLists.txt b/src/Mod/Path/CMakeLists.txt index 96156db6d1..7ba19b451e 100644 --- a/src/Mod/Path/CMakeLists.txt +++ b/src/Mod/Path/CMakeLists.txt @@ -148,6 +148,7 @@ SET(PathScripts_SRCS SET(Generator_SRCS Generators/drill_generator.py Generators/rotation_generator.py + Generators/helix_generator.py ) SET(PathScripts_post_SRCS @@ -231,6 +232,7 @@ SET(PathTests_SRCS PathTests/TestPathGeom.py PathTests/TestPathHelix.py PathTests/TestPathHelpers.py + PathTests/TestPathHelixGenerator.py PathTests/TestPathLog.py PathTests/TestPathOpTools.py PathTests/TestPathPost.py diff --git a/src/Mod/Path/Generators/drill_generator.py b/src/Mod/Path/Generators/drill_generator.py index 84adf233a6..550b678a67 100644 --- a/src/Mod/Path/Generators/drill_generator.py +++ b/src/Mod/Path/Generators/drill_generator.py @@ -67,6 +67,9 @@ def generate(edge, dwelltime=0.0, peckdepth=0.0, repeat=1): ): raise ValueError("edge is not aligned with Z axis") + if startPoint.z < endPoint.z: + raise ValueError("start point is below end point") + cmdParams = {} cmdParams["X"] = startPoint.x cmdParams["Y"] = startPoint.y diff --git a/src/Mod/Path/Generators/helix_generator.py b/src/Mod/Path/Generators/helix_generator.py new file mode 100644 index 0000000000..2d4f2cf29e --- /dev/null +++ b/src/Mod/Path/Generators/helix_generator.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2021 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 * +# * * +# *************************************************************************** + + +from numpy import ceil, linspace, isclose +import Path +import PathScripts.PathLog as PathLog + +__title__ = "Helix Path Generator" +__author__ = "sliptonic (Brad Collette)" +__url__ = "https://www.freecadweb.org" +__doc__ = "Generates the helix for a single spot targetshape" +__contributors__ = "russ4262 (Russell Johnson), Lorenz Hüdepohl" + + +if False: + PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule()) + PathLog.trackModule(PathLog.thisModule()) +else: + PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) + + +def generate( + edge, + hole_radius, + step_down, + step_over, + tool_diameter, + inner_radius=0.0, + direction="CW", + startAt="Inside", +): + """generate(edge, hole_radius, inner_radius, step_over) ... generate helix commands. + hole_radius, inner_radius: outer and inner radius of the hole + step_over: step over radius value""" + + startPoint = edge.Vertexes[0].Point + endPoint = edge.Vertexes[1].Point + + PathLog.track( + "(helix: <{}, {}>\n hole radius {}\n inner radius {}\n step over {}\n start point {}\n end point {}\n step_down {}\n tool diameter {}\n direction {}\n startAt {})".format( + startPoint.x, + startPoint.y, + hole_radius, + inner_radius, + step_over, + startPoint.z, + endPoint.z, + step_down, + tool_diameter, + direction, + startAt, + ) + ) + + if type(hole_radius) not in [float, int]: + raise ValueError("hole_radius must be a float") + + if hole_radius < 0.0: + raise ValueError("hole_radius < 0") + + if type(inner_radius) not in [float, int]: + raise ValueError("inner_radius must be a float") + + if type(tool_diameter) not in [float, int]: + raise ValueError("tool_diameter must be a float") + + if inner_radius > 0 and hole_radius - inner_radius < tool_diameter: + raise ValueError( + "hole_radius - inner_radius = {0} is < tool diameter of {1}".format( + hole_radius - inner_radius, tool_diameter + ) + ) + + if not hole_radius * 2 > tool_diameter: + raise ValueError( + "Cannot helix a hole of diameter {0} with a tool of diameter {1}".format( + 2 * hole_radius, tool_diameter + ) + ) + + elif startAt not in ["Inside", "Outside"]: + raise ValueError("Invalid value for parameter 'startAt'") + + elif direction not in ["CW", "CCW"]: + raise ValueError("Invalid value for parameter 'direction'") + + if not ( + isclose(startPoint.sub(endPoint).x, 0, rtol=1e-05, atol=1e-06) + and (isclose(startPoint.sub(endPoint).y, 0, rtol=1e-05, atol=1e-06)) + ): + raise ValueError("edge is not aligned with Z axis") + + if startPoint.z < endPoint.z: + raise ValueError("start point is below end point") + + if inner_radius > 0: + PathLog.debug("(annulus mode)\n") + outer_radius = hole_radius - tool_diameter / 2 + step_radius = inner_radius + tool_diameter / 2 + if abs((outer_radius - step_radius) / step_over) < 1e-5: + radii = [(outer_radius + step_radius) / 2] + else: + nr = max(int(ceil((outer_radius - step_radius) / step_over)), 2) + radii = linspace(outer_radius, step_radius, nr) + + elif hole_radius <= 2 * step_over: + PathLog.debug("(single helix mode)\n") + radii = [hole_radius - tool_diameter / 2] + if radii[0] <= 0: + raise ValueError( + "Cannot helix a hole of diameter {0} with a tool of diameter {1}".format( + 2 * hole_radius, tool_diameter + ) + ) + outer_radius = hole_radius + else: + PathLog.debug("(full hole mode)\n") + outer_radius = hole_radius - tool_diameter / 2 + step_radius = step_over / 2 + + nr = max(1 + int(ceil((outer_radius - step_radius) / step_over)), 2) + radii = [r for r in linspace(outer_radius, step_radius, nr) if r > 0] + if not radii: + raise ValueError( + "Cannot helix a hole of diameter {0} with a tool of diameter {1}".format( + 2 * hole_radius, tool_diameter + ) + ) + # calculate the number of full and partial turns required + # Each full turn is two 180 degree arcs. Zsteps is equally spaced step + # down values + turncount = max(int(ceil((startPoint.z - endPoint.z) / step_down)), 2) + zsteps = linspace(startPoint.z, endPoint.z, 2 * turncount + 1) + + def helix_cut_r(r): + commandlist = [] + arc_cmd = "G2" if direction == "CW" else "G3" + commandlist.append( + Path.Command("G0", {"X": startPoint.x + r, "Y": startPoint.y}) + ) + commandlist.append(Path.Command("G1", {"Z": startPoint.z})) + for i in range(1, turncount + 1): + commandlist.append( + Path.Command( + arc_cmd, + { + "X": startPoint.x - r, + "Y": startPoint.y, + "Z": zsteps[2 * i - 1], + "I": -r, + "J": 0.0, + }, + ) + ) + commandlist.append( + Path.Command( + arc_cmd, + { + "X": startPoint.x + r, + "Y": startPoint.y, + "Z": zsteps[2 * i], + "I": r, + "J": 0.0, + }, + ) + ) + commandlist.append( + Path.Command( + arc_cmd, + { + "X": startPoint.x - r, + "Y": startPoint.y, + "Z": endPoint.z, + "I": -r, + "J": 0.0, + }, + ) + ) + commandlist.append( + Path.Command( + arc_cmd, + { + "X": startPoint.x + r, + "Y": startPoint.y, + "Z": endPoint.z, + "I": r, + "J": 0.0, + }, + ) + ) + return commandlist + + def retract(): + # try to move to a safe place to retract without leaving a dwell + # mark + retractcommands = [] + # Calculate retraction + if hole_radius <= tool_diameter: # simple case where center is clear + center_clear = True + + elif startAt == "Inside" and inner_radius == 0.0: # middle is clear + center_clear = True + else: + center_clear = False + + if center_clear: + retractcommands.append( + Path.Command("G0", {"X": endPoint.x, "Y": endPoint.y, "Z": endPoint.z}) + ) + + # Technical Debt. + # If the operation is clearing multiple passes in annulus mode (inner + # radius > 0.0 and len(radii) > 1) then there is a derivable + # safe place which does not touch the inner or outer wall on all radii except + # the first. This is left as a future improvement. + + retractcommands.append(Path.Command("G0", {"Z": startPoint.z})) + + return retractcommands + + if startAt == "Inside": + radii = radii[::-1] + + commands = [] + for r in radii: + commands.extend(helix_cut_r(r)) + commands.extend(retract()) + + return commands diff --git a/src/Mod/Path/PathTests/TestPathDrillGenerator.py b/src/Mod/Path/PathTests/TestPathDrillGenerator.py index 8414c5d2e6..78837b1965 100644 --- a/src/Mod/Path/PathTests/TestPathDrillGenerator.py +++ b/src/Mod/Path/PathTests/TestPathDrillGenerator.py @@ -64,7 +64,11 @@ class TestPathDrillGenerator(PathTestUtils.PathTestBase): """Test edge alignment check""" v1 = FreeCAD.Vector(0, 10, 10) v2 = FreeCAD.Vector(0, 0, 0) + e = Part.makeLine(v1, v2) + self.assertRaises(ValueError, generator.generate, e) + v1 = FreeCAD.Vector(0, 0, 0) + v2 = FreeCAD.Vector(0, 0, 10) e = Part.makeLine(v1, v2) self.assertRaises(ValueError, generator.generate, e) diff --git a/src/Mod/Path/PathTests/TestPathHelixGenerator.py b/src/Mod/Path/PathTests/TestPathHelixGenerator.py new file mode 100644 index 0000000000..ea2b477823 --- /dev/null +++ b/src/Mod/Path/PathTests/TestPathHelixGenerator.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2021 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 Part +import Path +import PathScripts.PathLog as PathLog +import Generators.helix_generator as generator +import PathTests.PathTestUtils as PathTestUtils + + +PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) +PathLog.trackModule(PathLog.thisModule()) + + +def _resetArgs(): + v1 = FreeCAD.Vector(5, 5, 20) + v2 = FreeCAD.Vector(5, 5, 18) + + edg = Part.makeLine(v1, v2) + + return { + "edge": edg, + "hole_radius": 10.0, + "step_down": 1.0, + "step_over": 5.0, + "tool_diameter": 5.0, + "inner_radius": 0.0, + "direction": "CW", + "startAt": "Inside", + } + + +class TestPathHelixGenerator(PathTestUtils.PathTestBase): + expectedHelixGCode = "G0 X12.500000 Y5.000000\ +G1 Z20.000000\ +G2 I-7.500000 J0.000000 X-2.500000 Y5.000000 Z19.500000\ +G2 I7.500000 J0.000000 X12.500000 Y5.000000 Z19.000000\ +G2 I-7.500000 J0.000000 X-2.500000 Y5.000000 Z18.500000\ +G2 I7.500000 J0.000000 X12.500000 Y5.000000 Z18.000000\ +G2 I-7.500000 J0.000000 X-2.500000 Y5.000000 Z18.000000\ +G2 I7.500000 J0.000000 X12.500000 Y5.000000 Z18.000000\ +G0 X5.000000 Y5.000000 Z18.000000\ +G0 Z20.000000" + + + def test00(self): + """Test Basic Helix Generator Return""" + args = _resetArgs() + result = generator.generate(**args) + self.assertTrue(type(result) is list) + self.assertTrue(type(result[0]) is Path.Command) + + gcode = "".join([r.toGCode() for r in result]) + print(gcode) + self.assertTrue( + gcode == self.expectedHelixGCode, "Incorrect helix g-code generated" + ) + + def test01(self): + """Test Basic Helix Generator hole_radius is float > 0""" + args = _resetArgs() + args["hole_radius"] = '10' + self.assertRaises(ValueError, generator.generate, **args) + + args["hole_radius"] = -10.0 + self.assertRaises(ValueError, generator.generate, **args) + + def test02(self): + """Test Basic Helix Generator inner_radius is float""" + args = _resetArgs() + args["inner_radius"] = '2' + self.assertRaises(ValueError, generator.generate, **args) + + def test03(self): + """Test Basic Helix Generator tool_diameter is float""" + args = _resetArgs() + args["tool_diameter"] = '5' + self.assertRaises(ValueError, generator.generate, **args) + + def test04(self): + """Test Basic Helix Generator tool fit with radius difference less than tool diameter""" + args = _resetArgs() + # require tool fit 1: radius diff less than tool diam + args["hole_radius"] = 10.0 + args["inner_radius"] = 6.0 + args["tool_diameter"] = 5.0 + self.assertRaises(ValueError, generator.generate, **args) + + # require tool fit 2: hole diameter not greater than tool diam with zero inner radius + args["hole_radius"] = 2.0 + args["inner_radius"] = 0.0 + args["tool_diameter"] = 5.0 + self.assertRaises(ValueError, generator.generate, **args) + + def test05(self): + """Test Basic Helix Generator validate the startAt enumeration value""" + args = _resetArgs() + args["startAt"] = "Other" + self.assertRaises(ValueError, generator.generate, **args) + + def test06(self): + """Test Basic Helix Generator validate the direction enumeration value""" + args = _resetArgs() + args["direction"] = "clock" + self.assertRaises(ValueError, generator.generate, **args) + + def test07(self): + """Test Basic Helix Generator verify linear edge is vertical""" + # verify linear edge is vertical: X + args = _resetArgs() + v1 = FreeCAD.Vector(5, 5, 20) + v2 = FreeCAD.Vector(5.0001, 5, 10) + edg = Part.makeLine(v1, v2) + args["edge"] = edg + self.assertRaises(ValueError, generator.generate, **args) + + # verify linear edge is vertical: Y + args = _resetArgs() + v1 = FreeCAD.Vector(5, 5.0001, 20) + v2 = FreeCAD.Vector(5, 5, 10) + edg = Part.makeLine(v1, v2) + args["edge"] = edg + self.assertRaises(ValueError, generator.generate, **args) + + def test08(self): + """Test Helix Generator with horizontal edge""" + args = _resetArgs() + v1 = FreeCAD.Vector(10, 5, 5) + v2 = FreeCAD.Vector(20, 5, 5) + edg = Part.makeLine(v1, v2) + args["edge"] = edg + self.assertRaises(ValueError, generator.generate, **args) + + def test09(self): + """Test Helix Generator with inverted vertical edge""" + args = _resetArgs() + v1 = FreeCAD.Vector(5, 5, 18) + v2 = FreeCAD.Vector(5, 5, 20) + edg = Part.makeLine(v1, v2) + args["edge"] = edg + + self.assertRaises(ValueError, generator.generate, **args) + + def test10(self): + """Test Helix Retraction""" + + # if center is clear, the second to last move should be a rapid away + # from the wall + args = _resetArgs() + v1 = FreeCAD.Vector(0, 0, 20) + v2 = FreeCAD.Vector(0, 0, 18) + edg = Part.makeLine(v1, v2) + args["edge"] = edg + args["inner_radius"] = 0.0 + args["tool_diameter"] = 5.0 + result = generator.generate(**args) + self.assertTrue(result[-2].Name == "G0") + + # if center is not clear, retraction is one straight up on the last + # move. the second to last move should be a G2 + args["inner_radius"] = 2.0 + result = generator.generate(**args) + self.assertTrue(result[-2].Name == "G2") diff --git a/src/Mod/Path/TestPathApp.py b/src/Mod/Path/TestPathApp.py index b8f7dda9a0..797f7fc410 100644 --- a/src/Mod/Path/TestPathApp.py +++ b/src/Mod/Path/TestPathApp.py @@ -33,6 +33,7 @@ from PathTests.TestPathDrillGenerator import TestPathDrillGenerator from PathTests.TestPathGeom import TestPathGeom # from PathTests.TestPathHelix import TestPathHelix from PathTests.TestPathHelpers import TestPathHelpers +from PathTests.TestPathHelixGenerator import TestPathHelixGenerator from PathTests.TestPathLog import TestPathLog from PathTests.TestPathOpTools import TestPathOpTools # from PathTests.TestPathPost import PathPostTestCases @@ -51,18 +52,17 @@ from PathTests.TestPathVcarve import TestPathVcarve from PathTests.TestPathVoronoi import TestPathVoronoi # dummy usage to get flake8 and lgtm quiet +False if depthTestCases.__name__ else True False if TestApp.__name__ else True +False if TestDressupDogbone.__name__ else True +False if TestHoldingTags.__name__ else True False if TestPathAdaptive.__name__ else True False if TestPathCore.__name__ else True False if TestPathDeburr.__name__ else True -False if depthTestCases.__name__ else True -False if TestDressupDogbone.__name__ else True -False if TestHoldingTags.__name__ else True -# False if TestPathHelix.__name__ else True False if TestPathDrillable.__name__ else True -False if TestPathDrillGenerator.__name__ else True False if TestPathGeom.__name__ else True False if TestPathHelpers.__name__ else True +# False if TestPathHelix.__name__ else True False if TestPathLog.__name__ else True False if TestPathOpTools.__name__ else True # False if TestPathPost.__name__ else True @@ -79,4 +79,5 @@ False if TestPathTooltable.__name__ else True False if TestPathUtil.__name__ else True False if TestPathVcarve.__name__ else True False if TestPathVoronoi.__name__ else True - +False if TestPathDrillGenerator.__name__ else True +False if TestPathHelixGenerator.__name__ else True