Path: Add ExpandProfile feature

Two new properties, `ExpandProfile` (length) and `ExpandProfileStepOver` (percent) added for new feature.
New feature converts the normal single offset profile into a compound profile containing multiple increasing/decreasing offsets.
The new feature clears a each layer completely before stepping down to the next layer.
Adjust sorting procedure in PathAreaOp due to `ExpandProfile` modifications to open edges code.
This commit is contained in:
Russell Johnson
2020-06-04 13:39:55 -05:00
parent f05253f882
commit 939ac2e115
2 changed files with 276 additions and 74 deletions

View File

@@ -29,14 +29,13 @@ import PathScripts.PathOp as PathOp
import PathScripts.PathUtils as PathUtils
import PathScripts.PathGeom as PathGeom
import math
from PySide import QtCore
# lazily loaded modules
from lazy_loader.lazy_loader import LazyLoader
Draft = LazyLoader('Draft', globals(), 'Draft')
Part = LazyLoader('Part', globals(), 'Part')
# from PathScripts.PathUtils import waiting_effects
from PySide import QtCore
if FreeCAD.GuiUp:
import FreeCADGui
@@ -234,6 +233,8 @@ class ObjectOp(PathOp.ObjectOp):
area.add(baseobject)
areaParams = self.areaOpAreaParams(obj, isHole) # pylint: disable=assignment-from-no-return
if hasattr(obj, 'ExpandProfile') and obj.ExpandProfile != 0:
areaParams = self.areaOpAreaParamsExpandProfile(obj, isHole) # pylint: disable=assignment-from-no-return
heights = [i for i in self.depthparams]
PathLog.debug('depths: {}'.format(heights))
@@ -283,8 +284,8 @@ class ObjectOp(PathOp.ObjectOp):
return pp, simobj
def _buildProfileOpenEdges(self, obj, baseShape, isHole, start, getsim):
'''_buildPathArea(obj, baseShape, isHole, start, getsim) ... internal function.'''
def _buildProfileOpenEdges(self, obj, edgeList, isHole, start, getsim):
'''_buildPathArea(obj, edgeList, isHole, start, getsim) ... internal function.'''
# pylint: disable=unused-argument
PathLog.track()
@@ -293,35 +294,36 @@ class ObjectOp(PathOp.ObjectOp):
PathLog.debug('depths: {}'.format(heights))
lstIdx = len(heights) - 1
for i in range(0, len(heights)):
hWire = Part.Wire(Part.__sortEdges__(baseShape.Edges))
hWire.translate(FreeCAD.Vector(0, 0, heights[i] - hWire.BoundBox.ZMin))
for baseShape in edgeList:
hWire = Part.Wire(Part.__sortEdges__(baseShape.Edges))
hWire.translate(FreeCAD.Vector(0, 0, heights[i] - hWire.BoundBox.ZMin))
pathParams = {} # pylint: disable=assignment-from-no-return
pathParams['shapes'] = [hWire]
pathParams['feedrate'] = self.horizFeed
pathParams['feedrate_v'] = self.vertFeed
pathParams['verbose'] = True
pathParams['resume_height'] = obj.SafeHeight.Value
pathParams['retraction'] = obj.ClearanceHeight.Value
pathParams['return_end'] = True
# Note that emitting preambles between moves breaks some dressups and prevents path optimization on some controllers
pathParams['preamble'] = False
pathParams = {} # pylint: disable=assignment-from-no-return
pathParams['shapes'] = [hWire]
pathParams['feedrate'] = self.horizFeed
pathParams['feedrate_v'] = self.vertFeed
pathParams['verbose'] = True
pathParams['resume_height'] = obj.SafeHeight.Value
pathParams['retraction'] = obj.ClearanceHeight.Value
pathParams['return_end'] = True
# Note that emitting preambles between moves breaks some dressups and prevents path optimization on some controllers
pathParams['preamble'] = False
if self.endVector is None:
V = hWire.Wires[0].Vertexes
lv = len(V) - 1
pathParams['start'] = FreeCAD.Vector(V[0].X, V[0].Y, V[0].Z)
if obj.Direction == 'CCW':
pathParams['start'] = FreeCAD.Vector(V[lv].X, V[lv].Y, V[lv].Z)
else:
pathParams['start'] = self.endVector
if self.endVector is None:
V = hWire.Wires[0].Vertexes
lv = len(V) - 1
pathParams['start'] = FreeCAD.Vector(V[0].X, V[0].Y, V[0].Z)
if obj.Direction == 'CCW':
pathParams['start'] = FreeCAD.Vector(V[lv].X, V[lv].Y, V[lv].Z)
else:
pathParams['start'] = self.endVector
obj.PathParams = str({key: value for key, value in pathParams.items() if key != 'shapes'})
PathLog.debug("Path with params: {}".format(obj.PathParams))
obj.PathParams = str({key: value for key, value in pathParams.items() if key != 'shapes'})
PathLog.debug("Path with params: {}".format(obj.PathParams))
(pp, end_vector) = Path.fromShapes(**pathParams)
paths.extend(pp.Commands)
PathLog.debug('pp: {}, end vector: {}'.format(pp, end_vector))
(pp, end_vector) = Path.fromShapes(**pathParams)
paths.extend(pp.Commands)
PathLog.debug('pp: {}, end vector: {}'.format(pp, end_vector))
self.endVector = end_vector # pylint: disable=attribute-defined-outside-init
@@ -410,11 +412,17 @@ class ObjectOp(PathOp.ObjectOp):
shapes.append(shp)
if len(shapes) > 1:
jobs = [{
'x': s[0].BoundBox.XMax,
'y': s[0].BoundBox.YMax,
'shape': s
} for s in shapes]
jobs = list()
for s in shapes:
if s[2] == 'OpenEdge':
shp = Part.makeCompound(s[0])
else:
shp = s[0]
jobs.append({
'x': shp.BoundBox.XMax,
'y': shp.BoundBox.YMax,
'shape': s
})
jobs = PathUtils.sort_jobs(jobs, ['x', 'y'])

