# -*- 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 * # * * # *************************************************************************** # * * # * Additional modifications and contributions beginning 2019 * # * Focus: 4th-axis integration * # * by Russell Johnson * # * * # *************************************************************************** import ArchPanel import FreeCAD import DraftGeomUtils import Part import PathScripts.PathLog as PathLog import PathScripts.PathOp as PathOp import PathScripts.PathUtils as PathUtils from PySide import QtCore import PathScripts.PathGeom as PathGeom import math import Draft 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)" __created__ = "2017" __scriptVersion__ = "2b" __lastModified__ = "2020-02-13 17:11 CST" # 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)): (base, subsList) = obj.Base[p] for sub in subsList: if self.isHoleEnabled(obj, base, sub): shape = getattr(base.Shape, sub) rtn = False (norm, surf) = self.getFaceNormAndSurf(shape) (rtn, angle, axis, praInfo) = self.faceRotationAnalysis(obj, norm, surf) # pylint: disable=unused-variable if rtn is True: (clnBase, angle, clnStock, tag) = self.applyRotationalAnalysis(obj, base, angle, axis, subCount) # Verify faces are correctly oriented - InverseAngle might be necessary PathLog.debug("Verifying {} orientation: running faceRotationAnalysis() again.".format(sub)) faceIA = getattr(clnBase.Shape, sub) (norm, surf) = self.getFaceNormAndSurf(faceIA) (rtn, praAngle, praAxis, praInfo) = self.faceRotationAnalysis(obj, norm, surf) # pylint: disable=unused-variable if rtn is True: msg = obj.Name + ":: " msg += translate("Path", "{} might be misaligned after initial rotation.".format(sub)) + " " if obj.AttemptInverseAngle is True and obj.InverseAngle is False: (clnBase, clnStock, angle) = self.applyInverseAngle(obj, clnBase, clnStock, axis, angle) msg += translate("Path", "Rotated to 'InverseAngle' to attempt access.") else: if len(subsList) == 1: msg += translate("Path", "Consider toggling the 'InverseAngle' property and recomputing.") else: msg += translate("Path", "Consider transferring '{}' to independent operation.".format(sub)) PathLog.warning(msg) # title = translate("Path", 'Rotation Warning') # self.guiMessage(title, msg, False) else: PathLog.debug("Face appears to be oriented correctly.") cmnt = "{}: {} @ {}; ".format(sub, axis, str(round(angle, 5))) if cmnt not in obj.Comment: obj.Comment += cmnt tup = clnBase, sub, tag, angle, axis, clnStock allTuples.append(tup) else: if self.warnDisabledAxis(obj, axis, sub) is True: pass # Skip drill feature due to access issue else: 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 allTuples.append(tup) # Eif # Eif subCount += 1 # Efor # Efor (Tags, Grps) = self.sortTuplesByIndex(allTuples, 2) # return (TagList, GroupList) subList = [] for o in range(0, len(Tags)): PathLog.debug('hTag: {}'.format(Tags[o])) subList = [] for (base, sub, tag, angle, axis, stock) in Grps[o]: subList.append(sub) pair = base, subList, angle, axis, stock baseSubsTuples.append(pair) # Efor for base, subs, angle, axis, stock in baseSubsTuples: for sub in subs: if self.isHoleEnabled(obj, base, sub): pos = self.holePosition(obj, base, sub) if pos: # Default is treat selection as 'Face' shape holeBtm = base.Shape.getElement(sub).BoundBox.ZMin 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 finDep < 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': finDep, 'stkTop': stock.Shape.BoundBox.ZMax}) 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': finDep, '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) # bb = parentJob.Stock.Shape.BoundBox xlim = 0.0 ylim = 0.0 zlim = 0.0 xRotRad = 0.01 yRotRad = 0.01 xRotRad = 0.01 # 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 if ylim != 0.0: xRotRad = math.sqrt(ylim**2 + zlim**2) if xlim != 0.0: 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 is True: self.rotateFlag = True # pylint: disable=attribute-defined-outside-init # rtn = True 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" 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 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''' if not FreeCAD.ActiveDocument.getObject('xAxCyl'): xAx = 'xAxCyl' yAx = 'yAxCyl' 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) 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.getObject(clnNm).Shape = base.Shape else: FreeCAD.ActiveDocument.addObject('Part::Feature', clnNm).Shape = base.Shape self.useTempJobClones(clnNm) if FreeCAD.ActiveDocument.getObject(stckClnNm): FreeCAD.ActiveDocument.getObject(stckClnNm).Shape = PathUtils.findParentJob(obj).Stock.Shape else: FreeCAD.ActiveDocument.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 = 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 face.ShapeType == 'Edge': edgToFace = Part.Face(Part.Wire(Part.__sortEdges__([face]))) face = edgToFace 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 if math.fabs(angle) == 0.0: angle = 0.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 return (clnBase, clnStock, angle) 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