diff --git a/src/Mod/Path/PathScripts/PathAreaOp.py b/src/Mod/Path/PathScripts/PathAreaOp.py index a139b0eac3..2459548993 100644 --- a/src/Mod/Path/PathScripts/PathAreaOp.py +++ b/src/Mod/Path/PathScripts/PathAreaOp.py @@ -21,22 +21,31 @@ # * USA * # * * # *************************************************************************** +# * * +# * Additional modifications and contributions beginning 2019 * +# * Focus: 4th-axis integration * +# * by Russell Johnson * +# * * +# *************************************************************************** # SCRIPT NOTES: -# - Need to add "UseRotation" property to task window UI, and attach appropriate onChange event handler -# - Consult FC community about wording for "UseRotation" property # - FUTURE: Relocate rotational calculations to Job setup tool, creating a Machine section -# with axis & rotation toggles and associated min/max values +# with axis & rotation toggles and associated min/max values import FreeCAD import Path import PathScripts.PathLog as PathLog import PathScripts.PathOp as PathOp import PathScripts.PathUtils as PathUtils +import PathScripts.PathGeom as PathGeom +import Draft +import math # from PathScripts.PathUtils import waiting_effects from PySide import QtCore -import math +if FreeCAD.GuiUp: + import FreeCADGui + __title__ = "Base class for PathArea based operations." __author__ = "sliptonic (Brad Collette)" @@ -44,8 +53,8 @@ __url__ = "http://www.freecadweb.org" __doc__ = "Base class and properties for Path.Area based operations." __contributors__ = "mlampert [FreeCAD], russ4262 (Russell Johnson)" __createdDate__ = "2017" -__scriptVersion__ = "1h testing" -__lastModified__ = "2019-05-03 10:52 CST" +__scriptVersion__ = "2g testing" +__lastModified__ = "2019-06-13 15:37 CST" if False: PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule()) @@ -53,8 +62,9 @@ if False: else: PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) - # Qt translation handling + + def translate(context, text, disambig=None): return QtCore.QCoreApplication.translate(context, text, disambig) @@ -68,15 +78,17 @@ class ObjectOp(PathOp.ObjectOp): operations.''' # These are static while document is open, if it contains a 3D Surface Op - initFinalDepth = None initOpFinalDepth = None initOpStartDepth = None + initWithRotation = False + defValsSet = False docRestored = False def opFeatures(self, obj): '''opFeatures(obj) ... returns the base features supported by all Path.Area based operations. The standard feature list is OR'ed with the return value of areaOpFeatures(). Do not overwrite, implement areaOpFeatures(obj) instead.''' + # return PathOp.FeatureTool | PathOp.FeatureDepths | PathOp.FeatureStepDown | PathOp.FeatureHeights | PathOp.FeatureStartPoint | self.areaOpFeatures(obj) | PathOp.FeatureRotation return PathOp.FeatureTool | PathOp.FeatureDepths | PathOp.FeatureStepDown | PathOp.FeatureHeights | PathOp.FeatureStartPoint | self.areaOpFeatures(obj) def areaOpFeatures(self, obj): @@ -96,30 +108,32 @@ class ObjectOp(PathOp.ObjectOp): obj.setEditorMode('PathParams', 2) # hide obj.addProperty("Part::PropertyPartShape", "removalshape", "Path") obj.setEditorMode('removalshape', 2) # hide + # obj.Proxy = self self.setupAdditionalProperties(obj) + self.initAreaOp(obj) def setupAdditionalProperties(self, obj): - if not hasattr(obj, 'UseRotation'): - obj.addProperty("App::PropertyEnumeration", "UseRotation", "Path", QtCore.QT_TRANSLATE_NOOP("App::Property", "Use rotation to gain access to pockets/areas.")) - obj.UseRotation = ['Off', 'A(x)', 'B(y)', 'A & B'] + if not hasattr(obj, 'EnableRotation'): + obj.addProperty("App::PropertyEnumeration", "EnableRotation", "Rotation", QtCore.QT_TRANSLATE_NOOP("App::Property", "Enable rotation to gain access to pockets/areas not normal to Z axis.")) + obj.EnableRotation = ['Off', 'A(x)', 'B(y)', 'A & B'] def initAreaOp(self, obj): '''initAreaOp(obj) ... overwrite if the receiver class needs initialisation. Can safely be overwritten by subclasses.''' pass - def areaOpShapeForDepths(self, obj): + def areaOpShapeForDepths(self, obj, job): '''areaOpShapeForDepths(obj) ... returns the shape used to make an initial calculation for the depths being used. The default implementation returns the job's Base.Shape''' - job = PathUtils.findParentJob(obj) - if job and job.Base: - PathLog.debug("job=%s base=%s shape=%s" % (job, job.Base, job.Base.Shape)) - return job.Base.Shape if job: - PathLog.warning(translate("PathAreaOp", "job %s has no Base.") % job.Label) + if job.Stock: + PathLog.debug("job=%s base=%s shape=%s" % (job, job.Stock, job.Stock.Shape)) + return job.Stock.Shape + else: + PathLog.warning(translate("PathAreaOp", "job %s has no Base.") % job.Label) else: PathLog.warning(translate("PathAreaOp", "no job for op %s found.") % obj.Label) return None @@ -157,10 +171,13 @@ class ObjectOp(PathOp.ObjectOp): if hasattr(obj, prop): obj.setEditorMode(prop, 2) - self.setupAdditionalProperties(obj) - + self.initOpFinalDepth = obj.OpFinalDepth.Value + self.initOpStartDepth = obj.OpStartDepth.Value self.docRestored = True + # PathLog.debug("Imported existing OpFinalDepth of " + str(self.initOpFinalDepth) + " for recompute() purposes.") + # PathLog.debug("Imported existing StartDepth of " + str(self.initOpStartDepth) + " for recompute() purposes.") + self.setupAdditionalProperties(obj) self.areaOpOnDocumentRestored(obj) def areaOpOnDocumentRestored(self, obj): @@ -173,45 +190,73 @@ class ObjectOp(PathOp.ObjectOp): areaOpShapeForDepths() return value. Do not overwrite, overwrite areaOpSetDefaultValues(obj, job) instead.''' PathLog.debug("opSetDefaultValues(%s, %s)" % (obj.Label, job.Label)) + + # Initial setting for EnableRotation is taken from Job settings/SetupSheet + # User may override on per-operation basis as needed. + if hasattr(job.SetupSheet, 'SetupEnableRotation'): + obj.EnableRotation = job.SetupSheet.SetupEnableRotation + else: + obj.EnableRotation = 'Off' + PathLog.debug("opSetDefaultValues(): Enable Rotation: {}".format(obj.EnableRotation)) + if PathOp.FeatureDepths & self.opFeatures(obj): try: - shape = self.areaOpShapeForDepths(obj) - except: + shape = self.areaOpShapeForDepths(obj, job) + except Exception as ee: + PathLog.error(ee) shape = None - maxDep = 1.0 - minDep = 0.0 - - if obj.UseRotation == 'Off': - bb = job.Stock.Shape.BoundBox - maxDep = bb.ZMax - minDep = bb.ZMin + # Set initial start and final depths + if shape is None: + PathLog.debug("shape is None") + startDepth = 1.0 + finalDepth = 0.0 else: - opHeights = self.opDetermineRotationRadii(obj) # return is list with tuples [(xRotRad, yRotRad, zRotRad), (clrOfst, safOfst)] - (xRotRad, yRotRad, zRotRad) = opHeights[0] - # (clrOfst, safOfset) = opHeights[1] + bb = job.Stock.Shape.BoundBox + startDepth = bb.ZMax + finalDepth = bb.ZMin - maxDep = xRotRad - if yRotRad > xRotRad: - maxDep = yRotRad - minDep = -1 * maxDep + # Adjust start and final depths if rotation is enabled + if obj.EnableRotation != 'Off': + self.initWithRotation = True + self.stockBB = PathUtils.findParentJob(obj).Stock.Shape.BoundBox + # Calculate rotational distances/radii + opHeights = self.opDetermineRotationRadii(obj) # return is list with tuples [(xRotRad, yRotRad, zRotRad), (clrOfst, safOfset)] + (xRotRad, yRotRad, zRotRad) = opHeights[0] + # (self.safOfset, self.safOfst) = opHeights[1] + PathLog.debug("opHeights[0]: " + str(opHeights[0])) + PathLog.debug("opHeights[1]: " + str(opHeights[1])) + + if obj.EnableRotation == 'A(x)': + startDepth = xRotRad + if obj.EnableRotation == 'B(y)': + startDepth = yRotRad + else: + startDepth = max(xRotRad, yRotRad) + finalDepth = -1 * startDepth # Manage operation start and final depths - if self.docRestored is True: - PathLog.debug("doc restored") + if self.docRestored is True: # This op is NOT the first in the Operations list + PathLog.debug("Doc restored") obj.FinalDepth.Value = obj.OpFinalDepth.Value + obj.StartDepth.Value = obj.OpStartDepth.Value else: - PathLog.debug("new operation") - obj.OpFinalDepth.Value = minDep - obj.OpStartDepth.Value = maxDep - if self.initOpFinalDepth is None and self.initFinalDepth is None: - self.initFinalDepth = minDep - self.initOpFinalDepth = minDep - else: - PathLog.debug("-initFinalDepth" + str(self.initFinalDepth)) - PathLog.debug("-initOpFinalDepth" + str(self.initOpFinalDepth)) + PathLog.debug("New operation") + obj.StartDepth.Value = startDepth + obj.FinalDepth.Value = finalDepth + obj.OpStartDepth.Value = startDepth + obj.OpFinalDepth.Value = finalDepth - obj.UseRotation = 'Off' + if obj.EnableRotation != 'Off': + if self.initOpFinalDepth is None: + self.initOpFinalDepth = finalDepth + PathLog.debug("Saved self.initOpFinalDepth") + if self.initOpStartDepth is None: + self.initOpStartDepth = startDepth + PathLog.debug("Saved self.initOpStartDepth") + self.defValsSet = True + PathLog.debug("Default OpDepths are Start: {}, and Final: {}".format(obj.OpStartDepth.Value, obj.OpFinalDepth.Value)) + PathLog.debug("Default Depths are Start: {}, and Final: {}".format(startDepth, finalDepth)) self.areaOpSetDefaultValues(obj, job) @@ -287,15 +332,71 @@ class ObjectOp(PathOp.ObjectOp): areaOpUseProjection(obj) ... return true if operation can use projection instead.''' PathLog.track() - self.endVector = None - PathLog.debug("opExecute() in PathAreaOp.py") + PathLog.info("\n----- opExecute() in PathAreaOp.py") + # PathLog.debug("OpDepths are Start: {}, and Final: {}".format(obj.OpStartDepth.Value, obj.OpFinalDepth.Value)) + # PathLog.debug("Depths are Start: {}, and Final: {}".format(obj.StartDepth.Value, obj.FinalDepth.Value)) + # PathLog.debug("initOpDepths are Start: {}, and Final: {}".format(self.initOpStartDepth, self.initOpFinalDepth)) # Instantiate class variables for operation reference + self.endVector = None self.rotateFlag = False - self.modelName = None - self.leadIn = 2.0 # safOfset / 2.0 + self.leadIn = 2.0 # self.safOfst / 2.0 + self.cloneNames = [] + self.guiMsgs = [] # list of message tuples (title, msg) to be displayed in GUI + self.stockBB = PathUtils.findParentJob(obj).Stock.Shape.BoundBox + self.useTempJobClones('Delete') # Clear temporary group and recreate for temp job clones - # Initialize depthparams + # Import OpFinalDepth from pre-existing operation for recompute() scenarios + if self.defValsSet is True: + PathLog.debug("self.defValsSet is True.") + if self.initOpStartDepth is not None: + if self.initOpStartDepth != obj.OpStartDepth.Value: + obj.OpStartDepth.Value = self.initOpStartDepth + obj.StartDepth.Value = self.initOpStartDepth + + if self.initOpFinalDepth is not None: + if self.initOpFinalDepth != obj.OpFinalDepth.Value: + obj.OpFinalDepth.Value = self.initOpFinalDepth + obj.FinalDepth.Value = self.initOpFinalDepth + self.defValsSet = False + + if obj.EnableRotation != 'Off': + # Calculate operation heights based upon rotation radii + opHeights = self.opDetermineRotationRadii(obj) + (self.xRotRad, self.yRotRad, self.zRotRad) = opHeights[0] + (self.safOfset, self.safOfst) = opHeights[1] + + # Set clearnance and safe heights based upon rotation radii + if obj.EnableRotation == 'A(x)': + self.strDep = self.xRotRad + elif obj.EnableRotation == 'B(y)': + self.strDep = self.yRotRad + else: + self.strDep = max(self.xRotRad, self.yRotRad) + self.finDep = -1 * self.strDep + + obj.ClearanceHeight.Value = self.strDep + self.safOfset + obj.SafeHeight.Value = self.strDep + self.safOfst + + if self.initWithRotation is False: + if obj.FinalDepth.Value == obj.OpFinalDepth.Value: + obj.FinalDepth.Value = self.finDep + if obj.StartDepth.Value == obj.OpStartDepth.Value: + obj.StartDepth.Value = self.strDep + + # Create visual axises when debugging. + if PathLog.getLevel(PathLog.thisModule()) == 4: + self.visualAxis() + else: + self.strDep = obj.StartDepth.Value + self.finDep = obj.FinalDepth.Value + + # Set axial feed rates based upon horizontal feed rates + safeCircum = 2 * math.pi * obj.SafeHeight.Value + self.axialFeed = 360 / safeCircum * self.horizFeed + self.axialRapid = 360 / safeCircum * self.horizRapid + + # Initiate depthparams and calculate operation heights for rotational operation finish_step = obj.FinishDepth.Value if hasattr(obj, "FinishDepth") else 0.0 self.depthparams = PathUtils.depth_params( clearance_height=obj.ClearanceHeight.Value, @@ -306,40 +407,21 @@ class ObjectOp(PathOp.ObjectOp): final_depth=obj.FinalDepth.Value, user_depths=None) - # Recalculate operation heights for rotational operation - if obj.UseRotation != 'Off': - # Calculate operation heights based upon rotation radii - opHeights = self.opDetermineRotationRadii(obj) # return is [(xRotRad, yRotRad, zRotRad), (clrOfst, safOfst)] - (xRotRad, yRotRad, zRotRad) = opHeights[0] - (clrOfst, safOfset) = opHeights[1] - # self.leadIn = 0.0 #safOfset / 2.0 - - # Set clearnance and safe heights based upon rotation radii - obj.ClearanceHeight.Value = xRotRad + clrOfst - obj.SafeHeight.Value = xRotRad + safOfset - if yRotRad > xRotRad: - obj.ClearanceHeight.Value = yRotRad + clrOfst - obj.SafeHeight.Value = yRotRad + safOfset - - # Set axial feed rates based upon horizontal feed rates - safeCircum = 2 * math.pi * obj.SafeHeight.Value - self.axialFeed = 360 / safeCircum * self.horizFeed - self.axialRapid = 360 / safeCircum * self.horizRapid - # Set start point if PathOp.FeatureStartPoint & self.opFeatures(obj) and obj.UseStartPoint: start = obj.StartPoint else: start = None - aOS = self.areaOpShapes(obj) # list of tuples (shape, isHole, sub, angle, axis, tag) + aOS = self.areaOpShapes(obj) # list of tuples (shape, isHole, sub, angle, axis) # Adjust tuples length received from other PathWB tools/operations beside PathPocketShape shapes = [] for shp in aOS: if len(shp) == 2: (fc, iH) = shp - tup = fc, iH, 'notPocket', 0.0, 'X' + # fc, iH, sub, angle, axis + tup = fc, iH, 'otherOp', 0.0, 'S', obj.StartDepth.Value, obj.FinalDepth.Value shapes.append(tup) else: shapes.append(shp) @@ -355,45 +437,69 @@ class ObjectOp(PathOp.ObjectOp): shapes = [j['shape'] for j in jobs] + # PathLog.debug("Pre_path depths are Start: {}, and Final: {}".format(obj.StartDepth.Value, obj.FinalDepth.Value)) sims = [] - for (shape, isHole, sub, angle, axis) in shapes: - startDep = obj.StartDepth.Value # + safOfset - safeDep = obj.SafeHeight.Value - clearDep = obj.ClearanceHeight.Value - finalDep = obj.FinalDepth.Value # finDep + numShapes = len(shapes) + + if numShapes == 1: + nextAxis = shapes[0][4] + elif numShapes > 1: + nextAxis = shapes[1][4] + else: + nextAxis = 'L' + + for ns in range(0, numShapes): + (shape, isHole, sub, angle, axis, strDep, finDep) = shapes[ns] + if ns < numShapes - 1: + nextAxis = shapes[ns + 1][4] + else: + nextAxis = 'L' finish_step = obj.FinishDepth.Value if hasattr(obj, "FinishDepth") else 0.0 self.depthparams = PathUtils.depth_params( - clearance_height=clearDep, # obj.ClearanceHeight.Value - safe_height=safeDep, # obj.SafeHeight.Value - start_depth=startDep, + clearance_height=obj.ClearanceHeight.Value, + safe_height=obj.SafeHeight.Value, + start_depth=strDep, # obj.StartDepth.Value, step_down=obj.StepDown.Value, - z_finish_step=finish_step, # obj.FinalDepth.Value - final_depth=finalDep, + z_finish_step=finish_step, + final_depth=finDep, # obj.FinalDepth.Value, user_depths=None) try: (pp, sim) = self._buildPathArea(obj, shape, isHole, start, getsim) + except Exception as e: + FreeCAD.Console.PrintError(e) + FreeCAD.Console.PrintError("Something unexpected happened. Check project and tool config.") + else: ppCmds = pp.Commands - if obj.UseRotation != 'Off' and self.rotateFlag is True: + if obj.EnableRotation != 'Off' and self.rotateFlag is True: # Rotate model to index for cut - axisOfRot = 'A' - if axis == 'Y': + if axis == 'X': + axisOfRot = 'A' + elif axis == 'Y': axisOfRot = 'B' # Reverse angle temporarily to match model. Error in FreeCAD render of B axis rotations if obj.B_AxisErrorOverride is True: angle = -1 * angle + elif axis == 'Z': + axisOfRot = 'C' + else: + axisOfRot = 'A' # Rotate Model to correct angle - ppCmds.insert(0, Path.Command('G0', {axisOfRot: angle, 'F': self.axialFeed})) + ppCmds.insert(0, Path.Command('G1', {axisOfRot: angle, 'F': self.axialFeed})) + ppCmds.insert(0, Path.Command('N100', {})) + # Raise cutter to safe depth and return index to starting position - ppCmds.append(Path.Command('G0', {'Z': safeDep, 'F': self.vertRapid})) - ppCmds.append(Path.Command('G0', {axisOfRot: 0.0, 'F': self.axialFeed})) + ppCmds.append(Path.Command('N200', {})) + ppCmds.append(Path.Command('G0', {'Z': obj.SafeHeight.Value, 'F': self.vertRapid})) + if axis != nextAxis: + ppCmds.append(Path.Command('G0', {axisOfRot: 0.0, 'F': self.axialRapid})) + # Eif + # Save gcode commands to object command list self.commandlist.extend(ppCmds) sims.append(sim) - except Exception as e: - FreeCAD.Console.PrintError(e) - FreeCAD.Console.PrintError("Something unexpected happened. Check project and tool config.") + # Eif if self.areaOpRetractTool(obj): self.endVector = None @@ -401,10 +507,11 @@ class ObjectOp(PathOp.ObjectOp): # Raise cutter to safe height and rotate back to original orientation if self.rotateFlag is True: self.commandlist.append(Path.Command('G0', {'Z': obj.SafeHeight.Value, 'F': self.vertRapid})) - self.commandlist.append(Path.Command('G0', {'A': 0.0, 'F': self.axialFeed})) - self.commandlist.append(Path.Command('G0', {'B': 0.0, 'F': self.axialFeed})) - FreeCAD.ActiveDocument.getObject(self.modelName).purgeTouched() + self.commandlist.append(Path.Command('G0', {'A': 0.0, 'F': self.axialRapid})) + self.commandlist.append(Path.Command('G0', {'B': 0.0, 'F': self.axialRapid})) + self.useTempJobClones('Delete') # Delete temp job clone group and contents + self.guiMessage('title', None, show=True) # Process GUI messages to user PathLog.debug("obj.Name: " + str(obj.Name)) return sims @@ -435,34 +542,34 @@ class ObjectOp(PathOp.ObjectOp): return False def opDetermineRotationRadii(self, obj): - '''opDetermineRotationRadii(self, obj) + '''opDetermineRotationRadii(obj) Determine rotational radii for 4th-axis rotations, for clearance/safe heights ''' parentJob = PathUtils.findParentJob(obj) - bb = parentJob.Stock.Shape.BoundBox + # bb = parentJob.Stock.Shape.BoundBox xlim = 0.0 ylim = 0.0 zlim = 0.0 # Determine boundbox radius based upon xzy limits data - if math.fabs(bb.ZMin) > math.fabs(bb.ZMax): - zlim = bb.ZMin + if math.fabs(self.stockBB.ZMin) > math.fabs(self.stockBB.ZMax): + zlim = self.stockBB.ZMin else: - zlim = bb.ZMax + zlim = self.stockBB.ZMax - if obj.UseRotation != 'B(y)': + if obj.EnableRotation != 'B(y)': # Rotation is around X-axis, cutter moves along same axis - if math.fabs(bb.YMin) > math.fabs(bb.YMax): - ylim = bb.YMin + if math.fabs(self.stockBB.YMin) > math.fabs(self.stockBB.YMax): + ylim = self.stockBB.YMin else: - ylim = bb.YMax + ylim = self.stockBB.YMax - if obj.UseRotation != 'A(x)': + if obj.EnableRotation != 'A(x)': # Rotation is around Y-axis, cutter moves along same axis - if math.fabs(bb.XMin) > math.fabs(bb.XMax): - xlim = bb.XMin + if math.fabs(self.stockBB.XMin) > math.fabs(self.stockBB.XMax): + xlim = self.stockBB.XMin else: - xlim = bb.XMax + xlim = self.stockBB.XMax xRotRad = math.sqrt(ylim**2 + zlim**2) yRotRad = math.sqrt(xlim**2 + zlim**2) @@ -473,41 +580,42 @@ class ObjectOp(PathOp.ObjectOp): return [(xRotRad, yRotRad, zRotRad), (clrOfst, safOfst)] - def pocketRotationAnalysis(self, obj, objRef, sub, prnt): - '''pocketRotationAnalysis(self, obj, objRef, sub, prnt) + def faceRotationAnalysis(self, obj, norm, surf): + '''faceRotationAnalysis(obj, norm, surf) Determine X and Y independent rotation necessary to make normalAt = Z=1 (0,0,1) ''' + PathLog.track() - rtn = False + praInfo = "faceRotationAnalysis() in PathAreaOp.py" + rtn = True axis = 'X' orientation = 'X' angle = 500.0 - zTol = 1.0E-9 - rndTol = 1.0 - zTol - testId = "pocketRotationAnalysis() in PathAreaOp.py" + precision = 6 - def roundRoughValues(val, zTol, rndTol): + for i in range(0, 13): + if PathGeom.Tolerance * (i * 10) == 1.0: + precision = i + break + + def roundRoughValues(precision, val): # Convert VALxe-15 numbers to zero - if math.fabs(val) <= zTol: + if PathGeom.isRoughly(0.0, val) is True: return 0.0 # Convert VAL.99999999 to next integer - elif math.fabs(val % 1) > rndTol: + elif math.fabs(val % 1) > 1.0 - PathGeom.Tolerance: return round(val) else: - return val + return round(val, precision) - face = objRef.Shape.getElement(sub) + nX = roundRoughValues(precision, norm.x) + nY = roundRoughValues(precision, norm.y) + nZ = roundRoughValues(precision, norm.z) + praInfo += "\n -normalAt(0,0): " + str(nX) + ", " + str(nY) + ", " + str(nZ) - norm = face.normalAt(0, 0) - nX = roundRoughValues(norm.x, zTol, rndTol) - nY = roundRoughValues(norm.y, zTol, rndTol) - nZ = roundRoughValues(norm.z, zTol, rndTol) - testId += "\n -normalAt(0,0): " + str(nX) + ", " + str(nY) + ", " + str(nZ) - - surf = face.Surface.Axis - saX = roundRoughValues(surf.x, zTol, rndTol) - saY = roundRoughValues(surf.y, zTol, rndTol) - saZ = roundRoughValues(surf.z, zTol, rndTol) - testId += "\n -Surface.Axis: " + str(saX) + ", " + str(saY) + ", " + str(saZ) + saX = roundRoughValues(precision, surf.x) + saY = roundRoughValues(precision, surf.y) + saZ = roundRoughValues(precision, surf.z) + praInfo += "\n -Surface.Axis: " + str(saX) + ", " + str(saY) + ", " + str(saZ) # Determine rotation needed and current orientation if saX == 0.0: @@ -518,7 +626,7 @@ class ObjectOp(PathOp.ObjectOp): elif saZ == -1.0: angle = -180.0 else: - testId += "_else_X" + str(saZ) + praInfo += "_else_X" + str(saZ) elif saY == 1.0: orientation = "Y" angle = 90.0 @@ -537,16 +645,16 @@ class ObjectOp(PathOp.ObjectOp): elif saX == -1.0: angle = 90.0 else: - testId += "_else_X" + str(saX) + praInfo += "_else_X" + str(saX) else: orientation = "X" ratio = saX / saZ angle = math.degrees(math.atan(ratio)) if ratio < 0.0: - testId += " NEG-ratio" - angle -= 90 + praInfo += " NEG-ratio" + # angle -= 90 else: - testId += " POS-ratio" + praInfo += " POS-ratio" angle = -1 * angle if saX < 0.0: angle = angle + 180.0 @@ -566,16 +674,23 @@ class ObjectOp(PathOp.ObjectOp): if nX != 0.0: angle = -1 * angle + # Enforce enabled rotation in settings if orientation == 'Y': axis = 'X' - if obj.UseRotation == 'B(y)': # Axis disabled - angle = 500.0 + if obj.EnableRotation == 'B(y)': # Required axis disabled + rtn = False else: axis = 'Y' - if obj.UseRotation == 'A(x)': # Axis disabled - angle = 500.0 + if obj.EnableRotation == 'A(x)': # Required axis disabled + rtn = False - if angle != 500.0 and angle != 0.0: + if angle == 500.0: + rtn = False + + if angle == 0.0: + rtn = False + + if rtn is True: self.rotateFlag = True rtn = True if obj.ReverseDirection is True: @@ -583,11 +698,267 @@ class ObjectOp(PathOp.ObjectOp): angle = angle + 180.0 else: angle = angle - 180.0 - testId += "\n - ... rotation triggered" - else: - testId += "\n - ... NO rotation triggered" + angle = round(angle, precision) - testId += "\n -Suggested rotation: angle: " + str(angle) + ", axis: " + str(axis) - if prnt is True: - PathLog.debug("testId: " + testId) - return (rtn, angle, axis) + praInfo += "\n -Rotation analysis: angle: " + str(angle) + ", axis: " + str(axis) + if rtn is True: + praInfo += "\n - ... rotation triggered" + else: + praInfo += "\n - ... NO rotation triggered" + + PathLog.debug("\n" + str(praInfo)) + + return (rtn, angle, axis, praInfo) + + def guiMessage(self, title, msg, show=False): + '''guiMessage(title, msg, show=False) + Handle op related GUI messages to user''' + if msg is not None: + self.guiMsgs.append((title, msg)) + if show is True: + if FreeCAD.GuiUp and len(self.guiMsgs) > 0: + # self.guiMsgs.pop(0) # remove formatted place holder. + from PySide.QtGui import QMessageBox + # from PySide import QtGui + for entry in self.guiMsgs: + (title, msg) = entry + QMessageBox.warning(None, title, msg) + # QtGui.QMessageBox.warning(None, title, msg) + self.guiMsgs = [] # Reset messages + return True + + # Types: information, warning, critical, question + return False + + def visualAxis(self): + '''visualAxis() + Create visual X & Y axis for use in orientation of rotational operations + Triggered only for PathLog.debug''' + + if not FreeCAD.ActiveDocument.getObject('xAxCyl'): + xAx = 'xAxCyl' + yAx = 'yAxCyl' + zAx = 'zAxCyl' + FreeCAD.ActiveDocument.addObject("App::DocumentObjectGroup", "visualAxis") + if FreeCAD.GuiUp: + FreeCADGui.ActiveDocument.getObject('visualAxis').Visibility = False + vaGrp = FreeCAD.ActiveDocument.getObject("visualAxis") + + FreeCAD.ActiveDocument.addObject("Part::Cylinder", xAx) + cyl = FreeCAD.ActiveDocument.getObject(xAx) + cyl.Label = xAx + cyl.Radius = self.xRotRad + cyl.Height = 0.01 + cyl.Placement = FreeCAD.Placement(FreeCAD.Vector(0, 0, 0), FreeCAD.Rotation(FreeCAD.Vector(0, 1, 0), 90)) + cyl.purgeTouched() + if FreeCAD.GuiUp: + cylGui = FreeCADGui.ActiveDocument.getObject(xAx) + cylGui.ShapeColor = (0.667, 0.000, 0.000) + cylGui.Transparency = 85 + cylGui.Visibility = False + vaGrp.addObject(cyl) + + FreeCAD.ActiveDocument.addObject("Part::Cylinder", yAx) + cyl = FreeCAD.ActiveDocument.getObject(yAx) + cyl.Label = yAx + cyl.Radius = self.yRotRad + cyl.Height = 0.01 + cyl.Placement = FreeCAD.Placement(FreeCAD.Vector(0, 0, 0), FreeCAD.Rotation(FreeCAD.Vector(1, 0, 0), 90)) + cyl.purgeTouched() + if FreeCAD.GuiUp: + cylGui = FreeCADGui.ActiveDocument.getObject(yAx) + cylGui.ShapeColor = (0.000, 0.667, 0.000) + cylGui.Transparency = 85 + cylGui.Visibility = False + vaGrp.addObject(cyl) + + if False: + FreeCAD.ActiveDocument.addObject("Part::Cylinder", zAx) + cyl = FreeCAD.ActiveDocument.getObject(zAx) + cyl.Label = zAx + cyl.Radius = self.yRotRad + cyl.Height = 0.01 + # cyl.Placement = FreeCAD.Placement(FreeCAD.Vector(0,0,0),FreeCAD.Rotation(FreeCAD.Vector(1,0,0),90)) + cyl.purgeTouched() + if FreeCAD.GuiUp: + cylGui = FreeCADGui.ActiveDocument.getObject(zAx) + cylGui.ShapeColor = (0.000, 0.000, 0.498) + cylGui.Transparency = 85 + cylGui.Visibility = False + vaGrp.addObject(cyl) + + def useTempJobClones(self, cloneName): + '''useTempJobClones(cloneName) + Manage use of temporary model clones for rotational operation calculations. + Clones are stored in 'rotJobClones' group.''' + if FreeCAD.ActiveDocument.getObject('rotJobClones'): + if cloneName == 'Start': + if PathLog.getLevel(PathLog.thisModule()) < 4: + for cln in FreeCAD.ActiveDocument.getObject('rotJobClones').Group: + FreeCAD.ActiveDocument.removeObject(cln.Name) + elif cloneName == 'Delete': + if PathLog.getLevel(PathLog.thisModule()) < 4: + for cln in FreeCAD.ActiveDocument.getObject('rotJobClones').Group: + FreeCAD.ActiveDocument.removeObject(cln.Name) + FreeCAD.ActiveDocument.removeObject('rotJobClones') + else: + FreeCAD.ActiveDocument.addObject("App::DocumentObjectGroup", "rotJobClones") + if FreeCAD.GuiUp: + FreeCADGui.ActiveDocument.getObject('rotJobClones').Visibility = False + + if cloneName != 'Start' and cloneName != 'Delete': + FreeCAD.ActiveDocument.getObject('rotJobClones').addObject(FreeCAD.ActiveDocument.getObject(cloneName)) + if FreeCAD.GuiUp: + FreeCADGui.ActiveDocument.getObject(cloneName).Visibility = False + + def cloneBaseAndStock(self, obj, base, angle, axis, subCount): + '''cloneBaseAndStock(obj, base, angle, axis, subCount) + Method called to create a temporary clone of the base and parent Job stock. + Clones are destroyed after usage for calculations related to rotational operations.''' + # Create a temporary clone and stock of model for rotational use. + rndAng = round(angle, 8) + if rndAng < 0.0: # neg sign is converted to underscore in clone name creation. + tag = axis + '_' + axis + '_' + str(math.fabs(rndAng)).replace('.', '_') + else: + tag = axis + str(rndAng).replace('.', '_') + clnNm = obj.Name + '_base_' + '_' + str(subCount) + '_' + tag + stckClnNm = obj.Name + '_stock_' + '_' + str(subCount) + '_' + tag + if clnNm not in self.cloneNames: + self.cloneNames.append(clnNm) + self.cloneNames.append(stckClnNm) + if FreeCAD.ActiveDocument.getObject(clnNm): + FreeCAD.ActiveDocument.removeObject(clnNm) + if FreeCAD.ActiveDocument.getObject(stckClnNm): + FreeCAD.ActiveDocument.removeObject(stckClnNm) + FreeCAD.ActiveDocument.addObject('Part::Feature', clnNm).Shape = base.Shape + FreeCAD.ActiveDocument.addObject('Part::Feature', stckClnNm).Shape = PathUtils.findParentJob(obj).Stock.Shape + if FreeCAD.GuiUp: + FreeCADGui.ActiveDocument.getObject(stckClnNm).Transparency = 90 + FreeCADGui.ActiveDocument.getObject(clnNm).ShapeColor = (1.000, 0.667, 0.000) + self.useTempJobClones(clnNm) + self.useTempJobClones(stckClnNm) + clnBase = FreeCAD.ActiveDocument.getObject(clnNm) + clnStock = FreeCAD.ActiveDocument.getObject(stckClnNm) + tag = base.Name + '_' + tag + return (clnBase, clnStock, tag) + + def getFaceNormAndSurf(self, face): + '''getFaceNormAndSurf(face) + Return face.normalAt(0,0) or face.normal(0,0) and face.Surface.Axis vectors + ''' + norm = FreeCAD.Vector(0.0, 0.0, 0.0) + surf = FreeCAD.Vector(0.0, 0.0, 0.0) + + if hasattr(face, 'normalAt'): + n = face.normalAt(0, 0) + elif hasattr(face, 'normal'): + n = face.normal(0, 0) + if hasattr(face.Surface, 'Axis'): + s = face.Surface.Axis + else: + s = n + norm.x = n.x + norm.y = n.y + norm.z = n.z + surf.x = s.x + surf.y = s.y + surf.z = s.z + return (norm, surf) + + def applyRotationalAnalysis(self, obj, base, angle, axis, subCount): + '''applyRotationalAnalysis(obj, base, angle, axis, subCount) + Create temp clone and stock and apply rotation to both. + Return new rotated clones + ''' + if axis == 'X': + vect = FreeCAD.Vector(1, 0, 0) + elif axis == 'Y': + vect = FreeCAD.Vector(0, 1, 0) + + if obj.InverseAngle is True: + angle = -1 * angle + + # Create a temporary clone of model for rotational use. + (clnBase, clnStock, tag) = self.cloneBaseAndStock(obj, base, angle, axis, subCount) + + # Rotate base to such that Surface.Axis of pocket bottom is Z=1 + clnBase = Draft.rotate(clnBase, angle, center=FreeCAD.Vector(0.0, 0.0, 0.0), axis=vect, copy=False) + clnStock = Draft.rotate(clnStock, angle, center=FreeCAD.Vector(0.0, 0.0, 0.0), axis=vect, copy=False) + + clnBase.purgeTouched() + clnStock.purgeTouched() + return (clnBase, angle, clnStock, tag) + + def applyInverseAngle(self, obj, clnBase, clnStock, axis, angle): + '''applyInverseAngle(obj, clnBase, clnStock, axis, angle) + Apply rotations to incoming base and stock objects.''' + if axis == 'X': + vect = FreeCAD.Vector(1, 0, 0) + elif axis == 'Y': + vect = FreeCAD.Vector(0, 1, 0) + # Rotate base to inverse of original angle + clnBase = Draft.rotate(clnBase, (-2 * angle), center=FreeCAD.Vector(0.0, 0.0, 0.0), axis=vect, copy=False) + clnStock = Draft.rotate(clnStock, (-2 * angle), center=FreeCAD.Vector(0.0, 0.0, 0.0), axis=vect, copy=False) + clnBase.purgeTouched() + clnStock.purgeTouched() + # Update property and angle values + obj.InverseAngle = True + obj.AttemptInverseAngle = False + angle = -1 * angle + + PathLog.info(translate("Path", "Rotated to inverse angle.")) + return (clnBase, clnStock, angle) + + def calculateStartFinalDepths(self, obj, shape, stock): + '''calculateStartFinalDepths(obj, shape, stock) + Calculate correct start and final depths for the shape(face) object provided.''' + finDep = max(obj.FinalDepth.Value, shape.BoundBox.ZMin) + stockTop = stock.Shape.BoundBox.ZMax + if obj.EnableRotation == 'Off': + strDep = obj.StartDepth.Value + if strDep <= finDep: + strDep = stockTop + else: + strDep = min(obj.StartDepth.Value, stockTop) + if strDep <= finDep: + strDep = stockTop # self.strDep + msg = translate('Path', "Start depth <= face depth.\nIncreased to stock top.") + PathLog.error(msg) + return (strDep, finDep) + + def sortTuplesByIndex(self, TupleList, tagIdx): + '''sortTuplesByIndex(TupleList, tagIdx) + sort list of tuples based on tag index provided + return (TagList, GroupList) + ''' + # Separate elements, regroup by orientation (axis_angle combination) + TagList = ['X34.2'] + GroupList = [[(2.3, 3.4, 'X')]] + for tup in TupleList: + if tup[tagIdx] in TagList: + # Determine index of found string + i = 0 + for orn in TagList: + if orn == tup[4]: + break + i += 1 + GroupList[i].append(tup) + else: + TagList.append(tup[4]) # add orientation entry + GroupList.append([tup]) # add orientation entry + # Remove temp elements + TagList.pop(0) + GroupList.pop(0) + return (TagList, GroupList) + + def warnDisabledAxis(self, obj, axis): + '''warnDisabledAxis(self, obj, axis) + Provide user feedback if required axis is disabled''' + if axis == 'X' and obj.EnableRotation == 'B(y)': + PathLog.warning(translate('Path', "Part feature is inaccessible. Selected feature(s) require 'A(x)' for access.")) + return True + elif axis == 'Y' and obj.EnableRotation == 'A(x)': + PathLog.warning(translate('Path', "Part feature is inaccessible. Selected feature(s) require 'B(y)' for access.")) + return True + else: + return False diff --git a/src/Mod/Path/PathScripts/PathPocketShape.py b/src/Mod/Path/PathScripts/PathPocketShape.py index f687c66e9a..5a9387d8a6 100644 --- a/src/Mod/Path/PathScripts/PathPocketShape.py +++ b/src/Mod/Path/PathScripts/PathPocketShape.py @@ -21,17 +21,15 @@ # * USA * # * * # *************************************************************************** +# * * +# * Additional modifications and contributions beginning 2019 * +# * Focus: 4th-axis integration * +# * by Russell Johnson * +# * * +# *************************************************************************** # SCRIPT NOTES: -# - Need test models for testing vertical faces scenarios. Currently, I think they will fail with rotation. -# - Need to group VERTICAL faces per axis_angle tag just like horizontal faces. -# Then, need to run each grouping through -# PathGeom.combineConnectedShapes(vertical) algorithm grouping -# - Need to add face boundbox analysis code to vertical axis_angle -# section to identify highest zMax for all faces included in group -# - Need to implement judgeStartDepth() within rotational depth calculations -# - FUTURE: Re-iterate PathAreaOp.py need to relocate rotational settings -# to Job setup, under Machine settings tab +# - Need test models for testing vertical faces scenarios. import FreeCAD import Part @@ -39,11 +37,9 @@ import PathScripts.PathGeom as PathGeom import PathScripts.PathLog as PathLog import PathScripts.PathOp as PathOp import PathScripts.PathPocketBase as PathPocketBase -# import PathScripts.PathUtil as PathUtil -# import PathScripts.PathUtils as PathUtils +import PathScripts.PathUtils as PathUtils import TechDraw import math -# import sys from PySide import QtCore @@ -51,10 +47,10 @@ __title__ = "Path Pocket Shape Operation" __author__ = "sliptonic (Brad Collette)" __url__ = "http://www.freecadweb.org" __doc__ = "Class and implementation of shape based Pocket operation." -__contributors__ = "russ4262 (Russell Johnson)" +__contributors__ = "mlampert [FreeCAD], russ4262 (Russell Johnson)" __created__ = "2017" -__scriptVersion__ = "1i testing" -__lastModified__ = "2019-05-06 16:55 CST" +__scriptVersion__ = "2g testing" +__lastModified__ = "2019-06-12 23:29 CST" if False: PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule()) @@ -62,8 +58,9 @@ if False: else: PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) - # Qt translation handling + + def translate(context, text, disambig=None): return QtCore.QCoreApplication.translate(context, text, disambig) @@ -80,7 +77,7 @@ def endPoints(edgeOrWire): if 1 == cnt: unique.append(p) return unique - return [e.valueAt(edgeOrWire.FirstParameter), e.valueAt(edgeOrWire.LastParameter)] + return [edgeOrWire.valueAt(edgeOrWire.FirstParameter), edgeOrWire.valueAt(edgeOrWire.LastParameter)] def includesPoint(p, pts): @@ -108,28 +105,33 @@ def extendWire(feature, wire, length): try: off2D = wire.makeOffset2D(length) except Exception as e: - msg = "\nThe selected face cannot be used.\nYou must select the bottom face of the pocket area.\nextendWire() in PathPocketShape.py" + PathLog.error("extendWire(): wire.makeOffset2D()") PathLog.error(e) - PathLog.error(msg) return False else: endPts = endPoints(wire) - edges = [e for e in off2D.Edges if Part.Circle != type(e.Curve) or not includesPoint(e.Curve.Center, endPts)] + edges = [e for e in off2D.Edges if not isinstance(e.Curve, Part.Circle) or not includesPoint(e.Curve.Center, endPts)] wires = [Part.Wire(e) for e in Part.sortEdges(edges)] offset = selectOffsetWire(feature, wires) ePts = endPoints(offset) - l0 = (ePts[0] - endPts[0]).Length - l1 = (ePts[1] - endPts[0]).Length - edges = wire.Edges - if l0 < l1: - edges.append(Part.Edge(Part.LineSegment(endPts[0], ePts[0]))) - edges.extend(offset.Edges) - edges.append(Part.Edge(Part.LineSegment(endPts[1], ePts[1]))) + try: + l0 = (ePts[0] - endPts[0]).Length + except Exception as ee: + PathLog.error("extendWire(): (ePts[0] - endPts[0]).Length") + PathLog.error(ee) + return False else: - edges.append(Part.Edge(Part.LineSegment(endPts[1], ePts[0]))) - edges.extend(offset.Edges) - edges.append(Part.Edge(Part.LineSegment(endPts[0], ePts[1]))) - return Part.Wire(edges) + l1 = (ePts[1] - endPts[0]).Length + edges = wire.Edges + if l0 < l1: + edges.append(Part.Edge(Part.LineSegment(endPts[0], ePts[0]))) + edges.extend(offset.Edges) + edges.append(Part.Edge(Part.LineSegment(endPts[1], ePts[1]))) + else: + edges.append(Part.Edge(Part.LineSegment(endPts[1], ePts[0]))) + edges.extend(offset.Edges) + edges.append(Part.Edge(Part.LineSegment(endPts[0], ePts[1]))) + return Part.Wire(edges) class Extension(object): @@ -148,7 +150,7 @@ class Extension(object): return "%s:%s" % (self.feature, self.sub) def extendEdge(self, feature, e0, direction): - if Part.Line == type(e0.Curve) or Part.LineSegment == type(e0.Curve): + if isinstance(e0.Curve, Part.Line) or isinstance(e0.Curve, Part.LineSegment): e2 = e0.copy() off = self.length.Value * direction e2.translate(off) @@ -175,14 +177,20 @@ class Extension(object): e0 = wire.Edges[0] midparam = e0.FirstParameter + 0.5 * (e0.LastParameter - e0.FirstParameter) tangent = e0.tangentAt(midparam) - normal = tangent.cross(FreeCAD.Vector(0, 0, 1)).normalize() - poffPlus = e0.valueAt(midparam) + 0.01 * normal - poffMinus = e0.valueAt(midparam) - 0.01 * normal - if not self.obj.Shape.isInside(poffPlus, 0.005, True): - return normal - if not self.obj.Shape.isInside(poffMinus, 0.005, True): - return normal.negative() - return None + try: + normal = tangent.cross(FreeCAD.Vector(0, 0, 1)).normalize() + except Exception as e: + PathLog.error('getDirection(): tangent.cross(FreeCAD.Vector(0, 0, 1)).normalize()') + PathLog.error(e) + return None + else: + poffPlus = e0.valueAt(midparam) + 0.01 * normal + poffMinus = e0.valueAt(midparam) - 0.01 * normal + if not self.obj.Shape.isInside(poffPlus, 0.005, True): + return normal + if not self.obj.Shape.isInside(poffMinus, 0.005, True): + return normal.negative() + return None def getWire(self): if PathGeom.isRoughly(0, self.length.Value) or not self.sub: @@ -204,26 +212,28 @@ class ObjectPocket(PathPocketBase.ObjectPocket): '''Proxy object for Pocket operation.''' def areaOpFeatures(self, obj): + # return super(self.__class__, self).areaOpFeatures(obj) | PathOp.FeatureLocations | PathOp.FeatureRotation return super(self.__class__, self).areaOpFeatures(obj) | PathOp.FeatureLocations def initPocketOp(self, obj): '''initPocketOp(obj) ... setup receiver''' if not hasattr(obj, 'UseOutline'): obj.addProperty('App::PropertyBool', 'UseOutline', 'Pocket', QtCore.QT_TRANSLATE_NOOP('PathPocketShape', 'Uses the outline of the base geometry.')) - obj.UseOutline = False if not hasattr(obj, 'ExtensionLengthDefault'): obj.addProperty('App::PropertyDistance', 'ExtensionLengthDefault', 'Extension', QtCore.QT_TRANSLATE_NOOP('PathPocketShape', 'Default length of extensions.')) if not hasattr(obj, 'ExtensionFeature'): obj.addProperty('App::PropertyLinkSubListGlobal', 'ExtensionFeature', 'Extension', QtCore.QT_TRANSLATE_NOOP('PathPocketShape', 'List of features to extend.')) if not hasattr(obj, 'ExtensionCorners'): obj.addProperty('App::PropertyBool', 'ExtensionCorners', 'Extension', QtCore.QT_TRANSLATE_NOOP('PathPocketShape', 'When enabled connected extension edges are combined to wires.')) - obj.ExtensionCorners = True - if not hasattr(obj, 'B_AxisErrorOverride'): - obj.addProperty('App::PropertyBool', 'B_AxisErrorOverride', 'Path', QtCore.QT_TRANSLATE_NOOP('PathPocketShape', 'Match B rotations to model (error in FreeCAD rendering).')) - obj.B_AxisErrorOverride = False + if not hasattr(obj, 'ReverseDirection'): - obj.addProperty('App::PropertyBool', 'ReverseDirection', 'Path', QtCore.QT_TRANSLATE_NOOP('PathPocketShape', 'Reverse direction of pocket operation.')) - obj.ReverseDirection = False + obj.addProperty('App::PropertyBool', 'ReverseDirection', 'Rotation', QtCore.QT_TRANSLATE_NOOP('App::Property', 'Reverse direction of pocket operation.')) + if not hasattr(obj, 'InverseAngle'): + obj.addProperty('App::PropertyBool', 'InverseAngle', 'Rotation', QtCore.QT_TRANSLATE_NOOP('App::Property', 'Inverse the angle. Example: -22.5 -> 22.5 degrees.')) + if not hasattr(obj, 'B_AxisErrorOverride'): + obj.addProperty('App::PropertyBool', 'B_AxisErrorOverride', 'Rotation', QtCore.QT_TRANSLATE_NOOP('App::Property', 'Match B rotations to model (error in FreeCAD rendering).')) + if not hasattr(obj, 'AttemptInverseAngle'): + obj.addProperty('App::PropertyBool', 'AttemptInverseAngle', 'Rotation', QtCore.QT_TRANSLATE_NOOP('App::Property', 'Attempt the inverse angle for face access if original rotation fails.')) obj.setEditorMode('ExtensionFeature', 2) @@ -237,324 +247,334 @@ class ObjectPocket(PathPocketBase.ObjectPocket): def areaOpShapes(self, obj): '''areaOpShapes(obj) ... return shapes representing the solids to be removed.''' PathLog.track() - PathLog.debug("areaOpShapes() in PathPocketShape.py") + PathLog.debug("----- areaOpShapes() in PathPocketShape.py") - def judgeFinalDepth(obj, fD): - if obj.FinalDepth.Value >= fD: - return obj.FinalDepth.Value - else: - return fD + baseSubsTuples = [] + subCount = 0 + allTuples = [] + finalDepths = [] - def judgeStartDepth(obj, sD): - if obj.StartDepth.Value >= sD: - return obj.StartDepth.Value - else: - return sD - - def analyzeVerticalFaces(self, obj, vertTuples): - hT = [] - # base = FreeCAD.ActiveDocument.getObject(self.modelName) - - # Separate elements, regroup by orientation (axis_angle combination) - vTags = ['X34.2'] - vGrps = [[(2.3, 3.4, 'X')]] - for tup in vertTuples: - (face, sub, angle, axis, tag, strDep, finDep, trans) = tup - if tag in vTags: - # Determine index of found string - i = 0 - for orn in vTags: - if orn == tag: - break - i += 1 - vGrps[i].append(tup) - else: - vTags.append(tag) # add orientation entry - vGrps.append([tup]) # add orientation entry - # Remove temp elements - vTags.pop(0) - vGrps.pop(0) - - # check all faces in each axis_angle group - shpList = [] - zmaxH = 0.0 - for o in range(0, len(vTags)): - shpList = [] - zmaxH = vGrps[o][0].BoundBox.ZMax - for (face, sub, angle, axis, tag, strDep, finDep, trans) in vGrps[o]: - shpList.append(face) - # Identify tallest face to use as zMax - if face.BoundBox.ZMax > zmaxH: - zmaxH = face.BoundBox.ZMax - # check all faces and see if they are touching/overlapping and combine those into a compound - # Original Code in For loop - self.vertical = PathGeom.combineConnectedShapes(shpList) - self.vWires = [TechDraw.findShapeOutline(shape, 1, FreeCAD.Vector(0, 0, 1)) for shape in self.vertical] - for wire in self.vWires: - w = PathGeom.removeDuplicateEdges(wire) - face = Part.Face(w) - face.tessellate(0.05) - if PathGeom.isRoughly(face.Area, 0): - PathLog.error(translate('PathPocket', 'Vertical faces do not form a loop - ignoring')) + def planarFaceFromExtrusionEdges(face, trans): + useFace = 'useFaceName' + minArea = 0.0 + fCnt = 0 + clsd = [] + planar = False + # Identify closed edges + for edg in face.Edges: + if edg.isClosed(): + PathLog.debug(' -e.isClosed()') + clsd.append(edg) + planar = True + # Attempt to create planar faces and select that with smallest area for use as pocket base + if planar is True: + planar = False + for edg in clsd: + fCnt += 1 + fName = sub + '_face_' + str(fCnt) + # Create planar face from edge + mFF = Part.Face(Part.Wire(Part.__sortEdges__([edg]))) + if mFF.isNull(): + PathLog.debug('Face(Part.Wire()) failed') else: - strDep = zmaxH + self.leadIn # base.Shape.BoundBox.ZMax - finDep = judgeFinalDepth(obj, face.BoundBox.ZMin) - tup = face, sub, angle, axis, tag, strDep, finDep, trans - hT.append(tup) - # Eol - return hT + if trans is True: + mFF.translate(FreeCAD.Vector(0, 0, face.BoundBox.ZMin - mFF.BoundBox.ZMin)) + if FreeCAD.ActiveDocument.getObject(fName): + FreeCAD.ActiveDocument.removeObject(fName) + tmpFace = FreeCAD.ActiveDocument.addObject('Part::Feature', fName).Shape = mFF + tmpFace = FreeCAD.ActiveDocument.getObject(fName) + tmpFace.purgeTouched() + if minArea == 0.0: + minArea = tmpFace.Shape.Face1.Area + useFace = fName + planar = True + elif tmpFace.Shape.Face1.Area < minArea: + minArea = tmpFace.Shape.Face1.Area + FreeCAD.ActiveDocument.removeObject(useFace) + useFace = fName + else: + FreeCAD.ActiveDocument.removeObject(fName) + if useFace != 'useFaceName': + self.useTempJobClones(useFace) + return (planar, useFace) + + def clasifySub(self, bs, sub): + face = bs.Shape.getElement(sub) + + if type(face.Surface) == Part.Plane: + PathLog.debug('type() == Part.Plane') + if PathGeom.isVertical(face.Surface.Axis): + PathLog.debug(' -isVertical()') + # it's a flat horizontal face + self.horiz.append(face) + return True + elif PathGeom.isHorizontal(face.Surface.Axis): + PathLog.debug(' -isHorizontal()') + self.vert.append(face) + return True + else: + return False + elif type(face.Surface) == Part.Cylinder and PathGeom.isVertical(face.Surface.Axis): + PathLog.debug('type() == Part.Cylinder') + # vertical cylinder wall + if any(e.isClosed() for e in face.Edges): + PathLog.debug(' -e.isClosed()') + # complete cylinder + circle = Part.makeCircle(face.Surface.Radius, face.Surface.Center) + disk = Part.Face(Part.Wire(circle)) + disk.translate(FreeCAD.Vector(0, 0, face.BoundBox.ZMin - disk.BoundBox.ZMin)) + self.horiz.append(disk) + return True + else: + PathLog.debug(' -none isClosed()') + # partial cylinder wall + self.vert.append(face) + return True + elif type(face.Surface) == Part.SurfaceOfExtrusion: + # extrusion wall + PathLog.debug('type() == Part.SurfaceOfExtrusion') + # Attempt to extract planar face from surface of extrusion + (planar, useFace) = planarFaceFromExtrusionEdges(face, trans=True) + # Save face object to self.horiz for processing or display error + if planar is True: + uFace = FreeCAD.ActiveDocument.getObject(useFace) + self.horiz.append(uFace.Shape.Faces[0]) + msg = translate('Path', "Verify depth of pocket for '{}'.".format(sub)) + msg += translate('Path', "\n
Pocket is based on extruded surface.") + msg += translate('Path', "\n
Bottom of pocket might be non-planar and/or not normal to spindle axis.") + msg += translate('Path', "\n
\n
3D pocket bottom is NOT available in this operation.") + PathLog.info(msg) + title = translate('Path', 'Depth Warning') + self.guiMessage(title, msg, False) + else: + PathLog.error(translate("Path", "Failed to create a planar face from edges in {}.".format(sub))) + else: + PathLog.debug(' -type(face.Surface): {}'.format(type(face.Surface))) + return False if obj.Base: - PathLog.debug('base items exist. Processing...') + PathLog.debug('Processing... obj.Base') self.removalshapes = [] - self.horiz = [] - vertical = [] - horizTuples = [] - vertTuples = [] - axis = 'X' - angle = 0.0 - reset = False - resetPlacement = None - trans = FreeCAD.Vector(0.0, 0.0, 0.0) + # ---------------------------------------------------------------------- + if obj.EnableRotation == 'Off': + stock = PathUtils.findParentJob(obj).Stock + for (base, subList) in obj.Base: + baseSubsTuples.append((base, subList, 0.0, 'X', stock)) + else: + for p in range(0, len(obj.Base)): + (base, subsList) = obj.Base[p] + isLoop = False - for o in obj.Base: - PathLog.debug('Base item: {}'.format(o)) - base = o[0] - - # Limit sub faces to children of single Model object. - if self.modelName is None: - self.modelName = base.Name - else: - if base.Name != self.modelName: - for sub in o[1]: - PathLog.error(sub + " is not a part of Model object: " + self.modelName) - o[1] = [] - PathLog.error("Only processing faces on a single Model object per operation.") - PathLog.error("You will need to separate faces per Model object within the Job.") - - startBase = FreeCAD.Vector(base.Placement.Base.x, base.Placement.Base.y, base.Placement.Base.z) - startAngle = base.Placement.Rotation.Angle - startAxis = base.Placement.Rotation.Axis - startRotation = FreeCAD.Rotation(startAxis, startAngle) - resetPlacement = FreeCAD.Placement(startBase, startRotation) - for sub in o[1]: - if 'Face' in sub: - PathLog.debug('sub: {}'.format(sub)) - # Determine angle of rotation needed to make normal vector = (0,0,1) - strDep = obj.StartDepth.Value - finDep = obj.FinalDepth.Value - trans = FreeCAD.Vector(0.0, 0.0, 0.0) + # First, check all subs collectively for loop of faces + if len(subsList) > 2: + (isLoop, norm, surf) = self.checkForFacesLoop(base, subsList) + if isLoop is True: + PathLog.info("Common Surface.Axis or normalAt() value found for loop faces.") rtn = False - - if obj.UseRotation != 'Off': - (rtn, angle, axis) = self.pocketRotationAnalysis(obj, base, sub, prnt=True) + subCount += 1 + (rtn, angle, axis, praInfo) = self.faceRotationAnalysis(obj, norm, surf) + PathLog.info("angle: {}; axis: {}".format(angle, axis)) if rtn is True: - reset = True - PathLog.debug(str(sub) + ": rotating model to make face normal at (0,0,1) ...") - if axis == 'X': - bX = 0.0 - bY = 0.0 - bZ = math.sin(math.radians(angle)) * base.Placement.Base.y - vect = FreeCAD.Vector(1, 0, 0) - elif axis == 'Y': - bX = 0.0 - bY = 0.0 - bZ = math.sin(math.radians(angle)) * base.Placement.Base.x - if obj.B_AxisErrorOverride is True: - bZ = -1 * bZ - vect = FreeCAD.Vector(0, 1, 0) - # Rotate base to such that Surface.Axis of pocket bottom is Z=1 - base.Placement.Rotation = FreeCAD.Rotation(vect, angle) - base.recompute() - trans = FreeCAD.Vector(bX, bY, bZ) + faceNums = "" + for f in subsList: + faceNums += '_' + f.replace('Face', '') + (clnBase, angle, clnStock, tag) = self.applyRotationalAnalysis(obj, base, angle, axis, faceNums) + + # Verify faces are correctly oriented - InverseAngle might be necessary + PathLog.debug("Checking if faces are oriented correctly after rotation...") + for sub in subsList: + face = clnBase.Shape.getElement(sub) + if type(face.Surface) == Part.Plane: + if not PathGeom.isHorizontal(face.Surface.Axis): + rtn = False + break + if rtn is False: + if obj.AttemptInverseAngle is True and obj.InverseAngle is False: + (clnBase, clnStock, angle) = self.applyInverseAngle(obj, clnBase, clnStock, axis, angle) + else: + PathLog.info(translate("Path", "Consider toggling the InverseAngle property and recomputing the operation.")) + + tup = clnBase, subsList, angle, axis, clnStock else: + if self.warnDisabledAxis(obj, axis) is False: + PathLog.debug("No rotation used") axis = 'X' angle = 0.0 - tag = axis + str(round(angle, 7)) - face = base.Shape.getElement(sub) + stock = PathUtils.findParentJob(obj).Stock + tup = base, subsList, angle, axis, stock + # Eif + allTuples.append(tup) + baseSubsTuples.append(tup) + # Eif - if type(face.Surface) == Part.Plane and PathGeom.isVertical(face.Surface.Axis): - # it's a flat horizontal face - PathLog.debug(" == Part.Plane: isVertical") - # Adjust start and finish depths for pocket - strDep = base.Shape.BoundBox.ZMax + self.leadIn - finDep = judgeFinalDepth(obj, face.BoundBox.ZMin) - # Over-write default final depth value, leaves manual override by user - obj.StartDepth.Value = trans.z + strDep - obj.FinalDepth.Value = trans.z + finDep + if isLoop is False: + PathLog.debug(translate('Path', "Processing subs individually ...")) + for sub in subsList: + subCount += 1 + if 'Face' in sub: + rtn = False - tup = face, sub, angle, axis, tag, strDep, finDep, trans - horizTuples.append(tup) - elif type(face.Surface) == Part.Cylinder and PathGeom.isVertical(face.Surface.Axis): - PathLog.debug("== Part.Cylinder") - # vertical cylinder wall - if any(e.isClosed() for e in face.Edges): - PathLog.debug("e.isClosed()") - # complete cylinder - circle = Part.makeCircle(face.Surface.Radius, face.Surface.Center) - disk = Part.Face(Part.Wire(circle)) + PathLog.debug(translate('Path', "Base Geometry sub: {}".format(sub))) + face = base.Shape.getElement(sub) - # Adjust start and finish depths for pocket - strDep = face.BoundBox.ZMax + self.leadIn # base.Shape.BoundBox.ZMax + self.leadIn - finDep = judgeFinalDepth(obj, face.BoundBox.ZMin) - # Over-write default final depth value, leaves manual override by user - obj.StartDepth.Value = trans.z + strDep - obj.FinalDepth.Value = trans.z + finDep + # -------------------------------------------------------- + if type(face.Surface) == Part.SurfaceOfExtrusion: + # extrusion wall + PathLog.debug('analyzing type() == Part.SurfaceOfExtrusion') + # Attempt to extract planar face from surface of extrusion + (planar, useFace) = planarFaceFromExtrusionEdges(face, trans=False) + # Save face object to self.horiz for processing or display error + if planar is True: + base = FreeCAD.ActiveDocument.getObject(useFace) + sub = 'Face1' + PathLog.debug(' -successful face crated: {}'.format(useFace)) + else: + PathLog.error(translate("Path", "Failed to create a planar face from edges in {}.".format(sub))) + # -------------------------------------------------------- - tup = disk, sub, angle, axis, tag, strDep, finDep, trans - horizTuples.append(tup) + (norm, surf) = self.getFaceNormAndSurf(face) + (rtn, angle, axis, praInfo) = self.faceRotationAnalysis(obj, norm, surf) + + if rtn is True: + faceNum = sub.replace('Face', '') + (clnBase, angle, clnStock, tag) = self.applyRotationalAnalysis(obj, base, angle, axis, faceNum) + # Verify faces are correctly oriented - InverseAngle might be necessary + faceIA = clnBase.Shape.getElement(sub) + (norm, surf) = self.getFaceNormAndSurf(faceIA) + (rtn, praAngle, praAxis, praInfo) = self.faceRotationAnalysis(obj, norm, surf) + if rtn is True: + PathLog.debug("Face not aligned after initial rotation.") + if obj.AttemptInverseAngle is True and obj.InverseAngle is False: + (clnBase, clnStock, angle) = self.applyInverseAngle(obj, clnBase, clnStock, axis, angle) + else: + PathLog.info(translate("Path", "Consider toggling the InverseAngle property and recomputing the operation.")) + else: + PathLog.debug("Face appears to be oriented correctly.") + + tup = clnBase, [sub], angle, axis, clnStock + else: + if self.warnDisabledAxis(obj, axis) is False: + PathLog.debug(str(sub) + ": No rotation used") + axis = 'X' + angle = 0.0 + stock = PathUtils.findParentJob(obj).Stock + tup = base, [sub], angle, axis, stock + # Eif + allTuples.append(tup) + baseSubsTuples.append(tup) else: - # partial cylinder wall - vertical.append(face) + ignoreSub = base.Name + '.' + sub + PathLog.error(translate('Path', "Selected feature is not a Face. Ignoring: {}".format(ignoreSub))) + # Eif + # Efor + # Efor + if False: + if False: + (Tags, Grps) = self.sortTuplesByIndex(allTuples, 2) # return (TagList, GroupList) + subList = [] + for o in range(0, len(Tags)): + subList = [] + for (base, sub, tag, angle, axis, stock) in Grps[o]: + subList.append(sub) + pair = base, subList, angle, axis, stock + baseSubsTuples.append(pair) + if False: + for (bs, sb, tg, agl, ax, stk) in allTuples: + pair = bs, [sb], agl, ax, stk + baseSubsTuples.append(pair) + # ---------------------------------------------------------------------- - # Adjust start and finish depths for pocket - strDep = face.BoundBox.ZMax + self.leadIn # base.Shape.BoundBox.ZMax + self.leadIn - finDep = judgeFinalDepth(obj, face.BoundBox.ZMin) - # Over-write default final depth value, leaves manual override by user - obj.StartDepth.Value = trans.z + strDep - obj.FinalDepth.Value = trans.z + finDep - tup = face, sub, angle, axis, tag, strDep, finDep, trans - vertTuples.append(tup) + for o in baseSubsTuples: + self.horiz = [] + self.vert = [] + subBase = o[0] + subsList = o[1] + angle = o[2] + axis = o[3] + stock = o[4] - PathLog.debug(sub + "is vertical after rotation.") - elif type(face.Surface) == Part.Plane and PathGeom.isHorizontal(face.Surface.Axis): - vertical.append(face) + for sub in subsList: + if 'Face' in sub: + if clasifySub(self, subBase, sub) is False: + PathLog.error(translate('PathPocket', 'Pocket does not support shape %s.%s') % (subBase.Label, sub)) + if obj.EnableRotation != 'Off': + PathLog.info(translate('PathPocket', 'Face might not be within rotation accessibility limits.')) - # Adjust start and finish depths for pocket - strDep = face.BoundBox.ZMax + self.leadIn # base.Shape.BoundBox.ZMax + self.leadIn - finDep = judgeFinalDepth(obj, face.BoundBox.ZMin) - # Over-write default final depth value, leaves manual override by user - obj.StartDepth.Value = trans.z + strDep - obj.FinalDepth.Value = trans.z + finDep - tup = face, sub, angle, axis, tag, strDep, finDep, trans - vertTuples.append(tup) - PathLog.debug(sub + "is vertical after rotation.") + # Determine final depth as highest value of bottom boundbox of vertical face, + # in case of uneven faces on bottom + if len(self.vert) > 0: + vFinDep = self.vert[0].BoundBox.ZMin + for vFace in self.vert: + if vFace.BoundBox.ZMin > vFinDep: + vFinDep = vFace.BoundBox.ZMin + # Determine if vertical faces for a loop: Extract planar loop wire as new horizontal face. + self.vertical = PathGeom.combineConnectedShapes(self.vert) + self.vWires = [TechDraw.findShapeOutline(shape, 1, FreeCAD.Vector(0, 0, 1)) for shape in self.vertical] + for wire in self.vWires: + w = PathGeom.removeDuplicateEdges(wire) + face = Part.Face(w) + # face.tessellate(0.1) + if PathGeom.isRoughly(face.Area, 0): + msg = translate('PathPocket', 'Vertical faces do not form a loop - ignoring') + PathLog.error(msg) + # title = translate("Path", "Face Selection Warning") + # self.guiMessage(title, msg, True) else: - PathLog.error(translate('PathPocket', 'Pocket does not support shape %s.%s') % (base.Label, sub)) + face.translate(FreeCAD.Vector(0, 0, vFinDep - face.BoundBox.ZMin)) + self.horiz.append(face) + msg = translate('Path', 'Verify final depth of pocket shaped by vertical faces.') + PathLog.error(msg) + title = translate('Path', 'Depth Warning') + self.guiMessage(title, msg, False) - if reset is True: - base.Placement.Rotation = startRotation - base.recompute() - reset = False - # End IF - # End FOR - base.Placement = resetPlacement - base.recompute() - # End FOR + # add faces for extensions + self.exts = [] + for ext in self.getExtensions(obj): + wire = Part.Face(ext.getWire()) + if wire: + face = Part.Face(wire) + self.horiz.append(face) + self.exts.append(face) - # Analyze vertical faces via PathGeom.combineConnectedShapes() - # hT = analyzeVerticalFaces(self, obj, vertTuples) - # horizTuples.extend(hT) + # move all horizontal faces to FinalDepth + for f in self.horiz: + finDep = max(obj.FinalDepth.Value, f.BoundBox.ZMin) + f.translate(FreeCAD.Vector(0, 0, finDep - f.BoundBox.ZMin)) - # This section will be replaced analyzeVerticalFaces(self, obj, vertTuples) above - self.vertical = PathGeom.combineConnectedShapes(vertical) - self.vWires = [TechDraw.findShapeOutline(shape, 1, FreeCAD.Vector(0.0, 0.0, 1.0)) for shape in self.vertical] - for wire in self.vWires: - w = PathGeom.removeDuplicateEdges(wire) - face = Part.Face(w) - face.tessellate(0.05) - if PathGeom.isRoughly(face.Area, 0): - PathLog.error(translate('PathPocket', 'Vertical faces do not form a loop - ignoring')) - else: - # self.horiz.append(face) - strDep = base.Shape.BoundBox.ZMax + self.leadIn - finDep = judgeFinalDepth(obj, face.BoundBox.ZMin) - tup = face, 'vertFace', 0.0, 'X', 'X0.0', strDep, finDep, FreeCAD.Vector(0.0, 0.0, 0.0) - horizTuples.append(tup) - - # add faces for extensions - self.exts = [] - for ext in self.getExtensions(obj): - wire = Part.Face(ext.getWire()) - if wire: - face = Part.Face(wire) - # self.horiz.append(face) - strDep = base.Shape.BoundBox.ZMax + self.leadIn - finDep = judgeFinalDepth(obj, face.BoundBox.ZMin) - tup = face, 'vertFace', 0.0, 'X', 'X0.0', strDep, finDep, FreeCAD.Vector(0.0, 0.0, 0.0) - horizTuples.append(tup) - self.exts.append(face) - - # move all horizontal faces to FinalDepth - for (face, sub, angle, axis, tag, strDep, finDep, trans) in horizTuples: - # face.translate(FreeCAD.Vector(0, 0, obj.FinalDepth.Value - face.BoundBox.ZMin)) - if angle <= 0.0: - if axis == 'X': - face.translate(FreeCAD.Vector(0, trans.z, trans.z + finDep - face.BoundBox.ZMin)) - elif axis == 'Y': - face.translate(FreeCAD.Vector(-1 * trans.z, 0, trans.z + finDep - face.BoundBox.ZMin)) - else: - if axis == 'X': - face.translate(FreeCAD.Vector(0, -1 * trans.z, trans.z + finDep - face.BoundBox.ZMin)) - elif axis == 'Y': - face.translate(FreeCAD.Vector(trans.z, 0, trans.z + finDep - face.BoundBox.ZMin)) - - # Separate elements, regroup by orientation (axis_angle combination) - hTags = ['X34.2'] - hGrps = [[(2.3, 3.4, 'X')]] - for tup in horizTuples: - (face, sub, angle, axis, tag, strDep, finDep, trans) = tup - if tag in hTags: - # Determine index of found string - i = 0 - for orn in hTags: - if orn == tag: - break - i += 1 - hGrps[i].append(tup) - else: - hTags.append(tag) # add orientation entry - hGrps.append([tup]) # add orientation entry - # Remove temp elements - hTags.pop(0) - hGrps.pop(0) - - # check all faces in each axis_angle group - self.horizontal = [] - shpList = [] - for o in range(0, len(hTags)): - PathLog.debug('hTag: {}'.format(hTags[o])) - shpList = [] - for (face, sub, angle, axis, tag, strDep, finDep, trans) in hGrps[o]: - shpList.append(face) # check all faces and see if they are touching/overlapping and combine those into a compound - # Original Code in For loop - for shape in PathGeom.combineConnectedShapes(shpList): + self.horizontal = [] + for shape in PathGeom.combineConnectedShapes(self.horiz): shape.sewShape() - # shape.tessellate(0.05) # Russ4262 0.1 original + # shape.tessellate(0.1) if obj.UseOutline: wire = TechDraw.findShapeOutline(shape, 1, FreeCAD.Vector(0, 0, 1)) wire.translate(FreeCAD.Vector(0, 0, obj.FinalDepth.Value - wire.BoundBox.ZMin)) - PathLog.debug(" -obj.UseOutline: obj.FinalDepth.Value" + str(obj.FinalDepth.Value)) - PathLog.debug(" -obj.UseOutline: wire.BoundBox.ZMin" + str(wire.BoundBox.ZMin)) - # shape.tessellate(0.05) # Russ4262 0.1 original - face = Part.Face(wire) - tup = face, sub, angle, axis, tag, strDep, finDep, trans - self.horizontal.append(tup) + self.horizontal.append(Part.Face(wire)) else: - # Re-pair shape to tuple set - for (face, sub, angle, axis, tag, strDep, finDep, trans) in hGrps[o]: - if shape is face: - tup = face, sub, angle, axis, tag, strDep, finDep, trans - self.horizontal.append(tup) - break - # Eol + self.horizontal.append(shape) - # extrude all faces up to StartDepth and those are the removal shapes - for (face, sub, angle, axis, tag, strDep, finDep, trans) in self.horizontal: - # extent = FreeCAD.Vector(0, 0, obj.StartDepth.Value - obj.FinalDepth.Value) - extent = FreeCAD.Vector(0, 0, strDep - finDep) - shp = face.removeSplitter().extrude(extent) - # tup = shp, False, sub, angle, axis, tag, strDep, finDep, trans - tup = shp, False, sub, angle, axis # shape, isHole, sub, angle, axis - self.removalshapes.append(tup) + for face in self.horizontal: + # extrude all faces up to StartDepth and those are the removal shapes + (strDep, finDep) = self.calculateStartFinalDepths(obj, face, stock) + finalDepths.append(finDep) + extent = FreeCAD.Vector(0, 0, strDep - finDep) + self.removalshapes.append((face.removeSplitter().extrude(extent), False, 'pathPocketShape', angle, axis, strDep, finDep)) + PathLog.debug("Extent depths are str: {}, and fin: {}".format(strDep, finDep)) + # Efor face - else: # process the job base object as a whole - PathLog.debug("processing the whole job base object") + # Adjust obj.FinalDepth.Value as needed. + if len(finalDepths) > 0: + finalDepths = min(finalDepths) + if subCount == 1: + obj.FinalDepth.Value = finDep + else: + # process the job base object as a whole + PathLog.debug(translate("Path", 'Processing model as a whole ...')) + finDep = obj.FinalDepth.Value + strDep = obj.StartDepth.Value self.outlines = [Part.Face(TechDraw.findShapeOutline(base.Shape, 1, FreeCAD.Vector(0, 0, 1))) for base in self.model] stockBB = self.stock.Shape.BoundBox - PathLog.debug(" -Using outlines; no obj.Base") self.removalshapes = [] self.bodies = [] @@ -562,22 +582,32 @@ class ObjectPocket(PathPocketBase.ObjectPocket): outline.translate(FreeCAD.Vector(0, 0, stockBB.ZMin - 1)) body = outline.extrude(FreeCAD.Vector(0, 0, stockBB.ZLength + 2)) self.bodies.append(body) - self.removalshapes.append((self.stock.Shape.cut(body), False, 'outline', 0.0, 'X')) + # self.removalshapes.append((self.stock.Shape.cut(body), False)) + self.removalshapes.append((self.stock.Shape.cut(body), False, 'pathPocketShape', 0.0, 'X', strDep, finDep)) - for (shape, isHole, sub, angle, axis) in self.removalshapes: - shape.tessellate(0.05) + for (shape, hole, sub, angle, axis, strDep, finDep) in self.removalshapes: + shape.tessellate(0.05) # originally 0.1 if self.removalshapes: obj.removalshape = self.removalshapes[0][0] + + # if PathLog.getLevel(PathLog.thisModule()) != 4: + # if self.delTempNameList > 0: + # for tmpNm in self.tempNameList: + # FreeCAD.ActiveDocument.removeObject(tmpNm) + return self.removalshapes def areaOpSetDefaultValues(self, obj, job): '''areaOpSetDefaultValues(obj, job) ... set default values''' - obj.StepOver = 100 obj.ZigZagAngle = 45 - obj.B_AxisErrorOverride = False + obj.ExtensionCorners = False + obj.UseOutline = False obj.ReverseDirection = False + obj.InverseAngle = False + obj.B_AxisErrorOverride = False + obj.AttemptInverseAngle = True obj.setExpression('ExtensionLengthDefault', 'OpToolDiameter / 2') def createExtension(self, obj, extObj, extFeature, extSub): @@ -597,14 +627,181 @@ class ObjectPocket(PathPocketBase.ObjectPocket): PathLog.track(obj.Label, len(extensions)) obj.ExtensionFeature = [(ext.obj, ext.getSubLink()) for ext in extensions] + def checkForFacesLoop(self, base, subsList): + '''checkForFacesLoop(base, subsList)... + Accepts a list of face names for the given base. + Checks to determine if they are looped together. + ''' + PathLog.track() + fCnt = 0 + go = True + vertLoopFace = None + tempNameList = [] + delTempNameList = 0 + saSum = FreeCAD.Vector(0.0, 0.0, 0.0) + norm = FreeCAD.Vector(0.0, 0.0, 0.0) + surf = FreeCAD.Vector(0.0, 0.0, 0.0) + precision = 6 + + def makeTempExtrusion(base, sub, fCnt): + extName = 'tmpExtrude' + str(fCnt) + wireName = 'tmpWire' + str(fCnt) + wr = Part.Wire(Part.__sortEdges__(base.Shape.getElement(sub).Edges)) + if wr.isNull(): + PathLog.debug('No wire created from {}'.format(sub)) + return (False, 0, 0) + else: + tmpWire = FreeCAD.ActiveDocument.addObject('Part::Feature', wireName).Shape = wr + tmpWire = FreeCAD.ActiveDocument.getObject(wireName) + tmpExt = FreeCAD.ActiveDocument.addObject('Part::Extrusion', extName) + tmpExt = FreeCAD.ActiveDocument.getObject(extName) + tmpExt.Base = tmpWire + tmpExt.DirMode = "Normal" + tmpExt.DirLink = None + tmpExt.LengthFwd = 10.0 + tmpExt.LengthRev = 0.0 + tmpExt.Solid = True + tmpExt.Reversed = False + tmpExt.Symmetric = False + tmpExt.TaperAngle = 0.0 + tmpExt.TaperAngleRev = 0.0 + + tmpExt.recompute() + tmpExt.purgeTouched() + tmpWire.purgeTouched() + return (True, tmpWire, tmpExt) + + def roundValue(precision, val): + # Convert VALxe-15 numbers to zero + if PathGeom.isRoughly(0.0, val) is True: + return 0.0 + # Convert VAL.99999999 to next integer + elif math.fabs(val % 1) > 1.0 - PathGeom.Tolerance: + return round(val) + else: + return round(val, precision) + + # Determine precision from Tolerance + for i in range(0, 13): + if PathGeom.Tolerance * (i * 10) == 1.0: + precision = i + break + + # Sub Surface.Axis values of faces + # Vector of (0, 0, 0) will suggests a loop + for sub in subsList: + if 'Face' in sub: + fCnt += 1 + saSum = saSum.add(base.Shape.getElement(sub).Surface.Axis) + + # Minimim of three faces required for loop to exist + if fCnt < 3: + go = False + + # Determine if all faces combined point toward loop center = False + if PathGeom.isRoughly(0, saSum.x): + if PathGeom.isRoughly(0, saSum.y): + if PathGeom.isRoughly(0, saSum.z): + PathLog.debug("Combined subs suggest loop of faces. Checking ...") + go is True + + if go is True: + lastExtrusion = None + matchList = [] + go = False + + # Cycle through subs, extruding to solid for each + for sub in subsList: + if 'Face' in sub: + fCnt += 1 + go = False + + # Extrude face to solid + (rtn, tmpWire, tmpExt) = makeTempExtrusion(base, sub, fCnt) + + # If success, record new temporary objects for deletion + if rtn is True: + tempNameList.append(tmpExt.Name) + tempNameList.append(tmpWire.Name) + delTempNameList += 1 + if lastExtrusion is None: + lastExtrusion = tmpExt + rtn = True + else: + go = False + break + + # Cycle through faces on each extrusion, looking for common normal faces for rotation analysis + if len(matchList) == 0: + for fc in lastExtrusion.Shape.Faces: + (norm, raw) = self.getFaceNormAndSurf(fc) + rnded = FreeCAD.Vector(roundValue(precision, raw.x), roundValue(precision, raw.y), roundValue(precision, raw.z)) + if rnded.x == 0.0 or rnded.y == 0.0 or rnded.z == 0.0: + for fc2 in tmpExt.Shape.Faces: + (norm2, raw2) = self.getFaceNormAndSurf(fc2) + rnded2 = FreeCAD.Vector(roundValue(precision, raw2.x), roundValue(precision, raw2.y), roundValue(precision, raw2.z)) + if rnded == rnded2: + matchList.append(fc2) + go = True + else: + for m in matchList: + (norm, raw) = self.getFaceNormAndSurf(m) + rnded = FreeCAD.Vector(roundValue(precision, raw.x), roundValue(precision, raw.y), roundValue(precision, raw.z)) + for fc2 in tmpExt.Shape.Faces: + (norm2, raw2) = self.getFaceNormAndSurf(fc2) + rnded2 = FreeCAD.Vector(roundValue(precision, raw2.x), roundValue(precision, raw2.y), roundValue(precision, raw2.z)) + if rnded.x == 0.0 or rnded.y == 0.0 or rnded.z == 0.0: + if rnded == rnded2: + go = True + # Eif + if go is False: + break + # Eif + # Eif 'Face' + # Efor + if go is True: + go = False + if len(matchList) == 2: + saTotal = FreeCAD.Vector(0.0, 0.0, 0.0) + for fc in matchList: + (norm, raw) = self.getFaceNormAndSurf(fc) + rnded = FreeCAD.Vector(roundValue(precision, raw.x), roundValue(precision, raw.y), roundValue(precision, raw.z)) + if (rnded.y > 0.0 or rnded.z > 0.0) and vertLoopFace is None: + vertLoopFace = fc + saTotal = saTotal.add(rnded) + + if saTotal == FreeCAD.Vector(0.0, 0.0, 0.0): + if vertLoopFace is not None: + go = True + + if go is True: + (norm, surf) = self.getFaceNormAndSurf(vertLoopFace) + else: + PathLog.debug(translate('Path', 'Can not identify loop.')) + + if delTempNameList > 0: + for tmpNm in tempNameList: + FreeCAD.ActiveDocument.removeObject(tmpNm) + + return (go, norm, surf) + def SetupProperties(): - return PathPocketBase.SetupProperties() + ['UseOutline', 'ExtensionCorners'] + setup = PathPocketBase.SetupProperties() + setup.append('UseOutline') + setup.append('ExtensionLengthDefault') + setup.append('ExtensionFeature') + setup.append('ExtensionCorners') + setup.append("ReverseDirection") + setup.append("InverseAngle") + setup.append("B_AxisErrorOverride") + setup.append("AttemptInverseAngle") + return setup def Create(name, obj=None): '''Create(name) ... Creates and returns a Pocket operation.''' if obj is None: obj = FreeCAD.ActiveDocument.addObject('Path::FeaturePython', name) - proxy = ObjectPocket(obj, name) + obj.proxy = ObjectPocket(obj, name) return obj diff --git a/src/Mod/Path/PathScripts/PathProfileFaces.py b/src/Mod/Path/PathScripts/PathProfileFaces.py index 73a8371683..63e7807a31 100644 --- a/src/Mod/Path/PathScripts/PathProfileFaces.py +++ b/src/Mod/Path/PathScripts/PathProfileFaces.py @@ -21,21 +21,36 @@ # * USA * # * * # *************************************************************************** +# * * +# * Additional modifications and contributions beginning 2019 * +# * Focus: 4th-axis integration * +# * by Russell Johnson * +# * * +# *************************************************************************** import ArchPanel import FreeCAD import Part import Path -import PathScripts.PathAreaOp as PathAreaOp +# import PathScripts.PathAreaOp as PathAreaOp import PathScripts.PathLog as PathLog import PathScripts.PathOp as PathOp import PathScripts.PathProfileBase as PathProfileBase import PathScripts.PathUtils as PathUtils import numpy -from PathScripts.PathUtils import depth_params +# from PathScripts.PathUtils import depth_params from PySide import QtCore +__title__ = "Path Profile Faces Operation" +__author__ = "sliptonic (Brad Collette)" +__url__ = "http://www.freecadweb.org" +__doc__ = "Path Profile operation based on faces." +__contributors__ = "russ4262 (Russell Johnson)" +__created__ = "2014" +__scriptVersion__ = "2g testing" +__lastModified__ = "2019-06-12 23:29 CST" + if False: PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule()) PathLog.trackModule(PathLog.thisModule()) @@ -43,14 +58,11 @@ else: PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) # Qt translation handling + + def translate(context, text, disambig=None): return QtCore.QCoreApplication.translate(context, text, disambig) -__title__ = "Path Profile Faces Operation" -__author__ = "sliptonic (Brad Collette)" -__url__ = "http://www.freecadweb.org" -__doc__ = "Path Profile operation based on faces." - class ObjectProfile(PathProfileBase.ObjectProfile): '''Proxy object for Profile operations based on faces.''' @@ -63,6 +75,7 @@ class ObjectProfile(PathProfileBase.ObjectProfile): def areaOpFeatures(self, obj): '''baseObject() ... returns super of receiver Used to call base implementation in overwritten functions.''' + # return PathOp.FeatureBaseFaces | PathOp.FeatureBasePanels | PathOp.FeatureRotation return PathOp.FeatureBaseFaces | PathOp.FeatureBasePanels def initAreaOp(self, obj): @@ -72,10 +85,22 @@ class ObjectProfile(PathProfileBase.ObjectProfile): obj.addProperty("App::PropertyBool", "processPerimeter", "Profile", QtCore.QT_TRANSLATE_NOOP("App::Property", "Profile the outline")) obj.addProperty("App::PropertyBool", "processCircles", "Profile", QtCore.QT_TRANSLATE_NOOP("App::Property", "Profile round holes")) + if not hasattr(obj, 'ReverseDirection'): + obj.addProperty('App::PropertyBool', 'ReverseDirection', 'Rotation', QtCore.QT_TRANSLATE_NOOP('App::Property', 'Reverse direction of pocket operation.')) + if not hasattr(obj, 'InverseAngle'): + obj.addProperty('App::PropertyBool', 'InverseAngle', 'Rotation', QtCore.QT_TRANSLATE_NOOP('App::Property', 'Inverse the angle. Example: -22.5 -> 22.5 degrees.')) + if not hasattr(obj, 'B_AxisErrorOverride'): + obj.addProperty('App::PropertyBool', 'B_AxisErrorOverride', 'Rotation', QtCore.QT_TRANSLATE_NOOP('App::Property', 'Match B rotations to model (error in FreeCAD rendering).')) + if not hasattr(obj, 'AttemptInverseAngle'): + obj.addProperty('App::PropertyBool', 'AttemptInverseAngle', 'Rotation', QtCore.QT_TRANSLATE_NOOP('App::Property', 'Attempt the inverse angle for face access if original rotation fails.')) + self.baseObject().initAreaOp(obj) def areaOpShapes(self, obj): '''areaOpShapes(obj) ... returns envelope for all base shapes or wires for Arch.Panels.''' + PathLog.track() + PathLog.debug("----- areaOpShapes() in PathProfileFaces.py") + if obj.UseComp: self.commandlist.append(Path.Command("(Compensated Tool Path. Diameter: " + str(self.radius * 2) + ")")) else: @@ -83,39 +108,173 @@ class ObjectProfile(PathProfileBase.ObjectProfile): shapes = [] self.profileshape = [] + finalDepths = [] + + baseSubsTuples = [] + subCount = 0 + allTuples = [] if obj.Base: # The user has selected subobjects from the base. Process each. - for base in obj.Base: + if obj.EnableRotation != 'Off': + for p in range(0, len(obj.Base)): + (base, subsList) = obj.Base[p] + for sub in subsList: + subCount += 1 + shape = getattr(base.Shape, sub) + if isinstance(shape, Part.Face): + rtn = False + (norm, surf) = self.getFaceNormAndSurf(shape) + (rtn, angle, axis, praInfo) = self.faceRotationAnalysis(obj, norm, surf) + if rtn is True: + (clnBase, angle, clnStock, tag) = self.applyRotationalAnalysis(obj, base, angle, axis, subCount) + # Verify faces are correctly oriented - InverseAngle might be necessary + faceIA = getattr(clnBase.Shape, sub) + (norm, surf) = self.getFaceNormAndSurf(faceIA) + (rtn, praAngle, praAxis, praInfo) = self.faceRotationAnalysis(obj, norm, surf) + if rtn is True: + PathLog.error(translate("Path", "Face appears misaligned after initial rotation.")) + if obj.AttemptInverseAngle is True and obj.InverseAngle is False: + (clnBase, clnStock, angle) = self.applyInverseAngle(obj, clnBase, clnStock, axis, angle) + else: + msg = translate("Path", "Consider toggling the 'InverseAngle' property and recomputing.") + PathLog.error(msg) + # title = translate("Path", 'Rotation Warning') + # self.guiMessage(title, msg, False) + else: + PathLog.debug("Face appears to be oriented correctly.") + + tup = clnBase, sub, tag, angle, axis, clnStock + else: + if self.warnDisabledAxis(obj, axis) is False: + PathLog.debug(str(sub) + ": No rotation used") + axis = 'X' + angle = 0.0 + tag = base.Name + '_' + axis + str(angle).replace('.', '_') + stock = PathUtils.findParentJob(obj).Stock + tup = base, sub, tag, angle, axis, stock + # Eif + allTuples.append(tup) + # Eif + # Efor + # Efor + if subCount > 1: + msg = translate('Path', "Multiple faces in Base Geometry.") + " " + msg += translate('Path', "Depth settings will be applied to all faces.") + PathLog.warning(msg) + # title = translate("Path", "Depth Warning") + # self.guiMessage(title, msg) + (Tags, Grps) = self.sortTuplesByIndex(allTuples, 2) # return (TagList, GroupList) + subList = [] + for o in range(0, len(Tags)): + subList = [] + for (base, sub, tag, angle, axis, stock) in Grps[o]: + subList.append(sub) + pair = base, subList, angle, axis, stock + baseSubsTuples.append(pair) + # Efor + else: + PathLog.info(translate("Path", "EnableRotation property is 'Off'.")) + stock = PathUtils.findParentJob(obj).Stock + for (base, subList) in obj.Base: + baseSubsTuples.append((base, subList, 0.0, 'X', stock)) + + # for base in obj.Base: + for (base, subsList, angle, axis, stock) in baseSubsTuples: holes = [] faces = [] - for sub in base[1]: - shape = getattr(base[0].Shape, sub) + + for sub in subsList: + shape = getattr(base.Shape, sub) if isinstance(shape, Part.Face): faces.append(shape) if numpy.isclose(abs(shape.normalAt(0, 0).z), 1): # horizontal face for wire in shape.Wires[1:]: - holes.append((base[0].Shape, wire)) + holes.append((base.Shape, wire)) else: - FreeCAD.Console.PrintWarning("found a base object which is not a face. Can't continue.") - return + ignoreSub = base.Name + '.' + sub + msg = translate('Path', "Found a selected object which is not a face. Ignoring: {}".format(ignoreSub)) + PathLog.error(msg) + FreeCAD.Console.PrintWarning(msg) + # return for shape, wire in holes: f = Part.makeFace(wire, 'Part::FaceMakerSimple') drillable = PathUtils.isDrillable(shape, wire) if (drillable and obj.processCircles) or (not drillable and obj.processHoles): - env = PathUtils.getEnvelope(shape, subshape=f, depthparams=self.depthparams) PathLog.track() - shapes.append((env, True)) + # Recalculate depthparams + (strDep, finDep) = self.calculateStartFinalDepths(obj, shape, stock) + finalDepths.append(finDep) + PathLog.debug("Adjusted face depths strDep: {}, and finDep: {}".format(self.strDep, self.finDep)) + finish_step = obj.FinishDepth.Value if hasattr(obj, "FinishDepth") else 0.0 + self.depthparams = PathUtils.depth_params( + clearance_height=obj.ClearanceHeight.Value, + safe_height=obj.SafeHeight.Value, + start_depth=strDep, # obj.StartDepth.Value, + step_down=obj.StepDown.Value, + z_finish_step=finish_step, + final_depth=finDep, # obj.FinalDepth.Value, + user_depths=None) + env = PathUtils.getEnvelope(shape, subshape=f, depthparams=self.depthparams) + # shapes.append((env, True)) + tup = env, True, 'pathProfileFaces', angle, axis, strDep, finDep + shapes.append(tup) if len(faces) > 0: profileshape = Part.makeCompound(faces) self.profileshape.append(profileshape) if obj.processPerimeter: - env = PathUtils.getEnvelope(base[0].Shape, subshape=profileshape, depthparams=self.depthparams) PathLog.track() - shapes.append((env, False)) + if profileshape: + # Recalculate depthparams + (strDep, finDep) = self.calculateStartFinalDepths(obj, profileshape, stock) + finalDepths.append(finDep) + PathLog.debug("Adjusted face depths strDep: {}, and finDep: {}".format(self.strDep, self.finDep)) + finish_step = obj.FinishDepth.Value if hasattr(obj, "FinishDepth") else 0.0 + self.depthparams = PathUtils.depth_params( + clearance_height=obj.ClearanceHeight.Value, + safe_height=obj.SafeHeight.Value, + start_depth=strDep, # obj.StartDepth.Value, + step_down=obj.StepDown.Value, + z_finish_step=finish_step, + final_depth=finDep, # obj.FinalDepth.Value, + user_depths=None) + else: + strDep = obj.StartDepth.Value + finDep = obj.FinalDepth.Value + try: + env = PathUtils.getEnvelope(base.Shape, subshape=profileshape, depthparams=self.depthparams) + except Exception: + # PathUtils.getEnvelope() failed to return an object. + PathLog.error(translate('Path', 'Unable to create path for face(s).')) + else: + # shapes.append((env, False)) + tup = env, False, 'pathProfileFaces', angle, axis, strDep, finDep + shapes.append(tup) + else: + for shape in faces: + # Recalculate depthparams + (strDep, finDep) = self.calculateStartFinalDepths(obj, shape, stock) + finalDepths.append(finDep) + finish_step = obj.FinishDepth.Value if hasattr(obj, "FinishDepth") else 0.0 + self.depthparams = PathUtils.depth_params( + clearance_height=obj.ClearanceHeight.Value, + safe_height=obj.SafeHeight.Value, + start_depth=strDep, # obj.StartDepth.Value, + step_down=obj.StepDown.Value, + z_finish_step=finish_step, + final_depth=finDep, # obj.FinalDepth.Value, + user_depths=None) + env = PathUtils.getEnvelope(base.Shape, subshape=shape, depthparams=self.depthparams) + tup = env, False, 'pathProfileFaces', angle, axis, strDep, finDep + shapes.append(tup) + # Eif + # adjust FinalDepth as needed + finalDepth = min(finalDepths) + if obj.FinalDepth.Value < finalDepth: + obj.FinalDepth.Value = finalDepth else: # Try to build targets from the job base if 1 == len(self.model) and hasattr(self.model[0], "Proxy"): if isinstance(self.model[0].Proxy, ArchPanel.PanelSheet): # process the sheet @@ -126,17 +285,22 @@ class ObjectProfile(PathProfileBase.ObjectProfile): if (drillable and obj.processCircles) or (not drillable and obj.processHoles): f = Part.makeFace(wire, 'Part::FaceMakerSimple') env = PathUtils.getEnvelope(self.model[0].Shape, subshape=f, depthparams=self.depthparams) - shapes.append((env, True)) + # shapes.append((env, True)) + tup = env, True, 'pathProfileFaces', 0.0, 'X', obj.StartDepth.Value, obj.FinalDepth.Value + shapes.append(tup) if obj.processPerimeter: for shape in self.model[0].Proxy.getOutlines(self.model[0], transform=True): for wire in shape.Wires: f = Part.makeFace(wire, 'Part::FaceMakerSimple') env = PathUtils.getEnvelope(self.model[0].Shape, subshape=f, depthparams=self.depthparams) - shapes.append((env, False)) + # shapes.append((env, False)) + tup = env, False, 'pathProfileFaces', 0.0, 'X', obj.StartDepth.Value, obj.FinalDepth.Value + shapes.append(tup) self.removalshapes = shapes PathLog.debug("%d shapes" % len(shapes)) + return shapes def areaOpSetDefaultValues(self, obj, job): @@ -146,17 +310,27 @@ class ObjectProfile(PathProfileBase.ObjectProfile): obj.processHoles = False obj.processCircles = False obj.processPerimeter = True + obj.ReverseDirection = False + obj.InverseAngle = False + obj.AttemptInverseAngle = True + obj.B_AxisErrorOverride = False + def SetupProperties(): - setup = [] + setup = PathProfileBase.SetupProperties() setup.append("processHoles") setup.append("processPerimeter") setup.append("processCircles") - return PathProfileBase.SetupProperties() + setup + setup.append("ReverseDirection") + setup.append("InverseAngle") + setup.append("B_AxisErrorOverride") + setup.append("AttemptInverseAngle") + return setup -def Create(name, obj = None): + +def Create(name, obj=None): '''Create(name) ... Creates and returns a Profile based on faces operation.''' if obj is None: obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name) - proxy = ObjectProfile(obj, name) + obj.Proxy = ObjectProfile(obj, name) return obj