diff --git a/src/Mod/Path/CMakeLists.txt b/src/Mod/Path/CMakeLists.txt index 4a0d5edfc..a130190b0 100644 --- a/src/Mod/Path/CMakeLists.txt +++ b/src/Mod/Path/CMakeLists.txt @@ -52,6 +52,7 @@ SET(PathScripts_SRCS PathScripts/PathSimpleCopy.py PathScripts/PathStock.py PathScripts/PathStop.py + PathScripts/PathHelix.py PathScripts/PathSurface.py PathScripts/PathToolLenOffset.py PathScripts/PathToolLibraryManager.py diff --git a/src/Mod/Path/Gui/Resources/Path.qrc b/src/Mod/Path/Gui/Resources/Path.qrc index bb147e140..59e8f3ce1 100644 --- a/src/Mod/Path/Gui/Resources/Path.qrc +++ b/src/Mod/Path/Gui/Resources/Path.qrc @@ -18,6 +18,7 @@ icons/Path-FaceProfile.svg icons/Path-Face.svg icons/Path-Heights.svg + icons/Path-Helix.svg icons/Path-Hop.svg icons/Path-Inspect.svg icons/Path-Job.svg diff --git a/src/Mod/Path/Gui/Resources/icons/Path-Helix.svg b/src/Mod/Path/Gui/Resources/icons/Path-Helix.svg new file mode 100644 index 000000000..48b2ed309 --- /dev/null +++ b/src/Mod/Path/Gui/Resources/icons/Path-Helix.svg @@ -0,0 +1,548 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/src/Mod/Path/InitGui.py b/src/Mod/Path/InitGui.py index cc8405ef8..397c1d818 100644 --- a/src/Mod/Path/InitGui.py +++ b/src/Mod/Path/InitGui.py @@ -65,6 +65,7 @@ class PathWorkbench (Workbench): from PathScripts import PathCustom from PathScripts import PathInspect from PathScripts import PathSimpleCopy + from PathScripts import PathHelix from PathScripts import PathEngrave from PathScripts import PathSurface from PathScripts import PathSanity @@ -79,7 +80,7 @@ class PathWorkbench (Workbench): projcmdlist = ["Path_Job", "Path_Post", "Path_Inspect", "Path_Sanity"] toolcmdlist = ["Path_ToolLibraryEdit", "Path_LoadTool"] prepcmdlist = ["Path_Plane", "Path_Fixture", "Path_ToolLenOffset", "Path_Comment", "Path_Stop", "Path_FaceProfile", "Path_FacePocket", "Path_Custom", "Path_FromShape"] - twodopcmdlist = ["Path_Contour", "Path_Profile", "Path_Profile_Edges", "Path_Pocket", "Path_Drilling", "Path_Engrave", "Path_MillFace"] + twodopcmdlist = ["Path_Contour", "Path_Profile", "Path_Profile_Edges", "Path_Pocket", "Path_Drilling", "Path_Engrave", "Path_MillFace", "Path_Helix"] threedopcmdlist = ["Path_Surfacing"] modcmdlist = ["Path_Copy", "Path_CompoundExtended", "Path_Array", "Path_SimpleCopy" ] dressupcmdlist = ["PathDressup_Dogbone", "PathDressup_DragKnife"] diff --git a/src/Mod/Path/PathScripts/PathHelix.py b/src/Mod/Path/PathScripts/PathHelix.py new file mode 100644 index 000000000..2325ea16f --- /dev/null +++ b/src/Mod/Path/PathScripts/PathHelix.py @@ -0,0 +1,496 @@ +# -*- coding: utf-8 -*- + +#*************************************************************************** +#* * +#* Copyright (c) 2016 Lorenz Hüdepohl * +#* * +#* 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,Path +from PySide import QtCore,QtGui +from PathScripts import PathUtils +from PathUtils import fmt + +"""Helix Drill object and FreeCAD command""" + +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def translate(context, text, disambig=None): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except AttributeError: + def translate(context, text, disambig=None): + return QtGui.QApplication.translate(context, text, disambig) + +def hollow_cylinder(cyl): + """Test if this is a hollow cylinder""" + from Part import Circle + circle1 = None + line = None + for edge in cyl.Edges: + if isinstance(edge.Curve, Circle): + if circle1 is None: + circle1 = edge + else: + circle2 = edge + else: + line = edge + center = (circle1.Curve.Center + circle2.Curve.Center).scale(0.5, 0.5, 0.5) + p = (circle1.valueAt(circle1.ParameterRange[0]) + circle2.valueAt(circle1.ParameterRange[0])).scale(0.5, 0.5, 0.5) + to_outside = (p - center).normalize() + u, v = cyl.Surface.parameter(p) + normal = cyl.normalAt(u, v).normalize() + + cos_a = to_outside.dot(normal) + + if cos_a > 1.0 - 1e-12: + return False + elif cos_a < -1.0 + 1e-12: + return True + else: + raise Exception("Strange cylinder encountered, cannot determine if it is hollow or not") + +def z_cylinder(cyl): + """ Test if cylinder is aligned to z-Axis""" + if cyl.Surface.Axis.x != 0.0: + return False + if cyl.Surface.Axis.y != 0.0: + return False + return True + + +def connected(edge, face): + for otheredge in face.Edges: + if edge.isSame(otheredge): + return True + return False + + +def helix_cut(center, r_out, r_in, dr, zmax, zmin, dz, safe_z, tool_diameter, vfeed, hfeed, direction, startside): + """ + center: 2-tuple + (x0,y0) coordinates of center + r_out, r_in: floats + radial range, cut from outer radius r_out in layers of dr to inner radius r_in + zmax, zmin: floats + z-range, cut from zmax in layers of dz down to zmin + safe_z: float + safety layer height + tool_diameter: float + Width of tool + """ + from numpy import ceil, allclose, linspace + + out = "(helix_cut <{0}, {1}>, {2})".format(center[0], center[1], ", ".join(map(str, (r_out, r_in, dr, zmax, zmin, dz, safe_z, tool_diameter)))) + + x0, y0 = center + nz = int(ceil(2*(zmax - zmin)/dz)) + dz = (zmax - zmin) / nz + if dr > tool_diameter: + FreeCAD.Console.PrintWarning("PathHelix: Warning, shortening dr to tool diameter!\n") + dr = tool_diameter + + def xyz(x=None, y=None, z=None): + out = "" + if x is not None: + out += " X" + fmt(x) + if y is not None: + out += " Y" + fmt(y) + if z is not None: + out += " Z" + fmt(z) + return out + + def rapid(x=None, y=None, z=None): + return "G0" + xyz(x,y,z) + "\n" + + def F(f=None): + return (" F" + fmt(f) if f else "") + + def feed(x=None, y=None, z=None, f=None): + return "G1" + xyz(x,y,z) + F(f) + "\n" + + def arc(x,y,i,j,z,f): + if direction == "CW": + code = "G2" + elif direction == "CCW": + code = "G3" + return code + " I" + fmt(i) + " J" + fmt(j) + " X" + fmt(x) + " Y" + fmt(y) + " Z" + fmt(z) + F(f) + "\n" + + def helix_cut_r(r): + out = "" + out += rapid(x=x0+r,y=y0) + out += rapid(z=zmax + tool_diameter) + out += feed(z=zmax,f=vfeed) + for i in range(1,nz+2): + out += arc(x0-r, y0, i=-r, j=0.0, z = max(zmax - (i - 0.5) * dz, zmin), f=hfeed) + out += arc(x0+r, y0, i=r, j=0.0, z = max(zmax - i * dz, zmin), f=hfeed) + out += feed(z=zmax + tool_diameter, f=vfeed) + out += rapid(z=safe_z) + return out + + assert(r_out > 0.0) + assert(r_in >= 0.0) + + msg = None + if r_out < 0.0: + msg = "r_out < 0" + elif r_in > 0 and r_out - r_in < tool_diameter: + msg = "r_out - r_in = {0} is < tool diameter of {1}".format(r_out - r_in, tool_diamater) + elif r_in == 0.0 and not r_out > tool_diameter/2.: + msg = "Cannot drill a hole of diameter {0} with a tool of diameter {1}".format(2 * r_out, tool_diameter) + elif not startside in ["inside", "outside"]: + msg = "Invalid value for parameter 'startside'" + + if msg: + out += "(ERROR: Hole at {0}:".format((x0, y0, zmax)) + msg + ")\n" + FreeCAD.Console.PrintError("PathHelix: Hole at {0}:".format((x0, y0, zmax)) + msg + "\n") + return out + + if r_in > 0: + out += "(annulus mode)\n" + r_out = r_out - tool_diameter/2 + r_in = r_in + tool_diameter/2 + if abs((r_out - r_in) / dr) < 1e-5: + radii = [(r_out + r_in)/2] + else: + nr = max(int(ceil((r_out - r_in)/dr)), 2) + radii = linspace(r_out, r_in, nr) + elif r_out < dr: + out += "(single helix mode)\n" + radii = [r_out - tool_diameter/2] + assert(radii[0] > 0) + else: + out += "(full hole mode)\n" + r_out = r_out - tool_diameter/2 + r_in = dr/2 + + nr = max(1 + int(ceil((r_out - r_in)/dr)), 2) + radii = linspace(r_out, r_in, nr) + assert(all(radii > 0)) + + if startside == "inside": + radii = radii[::-1] + + for r in radii: + out += "(radius {0})\n".format(r) + out += helix_cut_r(r) + + return out + +class ObjectPathHelix: + + def __init__(self,obj): + + # Basic + obj.addProperty("App::PropertyLinkSub","Base","Path",translate("Parent Object","The base geometry of this toolpath")) + obj.addProperty("App::PropertyLinkSubList","Features","Path",translate("Features","Selected features for the drill operation")) + obj.addProperty("App::PropertyBool","Active","Path",translate("Active","Set to False to disable code generation")) + obj.addProperty("App::PropertyString","Comment","Path",translate("Comment","An optional comment for this profile, will appear in G-Code")) + + # Helix specific + obj.addProperty("App::PropertyEnumeration", "Direction", "Helix Drill", + translate("Direction", "The direction of the circular cuts, clockwise (CW), or counter clockwise (CCW)")) + obj.Direction = ['CW','CCW'] + + obj.addProperty("App::PropertyEnumeration", "StartSide", "Helix Drill", + translate("Direction", "Start cutting from the inside or outside")) + obj.StartSide = ['inside','outside'] + obj.addProperty("App::PropertyLength", "DeltaR", "Helix Drill", translate("DeltaR", "Radius increment, must be smaller than tool diameter")) + obj.addProperty("App::PropertyBool", "Recursive", "Helix Drill", translate("Recursive", "If True, drill holes also in any subsequent holes at the bottom of holes that are not fully through")) + + # Depth Properties + obj.addProperty("App::PropertyDistance", "ClearanceHeight", "Depth", translate("Clearance Height","Distance above edge to which to retract the tool")) + obj.addProperty("App::PropertyLength", "StepDown", "Depth", translate("StepDown","Incremental Step Down of Tool")) + obj.addProperty("App::PropertyBool","UseStartDepth","Depth",translate("Use Start Depth","Set to True to manually specify a start depth")) + obj.addProperty("App::PropertyDistance", "StartDepth", "Depth", translate("Start Depth","Starting Depth of Tool - first cut depth in Z")) + obj.addProperty("App::PropertyBool","UseFinalDepth","Depth", translate("Use Final Depth","Set to True to manually specify a final depth")) + obj.addProperty("App::PropertyDistance", "FinalDepth", "Depth", translate("Final Depth","Final Depth of Tool - lowest value in Z")) + obj.addProperty("App::PropertyDistance", "ThroughDepth", "Depth", translate("Through Depth","Add this amount of additional cutting depth to open holes, " + "only used if UseFinalDepth is False")) + + # Feed Properties + obj.addProperty("App::PropertySpeed", "VertFeed", "Feed", translate("Vert Feed","Feed rate for vertical moves")) + obj.addProperty("App::PropertySpeed", "HorizFeed", "Feed", translate("Horiz Feed","Feed rate for horizontal moves")) + + # The current tool number, read-only + # this is apparently used internally, to keep track of tool chagnes + obj.addProperty("App::PropertyIntegerConstraint","ToolNumber","Tool",translate("PathProfile","The current tool in use")) + obj.ToolNumber = (0,0,1000,1) + obj.setEditorMode('ToolNumber',1) #make this read only + + obj.Proxy = self + + def __getstate__(self): + return None + + def __setstate__(self,state): + return None + + def execute(self,obj): + from Part import Circle, Cylinder, Plane + if obj.Base: + if not obj.Active: + obj.Path = Path.Path("(helix cut operation inactive)") + obj.ViewObject.Visibility = False + return + + if not len(obj.InList) > 0: + FreeCAD.Console.PrintError("PathHelix: Operation is not part of a project\n") + obj.Path = Path.Path("(helix cut operation not part of any project)") + obj.ViewObject.Visibility = False + return + + project = obj.InList[0] + obj.ToolNumber = int(PathUtils.changeTool(obj,project)) + tool = PathUtils.getTool(obj,obj.ToolNumber) + + if not tool: + FreeCAD.Console.PrintError("PathHelix: No tool selected for helix cut operation, insert a tool change operation first\n") + obj.Path = Path.Path("(ERROR: no tool selected for helix cut operation)") + return + + def connected_cylinders(base, edge): + cylinders = [] + for face in base.Shape.Faces: + if isinstance(face.Surface, Cylinder): + if connected(edge, face): + if z_cylinder(face): + cylinders.append((base, face)) + return cylinders + + cylinders = [] + + for base, feature in obj.Features: + subobj = getattr(base.Shape, feature) + if subobj.ShapeType =='Face': + if isinstance(subobj.Surface, Cylinder): + if z_cylinder(subobj): + cylinders.append((base, subobj)) + else: + # brute force triple-loop as FreeCAD does not expose + # any topology information... + for edge in subobj.Edges: + cylinders.extend(filter(lambda b_c: hollow_cylinder(b_c[1]), (connected_cylinders(base, edge)))) + + if subobj.ShapeType == 'Edge': + cylinders.extend(connected_cylinders(base, subobj)) + + output = '(helix cut operation' + if obj.Comment: + output += ', '+ str(obj.Comment)+')\n' + else: + output += ')\n' + + output += "G0 Z" + fmt(obj.Base[0].Shape.BoundBox.ZMax + float(obj.ClearanceHeight)) + + drill_jobs = [] + + for base, cylinder in cylinders: + xc, yc, zc = cylinder.Surface.Center + if obj.UseStartDepth: + zmax = obj.StartDepth.Value + else: + zmax = cylinder.BoundBox.ZMax + + if obj.Recursive: + cur_z = zmax + jobs = [] + + while cylinder: + # Find other edge of current cylinder + other_edge = None + for edge in cylinder.Edges: + if isinstance(edge.Curve, Circle) and edge.Curve.Center.z != cur_z: + other_edge = edge + break + + next_z = other_edge.Curve.Center.z + dz = next_z - cur_z + r = cylinder.Surface.Radius + print cur_z, dz, r + + if dz < 0: + # This is a closed hole if the face connecting to the current cylinder at next_z has + # the cylinder's edge as its OuterWire + closed = None + for face in base.Shape.Faces: + if connected(other_edge, face) and not face.isSame(cylinder.Faces[0]): + wire = face.OuterWire + if len(wire.Edges) == 1 and wire.Edges[0].isSame(other_edge): + closed = True + else: + closed = False + + if closed is None: + raise Exception("Cannot determine if this cylinder is closed on the z = {0} side".format(next_z)) + + jobs.append(dict(xc=xc, yc=yc, zmin=next_z, zmax=cur_z, r_out=r, r_in=0.0, closed=closed)) + + elif dz > 0: + new_jobs = [] + for job in jobs: + if job["zmin"] < next_z < job["zmax"]: + # split this job + job1 = dict(job) + job2 = dict(job) + job1["zmin"] = next_z + job2["zmax"] = next_z + job2["r_in"] = r + new_jobs.append(job1) + new_jobs.append(job2) + else: + new_jobs.append(job) + jobs = new_jobs + else: + FreeCAD.Console.PrintWarning("PathHelix: Encountered cylinder with zero height\n") + break + + cur_z = next_z + cylinder = None + faces = [] + for face in base.Shape.Faces: + if connected(other_edge, face): + if isinstance(face.Surface, Plane): + faces.append(face) + face, = faces + for edge in face.Edges: + if not edge.isSame(other_edge): + for base, other_cylinder in connected_cylinders(base, edge): + if other_cylinder.Surface.Center.x == xc and other_cylinder.Surface.Center.y == yc and other_cylinder.Surface.Radius < r: + cylinder = other_cylinder + break + + if obj.UseFinalDepth: + jobs[-1]["zmin"] = obj.FinalDepth.Value + else: + if not jobs[-1]["closed"]: + jobs[-1]["zmin"] -= obj.ThroughDepth.Value + + drill_jobs.extend(jobs) + else: + if obj.UseFinalDepth: + zmin = obj.FinalDepth.Value + else: + zmin = cylinder.BoundBox.ZMin - obj.ThroughDepth.Value + drill_jobs.append(dict(xc=xc, yc=yc, zmin=zmin, zmax=zmax, r_out=cylinder.Surface.Radius, r_in=0.0)) + + for job in drill_jobs: + output += helix_cut((job["xc"], job["yc"]), job["r_out"], job["r_in"], obj.DeltaR.Value, + job["zmax"], job["zmin"], obj.StepDown.Value, + job["zmax"] + obj.ClearanceHeight.Value, tool.Diameter, + obj.VertFeed.Value, obj.HorizFeed.Value, obj.Direction, obj.StartSide) + output += '\n' + + obj.Path = Path.Path(output) + if obj.ViewObject: + obj.ViewObject.Visibility = True + + +class ViewProviderPathHelix: + def __init__(self,vobj): + vobj.Proxy = self + + def attach(self,vobj): + self.Object = vobj.Object + return + + def getIcon(self): + return ":/icons/Path-Helix.svg" + + def __getstate__(self): + return None + + def __setstate__(self,state): + return None + + +class CommandPathHelix: + def GetResources(self): + return {'Pixmap' : 'Path-Helix', + 'MenuText': QtCore.QT_TRANSLATE_NOOP("PathHelix","PathHelix"), + 'ToolTip': QtCore.QT_TRANSLATE_NOOP("PathHelix","Creates a helix cut from selected circles")} + + def IsActive(self): + return not FreeCAD.ActiveDocument is None + + def Activated(self): + import FreeCADGui + import Path + from PathScripts import PathUtils, PathHelix + + selection = FreeCADGui.Selection.getSelectionEx() + + if not len(selection) == 1: + FreeCAD.Console.PrintError("Only considering first object for PathHelix!\n") + selection = selection[0] + + if not len(selection.SubElementNames) > 0: + FreeCAD.Console.PrintError("Select a face or circles to create helix cuts\n") + + # register the transaction for the undo stack + try: + FreeCAD.ActiveDocument.openTransaction(translate("PathHelix","Create a helix cut")) + FreeCADGui.addModule("PathScripts.PathHelix") + + obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython","PathHelix") + PathHelix.ObjectPathHelix(obj) + PathHelix.ViewProviderPathHelix(obj.ViewObject) + + obj.Base = selection.Object + obj.Features = [(selection.Object, subobj) for subobj in selection.SubElementNames] + obj.DeltaR = 1.0 + + project = PathUtils.addToProject(obj) + tl = PathUtils.changeTool(obj,project) + if tl: + obj.ToolNumber = tl + tool = PathUtils.getTool(obj,obj.ToolNumber) + if tool: + # start with 25% overlap + obj.DeltaR = tool.Diameter * 0.75 + + obj.Active = True + obj.Comment = "" + + obj.Direction = "CW" + obj.StartSide = "inside" + + obj.ClearanceHeight = 10.0 + obj.StepDown = 1.0 + obj.UseStartDepth = False + obj.StartDepth = 1.0 + obj.UseFinalDepth = False + obj.FinalDepth = 0.0 + obj.ThroughDepth = 0.0 + obj.Recursive = True + + obj.VertFeed = 0.0 + obj.HorizFeed = 0.0 + + # commit + FreeCAD.ActiveDocument.commitTransaction() + + except: + FreeCAD.ActiveDocument.abortTransaction() + raise + + FreeCAD.ActiveDocument.recompute() + +if FreeCAD.GuiUp: + import FreeCADGui + FreeCADGui.addCommand('Path_Helix',CommandPathHelix())