# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * Copyright (c) 2018 Kresimir Tusek * # * Copyright (c) 2019-2021 Schildkroet * # * * # * This file is part of the FreeCAD CAx development system. * # * * # * This library is free software; you can redistribute it and/or * # * modify it under the terms of the GNU Library General Public * # * License as published by the Free Software Foundation; either * # * version 2 of the License, or (at your option) any later version. * # * * # * This library 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 library; see the file COPYING.LIB. If not, * # * write to the Free Software Foundation, Inc., 59 Temple Place, * # * Suite 330, Boston, MA 02111-1307, USA * # * * # *************************************************************************** # NOTE: "isNull() note" # After performing cut operations, checking the resulting shape.isNull() will # sometimes return False even when the resulting shape is infinitesimal and # further operations with it will raise exceptions. Instead checking if the # shape.Wires list is non-empty bypasses this issue. import Path import Path.Op.Base as PathOp import PathScripts.PathUtils as PathUtils import FreeCAD import time import json import math import area from PySide.QtCore import QT_TRANSLATE_NOOP if FreeCAD.GuiUp: from pivy import coin import FreeCADGui __doc__ = "Class and implementation of the Adaptive CAM operation." # lazily loaded modules from lazy_loader.lazy_loader import LazyLoader Part = LazyLoader("Part", globals(), "Part") TechDraw = LazyLoader("TechDraw", globals(), "TechDraw") FeatureExtensions = LazyLoader("Path.Op.FeatureExtension", globals(), "Path.Op.FeatureExtension") DraftGeomUtils = LazyLoader("DraftGeomUtils", globals(), "DraftGeomUtils") if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) translate = FreeCAD.Qt.translate def convertTo2d(pathArray): output = [] for path in pathArray: pth2 = [] for edge in path: for pt in edge: pth2.append([pt[0], pt[1]]) output.append(pth2) return output sceneGraph = None scenePathNodes = [] # for scene cleanup afterwards topZ = 10 # Constants to avoid magic numbers in the code _ADAPTIVE_MIN_STEPDOWN = 0.1 def sceneDrawPath(path, color=(0, 0, 1)): coPoint = coin.SoCoordinate3() pts = [] for pt in path: pts.append([pt[0], pt[1], topZ]) coPoint.point.setValues(0, len(pts), pts) ma = coin.SoBaseColor() ma.rgb = color li = coin.SoLineSet() li.numVertices.setValue(len(pts)) pathNode = coin.SoSeparator() pathNode.addChild(coPoint) pathNode.addChild(ma) pathNode.addChild(li) sceneGraph.addChild(pathNode) scenePathNodes.append(pathNode) # for scene cleanup afterwards def sceneClean(): for n in scenePathNodes: sceneGraph.removeChild(n) del scenePathNodes[:] def discretize(edge, flipDirection=False): pts = edge.discretize(Deflection=0.002) if flipDirection: pts.reverse() return pts def GenerateGCode(op, obj, adaptiveResults): if not adaptiveResults or not adaptiveResults[0]["AdaptivePaths"]: return # minLiftDistance = op.tool.Diameter helixRadius = 0 for region in adaptiveResults: p1 = region["HelixCenterPoint"] p2 = region["StartPoint"] helixRadius = max(math.dist(p1[:2], p2[:2]), helixRadius) stepDown = max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN) length = 2 * math.pi * helixRadius obj.HelixAngle = min(89.99, max(obj.HelixAngle.Value, 0.01)) obj.HelixConeAngle = max(obj.HelixConeAngle, 0) helixAngleRad = math.radians(obj.HelixAngle) depthPerOneCircle = length * math.tan(helixAngleRad) if obj.HelixMaxStepdown.Value != 0 and obj.HelixMaxStepdown.Value < depthPerOneCircle: depthPerOneCircle = obj.HelixMaxStepdown.Value stepUp = max(obj.LiftDistance.Value, 0) # TODO: finishStep is of limited utility with how regions are now broken # up based on the model geometry- the "finish" step gets applied to each # region separately, which results in excessive "finish" steps being taken # where they really need not be. Leaving stock in Z generally makes more # sense, but both technically have their uses, so leaving this here as # option. Implementing flat area detection would make better use of both. finishStep = min(obj.FinishDepth.Value, stepDown) if hasattr(obj, "FinishDepth") else 0.0 # Track Z position to determine when changing height is necessary prior to a move lz = obj.StartDepth.Value for region in adaptiveResults: passStartDepth = region.get("TopDepth", obj.StartDepth.Value) depthParams = PathUtils.depth_params( clearance_height=obj.ClearanceHeight.Value, safe_height=obj.SafeHeight.Value, start_depth=passStartDepth, step_down=stepDown, z_finish_step=finishStep, final_depth=region.get("BottomDepth", obj.FinalDepth.Value), user_depths=None, ) for passEndDepth in depthParams.data: pass_start_angle = math.atan2( region["StartPoint"][1] - region["HelixCenterPoint"][1], region["StartPoint"][0] - region["HelixCenterPoint"][0], ) passDepth = passStartDepth - passEndDepth p1 = region["HelixCenterPoint"] p2 = region["StartPoint"] helixRadius = math.dist(p1[:2], p2[:2]) # Helix ramp if helixRadius > 0.01: r = helixRadius - 0.01 op.commandlist.append(Path.Command("(Helix to depth: %f)" % passEndDepth)) if obj.UseHelixArcs is False: helix_down_angle = passDepth / depthPerOneCircle * 2 * math.pi r_bottom = r - (passStartDepth - passEndDepth) * math.tan( math.radians(obj.HelixConeAngle.Value) ) r_bottom = max( r_bottom, r * 0.5 ) # put a limit on how small the cone tip can be step_over = obj.StepOver * 0.01 * op.tool.Diameter.Value spiral_out_angle = (r - r_bottom) / step_over * 2 * math.pi helix_base_angle = pass_start_angle - helix_down_angle - spiral_out_angle helix_angular_progress = 0 helixStart = [ region["HelixCenterPoint"][0] + r * math.cos(helix_base_angle), region["HelixCenterPoint"][1] + r * math.sin(helix_base_angle), ] # rapid move to start point op.commandlist.append(Path.Command("G0", {"Z": obj.ClearanceHeight.Value})) op.commandlist.append( Path.Command( "G0", { "X": helixStart[0], "Y": helixStart[1], "Z": obj.ClearanceHeight.Value, }, ) ) # rapid move to safe height op.commandlist.append( Path.Command( "G0", { "X": helixStart[0], "Y": helixStart[1], "Z": obj.SafeHeight.Value, }, ) ) # move to start depth op.commandlist.append( Path.Command( "G1", { "X": helixStart[0], "Y": helixStart[1], "Z": passStartDepth, "F": op.vertFeed, }, ) ) # helix down while helix_angular_progress < helix_down_angle: progress = helix_angular_progress / helix_down_angle r_current = r * (1 - progress) + r_bottom * progress x = region["HelixCenterPoint"][0] + r_current * math.cos( helix_angular_progress + helix_base_angle ) y = region["HelixCenterPoint"][1] + r_current * math.sin( helix_angular_progress + helix_base_angle ) z = passStartDepth - progress * (passStartDepth - passEndDepth) op.commandlist.append( Path.Command("G1", {"X": x, "Y": y, "Z": z, "F": op.vertFeed}) ) helix_angular_progress = min( helix_angular_progress + math.pi / 16, helix_down_angle ) # spiral out, plus a full extra circle max_angle = helix_down_angle + spiral_out_angle + 2 * math.pi while helix_angular_progress < max_angle: if spiral_out_angle == 0: progress = 1 else: progress = min( 1, (helix_angular_progress - helix_down_angle) / spiral_out_angle ) r_current = r_bottom * (1 - progress) + r * progress x = region["HelixCenterPoint"][0] + r_current * math.cos( helix_angular_progress + helix_base_angle ) y = region["HelixCenterPoint"][1] + r_current * math.sin( helix_angular_progress + helix_base_angle ) z = passEndDepth op.commandlist.append( Path.Command("G1", {"X": x, "Y": y, "Z": z, "F": op.horizFeed}) ) helix_angular_progress = min( helix_angular_progress + math.pi / 16, max_angle ) else: # Use arcs for helix - no conical shape support helixStart = [ region["HelixCenterPoint"][0] + r, region["HelixCenterPoint"][1], ] # rapid move to start point op.commandlist.append(Path.Command("G0", {"Z": obj.ClearanceHeight.Value})) op.commandlist.append( Path.Command( "G0", { "X": helixStart[0], "Y": helixStart[1], "Z": obj.ClearanceHeight.Value, }, ) ) # rapid move to safe height op.commandlist.append( Path.Command( "G0", { "X": helixStart[0], "Y": helixStart[1], "Z": obj.SafeHeight.Value, }, ) ) # move to start depth op.commandlist.append( Path.Command( "G1", { "X": helixStart[0], "Y": helixStart[1], "Z": passStartDepth, "F": op.vertFeed, }, ) ) x = region["HelixCenterPoint"][0] + r y = region["HelixCenterPoint"][1] curDep = passStartDepth while curDep > (passEndDepth + depthPerOneCircle): op.commandlist.append( Path.Command( "G2", { "X": x - (2 * r), "Y": y, "Z": curDep - (depthPerOneCircle / 2), "I": -r, "F": op.vertFeed, }, ) ) op.commandlist.append( Path.Command( "G2", { "X": x, "Y": y, "Z": curDep - depthPerOneCircle, "I": r, "F": op.vertFeed, }, ) ) curDep = curDep - depthPerOneCircle lastStep = curDep - passEndDepth if lastStep > (depthPerOneCircle / 2): op.commandlist.append( Path.Command( "G2", { "X": x - (2 * r), "Y": y, "Z": curDep - (lastStep / 2), "I": -r, "F": op.vertFeed, }, ) ) op.commandlist.append( Path.Command( "G2", { "X": x, "Y": y, "Z": passEndDepth, "I": r, "F": op.vertFeed, }, ) ) else: op.commandlist.append( Path.Command( "G2", { "X": x - (2 * r), "Y": y, "Z": passEndDepth, "I": -r, "F": op.vertFeed, }, ) ) op.commandlist.append( Path.Command( "G1", {"X": x, "Y": y, "Z": passEndDepth, "F": op.vertFeed}, ) ) # one more circle at target depth to make sure center is cleared op.commandlist.append( Path.Command( "G2", { "X": x - (2 * r), "Y": y, "Z": passEndDepth, "I": -r, "F": op.horizFeed, }, ) ) op.commandlist.append( Path.Command( "G2", { "X": x, "Y": y, "Z": passEndDepth, "I": r, "F": op.horizFeed, }, ) ) else: # no helix entry # rapid move to clearance height op.commandlist.append(Path.Command("G0", {"Z": obj.ClearanceHeight.Value})) op.commandlist.append( Path.Command( "G0", { "X": region["StartPoint"][0], "Y": region["StartPoint"][1], "Z": obj.ClearanceHeight.Value, }, ) ) # straight plunge to target depth op.commandlist.append( Path.Command( "G1", { "X": region["StartPoint"][0], "Y": region["StartPoint"][1], "Z": passEndDepth, "F": op.vertFeed, }, ) ) lz = passEndDepth z = obj.ClearanceHeight.Value op.commandlist.append(Path.Command("(Adaptive - depth: %f)" % passEndDepth)) # add adaptive paths for pth in region["AdaptivePaths"]: motionType = pth[0] # [0] contains motion type for pt in pth[1]: # [1] contains list of points x = pt[0] y = pt[1] if motionType == area.AdaptiveMotionType.Cutting: z = passEndDepth if z != lz: op.commandlist.append(Path.Command("G1", {"Z": z, "F": op.vertFeed})) op.commandlist.append( Path.Command("G1", {"X": x, "Y": y, "F": op.horizFeed}) ) elif motionType == area.AdaptiveMotionType.LinkClear: z = passEndDepth + stepUp if z != lz: op.commandlist.append(Path.Command("G0", {"Z": z})) op.commandlist.append(Path.Command("G0", {"X": x, "Y": y})) elif motionType == area.AdaptiveMotionType.LinkNotClear: z = obj.ClearanceHeight.Value if z != lz: op.commandlist.append(Path.Command("G0", {"Z": z})) op.commandlist.append(Path.Command("G0", {"X": x, "Y": y})) lz = z # return to safe height in this Z pass z = obj.ClearanceHeight.Value if z != lz: op.commandlist.append(Path.Command("G0", {"Z": z})) lz = z passStartDepth = passEndDepth # return to safe height in this Z pass z = obj.ClearanceHeight.Value if z != lz: op.commandlist.append(Path.Command("G0", {"Z": z})) lz = z z = obj.ClearanceHeight.Value if z != lz: op.commandlist.append(Path.Command("G0", {"Z": z})) def Execute(op, obj): global sceneGraph global topZ if FreeCAD.GuiUp: sceneGraph = FreeCADGui.ActiveDocument.ActiveView.getSceneGraph() Path.Log.info("*** Adaptive toolpath processing started...\n") # hide old toolpaths during recalculation obj.Path = Path.Path("(Calculating...)") oldObjVisibility = oldJobVisibility = False if FreeCAD.GuiUp: # store old visibility state job = op.getJob(obj) oldObjVisibility = obj.ViewObject.Visibility oldJobVisibility = job.ViewObject.Visibility obj.ViewObject.Visibility = False job.ViewObject.Visibility = False FreeCADGui.updateGui() try: obj.HelixMinDiameterPercent = max(obj.HelixMinDiameterPercent, 10) obj.HelixMaxDiameterPercent = max(obj.HelixMaxDiameterPercent, obj.HelixMinDiameterPercent) helixDiameter = obj.HelixMaxDiameterPercent / 100 * op.tool.Diameter.Value helixMinDiameter = obj.HelixMinDiameterPercent / 100 * op.tool.Diameter.Value topZ = op.stock.Shape.BoundBox.ZMax obj.Stopped = False obj.StopProcessing = False if obj.Tolerance < 0.001: obj.Tolerance = 0.001 # Get list of working edges for adaptive algorithm pathArray = op.pathArray if not pathArray: msg = translate( "CAM", "Adaptive operation couldn't determine the boundary wire. Did you select base geometry?", ) FreeCAD.Console.PrintUserWarning(msg) return path2d = convertTo2d(pathArray) # Use the 2D outline of the stock as the stock # FIXME: This does not account for holes in the middle of stock! outer_wire = TechDraw.findShapeOutline(op.stock.Shape, 1, FreeCAD.Vector(0, 0, 1)) stockPaths = [[discretize(outer_wire)]] stockPath2d = convertTo2d(stockPaths) # opType = area.AdaptiveOperationType.ClearingInside # Commented out per LGTM suggestion if obj.OperationType == "Clearing": if obj.Side == "Outside": opType = area.AdaptiveOperationType.ClearingOutside else: opType = area.AdaptiveOperationType.ClearingInside else: # profiling if obj.Side == "Outside": opType = area.AdaptiveOperationType.ProfilingOutside else: opType = area.AdaptiveOperationType.ProfilingInside keepToolDownRatio = 3.0 if hasattr(obj, "KeepToolDownRatio"): keepToolDownRatio = float(obj.KeepToolDownRatio) # put here all properties that influence calculation of adaptive base paths, inputStateObject = { "tool": float(op.tool.Diameter), "tolerance": float(obj.Tolerance), "geometry": path2d, "stockGeometry": stockPath2d, "stepover": float(obj.StepOver), "effectiveHelixDiameter": float(helixDiameter), "helixMinDiameter": float(helixMinDiameter), "operationType": obj.OperationType, "side": obj.Side, "forceInsideOut": obj.ForceInsideOut, "finishingProfile": obj.FinishingProfile, "keepToolDownRatio": keepToolDownRatio, "stockToLeave": float(obj.StockToLeave), "modelAwareExperiment": obj.ModelAwareExperiment, } inputStateChanged = False adaptiveResults = None if obj.AdaptiveOutputState is not None and obj.AdaptiveOutputState != "": adaptiveResults = obj.AdaptiveOutputState if json.dumps(obj.AdaptiveInputState) != json.dumps(inputStateObject): inputStateChanged = True adaptiveResults = None # progress callback fn, if return true it will stop processing def progressFn(tpaths): if FreeCAD.GuiUp: for ( path ) in tpaths: # path[0] contains the MotionType, #path[1] contains list of points if path[0] == area.AdaptiveMotionType.Cutting: sceneDrawPath(path[1], (0, 0, 1)) else: sceneDrawPath(path[1], (1, 0, 1)) FreeCADGui.updateGui() return obj.StopProcessing start = time.time() if inputStateChanged or adaptiveResults is None: a2d = area.Adaptive2d() a2d.stepOverFactor = 0.01 * obj.StepOver a2d.toolDiameter = float(op.tool.Diameter) a2d.helixRampTargetDiameter = helixDiameter a2d.helixRampMinDiameter = helixMinDiameter a2d.keepToolDownDistRatio = keepToolDownRatio a2d.stockToLeave = float(obj.StockToLeave) a2d.tolerance = float(obj.Tolerance) a2d.forceInsideOut = obj.ForceInsideOut a2d.finishingProfile = obj.FinishingProfile a2d.opType = opType # EXECUTE results = a2d.Execute(stockPath2d, path2d, progressFn) # need to convert results to python object to be JSON serializable adaptiveResults = [] for result in results: adaptiveResults.append( { "HelixCenterPoint": result.HelixCenterPoint, "StartPoint": result.StartPoint, "AdaptivePaths": result.AdaptivePaths, "ReturnMotionType": result.ReturnMotionType, } ) # GENERATE GenerateGCode(op, obj, adaptiveResults) if not obj.StopProcessing: Path.Log.info("*** Done. Elapsed time: %f sec\n\n" % (time.time() - start)) obj.AdaptiveOutputState = adaptiveResults obj.AdaptiveInputState = inputStateObject else: Path.Log.info("*** Processing cancelled (after: %f sec).\n\n" % (time.time() - start)) finally: if FreeCAD.GuiUp: obj.ViewObject.Visibility = oldObjVisibility job.ViewObject.Visibility = oldJobVisibility sceneClean() def ExecuteModelAware(op, obj): global sceneGraph global topZ if FreeCAD.GuiUp: sceneGraph = FreeCADGui.ActiveDocument.ActiveView.getSceneGraph() Path.Log.info("*** Adaptive toolpath processing started...\n") # hide old toolpaths during recalculation obj.Path = Path.Path("(Calculating...)") oldObjVisibility = oldJobVisibility = False if FreeCAD.GuiUp: # store old visibility state job = op.getJob(obj) oldObjVisibility = obj.ViewObject.Visibility oldJobVisibility = job.ViewObject.Visibility obj.ViewObject.Visibility = False job.ViewObject.Visibility = False FreeCADGui.updateGui() try: obj.HelixMinDiameterPercent = max(obj.HelixMinDiameterPercent, 10) obj.HelixMaxDiameterPercent = max(obj.HelixMaxDiameterPercent, obj.HelixMinDiameterPercent) obj.StepOver = max(obj.StepOver, 1) helixDiameter = obj.HelixMaxDiameterPercent / 100 * op.tool.Diameter.Value helixMinDiameter = obj.HelixMinDiameterPercent / 100 * op.tool.Diameter.Value topZ = op.stock.Shape.BoundBox.ZMax obj.Stopped = False obj.StopProcessing = False obj.Tolerance = max(0.001, obj.Tolerance) # NOTE: Reminder that stock is formatted differently than inside/outside! stockPaths = {d: convertTo2d(op.stockPathArray[d]) for d in op.stockPathArray} outsideClearing = area.AdaptiveOperationType.ClearingOutside insideClearing = area.AdaptiveOperationType.ClearingInside outsideProfiling = area.AdaptiveOperationType.ProfilingOutside insideProfiling = area.AdaptiveOperationType.ProfilingInside # List every REGION separately- we can then calculate a toolpath based # on the region. One or more stepdowns may use that same toolpath by # keeping a reference to the region without requiring we calculate the # toolpath once per step down OR forcing all stepdowns of a region into # a single list. regionOps = list() outsidePathArray2dDepthTuples = list() insidePathArray2dDepthTuples = list() # NOTE: Make sure the depth lists are sorted for use in order-by-depth # and order-by-region algorithms below # NOTE: Pretty sure sorting is already guaranteed by how these are # created, but best to not assume that for rdict in op.outsidePathArray: regionOps.append( { "opType": ( outsideClearing if obj.OperationType == "Clearing" else outsideProfiling ), "path2d": convertTo2d(rdict["edges"]), "id": rdict["id"], "children": rdict["children"], # FIXME: Kinda gross- just use this to match up with the # appropriate stockpaths entry... "startdepth": rdict["depths"][0], } ) outsidePathArray2dDepthTuples.append( (sorted(rdict["depths"], reverse=True), regionOps[-1]) ) for rdict in op.insidePathArray: regionOps.append( { "opType": ( insideClearing if obj.OperationType == "Clearing" else insideProfiling ), "path2d": convertTo2d(rdict["edges"]), "id": rdict["id"], "children": rdict["children"], # FIXME: Kinda gross- just use this to match up with the # appropriate stockpaths entry... "startdepth": rdict["depths"][0], } ) insidePathArray2dDepthTuples.append( (sorted(rdict["depths"], reverse=True), regionOps[-1]) ) keepToolDownRatio = 3.0 if hasattr(obj, "KeepToolDownRatio"): keepToolDownRatio = obj.KeepToolDownRatio.Value # These fields are used to determine if toolpaths should be recalculated outsideInputStateObject = { "tool": op.tool.Diameter.Value, "tolerance": obj.Tolerance, "geometry": [ k["path2d"] for k in regionOps if k["opType"] in [outsideClearing, outsideProfiling] ], "stockGeometry": stockPaths, "stepover": obj.StepOver, "effectiveHelixDiameter": helixDiameter, "helixMinDiameter": helixMinDiameter, "operationType": "Clearing", "side": "Outside", "forceInsideOut": obj.ForceInsideOut, "finishingProfile": obj.FinishingProfile, "keepToolDownRatio": keepToolDownRatio, "stockToLeave": obj.StockToLeave.Value, "zStockToLeave": obj.ZStockToLeave.Value, "orderCutsByRegion": obj.OrderCutsByRegion, } insideInputStateObject = { "tool": op.tool.Diameter.Value, "tolerance": obj.Tolerance, "geometry": [ k["path2d"] for k in regionOps if k["opType"] in [insideClearing, insideProfiling] ], "stockGeometry": stockPaths, "stepover": obj.StepOver, "effectiveHelixDiameter": helixDiameter, "helixMinDiameter": helixMinDiameter, "operationType": "Clearing", "side": "Inside", "forceInsideOut": obj.ForceInsideOut, "finishingProfile": obj.FinishingProfile, "keepToolDownRatio": keepToolDownRatio, "stockToLeave": obj.StockToLeave.Value, "zStockToLeave": obj.ZStockToLeave.Value, "orderCutsByRegion": obj.OrderCutsByRegion, "modelAwareExperiment": obj.ModelAwareExperiment, } inputStateObject = [outsideInputStateObject, insideInputStateObject] inputStateChanged = False adaptiveResults = None # If we have a valid... path? Something. Generated, make that # tentatively the output if obj.AdaptiveOutputState: adaptiveResults = obj.AdaptiveOutputState # If ANYTHING in our input-cutting parameters, cutting regions, # etc.- changes, force recalculating if json.dumps(obj.AdaptiveInputState) != json.dumps(inputStateObject): inputStateChanged = True adaptiveResults = None # progress callback fn, if return true it will stop processing def progressFn(tpaths): if FreeCAD.GuiUp: for ( path ) in tpaths: # path[0] contains the MotionType, #path[1] contains list of points if path[0] == area.AdaptiveMotionType.Cutting: sceneDrawPath(path[1], (0, 0, 1)) else: sceneDrawPath(path[1], (1, 0, 1)) FreeCADGui.updateGui() return obj.StopProcessing start = time.time() if inputStateChanged or adaptiveResults is None: # NOTE: Seem to need to create a new a2d for each area when we're # stepping down depths like this. If we don't, it will keep history # from the last region we did. # TODO: QThread/QRunnable trigger Python's global interpreter lock # (GIL). To calculate toolpaths in parallel, making a C++ shim that # takes in the array of regions/stock paths and parallelizes in # C++-land is probably the way to do it. # Create a toolpath for each region to avoid re-calculating for # identical stepdowns for rdict in regionOps: path2d = rdict["path2d"] opType = rdict["opType"] a2d = area.Adaptive2d() a2d.stepOverFactor = 0.01 * obj.StepOver a2d.toolDiameter = op.tool.Diameter.Value a2d.helixRampTargetDiameter = helixDiameter a2d.helixRampMinDiameter = helixMinDiameter a2d.keepToolDownDistRatio = keepToolDownRatio # NOTE: Z stock is handled in our stepdowns a2d.stockToLeave = obj.StockToLeave.Value a2d.tolerance = obj.Tolerance a2d.forceInsideOut = obj.ForceInsideOut a2d.finishingProfile = obj.FinishingProfile a2d.opType = opType rdict["toolpaths"] = a2d.Execute( stockPaths[rdict["startdepth"]], path2d, progressFn ) # Sort regions to cut by either depth or area. # TODO: Bonus points for ordering to minimize rapids cutlist = list() # Region IDs that have been cut already cutids = list() # Create sorted list of unique depths # NOTE: reverse because we cut top-down! depths = list() # NOTE: alltuples is sorted by depth already alltuples = outsidePathArray2dDepthTuples + insidePathArray2dDepthTuples for t in alltuples: depths += [d for d in t[0]] depths = sorted(list(set(depths)), reverse=True) if obj.OrderCutsByRegion: # Translate child ID numbers to an actual reference to the # associated tuple for rdict in regionOps: rdict["childTuples"] = [t for t in alltuples if t[1]["id"] in rdict["children"]] # Helper function to recurse down children def addToCutList(tuples): for k in tuples: if k in cutlist: continue cutlist.append(k) addToCutList(k[1]["childTuples"]) addToCutList(alltuples) else: for d in depths: cutlist += [([d], o[1]) for o in outsidePathArray2dDepthTuples if d in o[0]] cutlist += [([d], i[1]) for i in insidePathArray2dDepthTuples if d in i[0]] # need to convert results to python object to be JSON serializable stepdown = max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN) adaptiveResults = list() for depths, region in cutlist: for result in region["toolpaths"]: # Top depth is the height where the helix starts for a # region. # We want the lowest of 3 possibilities: # - the top of the stock OR # - the region's first cut depth + stepdown OR # - the operation's starting depth # The starting depth option covers the case where the user # has a previous operations that cleared some stock and # wants the adaptive toolpath to pick up where the previous # operation left off. # Regions are only generated where stock needs to be # removed, so we can't start at the cut level- we know # there's material there. # TODO: Due to the adaptive algorithm currently not # processing holes in the stock when finding entry points, # this may result in a helix up to stepdown in height where # one isn't required. This should be fixed in FindEntryPoint # or FindEntryPointOutside in Adaptive.cpp, not bandaged # here. TopDepth = min( topZ, depths[0] + stepdown, obj.StartDepth.Value, ) adaptiveResults.append( { "HelixCenterPoint": result.HelixCenterPoint, "StartPoint": result.StartPoint, "AdaptivePaths": result.AdaptivePaths, "ReturnMotionType": result.ReturnMotionType, "TopDepth": TopDepth, "BottomDepth": depths[-1], } ) # GENERATE GenerateGCode(op, obj, adaptiveResults) if not obj.StopProcessing: Path.Log.info("*** Done. Elapsed time: %f sec\n\n" % (time.time() - start)) obj.AdaptiveOutputState = adaptiveResults obj.AdaptiveInputState = inputStateObject else: Path.Log.info("*** Processing cancelled (after: %f sec).\n\n" % (time.time() - start)) finally: if FreeCAD.GuiUp: obj.ViewObject.Visibility = oldObjVisibility job.ViewObject.Visibility = oldJobVisibility sceneClean() def projectFacesToXY(faces, minEdgeLength=1e-10): """projectFacesToXY(faces, minEdgeLength) Calculates the projection of the provided list of faces onto the XY plane. The returned value is a single shape that may contain multiple faces if there were disjoint projections. Each individual face will be clean, without triangulated geometry, etc., and will be at Z=0 on the XY plane minEdgeLength is provided to (eg) filter out the tips of cones that are internally represented as arbitrarily-small circular faces- using those for additional operations causes problems. """ projdir = FreeCAD.Vector(0, 0, 1) outfaces = [] for f in faces: # Vertical cones and spheres will still have a projection on the XY # plane. Cylinders and flat faces will not. if Path.Geom.isVertical(f) and type(f.Surface) not in [Part.Cone, Part.Sphere]: continue # NOTE: Wires/edges get clipped if we have an "exact fit" bounding box projface = Path.Geom.makeBoundBoxFace(f.BoundBox, offset=1, zHeight=0) # NOTE: Cylinders, cones, and spheres are messy: # - Internal representation of non-truncted cones and spheres includes # the "tip" with a ~0-area closed edge. This is different than the # "isNull() note" at the top in magnitude # - Projecting edges doesn't naively work due to the way seams are handled # - There may be holes at either end that may or may not line up- any # overlap is a hole in the projection if type(f.Surface) in [Part.Cone, Part.Cylinder, Part.Sphere]: # This gets most of the face outline, but since cylinder/cone faces # are hollow, if the ends overlap in the projection there may be a # hole we need to remove from the solid projection oface = Part.makeFace(TechDraw.findShapeOutline(f, 1, projdir)) # "endfacewires" is JUST the end faces of a cylinder/cone, used to # determine if there's a hole we can see through the shape that # should NOT be solid in the projection endfacewires = DraftGeomUtils.findWires( [e for e in f.Edges if not e.isSeam(f) and e.Length > minEdgeLength] ) # Need to verify that there actually is a projection before taking # a wire from the list, else this could nicely be one line. projwires = [] for w in endfacewires: pp = projface.makeParallelProjection(w, projdir).Wires if pp: projwires.append(pp[0]) if len(projwires) > 1: faces = [Part.makeFace(x) for x in projwires] overlap = faces[0].common(faces[1:]) outfaces.append(oface.cut(overlap)) else: outfaces.append(oface) # For other cases, projecting the wires to a plane should suffice else: facewires = list() for w in f.Wires: if w.isClosed(): projwire = projface.makeParallelProjection(w, projdir).Wires[0] if projwire.isClosed(): facewires.append(projwire) if facewires: outfaces.append(Part.makeFace(facewires)) if outfaces: fusion = outfaces[0].fuse(outfaces[1:]) # removeSplitter fixes occasional concatenate issues for some face orders return DraftGeomUtils.concatenate(fusion.removeSplitter()) else: return Part.Shape() def _getSolidProjection(shp, z): """_getSolidProjection(shp, z) Calculates a shape obtained by slicing shp at the height z, then projecting the solids above that height onto a region of proj_face, and creating a simplified face """ bb = shp.BoundBox # Find all faces above the machining depth. This is used to mask future # interior cuts, and the outer wire is used as the external wire bbCutTop = Part.makeBox( bb.XLength, bb.YLength, max(bb.ZLength, bb.ZLength - z), FreeCAD.Vector(bb.XMin, bb.YMin, z), ) aboveSolids = shp.common(bbCutTop).Solids faces = list() for s in aboveSolids: faces += s.Faces return projectFacesToXY(faces) def _workingEdgeHelperRoughing(op, obj, depths): # Final calculated regions- list of dicts with entries: # "region" - actual shape # "depths" - list of depths this region applies to insideRegions = list() outsideRegions = list() # Multiple input solids can be selected- make a single part out of them, # will process each solid separately as appropriate shps = op.model[0].Shape.fuse([k.Shape for k in op.model[1:]]) projdir = FreeCAD.Vector(0, 0, 1) # Take outline of entire model as our baseline machining region. No need to # do this repeatedly inside the loop. modelOutlineFaces = [ Part.makeFace(TechDraw.findShapeOutline(s, 1, projdir)) for s in shps.Solids ] lastdepth = obj.StartDepth.Value for depth in depths: # If we have no stock to machine, just skip all the rest of the math if depth >= op.stock.Shape.BoundBox.ZMax: lastdepth = depth continue # NOTE: To "leave" stock along Z without actually checking any face # depths, we simply slice the model "lower" than our actual cut depth by # the Z stock to leave, which ensures we stay at least that far from the # actual faces stockface = _getSolidProjection(op.stock.Shape, depth - obj.ZStockToLeave.Value) aboveRefined = _getSolidProjection(shps, depth - obj.ZStockToLeave.Value) # Outside is based on the outer wire of the above_faces # Insides are based on the remaining "below" regions, masked by the # "above"- if something is above an area, we can't machine it in 2.5D # OUTSIDE REGIONS # Outside: Take the outer wire of the above faces # NOTE: Exactly one entry per depth (not necessarily one depth entry per # stepdown, however), which is a LIST of the wires we're staying outside # NOTE: Do this FIRST- if any inside regions share enough of an edge # with an outside region for a tool to get through, we want to skip them # for the current stepdown if aboveModelFaces := [ Part.makeFace(TechDraw.findShapeOutline(f, 1, projdir)) for f in aboveRefined.Faces ]: aboveModelFaces = aboveModelFaces[0].fuse(aboveModelFaces[1:]) else: aboveModelFaces = Part.Shape() # If this region exists in our list, it has to be the last entry, due to # proceeding in order and having only one per depth. If it's already # there, replace with the new, deeper depth, else add new # NOTE: Check for NULL regions to not barf on regions between the top of # the model and the top of the stock, which are "outside" of nothing # NOTE: See "isNull() note" at top of file if ( outsideRegions and outsideRegions[-1]["region"].Wires and aboveModelFaces.Wires and not aboveModelFaces.cut(outsideRegions[-1]["region"]).Wires ): outsideRegions[-1]["depths"].append(depth) else: outsideRegions.append({"region": aboveModelFaces, "depths": [depth]}) # NOTE: If you don't care about controlling depth vs region ordering, # you can actually just do everything with "outside" processing, if you # don't remove internal holes from the regions above # INSIDE REGIONS # NOTE: Nothing inside if there's no model above us # NOTE: See "isNull() note" at top of file if aboveModelFaces.Wires: # Remove any overlapping areas already machined from the outside. outsideface = stockface.cut(outsideRegions[-1]["region"].Faces) # NOTE: See "isNull() note" at top of file if outsideface.Wires: belowFaces = [f.cut(outsideface) for f in modelOutlineFaces] else: # NOTE: Doesn't matter here, but ensure we're making a new list so # we don't clobber modelOutlineFaces belowFaces = [f for f in modelOutlineFaces] # This isn't really necessary unless the user inputs bad data- eg, a # min depth above the top of the model. In which case we still want to # clear the stock if belowFaces: # Remove the overhangs from the desired region to cut belowCut = belowFaces[0].fuse(belowFaces[1:]).cut(aboveRefined) # NOTE: See "isNull() note" at top of file if belowCut.Wires: # removeSplitter fixes occasional concatenate issues for # some face orders finalCut = DraftGeomUtils.concatenate(belowCut.removeSplitter()) else: finalCut = Part.Shape() else: # Make a dummy shape if we don't have anything actually below finalCut = Part.Shape() # Split up into individual faces if any are disjoint, then update # insideRegions- either by adding a new entry OR by updating the depth # of an existing entry for f in finalCut.Faces: addNew = True # Brute-force search all existing regions to see if any are the same newtop = lastdepth for rdict in insideRegions: # FIXME: Smarter way to do this than a full cut operation? if not rdict["region"].cut(f).Wires: rdict["depths"].append(depth) addNew = False break if addNew: insideRegions.append({"region": f, "depths": [depth]}) # Update the last depth step lastdepth = depth # end for depth return insideRegions, outsideRegions def _workingEdgeHelperManual(op, obj, depths): # Final calculated regions- list of dicts with entries: # "region" - actual shape # "depths" - list of depths this region applies to insideRegions = list() outsideRegions = list() # User selections, with extensions selectedRegions = list() selectedEdges = list() # Get extensions and identify faces to avoid extensions = FeatureExtensions.getExtensions(obj) avoidFeatures = [e for e in extensions if e.avoid] # Similarly, expand selected regions with extensions for ext in extensions: if not ext.avoid: if wire := ext.getWire(): # NOTE: Can NOT just make a face directly, since that just gives # the outside profile and removes internal holes for f in ext.getExtensionFaces(wire): selectedRegions.extend(f.Faces) for base, subs in obj.Base: for sub in subs: element = base.Shape.getElement(sub) if sub.startswith("Face") and sub not in avoidFeatures: shape = Part.Face(element.OuterWire) if obj.UseOutline else element selectedRegions.append(shape) # Omit vertical edges, since they project to nothing in the XY plane # and cause processing failures later if included elif sub.startswith("Edge") and not Path.Geom.isVertical(element): selectedEdges.append(element) # Multiple input solids can be selected- make a single part out of them, # will process each solid separately as appropriate shps = op.model[0].Shape.fuse([k.Shape for k in op.model[1:]]) # Make a face to project onto # NOTE: Use 0 as the height, since that's what TechDraw.findShapeOutline # uses, which we use to find the machining boundary, and the actual depth # is tracked separately. # NOTE: Project to the PART bounding box- with some padding- not the stock, # since the stock may be smaller than the part projface = Path.Geom.makeBoundBoxFace(shps.BoundBox, offset=1, zHeight=0) projdir = FreeCAD.Vector(0, 0, 1) # When looking for selected edges, project to a single plane first, THEN try # to find wires. Take all the resulting wires and make faces in one shot to # make bullseye-style cutouts where selected wires nest. edgefaces = list() if selectedEdges: pp = [projface.makeParallelProjection(e, projdir).Wires[0] for e in selectedEdges] ppe = list() for w in pp: ppe += w.Edges edgeWires = DraftGeomUtils.findWires(ppe) edgefaces = Part.makeFace(edgeWires).Faces selectedRefined = projectFacesToXY(selectedRegions + edgefaces) # If the user selected only faces that don't have an XY projection AND no # edges, give a useful message if not selectedRefined.Wires: Path.Log.warning("Selected faces/wires have no projection on the XY plane") return insideRegions, outsideRegions lastdepth = obj.StartDepth.Value for depth in depths: # If our depth is above the top of the stock, there's nothing to machine if depth >= op.stock.Shape.BoundBox.ZMax: lastdepth = depth continue # NOTE: See note in _workingEdgeHelperRoughing- tl;dr slice stock # lower than cut depth to effectively leave (at least) obj.ZStockToLeave aboveRefined = _getSolidProjection(shps, depth - obj.ZStockToLeave.Value) # Create appropriate tuples and add to list, processing inside/outside # as requested by operation if obj.Side == "Outside": # Outside is based on the outer wire of the faces of aboveRefined # Insides are based on the remaining "below" regions, masked by the # "above"- if something is above an area, we can't machine it in 2.5D # Outside: Take the outer wire of the above faces, added to selected # edges and regions # NOTE: Exactly one entry per depth (not necessarily one depth entry per # stepdown, however), which is a LIST of the wires we're staying outside # NOTE: Do this FIRST- if any inside regions share enough of an edge # with an outside region for a tool to get through, we want to skip them # for the current stepdown # NOTE: This check naively seems unnecessary, but it's possible the # user selected a vertical face as the only face to stay outside of, # and we're above the model, causing keepOutFaces to be empty if keepOutFaces := [ Part.makeFace(TechDraw.findShapeOutline(f, 1, projdir)) for f in aboveRefined.Faces + selectedRefined.Faces ]: finalMerge = keepOutFaces[0].fuse(keepOutFaces[1:]) else: finalMerge = selectedRefined # Without removeSplitter(), concatenate will sometimes fail when # trying to merge faces that are (eg) connected A-B and B-C, # seemingly when trying to merge A-C regions = DraftGeomUtils.concatenate(finalMerge.removeSplitter()) # If this region exists in our list, it has to be the last entry, due to # proceeding in order and having only one per depth. If it's already # there, replace with the new, deeper depth, else add new # NOTE: Do NOT need a check for whether outsideRegions[-1]["region"] # is valid since we have a user-specified region regardless of depth # NOTE: See "isNull() note" at top of file if ( outsideRegions and regions.Wires and not regions.cut(outsideRegions[-1]["region"]).Wires ): outsideRegions[-1]["depths"].append(depth) else: outsideRegions.append({"region": regions, "depths": [depth]}) # Inside # For every area selected by the user, project to a plane # NOTE: See "isNull() note" at top of file else: if aboveRefined.Wires: finalCut = selectedRefined.cut(aboveRefined) else: finalCut = selectedRefined # Split up into individual faces if any are disjoint, then update # insideRegions- either by adding a new entry OR by updating the depth # of an existing entry for f in finalCut.Faces: addNew = True # Brute-force search all existing regions to see if any are the same newtop = lastdepth for rdict in insideRegions: # FIXME: Smarter way to do this than a full cut operation? if not rdict["region"].cut(f).Wires: rdict["depths"].append(depth) addNew = False break if addNew: insideRegions.append({"region": f, "depths": [depth]}) # Update the last depth step lastdepth = depth # end for depth return insideRegions, outsideRegions def _get_working_edges(op, obj): """_get_working_edges(op, obj)... Compile all working edges from the Base Geometry selection (obj.Base) for the current operation. Additional modifications to selected region(face), such as extensions, should be placed within this function. """ all_regions = list() edge_list = list() avoidFeatures = list() rawEdges = list() # Get extensions and identify faces to avoid extensions = FeatureExtensions.getExtensions(obj) for e in extensions: if e.avoid: avoidFeatures.append(e.feature) # Get faces selected by user for base, subs in obj.Base: for sub in subs: if sub.startswith("Face"): if sub not in avoidFeatures: if obj.UseOutline: face = base.Shape.getElement(sub) # get outline with wire_A method used in PocketShape, but it does not play nicely later # wire_A = TechDraw.findShapeOutline(face, 1, FreeCAD.Vector(0.0, 0.0, 1.0)) wire_B = face.OuterWire shape = Part.Face(wire_B) else: shape = base.Shape.getElement(sub) all_regions.append(shape) elif sub.startswith("Edge"): # Save edges for later processing rawEdges.append(base.Shape.getElement(sub)) # Efor # Process selected edges if rawEdges: edgeWires = DraftGeomUtils.findWires(rawEdges) if edgeWires: for w in edgeWires: for e in w.Edges: edge_list.append([discretize(e)]) # Apply regular Extensions op.exts = [] for ext in extensions: if not ext.avoid: wire = ext.getWire() if wire: for f in ext.getExtensionFaces(wire): op.exts.append(f) all_regions.append(f) # Second face-combining method attempted horizontal = Path.Geom.combineHorizontalFaces(all_regions) if horizontal: obj.removalshape = Part.makeCompound(horizontal) for f in horizontal: for w in f.Wires: for e in w.Edges: edge_list.append([discretize(e)]) return edge_list def _getWorkingEdgesModelAware(op, obj): """_getWorkingEdgesModelAware(op, obj)... Compile all working edges from the Base Geometry selection (obj.Base) for the current operation (or the entire model if no selections). Additional modifications to selected region(face), such as extensions, should be placed within this function. This version will return two lists- one for outside (keepout) edges and one for inside ("machine inside") edges. Each list will be a dict with "region" and "depths" entries- the former being discretized geometry of the region, the latter being a list of every depth the geometry is machined on """ # Find depth steps, throwing out all depths above anywhere we might cut # NOTE: Finish stepdown = 0 here- it's actually applied when gcode is # generated; doing so here would cause it to be applied twice. depthParams = PathUtils.depth_params( clearance_height=obj.ClearanceHeight.Value, safe_height=obj.SafeHeight.Value, start_depth=obj.StartDepth.Value, step_down=max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN), z_finish_step=0.0, final_depth=obj.FinalDepth.Value, user_depths=None, ) # d < op.stock.Shape.BoundBox.ZMax may be true even if slicing at that # height causes no projection, which results in a NULL shape. Use the # operation tolerance to prevent that. depths = [d for d in depthParams.data if d - op.stock.Shape.BoundBox.ZMax < -obj.Tolerance] # Get the stock outline at each stepdown. Used to calculate toolpaths and # for calculating cut regions in some instances # NOTE: See note in _workingEdgeHelperRoughing- tl;dr slice stock lower # than cut depth to effectively leave (at least) obj.ZStockToLeave # NOTE: Stock is handled DIFFERENTLY than inside and outside regions! # Combining different depths just adds code to look up the correct outline # when computing inside/outside regions, for no real benefit. stockProjectionDict = { d: _getSolidProjection(op.stock.Shape, d - obj.ZStockToLeave.Value) for d in depths } # If user specified edges, calculate the machining regions based on that # input. Otherwise, process entire model # Output are lists of dicts with "region" and "depths" entries. Depths are # a list of Z depths that the region applies to # Inside regions are a single face; outside regions consist of ALL geometry # to be avoided at those depths. if obj.Base: insideRegions, outsideRegions = _workingEdgeHelperManual(op, obj, depths) else: insideRegions, outsideRegions = _workingEdgeHelperRoughing(op, obj, depths) # Find all children of each region. A child of region X is any region Y such # that Y is a subset of X AND Y starts within one stepdown of X (ie, direct # children only). # NOTE: Inside and outside regions are inverses of each other, so above # refers to the area to be machined! # Assign an ID number to track each region idnumber = 0 for r in insideRegions + outsideRegions: r["id"] = idnumber r["children"] = list() idnumber += 1 # NOTE: Inside and outside regions are inverses of each other # NOTE: Outside regions can't have parents for rx in insideRegions: for ry in [k for k in insideRegions if k != rx]: dist = min(rx["depths"]) - max(ry["depths"]) # Ignore regions at our level or above, or more than one step down if dist <= 0 or dist > depthParams.step_down: continue if not ry["region"].cut(rx["region"]).Wires: rx["children"].append(ry["id"]) # See which outside region this is a child of- basically inverse of above for ry in [k for k in outsideRegions]: dist = min(ry["depths"]) - max(rx["depths"]) # Ignore regions at our level or above, or more than one step down if dist <= 0 or dist > depthParams.step_down: continue # child if there is NO overlap between the stay-outside and stay- # inside regions # Also a child if the outer region is NULL (includes everything) # NOTE: See "isNull() note" at top of file if not ry["region"].Wires or not rx["region"].common(ry["region"]).Wires: ry["children"].append(rx["id"]) # Further split regions as necessary for when the stock changes- a region as # reported here is where a toolpath will be generated, and can be projected # along all of the depths associated with it. By doing this, we can minimize # the number of toolpaths that need to be generated AND avoid more complex # logic in depth-first vs region-first sorting of regions. # NOTE: For internal regions, stock is "the same" if the region cut with # the stock results in the same region. # NOTE: For external regions, stock is "the same" if the stock cut by the # region results in the same region def _regionChildSplitterHelper(regions, areInsideRegions): nonlocal stockProjectionDict nonlocal idnumber for r in regions: depths = sorted(r["depths"], reverse=True) if areInsideRegions: rcut = r["region"].cut(stockProjectionDict[depths[0]]) else: # NOTE: We may end up with empty "outside" regions in the space # between the top of the stock and the top of the model- want # to machine the entire stock in that case # NOTE: See "isNull() note" at top of file if not r["region"].Wires: rcut = stockProjectionDict[depths[0]] else: rcut = stockProjectionDict[depths[0]].cut(r["region"]) # If the region is already entirely within in the stock, there's no # way the region can change at a lower depth. That rcut is not # empty is an assumption for the check in the depth loop below if not rcut.Wires: continue # If the region cut with the stock at a new depth is different than # the original cut, we need to split this region # The new region gets all of the children, and becomes a child of # the existing region. parentdepths = depths[0:1] for d in depths[1:]: if ( areInsideRegions and r["region"].cut(stockProjectionDict[d]).cut(rcut).Wires ) or stockProjectionDict[d].cut(r["region"]).cut(rcut).Wires: newregion = { "id": idnumber, "depths": [k for k in depths if k not in parentdepths], "region": r["region"], "children": r["children"], } # Update parent with the new region as a child, along with all # the depths it was unchanged on r["children"] = [idnumber] r["depths"] = parentdepths # Add the new region to the end of the list and stop processing # this region # When the new region is processed at the end, we'll effectively # recurse and handle splitting that new region if required regions.append(newregion) idnumber += 1 continue # If we didn't split at this depth, the parent will keep "control" # of this depth parentdepths.append(d) _regionChildSplitterHelper(insideRegions, True) _regionChildSplitterHelper(outsideRegions, False) # Create discretized regions def _createDiscretizedRegions(regionDicts): discretizedRegions = list() for rdict in regionDicts: discretizedRegions.append( { "edges": [[discretize(w)] for w in rdict["region"].Wires], "depths": rdict["depths"], "id": rdict["id"], "children": rdict["children"], } ) return discretizedRegions insideDiscretized = _createDiscretizedRegions(insideRegions) outsideDiscretized = _createDiscretizedRegions(outsideRegions) # NOTE: REMINDER: This is notably different from machining regions- just # a dict with depth: region entries, single depth for easy lookup stockDiscretized = {} for d in stockProjectionDict: discretizedEdges = list() for a in stockProjectionDict[d].Faces: for w in a.Wires: discretizedEdges.append([discretize(w)]) stockDiscretized[d] = discretizedEdges # Return found inside and outside regions/depths. Up to the caller to decide # which ones it cares about. # NOTE: REMINDER: Stock is notably different from machining regions- just # a dict with depth: region entries, single depth for easy lookup return insideDiscretized, outsideDiscretized, stockDiscretized class PathAdaptive(PathOp.ObjectOp): def opFeatures(self, obj): """opFeatures(obj) ... returns the OR'ed list of features used and supported by the operation. The default implementation returns "FeatureTool | FeatureDepths | FeatureHeights | FeatureStartPoint" Should be overwritten by subclasses.""" return ( PathOp.FeatureTool | PathOp.FeatureBaseEdges | PathOp.FeatureDepths | PathOp.FeatureFinishDepth | PathOp.FeatureStepDown | PathOp.FeatureHeights | PathOp.FeatureBaseGeometry | PathOp.FeatureCoolant | PathOp.FeatureLocations ) @classmethod def propertyEnumerations(self, dataType="data"): """helixOpPropertyEnumerations(dataType="data")... return property enumeration lists of specified dataType. Args: dataType = 'data', 'raw', 'translated' Notes: 'data' is list of internal string literals used in code 'raw' is list of (translated_text, data_string) tuples 'translated' is list of translated string literals """ # Enumeration lists for App::PropertyEnumeration properties enums = { "Side": [ (translate("CAM_Adaptive", "Outside"), "Outside"), (translate("CAM_Adaptive", "Inside"), "Inside"), ], # this is the direction that the profile runs "OperationType": [ (translate("CAM_Adaptive", "Clearing"), "Clearing"), (translate("CAM_Adaptive", "Profiling"), "Profiling"), ], # side of profile that cutter is on in relation to direction of profile } if dataType == "raw": return enums data = list() idx = 0 if dataType == "translated" else 1 Path.Log.debug(enums) for k, v in enumerate(enums): data.append((v, [tup[idx] for tup in enums[v]])) Path.Log.debug(data) return data def initOperation(self, obj): """initOperation(obj) ... implement to create additional properties. Should be overwritten by subclasses.""" obj.addProperty( "App::PropertyEnumeration", "Side", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Side of selected faces that tool should cut", ), ) obj.addProperty( "App::PropertyEnumeration", "OperationType", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Type of adaptive operation", ), ) obj.addProperty( "App::PropertyFloat", "Tolerance", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Influences calculation performance vs stability and accuracy.\n\nLarger values (further to the right) will calculate faster; smaller values (further to the left) will result in more accurate toolpaths.", ), ) obj.addProperty( "App::PropertyPercent", "StepOver", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Percent of cutter diameter to step over on each pass", ), ) obj.addProperty( "App::PropertyDistance", "LiftDistance", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Lift distance for rapid moves", ), ) obj.addProperty( "App::PropertyDistance", "KeepToolDownRatio", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Max length of keep tool down path compared to direct distance between points", ), ) obj.addProperty( "App::PropertyDistance", "StockToLeave", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "How much stock to leave in the XY plane (eg for finishing operation)", ), ) obj.addProperty( "App::PropertyDistance", "ZStockToLeave", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "How much stock to leave along the Z axis (eg for finishing operation). This property is only used if the ModelAwareExperiment is enabled.", ), ) obj.addProperty( "App::PropertyBool", "ForceInsideOut", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Force plunging into material inside and clearing towards the edges", ), ) obj.addProperty( "App::PropertyBool", "FinishingProfile", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "To take a finishing profile path at the end", ), ) obj.addProperty( "App::PropertyBool", "Stopped", "Adaptive", QT_TRANSLATE_NOOP("App::Property", "Stop processing"), ) obj.setEditorMode("Stopped", 2) # hide this property obj.addProperty( "App::PropertyBool", "StopProcessing", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Stop processing", ), ) obj.setEditorMode("StopProcessing", 2) # hide this property obj.addProperty( "App::PropertyBool", "UseHelixArcs", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Use Arcs (G2) for helix ramp", ), ) obj.addProperty( "App::PropertyPythonObject", "AdaptiveInputState", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Internal input state", ), ) obj.addProperty( "App::PropertyPythonObject", "AdaptiveOutputState", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Internal output state", ), ) obj.setEditorMode("AdaptiveInputState", 2) # hide this property obj.setEditorMode("AdaptiveOutputState", 2) # hide this property obj.addProperty( "App::PropertyAngle", "HelixAngle", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Helix ramp entry angle (degrees)", ), ) obj.addProperty( "App::PropertyLength", "HelixMaxStepdown", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "The maximum allowable descent in a single revolution of the helix.", ), ) obj.addProperty( "App::PropertyAngle", "HelixConeAngle", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Helix cone angle (degrees)", ), ) obj.addProperty( "App::PropertyPercent", "HelixMaxDiameterPercent", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Maximum (and nominal) helix entry diameter, as a percentage of the tool diameter", ), ) obj.addProperty( "App::PropertyPercent", "HelixMinDiameterPercent", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Minimum acceptable helix entry diameter, as a percentage of the tool diameter", ), ) obj.addProperty( "App::PropertyBool", "UseOutline", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Uses the outline of the base geometry.", ), ) obj.addProperty( "App::PropertyBool", "OrderCutsByRegion", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Orders cuts by region instead of depth. This property is only used if the ModelAwareExperiment is enabled.", ), ) obj.addProperty( "Part::PropertyPartShape", "removalshape", "Path", QT_TRANSLATE_NOOP("App::Property", ""), ) obj.addProperty( "App::PropertyBool", "ModelAwareExperiment", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Enable the experimental model awareness feature to respect 3D geometry and prevent cutting under overhangs", ), ) obj.setEditorMode("OrderCutsByRegion", 2) obj.setEditorMode("ZStockToLeave", 2) for n in self.propertyEnumerations(): setattr(obj, n[0], n[1]) obj.setEditorMode("removalshape", 2) # hide FeatureExtensions.initialize_properties(obj) def opSetDefaultValues(self, obj, job): obj.Side = "Inside" obj.OperationType = "Clearing" obj.Tolerance = 0.1 obj.StepOver = 20 obj.LiftDistance = 0 # obj.ProcessHoles = True obj.ForceInsideOut = False obj.FinishingProfile = True obj.Stopped = False obj.StopProcessing = False obj.HelixAngle = 5 obj.HelixConeAngle = 0 obj.HelixMaxDiameterPercent = 100 obj.HelixMinDiameterPercent = 10 obj.AdaptiveInputState = "" obj.AdaptiveOutputState = "" obj.StockToLeave = 0 obj.ZStockToLeave = 0 obj.KeepToolDownRatio = 3.0 obj.UseHelixArcs = False obj.UseOutline = False obj.OrderCutsByRegion = False obj.ModelAwareExperiment = False FeatureExtensions.set_default_property_values(obj, job) def opExecute(self, obj): """opExecute(obj) ... called whenever the receiver needs to be recalculated. See documentation of execute() for a list of base functionality provided. Should be overwritten by subclasses.""" obj.setEditorMode("OrderCutsByRegion", 0 if obj.ModelAwareExperiment else 2) obj.setEditorMode("ZStockToLeave", 0 if obj.ModelAwareExperiment else 2) if obj.ModelAwareExperiment: # Contains both geometry to machine and the applicable depths # NOTE: Reminder that stock is formatted differently than inside/outside! inside, outside, stock = _getWorkingEdgesModelAware(self, obj) self.insidePathArray = inside self.outsidePathArray = outside self.stockPathArray = stock ExecuteModelAware(self, obj) else: self.pathArray = _get_working_edges(self, obj) Execute(self, obj) def opOnDocumentRestored(self, obj): if not hasattr(obj, "HelixConeAngle"): obj.addProperty( "App::PropertyAngle", "HelixConeAngle", "Adaptive", "Helix cone angle (degrees)", ) if not hasattr(obj, "UseOutline"): obj.addProperty( "App::PropertyBool", "UseOutline", "Adaptive", "Uses the outline of the base geometry.", ) if not hasattr(obj, "OrderCutsByRegion"): obj.addProperty( "App::PropertyBool", "OrderCutsByRegion", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Orders cuts by region instead of depth.", ), ) if not hasattr(obj, "ZStockToLeave"): obj.addProperty( "App::PropertyDistance", "ZStockToLeave", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "How much stock to leave along the Z axis (eg for finishing operation)", ), ) if not hasattr(obj, "ModelAwareExperiment"): obj.addProperty( "App::PropertyBool", "ModelAwareExperiment", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Enable the experimental model awareness feature to respect 3D geometry and prevent cutting under overhangs", ), ) obj.setEditorMode("OrderCutsByRegion", 0 if obj.ModelAwareExperiment else 2) obj.setEditorMode("ZStockToLeave", 0 if obj.ModelAwareExperiment else 2) if not hasattr(obj, "removalshape"): obj.addProperty("Part::PropertyPartShape", "removalshape", "Path", "") obj.setEditorMode("removalshape", 2) # hide if hasattr(obj, "HelixDiameterLimit"): oldD = obj.HelixDiameterLimit.Value obj.removeProperty("HelixDiameterLimit") obj.addProperty( "App::PropertyPercent", "HelixMaxDiameterPercent", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Maximum (and nominal) helix entry diameter, as a percentage of the tool diameter", ), ) obj.addProperty( "App::PropertyPercent", "HelixMinDiameterPercent", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "Minimum acceptable helix entry diameter, as a percentage of the tool diameter", ), ) obj.HelixMinDiameterPercent = 10 if hasattr(obj, "ToolController"): obj.HelixMaxDiameterPercent = int( 75 if oldD == 0 else 100 * oldD / obj.ToolController.Tool.Diameter.Value ) if not hasattr(obj, "HelixMaxStepdown"): obj.addProperty( "App::PropertyLength", "HelixMaxStepdown", "Adaptive", QT_TRANSLATE_NOOP( "App::Property", "The maximum allowable descent in a single revolution of the helix.", ), ) FeatureExtensions.initialize_properties(obj) def SetupProperties(): setup = [ "Side", "OperationType", "Tolerance", "StepOver", "LiftDistance", "KeepToolDownRatio", "StockToLeave", "ZStockToLeave", "ForceInsideOut", "FinishingProfile", "Stopped", "StopProcessing", "UseHelixArcs", "AdaptiveInputState", "AdaptiveOutputState", "HelixAngle", "HelixConeAngle", "HelixMaxDiameterPercent", "HelixMinDiameterPercent", "UseOutline", "OrderCutsByRegion", ] return setup def Create(name, obj=None, parentJob=None): """Create(name) ... Creates and returns a Adaptive operation.""" if obj is None: obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name) obj.Proxy = PathAdaptive(obj, name, parentJob) return obj