View File

@@ -101,6 +101,10 @@ class ObjectProfile(PathAreaOp.ObjectOp):
return [
("App::PropertyEnumeration", "Direction", "Profile",
QtCore.QT_TRANSLATE_NOOP("App::Property", "The direction that the toolpath should go around the part ClockWise (CW) or CounterClockWise (CCW)")),
("App::PropertyLength", "ExpandProfile", "Profile",
QtCore.QT_TRANSLATE_NOOP("App::Property", "Extend the profile clearing beyond the Extra Offset.")),
("App::PropertyPercent", "ExpandProfileStepOver", "Profile",
QtCore.QT_TRANSLATE_NOOP("App::Property", "Set the stepover percentage, based on the tool's diameter.")),
("App::PropertyEnumeration", "HandleMultipleFeatures", "Profile",
QtCore.QT_TRANSLATE_NOOP("PathPocket", "Choose how to process multiple Base Geometry features.")),
("App::PropertyEnumeration", "JoinType", "Profile",
@@ -147,6 +151,8 @@ class ObjectProfile(PathAreaOp.ObjectOp):
return {
'AttemptInverseAngle': True,
'Direction': 'CW',
'ExpandProfile': 0.0,
'ExpandProfileStepOver': 100,
'HandleMultipleFeatures': 'Individually',
'InverseAngle': False,
'JoinType': 'Round',
@@ -257,6 +263,29 @@ class ObjectProfile(PathAreaOp.ObjectOp):
return params
def areaOpAreaParamsExpandProfile(self, obj, isHole):
'''areaOpPathParamsExpandProfile(obj, isHole) ... return dictionary with area parameters for expaned profile'''
params = {}
params['Fill'] = 1
params['Coplanar'] = 0
params['PocketMode'] = 1
params['SectionCount'] = -1
# params['Angle'] = obj.ZigZagAngle
# params['FromCenter'] = (obj.StartAt == "Center")
params['PocketStepover'] = self.tool.Diameter * (float(obj.ExpandProfileStepOver) / 100.0)
extraOffset = obj.OffsetExtra.Value
if False: # self.pocketInvertExtraOffset(): # Method simply returns False
extraOffset = 0.0 - extraOffset
params['PocketExtraOffset'] = extraOffset
params['ToolRadius'] = self.radius
# Pattern = ['ZigZag', 'Offset', 'Spiral', 'ZigZagOffset', 'Line', 'Grid', 'Triangle']
params['PocketMode'] = 2 # Pattern.index(obj.OffsetPattern) + 1
params['JoinType'] = 0 # jointype = ['Round', 'Square', 'Miter']
return params
def areaOpPathParams(self, obj, isHole):
'''areaOpPathParams(obj, isHole) ... returns dictionary with path parameters.
Do not overwrite.'''
@@ -283,7 +312,9 @@ class ObjectProfile(PathAreaOp.ObjectOp):
def areaOpUseProjection(self, obj):
'''areaOpUseProjection(obj) ... returns True'''
return True
if obj.ExpandProfile.Value == 0.0:
return True
return False
def opUpdateDepths(self, obj):
if hasattr(obj, 'Base') and obj.Base.__len__() == 0:
@@ -300,8 +331,8 @@ class ObjectProfile(PathAreaOp.ObjectOp):
edgeFaces = list()
subCount = 0
self.inaccessibleMsg = translate('PathProfile', 'The selected edge(s) are inaccessible. If multiple, re-ordering selection might work.')
self.profileshape = list() # pylint: disable=attribute-defined-outside-init
self.offsetExtra = obj.OffsetExtra.Value # abs(obj.OffsetExtra.Value)
self.offsetExtra = obj.OffsetExtra.Value
self.expandProfile = None
if PathLog.getLevel(PathLog.thisModule()) == 4:
for grpNm in ['tmpDebugGrp', 'tmpDebugGrp001']:
@@ -313,6 +344,11 @@ class ObjectProfile(PathAreaOp.ObjectOp):
tmpGrpNm = self.tmpGrp.Name
self.JOB = PathUtils.findParentJob(obj)
if obj.ExpandProfile.Value != 0.0:
import PathScripts.PathSurfaceSupport as PathSurfaceSupport
self.PathSurfaceSupport = PathSurfaceSupport
self.expandProfile = True
if obj.UseComp:
self.useComp = True
self.ofstRadius = self.radius + self.offsetExtra
@@ -389,56 +425,93 @@ class ObjectProfile(PathAreaOp.ObjectOp):
startDepths.append(strDep)
self.depthparams = self._customDepthParams(obj, strDep, finDep)
for shape, wire in holes:
f = Part.makeFace(wire, 'Part::FaceMakerSimple')
drillable = PathUtils.isDrillable(shape, wire)
if (drillable and obj.processCircles) or (not drillable and obj.processHoles):
env = PathUtils.getEnvelope(shape, subshape=f, depthparams=self.depthparams)
tup = env, True, 'pathProfileFaces', angle, axis, strDep, finDep
shapes.append(tup)
drillable = PathUtils.isDrillable(baseShape, wire)
ot = self._openingType(obj, baseShape, f, strDep, finDep)
if len(faces) > 0:
profileshape = Part.makeCompound(faces)
self.profileshape.append(profileshape)
if obj.processCircles:
if drillable:
if ot < 1:
cont = True
if obj.processHoles:
if not drillable:
if ot < 1:
cont = True
if cont:
if self.expandProfile:
shapeEnv = self._getExpandedProfileEnvelope(obj, f, True, obj.StartDepth.Value, finDep)
else:
shapeEnv = PathUtils.getEnvelope(baseShape, subshape=f, depthparams=self.depthparams)
if shapeEnv:
self._addDebugObject('HoleShapeEnvelope', shapeEnv)
# env = PathUtils.getEnvelope(baseShape, subshape=f, depthparams=self.depthparams)
tup = shapeEnv, True, 'pathProfile', angle, axis, strDep, finDep
shapes.append(tup)
if obj.processPerimeter:
if obj.HandleMultipleFeatures == 'Collectively':
custDepthparams = self.depthparams
cont = True
if len(faces) > 0:
profileshape = Part.makeCompound(faces)
if obj.LimitDepthToFace is True and obj.EnableRotation != 'Off':
if profileshape.BoundBox.ZMin > obj.FinalDepth.Value:
finDep = profileshape.BoundBox.ZMin
envDepthparams = self._customDepthParams(obj, strDep + 0.5, finDep) # only an envelope
custDepthparams = self._customDepthParams(obj, strDep + 0.5, finDep) # only an envelope
try:
env = PathUtils.getEnvelope(profileshape, depthparams=envDepthparams)
# env = PathUtils.getEnvelope(profileshape, depthparams=custDepthparams)
if self.expandProfile:
shapeEnv = self._getExpandedProfileEnvelope(obj, shape, False, obj.StartDepth.Value, finDep)
else:
shapeEnv = PathUtils.getEnvelope(profileshape, depthparams=custDepthparams)
except Exception as ee: # pylint: disable=broad-except
# PathUtils.getEnvelope() failed to return an object.
msg = translate('Path', 'Unable to create path for face(s).')
PathLog.error(msg + '\n{}'.format(ee))
else:
tup = env, False, 'pathProfileFaces', angle, axis, strDep, finDep
cont = False
if cont:
self._addDebugObject('CollectCutShapeEnv', shapeEnv)
tup = shapeEnv, False, 'pathProfile', angle, axis, strDep, finDep
shapes.append(tup)
elif obj.HandleMultipleFeatures == 'Individually':
for shape in faces:
finalDep = obj.FinalDepth.Value
custDepthparams = self.depthparams
if obj.Side == 'Inside':
if finalDep < shape.BoundBox.ZMin:
# Recalculate depthparams
finalDep = shape.BoundBox.ZMin
custDepthparams = self._customDepthParams(obj, strDep + 0.5, finalDep)
env = PathUtils.getEnvelope(shape, depthparams=custDepthparams)
tup = env, False, 'pathProfileFaces', angle, axis, strDep, finalDep
shapes.append(tup)
if self.expandProfile:
shapeEnv = self._getExpandedProfileEnvelope(obj, shape, False, obj.StartDepth.Value, finalDep)
else:
shapeEnv = PathUtils.getEnvelope(shape, depthparams=custDepthparams)
else: # Try to build targets from the job base
if shapeEnv:
self._addDebugObject('IndivCutShapeEnv', shapeEnv)
tup = shapeEnv, False, 'pathProfile', angle, axis, strDep, finalDep
shapes.append(tup)
else: # Try to build targets from the job models
# No base geometry selected, so treating operation like a exterior contour operation
self.opUpdateDepths(obj)
obj.Side = 'Outside' # Force outside for whole model profile
if 1 == len(self.model) and hasattr(self.model[0], "Proxy"):
if isinstance(self.model[0].Proxy, ArchPanel.PanelSheet): # process the sheet
# Cancel ExpandProfile feature. Unavailable for ArchPanels.
if obj.ExpandProfile.Value != 0.0:
obj.ExpandProfile.Value == 0.0
msg = translate('PathProfile', 'No ExpandProfile support for ArchPanel models.')
FreeCAD.Console.PrintWarning(msg + '\n')
modelProxy = self.model[0].Proxy
# Process circles and holes if requested by user
if obj.processCircles or obj.processHoles:
@@ -457,12 +530,15 @@ class ObjectProfile(PathAreaOp.ObjectOp):
for wire in shape.Wires:
f = Part.makeFace(wire, 'Part::FaceMakerSimple')
env = PathUtils.getEnvelope(self.model[0].Shape, subshape=f, depthparams=self.depthparams)
tup = env, False, 'pathProfileFaces', 0.0, 'X', obj.StartDepth.Value, obj.FinalDepth.Value
tup = env, False, 'pathProfile', 0.0, 'X', obj.StartDepth.Value, obj.FinalDepth.Value
shapes.append(tup)
else:
shapes.extend([(PathUtils.getEnvelope(partshape=base.Shape, subshape=None, depthparams=self.depthparams), False) for base in self.model if hasattr(base, 'Shape')])
# shapes.extend([(PathUtils.getEnvelope(partshape=base.Shape, subshape=None, depthparams=self.depthparams), False) for base in self.model if hasattr(base, 'Shape')])
PathLog.debug('Single model processed.')
shapes.extend(self._processEachModel(obj))
else:
shapes.extend([(PathUtils.getEnvelope(partshape=base.Shape, subshape=None, depthparams=self.depthparams), False) for base in self.model if hasattr(base, 'Shape')])
# shapes.extend([(PathUtils.getEnvelope(partshape=base.Shape, subshape=None, depthparams=self.depthparams), False) for base in self.model if hasattr(base, 'Shape')])
shapes.extend(self._processEachModel(obj))
self.removalshapes = shapes # pylint: disable=attribute-defined-outside-init
PathLog.debug("%d shapes" % len(shapes))
@@ -529,6 +605,106 @@ class ObjectProfile(PathAreaOp.ObjectOp):
return tup
def _openingType(self, obj, baseShape, face, strDep, finDep):
# Test if solid geometry above opening
extDistPos = strDep - face.BoundBox.ZMin
if extDistPos > 0:
extFacePos = face.extrude(FreeCAD.Vector(0.0, 0.0, extDistPos))
cmnPos = baseShape.common(extFacePos)
if cmnPos.Volume > 0:
# Signifies solid protrusion above,
# or overhang geometry above opening
return 1
# Test if solid geometry below opening
extDistNeg = finDep - face.BoundBox.ZMin
if extDistNeg < 0:
extFaceNeg = face.extrude(FreeCAD.Vector(0.0, 0.0, extDistNeg))
cmnNeg = baseShape.common(extFaceNeg)
if cmnNeg.Volume == 0:
# No volume below signifies
# an unobstructed/nonconstricted opening through baseShape
return 0
else:
# Could be a pocket,
# or a constricted/narrowing hole through baseShape
return -1
msg = translate('PathProfile', 'failed to return opening type.')
PathLog.debug('_openingType() ' + msg)
return -2
# Method for expanded profile
def _getExpandedProfileEnvelope(self, obj, faceShape, isHole, strDep, finalDep):
shapeZ = faceShape.BoundBox.ZMin
def calculateOffsetValue(obj, isHole):
offset = obj.ExpandProfile.Value + obj.OffsetExtra.Value # 0.0
if obj.UseComp:
offset = obj.OffsetExtra.Value + self.tool.Diameter
offset += obj.ExpandProfile.Value
if isHole:
if obj.Side == 'Outside':
offset = 0 - offset
else:
if obj.Side == 'Inside':
offset = 0 - offset
return offset
faceEnv = self.PathSurfaceSupport.getShapeEnvelope(faceShape)
# newFace = self.PathSurfaceSupport.getSliceFromEnvelope(faceEnv)
newFace = self.PathSurfaceSupport.getShapeSlice(faceEnv)
# Compute necessary offset
offsetVal = calculateOffsetValue(obj, isHole)
expandedFace = self.PathSurfaceSupport.extractFaceOffset(newFace, offsetVal, newFace)
if expandedFace:
if shapeZ != 0.0:
expandedFace.translate(FreeCAD.Vector(0.0, 0.0, shapeZ))
newFace.translate(FreeCAD.Vector(0.0, 0.0, shapeZ))
if isHole:
if obj.Side == 'Outside':
newFace = newFace.cut(expandedFace)
else:
newFace = expandedFace.cut(newFace)
else:
if obj.Side == 'Inside':
newFace = newFace.cut(expandedFace)
else:
newFace = expandedFace.cut(newFace)
if finalDep - shapeZ != 0:
newFace.translate(FreeCAD.Vector(0.0, 0.0, finalDep - shapeZ))
if strDep - finalDep != 0:
if newFace.Area > 0:
return newFace.extrude(FreeCAD.Vector(0.0, 0.0, strDep - finalDep))
else:
PathLog.debug('No expanded profile face shape.\n')
return False
else:
PathLog.debug(translate('PathProfile', 'Failed to extract offset(s) for expanded profile.') + '\n')
PathLog.debug(translate('PathProfile', 'Failed to expand profile.') + '\n')
return False
# Method to handle each model as a whole, when no faces are selected
# It includes ExpandProfile implementation
def _processEachModel(self, obj):
shapeTups = list()
for base in self.model:
if hasattr(base, 'Shape'):
env = PathUtils.getEnvelope(partshape=base.Shape, subshape=None, depthparams=self.depthparams)
if self.expandProfile:
eSlice = self.PathSurfaceSupport.getCrossSection(env) # getSliceFromEnvelope(env)
eSlice.translate(FreeCAD.Vector(0.0, 0.0, base.Shape.BoundBox.ZMin - env.BoundBox.ZMin))
self._addDebugObject('ModelSlice', eSlice)
shapeEnv = self._getExpandedProfileEnvelope(obj, eSlice, False, obj.StartDepth.Value, obj.FinalDepth.Value)
else:
shapeEnv = env
if shapeEnv:
shapeTups.append((shapeEnv, False))
return shapeTups
# Edges pre-processing
def _processEdges(self, obj):
import DraftGeomUtils
@@ -536,6 +712,8 @@ class ObjectProfile(PathAreaOp.ObjectOp):
basewires = list()
delPairs = list()
ezMin = None
self.cutOut = self.tool.Diameter * (float(obj.ExpandProfileStepOver) / 100.0)
for p in range(0, len(obj.Base)):
(base, subsList) = obj.Base[p]
tmpSubs = list()
@@ -570,10 +748,15 @@ class ObjectProfile(PathAreaOp.ObjectOp):
zShift = ezMin - f.BoundBox.ZMin
newPlace = FreeCAD.Placement(FreeCAD.Vector(0, 0, zShift), f.Placement.Rotation)
f.Placement = newPlace
env = PathUtils.getEnvelope(base.Shape, subshape=f, depthparams=self.depthparams)
# shapes.append((env, False))
tup = env, False, 'Profile', 0.0, 'X', obj.StartDepth.Value, obj.FinalDepth.Value
shapes.append(tup)
if self.expandProfile:
shapeEnv = self._getExpandedProfileEnvelope(obj, Part.Face(f), False, obj.StartDepth.Value, ezMin)
else:
shapeEnv = PathUtils.getEnvelope(base.Shape, subshape=f, depthparams=self.depthparams)
if shapeEnv:
tup = shapeEnv, False, 'Profile', 0.0, 'X', obj.StartDepth.Value, obj.FinalDepth.Value
shapes.append(tup)
else:
PathLog.error(self.inaccessibleMsg)
else:
@@ -583,28 +766,39 @@ class ObjectProfile(PathAreaOp.ObjectOp):
msg += translate('PathProfile', 'Please set to an acceptable value greater than zero.')
PathLog.error(msg)
else:
cutWireObjs = False
flattened = self._flattenWire(obj, wire, obj.FinalDepth.Value)
if flattened:
cutWireObjs = False
openEdges = list()
passOffsets = [self.ofstRadius]
(origWire, flatWire) = flattened
if PathLog.getLevel(PathLog.thisModule()) == 4:
os = FreeCAD.ActiveDocument.addObject('Part::Feature', 'tmpFlatWire')
os.Shape = flatWire
os.purgeTouched()
self.tmpGrp.addObject(os)
if self.expandProfile:
# Identify list of pass offset values for expanded profile paths
regularOfst = self.ofstRadius
targetOfst = regularOfst + obj.ExpandProfile.Value
while regularOfst < targetOfst:
regularOfst += self.cutOut
passOffsets.insert(0, regularOfst)
cutShp = self._getCutAreaCrossSection(obj, base, origWire, flatWire)
if cutShp:
cutWireObjs = self._extractPathWire(obj, base, flatWire, cutShp)
for po in passOffsets:
self.ofstRadius = po
cutShp = self._getCutAreaCrossSection(obj, base, origWire, flatWire)
if cutShp:
cutWireObjs = self._extractPathWire(obj, base, flatWire, cutShp)
if cutWireObjs:
for cW in cutWireObjs:
# shapes.append((cW, False))
# self.profileEdgesIsOpen = True
tup = cW, False, 'OpenEdge', 0.0, 'X', obj.StartDepth.Value, obj.FinalDepth.Value
shapes.append(tup)
else:
PathLog.error(self.inaccessibleMsg)
if cutWireObjs:
for cW in cutWireObjs:
openEdges.append(cW)
else:
PathLog.error(self.inaccessibleMsg)
tup = openEdges, False, 'OpenEdge', 0.0, 'X', obj.StartDepth.Value, obj.FinalDepth.Value
shapes.append(tup)
else:
PathLog.error(self.inaccessibleMsg)
# Eif