# -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (c) 2017 sliptonic * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * # * as published by the Free Software Foundation; either version 2 of * # * the License, or (at your option) any later version. * # * for detail see the LICENCE text file. * # * * # * This program is distributed in the hope that it will be useful, * # * but WITHOUT ANY WARRANTY; without even the implied warranty of * # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * # * GNU Library General Public License for more details. * # * * # * You should have received a copy of the GNU Library General Public * # * License along with this program; if not, write to the Free Software * # * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * # * USA * # * * # *************************************************************************** import FreeCAD import PathScripts.PathLog as PathLog import PathScripts.PathOp as PathOp import PathScripts.PathUtils as PathUtils from PySide import QtCore import PathScripts.PathGeom as PathGeom # lazily loaded modules from lazy_loader.lazy_loader import LazyLoader ArchPanel = LazyLoader('ArchPanel', globals(), 'ArchPanel') Draft = LazyLoader('Draft', globals(), 'Draft') Part = LazyLoader('Part', globals(), 'Part') DraftGeomUtils = LazyLoader('DraftGeomUtils', globals(), 'DraftGeomUtils') import math if FreeCAD.GuiUp: import FreeCADGui __title__ = "Path Circular Holes Base Operation" __author__ = "sliptonic (Brad Collette)" __url__ = "https://www.freecadweb.org" __doc__ = "Base class an implementation for operations on circular holes." __contributors__ = "russ4262 (Russell Johnson)" # Qt translation handling def translate(context, text, disambig=None): return QtCore.QCoreApplication.translate(context, text, disambig) PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) # PathLog.trackModule(PathLog.thisModule()) class ObjectOp(PathOp.ObjectOp): '''Base class for proxy objects of all operations on circular holes.''' # These are static while document is open, if it contains a CircularHole Op initOpFinalDepth = None initOpStartDepth = None initWithRotation = False defValsSet = False docRestored = False def opFeatures(self, obj): '''opFeatures(obj) ... calls circularHoleFeatures(obj) and ORs in the standard features required for processing circular holes. Do not overwrite, implement circularHoleFeatures(obj) instead''' return PathOp.FeatureTool | PathOp.FeatureDepths | PathOp.FeatureHeights | PathOp.FeatureBaseFaces | self.circularHoleFeatures(obj) | PathOp.FeatureCoolant def circularHoleFeatures(self, obj): '''circularHoleFeatures(obj) ... overwrite to add operations specific features. Can safely be overwritten by subclasses.''' # pylint: disable=unused-argument return 0 def initOperation(self, obj): '''initOperation(obj) ... adds Disabled properties and calls initCircularHoleOperation(obj). Do not overwrite, implement initCircularHoleOperation(obj) instead.''' obj.addProperty("App::PropertyStringList", "Disabled", "Base", QtCore.QT_TRANSLATE_NOOP("Path", "List of disabled features")) self.initCircularHoleOperation(obj) def initCircularHoleOperation(self, obj): '''initCircularHoleOperation(obj) ... overwrite if the subclass needs initialisation. Can safely be overwritten by subclasses.''' pass # pylint: disable=unnecessary-pass def baseIsArchPanel(self, obj, base): '''baseIsArchPanel(obj, base) ... return true if op deals with an Arch.Panel.''' # pylint: disable=unused-argument return hasattr(base, "Proxy") and isinstance(base.Proxy, ArchPanel.PanelSheet) def getArchPanelEdge(self, obj, base, sub): '''getArchPanelEdge(obj, base, sub) ... helper function to identify a specific edge of an Arch.Panel. Edges are identified by 3 numbers: .. Let's say the edge is specified as "3.2.7", then the 7th edge of the 2nd wire in the 3rd hole returned by the panel sheet is the edge returned. Obviously this is as fragile as can be, but currently the best we can do while the panel sheets hide the actual features from Path and they can't be referenced directly. ''' # pylint: disable=unused-argument ids = sub.split(".") holeId = int(ids[0]) wireId = int(ids[1]) edgeId = int(ids[2]) for holeNr, hole in enumerate(base.Proxy.getHoles(base, transform=True)): if holeNr == holeId: for wireNr, wire in enumerate(hole.Wires): if wireNr == wireId: for edgeNr, edge in enumerate(wire.Edges): if edgeNr == edgeId: return edge def holeDiameter(self, obj, base, sub): '''holeDiameter(obj, base, sub) ... returns the diameter of the specified hole.''' if self.baseIsArchPanel(obj, base): edge = self.getArchPanelEdge(obj, base, sub) return edge.BoundBox.XLength try: shape = base.Shape.getElement(sub) if shape.ShapeType == 'Vertex': return 0 if shape.ShapeType == 'Edge' and type(shape.Curve) == Part.Circle: return shape.Curve.Radius * 2 if shape.ShapeType == 'Face': for i in range(len(shape.Edges)): if (type(shape.Edges[i].Curve) == Part.Circle and shape.Edges[i].Curve.Radius * 2 < shape.BoundBox.XLength*1.1 and shape.Edges[i].Curve.Radius * 2 > shape.BoundBox.XLength*0.9): return shape.Edges[i].Curve.Radius * 2 # for all other shapes the diameter is just the dimension in X. This may be inaccurate as the BoundBox is calculated on the tessellated geometry PathLog.warning(translate("Path", "Hole diameter may be inaccurate due to tessellation on face. Consider selecting hole edge.")) return shape.BoundBox.XLength except Part.OCCError as e: PathLog.error(e) return 0 def holePosition(self, obj, base, sub): '''holePosition(obj, base, sub) ... returns a Vector for the position defined by the given features. Note that the value for Z is set to 0.''' if self.baseIsArchPanel(obj, base): edge = self.getArchPanelEdge(obj, base, sub) center = edge.Curve.Center return FreeCAD.Vector(center.x, center.y, 0) try: shape = base.Shape.getElement(sub) if shape.ShapeType == 'Vertex': return FreeCAD.Vector(shape.X, shape.Y, 0) if shape.ShapeType == 'Edge' and hasattr(shape.Curve, 'Center'): return FreeCAD.Vector(shape.Curve.Center.x, shape.Curve.Center.y, 0) if shape.ShapeType == 'Face': if hasattr(shape.Surface, 'Center'): return FreeCAD.Vector(shape.Surface.Center.x, shape.Surface.Center.y, 0) if len(shape.Edges) == 1 and type(shape.Edges[0].Curve) == Part.Circle: return shape.Edges[0].Curve.Center except Part.OCCError as e: PathLog.error(e) PathLog.error(translate("Path", "Feature %s.%s cannot be processed as a circular hole - please remove from Base geometry list.") % (base.Label, sub)) return None def isHoleEnabled(self, obj, base, sub): '''isHoleEnabled(obj, base, sub) ... return true if hole is enabled.''' name = "%s.%s" % (base.Name, sub) return not name in obj.Disabled def opExecute(self, obj): '''opExecute(obj) ... processes all Base features and Locations and collects them in a list of positions and radii which is then passed to circularHoleExecute(obj, holes). If no Base geometries and no Locations are present, the job's Base is inspected and all drillable features are added to Base. In this case appropriate values for depths are also calculated and assigned. Do not overwrite, implement circularHoleExecute(obj, holes) instead.''' PathLog.track() holes = [] baseSubsTuples = [] subCount = 0 allTuples = [] self.cloneNames = [] # pylint: disable=attribute-defined-outside-init self.guiMsgs = [] # pylint: disable=attribute-defined-outside-init self.rotateFlag = False # pylint: disable=attribute-defined-outside-init self.useTempJobClones('Delete') # pylint: disable=attribute-defined-outside-init self.stockBB = PathUtils.findParentJob(obj).Stock.Shape.BoundBox # pylint: disable=attribute-defined-outside-init self.clearHeight = obj.ClearanceHeight.Value # pylint: disable=attribute-defined-outside-init self.safeHeight = obj.SafeHeight.Value # pylint: disable=attribute-defined-outside-init self.axialFeed = 0.0 # pylint: disable=attribute-defined-outside-init self.axialRapid = 0.0 # pylint: disable=attribute-defined-outside-init def haveLocations(self, obj): if PathOp.FeatureLocations & self.opFeatures(obj): return len(obj.Locations) != 0 return False if obj.EnableRotation == 'Off': strDep = obj.StartDepth.Value finDep = obj.FinalDepth.Value else: # Calculate operation heights based upon rotation radii opHeights = self.opDetermineRotationRadii(obj) (self.xRotRad, self.yRotRad, self.zRotRad) = opHeights[0] # pylint: disable=attribute-defined-outside-init (clrOfset, safOfst) = opHeights[1] PathLog.debug("Exec. opHeights[0]: " + str(opHeights[0])) PathLog.debug("Exec. opHeights[1]: " + str(opHeights[1])) # Set clearance and safe heights based upon rotation radii if obj.EnableRotation == 'A(x)': strDep = self.xRotRad elif obj.EnableRotation == 'B(y)': strDep = self.yRotRad else: strDep = max(self.xRotRad, self.yRotRad) finDep = -1 * strDep obj.ClearanceHeight.Value = strDep + clrOfset obj.SafeHeight.Value = strDep + safOfst # Create visual axes when debugging. if PathLog.getLevel(PathLog.thisModule()) == 4: self.visualAxis() # Set axial feed rates based upon horizontal feed rates safeCircum = 2 * math.pi * obj.SafeHeight.Value self.axialFeed = 360 / safeCircum * self.horizFeed # pylint: disable=attribute-defined-outside-init self.axialRapid = 360 / safeCircum * self.horizRapid # pylint: disable=attribute-defined-outside-init # Complete rotational analysis and temp clone creation as needed if obj.EnableRotation == 'Off': PathLog.debug("Enable Rotation setting is 'Off' for {}.".format(obj.Name)) stock = PathUtils.findParentJob(obj).Stock for (base, subList) in obj.Base: baseSubsTuples.append((base, subList, 0.0, 'A', stock)) else: for p in range(0, len(obj.Base)): (bst, at) = self.process_base_geometry_with_rotation(obj, p, subCount) allTuples.extend(at) baseSubsTuples.extend(bst) for base, subs, angle, axis, stock in baseSubsTuples: # rotate shorter angle in opposite direction if angle > 180: angle -= 360 elif angle < -180: angle += 360 # Re-analyze rotated model for drillable holes if obj.EnableRotation != 'Off': rotated_features = self.findHoles(obj, base) for sub in subs: PathLog.debug('sub, angle, axis: {}, {}, {}'.format(sub, angle, axis)) if self.isHoleEnabled(obj, base, sub): pos = self.holePosition(obj, base, sub) if pos: # Identify face to which edge belongs sub_shape = base.Shape.getElement(sub) # Default is to treat selection as 'Face' shape holeBtm = sub_shape.BoundBox.ZMin if obj.EnableRotation != 'Off': # Update Start and Final depths due to rotation, if auto defaults are active parent_face = self._find_parent_face_of_edge(rotated_features, sub_shape) if parent_face: PathLog.debug('parent_face found') holeBtm = parent_face.BoundBox.ZMin if obj.OpStartDepth == obj.StartDepth: obj.StartDepth.Value = parent_face.BoundBox.ZMax PathLog.debug('new StartDepth: {}'.format(obj.StartDepth.Value)) if obj.OpFinalDepth == obj.FinalDepth: obj.FinalDepth.Value = holeBtm PathLog.debug('new FinalDepth: {}'.format(holeBtm)) else: PathLog.debug('NO parent_face identified') if base.Shape.getElement(sub).ShapeType == 'Edge': msg = translate("Path", "Verify Final Depth of holes based on edges. {} depth is: {} mm".format(sub, round(holeBtm, 4))) + " " msg += translate("Path", "Always select the bottom edge of the hole when using an edge.") PathLog.warning(msg) # Warn user if Final Depth set lower than bottom of hole if obj.FinalDepth.Value < holeBtm: msg = translate("Path", "Final Depth setting is below the hole bottom for {}.".format(sub)) + ' ' msg += translate("Path", "{} depth is calculated at {} mm".format(sub, round(holeBtm, 4))) PathLog.warning(msg) holes.append({'x': pos.x, 'y': pos.y, 'r': self.holeDiameter(obj, base, sub), 'angle': angle, 'axis': axis, 'trgtDep': obj.FinalDepth.Value, 'stkTop': stock.Shape.BoundBox.ZMax}) # haveLocations are populated from user-provided (x, y) coordinates # provided by the user in the Base Locations tab of the Task Editor window if haveLocations(self, obj): for location in obj.Locations: # holes.append({'x': location.x, 'y': location.y, 'r': 0, 'angle': 0.0, 'axis': 'X', 'holeBtm': obj.FinalDepth.Value}) holes.append({'x': location.x, 'y': location.y, 'r': 0, 'angle': 0.0, 'axis': 'X', 'trgtDep': obj.FinalDepth.Value, 'stkTop': PathUtils.findParentJob(obj).Stock.Shape.BoundBox.ZMax}) if len(holes) > 0: self.circularHoleExecute(obj, holes) # circularHoleExecute() located in PathDrilling.py 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)) def circularHoleExecute(self, obj, holes): '''circularHoleExecute(obj, holes) ... implement processing of holes. holes is a list of dictionaries with 'x', 'y' and 'r' specified for each hole. Note that for Vertexes, non-circular Edges and Locations r=0. Must be overwritten by subclasses.''' pass # pylint: disable=unnecessary-pass def findAllHoles(self, obj): '''findAllHoles(obj) ... find all holes of all base models and assign as features.''' PathLog.track() if not self.getJob(obj): return features = [] if 1 == len(self.model) and self.baseIsArchPanel(obj, self.model[0]): panel = self.model[0] holeshapes = panel.Proxy.getHoles(panel, transform=True) tooldiameter = float(obj.ToolController.Proxy.getTool(obj.ToolController).Diameter) for holeNr, hole in enumerate(holeshapes): PathLog.debug('Entering new HoleShape') for wireNr, wire in enumerate(hole.Wires): PathLog.debug('Entering new Wire') for edgeNr, edge in enumerate(wire.Edges): if PathUtils.isDrillable(panel, edge, tooldiameter): PathLog.debug('Found drillable hole edges: {}'.format(edge)) features.append((panel, "%d.%d.%d" % (holeNr, wireNr, edgeNr))) else: for base in self.model: features.extend(self.findHoles(obj, base)) obj.Base = features obj.Disabled = [] def findHoles(self, obj, baseobject): '''findHoles(obj, baseobject) ... inspect baseobject and identify all features that resemble a straight cricular hole.''' shape = baseobject.Shape PathLog.track('obj: {} shape: {}'.format(obj, shape)) holelist = [] features = [] # tooldiameter = float(obj.ToolController.Proxy.getTool(obj.ToolController).Diameter) tooldiameter = None PathLog.debug('search for holes larger than tooldiameter: {}: '.format(tooldiameter)) if DraftGeomUtils.isPlanar(shape): PathLog.debug("shape is planar") for i in range(len(shape.Edges)): candidateEdgeName = "Edge" + str(i + 1) e = shape.getElement(candidateEdgeName) if PathUtils.isDrillable(shape, e, tooldiameter): PathLog.debug('edge candidate: {} (hash {})is drillable '.format(e, e.hashCode())) x = e.Curve.Center.x y = e.Curve.Center.y diameter = e.BoundBox.XLength holelist.append({'featureName': candidateEdgeName, 'feature': e, 'x': x, 'y': y, 'd': diameter, 'enabled': True}) features.append((baseobject, candidateEdgeName)) PathLog.debug("Found hole feature %s.%s" % (baseobject.Label, candidateEdgeName)) else: PathLog.debug("shape is not planar") for i in range(len(shape.Faces)): candidateFaceName = "Face" + str(i + 1) f = shape.getElement(candidateFaceName) if PathUtils.isDrillable(shape, f, tooldiameter): PathLog.debug('face candidate: {} is drillable '.format(f)) if hasattr(f.Surface, 'Center'): x = f.Surface.Center.x y = f.Surface.Center.y diameter = f.BoundBox.XLength else: center = f.Edges[0].Curve.Center x = center.x y = center.y diameter = f.Edges[0].Curve.Radius * 2 holelist.append({'featureName': candidateFaceName, 'feature': f, 'x': x, 'y': y, 'd': diameter, 'enabled': True}) features.append((baseobject, candidateFaceName)) PathLog.debug("Found hole feature %s.%s" % (baseobject.Label, candidateFaceName)) PathLog.debug("holes found: {}".format(holelist)) return features # Rotation-related methods def opDetermineRotationRadii(self, obj): '''opDetermineRotationRadii(obj) Determine rotational radii for 4th-axis rotations, for clearance/safe heights ''' parentJob = PathUtils.findParentJob(obj) xlim = 0.0 ylim = 0.0 # Determine boundbox radius based upon xzy limits data if math.fabs(self.stockBB.ZMin) > math.fabs(self.stockBB.ZMax): zlim = self.stockBB.ZMin else: zlim = self.stockBB.ZMax if obj.EnableRotation != 'B(y)': # Rotation is around X-axis, cutter moves along same axis if math.fabs(self.stockBB.YMin) > math.fabs(self.stockBB.YMax): ylim = self.stockBB.YMin else: ylim = self.stockBB.YMax if obj.EnableRotation != 'A(x)': # Rotation is around Y-axis, cutter moves along same axis if math.fabs(self.stockBB.XMin) > math.fabs(self.stockBB.XMax): xlim = self.stockBB.XMin else: xlim = self.stockBB.XMax xRotRad = math.sqrt(ylim**2 + zlim**2) yRotRad = math.sqrt(xlim**2 + zlim**2) zRotRad = math.sqrt(xlim**2 + ylim**2) clrOfst = parentJob.SetupSheet.ClearanceHeightOffset.Value safOfst = parentJob.SetupSheet.SafeHeightOffset.Value return [(xRotRad, yRotRad, zRotRad), (clrOfst, safOfst)] 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() praInfo = "faceRotationAnalysis(): " rtn = True orientation = 'X' angle = 500.0 precision = 6 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 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) 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) 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: if saY == 0.0: orientation = "Z" if saZ == 1.0: angle = 0.0 elif saZ == -1.0: angle = -180.0 else: praInfo += "_else_X" + str(saZ) elif saY == 1.0: orientation = "Y" angle = 90.0 elif saY == -1.0: orientation = "Y" angle = -90.0 else: if saZ != 0.0: angle = math.degrees(math.atan(saY / saZ)) orientation = "Y" elif saY == 0.0: if saZ == 0.0: orientation = "X" if saX == 1.0: angle = -90.0 elif saX == -1.0: angle = 90.0 else: praInfo += "_else_X" + str(saX) else: orientation = "X" ratio = saX / saZ angle = math.degrees(math.atan(ratio)) if ratio < 0.0: praInfo += " NEG-ratio" # angle -= 90 else: praInfo += " POS-ratio" angle = -1 * angle if saX < 0.0: angle = angle + 180.0 elif saZ == 0.0: # if saY != 0.0: angle = math.degrees(math.atan(saX / saY)) orientation = "Y" if saX + nX == 0.0: angle = -1 * angle if saY + nY == 0.0: angle = -1 * angle if saZ + nZ == 0.0: angle = -1 * angle if saY == -1.0 or saY == 1.0: if nX != 0.0: angle = -1 * angle # Enforce enabled rotation in settings praInfo += "\n -Initial orientation: {}".format(orientation) if orientation == 'Y': axis = 'X' if obj.EnableRotation == 'B(y)': # Required axis disabled if angle == 180.0 or angle == -180.0: axis = 'Y' else: rtn = False elif orientation == 'X': axis = 'Y' if obj.EnableRotation == 'A(x)': # Required axis disabled if angle == 180.0 or angle == -180.0: axis = 'X' else: rtn = False elif orientation == 'Z': axis = 'X' if math.fabs(angle) == 0.0: angle = 0.0 rtn = False if angle == 500.0: angle = 0.0 rtn = False if rtn is False: if orientation == 'Z' and angle == 0.0 and obj.ReverseDirection is True: if obj.EnableRotation == 'B(y)': axis = 'Y' rtn = True if rtn: self.rotateFlag = True # pylint: disable=attribute-defined-outside-init if obj.ReverseDirection is True: if angle < 180.0: angle = angle + 180.0 else: angle = angle - 180.0 angle = round(angle, precision) praInfo += "\n -Rotation analysis: angle: " + str(angle) + ", axis: " + str(axis) if rtn is True: praInfo += "\n - ... rotation triggered" else: praInfo += "\n - ... NO rotation triggered" 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 len(self.guiMsgs) > 0: if FreeCAD.GuiUp: from PySide.QtGui import QMessageBox for entry in self.guiMsgs: (title, msg) = entry QMessageBox.warning(None, title, msg) self.guiMsgs = [] # pylint: disable=attribute-defined-outside-init return True else: for entry in self.guiMsgs: (title, msg) = entry PathLog.warning("{}:: {}".format(title, msg)) self.guiMsgs = [] # pylint: disable=attribute-defined-outside-init return True return False def visualAxis(self): '''visualAxis() Create visual X & Y axis for use in orientation of rotational operations Triggered only for PathLog.debug''' fcad = FreeCAD.ActiveDocument if not fcad.getObject('xAxCyl'): xAx = 'xAxCyl' yAx = 'yAxCyl' # zAx = 'zAxCyl' visual_axis_obj = fcad.addObject("App::DocumentObjectGroup", "visualAxis") if FreeCAD.GuiUp: FreeCADGui.ActiveDocument.getObject('visualAxis').Visibility = False vaGrp = fcad.getObject("visualAxis") fcad.addObject("Part::Cylinder", xAx) cyl = fcad.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) fcad.addObject("Part::Cylinder", yAx) cyl = fcad.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) visual_axis_obj.purgeTouched() def useTempJobClones(self, cloneName): '''useTempJobClones(cloneName) Manage use of temporary model clones for rotational operation calculations. Clones are stored in 'rotJobClones' group.''' fcad = FreeCAD.ActiveDocument if fcad.getObject('rotJobClones'): if cloneName == 'Start': if PathLog.getLevel(PathLog.thisModule()) < 4: for cln in fcad.getObject('rotJobClones').Group: fcad.removeObject(cln.Name) elif cloneName == 'Delete': if PathLog.getLevel(PathLog.thisModule()) < 4: for cln in fcad.getObject('rotJobClones').Group: fcad.removeObject(cln.Name) fcad.removeObject('rotJobClones') else: fcad.getObject('rotJobClones').purgeTouched() else: fcad.addObject("App::DocumentObjectGroup", "rotJobClones") if FreeCAD.GuiUp: FreeCADGui.ActiveDocument.getObject('rotJobClones').Visibility = False if cloneName != 'Start' and cloneName != 'Delete': fcad.getObject('rotJobClones').addObject(fcad.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. fcad = FreeCAD.ActiveDocument 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 fcad.getObject(clnNm): fcad.getObject(clnNm).Shape = base.Shape else: fcad.addObject('Part::Feature', clnNm).Shape = base.Shape self.useTempJobClones(clnNm) if fcad.getObject(stckClnNm): fcad.getObject(stckClnNm).Shape = PathUtils.findParentJob(obj).Stock.Shape else: fcad.addObject('Part::Feature', stckClnNm).Shape = PathUtils.findParentJob(obj).Stock.Shape self.useTempJobClones(stckClnNm) if FreeCAD.GuiUp: FreeCADGui.ActiveDocument.getObject(stckClnNm).Transparency = 90 FreeCADGui.ActiveDocument.getObject(clnNm).ShapeColor = (1.000, 0.667, 0.000) clnBase = fcad.getObject(clnNm) clnStock = fcad.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) # 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.debug(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 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, sub=''): '''warnDisabledAxis(self, obj, axis) Provide user feedback if required axis is disabled''' if axis == 'X' and obj.EnableRotation == 'B(y)': msg = translate('Path', "{}:: {} is inaccessible.".format(obj.Name, sub)) + " " msg += translate('Path', "Selected feature(s) require 'Enable Rotation: A(x)' for access.") PathLog.warning(msg) return True elif axis == 'Y' and obj.EnableRotation == 'A(x)': msg = translate('Path', "{}:: {} is inaccessible.".format(obj.Name, sub)) + " " msg += translate('Path', "Selected feature(s) require 'Enable Rotation: B(y)' for access.") PathLog.warning(msg) return True else: return False def isFaceUp(self, base, face): '''isFaceUp(base, face) ... When passed a base object and face shape, returns True if face is up. This method is used to identify correct rotation of a model. ''' # verify face is normal to Z+- (norm, surf) = self.getFaceNormAndSurf(face) if round(abs(norm.z), 8) != 1.0 or round(abs(surf.z), 8) != 1.0: PathLog.debug('isFaceUp - face not oriented normal to Z+-') return False curve = face.OuterWire.Edges[0].Curve if curve.TypeId == "Part::GeomCircle": center = curve.Center radius = curve.Radius * 1. face = Part.Face(Part.Wire(Part.makeCircle(radius, center))) up = face.extrude(FreeCAD.Vector(0.0, 0.0, 5.0)) dwn = face.extrude(FreeCAD.Vector(0.0, 0.0, -5.0)) upCmn = base.Shape.common(up) dwnCmn = base.Shape.common(dwn) # Identify orientation based on volumes of common() results if len(upCmn.Edges) > 0: PathLog.debug('isFaceUp - HAS up edges\n') if len(dwnCmn.Edges) > 0: PathLog.debug('isFaceUp - up and dwn edges\n') dVol = round(dwnCmn.Volume, 6) uVol = round(upCmn.Volume, 6) if uVol > dVol: return False return True else: if round(upCmn.Volume, 6) == 0.0: return True return False elif len(dwnCmn.Edges) > 0: PathLog.debug('isFaceUp - HAS dwn edges only\n') dVol = round(dwnCmn.Volume, 6) if dVol == 0.0: return False return True PathLog.debug('isFaceUp - exit True') return True def process_base_geometry_with_rotation(self, obj, p, subCount): '''process_base_geometry_with_rotation(obj, p, subCount)... This method is the control method for analyzing the selected features, determining their rotational needs, and creating clones as needed for rotational access for the pocketing operation. Requires the object, obj.Base index (p), and subCount reference arguments. Returns two lists of tuples for continued processing into paths. ''' baseSubsTuples = [] allTuples = [] (base, subsList) = obj.Base[p] PathLog.debug(translate('Path', "Processing subs individually ...")) for sub in subsList: subCount += 1 tup = self.process_nonloop_sublist(obj, base, sub) if tup: allTuples.append(tup) baseSubsTuples.append(tup) return (baseSubsTuples, allTuples) def process_nonloop_sublist(self, obj, base, sub): '''process_nonloop_sublist(obj, sub)... Process sublist with non-looped set of features when rotation is enabled. ''' rtn = False face = base.Shape.getElement(sub) if sub[:4] != 'Face': if face.ShapeType == 'Edge': edgToFace = Part.Face(Part.Wire(Part.__sortEdges__([face]))) face = edgToFace else: ignoreSub = base.Name + '.' + sub PathLog.error(translate('Path', "Selected feature is not a Face. Ignoring: {}".format(ignoreSub))) return False (norm, surf) = self.getFaceNormAndSurf(face) (rtn, angle, axis, praInfo) = self.faceRotationAnalysis(obj, norm, surf) # pylint: disable=unused-variable PathLog.debug("initial rotational analysis: {}".format(praInfo)) clnBase = base faceIA = clnBase.Shape.getElement(sub) if faceIA.ShapeType == 'Edge': edgToFace = Part.Face(Part.Wire(Part.__sortEdges__([faceIA]))) faceIA = edgToFace if rtn is True: faceNum = sub.replace('Face', '') PathLog.debug("initial applyRotationalAnalysis") (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) if faceIA.ShapeType == 'Edge': edgToFace = Part.Face(Part.Wire(Part.__sortEdges__([faceIA]))) faceIA = edgToFace (norm, surf) = self.getFaceNormAndSurf(faceIA) (rtn, praAngle, praAxis, praInfo2) = self.faceRotationAnalysis(obj, norm, surf) # pylint: disable=unused-variable PathLog.debug("follow-up rotational analysis: {}".format(praInfo2)) isFaceUp = self.isFaceUp(clnBase, faceIA) PathLog.debug('... initial isFaceUp: {}'.format(isFaceUp)) if isFaceUp: rtn = False PathLog.debug('returning analysis: {}, {}'.format(praAngle, praAxis)) return (clnBase, [sub], angle, axis, clnStock) if round(abs(praAngle), 8) == 180.0: rtn = False if not isFaceUp: PathLog.debug('initial isFaceUp is False') angle = 0.0 # Eif if rtn: # initial rotation failed, attempt inverse rotation if user requests it PathLog.debug(translate("Path", "Face appears misaligned after initial rotation.") + ' 2') if obj.AttemptInverseAngle: PathLog.debug(translate("Path", "Applying inverse angle automatically.")) (clnBase, clnStock, angle) = self.applyInverseAngle(obj, clnBase, clnStock, axis, angle) else: if obj.InverseAngle: PathLog.debug(translate("Path", "Applying inverse angle manually.")) (clnBase, clnStock, angle) = self.applyInverseAngle(obj, clnBase, clnStock, axis, angle) else: msg = translate("Path", "Consider toggling the 'InverseAngle' property and recomputing.") PathLog.warning(msg) faceIA = clnBase.Shape.getElement(sub) if faceIA.ShapeType == 'Edge': edgToFace = Part.Face(Part.Wire(Part.__sortEdges__([faceIA]))) faceIA = edgToFace if not self.isFaceUp(clnBase, faceIA): angle += 180.0 # Normalize rotation angle if angle < 0.0: angle += 360.0 elif angle > 360.0: angle -= 360.0 return (clnBase, [sub], angle, axis, clnStock) if not self.warnDisabledAxis(obj, axis): PathLog.debug(str(sub) + ": No rotation used") axis = 'X' angle = 0.0 stock = PathUtils.findParentJob(obj).Stock return (base, [sub], angle, axis, stock) def _find_parent_face_of_edge(self, rotated_features, test_shape): '''_find_parent_face_of_edge(rotated_features, test_shape)... Compare test_shape with each within rotated_features to identify and return the parent face of the test_shape, if it exists.''' for (base, sub) in rotated_features: sub_shape = base.Shape.getElement(sub) if test_shape.isSame(sub_shape): return sub_shape elif test_shape.isEqual(sub_shape): return sub_shape else: for e in sub_shape.Edges: if test_shape.isSame(e): return sub_shape elif test_shape.isEqual(e): return sub_shape return False