lazy_loader is copied to Ext now, modified external imports to lazy_load add a few more imports to be lazy loaded, think the install path is correct now [TD]"<" symbol embedded in html revert changes to path modules for testing use lazyloader in PathAreaOp.py add back in deferred loading temp change to print error message in tests temp change to print error message in tests add _init__.py to lazy_loader make install in CMakeLists.txt one line
857 lines
40 KiB
Python
857 lines
40 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
# ***************************************************************************
|
|
# * *
|
|
# * Copyright (c) 2017 sliptonic <shopinthewoods@gmail.com> *
|
|
# * *
|
|
# * 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 <russ4262@gmail.com> *
|
|
# * *
|
|
# ***************************************************************************
|
|
|
|
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)"
|
|
__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:
|
|
<holeId>.<wireId>.<edgeId>
|
|
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
|
|
zRotRad = 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
|