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

@@ -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