diff --git a/src/Mod/Path/PathScripts/PathAreaOp.py b/src/Mod/Path/PathScripts/PathAreaOp.py index f248fbfa44..067845c51d 100644 --- a/src/Mod/Path/PathScripts/PathAreaOp.py +++ b/src/Mod/Path/PathScripts/PathAreaOp.py @@ -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']) diff --git a/src/Mod/Path/PathScripts/PathProfile.py b/src/Mod/Path/PathScripts/PathProfile.py index ad4ac0536d..5c223334db 100644 --- a/src/Mod/Path/PathScripts/PathProfile.py +++ b/src/Mod/Path/PathScripts/PathProfile.py @@ -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