diff --git a/src/Mod/Path/CMakeLists.txt b/src/Mod/Path/CMakeLists.txt index c82bb15bcc..96156db6d1 100644 --- a/src/Mod/Path/CMakeLists.txt +++ b/src/Mod/Path/CMakeLists.txt @@ -10,6 +10,8 @@ set(Path_Scripts Init.py PathCommands.py TestPathApp.py + PathMachineState.py + PathFeedRate.py ) if(BUILD_GUI) @@ -25,6 +27,7 @@ INSTALL( SET(PathScripts_SRCS PathCommands.py + PathScripts/drillableLib.py PathScripts/PathAdaptive.py PathScripts/PathAdaptiveGui.py PathScripts/PathAreaOp.py @@ -144,6 +147,7 @@ SET(PathScripts_SRCS SET(Generator_SRCS Generators/drill_generator.py + Generators/rotation_generator.py ) SET(PathScripts_post_SRCS @@ -208,6 +212,7 @@ SET(Tools_Shape_SRCS SET(PathTests_SRCS PathTests/__init__.py PathTests/boxtest.fcstd + PathTests/Drilling_1.FCStd PathTests/PathTestUtils.py PathTests/test_adaptive.fcstd PathTests/test_centroid_00.ngc @@ -221,8 +226,11 @@ SET(PathTests_SRCS PathTests/TestPathDressupDogbone.py PathTests/TestPathDressupHoldingTags.py PathTests/TestPathDrillGenerator.py + PathTests/TestPathDrillable.py + PathTests/TestPathRotationGenerator.py PathTests/TestPathGeom.py PathTests/TestPathHelix.py + PathTests/TestPathHelpers.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 74d8238305..84adf233a6 100644 --- a/src/Mod/Path/Generators/drill_generator.py +++ b/src/Mod/Path/Generators/drill_generator.py @@ -39,8 +39,6 @@ else: def generate(edge, dwelltime=0.0, peckdepth=0.0, repeat=1): - - startPoint = edge.Vertexes[0].Point endPoint = edge.Vertexes[1].Point diff --git a/src/Mod/Path/PathFeedRate.py b/src/Mod/Path/PathFeedRate.py new file mode 100644 index 0000000000..06962d60e1 --- /dev/null +++ b/src/Mod/Path/PathFeedRate.py @@ -0,0 +1,99 @@ +# -*- 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 PathScripts.PathLog as PathLog +import PathMachineState +import PathScripts.PathGeom as PathGeom +import Part +from PathScripts.PathGeom import CmdMoveRapid, CmdMoveAll + +__title__ = "Feed Rate Helper Utility" +__author__ = "sliptonic (Brad Collette)" +__url__ = "https://www.freecadweb.org" +__doc__ = "Helper for adding Feed Rate to Path Commands" + +""" +TODO: This needs to be able to handle feedrates for axes other than X,Y,Z +""" + +if True: + PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule()) + PathLog.trackModule(PathLog.thisModule()) +else: + PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) + + +def setFeedRate(commandlist, ToolController): + """Set the appropriate feed rate for a list of Path commands using the information from a Tool Controler + + Every motion command in the list will have a feed rate parameter added or overwritten based + on the information stored in the tool controller. If a motion is a plunge (vertical) motion, the + VertFeed value will be used, otherwise the HorizFeed value will be used instead.""" + + def _isVertical(currentposition, command): + x = ( + command.Parameters["X"] + if "X" in command.Parameters.keys() + else currentposition.x + ) + y = ( + command.Parameters["Y"] + if "Y" in command.Parameters.keys() + else currentposition.y + ) + z = ( + command.Parameters["Z"] + if "Z" in command.Parameters.keys() + else currentposition.z + ) + endpoint = FreeCAD.Vector(x, y, z) + if currentposition == endpoint: + return True + return PathGeom.isVertical(Part.makeLine(currentposition, endpoint)) + + machine = PathMachineState.MachineState() + + for command in commandlist: + if command.Name not in CmdMoveAll: + continue + + if _isVertical(machine.getPosition(), command): + rate = ( + ToolController.VertRapid.Value + if command.Name in CmdMoveRapid + else ToolController.VertFeed.Value + ) + else: + rate = ( + ToolController.HorizRapid.Value + if command.Name in CmdMoveRapid + else ToolController.HorizFeed.Value + ) + + params = command.Parameters + params["F"] = rate + command.Parameters = params + + machine.addCommand(command) + + return commandlist diff --git a/src/Mod/Path/PathMachineState.py b/src/Mod/Path/PathMachineState.py new file mode 100644 index 0000000000..2e64dc4b97 --- /dev/null +++ b/src/Mod/Path/PathMachineState.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2021 sliptonic shopinthewoods@gmail.com * +# * * +# * 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 * +# * * +# *************************************************************************** + +__title__ = "Path Machine State" +__author__ = "sliptonic (Brad Collette)" +__url__ = "https://www.freecadweb.org" +__doc__ = "Dataclass to implement a machinestate tracker" +__contributors__ = "" + +import PathScripts.PathLog as PathLog +import FreeCAD +from dataclasses import dataclass, field +from PathScripts.PathGeom import CmdMoveRapid, CmdMoveAll, CmdMoveDrill + +if True: + PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule()) + PathLog.trackModule(PathLog.thisModule()) +else: + PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) + + +@dataclass +class MachineState: + WCSLIST = [ + "G53", + "G54", + "G55", + "G56", + "G57", + "G58", + "G59", + "G59.1", + "G59.2", + "G59.3", + "G59.4", + "G59.5", + "G59.6", + "G59.7", + "G59.8", + "G59.9", + ] + + X: float = field(default=0) + Y: float = field(default=0) + Z: float = field(default=0) + A: float = field(default=0) + B: float = field(default=0) + C: float = field(default=0) + F: float = field(default=None) + Coolant: bool = field(default=False) + WCS: str = field(default="G54") + Spindle: str = field(default="off") + S: int = field(default=0) + T: int = field(default=None) + + def addCommand(self, command): + """Processes a command and updates the internal state of the machine. Returns true if the command has alterned the machine state""" + oldstate = self.getState() + if command.Name == "M6": + self.T = int(command.Parameters["T"]) + return not oldstate == self.getState() + + if command.Name in ["M3", "M4"]: + self.S = command.Parameters["S"] + self.Spindle = "CW" if command.Name == "M3" else "CCW" + return not oldstate == self.getState() + + if command.Name in ["M2", "M5"]: + self.S = 0 + self.Spindle = "off" + return not oldstate == self.getState() + + if command.Name in self.WCSLIST: + self.WCS = command.Name + return not oldstate == self.getState() + + if command.Name in CmdMoveDrill: + oldZ = self.Z + for p in command.Parameters: + self.__setattr__(p, command.Parameters[p]) + self.__setattr__("Z", oldZ) + return not oldstate == self.getState() + + for p in command.Parameters: + self.__setattr__(p, command.Parameters[p]) + + return not oldstate == self.getState() + + def getState(self): + """ + Returns a dictionary of the current machine state + """ + state = {} + state['X'] = self.X + state['Y'] = self.Y + state['Z'] = self.Z + state['A'] = self.A + state['B'] = self.B + state['C'] = self.C + state['F'] = self.F + state['Coolant'] = self.Coolant + state['WCS'] = self.WCS + state['Spindle'] = self.Spindle + state['S'] = self.S + state['T'] = self.T + + return state + + def getPosition(self): + """ + Returns a vector of the current machine position + """ + + # This is technical debt. The actual position may include a rotation + # component as well. We should probably be returning a placement + return FreeCAD.Vector(self.X, self.Y, self.Z) diff --git a/src/Mod/Path/PathScripts/PathAreaOp.py b/src/Mod/Path/PathScripts/PathAreaOp.py index 483560e865..026a8e487f 100644 --- a/src/Mod/Path/PathScripts/PathAreaOp.py +++ b/src/Mod/Path/PathScripts/PathAreaOp.py @@ -327,21 +327,21 @@ class ObjectOp(PathOp.ObjectOp): shapes.append(shp) if len(shapes) > 1: - jobs = list() + locations = [] for s in shapes: if s[2] == 'OpenEdge': shp = Part.makeCompound(s[0]) else: shp = s[0] - jobs.append({ + locations.append({ 'x': shp.BoundBox.XMax, 'y': shp.BoundBox.YMax, 'shape': s }) - jobs = PathUtils.sort_jobs(jobs, ['x', 'y']) + locations = PathUtils.sort_locations(locations, ['x', 'y']) - shapes = [j['shape'] for j in jobs] + shapes = [j['shape'] for j in locations] sims = [] for shape, isHole, sub in shapes: diff --git a/src/Mod/Path/PathScripts/PathCircularHoleBase.py b/src/Mod/Path/PathScripts/PathCircularHoleBase.py index 9e5253e474..30c1c78d4d 100644 --- a/src/Mod/Path/PathScripts/PathCircularHoleBase.py +++ b/src/Mod/Path/PathScripts/PathCircularHoleBase.py @@ -23,7 +23,9 @@ import FreeCAD import PathScripts.PathLog as PathLog import PathScripts.PathOp as PathOp -import PathScripts.PathUtils as PathUtils + +# import PathScripts.PathUtils as PathUtils +import PathScripts.drillableLib as drillableLib from PySide import QtCore @@ -46,9 +48,11 @@ def translate(context, text, disambig=None): return QtCore.QCoreApplication.translate(context, text, disambig) -PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) -# PathLog.trackModule(PathLog.thisModule()) - +if False: + PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule()) + PathLog.trackModule(PathLog.thisModule()) +else: + PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) class ObjectOp(PathOp.ObjectOp): """Base class for proxy objects of all operations on circular holes.""" @@ -172,7 +176,6 @@ class ObjectOp(PathOp.ObjectOp): return False holes = [] - for base, subs in obj.Base: for sub in subs: PathLog.debug("processing {} in {}".format(sub, base.Name)) @@ -204,85 +207,19 @@ class ObjectOp(PathOp.ObjectOp): def findAllHoles(self, obj): """findAllHoles(obj) ... find all holes of all base models and assign as features.""" PathLog.track() - if not self.getJob(obj): + job = self.getJob(obj) + if not job: return + + matchvector = None if job.JobType == "Multiaxis" else FreeCAD.Vector(0, 0, 1) + tooldiameter = obj.ToolController.Tool.Diameter + features = [] for base in self.model: - features.extend(self.findHoles(obj, base)) + features.extend( + drillableLib.getDrillableTargets( + base, ToolDiameter=tooldiameter, vector=matchvector + ) + ) obj.Base = features obj.Disabled = [] - - def findHoles(self, obj, baseobject): - """findHoles(obj, baseobject) ... inspect baseobject and identify all features that resemble a straight cricular hole.""" - shape = baseobject.Shape - PathLog.track("obj: {} shape: {}".format(obj, shape)) - holelist = [] - features = [] - # tooldiameter = float(obj.ToolController.Proxy.getTool(obj.ToolController).Diameter) - tooldiameter = None - PathLog.debug( - "search for holes larger than tooldiameter: {}: ".format(tooldiameter) - ) - if DraftGeomUtils.isPlanar(shape): - PathLog.debug("shape is planar") - for i in range(len(shape.Edges)): - candidateEdgeName = "Edge" + str(i + 1) - e = shape.getElement(candidateEdgeName) - if PathUtils.isDrillable(shape, e, tooldiameter): - PathLog.debug( - "edge candidate: {} (hash {})is drillable ".format( - e, e.hashCode() - ) - ) - x = e.Curve.Center.x - y = e.Curve.Center.y - diameter = e.BoundBox.XLength - holelist.append( - { - "featureName": candidateEdgeName, - "feature": e, - "x": x, - "y": y, - "d": diameter, - "enabled": True, - } - ) - features.append((baseobject, candidateEdgeName)) - PathLog.debug( - "Found hole feature %s.%s" - % (baseobject.Label, candidateEdgeName) - ) - else: - PathLog.debug("shape is not planar") - for i in range(len(shape.Faces)): - candidateFaceName = "Face" + str(i + 1) - f = shape.getElement(candidateFaceName) - if PathUtils.isDrillable(shape, f, tooldiameter): - PathLog.debug("face candidate: {} is drillable ".format(f)) - if hasattr(f.Surface, "Center"): - x = f.Surface.Center.x - y = f.Surface.Center.y - diameter = f.BoundBox.XLength - else: - center = f.Edges[0].Curve.Center - x = center.x - y = center.y - diameter = f.Edges[0].Curve.Radius * 2 - holelist.append( - { - "featureName": candidateFaceName, - "feature": f, - "x": x, - "y": y, - "d": diameter, - "enabled": True, - } - ) - features.append((baseobject, candidateFaceName)) - PathLog.debug( - "Found hole feature %s.%s" - % (baseobject.Label, candidateFaceName) - ) - - PathLog.debug("holes found: {}".format(holelist)) - return features diff --git a/src/Mod/Path/PathScripts/PathDrilling.py b/src/Mod/Path/PathScripts/PathDrilling.py index fbcbce910a..4f845a145b 100644 --- a/src/Mod/Path/PathScripts/PathDrilling.py +++ b/src/Mod/Path/PathScripts/PathDrilling.py @@ -23,15 +23,19 @@ from __future__ import print_function + +from Generators import drill_generator as generator +from PySide import QtCore import FreeCAD +import Part import Path +import PathFeedRate +import PathMachineState import PathScripts.PathCircularHoleBase as PathCircularHoleBase import PathScripts.PathLog as PathLog import PathScripts.PathOp as PathOp import PathScripts.PathUtils as PathUtils -from PySide import QtCore - __title__ = "Path Drilling Operation" __author__ = "sliptonic (Brad Collette)" __url__ = "https://www.freecadweb.org" @@ -39,9 +43,11 @@ __doc__ = "Path Drilling operation." __contributors__ = "IMBack!" -PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) -# PathLog.trackModule(PathLog.thisModule()) - +if False: + PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule()) + PathLog.trackModule(PathLog.thisModule()) +else: + PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) # Qt translation handling def translate(context, text, disambig=None): @@ -49,104 +55,199 @@ def translate(context, text, disambig=None): class ObjectDrilling(PathCircularHoleBase.ObjectOp): - '''Proxy object for Drilling operation.''' + """Proxy object for Drilling operation.""" def circularHoleFeatures(self, obj): - '''circularHoleFeatures(obj) ... drilling works on anything, turn on all Base geometries and Locations.''' - return PathOp.FeatureBaseGeometry | PathOp.FeatureLocations | PathOp.FeatureCoolant + """circularHoleFeatures(obj) ... drilling works on anything, turn on all Base geometries and Locations.""" + return ( + PathOp.FeatureBaseGeometry | PathOp.FeatureLocations | PathOp.FeatureCoolant + ) def initCircularHoleOperation(self, obj): - '''initCircularHoleOperation(obj) ... add drilling specific properties to obj.''' - obj.addProperty("App::PropertyLength", "PeckDepth", "Drill", QtCore.QT_TRANSLATE_NOOP("App::Property", "Incremental Drill depth before retracting to clear chips")) - obj.addProperty("App::PropertyBool", "PeckEnabled", "Drill", QtCore.QT_TRANSLATE_NOOP("App::Property", "Enable pecking")) - obj.addProperty("App::PropertyFloat", "DwellTime", "Drill", QtCore.QT_TRANSLATE_NOOP("App::Property", "The time to dwell between peck cycles")) - obj.addProperty("App::PropertyBool", "DwellEnabled", "Drill", QtCore.QT_TRANSLATE_NOOP("App::Property", "Enable dwell")) - obj.addProperty("App::PropertyBool", "AddTipLength", "Drill", QtCore.QT_TRANSLATE_NOOP("App::Property", "Calculate the tip length and subtract from final depth")) - obj.addProperty("App::PropertyEnumeration", "ReturnLevel", "Drill", QtCore.QT_TRANSLATE_NOOP("App::Property", "Controls how tool retracts Default=G99")) - obj.addProperty("App::PropertyDistance", "RetractHeight", "Drill", QtCore.QT_TRANSLATE_NOOP("App::Property", "The height where feed starts and height during retract tool when path is finished while in a peck operation")) - obj.addProperty("App::PropertyEnumeration", "ExtraOffset", "Drill", QtCore.QT_TRANSLATE_NOOP("App::Property", "How far the drill depth is extended")) + """initCircularHoleOperation(obj) ... add drilling specific properties to obj.""" + obj.addProperty( + "App::PropertyLength", + "PeckDepth", + "Drill", + QtCore.QT_TRANSLATE_NOOP( + "App::Property", + "Incremental Drill depth before retracting to clear chips", + ), + ) + obj.addProperty( + "App::PropertyBool", + "PeckEnabled", + "Drill", + QtCore.QT_TRANSLATE_NOOP("App::Property", "Enable pecking"), + ) + obj.addProperty( + "App::PropertyFloat", + "DwellTime", + "Drill", + QtCore.QT_TRANSLATE_NOOP( + "App::Property", "The time to dwell between peck cycles" + ), + ) + obj.addProperty( + "App::PropertyBool", + "DwellEnabled", + "Drill", + QtCore.QT_TRANSLATE_NOOP("App::Property", "Enable dwell"), + ) + obj.addProperty( + "App::PropertyBool", + "AddTipLength", + "Drill", + QtCore.QT_TRANSLATE_NOOP( + "App::Property", + "Calculate the tip length and subtract from final depth", + ), + ) + obj.addProperty( + "App::PropertyEnumeration", + "ReturnLevel", + "Drill", + QtCore.QT_TRANSLATE_NOOP( + "App::Property", "Controls how tool retracts Default=G99" + ), + ) + obj.addProperty( + "App::PropertyDistance", + "RetractHeight", + "Drill", + QtCore.QT_TRANSLATE_NOOP( + "App::Property", + "The height where feed starts and height during retract tool when path is finished while in a peck operation", + ), + ) + obj.addProperty( + "App::PropertyEnumeration", + "ExtraOffset", + "Drill", + QtCore.QT_TRANSLATE_NOOP( + "App::Property", "How far the drill depth is extended" + ), + ) - obj.ReturnLevel = ['G99', 'G98'] # Canned Cycle Return Level - obj.ExtraOffset = ['None', 'Drill Tip', '2x Drill Tip'] # Canned Cycle Return Level + obj.ReturnLevel = ["G99", "G98"] # Canned Cycle Return Level + obj.ExtraOffset = [ + "None", + "Drill Tip", + "2x Drill Tip", + ] # Canned Cycle Return Level def circularHoleExecute(self, obj, holes): - '''circularHoleExecute(obj, holes) ... generate drill operation for each hole in holes.''' + """circularHoleExecute(obj, holes) ... generate drill operation for each hole in holes.""" PathLog.track() + machine = PathMachineState.MachineState() self.commandlist.append(Path.Command("(Begin Drilling)")) # rapid to clearance height - self.commandlist.append(Path.Command('G0', {'Z': obj.ClearanceHeight.Value, 'F': self.vertRapid})) + command = Path.Command("G0", {"Z": obj.ClearanceHeight.Value}) + machine.addCommand(command) + self.commandlist.append(command) - tiplength = 0.0 - if obj.ExtraOffset == 'Drill Tip': - tiplength = PathUtils.drillTipLength(self.tool) - elif obj.ExtraOffset == '2x Drill Tip': - tiplength = PathUtils.drillTipLength(self.tool) * 2 + self.commandlist.append(Path.Command("G90")) # Absolute distance mode - holes = PathUtils.sort_jobs(holes, ['x', 'y']) - self.commandlist.append(Path.Command('G90')) + # Calculate offsets to add to target edge + endoffset = 0.0 + if obj.ExtraOffset == "Drill Tip": + endoffset = PathUtils.drillTipLength(self.tool) + elif obj.ExtraOffset == "2x Drill Tip": + endoffset = PathUtils.drillTipLength(self.tool) * 2 + + # http://linuxcnc.org/docs/html/gcode/g-code.html#gcode:g98-g99 self.commandlist.append(Path.Command(obj.ReturnLevel)) - cmd = "G81" - cmdParams = {} - cmdParams['Z'] = obj.FinalDepth.Value - tiplength - cmdParams['F'] = self.vertFeed - cmdParams['R'] = obj.RetractHeight.Value + holes = PathUtils.sort_locations(holes, ["x", "y"]) - if obj.PeckEnabled and obj.PeckDepth.Value > 0: - cmd = "G83" - cmdParams['Q'] = obj.PeckDepth.Value - elif obj.DwellEnabled and obj.DwellTime > 0: - cmd = "G82" - cmdParams['P'] = obj.DwellTime + # This section is technical debt. The computation of the + # target shapes should be factored out for re-use. + # This will likely mean refactoring upstream CircularHoleBase to pass + # spotshapes instead of holes. - # parentJob = PathUtils.findParentJob(obj) - # startHeight = obj.StartDepth.Value + parentJob.SetupSheet.SafeHeightOffset.Value startHeight = obj.StartDepth.Value + self.job.SetupSheet.SafeHeightOffset.Value - for p in holes: - params = {} - params['X'] = p['x'] - params['Y'] = p['y'] + edgelist = [] + for hole in holes: + v1 = FreeCAD.Vector(hole["x"], hole["y"], obj.StartDepth.Value) + v2 = FreeCAD.Vector(hole["x"], hole["y"], obj.FinalDepth.Value - endoffset) + edgelist.append(Part.makeLine(v1, v2)) + + # iterate the edgelist and generate gcode + for edge in edgelist: + + PathLog.debug(edge) # move to hole location - self.commandlist.append(Path.Command('G0', {'X': p['x'], 'Y': p['y'], 'F': self.horizRapid})) - self.commandlist.append(Path.Command('G0', {'Z': startHeight, 'F': self.vertRapid})) - self.commandlist.append(Path.Command('G1', {'Z': obj.StartDepth.Value, 'F': self.vertFeed})) - # Update changes to parameters - params.update(cmdParams) + command = Path.Command("G0", {"X": hole["x"], "Y": hole["y"]}) + self.commandlist.append(command) + machine.addCommand(command) - # Perform canned drilling cycle - self.commandlist.append(Path.Command(cmd, params)) + command = Path.Command("G0", {"Z": startHeight}) + self.commandlist.append(command) + machine.addCommand(command) - # Cancel canned drilling cycle - self.commandlist.append(Path.Command('G80')) - self.commandlist.append(Path.Command('G0', {'Z': obj.SafeHeight.Value})) + command = Path.Command("G1", {"Z": obj.StartDepth.Value}) + self.commandlist.append(command) + machine.addCommand(command) + + # Technical Debt: We are assuming the edges are aligned. + # This assumption should be corrected and the necessary rotations + # performed to align the edge with the Z axis for drilling + + # Perform drilling + dwelltime = obj.DwellTime if obj.DwellEnabled else 0.0 + peckdepth = obj.PeckDepth.Value if obj.PeckEnabled else 0.0 + repeat = 1 # technical debt: Add a repeat property for user control + + try: + drillcommands = generator.generate(edge, dwelltime, peckdepth, repeat) + + except ValueError as e: # any targets that fail the generator are ignored + PathLog.info(e) + continue + + for command in drillcommands: + self.commandlist.append(command) + machine.addCommand(command) + + # Cancel canned drilling cycle + self.commandlist.append(Path.Command("G80")) + command = Path.Command("G0", {"Z": obj.SafeHeight.Value}) + self.commandlist.append(command) + machine.addCommand(command) + + # Apply feedrates to commands + PathFeedRate.setFeedRate(self.commandlist, obj.ToolController) def opSetDefaultValues(self, obj, job): - '''opSetDefaultValues(obj, job) ... set default value for RetractHeight''' + """opSetDefaultValues(obj, job) ... set default value for RetractHeight""" obj.ExtraOffset = "None" - if hasattr(job.SetupSheet, 'RetractHeight'): + if hasattr(job.SetupSheet, "RetractHeight"): obj.RetractHeight = job.SetupSheet.RetractHeight - elif self.applyExpression(obj, 'RetractHeight', 'StartDepth+SetupSheet.SafeHeightOffset'): + elif self.applyExpression( + obj, "RetractHeight", "StartDepth+SetupSheet.SafeHeightOffset" + ): if not job: obj.RetractHeight = 10 else: obj.RetractHeight.Value = obj.StartDepth.Value + 1.0 - if hasattr(job.SetupSheet, 'PeckDepth'): + if hasattr(job.SetupSheet, "PeckDepth"): obj.PeckDepth = job.SetupSheet.PeckDepth - elif self.applyExpression(obj, 'PeckDepth', 'OpToolDiameter*0.75'): + elif self.applyExpression(obj, "PeckDepth", "OpToolDiameter*0.75"): obj.PeckDepth = 1 - if hasattr(job.SetupSheet, 'DwellTime'): + if hasattr(job.SetupSheet, "DwellTime"): obj.DwellTime = job.SetupSheet.DwellTime else: obj.DwellTime = 1 + def SetupProperties(): setup = [] setup.append("PeckDepth") @@ -161,7 +262,7 @@ def SetupProperties(): def Create(name, obj=None, parentJob=None): - '''Create(name) ... Creates and returns a Drilling operation.''' + """Create(name) ... Creates and returns a Drilling operation.""" if obj is None: obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name) @@ -169,4 +270,4 @@ def Create(name, obj=None, parentJob=None): if obj.Proxy: obj.Proxy.findAllHoles(obj) - return obj \ No newline at end of file + return obj diff --git a/src/Mod/Path/PathScripts/PathGeom.py b/src/Mod/Path/PathScripts/PathGeom.py index bf45ca4141..7c8ab9681e 100644 --- a/src/Mod/Path/PathScripts/PathGeom.py +++ b/src/Mod/Path/PathScripts/PathGeom.py @@ -87,8 +87,9 @@ CmdMoveRapid = ["G0", "G00"] CmdMoveStraight = ["G1", "G01"] CmdMoveCW = ["G2", "G02"] CmdMoveCCW = ["G3", "G03"] +CmdMoveDrill = ["G81", "G82", "G83"] CmdMoveArc = CmdMoveCW + CmdMoveCCW -CmdMove = CmdMoveStraight + CmdMoveArc +CmdMove = CmdMoveStraight + CmdMoveArc + CmdMoveDrill CmdMoveAll = CmdMove + CmdMoveRapid diff --git a/src/Mod/Path/PathScripts/PathHelix.py b/src/Mod/Path/PathScripts/PathHelix.py index a19910ed8c..cdf3a24faf 100644 --- a/src/Mod/Path/PathScripts/PathHelix.py +++ b/src/Mod/Path/PathScripts/PathHelix.py @@ -29,7 +29,7 @@ import PathScripts.PathOp as PathOp from PathScripts.PathUtils import fmt from PathScripts.PathUtils import findParentJob -from PathScripts.PathUtils import sort_jobs +from PathScripts.PathUtils import sort_locations from PySide import QtCore __title__ = "Path Helix Drill Operation" @@ -79,7 +79,7 @@ class ObjectHelix(PathCircularHoleBase.ObjectOp): output = '' output += "G0 Z" + fmt(zsafe) - holes = sort_jobs(holes, ['x', 'y']) + holes = sort_locations(holes, ['x', 'y']) for hole in holes: output += self.helix_cut(obj, hole['x'], hole['y'], hole['r'] / 2, float(obj.StartRadius.Value), (float(obj.StepOver.Value) / 50.0) * self.radius) PathLog.debug(output) diff --git a/src/Mod/Path/PathScripts/PathJob.py b/src/Mod/Path/PathScripts/PathJob.py index 63a452c0d4..5f21da0c40 100644 --- a/src/Mod/Path/PathScripts/PathJob.py +++ b/src/Mod/Path/PathScripts/PathJob.py @@ -181,6 +181,14 @@ class ObjectJob: ), ) + obj.addProperty( + "App::PropertyEnumeration", + "JobType", + "Base", + QtCore.QT_TRANSLATE_NOOP("PathJob", "Select the Type of Job"), + ) + obj.setEditorMode("JobType", 2) # Hide + obj.addProperty( "App::PropertyBool", "SplitOutput", @@ -208,6 +216,8 @@ class ObjectJob: obj.OrderOutputBy = ["Fixture", "Tool", "Operation"] obj.Fixtures = ["G54"] + obj.JobType = ["2D", "2.5D", "Lathe", "Multiaxis"] + obj.PostProcessorOutputFile = PathPreferences.defaultOutputFile() obj.PostProcessor = postProcessors = PathPreferences.allEnabledPostProcessors() defaultPostProcessor = PathPreferences.defaultPostProcessor() @@ -478,6 +488,17 @@ class ObjectJob: ) obj.SplitOutput = False + if not hasattr(obj, "JobType"): + obj.addProperty( + "App::PropertyEnumeration", + "JobType", + "Base", + QtCore.QT_TRANSLATE_NOOP("PathJob", "Select the Type of Job"), + ) + obj.setEditorMode("JobType", 2) # Hide + + obj.JobType = ["2D", "2.5D", "Lathe", "Multiaxis"] + def onChanged(self, obj, prop): if prop == "PostProcessor" and obj.PostProcessor: processor = PostProcessor.load(obj.PostProcessor) diff --git a/src/Mod/Path/PathScripts/PathProfile.py b/src/Mod/Path/PathScripts/PathProfile.py index d5afb71e21..76f6840a86 100644 --- a/src/Mod/Path/PathScripts/PathProfile.py +++ b/src/Mod/Path/PathScripts/PathProfile.py @@ -28,6 +28,7 @@ import PathScripts.PathAreaOp as PathAreaOp import PathScripts.PathLog as PathLog import PathScripts.PathOp as PathOp import PathScripts.PathUtils as PathUtils +import PathScripts.drillableLib as drillableLib import math import numpy from PySide.QtCore import QT_TRANSLATE_NOOP @@ -444,7 +445,7 @@ class ObjectProfile(PathAreaOp.ObjectOp): for baseShape, wire in holes: cont = False f = Part.makeFace(wire, "Part::FaceMakerSimple") - drillable = PathUtils.isDrillable(baseShape, wire) + drillable = drillableLib.isDrillable(baseShape, f) if obj.processCircles: if drillable: diff --git a/src/Mod/Path/PathScripts/PathSelection.py b/src/Mod/Path/PathScripts/PathSelection.py index 8f0725407b..2c640aaca2 100644 --- a/src/Mod/Path/PathScripts/PathSelection.py +++ b/src/Mod/Path/PathScripts/PathSelection.py @@ -21,13 +21,13 @@ # * * # *************************************************************************** -'''Selection gates and observers to control selectability while building Path operations ''' +"""Selection gates and observers to control selectability while building Path operations """ import FreeCAD import FreeCADGui import PathScripts.PathLog as PathLog import PathScripts.PathPreferences as PathPreferences -import PathScripts.PathUtils as PathUtils +import PathScripts.drillableLib as drillableLib import math PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) @@ -41,12 +41,12 @@ class PathBaseGate(object): class EGate(PathBaseGate): def allow(self, doc, obj, sub): # pylint: disable=unused-argument - return sub and sub[0:4] == 'Edge' + return sub and sub[0:4] == "Edge" class MESHGate(PathBaseGate): def allow(self, doc, obj, sub): # pylint: disable=unused-argument - return obj.TypeId[0:4] == 'Mesh' + return obj.TypeId[0:4] == "Mesh" class VCARVEGate: @@ -59,20 +59,20 @@ class VCARVEGate: if math.fabs(shape.Volume) < 1e-9 and len(shape.Wires) > 0: return True - if shape.ShapeType == 'Face': + if shape.ShapeType == "Face": return True - elif shape.ShapeType == 'Solid': - if sub and sub[0:4] == 'Face': + elif shape.ShapeType == "Solid": + if sub and sub[0:4] == "Face": return True - elif shape.ShapeType == 'Compound': - if sub and sub[0:4] == 'Face': + elif shape.ShapeType == "Compound": + if sub and sub[0:4] == "Face": return True if sub: subShape = shape.getElement(sub) - if subShape.ShapeType == 'Edge': + if subShape.ShapeType == "Edge": return False return False @@ -88,35 +88,35 @@ class ENGRAVEGate(PathBaseGate): if math.fabs(shape.Volume) < 1e-9 and len(shape.Wires) > 0: return True - if shape.ShapeType == 'Edge': + if shape.ShapeType == "Edge": return True if sub: subShape = shape.getElement(sub) - if subShape.ShapeType == 'Edge': + if subShape.ShapeType == "Edge": return True return False class CHAMFERGate(PathBaseGate): - def allow(self, doc, obj, sub): # pylint: disable=unused-argument + def allow(self, doc, obj, sub): # pylint: disable=unused-argument try: shape = obj.Shape - except Exception: # pylint: disable=broad-except + except Exception: # pylint: disable=broad-except return False if math.fabs(shape.Volume) < 1e-9 and len(shape.Wires) > 0: return True - if 'Edge' == shape.ShapeType or 'Face' == shape.ShapeType: + if "Edge" == shape.ShapeType or "Face" == shape.ShapeType: return True if sub: subShape = shape.getElement(sub) - if subShape.ShapeType == 'Edge': + if subShape.ShapeType == "Edge": return True - elif (subShape.ShapeType == 'Face'): + elif subShape.ShapeType == "Face": return True return False @@ -124,16 +124,19 @@ class CHAMFERGate(PathBaseGate): class DRILLGate(PathBaseGate): def allow(self, doc, obj, sub): # pylint: disable=unused-argument - PathLog.debug('obj: {} sub: {}'.format(obj, sub)) - if hasattr(obj, "Shape") and sub: - shape = obj.Shape - subobj = shape.getElement(sub) - return PathUtils.isDrillable(shape, subobj, includePartials=True) - else: + PathLog.debug("obj: {} sub: {}".format(obj, sub)) + if not hasattr(obj, "Shape") and sub: return False + shape = obj.Shape + subobj = shape.getElement(sub) + if subobj.ShapeType not in ["Edge", "Face"]: + return False + return drillableLib.isDrillable(shape, subobj, vector=None) -class FACEGate(PathBaseGate): # formerly PROFILEGate class using allow_ORIG method as allow() +class FACEGate( + PathBaseGate +): # formerly PROFILEGate class using allow_ORIG method as allow() def allow(self, doc, obj, sub): # pylint: disable=unused-argument profileable = False @@ -142,15 +145,15 @@ class FACEGate(PathBaseGate): # formerly PROFILEGate class using allow_ORIG me except Exception: # pylint: disable=broad-except return False - if obj.ShapeType == 'Compound': - if sub and sub[0:4] == 'Face': + if obj.ShapeType == "Compound": + if sub and sub[0:4] == "Face": profileable = True - elif obj.ShapeType == 'Face': # 3D Face, not flat, planar? - profileable = True # Was False + elif obj.ShapeType == "Face": # 3D Face, not flat, planar? + profileable = True # Was False - elif obj.ShapeType == 'Solid': - if sub and sub[0:4] == 'Face': + elif obj.ShapeType == "Solid": + if sub and sub[0:4] == "Face": profileable = True return profileable @@ -163,27 +166,27 @@ class FACEGate(PathBaseGate): # formerly PROFILEGate class using allow_ORIG me except Exception: # pylint: disable=broad-except return False - if obj.ShapeType == 'Edge': + if obj.ShapeType == "Edge": profileable = False - elif obj.ShapeType == 'Compound': - if sub and sub[0:4] == 'Face': + elif obj.ShapeType == "Compound": + if sub and sub[0:4] == "Face": profileable = True - if sub and sub[0:4] == 'Edge': + if sub and sub[0:4] == "Edge": profileable = False - elif obj.ShapeType == 'Face': + elif obj.ShapeType == "Face": profileable = False - elif obj.ShapeType == 'Solid': - if sub and sub[0:4] == 'Face': + elif obj.ShapeType == "Solid": + if sub and sub[0:4] == "Face": profileable = True - if sub and sub[0:4] == 'Edge': + if sub and sub[0:4] == "Edge": profileable = False - elif obj.ShapeType == 'Wire': + elif obj.ShapeType == "Wire": profileable = False return profileable @@ -191,7 +194,7 @@ class FACEGate(PathBaseGate): # formerly PROFILEGate class using allow_ORIG me class PROFILEGate(PathBaseGate): def allow(self, doc, obj, sub): # pylint: disable=unused-argument - if sub and sub[0:4] == 'Edge': + if sub and sub[0:4] == "Edge": return True try: @@ -199,18 +202,18 @@ class PROFILEGate(PathBaseGate): except Exception: # pylint: disable=broad-except return False - if obj.ShapeType == 'Compound': - if sub and sub[0:4] == 'Face': + if obj.ShapeType == "Compound": + if sub and sub[0:4] == "Face": return True - elif obj.ShapeType == 'Face': + elif obj.ShapeType == "Face": return True - elif obj.ShapeType == 'Solid': - if sub and sub[0:4] == 'Face': + elif obj.ShapeType == "Solid": + if sub and sub[0:4] == "Face": return True - elif obj.ShapeType == 'Wire': + elif obj.ShapeType == "Wire": return True return False @@ -225,18 +228,18 @@ class POCKETGate(PathBaseGate): except Exception: # pylint: disable=broad-except return False - if obj.ShapeType == 'Edge': + if obj.ShapeType == "Edge": pocketable = False - elif obj.ShapeType == 'Face': + elif obj.ShapeType == "Face": pocketable = True - elif obj.ShapeType == 'Solid': - if sub and sub[0:4] == 'Face': + elif obj.ShapeType == "Solid": + if sub and sub[0:4] == "Face": pocketable = True - elif obj.ShapeType == 'Compound': - if sub and sub[0:4] == 'Face': + elif obj.ShapeType == "Compound": + if sub and sub[0:4] == "Face": pocketable = True return pocketable @@ -266,22 +269,22 @@ class PROBEGate: class TURNGate(PathBaseGate): def allow(self, doc, obj, sub): # pylint: disable=unused-argument - PathLog.debug('obj: {} sub: {}'.format(obj, sub)) + PathLog.debug("obj: {} sub: {}".format(obj, sub)) if hasattr(obj, "Shape") and sub: shape = obj.Shape subobj = shape.getElement(sub) - return PathUtils.isDrillable(shape, subobj, includePartials=True) + return drillableLib.isDrillable(shape, subobj, vector=None) else: return False class ALLGate(PathBaseGate): def allow(self, doc, obj, sub): # pylint: disable=unused-argument - if sub and sub[0:6] == 'Vertex': + if sub and sub[0:6] == "Vertex": return True - if sub and sub[0:4] == 'Edge': + if sub and sub[0:4] == "Edge": return True - if sub and sub[0:4] == 'Face': + if sub and sub[0:4] == "Face": return True return False @@ -348,7 +351,7 @@ def slotselect(): def surfaceselect(): gate = False - if(MESHGate() or FACEGate()): + if MESHGate() or FACEGate(): gate = True FreeCADGui.Selection.addSelectionGate(gate) if not PathPreferences.suppressSelectionModeWarning(): @@ -380,30 +383,30 @@ def turnselect(): def select(op): opsel = {} - opsel['Contour'] = contourselect # (depreciated) - opsel['Deburr'] = chamferselect - opsel['Drilling'] = drillselect - opsel['Engrave'] = engraveselect - opsel['Helix'] = drillselect - opsel['MillFace'] = pocketselect - opsel['Pocket'] = pocketselect - opsel['Pocket 3D'] = pocketselect - opsel['Pocket Shape'] = pocketselect - opsel['Profile Edges'] = eselect # (depreciated) - opsel['Profile Faces'] = fselect # (depreciated) - opsel['Profile'] = profileselect - opsel['Slot'] = slotselect - opsel['Surface'] = surfaceselect - opsel['Waterline'] = surfaceselect - opsel['Adaptive'] = adaptiveselect - opsel['Vcarve'] = vcarveselect - opsel['Probe'] = probeselect - opsel['Custom'] = customselect - opsel['Thread Milling'] = drillselect - opsel['TurnFace'] = turnselect - opsel['TurnProfile'] = turnselect - opsel['TurnPartoff'] = turnselect - opsel['TurnRough'] = turnselect + opsel["Contour"] = contourselect # (depreciated) + opsel["Deburr"] = chamferselect + opsel["Drilling"] = drillselect + opsel["Engrave"] = engraveselect + opsel["Helix"] = drillselect + opsel["MillFace"] = pocketselect + opsel["Pocket"] = pocketselect + opsel["Pocket 3D"] = pocketselect + opsel["Pocket Shape"] = pocketselect + opsel["Profile Edges"] = eselect # (depreciated) + opsel["Profile Faces"] = fselect # (depreciated) + opsel["Profile"] = profileselect + opsel["Slot"] = slotselect + opsel["Surface"] = surfaceselect + opsel["Waterline"] = surfaceselect + opsel["Adaptive"] = adaptiveselect + opsel["Vcarve"] = vcarveselect + opsel["Probe"] = probeselect + opsel["Custom"] = customselect + opsel["Thread Milling"] = drillselect + opsel["TurnFace"] = turnselect + opsel["TurnProfile"] = turnselect + opsel["TurnPartoff"] = turnselect + opsel["TurnRough"] = turnselect return opsel[op] diff --git a/src/Mod/Path/PathScripts/PathUtils.py b/src/Mod/Path/PathScripts/PathUtils.py index 4d4f2426fb..63e42f3330 100644 --- a/src/Mod/Path/PathScripts/PathUtils.py +++ b/src/Mod/Path/PathScripts/PathUtils.py @@ -20,19 +20,17 @@ # * * # *************************************************************************** """PathUtils -common functions used in PathScripts for filtering, sorting, and generating gcode toolpath data """ + import FreeCAD -import Path - -# import PathScripts -import PathScripts.PathJob as PathJob -import PathScripts.PathGeom as PathGeom -import math -import numpy - from FreeCAD import Vector from PathScripts import PathLog from PySide import QtCore from PySide import QtGui +import Path +import PathScripts.PathGeom as PathGeom +import PathScripts.PathJob as PathJob +import math +from numpy import linspace # lazily loaded modules from lazy_loader.lazy_loader import LazyLoader @@ -73,119 +71,6 @@ def waiting_effects(function): return new_function -def isDrillable(obj, candidate, tooldiameter=None, includePartials=False): - """ - Checks candidates to see if they can be drilled. - Candidates can be either faces - circular or cylindrical or circular edges. - The tooldiameter can be optionally passed. if passed, the check will return - False for any holes smaller than the tooldiameter. - obj=Shape - candidate = Face or Edge - tooldiameter=float - """ - PathLog.track( - "obj: {} candidate: {} tooldiameter {}".format(obj, candidate, tooldiameter) - ) - if list == type(obj): - for shape in obj: - if isDrillable(shape, candidate, tooldiameter, includePartials): - return (True, shape) - return (False, None) - - drillable = False - try: - if candidate.ShapeType == "Face": - face = candidate - # eliminate flat faces - if (round(face.ParameterRange[0], 8) == 0.0) and ( - round(face.ParameterRange[1], 8) == round(math.pi * 2, 8) - ): - for ( - edge - ) in face.Edges: # Find seam edge and check if aligned to Z axis. - if isinstance(edge.Curve, Part.Line): - PathLog.debug("candidate is a circle") - v0 = edge.Vertexes[0].Point - v1 = edge.Vertexes[1].Point - # check if the cylinder seam is vertically aligned. Eliminate tilted holes - if ( - numpy.isclose(v1.sub(v0).x, 0, rtol=1e-05, atol=1e-06) - ) and (numpy.isclose(v1.sub(v0).y, 0, rtol=1e-05, atol=1e-06)): - drillable = True - # vector of top center - lsp = Vector( - face.BoundBox.Center.x, - face.BoundBox.Center.y, - face.BoundBox.ZMax, - ) - # vector of bottom center - lep = Vector( - face.BoundBox.Center.x, - face.BoundBox.Center.y, - face.BoundBox.ZMin, - ) - # check if the cylindrical 'lids' are inside the base - # object. This eliminates extruded circles but allows - # actual holes. - if obj.isInside(lsp, 1e-6, False) or obj.isInside( - lep, 1e-6, False - ): - PathLog.track( - "inside check failed. lsp: {} lep: {}".format( - lsp, lep - ) - ) - drillable = False - # eliminate elliptical holes - elif not hasattr(face.Surface, "Radius"): - PathLog.debug("candidate face has no radius attribute") - drillable = False - else: - if tooldiameter is not None: - drillable = face.Surface.Radius >= tooldiameter / 2 - else: - drillable = True - elif type(face.Surface) == Part.Plane and PathGeom.pointsCoincide( - face.Surface.Axis, FreeCAD.Vector(0, 0, 1) - ): - if len(face.Edges) == 1 and type(face.Edges[0].Curve) == Part.Circle: - center = face.Edges[0].Curve.Center - if obj.isInside(center, 1e-6, False): - if tooldiameter is not None: - drillable = face.Edges[0].Curve.Radius >= tooldiameter / 2 - else: - drillable = True - else: - for edge in candidate.Edges: - if isinstance(edge.Curve, Part.Circle) and ( - includePartials or edge.isClosed() - ): - PathLog.debug("candidate is a circle or ellipse") - if not hasattr(edge.Curve, "Radius"): - PathLog.debug("No radius. Ellipse.") - drillable = False - else: - PathLog.debug("Has Radius, Circle") - if tooldiameter is not None: - drillable = edge.Curve.Radius >= tooldiameter / 2 - if not drillable: - FreeCAD.Console.PrintMessage( - "Found a drillable hole with diameter: {}: " - "too small for the current tool with " - "diameter: {}".format( - edge.Curve.Radius * 2, tooldiameter - ) - ) - else: - drillable = True - PathLog.debug("candidate is drillable: {}".format(drillable)) - except Exception as ex: # pylint: disable=broad-except - PathLog.warning( - translate("Path", "Issue determine drillability: {}").format(ex) - ) - return drillable - - # set at 4 decimal places for testing def fmt(val): return format(val, ".4f") @@ -262,7 +147,7 @@ def horizontalFaceLoop(obj, face, faceList=None): # verify they form a valid hole by getting the outline and comparing # the resulting XY footprint with that of the faces comp = Part.makeCompound([obj.Shape.getElement(f) for f in faces]) - outline = TechDraw.findShapeOutline(comp, 1, FreeCAD.Vector(0, 0, 1)) + outline = TechDraw.findShapeOutline(comp, 1, Vector(0, 0, 1)) # findShapeOutline always returns closed wires, by removing the # trace-backs single edge spikes don't contribute to the bound box @@ -289,31 +174,30 @@ def horizontalFaceLoop(obj, face, faceList=None): def filterArcs(arcEdge): - """filterArcs(Edge) -used to split arcs that over 180 degrees. Returns list""" + """filterArcs(Edge) -used to split an arc that is over 180 degrees. Returns list""" PathLog.track() - s = arcEdge - if isinstance(s.Curve, Part.Circle): - splitlist = [] - angle = abs(s.LastParameter - s.FirstParameter) - # overhalfcircle = False - goodarc = False - if angle > math.pi: - pass - # overhalfcircle = True + splitlist = [] + if isinstance(arcEdge.Curve, Part.Circle): + angle = abs(arcEdge.LastParameter - arcEdge.FirstParameter) # Angle in radians + goodarc = angle <= math.pi + + if goodarc: + splitlist.append(arcEdge) else: - goodarc = True - if not goodarc: - arcstpt = s.valueAt(s.FirstParameter) - arcmid = s.valueAt( - (s.LastParameter - s.FirstParameter) * 0.5 + s.FirstParameter + arcstpt = arcEdge.valueAt(arcEdge.FirstParameter) + arcmid = arcEdge.valueAt( + (arcEdge.LastParameter - arcEdge.FirstParameter) * 0.5 + + arcEdge.FirstParameter ) - arcquad1 = s.valueAt( - (s.LastParameter - s.FirstParameter) * 0.25 + s.FirstParameter + arcquad1 = arcEdge.valueAt( + (arcEdge.LastParameter - arcEdge.FirstParameter) * 0.25 + + arcEdge.FirstParameter ) # future midpt for arc1 - arcquad2 = s.valueAt( - (s.LastParameter - s.FirstParameter) * 0.75 + s.FirstParameter + arcquad2 = arcEdge.valueAt( + (arcEdge.LastParameter - arcEdge.FirstParameter) * 0.75 + + arcEdge.FirstParameter ) # future midpt for arc2 - arcendpt = s.valueAt(s.LastParameter) + arcendpt = arcEdge.valueAt(arcEdge.LastParameter) # reconstruct with 2 arcs arcseg1 = Part.ArcOfCircle(arcstpt, arcquad1, arcmid) arcseg2 = Part.ArcOfCircle(arcmid, arcquad2, arcendpt) @@ -322,9 +206,8 @@ def filterArcs(arcEdge): eseg2 = arcseg2.toShape() splitlist.append(eseg1) splitlist.append(eseg2) - else: - splitlist.append(s) - elif isinstance(s.Curve, Part.LineSegment): + + elif isinstance(arcEdge.Curve, Part.LineSegment): pass return splitlist @@ -334,9 +217,7 @@ def makeWorkplane(shape): Creates a workplane circle at the ZMin level. """ PathLog.track() - loc = FreeCAD.Vector( - shape.BoundBox.Center.x, shape.BoundBox.Center.y, shape.BoundBox.ZMin - ) + loc = Vector(shape.BoundBox.Center.x, shape.BoundBox.Center.y, shape.BoundBox.ZMin) c = Part.makeCircle(10, loc) return c @@ -388,11 +269,11 @@ def getEnvelope(partshape, subshape=None, depthparams=None): eLength = partshape.BoundBox.ZLength - sec.BoundBox.ZMin # Shift the section based on selection and depthparams. - newPlace = FreeCAD.Placement(FreeCAD.Vector(0, 0, zShift), sec.Placement.Rotation) + newPlace = FreeCAD.Placement(Vector(0, 0, zShift), sec.Placement.Rotation) sec.Placement = newPlace # Extrude the section to top of Boundbox or desired height - envelopeshape = sec.extrude(FreeCAD.Vector(0, 0, eLength)) + envelopeshape = sec.extrude(Vector(0, 0, eLength)) if PathLog.getLevel(PathLog.thisModule()) == PathLog.Level.DEBUG: removalshape = FreeCAD.ActiveDocument.addObject("Part::Feature", "Envelope") removalshape.Shape = envelopeshape @@ -561,211 +442,16 @@ def addToJob(obj, jobname=None): return job -def rapid(x=None, y=None, z=None): - """Returns gcode string to perform a rapid move.""" - retstr = "G00" - if (x is not None) or (y is not None) or (z is not None): - if x is not None: - retstr += " X" + str("%.4f" % x) - if y is not None: - retstr += " Y" + str("%.4f" % y) - if z is not None: - retstr += " Z" + str("%.4f" % z) - else: - return "" - return retstr + "\n" - - -def feed(x=None, y=None, z=None, horizFeed=0, vertFeed=0): - """Return gcode string to perform a linear feed.""" - retstr = "G01 F" - if (x is None) and (y is None): - retstr += str("%.4f" % horizFeed) - else: - retstr += str("%.4f" % vertFeed) - - if (x is not None) or (y is not None) or (z is not None): - if x is not None: - retstr += " X" + str("%.4f" % x) - if y is not None: - retstr += " Y" + str("%.4f" % y) - if z is not None: - retstr += " Z" + str("%.4f" % z) - else: - return "" - return retstr + "\n" - - -def arc(cx, cy, sx, sy, ex, ey, horizFeed=0, ez=None, ccw=False): - """ - Return gcode string to perform an arc. - - Assumes XY plane or helix around Z - Don't worry about starting Z- assume that's dealt with elsewhere - If start/end radii aren't within eps, abort. - - cx, cy -- arc center coordinates - sx, sy -- arc start coordinates - ex, ey -- arc end coordinates - ez -- ending Z coordinate. None unless helix. - horizFeed -- horiz feed speed - ccw -- arc direction - """ - - eps = 0.01 - if ( - math.sqrt((cx - sx) ** 2 + (cy - sy) ** 2) - - math.sqrt((cx - ex) ** 2 + (cy - ey) ** 2) - ) >= eps: - PathLog.error(translate("Path", "Illegal arc: Start and end radii not equal")) - return "" - - retstr = "" - if ccw: - retstr += "G03 F" + str(horizFeed) - else: - retstr += "G02 F" + str(horizFeed) - - retstr += " X" + str("%.4f" % ex) + " Y" + str("%.4f" % ey) - - if ez is not None: - retstr += " Z" + str("%.4f" % ez) - - retstr += " I" + str("%.4f" % (cx - sx)) + " J" + str("%.4f" % (cy - sy)) - - return retstr + "\n" - - -def helicalPlunge(plungePos, rampangle, destZ, startZ, toold, plungeR, horizFeed): - """ - Return gcode string to perform helical entry move. - - plungePos -- vector of the helical entry location - destZ -- the lowest Z position or milling level - startZ -- Starting Z position for helical move - rampangle -- entry angle - toold -- tool diameter - plungeR -- the radius of the entry helix - """ - # toold = self.radius * 2 - - helixCmds = "(START HELICAL PLUNGE)\n" - if plungePos is None: - raise Exception("Helical plunging requires a position!") - - helixX = plungePos.x + toold / 2 * plungeR - helixY = plungePos.y - - helixCirc = math.pi * toold * plungeR - dzPerRev = math.sin(rampangle / 180.0 * math.pi) * helixCirc - - # Go to the start of the helix position - helixCmds += rapid(helixX, helixY) - helixCmds += rapid(z=startZ) - - # Helix as required to get to the requested depth - lastZ = startZ - curZ = max(startZ - dzPerRev, destZ) - done = False - while not done: - done = curZ == destZ - # NOTE: FreeCAD doesn't render this, but at least LinuxCNC considers it valid - # helixCmds += arc(plungePos.x, plungePos.y, helixX, helixY, helixX, helixY, ez = curZ, ccw=True) - - # Use two half-helixes; FreeCAD renders that correctly, - # and it fits with the other code breaking up 360-degree arcs - helixCmds += arc( - plungePos.x, - plungePos.y, - helixX, - helixY, - helixX - toold * plungeR, - helixY, - horizFeed, - ez=(curZ + lastZ) / 2.0, - ccw=True, - ) - helixCmds += arc( - plungePos.x, - plungePos.y, - helixX - toold * plungeR, - helixY, - helixX, - helixY, - horizFeed, - ez=curZ, - ccw=True, - ) - lastZ = curZ - curZ = max(curZ - dzPerRev, destZ) - - return helixCmds - - -def rampPlunge(edge, rampangle, destZ, startZ): - """ - Return gcode string to linearly ramp down to milling level. - - edge -- edge to follow - rampangle -- entry angle - destZ -- Final Z depth - startZ -- Starting Z depth - - FIXME: This ramps along the first edge, assuming it's long - enough, NOT just wiggling back and forth by ~0.75 * toolD. - Not sure if that's any worse, but it's simpler - I think this should be changed to be limited to a maximum ramp size. Otherwise machine time will get longer than it needs to be. - """ - - rampCmds = "(START RAMP PLUNGE)\n" - if edge is None: - raise Exception("Ramp plunging requires an edge!") - - sPoint = edge.Vertexes[0].Point - ePoint = edge.Vertexes[1].Point - # Evidently edges can get flipped- pick the right one in this case - if ePoint == sPoint: - # print "FLIP" - ePoint = edge.Vertexes[-1].Point - - rampDist = edge.Length - rampDZ = math.sin(rampangle / 180.0 * math.pi) * rampDist - - rampCmds += rapid(sPoint.x, sPoint.y) - rampCmds += rapid(z=startZ) - - # Ramp down to the requested depth - - curZ = max(startZ - rampDZ, destZ) - done = False - while not done: - done = curZ == destZ - - # If it's an arc, handle it! - if isinstance(edge.Curve, Part.Circle): - raise Exception("rampPlunge: Screw it, not handling an arc.") - # Straight feed! Easy! - else: - rampCmds += feed(ePoint.x, ePoint.y, curZ) - rampCmds += feed(sPoint.x, sPoint.y) - - curZ = max(curZ - rampDZ, destZ) - - return rampCmds - - -def sort_jobs(locations, keys, attractors=None): +def sort_locations(locations, keys, attractors=None): """sort holes by the nearest neighbor method keys: two-element list of keys for X and Y coordinates. for example ['x','y'] originally written by m0n5t3r for PathHelix """ + from queue import PriorityQueue + from collections import defaultdict + if attractors is None: attractors = [] - try: - from queue import PriorityQueue - except ImportError: - from Queue import PriorityQueue - from collections import defaultdict attractors = attractors or [keys[0]] @@ -1032,7 +718,7 @@ class depth_params(object): than max_size.""" steps_needed = math.ceil((start - stop) / max_size) - depths = list(numpy.linspace(stop, start, steps_needed, endpoint=False)) + depths = list(linspace(stop, start, steps_needed, endpoint=False)) return depths @@ -1044,7 +730,7 @@ class depth_params(object): fullsteps = int((start - stop) / size) last_step = start - (fullsteps * size) - depths = list(numpy.linspace(last_step, start, fullsteps, endpoint=False)) + depths = list(linspace(last_step, start, fullsteps, endpoint=False)) if last_step == stop: return depths @@ -1108,7 +794,7 @@ def RtoIJ(startpoint, command): chord = endpoint.sub(startpoint) # Take its perpendicular (we assume the arc is in the XY plane) - perp = chord.cross(FreeCAD.Vector(0, 0, 1)) + perp = chord.cross(Vector(0, 0, 1)) # use pythagoras to get the perp length plength = math.sqrt(radius ** 2 - (chord.Length / 2) ** 2) diff --git a/src/Mod/Path/PathScripts/drillableLib.py b/src/Mod/Path/PathScripts/drillableLib.py new file mode 100644 index 0000000000..7a5fe4108b --- /dev/null +++ b/src/Mod/Path/PathScripts/drillableLib.py @@ -0,0 +1,237 @@ +import PathScripts.PathLog as PathLog +import FreeCAD as App +import Part +import numpy +import math + +if False: + PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule()) + PathLog.trackModule(PathLog.thisModule()) +else: + PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) + + +def isDrillableCylinder(obj, candidate, tooldiameter=None, vector=App.Vector(0, 0, 1)): + """ + checks if a candidate cylindrical face is drillable + """ + + matchToolDiameter = tooldiameter is not None + matchVector = vector is not None + + PathLog.debug( + "\n match tool diameter {} \n match vector {}".format( + matchToolDiameter, matchVector + ) + ) + + def raisedFeature(obj, candidate): + # check if the cylindrical 'lids' are inside the base + # object. This eliminates extruded circles but allows + # actual holes. + + startLidCenter = App.Vector( + candidate.BoundBox.Center.x, + candidate.BoundBox.Center.y, + candidate.BoundBox.ZMax, + ) + + endLidCenter = App.Vector( + candidate.BoundBox.Center.x, + candidate.BoundBox.Center.y, + candidate.BoundBox.ZMin, + ) + + return obj.isInside(startLidCenter, 1e-6, False) or obj.isInside( + endLidCenter, 1e-6, False + ) + + def getSeam(candidate): + # Finds the vertical seam edge in a cylinder + + for e in candidate.Edges: + if isinstance(e.Curve, Part.Line): # found the seam + return e + + if not candidate.ShapeType == "Face": + raise TypeError("expected a Face") + + if not isinstance(candidate.Surface, Part.Cylinder): + raise TypeError("expected a cylinder") + + if len(candidate.Edges) != 3: + raise TypeError("cylinder does not have 3 edges. Not supported yet") + + if raisedFeature(obj, candidate): + PathLog.debug("The cylindrical face is a raised feature") + return False + + if not matchToolDiameter and not matchVector: + return True + + elif matchToolDiameter and tooldiameter / 2 > candidate.Surface.Radius: + PathLog.debug("The tool is larger than the target") + return False + + elif matchVector and not (compareVecs(getSeam(candidate).Curve.Direction, vector)): + PathLog.debug("The feature is not aligned with the given vector") + return False + else: + return True + + +def isDrillableCircle(obj, candidate, tooldiameter=None, vector=App.Vector(0, 0, 1)): + """ + checks if a flat face or edge is drillable + """ + + matchToolDiameter = tooldiameter is not None + matchVector = vector is not None + PathLog.debug( + "\n match tool diameter {} \n match vector {}".format( + matchToolDiameter, matchVector + ) + ) + + if candidate.ShapeType == "Face": + if not type(candidate.Surface) == Part.Plane: + PathLog.debug("Drilling on non-planar faces not supported") + return False + + if ( + len(candidate.Edges) == 1 and type(candidate.Edges[0].Curve) == Part.Circle + ): # Regular circular face + edge = candidate.Edges[0] + elif ( + len(candidate.Edges) == 2 + and type(candidate.Edges[0].Curve) == Part.Circle + and type(candidate.Edges[1].Curve) == Part.Circle + ): # process a donut + e1 = candidate.Edges[0] + e2 = candidate.Edges[1] + edge = e1 if e1.Curve.Radius < e2.Curve.Radius else e2 + else: + PathLog.debug( + "expected a Face with one or two circular edges got a face with {} edges".format( + len(candidate.Edges) + ) + ) + return False + + else: # edge + edge = candidate + if not (isinstance(edge.Curve, Part.Circle) and edge.isClosed()): + PathLog.debug("expected a closed circular edge") + return False + + if not hasattr(edge.Curve, "Radius"): + PathLog.debug("The Feature edge has no radius - Ellipse.") + return False + + if not matchToolDiameter and not matchVector: + return True + + elif matchToolDiameter and tooldiameter / 2 > edge.Curve.Radius: + PathLog.debug("The tool is larger than the target") + return False + + elif matchVector and not (compareVecs(edge.Curve.Axis, vector)): + PathLog.debug("The feature is not aligned with the given vector") + return False + else: + return True + + +def isDrillable(obj, candidate, tooldiameter=None, vector=App.Vector(0, 0, 1)): + """ + Checks candidates to see if they can be drilled at the given vector. + Candidates can be either faces - circular or cylindrical or circular edges. + The tooldiameter can be optionally passed. if passed, the check will return + False for any holes smaller than the tooldiameter. + + vector defaults to (0,0,1) which aligns with the Z axis. By default will return False + for any candidate not drillable in this orientation. Pass 'None' to vector to test whether + the hole is drillable at any orientation. + + obj=Shape + candidate = Face or Edge + tooldiameter=float + vector=App.Vector or None + + """ + PathLog.debug( + "obj: {} candidate: {} tooldiameter {} vector {}".format( + obj, candidate, tooldiameter, vector + ) + ) + + if list == type(obj): + for shape in obj: + if isDrillable(shape, candidate, tooldiameter, vector): + return (True, shape) + return (False, None) + + if candidate.ShapeType not in ["Face", "Edge"]: + raise TypeError("expected a Face or Edge. Got a {}".format(candidate.ShapeType)) + + try: + if candidate.ShapeType == "Face" and isinstance( + candidate.Surface, Part.Cylinder + ): + return isDrillableCylinder(obj, candidate, tooldiameter, vector) + else: + return isDrillableCircle(obj, candidate, tooldiameter, vector) + except TypeError as e: + PathLog.debug(e) + return False + # raise TypeError("{}".format(e)) + + +def compareVecs(vec1, vec2): + """ + compare the two vectors to see if they are aligned for drilling + alignment can indicate the vectors are the same or exactly opposite + """ + + angle = vec1.getAngle(vec2) + angle = 0 if math.isnan(angle) else math.degrees(angle) + PathLog.debug("vector angle: {}".format(angle)) + return numpy.isclose(angle, 0, rtol=1e-05, atol=1e-06) or numpy.isclose( + angle, 180, rtol=1e-05, atol=1e-06 + ) + + +def getDrillableTargets(obj, ToolDiameter=None, vector=App.Vector(0, 0, 1)): + """ + Returns a list of tuples for drillable subelements from the given object + [(obj,'Face1'),(obj,'Face3')] + + Finds cylindrical faces that are larger than the tool diameter (if provided) and + oriented with the vector. If vector is None, all drillables are returned + + """ + + shp = obj.Shape + + results = [] + for i in range(1, len(shp.Faces)): + fname = "Face{}".format(i) + PathLog.debug(fname) + candidate = obj.getSubObject(fname) + + if not isinstance(candidate.Surface, Part.Cylinder): + continue + + try: + drillable = isDrillable( + shp, candidate, tooldiameter=ToolDiameter, vector=vector + ) + PathLog.debug("fname: {} : drillable {}".format(fname, drillable)) + except Exception as e: + PathLog.debug(e) + continue + + if drillable: + results.append((obj, fname)) + + return results diff --git a/src/Mod/Path/PathTests/Drilling_1.FCStd b/src/Mod/Path/PathTests/Drilling_1.FCStd new file mode 100644 index 0000000000..8ad87ac69a Binary files /dev/null and b/src/Mod/Path/PathTests/Drilling_1.FCStd differ diff --git a/src/Mod/Path/PathTests/TestPathDrillable.py b/src/Mod/Path/PathTests/TestPathDrillable.py new file mode 100644 index 0000000000..76728b0149 --- /dev/null +++ b/src/Mod/Path/PathTests/TestPathDrillable.py @@ -0,0 +1,313 @@ +# -*- 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 Path +import FreeCAD as App +import PathScripts.PathLog as PathLog +import PathTests.PathTestUtils as PathTestUtils +import PathScripts.drillableLib as drillableLib + + +if False: + PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule()) + PathLog.trackModule(PathLog.thisModule()) +else: + PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) + +class TestPathDrillable(PathTestUtils.PathTestBase): + def setUp(self): + self.doc = App.open(App.getHomePath() + "/Mod/Path/PathTests/Drilling_1.FCStd") + self.obj = self.doc.getObject("Pocket010") + + def tearDown(self): + App.closeDocument(self.doc.Name) + + def test00(self): + """Test CompareVecs""" + + # Vec and origin + v1 = App.Vector(0, 0, 10) + v2 = App.Vector(0, 0, 0) + self.assertTrue(drillableLib.compareVecs(v1, v2)) + + # two valid vectors + v1 = App.Vector(0, 10, 0) + v2 = App.Vector(0, 20, 0) + self.assertTrue(drillableLib.compareVecs(v1, v2)) + + # two valid vectors not aligned + v1 = App.Vector(0, 10, 0) + v2 = App.Vector(10, 0, 0) + self.assertFalse(drillableLib.compareVecs(v1, v2)) + + def test10(self): + """Test isDrillable""" + + # Invalid types + candidate = self.obj.getSubObject("Vertex1") + self.assertRaises( + TypeError, lambda: drillableLib.isDrillable(self.obj.Shape, candidate) + ) + + # # partial cylinder + # candidate = self.obj.getSubObject("Face10") + # self.assertRaises( + # TypeError, lambda: drillableLib.isDrillable(self.obj.Shape, candidate) + # ) + + # Test cylinder faces + + # thru-hole + candidate = self.obj.getSubObject("Face25") + + # Typical drilling + self.assertTrue(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Drilling with smaller bit + self.assertTrue( + drillableLib.isDrillable(self.obj.Shape, candidate, tooldiameter=20) + ) + + # Drilling with bit too large + self.assertFalse( + drillableLib.isDrillable(self.obj.Shape, candidate, tooldiameter=30) + ) + + # off-axis hole + candidate = self.obj.getSubObject("Face42") + + # Typical drilling + self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertTrue( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + # Passing explicit vector + self.assertTrue( + drillableLib.isDrillable( + self.obj.Shape, candidate, vector=App.Vector(0, 1, 0) + ) + ) + + # Drilling with smaller bit + self.assertTrue( + drillableLib.isDrillable( + self.obj.Shape, candidate, tooldiameter=10, vector=App.Vector(0, 1, 0) + ) + ) + + # Drilling with bit too large + self.assertFalse( + drillableLib.isDrillable( + self.obj.Shape, candidate, tooldiameter=30, vector=App.Vector(0, 1, 0) + ) + ) + + # ellipse hole + candidate = self.obj.getSubObject("Face20") + + # Typical drilling + self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertFalse( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + # raised cylinder + candidate = self.obj.getSubObject("Face30") + + # Typical drilling + self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertFalse( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + + # cylinder on slope + candidate = self.obj.getSubObject("Face26") + # Typical drilling + self.assertTrue(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertTrue( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + # Circular Faces + candidate = self.obj.getSubObject("Face51") + + # Typical drilling + self.assertTrue(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertTrue( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + # Passing explicit vector + self.assertTrue( + drillableLib.isDrillable( + self.obj.Shape, candidate, vector=App.Vector(0, 0, 1) + ) + ) + + # Drilling with smaller bit + self.assertTrue( + drillableLib.isDrillable(self.obj.Shape, candidate, tooldiameter=10) + ) + + # Drilling with bit too large + self.assertFalse( + drillableLib.isDrillable(self.obj.Shape, candidate, tooldiameter=30) + ) + + # off-axis circular face hole + candidate = self.obj.getSubObject("Face54") + + # Typical drilling + self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertTrue( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + # Passing explicit vector + self.assertTrue( + drillableLib.isDrillable( + self.obj.Shape, candidate, vector=App.Vector(0, 1, 0) + ) + ) + + # raised face + candidate = self.obj.getSubObject("Face45") + # Typical drilling + self.assertTrue(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertTrue( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + # interrupted Face + candidate = self.obj.getSubObject("Face46") + # Typical drilling + self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertFalse( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + # donut face + candidate = self.obj.getSubObject("Face44") + # Typical drilling + self.assertTrue(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertTrue( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + # Test edges + # circular edge + candidate = self.obj.getSubObject("Edge53") + + # Typical drilling + self.assertTrue(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertTrue( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + # Passing explicit vector + self.assertTrue( + drillableLib.isDrillable( + self.obj.Shape, candidate, vector=App.Vector(0, 0, 1) + ) + ) + + # Drilling with smaller bit + self.assertTrue( + drillableLib.isDrillable(self.obj.Shape, candidate, tooldiameter=10) + ) + + # Drilling with bit too large + self.assertFalse( + drillableLib.isDrillable(self.obj.Shape, candidate, tooldiameter=30) + ) + + # off-axis circular edge + candidate = self.obj.getSubObject("Edge72") + + # Typical drilling + self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertTrue( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + # Passing explicit vector + self.assertTrue( + drillableLib.isDrillable( + self.obj.Shape, candidate, vector=App.Vector(0, 1, 0) + ) + ) + + # incomplete circular edge + candidate = self.obj.getSubObject("Edge108") + # Typical drilling + self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertFalse( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + # elliptical edge + candidate = self.obj.getSubObject("Edge54") + # Typical drilling + self.assertFalse(drillableLib.isDrillable(self.obj.Shape, candidate)) + + # Passing None as vector + self.assertFalse( + drillableLib.isDrillable(self.obj.Shape, candidate, vector=None) + ) + + + def test20(self): + """Test getDrillableTargets""" + results = drillableLib.getDrillableTargets(self.obj) + self.assertEqual(len(results), 15) + + results = drillableLib.getDrillableTargets(self.obj, vector=None) + self.assertEqual(len(results), 18) + + results = drillableLib.getDrillableTargets(self.obj, ToolDiameter= 20, vector=None) + self.assertEqual(len(results), 5) diff --git a/src/Mod/Path/PathTests/TestPathHelpers.py b/src/Mod/Path/PathTests/TestPathHelpers.py new file mode 100644 index 0000000000..653fafabad --- /dev/null +++ b/src/Mod/Path/PathTests/TestPathHelpers.py @@ -0,0 +1,139 @@ +# -*- 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 PathFeedRate +import PathMachineState +import PathScripts.PathGeom as PathGeom +import PathScripts.PathToolController as PathToolController +import PathScripts.PathUtils as PathUtils + +from PathTests.PathTestUtils import PathTestBase + + +class TestPathHelpers(PathTestBase): + def setUp(self): + self.doc = FreeCAD.newDocument("TestPathUtils") + + c1 = Path.Command("G0 Z10") + c2 = Path.Command("G0 X20 Y10") + c3 = Path.Command("G1 X20 Y10 Z5") + c4 = Path.Command("G1 X20 Y20") + + self.commandlist = [c1, c2, c3, c4] + + def tearDown(self): + FreeCAD.closeDocument("TestPathUtils") + + def test00(self): + """Test that FeedRate Helper populates horiz and vert feed rate based on TC""" + t = Path.Tool("test", "5.0") + tc = PathToolController.Create("TC0", t) + tc.VertRapid = 5 + tc.HorizRapid = 10 + tc.VertFeed = 15 + tc.HorizFeed = 20 + + resultlist = PathFeedRate.setFeedRate(self.commandlist, tc) + print(resultlist) + + self.assertTrue(resultlist[0].Parameters["F"] == 5) + self.assertTrue(resultlist[1].Parameters["F"] == 10) + self.assertTrue(resultlist[2].Parameters["F"] == 15) + self.assertTrue(resultlist[3].Parameters["F"] == 20) + + def test01(self): + """Test that Machine State initializes and stores position correctly""" + + machine = PathMachineState.MachineState() + state = machine.getState() + self.assertTrue(state["X"] == 0) + self.assertTrue(state["Y"] == 0) + self.assertTrue(state["Z"] == 0) + self.assertTrue(machine.WCS == "G54") + + for c in self.commandlist: + result = machine.addCommand(c) + + state = machine.getState() + self.assertTrue(state["X"] == 20) + self.assertTrue(state["Y"] == 20) + self.assertTrue(state["Z"] == 5) + + machine.addCommand(Path.Command("M3 S200")) + self.assertTrue(machine.S == 200) + self.assertTrue(machine.Spindle == "CW") + + machine.addCommand(Path.Command("M4 S200")) + self.assertTrue(machine.Spindle == "CCW") + + machine.addCommand(Path.Command("M2")) + self.assertTrue(machine.Spindle == "off") + self.assertTrue(machine.S == 0) + + machine.addCommand(Path.Command("G57")) + self.assertTrue(machine.WCS == "G57") + + machine.addCommand(Path.Command("M6 T5")) + self.assertTrue(machine.T == 5) + + # Test that non-change commands return false + result = machine.addCommand(Path.Command("G0 X20")) + self.assertFalse(result) + + result = machine.addCommand(Path.Command("G0 X30")) + self.assertTrue(result) + + # Test that Drilling moves are handled correctly + result = machine.addCommand(Path.Command("G81 X50 Y50 Z0")) + state = machine.getState() + self.assertTrue(state["X"] == 50) + self.assertTrue(state["Y"] == 50) + self.assertTrue(state["Z"] == 5) + + def test02(self): + """Test PathUtils filterarcs""" + + # filter a full circle + c = Part.Circle() + c.Radius = 5 + edge = c.toShape() + + results = PathUtils.filterArcs(edge) + self.assertTrue(len(results) == 2) + e1 = results[0] + self.assertTrue(isinstance(e1.Curve, Part.Circle)) + self.assertTrue(PathGeom.pointsCoincide(edge.Curve.Location, e1.Curve.Location)) + self.assertTrue(edge.Curve.Radius == e1.Curve.Radius) + + # filter a 180 degree arc + results = PathUtils.filterArcs(e1) + self.assertTrue(len(results) == 1) + + # Handle a straight segment + v1 = FreeCAD.Vector(0, 0, 0) + v2 = FreeCAD.Vector(10, 0, 0) + l = Part.makeLine(v1, v2) + results = PathUtils.filterArcs(l) + self.assertTrue(len(results) == 0) diff --git a/src/Mod/Path/TestPathApp.py b/src/Mod/Path/TestPathApp.py index 6f34e71c5a..b8f7dda9a0 100644 --- a/src/Mod/Path/TestPathApp.py +++ b/src/Mod/Path/TestPathApp.py @@ -28,14 +28,17 @@ from PathTests.TestPathDeburr import TestPathDeburr from PathTests.TestPathDepthParams import depthTestCases from PathTests.TestPathDressupDogbone import TestDressupDogbone from PathTests.TestPathDressupHoldingTags import TestHoldingTags +from PathTests.TestPathDrillable import TestPathDrillable from PathTests.TestPathDrillGenerator import TestPathDrillGenerator from PathTests.TestPathGeom import TestPathGeom # from PathTests.TestPathHelix import TestPathHelix +from PathTests.TestPathHelpers import TestPathHelpers from PathTests.TestPathLog import TestPathLog from PathTests.TestPathOpTools import TestPathOpTools # from PathTests.TestPathPost import PathPostTestCases from PathTests.TestPathPreferences import TestPathPreferences from PathTests.TestPathPropertyBag import TestPathPropertyBag +from PathTests.TestPathRotationGenerator import TestPathRotationGenerator from PathTests.TestPathSetupSheet import TestPathSetupSheet from PathTests.TestPathStock import TestPathStock from PathTests.TestPathThreadMilling import TestPathThreadMilling @@ -48,19 +51,24 @@ 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 TestPathGeom.__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 TestPathLog.__name__ else True False if TestPathOpTools.__name__ else True +# False if TestPathPost.__name__ else True False if TestPathPreferences.__name__ else True False if TestPathPropertyBag.__name__ else True +False if TestPathRotationGenerator.__name__ else True False if TestPathSetupSheet.__name__ else True False if TestPathStock.__name__ else True False if TestPathThreadMilling.__name__ else True @@ -71,4 +79,4 @@ 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 +